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
@@ -7,6 +7,7 @@ vi.mock('@supabase/supabase-js', () => ({
|
|||||||
|
|
||||||
vi.mock('@/extensions/general/cloud-backup/lib/sync', () => ({
|
vi.mock('@/extensions/general/cloud-backup/lib/sync', () => ({
|
||||||
performSync: vi.fn(),
|
performSync: vi.fn(),
|
||||||
|
CONNECTION_KEY: 'google_drive_connection',
|
||||||
SCHEDULE_KEY: 'google_drive_schedule',
|
SCHEDULE_KEY: 'google_drive_schedule',
|
||||||
saveExtensionData: vi.fn().mockResolvedValue(undefined),
|
saveExtensionData: vi.fn().mockResolvedValue(undefined),
|
||||||
}))
|
}))
|
||||||
@@ -34,13 +35,44 @@ function makeRequest() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeSupabaseStub(rows: unknown[], error: unknown = null) {
|
/**
|
||||||
const chain = {
|
* The route issues two queries against extension_data: schedules
|
||||||
select: vi.fn().mockReturnThis(),
|
* (key = google_drive_schedule) and connections (key = google_drive_connection,
|
||||||
eq: vi.fn().mockReturnThis(),
|
* with an .in() filter). Route rows to the right result by the `key` eq filter.
|
||||||
then: (resolve: (v: unknown) => void) => resolve({ data: rows, error }),
|
*/
|
||||||
}
|
function makeSupabaseStub(
|
||||||
return { from: vi.fn().mockReturnValue(chain) } as any
|
scheduleRows: unknown[],
|
||||||
|
options: {
|
||||||
|
scheduleError?: unknown
|
||||||
|
connectionRows?: unknown[]
|
||||||
|
connectionError?: unknown
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const from = vi.fn().mockImplementation(() => {
|
||||||
|
let key: string | null = null
|
||||||
|
const chain: any = {
|
||||||
|
select: vi.fn().mockReturnThis(),
|
||||||
|
in: vi.fn().mockReturnThis(),
|
||||||
|
eq: vi.fn().mockImplementation((column: string, value: string) => {
|
||||||
|
if (column === 'key') key = value
|
||||||
|
return chain
|
||||||
|
}),
|
||||||
|
then: (resolve: (v: unknown) => void) => {
|
||||||
|
if (key === 'google_drive_connection') {
|
||||||
|
return resolve({
|
||||||
|
data: options.connectionRows ?? [],
|
||||||
|
error: options.connectionError ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return resolve({
|
||||||
|
data: scheduleRows,
|
||||||
|
error: options.scheduleError ?? null,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return chain
|
||||||
|
})
|
||||||
|
return { from } as any
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('cloud-backup auto-sync cron', () => {
|
describe('cloud-backup auto-sync cron', () => {
|
||||||
@@ -224,4 +256,124 @@ describe('cloud-backup auto-sync cron', () => {
|
|||||||
expect((value as any).last_auto_sync_status).toBe('error')
|
expect((value as any).last_auto_sync_status).toBe('error')
|
||||||
expect((value as any).last_auto_sync_error).toContain('Drive quota exceeded')
|
expect((value as any).last_auto_sync_error).toContain('Drive quota exceeded')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('skips connections flagged needs_reauth without syncing or touching the schedule', async () => {
|
||||||
|
mockCreateClient.mockReturnValueOnce(
|
||||||
|
makeSupabaseStub(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
company_id: 'c-1',
|
||||||
|
user_id: 'u-1',
|
||||||
|
value: {
|
||||||
|
enabled: true,
|
||||||
|
hour_utc: new Date().getUTCHours(),
|
||||||
|
last_auto_sync_at: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
connectionRows: [
|
||||||
|
{ company_id: 'c-1', value: { status: 'needs_reauth' } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const res = await GET(makeRequest())
|
||||||
|
const body = await res.json()
|
||||||
|
|
||||||
|
expect(mockPerformSync).not.toHaveBeenCalled()
|
||||||
|
expect(mockSaveExtensionData).not.toHaveBeenCalled()
|
||||||
|
expect(body.skipped).toBe(1)
|
||||||
|
expect(body.successes).toBe(0)
|
||||||
|
expect(body.errors).toBe(0)
|
||||||
|
expect(body.results).toEqual([
|
||||||
|
{ companyId: 'c-1', status: 'skipped', error: 'needs_reauth' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only skips the flagged company when others are due', async () => {
|
||||||
|
mockCreateClient.mockReturnValueOnce(
|
||||||
|
makeSupabaseStub(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
company_id: 'c-dead',
|
||||||
|
user_id: 'u-1',
|
||||||
|
value: {
|
||||||
|
enabled: true,
|
||||||
|
hour_utc: new Date().getUTCHours(),
|
||||||
|
last_auto_sync_at: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
company_id: 'c-live',
|
||||||
|
user_id: 'u-2',
|
||||||
|
value: {
|
||||||
|
enabled: true,
|
||||||
|
hour_utc: new Date().getUTCHours(),
|
||||||
|
last_auto_sync_at: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
connectionRows: [
|
||||||
|
{ company_id: 'c-dead', value: { status: 'needs_reauth' } },
|
||||||
|
{ company_id: 'c-live', value: { status: 'active' } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
mockPerformSync.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
lastSync: {
|
||||||
|
at: '2026-07-10T03:00:00Z',
|
||||||
|
file_id: 'f-1',
|
||||||
|
file_name: 'arkiv.zip',
|
||||||
|
file_size_bytes: 1000,
|
||||||
|
folder_id: 'folder-1',
|
||||||
|
},
|
||||||
|
webViewLink: 'https://drive.google.com/file/d/f-1/view',
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await GET(makeRequest())
|
||||||
|
const body = await res.json()
|
||||||
|
|
||||||
|
expect(mockPerformSync).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockPerformSync).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ companyId: 'c-live' })
|
||||||
|
)
|
||||||
|
expect(body.skipped).toBe(1)
|
||||||
|
expect(body.successes).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails open and attempts the sync when the connection lookup errors', async () => {
|
||||||
|
mockCreateClient.mockReturnValueOnce(
|
||||||
|
makeSupabaseStub(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
company_id: 'c-1',
|
||||||
|
user_id: 'u-1',
|
||||||
|
value: {
|
||||||
|
enabled: true,
|
||||||
|
hour_utc: new Date().getUTCHours(),
|
||||||
|
last_auto_sync_at: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ connectionError: { message: 'connection query failed' } }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
mockPerformSync.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
reason: 'needs_reauth',
|
||||||
|
message: 'Google Drive authorization expired; reconnect required',
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await GET(makeRequest())
|
||||||
|
const body = await res.json()
|
||||||
|
|
||||||
|
// performSync is still attempted (it re-flags dead tokens itself).
|
||||||
|
expect(mockPerformSync).toHaveBeenCalledTimes(1)
|
||||||
|
expect(body.errors).toBe(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ import { withCronContext } from '@/lib/api/with-cron-context'
|
|||||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||||
import {
|
import {
|
||||||
performSync,
|
performSync,
|
||||||
|
CONNECTION_KEY,
|
||||||
SCHEDULE_KEY,
|
SCHEDULE_KEY,
|
||||||
saveExtensionData,
|
saveExtensionData,
|
||||||
} from '@/extensions/general/cloud-backup/lib/sync'
|
} from '@/extensions/general/cloud-backup/lib/sync'
|
||||||
import type { GoogleDriveSchedule } from '@/extensions/general/cloud-backup/types'
|
import type {
|
||||||
|
GoogleDriveConnection,
|
||||||
|
GoogleDriveSchedule,
|
||||||
|
} from '@/extensions/general/cloud-backup/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/extensions/cloud-backup/auto-sync/cron
|
* GET /api/extensions/cloud-backup/auto-sync/cron
|
||||||
@@ -73,6 +77,35 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Connections flagged needs_reauth carry a permanently dead refresh token
|
||||||
|
// (Google returned 400 invalid_grant): skip them instead of retrying every
|
||||||
|
// night. They stay visible in the UI until the user reconnects.
|
||||||
|
const { data: connectionRows, error: connectionError } = await supabase
|
||||||
|
.from('extension_data')
|
||||||
|
.select('company_id, value')
|
||||||
|
.eq('extension_id', 'cloud-backup')
|
||||||
|
.eq('key', CONNECTION_KEY)
|
||||||
|
.in(
|
||||||
|
'company_id',
|
||||||
|
candidates.map((r) => r.company_id as string)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (connectionError) {
|
||||||
|
// Fail open: without connection data we cannot tell who needs reauth,
|
||||||
|
// so fall back to attempting everyone (performSync re-flags dead tokens).
|
||||||
|
ctx.log.warn('failed to fetch connections for reauth check', {
|
||||||
|
message: connectionError.message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsReauthCompanyIds = new Set(
|
||||||
|
(connectionRows ?? [])
|
||||||
|
.filter(
|
||||||
|
(r) => (r.value as GoogleDriveConnection | null)?.status === 'needs_reauth'
|
||||||
|
)
|
||||||
|
.map((r) => r.company_id as string)
|
||||||
|
)
|
||||||
|
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
const TIME_BUDGET_MS = 250_000 // 4m10s: leaves 50s margin below Vercel's 300s Pro limit
|
const TIME_BUDGET_MS = 250_000 // 4m10s: leaves 50s margin below Vercel's 300s Pro limit
|
||||||
|
|
||||||
@@ -95,6 +128,13 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
|||||||
const userId = row.user_id as string
|
const userId = row.user_id as string
|
||||||
const schedule = row.value as GoogleDriveSchedule
|
const schedule = row.value as GoogleDriveSchedule
|
||||||
|
|
||||||
|
if (needsReauthCompanyIds.has(companyId)) {
|
||||||
|
// Do not touch last_auto_sync_* here: the schedule keeps showing the
|
||||||
|
// failure from the night the dead token was detected.
|
||||||
|
results.push({ companyId, status: 'skipped', error: 'needs_reauth' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const syncResult = await performSync({
|
const syncResult = await performSync({
|
||||||
supabase,
|
supabase,
|
||||||
@@ -141,11 +181,13 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
|||||||
|
|
||||||
const successCount = results.filter((r) => r.status === 'success').length
|
const successCount = results.filter((r) => r.status === 'success').length
|
||||||
const errorCount = results.filter((r) => r.status === 'error').length
|
const errorCount = results.filter((r) => r.status === 'error').length
|
||||||
|
const skippedCount = results.filter((r) => r.status === 'skipped').length
|
||||||
|
|
||||||
ctx.log.info('cloud backup cron summary', {
|
ctx.log.info('cloud backup cron summary', {
|
||||||
processed: results.length,
|
processed: results.length,
|
||||||
succeeded: successCount,
|
succeeded: successCount,
|
||||||
failed: errorCount,
|
failed: errorCount,
|
||||||
|
skipped: skippedCount,
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -154,6 +196,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
|||||||
processed: results.length,
|
processed: results.length,
|
||||||
successes: successCount,
|
successes: successCount,
|
||||||
errors: errorCount,
|
errors: errorCount,
|
||||||
|
skipped: skippedCount,
|
||||||
results,
|
results,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,17 +2,19 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { useToast } from '@/components/ui/use-toast'
|
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'
|
import type { CloudBackupStatus, GoogleDriveSchedule } from '../types'
|
||||||
|
|
||||||
const API_BASE = '/api/extensions/ext/cloud-backup'
|
const API_BASE = '/api/extensions/ext/cloud-backup'
|
||||||
|
|
||||||
export default function CloudBackupCard() {
|
export default function CloudBackupCard() {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
const t = useTranslations('extensions')
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
const [status, setStatus] = useState<CloudBackupStatus | null>(null)
|
const [status, setStatus] = useState<CloudBackupStatus | null>(null)
|
||||||
@@ -118,6 +120,11 @@ export default function CloudBackupCard() {
|
|||||||
: 'Arkivet är för stort för direktsynk.'
|
: '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')
|
throw new Error(body.error || 'Synkningen misslyckades')
|
||||||
}
|
}
|
||||||
const { data } = (await res.json()) as {
|
const { data } = (await res.json()) as {
|
||||||
@@ -137,7 +144,7 @@ export default function CloudBackupCard() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSyncing(false)
|
setIsSyncing(false)
|
||||||
}
|
}
|
||||||
}, [loadStatus, toast])
|
}, [loadStatus, t, toast])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border bg-card p-6">
|
<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>
|
<p className="text-sm text-muted-foreground">Laddar…</p>
|
||||||
) : status?.connected ? (
|
) : 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">
|
<dl className="space-y-3 text-sm">
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
<dt className="shrink-0 text-muted-foreground">Konto</dt>
|
<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">
|
<div className="mt-6 pt-6 border-t border-border">
|
||||||
<ScheduleSection
|
<ScheduleSection
|
||||||
schedule={status.schedule}
|
schedule={status.schedule}
|
||||||
|
needsReauth={status.needs_reauth}
|
||||||
onUpdated={loadStatus}
|
onUpdated={loadStatus}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -280,11 +318,13 @@ function formatDate(iso: string): string {
|
|||||||
|
|
||||||
interface ScheduleSectionProps {
|
interface ScheduleSectionProps {
|
||||||
schedule: GoogleDriveSchedule | null
|
schedule: GoogleDriveSchedule | null
|
||||||
|
needsReauth: boolean
|
||||||
onUpdated: () => Promise<void> | void
|
onUpdated: () => Promise<void> | void
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScheduleSection({ schedule, onUpdated }: ScheduleSectionProps) {
|
function ScheduleSection({ schedule, needsReauth, onUpdated }: ScheduleSectionProps) {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
const t = useTranslations('extensions')
|
||||||
|
|
||||||
// Convert stored UTC hour to the user's local hour for display.
|
// Convert stored UTC hour to the user's local hour for display.
|
||||||
const initialLocalHour =
|
const initialLocalHour =
|
||||||
@@ -400,7 +440,11 @@ function ScheduleSection({ schedule, onUpdated }: ScheduleSectionProps) {
|
|||||||
) : schedule.last_auto_sync_status === 'error' ? (
|
) : schedule.last_auto_sync_status === 'error' ? (
|
||||||
<span className="text-destructive">
|
<span className="text-destructive">
|
||||||
· misslyckades
|
· 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>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ export const cloudBackupExtension: Extension = {
|
|||||||
const schedule = await ctx.settings.get<GoogleDriveSchedule>(SCHEDULE_KEY)
|
const schedule = await ctx.settings.get<GoogleDriveSchedule>(SCHEDULE_KEY)
|
||||||
const status: CloudBackupStatus = {
|
const status: CloudBackupStatus = {
|
||||||
connected: !!connection,
|
connected: !!connection,
|
||||||
|
needs_reauth: connection?.status === 'needs_reauth',
|
||||||
account_email: connection?.account_email ?? null,
|
account_email: connection?.account_email ?? null,
|
||||||
connected_at: connection?.connected_at ?? null,
|
connected_at: connection?.connected_at ?? null,
|
||||||
last_sync: lastSync ?? null,
|
last_sync: lastSync ?? null,
|
||||||
@@ -269,6 +270,9 @@ export const cloudBackupExtension: Extension = {
|
|||||||
if (result.reason === 'not_connected') {
|
if (result.reason === 'not_connected') {
|
||||||
return jsonError('not_connected', 400)
|
return jsonError('not_connected', 400)
|
||||||
}
|
}
|
||||||
|
if (result.reason === 'needs_reauth') {
|
||||||
|
return jsonError('needs_reauth', 400)
|
||||||
|
}
|
||||||
if (result.reason === 'archive_too_large') {
|
if (result.reason === 'archive_too_large') {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
exchangeCodeForTokens,
|
exchangeCodeForTokens,
|
||||||
refreshAccessToken,
|
refreshAccessToken,
|
||||||
getOAuthEnv,
|
getOAuthEnv,
|
||||||
|
GoogleTokenRefreshError,
|
||||||
} from '../google-oauth'
|
} from '../google-oauth'
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -100,4 +101,33 @@ describe('refreshAccessToken', () => {
|
|||||||
const result = await refreshAccessToken(env, 'old-refresh')
|
const result = await refreshAccessToken(env, 'old-refresh')
|
||||||
expect(result.access_token).toBe('new-at')
|
expect(result.access_token).toBe('new-at')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('throws GoogleTokenRefreshError carrying status and body on non-OK response', async () => {
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||||
|
new Response('{"error":"invalid_grant"}', { status: 400 })
|
||||||
|
)
|
||||||
|
const env = getOAuthEnv('http://localhost:3000')
|
||||||
|
const err = await refreshAccessToken(env, 'dead-refresh').catch((e) => e)
|
||||||
|
expect(err).toBeInstanceOf(GoogleTokenRefreshError)
|
||||||
|
expect(err.status).toBe(400)
|
||||||
|
expect(err.body).toContain('invalid_grant')
|
||||||
|
expect(err.message).toContain('400')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GoogleTokenRefreshError.isInvalidGrant', () => {
|
||||||
|
it('is true only for a 400 whose body mentions invalid_grant', () => {
|
||||||
|
expect(
|
||||||
|
new GoogleTokenRefreshError(400, '{"error":"invalid_grant"}').isInvalidGrant
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
new GoogleTokenRefreshError(400, '{"error":"invalid_request"}').isInvalidGrant
|
||||||
|
).toBe(false)
|
||||||
|
expect(
|
||||||
|
new GoogleTokenRefreshError(500, '{"error":"invalid_grant"}').isInvalidGrant
|
||||||
|
).toBe(false)
|
||||||
|
expect(new GoogleTokenRefreshError(503, 'Service Unavailable').isInvalidGrant).toBe(
|
||||||
|
false
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@/lib/reports/full-archive-export', () => ({
|
||||||
|
estimateArchiveSize: vi.fn(),
|
||||||
|
generateFullArchive: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../google-oauth', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../google-oauth')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getOAuthEnv: vi.fn().mockReturnValue({
|
||||||
|
clientId: 'client-id',
|
||||||
|
clientSecret: 'client-secret',
|
||||||
|
redirectUri: 'https://app.test/callback',
|
||||||
|
}),
|
||||||
|
refreshAccessToken: vi.fn(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('../google-drive', () => ({
|
||||||
|
ensureFolder: vi.fn(),
|
||||||
|
uploadFile: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../crypto', () => ({
|
||||||
|
decryptToken: vi.fn().mockReturnValue('plain-refresh-token'),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { performSync, CONNECTION_KEY, LAST_SYNC_KEY } from '../sync'
|
||||||
|
import { GoogleTokenRefreshError, refreshAccessToken } from '../google-oauth'
|
||||||
|
import { ensureFolder, uploadFile } from '../google-drive'
|
||||||
|
import {
|
||||||
|
estimateArchiveSize,
|
||||||
|
generateFullArchive,
|
||||||
|
} from '@/lib/reports/full-archive-export'
|
||||||
|
import type { GoogleDriveConnection } from '../../types'
|
||||||
|
|
||||||
|
const mockRefreshAccessToken = vi.mocked(refreshAccessToken)
|
||||||
|
const mockEnsureFolder = vi.mocked(ensureFolder)
|
||||||
|
const mockUploadFile = vi.mocked(uploadFile)
|
||||||
|
const mockEstimateArchiveSize = vi.mocked(estimateArchiveSize)
|
||||||
|
const mockGenerateFullArchive = vi.mocked(generateFullArchive)
|
||||||
|
|
||||||
|
function makeConnection(
|
||||||
|
overrides: Partial<GoogleDriveConnection> = {}
|
||||||
|
): GoogleDriveConnection {
|
||||||
|
return {
|
||||||
|
refresh_token_encrypted: 'encrypted-token',
|
||||||
|
account_email: 'user@example.com',
|
||||||
|
connected_at: '2026-01-01T00:00:00.000Z',
|
||||||
|
root_folder_id: 'root-1',
|
||||||
|
company_folder_id: 'company-1',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal supabase stub covering what performSync touches:
|
||||||
|
* - extension_data select ... maybeSingle() (connection load)
|
||||||
|
* - extension_data upsert (connection/last-sync save)
|
||||||
|
* - company_settings select ... maybeSingle() (folder label)
|
||||||
|
*/
|
||||||
|
function makeSupabase(connection: GoogleDriveConnection | null) {
|
||||||
|
const upsert = vi.fn().mockResolvedValue({ error: null })
|
||||||
|
const from = vi.fn().mockImplementation((table: string) => {
|
||||||
|
const chain: any = {
|
||||||
|
select: vi.fn().mockReturnThis(),
|
||||||
|
eq: vi.fn().mockReturnThis(),
|
||||||
|
maybeSingle: vi.fn().mockResolvedValue({
|
||||||
|
data:
|
||||||
|
table === 'extension_data'
|
||||||
|
? connection
|
||||||
|
? { value: connection }
|
||||||
|
: null
|
||||||
|
: { company_name: 'Testbolag AB', org_number: '556000-0000' },
|
||||||
|
}),
|
||||||
|
upsert,
|
||||||
|
}
|
||||||
|
return chain
|
||||||
|
})
|
||||||
|
return { supabase: { from } as any, upsert }
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncParams(supabase: any) {
|
||||||
|
return {
|
||||||
|
supabase,
|
||||||
|
companyId: 'company-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
origin: 'https://app.test',
|
||||||
|
includeDocuments: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockEstimateArchiveSize.mockResolvedValue({
|
||||||
|
total_bytes: 1_000,
|
||||||
|
document_bytes: 100,
|
||||||
|
} as any)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('performSync needs_reauth handling', () => {
|
||||||
|
it('flags the connection needs_reauth when Google returns 400 invalid_grant', async () => {
|
||||||
|
const { supabase, upsert } = makeSupabase(makeConnection())
|
||||||
|
mockRefreshAccessToken.mockRejectedValueOnce(
|
||||||
|
new GoogleTokenRefreshError(
|
||||||
|
400,
|
||||||
|
'{"error":"invalid_grant","error_description":"Token has been expired or revoked."}'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await performSync(syncParams(supabase))
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ ok: false, reason: 'needs_reauth' })
|
||||||
|
expect(upsert).toHaveBeenCalledTimes(1)
|
||||||
|
const [payload] = upsert.mock.calls[0]
|
||||||
|
expect(payload.key).toBe(CONNECTION_KEY)
|
||||||
|
expect(payload.value.status).toBe('needs_reauth')
|
||||||
|
expect(payload.value.needs_reauth_at).toEqual(expect.any(String))
|
||||||
|
// Token, email etc. stay intact so the UI can still show the account.
|
||||||
|
expect(payload.value.account_email).toBe('user@example.com')
|
||||||
|
expect(mockGenerateFullArchive).not.toHaveBeenCalled()
|
||||||
|
expect(mockUploadFile).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rethrows transient refresh failures (5xx) without flagging', async () => {
|
||||||
|
const { supabase, upsert } = makeSupabase(makeConnection())
|
||||||
|
mockRefreshAccessToken.mockRejectedValueOnce(
|
||||||
|
new GoogleTokenRefreshError(500, 'Internal Server Error')
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(performSync(syncParams(supabase))).rejects.toThrow(/500/)
|
||||||
|
expect(upsert).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rethrows a 400 that is not invalid_grant without flagging', async () => {
|
||||||
|
const { supabase, upsert } = makeSupabase(makeConnection())
|
||||||
|
mockRefreshAccessToken.mockRejectedValueOnce(
|
||||||
|
new GoogleTokenRefreshError(400, '{"error":"invalid_request"}')
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(performSync(syncParams(supabase))).rejects.toThrow(/400/)
|
||||||
|
expect(upsert).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rethrows non-refresh errors untouched', async () => {
|
||||||
|
const { supabase, upsert } = makeSupabase(makeConnection())
|
||||||
|
mockRefreshAccessToken.mockRejectedValueOnce(new Error('network down'))
|
||||||
|
|
||||||
|
await expect(performSync(syncParams(supabase))).rejects.toThrow('network down')
|
||||||
|
expect(upsert).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears a stale needs_reauth flag after a successful refresh', async () => {
|
||||||
|
const { supabase, upsert } = makeSupabase(
|
||||||
|
makeConnection({
|
||||||
|
status: 'needs_reauth',
|
||||||
|
needs_reauth_at: '2026-07-01T03:00:00.000Z',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
mockRefreshAccessToken.mockResolvedValueOnce({
|
||||||
|
access_token: 'fresh-token',
|
||||||
|
expires_in: 3600,
|
||||||
|
})
|
||||||
|
mockGenerateFullArchive.mockResolvedValueOnce(new ArrayBuffer(8))
|
||||||
|
mockUploadFile.mockResolvedValueOnce({
|
||||||
|
id: 'file-1',
|
||||||
|
name: 'arkiv.zip',
|
||||||
|
size_bytes: 8,
|
||||||
|
web_view_link: 'https://drive.google.com/file/d/file-1/view',
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
const result = await performSync(syncParams(supabase))
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true)
|
||||||
|
// First upsert rewrites the connection with the flag cleared.
|
||||||
|
const connectionSave = upsert.mock.calls.find(
|
||||||
|
([payload]) => payload.key === CONNECTION_KEY
|
||||||
|
)
|
||||||
|
expect(connectionSave).toBeDefined()
|
||||||
|
expect(connectionSave![0].value.status).toBe('active')
|
||||||
|
// Last-sync state is still persisted as usual.
|
||||||
|
const lastSyncSave = upsert.mock.calls.find(
|
||||||
|
([payload]) => payload.key === LAST_SYNC_KEY
|
||||||
|
)
|
||||||
|
expect(lastSyncSave).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still returns not_connected when no connection exists', async () => {
|
||||||
|
const { supabase } = makeSupabase(null)
|
||||||
|
|
||||||
|
const result = await performSync(syncParams(supabase))
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ ok: false, reason: 'not_connected' })
|
||||||
|
expect(mockRefreshAccessToken).not.toHaveBeenCalled()
|
||||||
|
expect(mockEnsureFolder).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -99,6 +99,32 @@ export interface AccessTokenResult {
|
|||||||
expires_in: number
|
expires_in: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when Google's token endpoint rejects a refresh attempt. Carries the
|
||||||
|
* HTTP status and raw response body so callers can distinguish a permanently
|
||||||
|
* dead refresh token (400 invalid_grant) from transient failures.
|
||||||
|
*/
|
||||||
|
export class GoogleTokenRefreshError extends Error {
|
||||||
|
readonly status: number
|
||||||
|
readonly body: string
|
||||||
|
|
||||||
|
constructor(status: number, body: string) {
|
||||||
|
super(`Google token refresh failed: ${status} ${body}`)
|
||||||
|
this.name = 'GoogleTokenRefreshError'
|
||||||
|
this.status = status
|
||||||
|
this.body = body
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when Google reports the refresh token itself is dead (revoked,
|
||||||
|
* expired, or the grant was invalidated). Retrying will never succeed;
|
||||||
|
* the user must re-consent.
|
||||||
|
*/
|
||||||
|
get isInvalidGrant(): boolean {
|
||||||
|
return this.status === 400 && this.body.includes('invalid_grant')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function refreshAccessToken(
|
export async function refreshAccessToken(
|
||||||
env: OAuthEnv,
|
env: OAuthEnv,
|
||||||
refreshToken: string
|
refreshToken: string
|
||||||
@@ -120,7 +146,7 @@ export async function refreshAccessToken(
|
|||||||
)
|
)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errText = await res.text()
|
const errText = await res.text()
|
||||||
throw new Error(`Google token refresh failed: ${res.status} ${errText}`)
|
throw new GoogleTokenRefreshError(res.status, errText)
|
||||||
}
|
}
|
||||||
return (await res.json()) as AccessTokenResult
|
return (await res.json()) as AccessTokenResult
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
getOAuthEnv,
|
getOAuthEnv,
|
||||||
refreshAccessToken,
|
refreshAccessToken,
|
||||||
|
GoogleTokenRefreshError,
|
||||||
} from './google-oauth'
|
} from './google-oauth'
|
||||||
import { ensureFolder, uploadFile } from './google-drive'
|
import { ensureFolder, uploadFile } from './google-drive'
|
||||||
import { decryptToken } from './crypto'
|
import { decryptToken } from './crypto'
|
||||||
@@ -19,6 +20,7 @@ export const SIZE_LIMIT_BYTES = 80 * 1024 * 1024
|
|||||||
|
|
||||||
export type SyncFailureReason =
|
export type SyncFailureReason =
|
||||||
| 'not_connected'
|
| 'not_connected'
|
||||||
|
| 'needs_reauth'
|
||||||
| 'archive_too_large'
|
| 'archive_too_large'
|
||||||
| 'upload_failed'
|
| 'upload_failed'
|
||||||
| 'internal'
|
| 'internal'
|
||||||
@@ -81,7 +83,30 @@ export async function performSync(params: PerformSyncParams): Promise<PerformSyn
|
|||||||
|
|
||||||
const env = getOAuthEnv(origin)
|
const env = getOAuthEnv(origin)
|
||||||
const refreshToken = decryptToken(connection.refresh_token_encrypted)
|
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 rootFolderId = connection.root_folder_id
|
||||||
let companyFolderId = connection.company_folder_id
|
let companyFolderId = connection.company_folder_id
|
||||||
@@ -96,12 +121,16 @@ export async function performSync(params: PerformSyncParams): Promise<PerformSyn
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
rootFolderId !== connection.root_folder_id ||
|
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, {
|
await saveExtensionData(supabase, companyId, userId, CONNECTION_KEY, {
|
||||||
...connection,
|
...connection,
|
||||||
root_folder_id: rootFolderId,
|
root_folder_id: rootFolderId,
|
||||||
company_folder_id: companyFolderId,
|
company_folder_id: companyFolderId,
|
||||||
|
status: 'active',
|
||||||
|
needs_reauth_at: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ export interface GoogleDriveConnection {
|
|||||||
root_folder_id: string | null
|
root_folder_id: string | null
|
||||||
/** ID of the per-company subfolder. */
|
/** ID of the per-company subfolder. */
|
||||||
company_folder_id: string | null
|
company_folder_id: string | null
|
||||||
|
/**
|
||||||
|
* Connection health. `needs_reauth` means Google rejected the refresh
|
||||||
|
* token permanently (400 invalid_grant): the cron skips the connection
|
||||||
|
* and the UI asks the user to reconnect. Absent/undefined means active
|
||||||
|
* (records created before this field existed).
|
||||||
|
*/
|
||||||
|
status?: 'active' | 'needs_reauth'
|
||||||
|
/** ISO timestamp of when the dead refresh token was detected. */
|
||||||
|
needs_reauth_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,6 +56,8 @@ export interface GoogleDriveSchedule {
|
|||||||
*/
|
*/
|
||||||
export interface CloudBackupStatus {
|
export interface CloudBackupStatus {
|
||||||
connected: boolean
|
connected: boolean
|
||||||
|
/** True when the stored Google refresh token is dead and the user must reconnect. */
|
||||||
|
needs_reauth: boolean
|
||||||
account_email: string | null
|
account_email: string | null
|
||||||
connected_at: string | null
|
connected_at: string | null
|
||||||
last_sync: GoogleDriveLastSync | null
|
last_sync: GoogleDriveLastSync | null
|
||||||
|
|||||||
@@ -3743,6 +3743,10 @@
|
|||||||
"ext_cloud_backup_name": "Cloud sync",
|
"ext_cloud_backup_name": "Cloud sync",
|
||||||
"ext_cloud_backup_description": "Sync backups to your own cloud storage",
|
"ext_cloud_backup_description": "Sync backups to your own cloud storage",
|
||||||
"ext_cloud_backup_long_description": "Connect your Google Drive account and upload a full backup with one click. Accounted creates a ZIP with SIE files, receipts and processing history and uploads it to a dedicated folder in your Drive.",
|
"ext_cloud_backup_long_description": "Connect your Google Drive account and upload a full backup with one click. Accounted creates a ZIP with SIE files, receipts and processing history and uploads it to a dedicated folder in your Drive.",
|
||||||
|
"ext_cloud_backup_reauth_title": "Google Drive needs to be reconnected",
|
||||||
|
"ext_cloud_backup_reauth_description": "Access to your Google account has expired or been revoked. Automatic sync is paused until you reconnect the account.",
|
||||||
|
"ext_cloud_backup_reauth_action": "Reconnect Google Drive",
|
||||||
|
"ext_cloud_backup_reauth_needed_short": "reconnect required",
|
||||||
"ext_skatteverket_name": "Skatteverket integration",
|
"ext_skatteverket_name": "Skatteverket integration",
|
||||||
"ext_skatteverket_description": "Submit the VAT declaration directly to Skatteverket via BankID.",
|
"ext_skatteverket_description": "Submit the VAT declaration directly to Skatteverket via BankID.",
|
||||||
"ext_skatteverket_long_description": "Connect to Skatteverket with BankID and submit your VAT declaration directly from accounted. Save drafts, validate, lock and sign: without leaving the app.",
|
"ext_skatteverket_long_description": "Connect to Skatteverket with BankID and submit your VAT declaration directly from accounted. Save drafts, validate, lock and sign: without leaving the app.",
|
||||||
|
|||||||
@@ -3743,6 +3743,10 @@
|
|||||||
"ext_cloud_backup_name": "Molnsynkronisering",
|
"ext_cloud_backup_name": "Molnsynkronisering",
|
||||||
"ext_cloud_backup_description": "Synka säkerhetsbackup till din egen molnlagring",
|
"ext_cloud_backup_description": "Synka säkerhetsbackup till din egen molnlagring",
|
||||||
"ext_cloud_backup_long_description": "Koppla ditt Google Drive-konto och ladda upp en fullständig säkerhetsbackup med ett klick. Accounted skapar en ZIP med SIE-filer, kvitton och behandlingshistorik och laddar upp till en egen mapp i din Drive.",
|
"ext_cloud_backup_long_description": "Koppla ditt Google Drive-konto och ladda upp en fullständig säkerhetsbackup med ett klick. Accounted skapar en ZIP med SIE-filer, kvitton och behandlingshistorik och laddar upp till en egen mapp i din Drive.",
|
||||||
|
"ext_cloud_backup_reauth_title": "Google Drive behöver kopplas om",
|
||||||
|
"ext_cloud_backup_reauth_description": "Åtkomsten till ditt Google-konto har gått ut eller återkallats. Automatisk synkronisering är pausad tills du kopplar om kontot.",
|
||||||
|
"ext_cloud_backup_reauth_action": "Koppla om Google Drive",
|
||||||
|
"ext_cloud_backup_reauth_needed_short": "kräver omkoppling",
|
||||||
"ext_skatteverket_name": "Skatteverket Integration",
|
"ext_skatteverket_name": "Skatteverket Integration",
|
||||||
"ext_skatteverket_description": "Skicka momsdeklaration direkt till Skatteverket via BankID.",
|
"ext_skatteverket_description": "Skicka momsdeklaration direkt till Skatteverket via BankID.",
|
||||||
"ext_skatteverket_long_description": "Anslut till Skatteverket med BankID och skicka din momsdeklaration direkt från accounted. Spara utkast, validera, lås och signera: utan att lämna appen.",
|
"ext_skatteverket_long_description": "Anslut till Skatteverket med BankID och skicka din momsdeklaration direkt från accounted. Spara utkast, validera, lås och signera: utan att lämna appen.",
|
||||||
|
|||||||
Reference in New Issue
Block a user