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:
Jakob Wennberg
2026-07-10 11:04:11 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent b06d73c23e
commit 2be104ba34
11 changed files with 562 additions and 15 deletions
+31 -2
View File
@@ -6,6 +6,7 @@ import {
import {
getOAuthEnv,
refreshAccessToken,
GoogleTokenRefreshError,
} from './google-oauth'
import { ensureFolder, uploadFile } from './google-drive'
import { decryptToken } from './crypto'
@@ -19,6 +20,7 @@ export const SIZE_LIMIT_BYTES = 80 * 1024 * 1024
export type SyncFailureReason =
| 'not_connected'
| 'needs_reauth'
| 'archive_too_large'
| 'upload_failed'
| 'internal'
@@ -81,7 +83,30 @@ export async function performSync(params: PerformSyncParams): Promise<PerformSyn
const env = getOAuthEnv(origin)
const refreshToken = decryptToken(connection.refresh_token_encrypted)
const { access_token: accessToken } = await refreshAccessToken(env, refreshToken)
let accessToken: string
try {
const refreshed = await refreshAccessToken(env, refreshToken)
accessToken = refreshed.access_token
} catch (err) {
if (err instanceof GoogleTokenRefreshError && err.isInvalidGrant) {
// The refresh token is permanently dead (revoked or expired). Flag the
// connection so the cron stops retrying it and the UI can ask the user
// to reconnect. Other failures (network, 5xx) stay throwing: they are
// transient and worth retrying.
const flagged: GoogleDriveConnection = {
...connection,
status: 'needs_reauth',
needs_reauth_at: new Date().toISOString(),
}
await saveExtensionData(supabase, companyId, userId, CONNECTION_KEY, flagged)
return {
ok: false,
reason: 'needs_reauth',
message: 'Google Drive authorization expired; reconnect required',
}
}
throw err
}
let rootFolderId = connection.root_folder_id
let companyFolderId = connection.company_folder_id
@@ -96,12 +121,16 @@ export async function performSync(params: PerformSyncParams): Promise<PerformSyn
}
if (
rootFolderId !== connection.root_folder_id ||
companyFolderId !== connection.company_folder_id
companyFolderId !== connection.company_folder_id ||
connection.status === 'needs_reauth'
) {
// A successful refresh also clears a stale needs_reauth flag.
await saveExtensionData(supabase, companyId, userId, CONNECTION_KEY, {
...connection,
root_folder_id: rootFolderId,
company_folder_id: companyFolderId,
status: 'active',
needs_reauth_at: undefined,
})
}