fdb5f6f891
* feat(white-label): brand and team-kind foundation
- brands table: one white-label identity per byra team (unique mutable
domain, row presence = live, email sender identity, hex color CHECKs)
- teams.kind ('personal'|'byra'): ops-only kind changes, deterministic
ensure_user_team (personal team only), AFTER UPDATE role re-sync so a
demoted consultant loses admin in client books immediately
- resolveBrandByHost/resolveBrandForCompany with 60s TTL cache, derived
chrome tone and WCAG contrast gate; no brand row = default appearance
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): per-request brand theming, wordmark slot and source footer
- root layout resolves the brand from the Host header and injects a
server-rendered style block (light + dark), font pair classes and a
BrandProvider/useBranding context; default hosts render byte-identically
- BrandWordmark logo slot, host-aware manifest and favicon,
images.remotePatterns for Supabase Storage logos
- curated font menu mechanism (font_key -> variable pair, preload:false
for non-default entries)
- AGPL source-code footer link on login and public pages, both brands
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra team invites, member management and team billing
- team invites unfrozen behind a kind gate (byra teams only, owner/admin
invite); members route handles multi-team membership; members/[id]
unfrozen with last-owner protection; invite management UI in settings
- billing/status learns team-scoped grants and the settings page shows a
read-only "part of the byra agreement" state instead of the upgrade pitch
- 30-day trial suppressed for companies created under a byra team
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): brand-aware outbound mail, auth email hook and public invoice branding
- every outbound mail is sent in the brand of the company it concerns:
getSenderForCompany/getBaseUrlForCompany chain (verified brand domain,
"via Accounted" fallback, canonical default) wired into invites,
payslips, invoice deliveries and reminders
- Supabase Send Email hook endpoint (signature-verified with node:crypto,
dormant until configured) renders auth mail per brand via redirect origin
- public invoice pages carry the company's brand mark
- snapshot suite per template class guards against wrong-brand mail
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra cockpit, home-domain rule and tab guard
- Klienter route: five urgency-sorted columns (company, unbooked, inbox,
next deadline via the status engine, last booked) for byra team members,
who land there after login on their home domain
- soft switch straight into a client and back; blocking two-exit tab
guard against writes to the wrong active company
- client company creation admin-gated at the DB level (a created company
is +1 on the byra invoice), bound to the byra team, no trial
- home-domain rule in the UI: switcher partitions companies by host,
signpost page for companies homed elsewhere
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): brand-aware app name across UI strings
- 24 message keys per locale converted to the {appName} ICU parameter,
27 call sites pass the active brand name (useBranding client-side,
getRequestAppName server-side)
- 6 hardcoded JSX literals swept; statutory filing and API identity
surfaces deliberately keep the Accounted name
- 34 new i18n keys for the cockpit, team invites, billing state, tab
guard, signpost and source footer (sv/en parity verified)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(white-label): domain glossary and decision log entries
- CONTEXT.md: the white-label ubiquitous language (brand, byra team,
home domain, signpost, umbrella subdomain, brand color, cockpit)
- DECISIONS.md entries from the build waves
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): lean byra cockpit sidebar with company-mode back link
Byra team members now get a two-mode sidebar: on cockpit routes (/clients
and the new /byra pages) only Hem, Klienter, Automationer and Nyckeltal
show; entering a client company brings back the full company sidebar with
a pinned back-to-clients link (expanded, rail and mobile). New pages: /byra
home with client count, needs-action count and per-client urgent deadlines
reusing the fetchClientOverview aggregation, plus designed empty states for
/byra/automations and /byra/kpi. Signpost gate allows the byra routes;
non-byra users are unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): cockpit shows no active company and keeps lean sidebar under settings
In cockpit mode the bottom user widget no longer shows the active company
subline or the company-switcher flyout: the cockpit sits above the
companies and clients are entered through the Klienter list. The settings
modal previously flipped the sidebar to the full company nav behind it
because the pathname becomes /settings/*; the sidebar now keeps the mode
of the surface underneath.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): keep company picker in cockpit with nothing selected
The cockpit user menu gets the company-switcher flyout back, but neutral:
the row reads "Valj bolag", no company carries the check mark or active
styling, and picking any company (including the technically-active one)
enters it with a full navigation. Company mode is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(db): renumber white-label migrations past main and add byra settings scope
Renumber 20260801100000-120000 to 20260804110000-113000: main already
carries applied versions up to 20260803231000, and Supabase branching
refuses local migrations stamped before the remote head (the repo rule
from 5932632f5: keep new versions strictly newest). Comment references
updated in the pg tests, route docs and onboarding precheck.
Also ships the byra settings scope: settings opened from the cockpit
(?ctx=byra, honored only for byra team members) show account-level
sections only (Konto, Medlemmar och roller), hide company-scoped
sections and the company kicker, and the team section is registered in
SETTINGS_SECTIONS so Medlemmar och roller renders inside the settings
window. The cockpit user menu drops Abonnemang and carries the scope on
its links; section switches preserve it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(byra): cross-client nyckeltal view in the cockpit
Period presets and company chips in the URL, summary tiles, merged
monthly income/expense chart and a sortable per-client KPI table.
Numbers come from the existing get_kpi_report_aggregates RPC per
client (no new migrations); calendar months are the cross-client
axis since clients can have different fiscal years.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra self-service brand logo and app name
New Varumarke settings section (byra scope, owner/admin): logo
upload/remove and an editable app name; domain stays read-only.
brands has no write RLS by design, so writes go through
/api/byra/brand routes with the service client behind an explicit
owner/admin team check. Files land in logos/byra/{teamId}/. The
expanded sidebar shows the brand app name beside the logo.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): route root layout through the shared brand resolver
app/layout.tsx carried a private copy of resolveRequestBrand, so it
and lib/branding/request-brand.ts could drift. The layout now uses
the shared function, which also gains a BRAND_DEV_DOMAIN override:
on literal localhost hosts only, resolve that brand so branding is
testable in local dev. Real domains are unaffected even if the
variable leaks into a deployment.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(byra): automations roadmap teaser and cockpit i18n strings
The Automationer tab now previews the planned automation set
(Monday briefing, deadline watch, rule-driven bookkeeping,
connection watch, monthly checklist, report delivery) instead of a
bare empty state. Bundles the sv/en strings for the whole cockpit
wave (nyckeltal, varumarke, automations) and the decision-log
entries.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra owners/admins land in the cockpit, not an auto-picked company
After login "/" resolved the first-membership fallback and opened a client
company nobody chose, and the top-left brand mark always linked back to it.
Byra owners/admins now home to /byra: the logo links there always, and "/"
redirects there unless a company was explicitly picked this browser session.
The middleware writes the fallback company back to user_preferences, so the
DB cannot tell picked from auto-picked; setActiveCompany stamps a session
cookie (gnubok-company-picked) on every explicit switch instead. The byra
check on "/" reuses the layout's team_members query via a request-cached
helper, so it costs no extra round trip. Byra members and regular users are
unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(white-label): drop brand color theming, keep monochrome everywhere
White-label is logo + app name + domain only (founder call): the
layout no longer injects brand color CSS variables, stamps
data-brand or colors the browser chrome. buildBrandVarsCss, its
WCAG gate and the brand_color/chrome_color columns stay dormant
for a future opt-in.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(db): arm SIE RPC statement_timeout via pgrst.db_pre_request hook
ALTER FUNCTION ... SET statement_timeout (20260629160100, 20260721144311)
never re-arms the running statement's timer, so large SIE imports still
died at the role default 8s. The pre-request hook runs as its own
statement before the main query, so set_config there is what the main
statement's timer is armed with. Scoped by request path to the three SIE
RPCs; every other request keeps 8s.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(byra): drop the 'what's coming' tail from the automations intro
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): byra owners/admins with zero companies land in the empty cockpit
Both no-company gates (Edge middleware and the dashboard layout) sent
every company-less user to the onboarding wizard, which forced a fresh
byra owner to create a personal company before ever seeing the cockpit.
Byra owners/admins now pass through to cockpit routes (/byra, /clients,
/companies/new, /settings, /api) and are steered to /byra elsewhere.
Plain byra members and regular users keep the onboarding redirect.
The membership lookup runs only in the rare no-company state, so the
middleware hot path is untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): auth wordmark shows the brand logo alone
Byra logos usually carry their own name, so logo + app name text on the
login/register hero read as a duplicate. Branded hosts with an uploaded
logo now render the logo only, with the app name as the image's alt
text. Hosts without a logo keep the text wordmark unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): per-brand favicon via brands.favicon_url
Branded hosts used logo_url as the tab icon, which squashes wide byra
lockups at 16px. New optional brands.favicon_url holds a square mark;
the root layout prefers it and falls back to logo_url as before.
Migration applied to staging (idempotent DDL).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(auth): wire the villkor and integritetspolicy footer links
Both auth pages shipped with href="#" placeholders. Villkor now points
at the platform terms on the marketing site (accounted.se/terms; the
terms are the platform's even on branded byra hosts) and
integritetspolicy at the in-app /privacy page, host-relative so it
resolves on every branded domain. Both open in a new tab so the auth
form state survives.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): styled popup for the team role dropdowns
The byra team panel's role pickers (member rows + invite form) were
native selects, so the opened list rendered as the unstylable OS menu.
Swapped to the Radix Select with the popup styled like every other
overlay; the trigger keeps the flat quiet SettingsSelect look.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(email): branded sender shows the brand name alone, no via-platform
Byra invite mail read "Willem via Accounted" in the From display name.
The tier-2 fallback (brand on the platform address) now renders just the
brand name; the platform stays visible in the actual From address until
the brand verifies its own sender domain (tier 1, unchanged).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): byra landing applies to every team member, not only owners/admins
An invited byra consultant (role member) still landed in an auto-picked
client company after signup. The cockpit landing rules ("/" redirect,
brand-mark home link, and both no-company gates) now key on byra team
MEMBERSHIP instead of the owner/admin role: anyone with cockpit access
homes to /byra. Regular users unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(email): branded team invite names the byra, not "ett team pa <platform>"
Subject, headline, body and text variant now read "Du har blivit
inbjuden till <Byra>" (brand casing kept) when the team has a brand.
Brandless teams keep the platform phrasing byte-identical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): sidebar keeps cockpit mode after refresh on settings
The sidebar's cockpit/company decision on /settings/* rested on React
state remembering the surface underneath, which a hard reload wipes: a
byra user refreshing settings opened from the cockpit got the full
company nav and read it as landing in a client company. The ?ctx=byra
marker already in the URL survives reloads, so the sidebar now honors
it as the cockpit signal alongside the in-session memory.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): hide the active-company chip in byra-scoped settings
The full-page settings header (the hard-refresh fallback surface) showed
the ActiveCompanyBadge even under ?ctx=byra, so a byra user read the
auto-active client as "the company I am in". The chip now follows the
same byra-scope rule as the modal's kicker.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): tab guard no longer fires in the tab that initiated the switch
BroadcastChannel delivers the company-switch broadcast to every listener in
the same tab too, so the cockpit tab raised its own WL-09 "switched in
another tab" dialog over the hard navigation into the clicked client.
performCompanySwitch now marks the switch as self-initiated; CompanyTabSync
suppresses only the dialog for that observation (stray writes still get
their 409) and clears the marker on bfcache restore so back-navigation
regains the full guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): styled popups for every settings dropdown
SettingsSelect rendered a native <select>, whose OS listbox cannot be
styled and clashes with the panel (same problem the team-panel role
dropdowns had). It now renders through Radix Select with the flat
dashed-underline trigger, keeping the native prop surface so all 13 call
sites work unchanged: value/defaultValue, onChange(e.target.value),
<option> children, and a hidden input that carries `name` into
SettingsFormWrapper's FormData read and raises the bubbling input event
its dirty tracking listens for. Empty-string option values map onto a
sentinel at the Radix boundary. The backup form's boxed fiscal-year
select moves to the shadcn Select with a placeholder.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): home-domain affinity redirect in middleware
Every signed-in user now homes on a domain: byra team members on their
brand's domain, everyone else on the platform app URL, except a byra's
client users, whose home is the byra domain their companies live under.
On any other product host the request redirects to the home domain's
root, where the user meets the RIGHT branded login (sessions are
per-domain by design). localhost, direct *.vercel.app hosts and IP
hosts are exempt; a 15-minute host-scoped cookie caches the "this is
home" verdict so the hot path costs zero extra queries; lookup failures
fail open. Complements the WL-01 signpost, which keeps handling
per-company homing inside a domain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): render hero brand logo at 64px on auth pages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(white-label): shareable invite link and re-send for byra team invites
A failed invite mail previously surfaced only as a toast description while
the invitation quietly waited for a mail that never arrived (the Arbore
case). The inviter now always has a recovery path:
- persistent share-link line after invite create/re-send: ochre attn line
with a copy action when the mail did not go out, quiet muted line with
the same action when it did
- POST /api/team/invite/[id] re-sends a pending invitation with a fresh
token and expiry (same byra-only owner/admin gates as DELETE)
- brand mail sending extracted to lib/email/send-team-invite.ts, shared
by create and re-send so the two paths cannot drift
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): sidebar shows uploaded brand logo alone, no app-name label
Byra logos usually carry their own name, so logo + text in the expanded
sidebar read as a duplicate (same founder call as BrandWordmark,
2026-08-05). The app-name label now renders only for branded hosts
without an uploaded logo.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): close the four skeptic refutations before merge
- trial seed: migration 130300 now carries the seven-key PAID body from
20260818170000 plus the byra guard, instead of silently reverting it;
pg test pins the full key set against PAID_CAPABILITIES
- byra gate: new migration 130600 adds the owner/admin gate to
create_company_for_user (v1 API + MCP path), and both surfaces resolve
the default team personal-only, so a consultant's private company can
never attach to the byra team
- home-domain: byra staff who also have canonical-homed companies are no
longer redirected off the platform host; the signpost handles per-company
homing (5 new middleware tests)
- settings selects: the Radix popup renders optgroup group headers again
(ROT/RUT work-type picker)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(schema): re-baseline unresolvable-expression ceiling after #1954 catch-up merge
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(white-label): pg-real rollback-safe assertions and deep-link-preserving affinity redirect
The byra company-creation pg test asserted persisted rows through the pool
after withUserContext, which always rolls back its transaction; the
assertions now run inside the transaction after RESET ROLE. The home-domain
affinity redirect carries the original path and query across the domain hop
(PR Agent finding), so invite links and deep links survive the correction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
538 lines
22 KiB
TypeScript
538 lines
22 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 AnalyticsIdentify from '@/components/AnalyticsIdentify'
|
|
import { computeIdentityHash } from '@/lib/analytics/identity-hash'
|
|
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 { SessionTimeoutController } from '@/components/auth/SessionTimeoutController'
|
|
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
|
|
import TrialExpiredDialog from '@/components/billing/TrialExpiredDialog'
|
|
import { getExtensionNavItems } from '@/lib/extensions/sectors'
|
|
import { CompanyProvider, type ByraTeamRef } from '@/contexts/CompanyContext'
|
|
import { ReferenceDataSeed } from '@/components/providers/ReferenceDataSeed'
|
|
import { getCompanyEntitlements } from '@/lib/entitlements/has-capability'
|
|
import { getDashboardNavFlags } from '@/lib/dashboard/nav-flags'
|
|
import { getBranding } from '@/lib/branding/service'
|
|
import { resolveBrandByHost } from '@/lib/branding/resolve'
|
|
import { resolveBrandsForTeams } from '@/lib/branding/team-brands'
|
|
import {
|
|
partitionCompaniesByHomeDomain,
|
|
isCompanyHomedOnHost,
|
|
} from '@/lib/company/home-domain'
|
|
import HomeDomainSignpost from '@/components/dashboard/HomeDomainSignpost'
|
|
import type { AccountingFramework, EntityType, CompanyRole, Team } from '@/types'
|
|
import {
|
|
getDashboardAuthContext,
|
|
getDashboardCompanyId,
|
|
getDashboardSettings,
|
|
getDashboardTeamMemberships,
|
|
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-dvh ' +
|
|
'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 memberships come from the request-cached getDashboardTeamMemberships
|
|
// (ALL rows: multi-team is the supported shape after WL-08) so the home
|
|
// page's byrå landing redirect reuses the same single query.
|
|
// Wave 1: everything keyed on the user alone runs alongside the company
|
|
// resolution. The memberships join carries the active company's row and
|
|
// role too, so wave 2 no longer re-reads companies / company_members.
|
|
const [
|
|
companyId,
|
|
headerStore,
|
|
teamMemberships,
|
|
{ data: userProfile },
|
|
{ data: userPrefs },
|
|
{ data: allMemberships },
|
|
] = await Promise.all([
|
|
getDashboardCompanyId(),
|
|
// Read the pathname forwarded by middleware so we can branch on it.
|
|
headers(),
|
|
getDashboardTeamMemberships(),
|
|
// 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(),
|
|
// 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).
|
|
supabase.from('user_preferences').select('ui_state, hide_assistant_fab').eq('user_id', user.id).maybeSingle(),
|
|
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),
|
|
])
|
|
|
|
const pathname = headerStore.get('x-pathname') ?? ''
|
|
const isNoCompanyAllowed = NO_COMPANY_ALLOWED_PATHS.some((p) =>
|
|
pathname.startsWith(p)
|
|
)
|
|
|
|
// Team now carries `kind` directly (types/index.ts, WL-08).
|
|
const membershipRows = teamMemberships
|
|
const byraMembership = membershipRows.find((m) => m.teams?.kind === 'byra') ?? null
|
|
// Byrå team membership gates the cockpit ("Klienter" nav + /clients).
|
|
const byraTeam: ByraTeamRef | null = byraMembership?.teams
|
|
? {
|
|
id: byraMembership.teams.id,
|
|
name: byraMembership.teams.name,
|
|
role:
|
|
byraMembership.role === 'owner' || byraMembership.role === 'admin'
|
|
? byraMembership.role
|
|
: 'member',
|
|
}
|
|
: null
|
|
// Legacy single-team fields: the byrå team when present (the one consumers
|
|
// care about), else the first membership: same value as before for
|
|
// single-team users.
|
|
const team: Team | null =
|
|
(byraMembership?.teams as Team | null) ??
|
|
(membershipRows[0]?.teams as Team | null) ??
|
|
null
|
|
const isTeamMember = membershipRows.length > 0
|
|
|
|
// 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) and byrå team members (any
|
|
// role, founder call 2026-08-05), whose home is the EMPTY cockpit (mirrors
|
|
// the middleware's byrå exception): they render the no-company shell on
|
|
// cockpit routes and are steered to /byra everywhere else, never to the
|
|
// company wizard.
|
|
if (!companyId) {
|
|
const isByraMember = !!byraTeam
|
|
const isByraShellPath =
|
|
pathname.startsWith('/byra') ||
|
|
pathname.startsWith('/clients') ||
|
|
pathname.startsWith('/settings')
|
|
if (!isNoCompanyAllowed && !(isByraMember && isByraShellPath)) {
|
|
if (isByraMember) {
|
|
redirect('/byra')
|
|
}
|
|
redirect('/onboarding')
|
|
}
|
|
|
|
return (
|
|
<CompanyProvider
|
|
value={{
|
|
company: null,
|
|
role: null,
|
|
companies: [],
|
|
isTeamMember,
|
|
team,
|
|
byraTeam,
|
|
foreignCompanies: [],
|
|
isSandbox: false,
|
|
capabilities: [],
|
|
trialEndsAt: null,
|
|
entitlementState: 'none' as const,
|
|
trialExpiredAt: null,
|
|
}}
|
|
>
|
|
<SessionTimeoutController />
|
|
<AgentSheetProvider>
|
|
<CompanyTabSync />
|
|
<div className="min-h-dvh 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>
|
|
)
|
|
}
|
|
|
|
// The active company's row and role come from the memberships join above
|
|
// (a company the user is not a member of resolves to null, same as the old
|
|
// .single() reads did).
|
|
const activeMembership = (allMemberships || []).find((m) => m.company_id === companyId) ?? null
|
|
const companyRow = (activeMembership?.companies as unknown as import('@/types').Company | null) ?? null
|
|
const memberRow = activeMembership ? { role: activeMembership.role } : null
|
|
|
|
// Home-domain rule (WL-01): which brand serves this host, and which brand
|
|
// (if any) each membership company's team owns. Both resolvers are ~60s
|
|
// cached; unknown hosts and brandless teams resolve to null/absent, so the
|
|
// canonical no-brands hot path stays byte-identical. Both only depend on
|
|
// wave-1 data, so they ride in the wave-2 batch below.
|
|
const hostHeader =
|
|
headerStore.get('x-forwarded-host') ?? headerStore.get('host') ?? ''
|
|
|
|
// Wave 2: everything keyed on the company. Nav badge counts are NOT fetched
|
|
// here: DashboardNav loads them client-side after mount
|
|
// (lib/hooks/use-worklist-badges). The four nav-visibility probes
|
|
// (webshop connections/orders, mileage trips) collapsed into one RPC.
|
|
const [
|
|
{ data: settings, error: settingsError },
|
|
agentProfileIdentity,
|
|
entitlements,
|
|
{ data: allSettingsNames },
|
|
navFlags,
|
|
{ data: seedFiscalPeriods },
|
|
{ data: seedCashAccounts },
|
|
hostBrand,
|
|
brandByTeam,
|
|
] = await Promise.all([
|
|
getDashboardSettings(),
|
|
// Agent identity, name + avatar, surfaced on the FAB and chat
|
|
// surfaces. Null when no agent_profile exists yet (banner CTA path).
|
|
getResolvedDashboardAgentProfile(),
|
|
// teamId comes from the membership join, so the entitlements read is one
|
|
// wave (grants in parallel with config + subscription).
|
|
getCompanyEntitlements(supabase, companyId, { teamId: companyRow?.team_id ?? null }),
|
|
// 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'),
|
|
getDashboardNavFlags(supabase, companyId),
|
|
// Reference-data seed (lib/reference-data/seed.ts): the two small lists
|
|
// that gate almost every form, fetched once here so the first picker a
|
|
// user opens renders populated with zero client round trips. Same
|
|
// ordering as period.list and listForCompany so the seed and the client
|
|
// refetch agree. The chart of accounts is deliberately NOT seeded: it
|
|
// can be hundreds of KB for large companies.
|
|
supabase
|
|
.from('fiscal_periods')
|
|
.select('*')
|
|
.eq('company_id', companyId)
|
|
.order('period_start', { ascending: false }),
|
|
supabase
|
|
.from('cash_accounts')
|
|
.select('*')
|
|
.eq('company_id', companyId)
|
|
.order('is_primary', { ascending: false })
|
|
.order('ledger_account', { ascending: true }),
|
|
hostHeader ? resolveBrandByHost(hostHeader) : Promise.resolve(null),
|
|
resolveBrandsForTeams(
|
|
(allMemberships || []).map(
|
|
(m) => (m.companies as { team_id?: string | null } | null)?.team_id ?? null,
|
|
),
|
|
),
|
|
])
|
|
const hasWebshop = navFlags.hasWebshop
|
|
const hasMileageTrips = navFlags.hasMileageTrips
|
|
|
|
const canonicalDomain = (() => {
|
|
try {
|
|
return new URL(getBranding().appUrl).hostname
|
|
} catch {
|
|
return ''
|
|
}
|
|
})()
|
|
|
|
// 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,
|
|
byraTeam,
|
|
foreignCompanies: [],
|
|
isSandbox: false,
|
|
capabilities: [],
|
|
trialEndsAt: null,
|
|
entitlementState: 'none' as const,
|
|
trialExpiredAt: null,
|
|
}
|
|
|
|
return (
|
|
<CompanyProvider value={companyContextValue}>
|
|
<SessionTimeoutController />
|
|
<AgentSheetProvider>
|
|
<CompanyTabSync />
|
|
<div className="min-h-dvh 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
|
|
// Körjournal visibility: the settings toggle is the normal way in, existing
|
|
// trips force the row on so already-created data stays reachable.
|
|
const hasMileage = (settings?.mileage_enabled ?? false) || hasMileageTrips
|
|
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 allCompanyEntries = (allMemberships || [])
|
|
.filter((m) => m.companies)
|
|
.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,
|
|
}
|
|
})
|
|
|
|
// Home-domain rule (WL-01): the switcher offers only companies homed on
|
|
// THIS host; companies homed elsewhere become "Hanteras via <domain>"
|
|
// signpost entries. With no brands anywhere both lists reduce to
|
|
// visible = everything, foreign = [] : the additive guarantee.
|
|
const homePartition = partitionCompaniesByHomeDomain({
|
|
companies: allCompanyEntries,
|
|
getTeamId: (entry) => entry.company.team_id ?? null,
|
|
brandByTeam,
|
|
hostBrandTeamId: hostBrand?.teamId ?? null,
|
|
canonicalDomain,
|
|
canonicalAppName: getBranding().appName,
|
|
})
|
|
const foreignCompanies = homePartition.foreign.map((f) => ({
|
|
id: f.item.company.id,
|
|
name: f.item.company.name,
|
|
domain: f.domain,
|
|
}))
|
|
|
|
const companyContextValue = {
|
|
company: companyWithName,
|
|
role: memberRow.role as CompanyRole,
|
|
companies: homePartition.visible,
|
|
isTeamMember,
|
|
team,
|
|
byraTeam,
|
|
foreignCompanies,
|
|
isSandbox,
|
|
capabilities: entitlements.capabilities,
|
|
trialEndsAt: entitlements.trialEndsAt,
|
|
entitlementState: entitlements.entitlementState,
|
|
trialExpiredAt: entitlements.trialExpiredAt,
|
|
}
|
|
|
|
// Signpost gate (WL-01): a company is opened ONLY on its home domain. When
|
|
// the active company is homed elsewhere, the dashboard body is replaced by
|
|
// the signpost (never the wrong company's data), except on account-level
|
|
// and byrå-cockpit routes, which are not company surfaces. Navigation rule
|
|
// only: RLS/membership remain the security boundary, and middleware is
|
|
// untouched (the layout is the clean seam: brand lookups don't belong on
|
|
// the Edge hot path).
|
|
const activeCompanyHomed = isCompanyHomedOnHost({
|
|
companyTeamId: (companyRow.team_id as string | null) ?? null,
|
|
brandByTeam,
|
|
hostBrandTeamId: hostBrand?.teamId ?? null,
|
|
})
|
|
const SIGNPOST_ALLOWED_PATHS = ['/settings/account', '/clients', '/byra']
|
|
const showSignpost =
|
|
!activeCompanyHomed &&
|
|
!SIGNPOST_ALLOWED_PATHS.some((p) => pathname.startsWith(p))
|
|
|
|
return (
|
|
<CompanyProvider value={companyContextValue}>
|
|
<ReferenceDataSeed
|
|
companyId={companyId}
|
|
fiscalPeriods={seedFiscalPeriods ?? []}
|
|
cashAccounts={seedCashAccounts ?? []}
|
|
settings={settingsError ? undefined : settings}
|
|
>
|
|
<SessionTimeoutController />
|
|
<AgentSheetProvider
|
|
identity={{
|
|
displayName: agentProfileIdentity?.display_name ?? null,
|
|
avatarId: agentProfileIdentity?.avatar_id ?? null,
|
|
isVerified: Boolean(agentProfileIdentity?.verified_at),
|
|
}}
|
|
// Server-seeded panel geometry (docked width / floating rect / mode)
|
|
// so the assistant opens at the user's persisted size without a jump.
|
|
initialPanelPrefs={uiState.agent_panel}
|
|
>
|
|
<CompanyTabSync />
|
|
<div
|
|
id="dash-shell"
|
|
className="min-h-dvh 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
|
|
data-ph-unmask
|
|
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}
|
|
hasWebshop={hasWebshop}
|
|
hasMileage={hasMileage}
|
|
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}>
|
|
{showSignpost ? (
|
|
<HomeDomainSignpost
|
|
activeCompanyName={displayName}
|
|
homedCompanies={homePartition.visible.map((entry) => ({
|
|
id: entry.company.id,
|
|
name: entry.company.name,
|
|
}))}
|
|
foreignCompanies={foreignCompanies}
|
|
/>
|
|
) : (
|
|
children
|
|
)}
|
|
</MainContainer>
|
|
</main>
|
|
<AgentTrigger hidden={userPrefs?.hide_assistant_fab === true} />
|
|
{/* One-time expired-trial notice. Sandbox/anonymous demo users have
|
|
no billing (their companies carry trial grants too), so the gate
|
|
lives here where both flags are known. Acknowledgement persists
|
|
per user AND company in user_preferences.ui_state, read here
|
|
server-side so an acked dialog never flashes. */}
|
|
{!isSandbox && !user.is_anonymous && (
|
|
<TrialExpiredDialog
|
|
state={entitlements.entitlementState}
|
|
trialExpiredAt={entitlements.trialExpiredAt}
|
|
companyId={companyId}
|
|
initialAcknowledged={!!uiState.trial_expired_ack?.[companyId]}
|
|
/>
|
|
)}
|
|
<LazyCommandPalette />
|
|
<SettingsHotkey />
|
|
{settingsModal}
|
|
</div>
|
|
{!isSandbox && (
|
|
<AnalyticsIdentify
|
|
user={{
|
|
userId: user.id,
|
|
email: user.email,
|
|
fullName: userProfile?.full_name ?? null,
|
|
role: memberRow.role as CompanyRole,
|
|
}}
|
|
identityHash={computeIdentityHash(user.id)}
|
|
company={{
|
|
id: companyId,
|
|
name: displayName,
|
|
entityType,
|
|
accountingFramework: companyRow.accounting_framework as AccountingFramework,
|
|
paysSalaries,
|
|
trialEndsAt: entitlements.trialEndsAt,
|
|
capabilities: entitlements.capabilities,
|
|
}}
|
|
/>
|
|
)}
|
|
</AgentSheetProvider>
|
|
</ReferenceDataSeed>
|
|
</CompanyProvider>
|
|
)
|
|
}
|