fix(mcp): explain the Claude-side steps after "Anslut till Claude" and tick the checklist on a real connection (#2133) (#2147)

* fix(mcp): explain the Claude-side steps after "Anslut till Claude" and tick the checklist on a real connection (#2133)

Lazy auth is by design: Claude lists the tools before any sign-in and the
first company-scoped call answers 401, which opens the Accounted sign-in.
Nothing told the user, so a "connected" status with an unanswered first
question read as a broken connection (Axel, Discord).

- Settings -> API & MCP: one sentence of expectation under the button, and
  the step-by-step guide link moved from under two disclosures to directly
  under the button.
- Docs (connect-claude / anslut-claude): new "What happens after you click"
  section for Path A covering the connector dialog, the tools appearing
  before sign-in, the first-call login + consent screen, "ask again", and
  the "Required when the server asks" auth setting that only the manual
  path mentioned.
- Hem checklist step "Anslut till Claude": deep link now carries
  client=claude-connector like the settings button (claudeConnectorLink),
  the footnote carries the same expectation line plus the guide link, and
  the done-signal is an unrevoked api_keys row minted by the MCP OAuth
  token route (OAUTH_MCP_KEY_NAME) instead of the in-app AI-profile flag,
  which never meant "connected to Claude".
- Tests: claudeStepDone with/without a key row, deep-link snapshot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7iJQwKiRTDWSMnRm4WM4L

* fix(mcp): correct consent-page claims, stop the completion PATCH loop, count OAuth keys past RLS (#2133)

Three skeptic refutations on PR #2147, fixed in one pass:

- Docs (EN + SV): the consent page shows the company active in the app and
  pre-selects every scope for Claude's connector (founder decision
  2026-08-26); it has no company picker and nothing to tick. Steps 3-4 of
  the new section, the "Read-only by default" paragraph above it, the
  sandbox note and the 10-minute test now describe Endast läs under
  Behörigheter instead.
- Checklist completion: users with initial_setup_path NULL (skipped the
  books question, then imported) hit the route's "Välj först hur du vill
  komma igång" 400 and, with saving as an effect dependency, retried it
  forever with a toast. completionPatchBody() records path=migration when
  none was chosen, and a rejected PATCH is not retried within the session.
- hasMcpKey: api_keys' SELECT policy is company-scoped, so the user client
  could not see companyless (NULL company_id) or archived-company keys and
  the step stayed open for the user who had just connected. The head count
  now runs through the service client with an explicit user_id filter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7iJQwKiRTDWSMnRm4WM4L

* fix(mcp): surface a failed OAuth-key count and reserve the marker name (#2133)

CodeRabbit round on PR #2147:

- app/(dashboard)/page.tsx: a failed api_keys count answered count null,
  which claudeStepDone read as "never connected". Throw to the error
  boundary like the settings fetch does instead of guessing.
- app/api/settings/api-keys: reject a hand-minted key named
  MCP-klient (OAuth) (400 VALIDATION_ERROR): that name is the marker the
  Hem checklist reads as "connected to Claude", so a manual key with it
  would tick the step without any connection. Test added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7iJQwKiRTDWSMnRm4WM4L

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-02 00:04:31 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 8b09b06e14
commit 4f33184a9a
15 changed files with 292 additions and 43 deletions
+51 -12
View File
@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { useLocale, useTranslations } from 'next-intl'
import { Check } from 'lucide-react'
import posthog from 'posthog-js'
import { Badge } from '@/components/ui/badge'
@@ -13,7 +13,12 @@ import { cn } from '@/lib/utils'
import { useErrorToast } from '@/lib/hooks/use-error-toast'
import { useFormat } from '@/lib/hooks/use-format'
import { isAnalyticsEnabled } from '@/lib/analytics/enabled'
import { checklistNumbers, type VatDeadlineLine } from '@/lib/onboarding/checklist'
import {
checklistNumbers,
claudeConnectorLink,
completionPatchBody,
type VatDeadlineLine,
} from '@/lib/onboarding/checklist'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
@@ -27,7 +32,11 @@ interface NewUserChecklistProps {
hasBankConnected?: boolean
hasSkatteverketConnected?: boolean
hasInboxItems?: boolean
hasAgentBuilt?: boolean
/** The user holds a live OAuth-minted MCP key, i.e. a Claude (or other
* MCP client) connection completed its first sign-in. This is the only
* signal that means "connected to Claude"; the in-app AI-profile flag
* used to tick this step and never corresponded to it (issue #2133). */
hasMcpKey?: boolean
/** Personalized VAT-deadline line for the Skatteverket step (null = say nothing). */
vatLine?: VatDeadlineLine
/** Latest SIE reconciliation-sweep outcome: surfaces "X matchade, Y att
@@ -74,11 +83,12 @@ export default function NewUserChecklist({
hasBankConnected = false,
hasSkatteverketConnected = false,
hasInboxItems = false,
hasAgentBuilt = false,
hasMcpKey = false,
vatLine = null,
sieSweep = null,
}: NewUserChecklistProps) {
const t = useTranslations('initial_setup')
const locale = useLocale()
const { appName } = useBranding()
const router = useRouter()
const showError = useErrorToast()
@@ -92,6 +102,12 @@ export default function NewUserChecklist({
// step (companies whose completedAt arrives from the server never see it).
const [retiring, setRetiring] = useState<'verdict' | 'closing' | 'done' | null>(null)
const retireStartedRef = useRef(false)
// A completion PATCH the server rejected (4xx: no write role, no settings
// row) must not be retried in a loop: `saving` is a dependency of the
// completion effect, so without this latch every rejection re-armed the
// effect and re-raised the error toast forever. The next visit tries once
// more from server truth.
const completeRejectedRef = useRef(false)
// The ChatGPT side door on the Claude step: collapsed by default so the
// one-click Claude path stays the visual primary.
const [chatGptOpen, setChatGptOpen] = useState(false)
@@ -134,7 +150,7 @@ export default function NewUserChecklist({
// Companies built without the skatteverket/inbox extensions skip those steps.
const step3Done = !hasSkatteverket || hasSkatteverketConnected
const step4Done = !hasInbox || hasInboxItems
const step5Done = hasAgentBuilt
const step5Done = hasMcpKey
useEffect(() => {
// The block retires itself once every step is done; Dölj remains the
@@ -143,14 +159,18 @@ export default function NewUserChecklist({
if (
!state.completedAt &&
step1Done && step2Done && step3Done && step4Done && step5Done &&
saving === null
saving === null &&
!completeRejectedRef.current
) {
if (!retireStartedRef.current) {
retireStartedRef.current = true
setRetiring('verdict')
}
void persist({ completed: true }, 'complete').then((updated) => {
// completionPatchBody supplies a path when none was recorded: the route
// refuses completed:true without one, and this cohort looped on a 400.
void persist(completionPatchBody(state.path), 'complete').then((updated) => {
if (updated) captureSetup('onboarding_setup_completed', { path: updated.path })
else completeRejectedRef.current = true
})
}
// persist intentionally stays out: its identity follows the toast hook and
@@ -175,8 +195,9 @@ export default function NewUserChecklist({
if (state.dismissedAt) return null
// After the beat, stay retired even while the completion PATCH is still in
// flight or retrying: falling through to the full checklist here would
// flash it after the verdict already played. A failed PATCH keeps retrying
// invisibly; the next visit renders from server truth either way.
// flash it after the verdict already played. A rejected PATCH is not
// retried this session (completeRejectedRef); the next visit renders from
// server truth either way.
if (retiring === 'done') return null
if (state.completedAt && !retiring) return null
@@ -231,9 +252,11 @@ export default function NewUserChecklist({
// page origin so self-hosted and white-label domains link to themselves.
const goClaude = () => {
captureSetup('onboarding_setup_step_started', { step: 'claude' })
const serverUrl = `${window.location.origin}/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted`
const link = `https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=${encodeURIComponent(appName)}&connectorUrl=${encodeURIComponent(serverUrl)}`
window.open(link, '_blank', 'noopener')
window.open(
claudeConnectorLink({ origin: window.location.origin, appName }),
'_blank',
'noopener',
)
}
// ChatGPT has no add-connector deep link (the user pastes the server URL
// into Developer mode manually), so the side door copies the URL instead.
@@ -428,6 +451,22 @@ export default function NewUserChecklist({
)}
footnote={
<div className="mt-2">
{/* What the click leads to on Claude's side. Tools list before
any sign-in (lazy auth, by design), so without this line a
"connected" status with an unanswered first question reads
as a broken connection (issue #2133). The guide carries the
full sequence; one language per URL, see ApiKeysPanel. */}
<p className="mb-2 max-w-prose text-xs leading-5 text-muted-foreground">
{t('step_claude_expectation')}{' '}
<a
href={locale === 'sv' ? '/docs/api/anslut-claude' : '/docs/api/connect-claude'}
target="_blank"
rel="noopener noreferrer"
className="underline decoration-border underline-offset-4 transition-colors hover:text-foreground"
>
{t('step_claude_guide_link')}
</a>
</p>
<button
type="button"
onClick={toggleChatGpt}
+16 -13
View File
@@ -375,6 +375,22 @@ export function ApiKeysPanel() {
<p className="mt-2 max-w-prose text-xs text-muted-foreground">
{t('connect_to_claude_help')}
</p>
{/* The step-by-step guide is canonical on the docs site, in one
language per URL (the docs site has no locale routing). Root-relative
so the /docs/api/* 308 in next.config.ts forwards to docs.gnubok.se.
It sits right under the button: the steps on Claude's side after
the click (consent, first-call sign-in) live there, and a reader
who has just clicked should not have to open two disclosures to
find them (issue #2133). */}
<a
href={locale === 'sv' ? '/docs/api/anslut-claude' : '/docs/api/connect-claude'}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center gap-1.5 text-xs text-muted-foreground underline-offset-4 transition-colors duration-150 hover:text-foreground hover:underline"
>
{t('full_guide_link')}
<ArrowUpRight className="h-3 w-3" />
</a>
</div>
<button
@@ -493,19 +509,6 @@ export function ApiKeysPanel() {
</div>
</div>
</SettingsReveal>
{/* The step-by-step guide is canonical on the docs site, in one
language per URL (the docs site has no locale routing). Root-relative
so the /docs/api/* 308 in next.config.ts forwards to docs.gnubok.se. */}
<a
href={locale === 'sv' ? '/docs/api/anslut-claude' : '/docs/api/connect-claude'}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-1 py-3 text-xs text-muted-foreground underline-offset-4 transition-colors duration-150 hover:text-foreground hover:underline"
>
{t('full_guide_link')}
<ArrowUpRight className="h-3 w-3" />
</a>
</SettingsGroup>
<SettingsGroup>