This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user