Files
accounted/app/(dashboard)/layout.tsx
T
Mattsson a1a816b4a5 Delete features (#218)
* Implement company and account deletion features

- Add event types for company and account deletion to CoreEvent.
- Enhance Supabase middleware to handle company context resolution and cookie management for archived companies.
- Create API routes for deleting accounts and companies, including necessary validations and event emissions.
- Implement tests for account and company deletion endpoints to ensure proper functionality and error handling.
- Add retention notice component to inform users about bookkeeping data retention during destructive actions.
- Create database migrations to support soft deletion of companies and anonymization of user accounts, ensuring compliance with retention laws.

* feat: enhance account deletion process and update user notifications

* Add service client for onboarding completion check and update escape hatch visibility

* Enhance invite flow and email handling for company members

* Refactor company context and RLS policies for active company isolation

- Update `switchCompany` to remove unnecessary revalidation as client handles navigation.
- Revise `getActiveCompanyId` to prioritize `user_preferences` and validate against non-archived memberships.
- Modify `setActiveCompany` to ensure `user_preferences` is the authoritative source while maintaining cookie compatibility.
- Enhance middleware to resolve active company using `user_preferences` and fallback to first non-archived membership.
- Introduce new API route `/api/company/current` to fetch the active company ID for cross-tab synchronization.
- Implement `CompanyTabSync` component for real-time active company enforcement across tabs.
- Create migration for RLS policies to enforce single-active-company isolation using `current_active_company_id()`.

* feat: implement viewer role enforcement for write permissions

- Added `useCanWrite` hook to determine if the current user has write permissions based on their role in the active company.
- Updated various components (JournalEntryForm, CustomerForm, DeadlineForm, etc.) to disable write actions and show a lock icon with a tooltip for users without write permissions.
- Introduced `requireWritePermission` function to enforce write permissions at the API level, returning a 403 response for viewers.
- Created tests to verify the behavior of the viewer role and write permissions.
- Added database migration to enforce read-only access for viewers at the database level.
2026-04-11 17:06:32 +02:00

240 lines
8.3 KiB
TypeScript

import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { headers } from 'next/headers'
import DashboardNav from '@/components/dashboard/DashboardNav'
import CompanyTabSync from '@/components/dashboard/CompanyTabSync'
import { RecaptIdentify } from '@/components/RecaptIdentify'
import { SentryIdentify } from '@/components/SentryIdentify'
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
import { getExtensionNavItems } from '@/lib/extensions/sectors'
import { CompanyProvider } from '@/contexts/CompanyContext'
import { getActiveCompanyId } from '@/lib/company/context'
import type { EntityType, CompanyRole, Team } from '@/types'
/**
* 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']
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
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.
const companyId = await getActiveCompanyId(supabase, user.id)
// Read the pathname forwarded by middleware so we can branch on it.
const headerStore = await headers()
const pathname = headerStore.get('x-pathname') ?? ''
const isNoCompanyAllowed = NO_COMPANY_ALLOWED_PATHS.some((p) =>
pathname.startsWith(p)
)
// Fetch team membership + team info
const { data: teamMembership } = await supabase
.from('team_members')
.select('team_id, role')
.eq('user_id', user.id)
.limit(1)
.maybeSingle()
let team: Team | null = null
if (teamMembership?.team_id) {
const { data: teamRow } = await supabase
.from('teams')
.select('*')
.eq('id', teamMembership.team_id)
.single()
team = teamRow
}
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,
}}
>
<CompanyTabSync />
<div className="min-h-screen bg-background">
<DashboardNav
companyName="gnubok"
entityType="enskild_firma"
uncategorizedTransactionCount={0}
pendingOperationsCount={0}
isSandbox={false}
extensionNavItems={getExtensionNavItems()}
/>
<main
id="main-content"
className="safe-area-main-padding md:!pb-0 md:pl-[232px]"
role="main"
>
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
{children}
</div>
</main>
<SentryIdentify userId={user.id} email={user.email} />
</div>
</CompanyProvider>
)
}
// Fetch company + membership for context provider
const [
{ data: companyRow },
{ data: memberRow },
{ data: allMemberships },
] = 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, created_by, team_id, archived_at, created_at, updated_at)').eq('user_id', user.id),
])
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) => ({
company: m.companies as unknown as import('@/types').Company,
role: m.role as CompanyRole,
})),
isTeamMember,
team,
}
return (
<CompanyProvider value={companyContextValue}>
<CompanyTabSync />
<div className="min-h-screen bg-background">
<DashboardNav
companyName="gnubok"
entityType="enskild_firma"
uncategorizedTransactionCount={0}
pendingOperationsCount={0}
isSandbox={false}
extensionNavItems={getExtensionNavItems()}
/>
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-[232px]" role="main">
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
{children}
</div>
</main>
<SentryIdentify userId={user.id} email={user.email} />
</div>
</CompanyProvider>
)
}
const [{ data: settings }, { count: uncategorizedCount }, { count: pendingOpsCount }] = await Promise.all([
supabase
.from('company_settings')
.select('company_name, onboarding_complete, entity_type, is_sandbox')
.eq('company_id', companyId)
.single(),
supabase
.from('transactions')
.select('*', { count: 'exact', head: true })
.eq('company_id', companyId)
.is('is_business', null),
supabase
.from('pending_operations')
.select('*', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('status', 'pending'),
])
// 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
const companyWithName = { ...companyRow, name: displayName }
const companyContextValue = {
company: companyWithName,
role: memberRow.role as CompanyRole,
companies: (allMemberships || []).map((m) => {
const c = m.companies as unknown as import('@/types').Company
// Override active company's name with settings name
if (c.id === companyId) {
return { company: { ...c, name: displayName }, role: m.role as CompanyRole }
}
return { company: c, role: m.role as CompanyRole }
}),
isTeamMember,
team,
}
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const isSandbox = settings?.is_sandbox === true
return (
<CompanyProvider value={companyContextValue}>
<CompanyTabSync />
<div className="min-h-screen bg-background">
{/* 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}
uncategorizedTransactionCount={uncategorizedCount ?? 0}
pendingOperationsCount={pendingOpsCount ?? 0}
isSandbox={isSandbox}
extensionNavItems={getExtensionNavItems()}
/>
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-[232px]" role="main">
<div key={companyId} className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
{children}
</div>
</main>
<SentryIdentify userId={user.id} email={user.email} />
{!isSandbox && (
<RecaptIdentify
userId={user.id}
email={user.email}
displayName={settings?.company_name || undefined}
/>
)}
</div>
</CompanyProvider>
)
}