mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-16 10:32:10 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
+28
-39
@@ -6,14 +6,14 @@
|
||||
import SignIn from '$lib/components/SignIn.svelte';
|
||||
import SignOut from '$lib/components/SignOut.svelte';
|
||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { claimOfflineData, requestOfflineSync, startOfflineSync } from '$lib/stores/offlineSync';
|
||||
import {
|
||||
artworkDownloadStatus,
|
||||
claimOfflineData,
|
||||
downloadAllArtwork,
|
||||
offlineSyncStatus,
|
||||
requestOfflineSync,
|
||||
startOfflineSync
|
||||
} from '$lib/stores/offlineSync';
|
||||
PROVIDER_LABELS,
|
||||
backupsNeedingReconnect,
|
||||
clearBackupStatus,
|
||||
refreshBackupStatus
|
||||
} from '$lib/stores/backupStatus';
|
||||
|
||||
import { pwaInfo } from 'virtual:pwa-info';
|
||||
import { pwaAssetsHead } from 'virtual:pwa-assets/head';
|
||||
@@ -57,7 +57,10 @@
|
||||
updateOnlineState();
|
||||
void getUser()
|
||||
.then(async () => {
|
||||
if (localUser) await claimOfflineData(localUser.id);
|
||||
if (localUser) {
|
||||
void refreshBackupStatus();
|
||||
await claimOfflineData(localUser.id);
|
||||
}
|
||||
stopOfflineSync = startOfflineSync(() => localUser?.id ?? null);
|
||||
})
|
||||
.catch((error) => console.error('Unable to claim offline data', error));
|
||||
@@ -75,12 +78,14 @@
|
||||
void claimOfflineData(session.user.id)
|
||||
.then(requestOfflineSync)
|
||||
.catch((error) => console.error('Unable to claim offline data', error));
|
||||
void refreshBackupStatus();
|
||||
}
|
||||
} else {
|
||||
// Offline data is only cleared by the Sign Out button (or another account claiming it).
|
||||
// An expired or rejected session must not throw away artwork that would then have to be
|
||||
// downloaded again after signing back in.
|
||||
localUser = null;
|
||||
clearBackupStatus();
|
||||
}
|
||||
user.set(localUser);
|
||||
});
|
||||
@@ -98,10 +103,7 @@
|
||||
};
|
||||
});
|
||||
|
||||
function formatMegabytes(bytes: number) {
|
||||
const megabytes = bytes / 1048576;
|
||||
return megabytes < 1 ? '<1 MB' : `≈${Math.round(megabytes)} MB`;
|
||||
}
|
||||
$: reconnectLabels = $backupsNeedingReconnect.map((provider) => PROVIDER_LABELS[provider]);
|
||||
|
||||
async function getUser() {
|
||||
const {
|
||||
@@ -192,6 +194,9 @@
|
||||
<li>
|
||||
<a href="/backup-settings"> Backup Settings </a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/offline-guide"> Using Offline </a>
|
||||
</li>
|
||||
<li>
|
||||
<SignOut
|
||||
{supabase}
|
||||
@@ -222,34 +227,18 @@
|
||||
<div class="alert rounded-none" role="status">
|
||||
<span>Offline read-only mode: saved data remains available, but changes are disabled.</span>
|
||||
</div>
|
||||
{:else if localUser && $offlineSyncStatus.state === 'error'}
|
||||
<div class="alert alert-warning rounded-none" role="status">
|
||||
<span>Offline copy could not be refreshed: {$offlineSyncStatus.message}</span>
|
||||
<button class="btn btn-sm" on:click={requestOfflineSync}>Retry</button>
|
||||
</div>
|
||||
{:else if localUser && $artworkDownloadStatus.state === 'error'}
|
||||
<div class="alert alert-warning rounded-none" role="status">
|
||||
<span
|
||||
>Offline data is saved, but artwork could not be saved: {$artworkDownloadStatus.message}.</span
|
||||
>
|
||||
<button class="btn btn-sm" on:click={downloadAllArtwork}>Retry</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if isOnline && localUser && $offlineSyncStatus.state === 'syncing'}
|
||||
<p class="bg-base-200 px-4 py-1 text-center text-xs" role="status">Updating offline copy…</p>
|
||||
{:else if isOnline && localUser && $offlineSyncStatus.generatedAt}
|
||||
<p class="bg-base-200 px-4 py-1 text-center text-xs" role="status">
|
||||
Offline copy updated {new Date($offlineSyncStatus.generatedAt).toLocaleString()}.
|
||||
{#if $artworkDownloadStatus.state === 'downloading'}
|
||||
Saving all artwork for offline…
|
||||
{:else if $artworkDownloadStatus.state === 'missing'}
|
||||
<button class="link" on:click={downloadAllArtwork}>
|
||||
Save all artwork for offline{#if $artworkDownloadStatus.missingBytes}{' '}({formatMegabytes(
|
||||
$artworkDownloadStatus.missingBytes
|
||||
)}){/if}
|
||||
</button>
|
||||
{/if}
|
||||
</p>
|
||||
{#if localUser && reconnectLabels.length > 0 && $page.url.pathname !== '/backup-settings'}
|
||||
<div
|
||||
class="alert alert-warning rounded-none"
|
||||
role="alert"
|
||||
data-testid="backup-reconnect-banner"
|
||||
>
|
||||
<span>
|
||||
Your {reconnectLabels.join(' and ')} backup has stopped because access expired or was revoked.
|
||||
</span>
|
||||
<a class="btn btn-sm" href="/backup-settings">Reconnect</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<main class="flex-grow">
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
</svg>
|
||||
Open Source
|
||||
</div>
|
||||
<div class="badge badge-accent badge-lg gap-1">
|
||||
<a href="/offline-guide" class="badge badge-accent badge-lg gap-1 hover:opacity-80">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
@@ -130,7 +130,7 @@
|
||||
/>
|
||||
</svg>
|
||||
Offline-friendly
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<h1 class="text-5xl font-bold mb-6">Start Your Pokédex Journey!</h1>
|
||||
<p class="text-xl mb-6 text-base-content/80">
|
||||
|
||||
@@ -98,7 +98,9 @@ export const GET = async (event: RequestEvent) => {
|
||||
accessToken: tokenData.access_token,
|
||||
refreshToken: tokenData.refresh_token ?? null,
|
||||
accessTokenExpiresAt: expiresAt,
|
||||
metadata: tokenData.scope ? { scope: tokenData.scope } : null
|
||||
metadata: tokenData.scope ? { scope: tokenData.scope } : null,
|
||||
// A reconnect replaces the tokens, so any error from the old ones no longer applies.
|
||||
lastError: null
|
||||
});
|
||||
} catch (saveError) {
|
||||
console.error('Dropbox integration save failed:', saveError);
|
||||
|
||||
@@ -99,7 +99,9 @@ export const GET = async (event: RequestEvent) => {
|
||||
accessToken: tokenData.access_token,
|
||||
refreshToken: tokenData.refresh_token ?? null,
|
||||
accessTokenExpiresAt: expiresAt,
|
||||
metadata: tokenData.scope ? { scope: tokenData.scope } : null
|
||||
metadata: tokenData.scope ? { scope: tokenData.scope } : null,
|
||||
// A reconnect replaces the tokens, so any error from the old ones no longer applies.
|
||||
lastError: null
|
||||
});
|
||||
} catch (saveError) {
|
||||
console.error('Google Drive integration save failed:', saveError);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { setBackupStatus } from '$lib/stores/backupStatus';
|
||||
|
||||
type ExportIntegrationSummary = {
|
||||
id: string;
|
||||
@@ -40,6 +41,8 @@
|
||||
exportIntegrations = (await response.json()) as ExportIntegrationSummary[];
|
||||
googleIntegration = exportIntegrations.find((i) => i.provider === 'google_drive');
|
||||
dropboxIntegration = exportIntegrations.find((i) => i.provider === 'dropbox');
|
||||
// Keeps the sitewide banner in step, e.g. clearing it after the OAuth flow returns here.
|
||||
setBackupStatus(exportIntegrations);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
exportError = message || 'Failed to load export settings';
|
||||
@@ -48,6 +51,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadge(integration: ExportIntegrationSummary | undefined) {
|
||||
if (!integration) return { label: 'Not Connected', className: 'badge-ghost' };
|
||||
// Exports switch an integration off when the provider revokes its access.
|
||||
if (!integration.enabled) return { label: 'Reconnect needed', className: 'badge-warning' };
|
||||
return { label: 'Connected', className: 'badge-success' };
|
||||
}
|
||||
|
||||
$: googleBadge = statusBadge(googleIntegration);
|
||||
$: dropboxBadge = statusBadge(dropboxIntegration);
|
||||
|
||||
function getGoogleFolderUrl(folderId: string): string {
|
||||
return `https://drive.google.com/drive/folders/${folderId}`;
|
||||
}
|
||||
@@ -106,13 +119,14 @@
|
||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold">Google Drive</h2>
|
||||
<span class={`badge ${googleIntegration ? 'badge-success' : 'badge-ghost'}`}>
|
||||
{googleIntegration ? 'Connected' : 'Not Connected'}
|
||||
</span>
|
||||
<span class={`badge ${googleBadge.className}`}>{googleBadge.label}</span>
|
||||
</div>
|
||||
<div class="mt-3 space-y-2">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button class="btn btn-sm btn-outline" on:click={connectGoogleDrive}>
|
||||
<button
|
||||
class={`btn btn-sm ${googleIntegration?.enabled === false ? 'btn-primary' : 'btn-outline'}`}
|
||||
on:click={connectGoogleDrive}
|
||||
>
|
||||
{googleIntegration ? 'Reconnect' : 'Connect'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -142,13 +156,14 @@
|
||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold">Dropbox</h2>
|
||||
<span class={`badge ${dropboxIntegration ? 'badge-success' : 'badge-ghost'}`}>
|
||||
{dropboxIntegration ? 'Connected' : 'Not Connected'}
|
||||
</span>
|
||||
<span class={`badge ${dropboxBadge.className}`}>{dropboxBadge.label}</span>
|
||||
</div>
|
||||
<div class="mt-3 space-y-2">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button class="btn btn-sm btn-outline" on:click={connectDropbox}>
|
||||
<button
|
||||
class={`btn btn-sm ${dropboxIntegration?.enabled === false ? 'btn-primary' : 'btn-outline'}`}
|
||||
on:click={connectDropbox}
|
||||
>
|
||||
{dropboxIntegration ? 'Reconnect' : 'Connect'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
import { user } from '$lib/stores/user.js';
|
||||
import {
|
||||
artworkDownloadStatus,
|
||||
downloadAllArtwork,
|
||||
offlineSyncStatus,
|
||||
requestOfflineSync
|
||||
} from '$lib/stores/offlineSync';
|
||||
|
||||
function formatMegabytes(bytes: number) {
|
||||
const megabytes = bytes / 1048576;
|
||||
return megabytes < 1 ? '<1 MB' : `≈${Math.round(megabytes)} MB`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Using Offline - Living Dex Tracker</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto p-4 max-w-screen-lg">
|
||||
<h1 class="text-3xl font-bold mb-6">Using Living Dex Tracker offline</h1>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Your offline copy</h2>
|
||||
{#if !$user}
|
||||
<p>
|
||||
<a href="/signin" class="link link-primary">Sign in</a> to keep a copy of your pokédexes on
|
||||
this device.
|
||||
</p>
|
||||
{:else}
|
||||
<div data-testid="offline-copy-status" role="status">
|
||||
{#if $offlineSyncStatus.state === 'syncing'}
|
||||
<p>Updating your offline copy…</p>
|
||||
{:else if $offlineSyncStatus.state === 'error'}
|
||||
<div class="alert alert-warning">
|
||||
<span>Your offline copy could not be refreshed: {$offlineSyncStatus.message}</span>
|
||||
<button class="btn btn-sm" on:click={requestOfflineSync}>Retry</button>
|
||||
</div>
|
||||
{:else if $offlineSyncStatus.generatedAt}
|
||||
<p>
|
||||
Offline copy updated {new Date($offlineSyncStatus.generatedAt).toLocaleString()}.
|
||||
</p>
|
||||
{:else}
|
||||
<p>
|
||||
No offline copy is saved on this device yet. It saves automatically while you are
|
||||
online.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<h3 class="font-semibold mt-4">Artwork</h3>
|
||||
{#if $artworkDownloadStatus.state === 'downloading'}
|
||||
<p role="status">Saving all artwork for offline…</p>
|
||||
{:else if $artworkDownloadStatus.state === 'error'}
|
||||
<div class="alert alert-warning" role="status">
|
||||
<span>Some artwork could not be saved: {$artworkDownloadStatus.message}.</span>
|
||||
<button class="btn btn-sm" on:click={downloadAllArtwork}>Retry</button>
|
||||
</div>
|
||||
{:else if $artworkDownloadStatus.state === 'done'}
|
||||
<p>All artwork is saved on this device.</p>
|
||||
{:else}
|
||||
<p>
|
||||
Artwork is saved as you view it. To browse every Pokémon offline, including every form,
|
||||
shiny and female variant, save it all now.
|
||||
</p>
|
||||
{#if $artworkDownloadStatus.state === 'missing'}
|
||||
<div>
|
||||
<button class="btn btn-primary btn-sm" on:click={downloadAllArtwork}>
|
||||
Save all artwork for offline{#if $artworkDownloadStatus.missingBytes}{' '}({formatMegabytes(
|
||||
$artworkDownloadStatus.missingBytes
|
||||
)}){/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">How it works</h2>
|
||||
<ul class="list-disc pl-5 space-y-2">
|
||||
<li>
|
||||
<strong>Install the app.</strong> Use your browser's "Install app" or "Add to Home Screen"
|
||||
option so Living Dex Tracker opens without a connection.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Stay signed in.</strong> Your offline copy updates automatically whenever you are online,
|
||||
including after you make changes.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Offline is read-only.</strong> You can browse your pokédexes, but catches and edits
|
||||
are disabled until you are back online.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Artwork.</strong> Sprites are saved as you view them. Use "Save all artwork for offline"
|
||||
above to download the rest in one go.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Signing out</strong> removes the offline copy from this device.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -17,6 +17,14 @@
|
||||
import type { Pokedex } from '$lib/models/Pokedex';
|
||||
import type { PageData } from './$types';
|
||||
import { requestOfflineSync } from '$lib/stores/offlineSync';
|
||||
import { get } from 'svelte/store';
|
||||
import {
|
||||
PROVIDER_LABELS,
|
||||
backupsNeedingReconnect,
|
||||
markReconnectNeeded,
|
||||
refreshBackupStatus
|
||||
} from '$lib/stores/backupStatus';
|
||||
import type { ExportProvider } from '$lib/models/PokedexExportIntegration';
|
||||
import type { SharedCombinedData } from '$lib/models/SharedPokedex';
|
||||
|
||||
export let data: PageData;
|
||||
@@ -66,6 +74,8 @@
|
||||
lastSuccessfulFlushAt: null
|
||||
};
|
||||
let lastOfflineSyncFlush: number | null = null;
|
||||
// Backup providers that just refused this page's export because their access was revoked.
|
||||
let reconnectToastLabels: string[] = [];
|
||||
let exportAfterFlush = false;
|
||||
let exportInFlight = false;
|
||||
let exportTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -110,6 +120,24 @@
|
||||
console.error('Auto-export failed:', response.status, body);
|
||||
return;
|
||||
}
|
||||
const result = (await response.json().catch(() => null)) as {
|
||||
failed?: Array<{ provider: ExportProvider; reconnectRequired?: boolean }>;
|
||||
} | null;
|
||||
const revoked = (result?.failed ?? []).filter((failure) => failure.reconnectRequired);
|
||||
const alreadyPaused = new Set(get(backupsNeedingReconnect));
|
||||
if (revoked.length > 0) {
|
||||
markReconnectNeeded(revoked.map((failure) => failure.provider));
|
||||
} else {
|
||||
// Saving a catch record also exports on the server, and that export may already have
|
||||
// paused a provider, leaving this export nothing to report. Re-read the status to catch it.
|
||||
await refreshBackupStatus();
|
||||
}
|
||||
const newlyPaused = get(backupsNeedingReconnect).filter(
|
||||
(provider) => !alreadyPaused.has(provider)
|
||||
);
|
||||
if (newlyPaused.length > 0) {
|
||||
reconnectToastLabels = newlyPaused.map((provider) => PROVIDER_LABELS[provider]);
|
||||
}
|
||||
if (exportGeneration === exportInFlightGeneration) {
|
||||
exportAfterFlush = false;
|
||||
}
|
||||
@@ -514,6 +542,24 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if reconnectToastLabels.length > 0}
|
||||
<!-- Above DaisyUI's modal (z-index 999) so the alert stays usable over an open Pokémon dialog. -->
|
||||
<div class="toast toast-end z-[1000]">
|
||||
<div class="alert alert-warning" role="alert" data-testid="backup-reconnect-toast">
|
||||
<span>
|
||||
Backups to {reconnectToastLabels.join(' and ')} have stopped because access expired or was revoked.
|
||||
</span>
|
||||
<a class="btn btn-sm" href="/backup-settings">Reconnect</a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost"
|
||||
aria-label="Dismiss"
|
||||
on:click={() => (reconnectToastLabels = [])}>✕</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<svelte:head>
|
||||
<title>{pokedex ? `${pokedex.name} - Living Dex Tracker` : 'Pokédex - Living Dex Tracker'}</title>
|
||||
</svelte:head>
|
||||
|
||||
@@ -306,6 +306,7 @@
|
||||
</svg>
|
||||
<h4 class="font-semibold">Works Offline</h4>
|
||||
<p class="text-sm opacity-70">Track your catches even without an internet connection</p>
|
||||
<a href="/offline-guide" class="link link-primary text-sm">How to use offline</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user