46c0b72ab0
* feat(auth): surface duplicate-account traps around BankID login Three escape hatches for the stale-duplicate-account trap (#1231, the Chillen support case): a user whose BankID resolves to an abandoned account got an empty app with no hint that their real bookkeeping lives in another account. - check-org-number: new exists_elsewhere signal (service role, reduced to one boolean) + a warn chip in the onboarding journey when the org number already exists in an account the user is not a member of. - Hem: one AttnLine under the greeting when the whole account has zero journal entries but a same-orgnr company elsewhere has real bookkeeping, with a sign-out action. Common case costs one indexed existence probe. - scripts/support/unlink-bankid.ts: dry-run-by-default support action that unlinks a BankID identity (delete + app_metadata clear + append-only SECURITY_EVENT audit_log row). Replaces the raw SQL used to resolve the original ticket. Closes #1231 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(auth): harden unlink script and paginate hint queries per review - other-account-hint: fetchAllRows() on both company listings (PostgREST 1000-row cap; byrå users can hold many memberships); the journal probes stay limit(1) existence checks. - unlink-bankid: audit_log row is written BEFORE the delete so a partial failure can never delete without a trace; context queries fail closed instead of rendering an unknown account as empty; stdout no longer prints the personnummer hash or ciphertext (the unsalted hash is brute-forceable over the personnummer space); record_id now carries the identity row id and the snapshot includes id + linked_at. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
189 lines
7.8 KiB
TypeScript
189 lines
7.8 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import Link from 'next/link'
|
|
import { useRouter } from 'next/navigation'
|
|
import { useTranslations } from 'next-intl'
|
|
import { createClient } from '@/lib/supabase/client'
|
|
import { AttnLine } from '@/components/ui/attn-line'
|
|
import { Card, CardContent } from '@/components/ui/card'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { useCapability, useCompany } from '@/contexts/CompanyContext'
|
|
import { CAPABILITY } from '@/lib/entitlements/keys'
|
|
import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
|
|
import AttGoraSection from '@/components/dashboard/AttGoraSection'
|
|
import ResumePane from '@/components/dashboard/ResumePane'
|
|
import BackupHealthBanner from '@/components/dashboard/BackupHealthBanner'
|
|
import { SkatteverketPromoCard } from '@/components/dashboard/SkatteverketPromoCard'
|
|
import { ArrowRight } from 'lucide-react'
|
|
import type { InitialSetupState, OnboardingProgress } from '@/types'
|
|
import type { SuggestedMatch, WorklistCounts } from '@/lib/worklist/types'
|
|
import type { ResumeItem } from '@/lib/worklist/resume'
|
|
|
|
interface DashboardContentProps {
|
|
companyId: string
|
|
/** Signed-in user's first name for the greeting; null falls back to a
|
|
* nameless greeting. */
|
|
userFirstName?: string | null
|
|
/** Expiring PSD2 consents (dashboard-only worklist extra). */
|
|
expiringBankConnections?: { id: string; bank_name: string; days_left: number }[]
|
|
/** Unified pending-work counts from lib/worklist: same source as the sidebar badges. */
|
|
worklist: WorklistCounts
|
|
/** High-confidence transaction↔invoice matches for inline one-click confirm. */
|
|
suggestedMatches: SuggestedMatch[]
|
|
/** In-progress work for the Fortsätt pane (lib/worklist/resume). */
|
|
resumeItems: ResumeItem[]
|
|
/**
|
|
* True when this account looks bookkeeping-empty while a same-orgnr
|
|
* company with real bookkeeping exists in another account (#1231): the
|
|
* user probably signed in with the wrong login (stale BankID account).
|
|
*/
|
|
otherAccountHint?: boolean
|
|
onboardingProgress?: OnboardingProgress
|
|
initialSetup: InitialSetupState
|
|
/**
|
|
* False until the company has a verified agent_profile. When false the hero
|
|
* slot shows a build-assistant prompt instead of the next-best-action card,
|
|
* so existing/migrated users are nudged to build the assistant without a
|
|
* full-screen onboarding takeover.
|
|
*/
|
|
agentBuilt?: boolean
|
|
}
|
|
|
|
/**
|
|
* Hem (concept scene 14): greeting, then the two panes side by side:
|
|
* Att göra (obligations, lib/worklist) and Fortsätt (in-progress work,
|
|
* lib/worklist/resume). KPI tiles, revenue/expense cards and the deadline/tax
|
|
* widgets left the page (founder direction, dev_docs/last_session_resume.md
|
|
* §8): the numbers live at /kpi and /reports, deadlines render as Bevaka rows.
|
|
*/
|
|
export default function DashboardContent({
|
|
companyId,
|
|
userFirstName,
|
|
expiringBankConnections,
|
|
worklist,
|
|
suggestedMatches,
|
|
resumeItems,
|
|
otherAccountHint = false,
|
|
onboardingProgress,
|
|
initialSetup,
|
|
agentBuilt = true,
|
|
}: DashboardContentProps) {
|
|
const t = useTranslations('dashboard')
|
|
const hasAi = useCapability(CAPABILITY.ai)
|
|
const { company } = useCompany()
|
|
const router = useRouter()
|
|
|
|
// Wrong-account hint action: sign out so the user can come back in with
|
|
// their other login (email+password). Same flow as SandboxBanner.
|
|
async function handleSwitchAccount() {
|
|
const supabase = createClient()
|
|
await supabase.auth.signOut()
|
|
router.push('/login')
|
|
}
|
|
|
|
// Time-of-day greeting (concept: "God morgon, Jakob."). Client-side clock
|
|
// on purpose (the user's local morning, not the server's), captured once
|
|
// so render stays pure.
|
|
const [greetingNow] = useState(() => new Date())
|
|
const hour = greetingNow.getHours()
|
|
const greeting =
|
|
hour < 10 ? t('greeting_morning') : hour < 17 ? t('greeting_day') : t('greeting_evening')
|
|
const dateLine = new Intl.DateTimeFormat('sv-SE', {
|
|
weekday: 'long',
|
|
day: 'numeric',
|
|
month: 'long',
|
|
}).format(greetingNow)
|
|
|
|
return (
|
|
<div className="stagger-enter space-y-8">
|
|
<BackupHealthBanner />
|
|
|
|
{/* Greeting hero (concept scene 14) */}
|
|
<section>
|
|
<h1 className="font-display text-2xl leading-8 tracking-tight">
|
|
{userFirstName ? `${greeting}, ${userFirstName}.` : `${greeting}.`}
|
|
</h1>
|
|
<p className="mt-1.5 text-[13px] text-muted-foreground">
|
|
{dateLine}
|
|
{company?.name ? ` · ${company.name}` : ''}
|
|
</p>
|
|
{otherAccountHint && (
|
|
<AttnLine
|
|
className="mt-3"
|
|
action={{ label: t('other_account_hint_action'), onClick: handleSwitchAccount }}
|
|
>
|
|
{t('other_account_hint')}
|
|
</AttnLine>
|
|
)}
|
|
</section>
|
|
|
|
<NewUserChecklist
|
|
initialState={initialSetup}
|
|
hasBookkeepingImported={!!onboardingProgress?.hasSIEImport}
|
|
hasBankConnected={!!onboardingProgress?.hasBankConnected}
|
|
hasSkatteverketConnected={!!onboardingProgress?.hasSkatteverketConnected}
|
|
hasAgentBuilt={agentBuilt}
|
|
/>
|
|
|
|
{/* Build-assistant hero: shown only until the company has a verified
|
|
agent_profile, so existing/migrated users get a clear prompt instead
|
|
of a full-screen onboarding takeover. While the stepped first-run
|
|
checklist is visible it already carries the assistant as step 3, so
|
|
the hero waits until that block is dismissed or completed. */}
|
|
{!agentBuilt && (initialSetup.dismissedAt || initialSetup.completedAt) && (
|
|
<section>
|
|
{/* Non-payers keep seeing the hero (conversion surface) but it
|
|
routes to billing instead of a build flow that would 403. */}
|
|
<Link href={hasAi ? '/onboarding/agent' : '/settings/billing'} className="block group">
|
|
<Card className="transition-colors hover:border-primary/50">
|
|
<CardContent className="p-6 flex items-center gap-4">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<p className="font-display text-xl leading-tight">Bygg din bokföringsassistent</p>
|
|
<Badge variant="secondary" className="uppercase tracking-wider">Beta</Badge>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
{hasAi
|
|
? 'Några frågor om din verksamhet kalibrerar en assistent som föreslår bokföring åt dig.'
|
|
: 'Ingår i abonnemanget: en assistent som föreslår bokföring åt dig.'}
|
|
</p>
|
|
</div>
|
|
<div className="hidden sm:flex items-center gap-1.5 text-sm font-medium text-foreground group-hover:translate-x-0.5 transition-transform">
|
|
<span>{hasAi ? 'Kom igång' : 'Uppgradera'}</span>
|
|
<ArrowRight className="h-4 w-4" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</Link>
|
|
</section>
|
|
)}
|
|
|
|
{/* The two panes (concept hem-grid). When nothing is in progress the
|
|
right pane renders null and Att göra takes the full width. */}
|
|
<div
|
|
className={
|
|
resumeItems.length > 0 ? 'grid items-start gap-x-6 gap-y-8 md:grid-cols-2' : undefined
|
|
}
|
|
>
|
|
<AttGoraSection
|
|
worklist={worklist}
|
|
suggestedMatches={suggestedMatches}
|
|
expiringBankConnections={expiringBankConnections}
|
|
/>
|
|
<ResumePane items={resumeItems} />
|
|
</div>
|
|
|
|
{/* Connect-Skatteverket nudge for existing companies. Gated on
|
|
agentBuilt so it never stacks under the build-assistant hero:
|
|
one CTA surface at a time. */}
|
|
{agentBuilt && (
|
|
<SkatteverketPromoCard
|
|
companyId={companyId}
|
|
connected={!!onboardingProgress?.hasSkatteverketConnected}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|