Files
accounted/app/(dashboard)/layout.tsx
T
Jakob Wennberg d4f82cafc4 feat(analytics): add PostHog (EU) behind a same-origin proxy (#1237)
Recapt shuts down in four days, taking product analytics and session
replay with it. This adds PostHog Cloud EU alongside it; the Recapt
removal follows separately so events can be confirmed landing first.

Wiring choices that are not the tutorial defaults:

- Same-origin reverse proxy (/rl -> eu.i.posthog.com) instead of adding
  PostHog hosts to the CSP. connect-src 'self' and script-src 'self'
  already cover it, tracking blockers have no third-party host to match,
  and the Recapt allowlist entries in next.config.ts get replaced by
  nothing at all when they go. Needs skipTrailingSlashRedirect, since
  PostHog sends trailing-slash API requests; verified that trailing-slash
  URLs on normal routes still resolve 200 rather than 404.

- /rl is excluded from the proxy.ts matcher. Middleware runs BEFORE
  next.config rewrites, so without this updateSession() treats an
  ingestion POST as an unknown protected path and 307s it to /login.
  Verified with a control: /zz/flags/ -> 307 /login, /rl/flags/ -> 200
  from PostHog. This fails silently otherwise, because asset loads keep
  working through the rewrite while no events arrive.

- persistence: 'memory' so nothing is written to the device and no
  cookie-consent banner is required. Everything post-login is unaffected:
  AnalyticsIdentify re-identifies on each dashboard load.

- session_recording.maskTextSelector: '*'. PostHog masks inputs but not
  text by default, and this app renders org numbers (which for an
  enskild firma ARE the owner's personnummer), customer names and
  balances as ordinary text. Replays show where a user gets stuck, never
  what their books say. buildGroupProperties() also refuses to send
  org_number at all, with a test pinning it.

- Error tracking registers through the existing lib/observability sink
  rather than bypassing it, so every error-level createLogger() line is
  captured already redacted. instrumentation.ts onRequestError covers
  what escapes uncaught.

Analytics is hosted-only: isAnalyticsEnabled() short-circuits on
NEXT_PUBLIC_SELF_HOSTED and no Docker sentinel is added, so self-hosted
runs with zero third-party runtime code. Recapt got that outcome only by
accident, via a missing sentinel; here it is explicit and tested.

vitest.config.ts aliases 'server-only' to a stub: it is a build-time
guard whose real entry point always throws, which broke 48 test files the
moment a server-only module entered the graph. request-context.ts was
already carrying the same latent trap.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 14:30:49 +02:00

357 lines
14 KiB
TypeScript

import { redirect } from 'next/navigation'
import { headers } from 'next/headers'
import DashboardNav from '@/components/dashboard/DashboardNav'
import { MainContainer } from '@/components/dashboard/MainContainer'
import CompanyTabSync from '@/components/dashboard/CompanyTabSync'
import { RecaptIdentify } from '@/components/RecaptIdentify'
import AnalyticsIdentify from '@/components/AnalyticsIdentify'
import { AgentSheetProvider } from '@/components/agent/AgentSheetProvider'
import AgentTrigger from '@/components/agent/AgentTrigger'
import LazyCommandPalette from '@/components/common/LazyCommandPalette'
import { SettingsHotkey } from '@/components/settings/SettingsHotkey'
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
import { getExtensionNavItems } from '@/lib/extensions/sectors'
import { CompanyProvider } from '@/contexts/CompanyContext'
import { getCompanyEntitlements } from '@/lib/entitlements/has-capability'
import { getBranding } from '@/lib/branding/service'
import type { AccountingFramework, EntityType, CompanyRole, Team } from '@/types'
import {
getDashboardAuthContext,
getDashboardCompanyId,
getDashboardSettings,
getResolvedDashboardAgentProfile,
} from './request-context'
/**
* Routes inside the dashboard group that must remain reachable when the
* user has no active company. Keep in sync with the middleware's
* no-company allowlist.
*/
const NO_COMPANY_ALLOWED_PATHS = ['/settings/account']
/**
* Frame layout: on desktop the page is a rounded panel floating on the
* warm-toned frame (bg-frame on the wrapper div), with its own inner
* scroll. 10px margin against the frame; height is the remaining
* viewport. The sidebar (fixed, w-64) sits borderless on the frame, so
* the panel starts at ml-64. On mobile the panel dissolves: full-width
* document flow with the bottom nav, exactly as before.
*/
const MAIN_PANEL_CLASS =
'safe-area-main-padding md:!pb-0 relative bg-background min-h-screen ' +
'md:min-h-0 md:ml-[var(--nav-w)] md:mt-[10px] md:mr-[var(--agent-dock-w)] md:h-[calc(100vh-20px)] ' +
'md:overflow-y-auto md:rounded-xl md:border md:border-border ' +
'md:transition-[margin-left,margin-right] md:duration-300 md:ease-[cubic-bezier(0.32,0.72,0,1)]'
export default async function DashboardLayout({
children,
settingsModal,
}: {
children: React.ReactNode
// `@settingsModal` parallel slot: renders the routed settings modal over the
// current page on in-app navigation to /settings/*; null otherwise.
settingsModal: React.ReactNode
}) {
const { supabase, user } = await getDashboardAuthContext()
if (!user) {
redirect('/login')
}
// Resolve active company from user_preferences (authoritative). The
// `gnubok-company-id` cookie is intentionally no longer consulted here:
// `getActiveCompanyId` reads from user_preferences, matching what RLS
// sees via `current_active_company_id()`. Keeping both sides on the same
// source avoids cross-tab / cookie divergence.
// Team membership (with the team row embedded) only depends on user.id,
// so it resolves in parallel, this layout is on the critical path of
// every dashboard page, so sequential round-trips are wall-clock time.
const [companyId, headerStore, { data: teamMembership }] = await Promise.all([
getDashboardCompanyId(),
// Read the pathname forwarded by middleware so we can branch on it.
headers(),
supabase
.from('team_members')
.select('team_id, role, teams:team_id(*)')
.eq('user_id', user.id)
.limit(1)
.maybeSingle(),
])
const pathname = headerStore.get('x-pathname') ?? ''
const isNoCompanyAllowed = NO_COMPANY_ALLOWED_PATHS.some((p) =>
pathname.startsWith(p)
)
const team: Team | null =
(teamMembership?.teams as unknown as Team | null) ?? null
const isTeamMember = !!teamMembership
// No companies: redirect to onboarding, except for allowed escape-hatch
// routes (so the user can still reach /settings/account to delete their
// account after archiving their last company).
if (!companyId) {
if (!isNoCompanyAllowed) {
redirect('/onboarding')
}
return (
<CompanyProvider
value={{
company: null,
role: null,
companies: [],
isTeamMember,
team,
isSandbox: false,
capabilities: [],
trialEndsAt: null,
}}
>
<AgentSheetProvider>
<CompanyTabSync />
<div className="min-h-screen bg-frame md:flex md:flex-col">
<DashboardNav
companyName={getBranding().appName.toLowerCase()}
entityType="enskild_firma"
isSandbox={false}
extensionNavItems={getExtensionNavItems()}
/>
<main
id="main-content"
className={MAIN_PANEL_CLASS}
role="main"
>
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
{children}
</div>
</main>
{settingsModal}
<SettingsHotkey />
</div>
</AgentSheetProvider>
</CompanyProvider>
)
}
// Fetch company + membership for context provider, together with the
// nav/badge data, none of these depend on each other, only on
// companyId/user.id, so one round-trip batch instead of two. The rare
// stale-cookie early return below wastes the extra reads; that's cheaper
// than serializing two batches on every dashboard render.
const [
{ data: companyRow },
{ data: memberRow },
{ data: allMemberships },
{ data: settings },
agentProfileIdentity,
{ data: userProfile },
entitlements,
{ data: allSettingsNames },
{ data: userPrefs },
] = await Promise.all([
supabase.from('companies').select('*').eq('id', companyId).single(),
supabase.from('company_members').select('role').eq('company_id', companyId).eq('user_id', user.id).single(),
supabase.from('company_members').select('company_id, role, companies:company_id(id, name, org_number, entity_type, accounting_framework, created_by, team_id, archived_at, created_at, updated_at)').eq('user_id', user.id),
getDashboardSettings(),
// Nav badge counts (unbooked transactions, pending operations) are NOT
// fetched here anymore: DashboardNav loads them client-side after mount
// (lib/hooks/use-worklist-badges) so two head-count queries stop blocking
// first paint on every dashboard navigation.
// Agent identity, name + avatar, surfaced on the FAB and chat
// surfaces. Null when no agent_profile exists yet (banner CTA path).
getResolvedDashboardAgentProfile(),
// The signed-in user's profile, shown in the bottom-left account
// popover (full_name + initial) so it's clear which user is logged
// in, distinct from the active company shown at the top.
supabase.from('profiles').select('full_name').eq('id', user.id).maybeSingle(),
getCompanyEntitlements(supabase, companyId),
// Current display names for ALL the user's companies (the switcher list).
// RLS scopes company_settings SELECT to user_company_ids(), so this bare
// select returns exactly the caller's companies, letting non-active rows
// show company_settings.company_name instead of the frozen companies.name.
supabase.from('company_settings').select('company_id, company_name'),
// Per-user UI state (nav collapse/fold state), server-rendered so the
// sidebar width is right on first paint, plus the hide-assistant-FAB
// preference (Inställningar → Assistenten). Batched here so it costs no
// extra round-trip on the dashboard critical path.
supabase.from('user_preferences').select('ui_state, hide_assistant_fab').eq('user_id', user.id).maybeSingle(),
])
// company_id -> current display name for every company the user belongs to.
const nameByCompany = new Map(
(allSettingsNames || []).map((s) => [s.company_id, s.company_name as string | null]),
)
if (!companyRow || !memberRow) {
// Stale cookie pointing to a deleted/inaccessible company.
// Render the empty-state dashboard so user can switch or create a company.
const companyContextValue = {
company: null,
role: null,
companies: (allMemberships || []).filter(m => m.companies).map((m) => {
const c = m.companies as unknown as import('@/types').Company
return {
company: { ...c, name: nameByCompany.get(c.id) || c.name },
role: m.role as CompanyRole,
}
}),
isTeamMember,
team,
isSandbox: false,
capabilities: [],
trialEndsAt: null,
}
return (
<CompanyProvider value={companyContextValue}>
<AgentSheetProvider>
<CompanyTabSync />
<div className="min-h-screen bg-frame md:flex md:flex-col">
<DashboardNav
companyName={getBranding().appName.toLowerCase()}
entityType="enskild_firma"
isSandbox={false}
extensionNavItems={getExtensionNavItems()}
/>
<main id="main-content" className={MAIN_PANEL_CLASS} role="main">
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
{children}
</div>
</main>
{settingsModal}
<SettingsHotkey />
</div>
</AgentSheetProvider>
</CompanyProvider>
)
}
// If onboarding incomplete, still render the dashboard: the page component
// will show the inline onboarding card instead of the normal dashboard content.
// Use company_name from settings as the display name (companies.name may be stale)
const displayName = settings?.company_name || companyRow.name
// Resolve entity type the same way the report engines and
// getCompanyEntityType do: company_settings is read-primary, companies is the
// canonical fallback, then default to enskild_firma. Mirroring it onto the
// active company keeps the settings rail (useSettingsNavItems, which reads
// context) and the sidebar in agreement on who is an employer. #782
const entityType =
(settings?.entity_type as EntityType) ||
(companyRow.entity_type as EntityType) ||
'enskild_firma'
const paysSalaries = settings?.pays_salaries ?? false
// Dimensions register visibility (Kostnadsställen & projekt nav row). Same
// mechanism as paysSalaries: UI gate only, never load-bearing for
// correctness (dimensions plan §2).
const dimensionsEnabled = settings?.dimensions_enabled ?? false
const companyWithName = {
...companyRow,
name: displayName,
entity_type: entityType,
pays_salaries: paysSalaries,
}
const isSandbox = settings?.is_sandbox === true
// Client-driven UI preferences (sidebar collapse + fold state). Read here
// so the shell renders at the right width on first paint; the nav toggles
// flip the data attribute client-side and persist via /api/user/ui-state.
const uiState = (userPrefs?.ui_state ?? {}) as import('@/types').UserUiState
const navCollapsed = uiState.nav_collapsed === true
const companyContextValue = {
company: companyWithName,
role: memberRow.role as CompanyRole,
companies: (allMemberships || []).map((m) => {
const c = m.companies as unknown as import('@/types').Company
// Current display name for every company (company_settings.company_name,
// falling back to the frozen companies.name) so non-active switcher rows
// are current too. For the active company this equals `displayName`.
return {
company: { ...c, name: nameByCompany.get(c.id) || c.name },
role: m.role as CompanyRole,
}
}),
isTeamMember,
team,
isSandbox,
capabilities: entitlements.capabilities,
trialEndsAt: entitlements.trialEndsAt,
}
return (
<CompanyProvider value={companyContextValue}>
<AgentSheetProvider
identity={{
displayName: agentProfileIdentity?.display_name ?? null,
avatarId: agentProfileIdentity?.avatar_id ?? null,
isVerified: Boolean(agentProfileIdentity?.verified_at),
}}
>
<CompanyTabSync />
<div
id="dash-shell"
className="min-h-screen bg-frame md:flex md:flex-col"
style={{ '--nav-w': navCollapsed ? '64px' : '248px' } as React.CSSProperties}
>
{/* Skip to content link for keyboard/screen reader users */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-lg focus:text-sm focus:font-medium"
>
Hoppa till innehåll
</a>
{isSandbox && <SandboxBanner />}
<DashboardNav
companyName={settings?.company_name || 'Min verksamhet'}
entityType={entityType}
paysSalaries={paysSalaries}
dimensionsEnabled={dimensionsEnabled}
isSandbox={isSandbox}
extensionNavItems={getExtensionNavItems()}
userName={userProfile?.full_name ?? null}
userEmail={user.email ?? null}
initialUiState={uiState}
/>
<main id="main-content" className={MAIN_PANEL_CLASS} role="main">
<MainContainer companyId={companyId}>{children}</MainContainer>
</main>
<AgentTrigger hidden={userPrefs?.hide_assistant_fab === true} />
<LazyCommandPalette />
<SettingsHotkey />
{settingsModal}
</div>
{!isSandbox && (
<RecaptIdentify
userId={user.id}
email={user.email}
displayName={settings?.company_name || undefined}
/>
)}
{!isSandbox && (
<AnalyticsIdentify
user={{
userId: user.id,
email: user.email,
fullName: userProfile?.full_name ?? null,
role: memberRow.role as CompanyRole,
}}
company={{
id: companyId,
name: displayName,
entityType,
accountingFramework: companyRow.accounting_framework as AccountingFramework,
paysSalaries,
trialEndsAt: entitlements.trialEndsAt,
capabilities: entitlements.capabilities,
}}
/>
)}
</AgentSheetProvider>
</CompanyProvider>
)
}