141 lines
6.2 KiB
TypeScript
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>
|
|
)
|
|
}
|