Files
accounted/extensions/general/cloud-backup/components/CloudBackupCard.tsx
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

736 lines
26 KiB
TypeScript

'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { AttnLine } from '@/components/ui/attn-line'
import {
SettingsRow,
SettingsRowEnd,
SettingsSelect,
} from '@/components/settings/SettingsRows'
import { useToast } from '@/components/ui/use-toast'
import { cn } from '@/lib/utils'
import {
DestructiveConfirmDialog,
useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog'
import { Box, Cloud, ExternalLink, Loader2, RefreshCw, Unplug } from 'lucide-react'
import { useBranding } from '@/lib/branding/brand-context'
import type {
CloudBackupStatus,
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 t = useTranslations('extensions')
const { toast } = useToast()
const { appName } = useBranding()
const searchParams = useSearchParams()
const [status, setStatus] = useState<CloudBackupStatus | null>(null)
const [isLoading, setIsLoading] = useState(true)
const loadStatus = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/status`)
if (!res.ok) throw new Error(t('ext_cloud_backup_status_failed'))
const { data } = (await res.json()) as { data: CloudBackupStatus }
setStatus(data)
} finally {
setIsLoading(false)
}
}, [t])
useEffect(() => {
loadStatus()
}, [loadStatus])
// After a connect redirect the first backup builds in the background: poll
// the status a few times so the finished sync shows up without a reload.
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
return () => {
if (pollRef.current) clearInterval(pollRef.current)
}
}, [])
// 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', { provider: providerLabel }),
description: t(
result === 'connected_first'
? 'ext_cloud_backup_connected_first_description'
: 'ext_cloud_backup_connected_description'
),
})
let attempts = 0
pollRef.current = setInterval(() => {
attempts += 1
loadStatus()
if (attempts >= 6 && pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
}, 10_000)
} else if (result === 'error') {
const reason = searchParams.get('reason') || t('ext_cloud_backup_unknown_error')
toast({
title: t('ext_cloud_backup_connect_failed', { provider: providerLabel }),
description: reason,
variant: 'destructive',
})
}
// 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 (
<p className="px-1 py-3 text-xs text-muted-foreground">
{t('ext_cloud_backup_loading')}
</p>
)
}
const providers = status?.providers ?? []
// Configured too: reconnecting a destination whose credentials are gone
// would only bounce off `/connect`, so that row keeps its quiet
// "not configured" line instead.
const reauthProviders = providers.filter((p) => p.configured && p.needs_reauth)
// `/status` returns a row for every provider the build knows about, whether
// or not this deployment has credentials for it, so the note has to filter:
// naming Dropbox where Dropbox is not configured would promise a
// destination that does not exist. Configured (not connected) is the right
// cut, because the note is the compliance framing for the decision to
// connect and has to read before anything is connected.
const destinations = providers
.filter((p) => p.configured)
.map((p) => PROVIDER_META[p.provider]?.label ?? p.provider)
.join(' / ')
return (
<div>
{reauthProviders.length > 0 && <ReauthAttn providers={reauthProviders} />}
<div>
{providers.map((providerStatus) => (
<ProviderRow
key={providerStatus.provider}
status={providerStatus}
onChanged={loadStatus}
/>
))}
</div>
{/*
The 7-year BFL note is compliance context, so it stays visible rather
than hiding behind hover: it is shown once for the whole section
instead of once per destination, because the statement is identical
for every provider and repeating it was what made the panel bulky.
*/}
{destinations.length > 0 && (
<p className="mt-4 px-1 text-xs leading-5 text-muted-foreground">
{t('ext_cloud_backup_legal_note', { provider: destinations, appName })}
</p>
)}
</div>
)
}
/**
* Start the OAuth handshake for one destination. Shared by the connect button
* on a row and by the section-level reconnect line, so both take the exact
* same path.
*/
function useConnectProvider(providerId: CloudProviderId, provider: string) {
const { toast } = useToast()
const t = useTranslations('extensions')
const [isConnecting, setIsConnecting] = useState(false)
const connect = useCallback(async () => {
// The reconnect affordance is a plain inline link (no disabled state), so
// the guard lives here instead of on a button.
if (isConnecting) return
setIsConnecting(true)
try {
const res = await fetch(
`${API_BASE}/connect?provider=${encodeURIComponent(providerId)}`,
{ method: 'POST' }
)
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || t('ext_cloud_backup_connect_start_failed'))
}
const { url } = (await res.json()) as { url: string }
window.location.href = url
} catch (err) {
toast({
title: t('ext_cloud_backup_connect_failed', { provider }),
description: err instanceof Error ? err.message : t('ext_cloud_backup_try_again'),
variant: 'destructive',
})
setIsConnecting(false)
}
}, [isConnecting, provider, providerId, t, toast])
return { isConnecting, connect }
}
/**
* Attention is one ochre sentence, max one per section (convention 6), so the
* reconnect notice lives here and not on each row: both destinations can lose
* their token at once, and two stacked banners is exactly the shape the
* convention forbids. The sentence names every affected destination; the
* action reconnects the first, since one redirect can only hand off to one
* provider. Once it is back the line returns for the next one.
*/
function ReauthAttn({ providers }: { providers: CloudProviderStatus[] }) {
const t = useTranslations('extensions')
const labels = providers.map((p) => PROVIDER_META[p.provider]?.label ?? p.provider)
const { isConnecting, connect } = useConnectProvider(providers[0].provider, labels[0])
return (
<AttnLine
className="pb-3"
action={{
label: isConnecting
? t('ext_cloud_backup_redirecting')
: t('ext_cloud_backup_reauth_action', { provider: labels[0] }),
onClick: connect,
}}
>
{t('ext_cloud_backup_reauth_description', { provider: labels.join(' / ') })}
</AttnLine>
)
}
interface ProviderRowProps {
status: CloudProviderStatus
onChanged: () => Promise<void> | void
}
/**
* One destination as a single hairline row: brand mark and name on the left,
* the connect action on the right. Connected destinations unfold their detail
* (last sync, schedule) as indented settings rows below the same header row.
* Every request carries `?provider=`, so the two rows never touch each
* other's records. The reconnect notice is deliberately not here: it belongs
* to the section (see `ReauthAttn`), one ochre sentence for all destinations.
*/
function ProviderRow({ status, onChanged }: ProviderRowProps) {
const { toast } = useToast()
const t = useTranslations('extensions')
const { dialogProps, confirm } = useDestructiveConfirm()
const { appName } = useBranding()
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 { isConnecting, connect: handleConnect } = useConnectProvider(providerId, provider)
const handleDisconnect = useCallback(async () => {
setIsDisconnecting(true)
try {
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', { provider }) })
await onChanged()
} catch (err) {
toast({
title: t('ext_cloud_backup_disconnect_failed'),
description: err instanceof Error ? err.message : t('ext_cloud_backup_try_again'),
variant: 'destructive',
})
} finally {
setIsDisconnecting(false)
}
}, [onChanged, provider, qs, t, toast])
type SyncOutcome =
| { result: 'ok' | 'error' }
| { result: 'too_large'; sizeMb: number | null; limitMb: number | null }
const syncOnce = useCallback(
async (allowDocumentFallback: boolean): Promise<SyncOutcome> => {
setIsSyncing(true)
try {
const res = await fetch(`${API_BASE}/sync${qs}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
include_documents: true,
allow_document_fallback: allowDocumentFallback,
}),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
if (res.status === 413 && !allowDocumentFallback) {
// Handled by the caller: a dialog offers syncing the oversized
// archives without their document blobs.
return {
result: 'too_large',
sizeMb: body.size_bytes
? Math.round(body.size_bytes / (1024 * 1024))
: null,
limitMb: body.size_limit_bytes
? Math.round(body.size_limit_bytes / (1024 * 1024))
: null,
}
}
if (body.error === 'needs_reauth') {
// 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: CloudLastSync & {
web_view_link: string
uploaded_count?: number
skipped_count?: number
}
}
if (data.uploaded_count === 0) {
toast({
title: t('ext_cloud_backup_up_to_date'),
description: t('ext_cloud_backup_no_changes'),
})
} else {
const anyNoDocs = (data.files ?? []).some(
(f) => f.kind !== 'readme' && f.included_documents === false
)
toast({
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)})${
anyNoDocs ? ` · ${t('ext_cloud_backup_no_documents_note')}` : ''
}`,
})
}
await onChanged()
return { result: 'ok' }
} catch (err) {
toast({
title: t('ext_cloud_backup_sync_failed'),
description: err instanceof Error ? err.message : t('ext_cloud_backup_try_again'),
variant: 'destructive',
})
return { result: 'error' }
} finally {
setIsSyncing(false)
}
},
[onChanged, provider, qs, t, toast]
)
const handleSync = useCallback(async () => {
const first = await syncOnce(false)
if (first.result !== 'too_large') return
const ok = await confirm({
title: t('ext_cloud_backup_too_large_title'),
description: t('ext_cloud_backup_too_large_description', {
size: first.sizeMb != null ? String(first.sizeMb) : '?',
limit: first.limitMb != null ? String(first.limitMb) : '?',
}),
confirmLabel: t('ext_cloud_backup_too_large_confirm'),
variant: 'warning',
})
if (ok) await syncOnce(true)
}, [confirm, syncOnce, t])
// The scope copy ("only files the app creates" / "its own app folder") is
// what earns the OAuth grant, so it stays visible in the state where the
// user decides: it replaces the tagline, which only paraphrased the row
// title. Connected rows swap it for the account the backup lands in.
const scopeKey =
providerId === 'dropbox'
? 'ext_cloud_backup_connect_description_dropbox'
: 'ext_cloud_backup_connect_description_google'
const supportingLine = status.connected
? status.account_email
: status.configured
? t(scopeKey, { appName })
: null
return (
<div className="border-b border-border">
<DestructiveConfirmDialog {...dialogProps} />
{/* Header row: identity left, action right, in every state. */}
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-1 py-3">
<div className="flex min-w-0 items-center gap-3">
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0">
<span className="text-sm font-medium">{provider}</span>
{supportingLine && (
<p
className={cn(
'mt-1 text-xs leading-5 text-muted-foreground',
// Only the account address is a single unbreakable token
// worth clipping; the scope sentence has to wrap, or it is
// cut off on a 390px screen where the row does not wrap.
status.connected && 'truncate'
)}
>
{supportingLine}
</p>
)}
</div>
</div>
{!status.configured ? (
// Not configured is a state, not an action: one quiet line where the
// button would sit, free to wrap on narrow screens.
<span className="text-xs text-muted-foreground">
{t('ext_cloud_backup_not_configured', { provider })}
</span>
) : (
<div className="flex shrink-0 items-center gap-2">
{status.connected ? (
<>
<Button variant="outline" onClick={handleSync} disabled={isSyncing}>
{isSyncing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('ext_cloud_backup_syncing')}
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
{t('ext_cloud_backup_sync_now')}
</>
)}
</Button>
<Button variant="ghost" onClick={handleDisconnect} disabled={isDisconnecting}>
{isDisconnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('ext_cloud_backup_disconnecting')}
</>
) : (
<>
<Unplug className="mr-2 h-4 w-4" />
{t('ext_cloud_backup_disconnect')}
</>
)}
</Button>
</>
) : (
<Button onClick={handleConnect} disabled={isConnecting}>
{isConnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('ext_cloud_backup_redirecting')}
</>
) : (
t('ext_cloud_backup_connect', { provider })
)}
</Button>
)}
</div>
)}
</div>
{/*
Both gates, not just `connected`: a deployment that loses its
credentials while an account is still stored shows the "not
configured" state alone, exactly as the header row does, instead of
offering controls next to a line saying the destination is unavailable.
`role="group"` ties these controls to the destination they belong to:
with two rows open, "Synka nu" is otherwise unattributable.
*/}
{status.configured && status.connected && (
<div
role="group"
aria-label={provider}
className="ml-3 border-l border-border pb-3 pl-4"
>
<SettingsRow label={t('ext_cloud_backup_last_sync_label')} align="baseline">
{status.last_sync ? (
<LastSyncSummary lastSync={status.last_sync} providerId={providerId} />
) : (
<span className="text-muted-foreground">{t('ext_cloud_backup_never')}</span>
)}
</SettingsRow>
<ScheduleSection
providerId={providerId}
provider={provider}
schedule={status.schedule}
needsReauth={status.needs_reauth}
onUpdated={onChanged}
/>
</div>
)}
</div>
)
}
/**
* 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,
providerId,
}: {
lastSync: CloudLastSync
providerId: CloudProviderId
}) {
const t = useTranslations('extensions')
const files = lastSync.files
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
const anyNoDocs = files
? files.some((f) => f.kind !== 'readme' && f.included_documents === false)
: lastSync.included_documents === false
const archiveCount = files ? files.filter((f) => f.kind !== 'readme').length : null
const verified = files ? files.every((f) => f.sha256) : Boolean(lastSync.sha256)
return (
<div className="min-w-0">
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-medium underline-offset-4 hover:underline tabular-nums"
>
{formatDateTime(lastSync.at)}
<ExternalLink className="h-3 w-3 text-muted-foreground" />
</a>
<p className="text-xs text-muted-foreground tabular-nums">
{formatMb(sizeBytes)}
{archiveCount !== null &&
` · ${t('ext_cloud_backup_files_count', { count: archiveCount })}`}
{verified && ` · ${t('ext_cloud_backup_verified')}`}
</p>
{anyNoDocs && (
<p className="text-xs text-muted-foreground">
{t('ext_cloud_backup_last_sync_no_documents')}
</p>
)}
</div>
)
}
function formatMb(bytes: number): string {
const mb = bytes / (1024 * 1024)
return `${mb.toFixed(1)} MB`
}
function formatDateTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleString('sv-SE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
interface ScheduleSectionProps {
providerId: CloudProviderId
provider: string
schedule: CloudSchedule | null
needsReauth: boolean
onUpdated: () => Promise<void> | void
}
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: CloudSchedule | null): number =>
typeof s?.hour_local === 'number'
? s.hour_local
: utcHourToLocalHour(typeof s?.hour_utc === 'number' ? s.hour_utc : 3)
const [enabled, setEnabled] = useState(schedule?.enabled ?? false)
const [localHour, setLocalHour] = useState(scheduleHour(schedule))
const [isSaving, setIsSaving] = useState(false)
// Each provider renders its own controls, so the id must not collide.
const hourId = `auto-sync-hour-${providerId}`
useEffect(() => {
setEnabled(schedule?.enabled ?? false)
setLocalHour(scheduleHour(schedule))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schedule?.enabled, schedule?.hour_utc, schedule?.hour_local])
const save = useCallback(
async (nextEnabled: boolean, nextLocalHour: number) => {
setIsSaving(true)
try {
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'))
}
await onUpdated()
} catch (err) {
toast({
title: t('ext_cloud_backup_schedule_save_failed'),
description: err instanceof Error ? err.message : t('ext_cloud_backup_try_again'),
variant: 'destructive',
})
} finally {
setIsSaving(false)
}
},
[onUpdated, providerId, t, toast]
)
const handleToggle = useCallback(
(checked: boolean) => {
setEnabled(checked)
save(checked, localHour)
},
[localHour, save]
)
const handleHourChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const next = Number(e.target.value)
setLocalHour(next)
if (enabled) save(enabled, next)
},
[enabled, save]
)
return (
<>
<SettingsRow
label={t('ext_cloud_backup_auto_sync_title')}
help={t('ext_cloud_backup_auto_sync_description', { provider })}
borderless={!enabled}
>
<SettingsRowEnd>
<Switch
checked={enabled}
onCheckedChange={handleToggle}
disabled={isSaving}
aria-label={t('ext_cloud_backup_auto_sync_title')}
/>
</SettingsRowEnd>
</SettingsRow>
{enabled && (
<SettingsRow label={t('ext_cloud_backup_time_label')} htmlFor={hourId} borderless>
<SettingsSelect
id={hourId}
value={localHour}
onChange={handleHourChange}
disabled={isSaving}
className="tabular-nums"
>
{Array.from({ length: 24 }, (_, h) => (
<option key={h} value={h}>
{h.toString().padStart(2, '0')}:00
</option>
))}
</SettingsSelect>
{isSaving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
</SettingsRow>
)}
{schedule?.last_auto_sync_at && (
<p className="px-1 text-xs text-muted-foreground">
{t('ext_cloud_backup_last_auto_sync')} {formatDateTime(schedule.last_auto_sync_at)}{' '}
{schedule.last_auto_sync_status === 'success' ? (
<span className="text-success">· {t('ext_cloud_backup_auto_sync_success')}</span>
) : schedule.last_auto_sync_status === 'error' ? (
<span className="text-destructive">
· {t('ext_cloud_backup_auto_sync_error')}
{needsReauth
? ` (${t('ext_cloud_backup_reauth_needed_short')})`
: schedule.last_auto_sync_error
? ` (${schedule.last_auto_sync_error})`
: ''}
</span>
) : null}
</p>
)}
</>
)
}
/** Convert a UTC hour (0-23) to the browser's local hour. */
function utcHourToLocalHour(hourUtc: number): number {
const d = new Date()
d.setUTCHours(hourUtc, 0, 0, 0)
return d.getHours()
}