* 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>
191 lines
7.9 KiB
TypeScript
191 lines
7.9 KiB
TypeScript
import { Suspense } from 'react'
|
|
import { redirect } from 'next/navigation'
|
|
import { cookies } from 'next/headers'
|
|
import DashboardContent from '@/components/dashboard/DashboardContent'
|
|
import { ChecklistSkeleton, PanesSkeleton } from '@/components/dashboard/HemSkeletons'
|
|
import { COMPANY_PICKED_COOKIE } from '@/lib/company/context'
|
|
import { isCockpitLandingRole } from '@/lib/company/home-domain'
|
|
import { OAUTH_MCP_KEY_NAME } from '@/lib/auth/api-keys'
|
|
import { claudeStepDone } from '@/lib/onboarding/checklist'
|
|
import { createServiceClient } from '@/lib/supabase/server'
|
|
import {
|
|
getDashboardAuthContext,
|
|
getDashboardCompanyId,
|
|
getDashboardSettings,
|
|
getDashboardTeamMemberships,
|
|
getResolvedDashboardAgentProfile,
|
|
} from './request-context'
|
|
import { HemChecklistSection, HemNoticesSection, HemPanesSection } from './hem-sections'
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
// Home route = Hem (concept scene 14): greeting + Att göra + Fortsätt.
|
|
// The KPI/revenue/deadline widgets left the page (founder direction,
|
|
// dev_docs/last_session_resume.md §8), which also pruned their fetches:
|
|
// the journal-line YTD aggregation, unpaid-invoice totals and deadline
|
|
// queries are gone and the page got faster.
|
|
//
|
|
// Streaming: the page itself awaits only what the greeting shell and the
|
|
// redirects need (settings, profile, agent profile, the Skatteverket flag).
|
|
// The notice line, the setup checklist and the Att göra + Fortsätt panes are
|
|
// async server components behind their own <Suspense> (hem-sections.tsx),
|
|
// so ~30 queries fill three blocks in as they land instead of holding the
|
|
// whole page behind the slowest one. RSC streaming applies to client
|
|
// navigations too, not only hard loads.
|
|
|
|
export default async function DashboardPage() {
|
|
const [{ supabase, user }, companyId] = await Promise.all([
|
|
getDashboardAuthContext(),
|
|
getDashboardCompanyId(),
|
|
])
|
|
|
|
if (!user) {
|
|
redirect('/login')
|
|
}
|
|
|
|
if (!companyId) {
|
|
redirect('/onboarding')
|
|
}
|
|
|
|
// Byrå landing: byrå owners/admins home to the cockpit, not to an
|
|
// auto-resolved client company. Role-gated 2026-08-27 (superseding the
|
|
// 2026-08-05 all-members widening): plain members land like regular users
|
|
// and open the cockpit from the nav when they want it; the middleware's
|
|
// zero-company steer stays ungated since a member with no client companies
|
|
// has nowhere else to land. companyId above can be the middleware's
|
|
// fallback (which it also writes back to user_preferences, so the DB can't
|
|
// tell picked from auto-picked); the session cookie stamped by
|
|
// setActiveCompany is the explicit-choice signal. Once they enter a client
|
|
// this session, "/" is that company's Hem again. Memberships are
|
|
// request-cached and shared with the layout.
|
|
const [cookieStore, teamMemberships] = await Promise.all([
|
|
cookies(),
|
|
getDashboardTeamMemberships(),
|
|
])
|
|
if (!cookieStore.has(COMPANY_PICKED_COOKIE)) {
|
|
if (
|
|
teamMemberships.some(
|
|
(m) => m.teams?.kind === 'byra' && isCockpitLandingRole(m.role),
|
|
)
|
|
) {
|
|
redirect('/byra')
|
|
}
|
|
}
|
|
|
|
const now = new Date()
|
|
|
|
// Service role for the OAuth-key count below: api_keys' SELECT policy is
|
|
// company_id IN user_company_ids() (20260330130000), so through the user
|
|
// client a key minted companyless (company_id NULL, the connect-before-
|
|
// signup flow) or bound to a company the user has since archived or left is
|
|
// invisible, and the step would stay open for exactly the user who just
|
|
// connected. The query filters on user_id explicitly, so no other user's
|
|
// rows are reachable.
|
|
const serviceClient = await createServiceClient()
|
|
|
|
const [
|
|
settingsRes,
|
|
{ data: profile },
|
|
agentProfile,
|
|
{ count: skatteverketTokenCount },
|
|
{ count: oauthKeyCount, error: oauthKeyError },
|
|
] =
|
|
await Promise.all([
|
|
getDashboardSettings(),
|
|
// First name for the greeting.
|
|
supabase.from('profiles').select('full_name').eq('id', user.id).maybeSingle(),
|
|
getResolvedDashboardAgentProfile(),
|
|
// The Skatteverket promo below the panes needs this flag in the shell;
|
|
// the checklist section reads it again for its own step (cheap head count).
|
|
supabase.from('skatteverket_tokens').select('*', { count: 'exact', head: true }).eq('user_id', user.id).eq('company_id', companyId),
|
|
// The checklist's "Anslut till Claude" step is done when the MCP OAuth
|
|
// token route has minted a key for this user (claudeStepDone). Keyed on
|
|
// the user, not the company: the Claude connection follows the person,
|
|
// and the key's company_id is whatever was active at sign-in (or null
|
|
// for a companyless signup), so a company filter would miss real
|
|
// connections. Revoked rows do not count.
|
|
serviceClient
|
|
.from('api_keys')
|
|
.select('*', { count: 'exact', head: true })
|
|
.eq('user_id', user.id)
|
|
.eq('name', OAUTH_MCP_KEY_NAME)
|
|
.is('revoked_at', null),
|
|
])
|
|
|
|
// A FAILED settings read must not masquerade as "onboarding not done":
|
|
// that sent fully onboarded users back to the wizard on a transient query
|
|
// failure (issue #1053). Throw to the error boundary (retryable) and only
|
|
// redirect on a genuinely incomplete or missing settings row.
|
|
const { data: settings, error: settingsError } = settingsRes
|
|
if (settingsError) {
|
|
throw new Error(`company_settings fetch failed: ${settingsError.message}`)
|
|
}
|
|
// Same rule for the OAuth-key count: a failed query answers count null,
|
|
// which claudeStepDone would read as "never connected" and re-open the
|
|
// Claude step for a connected user. Surface it instead of guessing.
|
|
if (oauthKeyError) {
|
|
throw new Error(`api_keys count failed: ${oauthKeyError.message}`)
|
|
}
|
|
|
|
// If onboarding is not complete, redirect to onboarding. Exception: a byrå
|
|
// member who did NOT explicitly pick this company this session goes to the
|
|
// cockpit instead. The auto-resolved company can be onboarding-incomplete
|
|
// through no action of theirs (a client mid migration-reset repoints every
|
|
// member's active_company_id), and the first-run wizard is a dead end for
|
|
// role 'member': WL-15 refuses client creation and the shell has no nav.
|
|
// Before the owner/admin landing gate the /byra bounce above shielded every
|
|
// byrå member from this path; this keeps that shield without the gate.
|
|
if (!settings?.onboarding_complete) {
|
|
if (
|
|
!cookieStore.has(COMPANY_PICKED_COOKIE) &&
|
|
teamMemberships.some((m) => m.teams?.kind === 'byra')
|
|
) {
|
|
redirect('/byra')
|
|
}
|
|
redirect('/onboarding')
|
|
}
|
|
|
|
const agentBuilt = Boolean(agentProfile?.verified_at)
|
|
const hasMcpKey = claudeStepDone({ oauthKeyCount })
|
|
const userFirstName = profile?.full_name?.trim().split(/\s+/)[0] ?? null
|
|
const initialSetup = {
|
|
path: settings.initial_setup_path ?? null,
|
|
completedAt: settings.initial_setup_completed_at ?? null,
|
|
dismissedAt: settings.initial_setup_dismissed_at ?? null,
|
|
}
|
|
const setupOpen = !settings.initial_setup_completed_at && !settings.initial_setup_dismissed_at
|
|
|
|
return (
|
|
<DashboardContent
|
|
companyId={companyId}
|
|
agentBuilt={agentBuilt}
|
|
userFirstName={userFirstName}
|
|
initialSetup={initialSetup}
|
|
hasSkatteverketConnected={(skatteverketTokenCount || 0) > 0}
|
|
notices={
|
|
<Suspense fallback={null}>
|
|
<HemNoticesSection companyId={companyId} userId={user.id} now={now} />
|
|
</Suspense>
|
|
}
|
|
checklist={
|
|
<Suspense fallback={<ChecklistSkeleton />}>
|
|
<HemChecklistSection
|
|
companyId={companyId}
|
|
userId={user.id}
|
|
now={now}
|
|
initialSetup={initialSetup}
|
|
hasMcpKey={hasMcpKey}
|
|
vatRegistered={settings.vat_registered}
|
|
momsPeriod={settings.moms_period ?? null}
|
|
/>
|
|
</Suspense>
|
|
}
|
|
panes={
|
|
<Suspense fallback={<PanesSkeleton />}>
|
|
<HemPanesSection companyId={companyId} now={now} setupOpen={setupOpen} />
|
|
</Suspense>
|
|
}
|
|
/>
|
|
)
|
|
}
|