Å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.
736 lines
26 KiB
TypeScript
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()
|
|
}
|