mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-15 18:53:06 +00:00
144 lines
7.3 KiB
TypeScript
144 lines
7.3 KiB
TypeScript
import config from '@payload-config'
|
|
import { cookies, headers } from 'next/headers'
|
|
import { redirect } from 'next/navigation'
|
|
import { getPayload } from 'payload'
|
|
import React from 'react'
|
|
|
|
import { CampaignListPendingBoundary } from '@/components/campaign/shared/CampaignListPending'
|
|
import { CampaignAISidebarShell } from '@/components/campaign/shell/ai/CampaignAISidebarShell'
|
|
import {
|
|
BiometricEnrollmentToast,
|
|
type BiometricEnrollmentOffer,
|
|
} from '@/components/campaign/shell/BiometricEnrollmentToast'
|
|
import { CampaignAppScrollChrome } from '@/components/campaign/shell/CampaignAppScrollChrome'
|
|
import { CampaignBottomNav } from '@/components/campaign/shell/CampaignBottomNav'
|
|
import { CampaignDesktopHeader } from '@/components/campaign/shell/CampaignDesktopHeader'
|
|
import { CampaignHomeSearchChromeProvider } from '@/components/campaign/shell/CampaignHomeSearchChromeContext'
|
|
import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar'
|
|
import { CampaignNotificationBellSlot } from '@/components/campaign/shell/CampaignNotificationBellSlot'
|
|
import { CampaignPageChromeProvider } from '@/components/campaign/shell/CampaignPageChromeContext'
|
|
import {
|
|
CampaignPushOptInToast,
|
|
type CampaignPushOptInOffer,
|
|
} from '@/components/campaign/shell/CampaignPushOptInToast'
|
|
import { CampaignQuickActionContextProvider } from '@/components/campaign/shell/CampaignQuickActionContext'
|
|
import { CampaignSidebar } from '@/components/campaign/shell/CampaignSidebar'
|
|
import { CampaignSidebarViewportDefault } from '@/components/campaign/shell/CampaignSidebarViewportDefault'
|
|
import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext'
|
|
import { InstallPwaToast } from '@/components/campaign/shell/InstallPwaToast'
|
|
import { SIDEBAR_COOKIE_NAME, SidebarInset, SidebarProvider } from '@/components/ui/Sidebar'
|
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
|
import { advisorEditingScope, type AdvisorEditingScope } from '@/lib/campaignAdvisorProfile'
|
|
import { isStaffCampaignRole } from '@/lib/campaignRoles'
|
|
import { deviceLabelFromUserAgent } from '@/lib/deviceLabel'
|
|
import { getCampaignUserWithAvatar } from '@/utilities/campaignAuth'
|
|
import { getCampaignPushConsent } from '@/utilities/campaignConsent'
|
|
import { campaignUserShellView } from '@/utilities/campaignUserProfile'
|
|
import { getCampaignVapidPublicKey } from '@/utilities/notification/sendCampaignPush'
|
|
import { loadCampaignPasskeys } from '@/utilities/webauthn/campaignWebAuthnCeremony'
|
|
import { resolveCampaignWebAuthnRelyingParty } from '@/utilities/webauthn/campaignWebAuthnConfig'
|
|
|
|
export default async function CampaignAppLayout({ children }: { children: React.ReactNode }) {
|
|
const user = await getCampaignUserWithAvatar()
|
|
|
|
if (!user) {
|
|
redirect('/campanha/login')
|
|
}
|
|
|
|
const cookieStore = await cookies()
|
|
const sidebarStateCookie = cookieStore.get(SIDEBAR_COOKIE_NAME)
|
|
const hasSidebarCookie = sidebarStateCookie !== undefined
|
|
const defaultOpen = sidebarStateCookie ? sidebarStateCookie.value === 'true' : true
|
|
|
|
// B40 discovery. The relying party is decided from headers alone, so it is
|
|
// resolved first and this layout — which runs on every authenticated
|
|
// navigation — reads no passkeys at all on an origin that cannot host a
|
|
// ceremony. Whether THIS device is already enrolled is a question only the
|
|
// browser can answer, so the island finishes the decision.
|
|
const relyingParty = await resolveCampaignWebAuthnRelyingParty()
|
|
const isStaff = isStaffCampaignRole(user.role)
|
|
|
|
// C142 — the write scope is resolved once and threaded to the Chrome shell
|
|
// (FAB mount rule) and to every page via the user prop.
|
|
const editingScope: AdvisorEditingScope =
|
|
user.role === 'advisor' ? advisorEditingScope(user.visibility, user.editing) : 'tudo'
|
|
|
|
let biometricEnrollment: BiometricEnrollmentOffer | null = null
|
|
let pushOptIn: CampaignPushOptInOffer | null = null
|
|
if (relyingParty || isStaff) {
|
|
const payload = await getPayload({ config })
|
|
const [passkeys, requestHeaders, pushConsent] = await Promise.all([
|
|
relyingParty ? loadCampaignPasskeys(payload, user.id) : Promise.resolve([]),
|
|
headers(),
|
|
isStaff ? getCampaignPushConsent(payload) : Promise.resolve(null),
|
|
])
|
|
if (relyingParty) {
|
|
biometricEnrollment = {
|
|
// Only the emptiness crosses the boundary: the device labels have no
|
|
// business in the RSC payload of every page.
|
|
hasEnrolledPasskeys: passkeys.length > 0,
|
|
suggestedDeviceLabel: deviceLabelFromUserAgent(requestHeaders.get('user-agent')),
|
|
}
|
|
}
|
|
if (isStaff) {
|
|
pushOptIn = {
|
|
pushConsentConfigured: pushConsent !== null,
|
|
vapidPublicKey: getCampaignVapidPublicKey(),
|
|
}
|
|
}
|
|
}
|
|
|
|
return (
|
|
// print: unlock the h-svh/overflow-hidden shells and drop the app chrome,
|
|
// otherwise only the first page of the municipality dossier prints (E16).
|
|
<SidebarProvider
|
|
defaultOpen={defaultOpen}
|
|
className="h-svh min-h-0 overflow-hidden print:h-auto print:overflow-visible"
|
|
>
|
|
<CampaignSidebarViewportDefault hasSidebarCookie={hasSidebarCookie} />
|
|
<CampaignSidebar user={campaignUserShellView(user)} />
|
|
<SidebarInset className="h-svh min-h-0 overflow-hidden print:h-auto print:overflow-visible">
|
|
<CampaignAISidebarShell role={user.role}>
|
|
<CampaignPageChromeProvider role={user.role}>
|
|
<CampaignWizardChromeProvider>
|
|
<CampaignHomeSearchChromeProvider>
|
|
<CampaignQuickActionContextProvider>
|
|
<CampaignListPendingBoundary>
|
|
<CampaignMobileTopBar
|
|
notificationBell={<CampaignNotificationBellSlot user={user} />}
|
|
/>
|
|
<CampaignDesktopHeader
|
|
notificationBell={<CampaignNotificationBellSlot user={user} />}
|
|
/>
|
|
{/*
|
|
Provider must wrap CampaignAppScrollChrome, not only page children:
|
|
the quick-actions FAB overlay (B126) mounts as a sibling of
|
|
the scrollport and renders search hits with CampaignHoverTooltip
|
|
(priority flag). Nested only around {children} left focus→suggest
|
|
without a provider and crashed the page (B102).
|
|
*/}
|
|
<TooltipProvider delayDuration={300}>
|
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
<CampaignAppScrollChrome role={user.role} editingScope={editingScope}>
|
|
{children}
|
|
</CampaignAppScrollChrome>
|
|
</div>
|
|
{isStaffCampaignRole(user.role) ? (
|
|
<CampaignBottomNav user={campaignUserShellView(user)} />
|
|
) : null}
|
|
</TooltipProvider>
|
|
<InstallPwaToast />
|
|
{pushOptIn ? <CampaignPushOptInToast {...pushOptIn} /> : null}
|
|
{biometricEnrollment ? (
|
|
<BiometricEnrollmentToast {...biometricEnrollment} />
|
|
) : null}
|
|
</CampaignListPendingBoundary>
|
|
</CampaignQuickActionContextProvider>
|
|
</CampaignHomeSearchChromeProvider>
|
|
</CampaignWizardChromeProvider>
|
|
</CampaignPageChromeProvider>
|
|
</CampaignAISidebarShell>
|
|
</SidebarInset>
|
|
</SidebarProvider>
|
|
)
|
|
}
|