Files
template-app/apps/web/app/[org]/layout.tsx
T

141 lines
6.2 KiB
TypeScript

// 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 (
<img
className={className}
src={logoUrl(path, "1x")}
srcSet={`${logoUrl(path, "1x")} 1x, ${logoUrl(path, "2x")} 2x`}
alt=""
/>
)
}
/** 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<ReactElement> {
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 <main> 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 (
<div
data-tenant-brand={brand.primary ?? brand.accent ?? undefined}
style={tenantStyle(brand).style as CSSProperties}
>
<AppShell
modules={navigationFor(org)}
scope="org"
t={(key) => i18n.message(key)}
navigationLabel={i18n.message("ui.navigation")}
permissions={heldPermissions}
switcher={
<>
<a href={`/${org}`} className="app-brand-link">
{darkLogo ? <TenantLogo className="app-logo app-logo-dark" path={darkLogo.path} /> : null}
{lightLogo ? <TenantLogo className="app-logo app-logo-light" path={lightLogo.path} /> : null}
{brand.displayName ?? organization.name}
</a>
{me.organizations.length > 1 ? (
<OrganizationSwitch
current={org}
label={i18n.message("shell.switchOrganization")}
organizations={me.organizations.map((entry) => ({ slug: entry.slug, name: entry.name }))}
/>
) : null}
{administers ? <a href={`/${org}/audit`}>{i18n.message("app.audit")}</a> : null}
{administers && PROFILE !== "convex" ? (
<a href={`/${org}/branding`}>{i18n.message("branding.title")}</a>
) : null}
</>
}
>
{children}
</AppShell>
{/* The enabled modules' browser providers (an in-app guide's overlay), beside the page. */}
<ClientModules context={{ org, locale: i18n.locale, user: me.userId }} />
</div>
)
}