Add/db and speed (#1243)

* 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>
This commit is contained in:
Mattsson
2026-07-27 16:49:24 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 1a7152a7af
commit fbd4b992f5
27 changed files with 2852 additions and 478 deletions
@@ -11,26 +11,42 @@ import {
DestructiveConfirmDialog,
useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog'
import { AlertTriangle, Cloud, ExternalLink, Loader2, RefreshCw, Unplug } from 'lucide-react'
import {
AlertTriangle,
Box,
Cloud,
ExternalLink,
Loader2,
RefreshCw,
Unplug,
} from 'lucide-react'
import type {
CloudBackupStatus,
GoogleDriveLastSync,
GoogleDriveSchedule,
CloudLastSync,
CloudProviderId,
CloudProviderStatus,
CloudSchedule,
} from '../types'
const API_BASE = '/api/extensions/ext/cloud-backup'
/**
* Provider presentation. The label is the brand name and stays untranslated in
* both locales; everything around it is a `{provider}` placeholder so the
* copy reads naturally whichever destination the row describes.
*/
const PROVIDER_META: Record<CloudProviderId, { label: string; icon: typeof Cloud }> = {
google_drive: { label: 'Google Drive', icon: Cloud },
dropbox: { label: 'Dropbox', icon: Box },
}
export default function CloudBackupCard() {
const { toast } = useToast()
const t = useTranslations('extensions')
const { toast } = useToast()
const searchParams = useSearchParams()
const { dialogProps, confirm } = useDestructiveConfirm()
const [status, setStatus] = useState<CloudBackupStatus | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isConnecting, setIsConnecting] = useState(false)
const [isSyncing, setIsSyncing] = useState(false)
const [isDisconnecting, setIsDisconnecting] = useState(false)
const loadStatus = useCallback(async () => {
try {
@@ -56,13 +72,18 @@ export default function CloudBackupCard() {
}
}, [])
// Handle OAuth callback redirect params.
// Handle OAuth callback redirect params. `provider` tells us which row the
// user just came back from; absent means a redirect issued before Dropbox
// existed, which can only have been Google Drive.
useEffect(() => {
const result = searchParams.get('cloud_backup')
if (!result) return
const providerId = (searchParams.get('provider') as CloudProviderId) || 'google_drive'
const providerLabel = PROVIDER_META[providerId]?.label ?? providerId
if (result === 'connected' || result === 'connected_first') {
toast({
title: t('ext_cloud_backup_connected_title'),
title: t('ext_cloud_backup_connected_title', { provider: providerLabel }),
description: t(
result === 'connected_first'
? 'ext_cloud_backup_connected_first_description'
@@ -81,7 +102,7 @@ export default function CloudBackupCard() {
} else if (result === 'error') {
const reason = searchParams.get('reason') || t('ext_cloud_backup_unknown_error')
toast({
title: t('ext_cloud_backup_connect_failed'),
title: t('ext_cloud_backup_connect_failed', { provider: providerLabel }),
description: reason,
variant: 'destructive',
})
@@ -89,14 +110,61 @@ export default function CloudBackupCard() {
// Clean the URL so refresh doesn't re-fire the toast.
const url = new URL(window.location.href)
url.searchParams.delete('cloud_backup')
url.searchParams.delete('provider')
url.searchParams.delete('reason')
window.history.replaceState({}, '', url.toString())
}, [loadStatus, searchParams, t, toast])
if (isLoading) {
return (
<div className="rounded-lg border border-border bg-card p-6">
<p className="text-sm text-muted-foreground">{t('ext_cloud_backup_loading')}</p>
</div>
)
}
return (
<div className="space-y-4">
{(status?.providers ?? []).map((providerStatus) => (
<ProviderRow
key={providerStatus.provider}
status={providerStatus}
onChanged={loadStatus}
/>
))}
</div>
)
}
interface ProviderRowProps {
status: CloudProviderStatus
onChanged: () => Promise<void> | void
}
/**
* One destination: identity on the left, its own connection state, schedule
* and actions on the right. Every request carries `?provider=`, so the two
* rows never touch each other's records.
*/
function ProviderRow({ status, onChanged }: ProviderRowProps) {
const { toast } = useToast()
const t = useTranslations('extensions')
const { dialogProps, confirm } = useDestructiveConfirm()
const [isConnecting, setIsConnecting] = useState(false)
const [isSyncing, setIsSyncing] = useState(false)
const [isDisconnecting, setIsDisconnecting] = useState(false)
const providerId = status.provider
const meta = PROVIDER_META[providerId]
const provider = meta?.label ?? providerId
const Icon = meta?.icon ?? Cloud
const qs = `?provider=${encodeURIComponent(providerId)}`
const handleConnect = useCallback(async () => {
setIsConnecting(true)
try {
const res = await fetch(`${API_BASE}/connect`, { method: 'POST' })
const res = await fetch(`${API_BASE}/connect${qs}`, { method: 'POST' })
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || t('ext_cloud_backup_connect_start_failed'))
@@ -105,24 +173,24 @@ export default function CloudBackupCard() {
window.location.href = url
} catch (err) {
toast({
title: t('ext_cloud_backup_connect_failed'),
title: t('ext_cloud_backup_connect_failed', { provider }),
description: err instanceof Error ? err.message : t('ext_cloud_backup_try_again'),
variant: 'destructive',
})
setIsConnecting(false)
}
}, [t, toast])
}, [provider, qs, t, toast])
const handleDisconnect = useCallback(async () => {
setIsDisconnecting(true)
try {
const res = await fetch(`${API_BASE}/disconnect`, { method: 'POST' })
const res = await fetch(`${API_BASE}/disconnect${qs}`, { method: 'POST' })
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || t('ext_cloud_backup_disconnect_failed'))
}
toast({ title: t('ext_cloud_backup_disconnected') })
await loadStatus()
toast({ title: t('ext_cloud_backup_disconnected', { provider }) })
await onChanged()
} catch (err) {
toast({
title: t('ext_cloud_backup_disconnect_failed'),
@@ -132,7 +200,7 @@ export default function CloudBackupCard() {
} finally {
setIsDisconnecting(false)
}
}, [loadStatus, t, toast])
}, [onChanged, provider, qs, t, toast])
type SyncOutcome =
| { result: 'ok' | 'error' }
@@ -142,7 +210,7 @@ export default function CloudBackupCard() {
async (allowDocumentFallback: boolean): Promise<SyncOutcome> => {
setIsSyncing(true)
try {
const res = await fetch(`${API_BASE}/sync`, {
const res = await fetch(`${API_BASE}/sync${qs}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -166,14 +234,14 @@ export default function CloudBackupCard() {
}
}
if (body.error === 'needs_reauth') {
// Refresh status so the card switches to the reconnect state.
await loadStatus()
throw new Error(t('ext_cloud_backup_reauth_description'))
// Refresh status so the row switches to the reconnect state.
await onChanged()
throw new Error(t('ext_cloud_backup_reauth_description', { provider }))
}
throw new Error(body.error || t('ext_cloud_backup_sync_failed'))
}
const { data } = (await res.json()) as {
data: GoogleDriveLastSync & {
data: CloudLastSync & {
web_view_link: string
uploaded_count?: number
skipped_count?: number
@@ -189,7 +257,7 @@ export default function CloudBackupCard() {
(f) => f.kind !== 'readme' && f.included_documents === false
)
toast({
title: t('ext_cloud_backup_uploaded'),
title: t('ext_cloud_backup_uploaded', { provider }),
description: `${t('ext_cloud_backup_files_updated', {
count: data.uploaded_count ?? 0,
})} (${formatMb(data.total_size_bytes ?? data.file_size_bytes ?? 0)})${
@@ -197,7 +265,7 @@ export default function CloudBackupCard() {
}`,
})
}
await loadStatus()
await onChanged()
return { result: 'ok' }
} catch (err) {
toast({
@@ -210,7 +278,7 @@ export default function CloudBackupCard() {
setIsSyncing(false)
}
},
[loadStatus, t, toast]
[onChanged, provider, qs, t, toast]
)
const handleSync = useCallback(async () => {
@@ -235,34 +303,36 @@ export default function CloudBackupCard() {
{/* Identity */}
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
<Cloud className="h-[18px] w-[18px] text-foreground/60" />
<Icon className="h-[18px] w-[18px] text-foreground/60" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-[15px] font-semibold leading-tight">Google Drive</h3>
<h3 className="text-[15px] font-semibold leading-tight">{provider}</h3>
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
{t('ext_cloud_backup_card_tagline')}
{t('ext_cloud_backup_card_tagline', { provider })}
</p>
<p className="text-xs text-muted-foreground mt-2 leading-relaxed">
{t('ext_cloud_backup_legal_note')}
{t('ext_cloud_backup_legal_note', { provider })}
</p>
</div>
</div>
{/* Controls */}
<div>
{isLoading ? (
<p className="text-sm text-muted-foreground">{t('ext_cloud_backup_loading')}</p>
) : status?.connected ? (
{!status.configured ? (
<p className="text-sm text-muted-foreground leading-relaxed">
{t('ext_cloud_backup_not_configured', { provider })}
</p>
) : status.connected ? (
<>
{status.needs_reauth && (
<div className="mb-6 flex items-start gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">
{t('ext_cloud_backup_reauth_title')}
{t('ext_cloud_backup_reauth_title', { provider })}
</p>
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
{t('ext_cloud_backup_reauth_description')}
{t('ext_cloud_backup_reauth_description', { provider })}
</p>
<Button
onClick={handleConnect}
@@ -276,8 +346,8 @@ export default function CloudBackupCard() {
</>
) : (
<>
<Cloud className="mr-2 h-4 w-4" />
{t('ext_cloud_backup_reauth_action')}
<Icon className="mr-2 h-4 w-4" />
{t('ext_cloud_backup_reauth_action', { provider })}
</>
)}
</Button>
@@ -297,7 +367,10 @@ export default function CloudBackupCard() {
</dt>
<dd className="min-w-0 text-right">
{status.last_sync ? (
<LastSyncSummary lastSync={status.last_sync} />
<LastSyncSummary
lastSync={status.last_sync}
providerId={providerId}
/>
) : (
<span className="text-muted-foreground">
{t('ext_cloud_backup_never')}
@@ -309,9 +382,11 @@ export default function CloudBackupCard() {
<div className="mt-6 pt-6 border-t border-border">
<ScheduleSection
providerId={providerId}
provider={provider}
schedule={status.schedule}
needsReauth={status.needs_reauth}
onUpdated={loadStatus}
onUpdated={onChanged}
/>
</div>
@@ -352,7 +427,11 @@ export default function CloudBackupCard() {
) : (
<>
<p className="text-sm text-muted-foreground leading-relaxed">
{t('ext_cloud_backup_connect_description')}
{t(
providerId === 'dropbox'
? 'ext_cloud_backup_connect_description_dropbox'
: 'ext_cloud_backup_connect_description_google'
)}
</p>
<div className="mt-4">
<Button onClick={handleConnect} disabled={isConnecting} className="w-full sm:w-auto">
@@ -363,8 +442,8 @@ export default function CloudBackupCard() {
</>
) : (
<>
<Cloud className="mr-2 h-4 w-4" />
{t('ext_cloud_backup_connect')}
<Icon className="mr-2 h-4 w-4" />
{t('ext_cloud_backup_connect', { provider })}
</>
)}
</Button>
@@ -378,15 +457,26 @@ export default function CloudBackupCard() {
}
/**
* Last-sync cell. New records list the per-fiscal-year files and link to the
* Drive folder; legacy single-ZIP records link to the file.
* Last-sync cell. Records written since the Dropbox target landed carry their
* own `web_view_link`; older Drive records only have a folder id, so the Drive
* URL is reconstructed. Legacy single-ZIP records link to the file itself.
*/
function LastSyncSummary({ lastSync }: { lastSync: GoogleDriveLastSync }) {
function LastSyncSummary({
lastSync,
providerId,
}: {
lastSync: CloudLastSync
providerId: CloudProviderId
}) {
const t = useTranslations('extensions')
const files = lastSync.files
const href = files
? `https://drive.google.com/drive/folders/${lastSync.folder_id}`
: `https://drive.google.com/file/d/${lastSync.file_id}/view`
const href =
lastSync.web_view_link ??
(providerId === 'google_drive'
? files
? `https://drive.google.com/drive/folders/${lastSync.folder_id}`
: `https://drive.google.com/file/d/${lastSync.file_id}/view`
: 'https://www.dropbox.com/home/Apps')
const sizeBytes = files
? lastSync.total_size_bytes ?? 0
: lastSync.file_size_bytes ?? 0
@@ -439,18 +529,26 @@ function formatDateTime(iso: string): string {
}
interface ScheduleSectionProps {
schedule: GoogleDriveSchedule | null
providerId: CloudProviderId
provider: string
schedule: CloudSchedule | null
needsReauth: boolean
onUpdated: () => Promise<void> | void
}
function ScheduleSection({ schedule, needsReauth, onUpdated }: ScheduleSectionProps) {
function ScheduleSection({
providerId,
provider,
schedule,
needsReauth,
onUpdated,
}: ScheduleSectionProps) {
const { toast } = useToast()
const t = useTranslations('extensions')
// Prefer the DST-stable Stockholm hour; fall back to converting the legacy
// UTC hour through the browser's clock (Swedish users: same thing).
const scheduleHour = (s: GoogleDriveSchedule | null): number =>
const scheduleHour = (s: CloudSchedule | null): number =>
typeof s?.hour_local === 'number'
? s.hour_local
: utcHourToLocalHour(typeof s?.hour_utc === 'number' ? s.hour_utc : 3)
@@ -459,6 +557,10 @@ function ScheduleSection({ schedule, needsReauth, onUpdated }: ScheduleSectionPr
const [localHour, setLocalHour] = useState(scheduleHour(schedule))
const [isSaving, setIsSaving] = useState(false)
// Each provider renders its own controls, so the ids must not collide.
const toggleId = `auto-sync-toggle-${providerId}`
const hourId = `auto-sync-hour-${providerId}`
useEffect(() => {
setEnabled(schedule?.enabled ?? false)
setLocalHour(scheduleHour(schedule))
@@ -469,14 +571,17 @@ function ScheduleSection({ schedule, needsReauth, onUpdated }: ScheduleSectionPr
async (nextEnabled: boolean, nextLocalHour: number) => {
setIsSaving(true)
try {
const res = await fetch(`${API_BASE}/schedule`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: nextEnabled,
hour_local: nextLocalHour,
}),
})
const res = await fetch(
`${API_BASE}/schedule?provider=${encodeURIComponent(providerId)}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: nextEnabled,
hour_local: nextLocalHour,
}),
}
)
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || t('ext_cloud_backup_schedule_save_failed'))
@@ -492,7 +597,7 @@ function ScheduleSection({ schedule, needsReauth, onUpdated }: ScheduleSectionPr
setIsSaving(false)
}
},
[onUpdated, t, toast]
[onUpdated, providerId, t, toast]
)
const handleToggle = useCallback(
@@ -516,15 +621,15 @@ function ScheduleSection({ schedule, needsReauth, onUpdated }: ScheduleSectionPr
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<Label htmlFor="auto-sync-toggle" className="text-sm font-medium">
<Label htmlFor={toggleId} className="text-sm font-medium">
{t('ext_cloud_backup_auto_sync_title')}
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
{t('ext_cloud_backup_auto_sync_description')}
{t('ext_cloud_backup_auto_sync_description', { provider })}
</p>
</div>
<Switch
id="auto-sync-toggle"
id={toggleId}
checked={enabled}
onCheckedChange={handleToggle}
disabled={isSaving}
@@ -533,11 +638,11 @@ function ScheduleSection({ schedule, needsReauth, onUpdated }: ScheduleSectionPr
{enabled && (
<div className="flex items-center gap-2">
<Label htmlFor="auto-sync-hour" className="text-xs text-muted-foreground">
<Label htmlFor={hourId} className="text-xs text-muted-foreground">
{t('ext_cloud_backup_time_label')}
</Label>
<select
id="auto-sync-hour"
id={hourId}
value={localHour}
onChange={handleHourChange}
disabled={isSaving}