fix(cloud-backup): mark dead Google tokens needs-reauth and surface reconnect in the UI (#970)
Nightly cloud-backup syncs kept retrying Google connections whose refresh token is permanently dead (Google returns 400 invalid_grant; 3 of 12 prod connections are in this state), and the settings card showed the raw English error string while presenting the account as connected. - refreshAccessToken now throws a typed GoogleTokenRefreshError carrying status + body, with an isInvalidGrant discriminator. - performSync catches the invalid_grant case, persists status: 'needs_reauth' (+ needs_reauth_at) on the connection JSON in extension_data (no migration needed), and returns a needs_reauth failure instead of throwing. Transient failures (5xx, network, other 400s) still throw and stay retried. - The nightly cron loads connections for due companies and skips needs_reauth ones (reported as skipped in the summary) instead of retrying the dead token every night. A successful refresh clears a stale flag; reconnecting via OAuth writes a fresh connection. - CloudBackupCard shows a reconnect callout (Swedish-first, sv+en strings) wired to the existing connect action, and replaces the raw error string on the schedule row with a short reconnect notice. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
b06d73c23e
commit
2be104ba34
@@ -2,17 +2,19 @@
|
||||
|
||||
import { useCallback, useEffect, 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 { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Cloud, ExternalLink, Loader2, RefreshCw, Unplug } from 'lucide-react'
|
||||
import { AlertTriangle, Cloud, ExternalLink, Loader2, RefreshCw, Unplug } from 'lucide-react'
|
||||
import type { CloudBackupStatus, GoogleDriveSchedule } from '../types'
|
||||
|
||||
const API_BASE = '/api/extensions/ext/cloud-backup'
|
||||
|
||||
export default function CloudBackupCard() {
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('extensions')
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const [status, setStatus] = useState<CloudBackupStatus | null>(null)
|
||||
@@ -118,6 +120,11 @@ export default function CloudBackupCard() {
|
||||
: 'Arkivet är för stort för direktsynk.'
|
||||
)
|
||||
}
|
||||
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'))
|
||||
}
|
||||
throw new Error(body.error || 'Synkningen misslyckades')
|
||||
}
|
||||
const { data } = (await res.json()) as {
|
||||
@@ -137,7 +144,7 @@ export default function CloudBackupCard() {
|
||||
} finally {
|
||||
setIsSyncing(false)
|
||||
}
|
||||
}, [loadStatus, toast])
|
||||
}, [loadStatus, t, toast])
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
@@ -161,6 +168,36 @@ export default function CloudBackupCard() {
|
||||
<p className="text-sm text-muted-foreground">Laddar…</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')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
|
||||
{t('ext_cloud_backup_reauth_description')}
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting}
|
||||
className="mt-3 w-full sm:w-auto"
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Omdirigerar…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Cloud className="mr-2 h-4 w-4" />
|
||||
{t('ext_cloud_backup_reauth_action')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<dt className="shrink-0 text-muted-foreground">Konto</dt>
|
||||
@@ -194,6 +231,7 @@ export default function CloudBackupCard() {
|
||||
<div className="mt-6 pt-6 border-t border-border">
|
||||
<ScheduleSection
|
||||
schedule={status.schedule}
|
||||
needsReauth={status.needs_reauth}
|
||||
onUpdated={loadStatus}
|
||||
/>
|
||||
</div>
|
||||
@@ -280,11 +318,13 @@ function formatDate(iso: string): string {
|
||||
|
||||
interface ScheduleSectionProps {
|
||||
schedule: GoogleDriveSchedule | null
|
||||
needsReauth: boolean
|
||||
onUpdated: () => Promise<void> | void
|
||||
}
|
||||
|
||||
function ScheduleSection({ schedule, onUpdated }: ScheduleSectionProps) {
|
||||
function ScheduleSection({ schedule, needsReauth, onUpdated }: ScheduleSectionProps) {
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('extensions')
|
||||
|
||||
// Convert stored UTC hour to the user's local hour for display.
|
||||
const initialLocalHour =
|
||||
@@ -400,7 +440,11 @@ function ScheduleSection({ schedule, onUpdated }: ScheduleSectionProps) {
|
||||
) : schedule.last_auto_sync_status === 'error' ? (
|
||||
<span className="text-destructive">
|
||||
· misslyckades
|
||||
{schedule.last_auto_sync_error ? ` (${schedule.last_auto_sync_error})` : ''}
|
||||
{needsReauth
|
||||
? ` (${t('ext_cloud_backup_reauth_needed_short')})`
|
||||
: schedule.last_auto_sync_error
|
||||
? ` (${schedule.last_auto_sync_error})`
|
||||
: ''}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user