fix(backup): pause revoked backups and tell users to reconnect

Google answers a revoked or expired refresh token with invalid_grant.
Every save then retried the dead token, and reconnecting never cleared
the old error, so it kept showing on Backup Settings afterwards.

- The Google Drive and Dropbox OAuth callbacks clear lastError when a
  provider is reconnected.
- An invalid_grant, or a missing refresh token, now pauses the
  integration with a readable "reconnect" message instead of retrying
  it on every catch update. Other failures still retry as before.
- A banner on every page and an alert on the Pokédex page point to
  Backup Settings, which shows a "Reconnect needed" badge. The Pokédex
  page re-checks backup status after each export, because saving a
  catch record also exports on the server and may pause a provider
  first.
- Offline sync status and the "Save all artwork" link move from every
  page to a new /offline-guide page, linked from the user menu and the
  home and welcome pages. Only the offline read-only banner stays
  sitewide.
- Unit tests cover every export path and the backup status store. BDD
  covers revocation and reconnecting for both providers, and the
  offline guide. The mock provider can now reject token refreshes, and
  mock control calls fail loudly if a stale mock is reused. Coverage
  thresholds are raised to the new baseline.
This commit is contained in:
Josh Creek
2026-09-14 18:42:10 +01:00
parent 5c25a763c0
commit fb95b43b30
21 changed files with 1105 additions and 62 deletions
@@ -139,6 +139,7 @@ class PokedexExportIntegrationRepository {
async updateExportStatus(
id: string,
patch: {
enabled?: boolean;
lastExportedAt?: string | null;
lastError?: string | null;
metadata?: Record<string, unknown> | null;
@@ -54,6 +54,17 @@ export function buildCsv(combinedData: CombinedData[]): string {
return lines.join('\r\n');
}
/** OAuth providers answer a revoked or expired refresh token with `invalid_grant`. */
export function isRevokedGrant(status: number, body: string): boolean {
if (status !== 400 && status !== 401) return false;
try {
const parsed = JSON.parse(body) as { error?: unknown } | null;
return parsed?.error === 'invalid_grant';
} catch {
return false;
}
}
export function shouldRefreshToken(expiresAt: string | null): boolean {
if (!expiresAt) return false;
const expiry = new Date(expiresAt).getTime();
+25 -4
View File
@@ -13,6 +13,7 @@ import { getEnv } from '$lib/utils/env';
import { getProviderEndpoints } from '$lib/services/providerEndpoints';
import {
buildCsv,
isRevokedGrant,
sanitizeFileName,
shouldRefreshToken
} from '$lib/services/PokedexExportFormatting';
@@ -21,6 +22,7 @@ type ExportFailure = {
integrationId: string;
provider: ExportProvider;
error: string;
reconnectRequired: boolean;
};
export type PokedexExportResult = {
@@ -29,12 +31,21 @@ export type PokedexExportResult = {
failed: ExportFailure[];
};
const RECONNECT_MESSAGES: Record<ExportProvider, string> = {
google_drive:
'Google Drive access has expired or was revoked. Reconnect Google Drive to resume backups.',
dropbox: 'Dropbox access has expired or was revoked. Reconnect Dropbox to resume backups.'
};
/** The provider rejected the stored grant, so only a fresh OAuth connection can resume exports. */
class ReconnectRequiredError extends Error {}
async function refreshGoogleToken(
integration: PokedexExportIntegration,
repo: PokedexExportIntegrationRepository
): Promise<PokedexExportIntegration> {
if (!integration.refreshToken) {
throw new Error('Missing Google refresh token');
throw new ReconnectRequiredError(RECONNECT_MESSAGES.google_drive);
}
const env = getEnv();
@@ -59,6 +70,9 @@ async function refreshGoogleToken(
if (!response.ok) {
const text = await response.text();
if (isRevokedGrant(response.status, text)) {
throw new ReconnectRequiredError(RECONNECT_MESSAGES.google_drive);
}
throw new Error(`Google token refresh failed: ${response.status} ${text}`);
}
@@ -88,7 +102,7 @@ async function refreshDropboxToken(
repo: PokedexExportIntegrationRepository
): Promise<PokedexExportIntegration> {
if (!integration.refreshToken) {
throw new Error('Missing Dropbox refresh token');
throw new ReconnectRequiredError(RECONNECT_MESSAGES.dropbox);
}
const env = getEnv();
@@ -113,6 +127,9 @@ async function refreshDropboxToken(
if (!response.ok) {
const text = await response.text();
if (isRevokedGrant(response.status, text)) {
throw new ReconnectRequiredError(RECONNECT_MESSAGES.dropbox);
}
throw new Error(`Dropbox token refresh failed: ${response.status} ${text}`);
}
@@ -418,13 +435,17 @@ export async function exportPokedexIfConfigured(
successes++;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// A revoked grant never succeeds on retry, so pause this integration until the user reconnects.
const reconnectRequired = error instanceof ReconnectRequiredError;
failures.push({
integrationId: integration._id,
provider: integration.provider,
error: message
error: message,
reconnectRequired
});
await scopedRepo.updateExportStatus(integration._id, {
lastError: message
lastError: message,
...(reconnectRequired ? { enabled: false } : {})
});
console.error('Failed to export pokedex:', integration.provider, message);
}
+38
View File
@@ -0,0 +1,38 @@
import { writable } from 'svelte/store';
import type { ExportProvider } from '$lib/models/PokedexExportIntegration';
export const PROVIDER_LABELS: Record<ExportProvider, string> = {
google_drive: 'Google Drive',
dropbox: 'Dropbox'
};
/**
* Backup providers whose access has lapsed. Exports switch an integration off when the provider
* revokes its grant, so these stay paused until the user reconnects.
*/
export const backupsNeedingReconnect = writable<ExportProvider[]>([]);
type IntegrationSummary = { provider: ExportProvider; enabled: boolean };
export function setBackupStatus(integrations: IntegrationSummary[]): void {
backupsNeedingReconnect.set(integrations.filter((i) => !i.enabled).map((i) => i.provider));
}
export async function refreshBackupStatus(): Promise<void> {
if (typeof window === 'undefined' || !navigator.onLine) return;
try {
const response = await fetch('/api/export-integrations', { credentials: 'include' });
if (!response.ok) return;
setBackupStatus((await response.json()) as IntegrationSummary[]);
} catch (error) {
console.error('Unable to check backup status', error);
}
}
export function markReconnectNeeded(providers: ExportProvider[]): void {
backupsNeedingReconnect.update((current) => [...new Set([...current, ...providers])]);
}
export function clearBackupStatus(): void {
backupsNeedingReconnect.set([]);
}