// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 // The organization layout. The scope is resolved on the server, once, by calling an // organization-scoped procedure: a member who is not a member gets the same NotFound a stranger // does, and nothing below this layout has to check again (spec §3.3, §6.4). // // It is also where the organization's own brand is applied (spec §10.3): the colours as custom // properties on an element wrapped around everything the organization sees, so the first paint of // every page is already branded, and the display name and logo in the switcher -- beside the // organization switcher itself, for a person who belongs to more than one. import { PROFILE } from "@app/config" import { appModules } from "@app/modules" import { type BrandView, logoUrl, tenantLogo, tenantStyle } from "@app/modules/branding" import { atLeast, brandingSchema, meGet, ORG_ROLE_AS_ROLE, orgGet } from "@conusai-kit/tenancy" import { AppShell } from "@conusai-kit/ui" import { redirect } from "next/navigation" import type { CSSProperties, ReactElement, ReactNode } from "react" import { webRuntime } from "../../src/server/app" import { orFallback, signInHref } from "../../src/server/guard" import { pageContext } from "../../src/server/page" import { ClientModules } from "../../src/ui/client-modules" import { OrganizationSwitch } from "../../src/ui/shell-controls" export const dynamic = "force-dynamic" // No generateMetadata here: the organization's share image (`/{org}/brand/og`) answers 404 to // anyone who is not a signed-in member, which is every link-preview crawler, and org existence is // deliberately indistinguishable from the outside. The root layout's metadata therefore stands: // the app's own public share image. /** One stored logo, at 1x and at 2x for a high-density screen. */ function TenantLogo({ className, path }: { className: string; path: string }): ReactElement { return ( ) } /** The modules' navigation, with each link made absolute inside this organization. */ const navigationFor = (org: string) => appModules .filter((module) => PROFILE !== "convex" || ["tenancy", "auth", "files", "notes"].includes(module.id)) .map((module) => module.ui?.nav ? { ...module, ui: { ...module.ui, nav: module.ui.nav.map((item) => ({ ...item, href: `/${org}/${item.href}` })) }, } : module, ) /** What `organizations.branding` holds, or nothing: a value that does not parse is not applied, whatever wrote it. */ function brandOf(raw: unknown): BrandView { const parsed = brandingSchema.safeParse(raw) const branding = parsed.success ? parsed.data : {} return { displayName: branding.displayName ?? null, primary: branding.primary ?? null, accent: branding.accent ?? null, logoLight: branding.logoLight ?? null, logoDark: branding.logoDark ?? null, } } export default async function OrgLayout({ children, params, }: { children: ReactNode params: Promise<{ org: string }> }): Promise { const { org } = await params const { caller, i18n, signedIn, path } = await pageContext() if (!signedIn) redirect(signInHref(path)) // A stranger and a missing organization both end at the not-found page (guard.ts): the words and // the status are the same, so the layout cannot be used to ask which organizations exist. const [organization, me] = await orFallback(path, () => Promise.all([caller.call(orgGet, { org }), caller.call(meGet, undefined)]), ) const role = me.organizations.find((entry) => entry.slug === org)?.role // The permissions this membership holds, so the shell drops nav items that would only be // refused (kit-ui filters when the prop is given; the registry is the permission source). const heldPermissions = role === undefined ? [] : Object.entries(webRuntime().registry.permissions) .filter(([, declared]) => atLeast(ORG_ROLE_AS_ROLE[role], declared.role)) .map(([name]) => name) // The audit log and the brand are for administrators; a link a member would only be refused at is noise. const administers = role === "admin" || role === "owner" const brand = brandOf(organization.branding) // A theme shows its own logo, or the other one when only one is uploaded (`tenantLogo`, the branding contract). const lightLogo = tenantLogo(brand, "light") const darkLogo = tenantLogo(brand, "dark") // The shell renders the
every page inside this layout fills, so an organization page // returns its content and not a second landmark (kit-ui's AppShell is the accessible one). return (
i18n.message(key)} navigationLabel={i18n.message("ui.navigation")} permissions={heldPermissions} switcher={ <> {darkLogo ? : null} {lightLogo ? : null} {brand.displayName ?? organization.name} {me.organizations.length > 1 ? ( ({ slug: entry.slug, name: entry.name }))} /> ) : null} {administers ? {i18n.message("app.audit")} : null} {administers && PROFILE !== "convex" ? ( {i18n.message("branding.title")} ) : null} } > {children} {/* The enabled modules' browser providers (an in-app guide's overlay), beside the page. */}
) }