The dashboard layout runs on every hard load, hard refresh, company switch and the 16 router.refresh() sites, and loading.tsx cannot paint until it resolves. It cost ~20 network calls in 4 sequential waves: a third getUser() round trip to Supabase Auth (after the proxy's and the route guard's), the company resolution, then 16 reads including four limit-1 probes whose only job is to decide whether to render the Webshop and Körjournal nav rows, and an entitlements read that itself ran two waves. - lib/auth/claims.ts: claimsPinned/userFromClaims extracted from require-auth.ts (unchanged) so the dashboard request context shares the exact pinning + mapping. getDashboardAuthContext verifies the JWT locally and falls back to getUser() only when claims are missing, unpinned or unverifiable: the proxy already performed the per-request revocation check before the layout runs (same semantics approved for routes on 2026-07-23). - Wave 1 (user-keyed, parallel with the company resolution): team membership, profile, user preferences and the memberships join, which now also supplies the active company's row and role, so the separate companies and company_members reads are gone. - Wave 2 (company-keyed): settings, agent profile, the switcher's settings names, entitlements in ONE wave (getCompanyEntitlements takes the team_id the join already carries and runs the grants read alongside config + subscription), and get_dashboard_nav_flags(). - supabase/migrations/20260826120000_get_dashboard_nav_flags.sql: SECURITY INVOKER, STABLE, EXECUTE for authenticated only; RLS applies inside. lib/dashboard/nav-flags.ts wraps it with the pre-RPC four-probe fallback on PGRST202/42883/42501 (self-hosted not yet migrated, deploy ordering) and degrades to hidden rows on any other error. - tests/pg/dashboard-nav-flags-rpc.pg.test.ts (6): fresh company, active vs pending WooCommerce, active Shopify, mileage trips, RLS for a member of another company, EXECUTE grants. Unit tests for the wrapper (RPC row, single-object payload, each fallback code, other errors) and for the entitlements teamId option. ~20 calls / 4 waves -> ~12 calls / 2 waves, 0 auth network calls. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
104 lines
3.6 KiB
TypeScript
104 lines
3.6 KiB
TypeScript
import 'server-only'
|
|
|
|
import { cache } from 'react'
|
|
import type { User } from '@supabase/supabase-js'
|
|
import { createClient } from '@/lib/supabase/server'
|
|
import { claimsPinned, userFromClaims } from '@/lib/auth/claims'
|
|
import { getActiveCompanyId } from '@/lib/company/context'
|
|
import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
|
|
|
|
/**
|
|
* Request-local dashboard auth context. React cache shares the Supabase client
|
|
* and auth lookup between the dashboard layout and every nested server page.
|
|
*/
|
|
export const getDashboardAuthContext = cache(async () => {
|
|
const supabase = await createClient()
|
|
// Local JWT verification first (same pinning + mapping as requireAuth,
|
|
// lib/auth/claims.ts): the proxy already performed the per-request
|
|
// revocation check with getUser() before this layout runs, so a second
|
|
// network round trip to Supabase Auth on every hard load, refresh and
|
|
// router.refresh() bought nothing. getUser() stays as the authoritative
|
|
// fallback when claims are missing, unpinned or unverifiable.
|
|
let user: User | null = null
|
|
if (typeof supabase.auth.getClaims === 'function') {
|
|
try {
|
|
const { data } = await supabase.auth.getClaims()
|
|
if (data?.claims?.sub && claimsPinned(data.claims)) user = userFromClaims(data.claims)
|
|
} catch {
|
|
// Fall through to the network check.
|
|
}
|
|
}
|
|
if (!user) {
|
|
const {
|
|
data: { user: fetched },
|
|
} = await supabase.auth.getUser()
|
|
user = fetched
|
|
}
|
|
|
|
return { supabase, user }
|
|
})
|
|
|
|
/**
|
|
* Request-local active company resolution. Nested layouts and pages commonly
|
|
* need the same value, so resolving it once removes repeated preference and
|
|
* membership round trips without caching anything across requests.
|
|
*/
|
|
export const getDashboardCompanyId = cache(async () => {
|
|
const { supabase, user } = await getDashboardAuthContext()
|
|
return user ? getActiveCompanyId(supabase, user.id) : null
|
|
})
|
|
|
|
export const getDashboardSettings = cache(async () => {
|
|
const [{ supabase }, companyId] = await Promise.all([
|
|
getDashboardAuthContext(),
|
|
getDashboardCompanyId(),
|
|
])
|
|
if (!companyId) return { data: null, error: null }
|
|
|
|
// Full row: the layout hands it to the client reference-data cache as the
|
|
// seed for useCompanySettings (which reads select('*') itself), so the
|
|
// narrow column list this once carried would have been refetched on the
|
|
// first mount anyway. The other consumers read a subset of the row.
|
|
return supabase
|
|
.from('company_settings')
|
|
.select('*')
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
})
|
|
|
|
const getDashboardAgentProfile = cache(async () => {
|
|
const [{ supabase }, companyId] = await Promise.all([
|
|
getDashboardAuthContext(),
|
|
getDashboardCompanyId(),
|
|
])
|
|
if (!companyId) return { data: null, error: null }
|
|
|
|
return supabase
|
|
.from('agent_profiles')
|
|
.select('display_name, avatar_id, verified_at')
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
})
|
|
|
|
export const getResolvedDashboardAgentProfile = cache(async () => {
|
|
const [{ supabase }, companyId, settingsResult, profileResult] = await Promise.all([
|
|
getDashboardAuthContext(),
|
|
getDashboardCompanyId(),
|
|
getDashboardSettings(),
|
|
getDashboardAgentProfile(),
|
|
])
|
|
|
|
let profile = profileResult.data
|
|
if (companyId && settingsResult.data?.is_sandbox === true && !profile?.verified_at) {
|
|
await ensureSandboxAgentProfile(supabase, companyId)
|
|
const refreshed = await supabase
|
|
.from('agent_profiles')
|
|
.select('display_name, avatar_id, verified_at')
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
profile = refreshed.data ?? profile
|
|
}
|
|
|
|
return profile
|
|
})
|