feat(connect): connector status + Synka nu row in Settings -> Abonnemang (PR6b-3) (#2104)
* feat(connect): connector status + Synka nu row in Settings -> Abonnemang (PR6b-3) Self-host only: shows per-upstream connector mode, key prefix, and the active company's granted capabilities from GET /api/connector/status, plus a manual run of the entitlement sync via the new authed POST /api/connector/sync (requireWrite, 60s cooldown) instead of waiting for the hourly cron. Hidden on hosted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KAUKbGUGtAmRhpDKfn84F5 * fix(connect): handle sync fetch rejection, count capabilities not rows, not_configured toast Skeptic findings on the Synka nu flow: a rejected fetch (instance restarting) was a silent no-op with an unhandled rejection; the success toast printed grant rows (companies x scopes) as capabilities; a not_configured outcome claimed the hosted service was unreachable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KAUKbGUGtAmRhpDKfn84F5 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
aabddb592f
commit
ee22c9c7b7
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
import { resetManualSyncThrottle } from '@/lib/connect/instance/manual-sync-throttle'
|
||||
|
||||
let selfHosted = true
|
||||
vi.mock('@/lib/env/public-flags', () => ({ isSelfHosted: () => selfHosted }))
|
||||
|
||||
const captured = vi.hoisted(() => ({ options: undefined as { requireWrite?: boolean } | undefined }))
|
||||
vi.mock('@/lib/api/with-route-context', () => ({
|
||||
withRouteContext: (
|
||||
_op: string,
|
||||
handler: (req: unknown, ctx: unknown) => unknown,
|
||||
options?: { requireWrite?: boolean },
|
||||
) => {
|
||||
captured.options = options
|
||||
return (req: unknown) =>
|
||||
handler(req, {
|
||||
supabase: {},
|
||||
companyId: 'company-1',
|
||||
user: { id: 'u1' },
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
const syncMock = vi.fn()
|
||||
vi.mock('@/lib/connect/instance/sync', () => ({
|
||||
syncConnectorEntitlements: (...args: unknown[]) => syncMock(...args),
|
||||
}))
|
||||
vi.mock('@/lib/supabase/server', () => ({ createServiceClient: () => ({ __service: true }) }))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
const run = () => POST(createMockRequest('/api/connector/sync', { method: 'POST' }), { params: Promise.resolve({}) })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetManualSyncThrottle()
|
||||
selfHosted = true
|
||||
vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_secretsecret')
|
||||
syncMock.mockResolvedValue({ outcome: 'synced', companies: 2, grantsUpserted: 4, grantsDeleted: 0, scopes: ['bank_sync'] })
|
||||
})
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
|
||||
describe('POST /api/connector/sync', () => {
|
||||
it('requires a non-viewer role', () => {
|
||||
expect(captured.options).toEqual({ requireWrite: true })
|
||||
})
|
||||
|
||||
it('answers not_configured on hosted without running the sync', async () => {
|
||||
selfHosted = false
|
||||
const { status, body } = await parseJsonResponse<{ data: { outcome: string } }>(await run())
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.outcome).toBe('not_configured')
|
||||
expect(syncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('answers not_configured when no connector key is set', async () => {
|
||||
vi.stubEnv('GNUBOK_CONNECTOR_KEY', '')
|
||||
const { body } = await parseJsonResponse<{ data: { outcome: string } }>(await run())
|
||||
expect(body.data.outcome).toBe('not_configured')
|
||||
expect(syncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs the entitlement sync with the service client and returns the result', async () => {
|
||||
const { status, body } = await parseJsonResponse<{ data: { outcome: string; grantsUpserted: number } }>(await run())
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.outcome).toBe('synced')
|
||||
expect(body.data.grantsUpserted).toBe(4)
|
||||
expect(syncMock).toHaveBeenCalledTimes(1)
|
||||
expect(syncMock.mock.calls[0][0]).toEqual({ __service: true })
|
||||
expect(Object.keys(syncMock.mock.calls[0][1] as object)).toEqual(
|
||||
expect.arrayContaining(['instanceUrl', 'appVersion']),
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a second run inside the cooldown window', async () => {
|
||||
await run()
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await run())
|
||||
expect(status).toBe(429)
|
||||
expect(body.error.code).toBe('CONNECTOR_SYNC_COOLDOWN')
|
||||
expect(syncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the cooldown even when the sync throws', async () => {
|
||||
syncMock.mockRejectedValueOnce(new Error('db down'))
|
||||
await expect(run()).rejects.toThrow('db down')
|
||||
const { status } = await parseJsonResponse<{ error: { code: string } }>(await run())
|
||||
expect(status).toBe(429)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { isSelfHosted } from '@/lib/env/public-flags'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { getConnectorConfig } from '@/lib/connect/instance/config'
|
||||
import { syncConnectorEntitlements } from '@/lib/connect/instance/sync'
|
||||
import {
|
||||
endManualSync,
|
||||
tryBeginManualSync,
|
||||
} from '@/lib/connect/instance/manual-sync-throttle'
|
||||
|
||||
/**
|
||||
* POST /api/connector/sync: operator-triggered run of the same entitlement
|
||||
* sync the hourly self-hosted cron performs (/api/connector/sync/cron),
|
||||
* for the "Synka nu" row in Settings -> Abonnemang. Runs with the service
|
||||
* client because grants are instance-wide (every company on the instance),
|
||||
* so the caller must hold a non-viewer role (requireWrite) and runs are
|
||||
* cooldown-gated: each one reports to the hosted entitlements endpoint.
|
||||
*
|
||||
* Hosted (or an instance without a key) answers 200 not_configured rather
|
||||
* than an error: the settings row is hidden there and this is the backstop.
|
||||
*/
|
||||
export const maxDuration = 60
|
||||
|
||||
export const POST = withRouteContext(
|
||||
'connector.sync',
|
||||
async (_request, { log }) => {
|
||||
if (!isSelfHosted() || !getConnectorConfig()) {
|
||||
return NextResponse.json({ data: { outcome: 'not_configured' } })
|
||||
}
|
||||
if (!tryBeginManualSync()) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'CONNECTOR_SYNC_COOLDOWN',
|
||||
message: 'En synkronisering kördes nyss. Vänta en minut och försök igen.',
|
||||
message_en: 'A sync just ran. Wait a minute and try again.',
|
||||
},
|
||||
},
|
||||
{ status: 429, headers: { 'Retry-After': '60' } },
|
||||
)
|
||||
}
|
||||
try {
|
||||
const result = await syncConnectorEntitlements(createServiceClient(), {
|
||||
instanceUrl: process.env.NEXT_PUBLIC_APP_URL?.trim() || null,
|
||||
appVersion: process.env.npm_package_version ?? null,
|
||||
})
|
||||
log.info('manual connector sync run', { ...result })
|
||||
return NextResponse.json({ data: result })
|
||||
} finally {
|
||||
endManualSync()
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -0,0 +1,212 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { Loader2, RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import {
|
||||
SettingsGroup,
|
||||
SettingsRow,
|
||||
SettingsRowEnd,
|
||||
SettingsRowNote,
|
||||
} from '@/components/settings/SettingsRows'
|
||||
import { isSelfHosted } from '@/lib/env/public-flags'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
|
||||
type UpstreamMode = 'connector' | 'own_credentials' | 'unconfigured'
|
||||
|
||||
interface ConnectorStatus {
|
||||
configured: boolean
|
||||
key_prefix: string | null
|
||||
upstreams: { bank: UpstreamMode; skatteverket: UpstreamMode }
|
||||
granted_capabilities: string[]
|
||||
}
|
||||
|
||||
interface SyncResult {
|
||||
outcome: 'not_configured' | 'synced' | 'revoked' | 'network_error' | 'server_error'
|
||||
grantsUpserted: number
|
||||
scopes?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings -> Abonnemang, self-host only: is the connector wired, per
|
||||
* upstream, and a "Synka nu" that runs the entitlement sync on demand
|
||||
* instead of waiting for the hourly cron (PR6b-3). Renders nothing on
|
||||
* hosted; the status endpoint's self_hosted:false answer is the backstop.
|
||||
*
|
||||
* The sync covers every company on the instance; the capability list shown
|
||||
* is the active company's (same as the status endpoint reports). That
|
||||
* asymmetry lives in the group help, not in the row copy.
|
||||
*/
|
||||
export function ConnectorSettingsSection() {
|
||||
const t = useTranslations('settings_billing')
|
||||
const errorLocale = useLocale() as ErrorLocale
|
||||
const { toast } = useToast()
|
||||
const [status, setStatus] = useState<ConnectorStatus | null>(null)
|
||||
const [loadFailed, setLoadFailed] = useState(false)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
|
||||
const selfHosted = isSelfHosted()
|
||||
|
||||
useEffect(() => {
|
||||
if (!selfHosted) return
|
||||
let active = true
|
||||
setLoadFailed(false)
|
||||
fetch('/api/connector/status')
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`status ${res.status}`)
|
||||
const body = (await res.json()) as { data?: { self_hosted?: boolean } & ConnectorStatus }
|
||||
if (!active) return
|
||||
if (!body.data || body.data.self_hosted !== true) {
|
||||
setStatus(null)
|
||||
return
|
||||
}
|
||||
setStatus(body.data)
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setLoadFailed(true)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [selfHosted, reloadKey])
|
||||
|
||||
const runSync = useCallback(async () => {
|
||||
setSyncing(true)
|
||||
try {
|
||||
const res = await fetch('/api/connector/sync', { method: 'POST' })
|
||||
const body = (await res.json().catch(() => null)) as
|
||||
| { data?: SyncResult; error?: unknown }
|
||||
| null
|
||||
if (!res.ok || !body?.data) {
|
||||
toast({
|
||||
title: t('connector_sync_failed'),
|
||||
description: getErrorMessage(body, { statusCode: res.status, locale: errorLocale }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
const result = body.data
|
||||
if (result.outcome === 'synced') {
|
||||
// Count capabilities, not grant rows: grantsUpserted is
|
||||
// companies x scopes and reads absurd on a multi-company instance.
|
||||
toast({
|
||||
title: t('connector_sync_done'),
|
||||
description:
|
||||
(result.scopes?.length ?? 0) > 0
|
||||
? t('connector_sync_done_desc', { count: result.scopes?.length ?? 0 })
|
||||
: t('connector_sync_done_empty'),
|
||||
})
|
||||
} else if (result.outcome === 'not_configured') {
|
||||
// The key vanished after the page loaded (operator reconfigured):
|
||||
// saying the hosted service was unreachable would be false.
|
||||
toast({
|
||||
title: t('connector_status_unconfigured'),
|
||||
description: t('connector_status_unconfigured_note'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else if (result.outcome === 'revoked') {
|
||||
// The grants were just deleted: this must not read as a generic error.
|
||||
toast({
|
||||
title: t('connector_sync_failed'),
|
||||
description: t('connector_sync_revoked_desc'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: t('connector_sync_failed'),
|
||||
description: t('connector_sync_unreachable_desc'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
setReloadKey((k) => k + 1)
|
||||
} catch {
|
||||
// fetch itself rejected (instance restarting, network drop): without
|
||||
// this the click is a silent no-op and the rejection goes unhandled.
|
||||
toast({
|
||||
title: t('connector_sync_failed'),
|
||||
description: t('connector_sync_request_failed_desc'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}, [t, errorLocale, toast])
|
||||
|
||||
if (!selfHosted) return null
|
||||
if (!status && !loadFailed) return null
|
||||
|
||||
const modeLabel: Record<UpstreamMode, string> = {
|
||||
connector: t('connector_mode_connector'),
|
||||
own_credentials: t('connector_mode_own'),
|
||||
unconfigured: t('connector_mode_unconfigured'),
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsGroup label={t('connector_group')} help={t('connector_help')}>
|
||||
{loadFailed || !status ? (
|
||||
<SettingsRow label={t('connector_row_status')} borderless>
|
||||
<SettingsRowNote>{t('connector_load_failed')}</SettingsRowNote>
|
||||
<SettingsRowEnd>
|
||||
<Button variant="outline" size="sm" onClick={() => setReloadKey((k) => k + 1)}>
|
||||
{t('load_retry')}
|
||||
</Button>
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
) : (
|
||||
<>
|
||||
<SettingsRow label={t('connector_row_status')}>
|
||||
{status.configured ? (
|
||||
<>
|
||||
<span className="font-medium">{t('connector_status_configured')}</span>
|
||||
{status.key_prefix ? (
|
||||
<span className="font-mono text-xs text-muted-foreground">{status.key_prefix}…</span>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>{t('connector_status_unconfigured')}</span>
|
||||
<SettingsRowNote>{t('connector_status_unconfigured_note')}</SettingsRowNote>
|
||||
</>
|
||||
)}
|
||||
{status.configured ? (
|
||||
<SettingsRowEnd>
|
||||
<Button variant="outline" size="sm" disabled={syncing} onClick={runSync}>
|
||||
{syncing ? (
|
||||
<Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>{syncing ? t('connector_sync_button_busy') : t('connector_sync_button')}</span>
|
||||
</Button>
|
||||
</SettingsRowEnd>
|
||||
) : null}
|
||||
</SettingsRow>
|
||||
<SettingsRow label={t('connector_row_bank')}>
|
||||
<span className={status.upstreams.bank === 'unconfigured' ? 'text-muted-foreground' : undefined}>
|
||||
{modeLabel[status.upstreams.bank]}
|
||||
</span>
|
||||
</SettingsRow>
|
||||
<SettingsRow label={t('connector_row_skv')}>
|
||||
<span className={status.upstreams.skatteverket === 'unconfigured' ? 'text-muted-foreground' : undefined}>
|
||||
{modeLabel[status.upstreams.skatteverket]}
|
||||
</span>
|
||||
</SettingsRow>
|
||||
<SettingsRow label={t('connector_row_capabilities')} borderless>
|
||||
{status.granted_capabilities.length > 0 ? (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{status.granted_capabilities.join(', ')}
|
||||
</span>
|
||||
) : (
|
||||
<SettingsRowNote>
|
||||
{status.configured ? t('connector_caps_none') : t('connector_caps_unconfigured')}
|
||||
</SettingsRowNote>
|
||||
)}
|
||||
</SettingsRow>
|
||||
</>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { cn, formatCurrency } from '@/lib/utils'
|
||||
import { useFormat } from '@/lib/hooks/use-format'
|
||||
import { BillingActions } from '@/components/settings/BillingActions'
|
||||
import { ConnectorSettingsSection } from '@/components/settings/ConnectorSettingsSection'
|
||||
import { PLAN_PRICES } from '@/components/settings/billing-plans'
|
||||
import type { BillingPlan } from '@/lib/stripe/client'
|
||||
import { useBranding } from '@/lib/branding/brand-context'
|
||||
@@ -74,6 +75,18 @@ function UnlockList({ className }: { className?: string }) {
|
||||
* made, instead of repeated as reassurance copy around the page.
|
||||
*/
|
||||
export function BillingSettingsContent() {
|
||||
return (
|
||||
<>
|
||||
<BillingCoreContent />
|
||||
{/* Self-host only (renders null on hosted): connector status + manual
|
||||
entitlement sync. Sits below the billing states, which is why the
|
||||
core content is split out: it has several early returns. */}
|
||||
<ConnectorSettingsSection />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function BillingCoreContent() {
|
||||
const tNav = useTranslations('settings_nav')
|
||||
const tIntro = useTranslations('settings_intro')
|
||||
const t = useTranslations('settings_billing')
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Cooldown for the operator-triggered connector sync (POST /api/connector/sync).
|
||||
* Every run POSTs the instance's company count to the hosted entitlements
|
||||
* endpoint, so a click storm in the settings panel must not turn into a
|
||||
* request storm against the hosted service. Module-level state is enough:
|
||||
* self-hosted runs as one long-lived process, and the hourly cron is
|
||||
* unaffected (it never goes through this gate).
|
||||
*/
|
||||
export const MANUAL_SYNC_COOLDOWN_MS = 60_000
|
||||
|
||||
let inFlight = false
|
||||
let lastStartedAt = 0
|
||||
|
||||
/** Claims the sync slot; false while a sync runs or the cooldown holds. */
|
||||
export function tryBeginManualSync(now: number = Date.now()): boolean {
|
||||
if (inFlight || now - lastStartedAt < MANUAL_SYNC_COOLDOWN_MS) return false
|
||||
inFlight = true
|
||||
lastStartedAt = now
|
||||
return true
|
||||
}
|
||||
|
||||
export function endManualSync(): void {
|
||||
inFlight = false
|
||||
}
|
||||
|
||||
/** Test hook: clears the in-flight flag and the cooldown window. */
|
||||
export function resetManualSyncThrottle(): void {
|
||||
inFlight = false
|
||||
lastStartedAt = 0
|
||||
}
|
||||
+25
-1
@@ -2274,7 +2274,31 @@
|
||||
"cta_coming_soon": "Upgrades open soon",
|
||||
"cta_opening": "Opening…",
|
||||
"cta_start_deferred": "Start the subscription: 0 kr today",
|
||||
"cta_start_now": "Start the subscription: {price}/{period}"
|
||||
"cta_start_now": "Start the subscription: {price}/{period}",
|
||||
"connector_group": "Connector",
|
||||
"connector_help": "The connector links your self-hosted instance to the hosted service for bank and Skatteverket. Syncing refreshes the capabilities for every company on the instance; the list below shows the active company's. Left alone, they sync automatically every hour.",
|
||||
"connector_row_status": "Status",
|
||||
"connector_status_configured": "Connected",
|
||||
"connector_status_unconfigured": "No connector key",
|
||||
"connector_status_unconfigured_note": "Set GNUBOK_CONNECTOR_KEY in the environment and restart the instance.",
|
||||
"connector_row_bank": "Bank",
|
||||
"connector_row_skv": "Skatteverket",
|
||||
"connector_mode_connector": "Via connector",
|
||||
"connector_mode_own": "Own credentials",
|
||||
"connector_mode_unconfigured": "Not configured",
|
||||
"connector_row_capabilities": "Capabilities",
|
||||
"connector_caps_none": "No active capabilities yet. Sync to fetch them.",
|
||||
"connector_caps_unconfigured": "Activates once a connector key is configured.",
|
||||
"connector_sync_button": "Sync now",
|
||||
"connector_sync_button_busy": "Syncing",
|
||||
"connector_sync_done": "Sync complete",
|
||||
"connector_sync_done_desc": "{count, plural, =1 {1 capability updated} other {# capabilities updated}}.",
|
||||
"connector_sync_done_empty": "The key is valid but the subscription grants no capabilities right now.",
|
||||
"connector_sync_failed": "Sync failed",
|
||||
"connector_sync_revoked_desc": "The key was rejected by the hosted service and the connector capabilities were removed. Check the subscription or the key.",
|
||||
"connector_sync_unreachable_desc": "The hosted service could not be reached. Existing capabilities are kept and remain valid until they expire.",
|
||||
"connector_sync_request_failed_desc": "The request failed. Check that the instance is running and try again.",
|
||||
"connector_load_failed": "Could not read connector status."
|
||||
},
|
||||
"settings_invoice_form": {
|
||||
"default_our_reference_label": "Default \"Our reference\"",
|
||||
|
||||
+25
-1
@@ -2274,7 +2274,31 @@
|
||||
"cta_coming_soon": "Uppgradering öppnar snart",
|
||||
"cta_opening": "Öppnar…",
|
||||
"cta_start_deferred": "Starta abonnemanget: 0 kr idag",
|
||||
"cta_start_now": "Starta abonnemanget: {price}/{period}"
|
||||
"cta_start_now": "Starta abonnemanget: {price}/{period}",
|
||||
"connector_group": "Connector",
|
||||
"connector_help": "Connectorn kopplar din självhostade instans till värdtjänsten för bank och Skatteverket. Synkroniseringen uppdaterar behörigheterna för alla företag på instansen; listan nedan visar det aktiva företagets. Utan åtgärd synkas de automatiskt varje timme.",
|
||||
"connector_row_status": "Status",
|
||||
"connector_status_configured": "Ansluten",
|
||||
"connector_status_unconfigured": "Ingen connector-nyckel",
|
||||
"connector_status_unconfigured_note": "Sätt GNUBOK_CONNECTOR_KEY i miljön och starta om instansen.",
|
||||
"connector_row_bank": "Bank",
|
||||
"connector_row_skv": "Skatteverket",
|
||||
"connector_mode_connector": "Via connector",
|
||||
"connector_mode_own": "Egna uppgifter",
|
||||
"connector_mode_unconfigured": "Ej konfigurerad",
|
||||
"connector_row_capabilities": "Behörigheter",
|
||||
"connector_caps_none": "Inga aktiva behörigheter än. Synka för att hämta dem.",
|
||||
"connector_caps_unconfigured": "Aktiveras när en connector-nyckel är konfigurerad.",
|
||||
"connector_sync_button": "Synka nu",
|
||||
"connector_sync_button_busy": "Synkar",
|
||||
"connector_sync_done": "Synkroniseringen är klar",
|
||||
"connector_sync_done_desc": "{count, plural, =1 {1 behörighet uppdaterad} other {# behörigheter uppdaterade}}.",
|
||||
"connector_sync_done_empty": "Nyckeln är giltig men abonnemanget ger inga behörigheter just nu.",
|
||||
"connector_sync_failed": "Synkroniseringen misslyckades",
|
||||
"connector_sync_revoked_desc": "Nyckeln avvisades av värdtjänsten och connector-behörigheterna har tagits bort. Kontrollera abonnemanget eller nyckeln.",
|
||||
"connector_sync_unreachable_desc": "Värdtjänsten kunde inte nås. Befintliga behörigheter behålls och gäller tills de löper ut.",
|
||||
"connector_sync_request_failed_desc": "Begäran misslyckades. Kontrollera att instansen är igång och försök igen.",
|
||||
"connector_load_failed": "Kunde inte läsa connector-status."
|
||||
},
|
||||
"settings_invoice_form": {
|
||||
"default_our_reference_label": "Standard för Vår referens",
|
||||
|
||||
Reference in New Issue
Block a user