@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The client entry: start observability before the app hydrates (what Next's `instrumentation-client.ts`
|
||||
// did; src/observe/browser.ts holds all of it), then hydrate the document the server rendered.
|
||||
//
|
||||
// Install React event replay immediately, and prepare the current view before its first render.
|
||||
// The server-rendered page remains in place while the router and its matched view load.
|
||||
|
||||
import "./zod-browser"
|
||||
|
||||
import { instrumentationClient } from "@conusai-sdk/observe-start/client"
|
||||
import { Await, RouterProvider } from "@tanstack/react-router"
|
||||
import { hydrateStart } from "@tanstack/react-start/client"
|
||||
import { StrictMode, startTransition } from "react"
|
||||
import { hydrateRoot } from "react-dom/client"
|
||||
import { startBrowserObserve } from "./observe/browser"
|
||||
import { prepareInitialModuleViews } from "./ui/page-views"
|
||||
|
||||
export const observe = startBrowserObserve(document, instrumentationClient)
|
||||
|
||||
const hydration = hydrateStart().then(async (router) => {
|
||||
await prepareInitialModuleViews(router.state.matches)
|
||||
return router
|
||||
})
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<Await promise={hydration}>{(router) => <RouterProvider router={router} />}</Await>
|
||||
</StrictMode>,
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The browser half of observability (spec §9.5): `instrumentation-client.ts` runs this before the
|
||||
// app hydrates, so an error thrown during hydration is already captured.
|
||||
//
|
||||
// There is no `NEXT_PUBLIC_*` value to bake in (spec §6.4): the server writes the two facts the
|
||||
// browser needs -- that observability is on, and the release and environment to report -- into the
|
||||
// document as a `<meta>` element (`observeMeta`), and this module reads them back. A deployment
|
||||
// without a DSN writes nothing, and the browser starts nothing: no request to the platform's
|
||||
// ingest, no handler on `window`, and no console noise.
|
||||
//
|
||||
// On the platform the SDK sends to this app's own origin (`/api/_conusai/*`, which the edge answers),
|
||||
// so the content security policy needs no extra `connect-src`.
|
||||
|
||||
import type { Observe, ObserveOptions } from "@conusai-sdk/observe"
|
||||
import { type ConsentChoice, type PrivacyNavigator, privacyRestricted, readConsentChoice } from "./consent"
|
||||
|
||||
export const OBSERVE_META_NAME = "app-observe"
|
||||
|
||||
/** What the server tells the browser. */
|
||||
export interface BrowserObserveSettings {
|
||||
readonly release: string
|
||||
readonly environment: string
|
||||
}
|
||||
|
||||
/** The attributes of the `<meta>` element the document carries when observability is on. */
|
||||
export function observeMeta(settings: BrowserObserveSettings): Record<string, string> {
|
||||
return {
|
||||
name: OBSERVE_META_NAME,
|
||||
content: "on",
|
||||
"data-release": settings.release,
|
||||
"data-environment": settings.environment,
|
||||
}
|
||||
}
|
||||
|
||||
/** The settings in a document, or nothing when the server did not write them. */
|
||||
export function readBrowserSettings(document: Pick<Document, "querySelector">): BrowserObserveSettings | undefined {
|
||||
const meta = document.querySelector(`meta[name="${OBSERVE_META_NAME}"]`)
|
||||
if (meta?.getAttribute("content") !== "on") return undefined
|
||||
return {
|
||||
release: meta.getAttribute("data-release") ?? "dev",
|
||||
environment: meta.getAttribute("data-environment") ?? "production",
|
||||
}
|
||||
}
|
||||
|
||||
/** Analytics and traces wait for opt-in. Replay is a separate purpose and loads only after its own yes. */
|
||||
export function browserObserveOptions(
|
||||
settings: BrowserObserveSettings,
|
||||
choice?: ConsentChoice,
|
||||
restricted = false,
|
||||
): ObserveOptions {
|
||||
return {
|
||||
release: settings.release,
|
||||
environment: settings.environment,
|
||||
consent: "opt-in",
|
||||
respectGPC: true,
|
||||
modules: { traces: true, analytics: true, ...(choice?.replay && !restricted ? { replay: true } : {}) },
|
||||
}
|
||||
}
|
||||
|
||||
/** Restores a recorded choice without storing anything new. GPC always wins over an older grant. */
|
||||
export function startBrowserObserve(
|
||||
document: Pick<Document, "querySelector" | "cookie">,
|
||||
start: (options: ObserveOptions) => Observe,
|
||||
navigator?: PrivacyNavigator,
|
||||
): Observe | undefined {
|
||||
const settings = readBrowserSettings(document)
|
||||
const restricted = privacyRestricted(navigator ?? globalThis.navigator)
|
||||
const choice = readConsentChoice(document.cookie)
|
||||
const started = settings ? start(browserObserveOptions(settings, choice, restricted)) : undefined
|
||||
if (started) {
|
||||
holder()[INSTANCE] = started
|
||||
if (restricted || choice?.analytics === false || (!choice && edgeGranted(document.cookie))) started.consent.revoke()
|
||||
else if (choice?.analytics) started.consent.grant()
|
||||
}
|
||||
return started
|
||||
}
|
||||
|
||||
/**
|
||||
* The edge's own record of a grant (`_conusai_consent`), which outlives a choice of this app that is
|
||||
* missing, malformed or under an older notice: withdrawn then, or the edge goes on setting its cookies
|
||||
* while the banner asks again. Nothing is written for a visitor the edge holds no grant for.
|
||||
*/
|
||||
const edgeGranted = (cookie: string | undefined): boolean =>
|
||||
cookie?.split(";").some((part) => part.trim() === "_conusai_consent=granted") ?? false
|
||||
|
||||
// The instance is kept on `globalThis`: `instrumentation-client.ts` is its own entry in the bundle
|
||||
// graph, and an error boundary further down must reach the very instance it started.
|
||||
const INSTANCE = Symbol.for("conusai.app.observe")
|
||||
const holder = (): Record<symbol, Observe | undefined> => globalThis as unknown as Record<symbol, Observe | undefined>
|
||||
|
||||
/**
|
||||
* Reports an error an error boundary caught. React swallows what a boundary catches, so the SDK's
|
||||
* `window` handlers never see it; this is how it reaches observability. A no-op when the deployment
|
||||
* has none.
|
||||
*/
|
||||
export function reportClientError(error: unknown): void {
|
||||
holder()[INSTANCE]?.captureException(error)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
|
||||
import type { Observe } from "@conusai-sdk/observe"
|
||||
|
||||
export const CONSENT_COOKIE = "app_consent"
|
||||
export const CONSENT_NOTICE_VERSION = "2026-10"
|
||||
export const CONSENT_SECONDS = 60 * 60 * 24 * 182
|
||||
|
||||
export interface ConsentChoice {
|
||||
readonly analytics: boolean
|
||||
readonly replay: boolean
|
||||
readonly at: string
|
||||
readonly notice: string
|
||||
}
|
||||
|
||||
export interface PrivacyNavigator {
|
||||
readonly globalPrivacyControl?: unknown
|
||||
readonly doNotTrack?: unknown
|
||||
}
|
||||
|
||||
export function privacyRestricted(navigator: PrivacyNavigator | undefined): boolean {
|
||||
try {
|
||||
return navigator?.globalPrivacyControl === true || navigator?.doNotTrack === "1"
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function readConsentChoice(cookie: string | undefined, now = Date.now()): ConsentChoice | undefined {
|
||||
const raw = cookie
|
||||
?.split(";")
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.startsWith(`${CONSENT_COOKIE}=`))
|
||||
?.slice(CONSENT_COOKIE.length + 1)
|
||||
if (!raw) return undefined
|
||||
try {
|
||||
const choice = JSON.parse(decodeURIComponent(raw)) as Partial<ConsentChoice>
|
||||
const at = typeof choice.at === "string" ? Date.parse(choice.at) : Number.NaN
|
||||
if (
|
||||
typeof choice.analytics !== "boolean" ||
|
||||
typeof choice.replay !== "boolean" ||
|
||||
choice.notice !== CONSENT_NOTICE_VERSION ||
|
||||
!Number.isFinite(at) ||
|
||||
at > now ||
|
||||
now - at >= CONSENT_SECONDS * 1000
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return { analytics: choice.analytics, replay: choice.replay, at: choice.at as string, notice: choice.notice }
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function consentCookie(choice: ConsentChoice): string {
|
||||
return `${CONSENT_COOKIE}=${encodeURIComponent(JSON.stringify(choice))}; Path=/; Max-Age=${CONSENT_SECONDS}; SameSite=Lax; Secure`
|
||||
}
|
||||
|
||||
export function browserObserve(): Observe | undefined {
|
||||
return (globalThis as unknown as Record<symbol, Observe | undefined>)[Symbol.for("conusai.app.observe")]
|
||||
}
|
||||
|
||||
export function recordConsentChoice(
|
||||
choice: Pick<ConsentChoice, "analytics" | "replay">,
|
||||
now = new Date(),
|
||||
): ConsentChoice {
|
||||
const record = { ...choice, at: now.toISOString(), notice: CONSENT_NOTICE_VERSION }
|
||||
const observe = browserObserve()
|
||||
if (choice.analytics && !privacyRestricted(globalThis.navigator)) observe?.consent.grant()
|
||||
else observe?.consent.revoke()
|
||||
if (!choice.replay) observe?.replay.stop()
|
||||
return record
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The router: the generated route tree (src/routeTree.gen.ts, written by the Start plugin and not
|
||||
// committed), and the request's nonce for the scripts the renderer injects (src/server.ts makes it).
|
||||
|
||||
import { navigationTransitions } from "@conusai-kit/ui/frame"
|
||||
import { createRouter } from "@tanstack/react-router"
|
||||
import { createIsomorphicFn } from "@tanstack/react-start"
|
||||
import { getRequestHeader } from "@tanstack/react-start/server"
|
||||
import { routeTree } from "./routeTree.gen"
|
||||
import { RoutePending } from "./ui/route-pending"
|
||||
|
||||
const requestNonce = createIsomorphicFn()
|
||||
.server(() => getRequestHeader("x-nonce"))
|
||||
.client(() => undefined)
|
||||
|
||||
export function getRouter() {
|
||||
const nonce = requestNonce()
|
||||
return createRouter({
|
||||
routeTree,
|
||||
defaultPendingComponent: RoutePending,
|
||||
defaultPendingMs: 180,
|
||||
defaultPendingMinMs: 220,
|
||||
defaultViewTransition: navigationTransitions(),
|
||||
...(nonce ? { ssr: { nonce } } : {}),
|
||||
// Every loader runs on the server for every navigation (F10): nothing is preloaded and nothing cached in the browser,
|
||||
// and the browser restores the scroll position of a page it loaded, so the router keeps no session storage.
|
||||
defaultPreload: false,
|
||||
defaultStaleTime: 0,
|
||||
})
|
||||
}
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: ReturnType<typeof getRouter>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The one route every module's pages are mounted through: `/<org>/<workspace>/<project>/<module>/...` (F10).
|
||||
//
|
||||
// The loader is a server function: it checks the session, then runs the module's `load` (`modules/pages.ts`)
|
||||
// with the request's own caller, messages and query string, and returns plain data. The component renders the module's
|
||||
// `View` with that data, inside the project route's transport and the organization route's <main>. A new
|
||||
// module therefore adds no route file here. A segment no module answers, a path the module does not know and
|
||||
// a record of another tenant are the same not-found page.
|
||||
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import { type ReactElement, Suspense } from "react"
|
||||
import type { ModulePageData, ModulePageParams } from "../server/page"
|
||||
import { projectPageViews } from "../ui/page-views"
|
||||
import { RoutePending } from "../ui/route-pending"
|
||||
|
||||
const ROUTE = "/$org/$workspace/$project/$module/$"
|
||||
|
||||
const readModulePage = createServerFn({ method: "GET" })
|
||||
.validator((params: ModulePageParams) => params)
|
||||
.handler(async ({ data }): Promise<ModulePageData> => {
|
||||
const { modulePageData, reported } = await import("../server/page")
|
||||
return reported(ROUTE, () => modulePageData(data))
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/$org/$workspace/$project/$module/$")({
|
||||
loaderDeps: ({ search }) => ({ search }),
|
||||
loader: ({ params, location }) =>
|
||||
readModulePage({
|
||||
data: {
|
||||
org: params.org,
|
||||
workspace: params.workspace,
|
||||
project: params.project,
|
||||
module: params.module,
|
||||
...(params._splat ? { _splat: params._splat } : {}),
|
||||
...(location.searchStr ? { search: location.searchStr } : {}),
|
||||
},
|
||||
}),
|
||||
head: ({ loaderData }) => (loaderData?.title ? { meta: [{ title: loaderData.title }] } : {}),
|
||||
component: ModuleRoute,
|
||||
})
|
||||
|
||||
function ModuleRoute(): ReactElement {
|
||||
const { org, workspace, project } = Route.useParams()
|
||||
const page = Route.useLoaderData()
|
||||
const View = projectPageViews.viewOf(page.module)
|
||||
return (
|
||||
<Suspense fallback={<RoutePending />}>
|
||||
<View
|
||||
key={`${org}:${workspace}:${project}:${page.module}:${page.segments.join("/")}`}
|
||||
slugs={{ org, workspace, project }}
|
||||
segments={page.segments}
|
||||
locale={page.locale}
|
||||
data={page.data}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// One document: its extracted lines, each with where it was read, and the reviewer's decisions.
|
||||
// Approve, edit and reject are procedure calls from the client component; nothing is exported
|
||||
// until a line is approved, and the export is the last control on the page. The document is live: the
|
||||
// loader's answer is the seed, the job's progress arrives through the live stream, and a decision reads it
|
||||
// again, so the header, the counts and the export follow without a reload.
|
||||
|
||||
import { documentHeader, ExportButton, LineReview, useLiveDocument } from "@app/modules/documents/ui"
|
||||
import { Page as PageFrame, PageHeader, PageSection } from "@conusai-kit/ui/patterns"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { ReactElement } from "react"
|
||||
import type { DocumentData, DocumentParams } from "../server/org-pages"
|
||||
|
||||
const ROUTE = "/$org/$workspace/$project/documents/$fileId"
|
||||
|
||||
const read = createServerFn({ method: "GET" })
|
||||
.validator((params: DocumentParams) => params)
|
||||
.handler(async ({ data }): Promise<DocumentData> => {
|
||||
const [{ reported }, { documentData }] = await Promise.all([
|
||||
import("../server/page"),
|
||||
import("../server/org-pages"),
|
||||
])
|
||||
return reported(ROUTE, () => documentData(data))
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/$org/$workspace/$project/documents/$fileId")({
|
||||
loader: ({ params }) =>
|
||||
read({
|
||||
data: {
|
||||
org: params.org,
|
||||
workspace: params.workspace,
|
||||
project: params.project,
|
||||
fileId: params.fileId,
|
||||
},
|
||||
}),
|
||||
head: ({ loaderData }) => (loaderData?.title ? { meta: [{ title: loaderData.title }] } : {}),
|
||||
component: Page,
|
||||
})
|
||||
|
||||
function Page(): ReactElement {
|
||||
const { fileId, ...slugs } = Route.useParams()
|
||||
const data = Route.useLoaderData()
|
||||
const live = useLiveDocument(slugs, fileId, { document: data.document, lines: data.lines })
|
||||
const header = documentHeader(live.document, data.header, data.locale)
|
||||
return (
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
title={data.title}
|
||||
description={header.status}
|
||||
breadcrumb={<a href={`/${slugs.org}/${slugs.workspace}/${slugs.project}/documents`}>{data.back}</a>}
|
||||
actions={
|
||||
<ExportButton slugs={slugs} fileId={fileId} approved={live.document.approved} labels={data.exportLabels} />
|
||||
}
|
||||
>
|
||||
<span>{header.counts}</span>
|
||||
{header.waiting ? <span role="status">{header.waiting}</span> : null}
|
||||
</PageHeader>
|
||||
{header.skipped ? (
|
||||
<PageSection title={header.skipped.title}>
|
||||
<ul>
|
||||
{header.skipped.pages.map((skipped) => (
|
||||
<li key={skipped.page}>{skipped.text}</li>
|
||||
))}
|
||||
</ul>
|
||||
</PageSection>
|
||||
) : null}
|
||||
<LineReview
|
||||
slugs={slugs}
|
||||
fileId={fileId}
|
||||
lines={live.lines}
|
||||
labels={data.review}
|
||||
locale={data.locale}
|
||||
onDecided={live.refetch}
|
||||
/>
|
||||
</PageFrame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The project's documents: upload one, and see where each is in being read and reviewed. Every
|
||||
// read is a procedure through the same caller the API uses; the upload is a client form that
|
||||
// calls the files module's own procedure (spec §6.2: one API path). The list is live: the loader's
|
||||
// documents are the seed, and the job's progress reaches every member's open page without a reload.
|
||||
|
||||
import { DocumentList, UploadForm, useLiveDocuments } from "@app/modules/documents/ui"
|
||||
import { downloadHref } from "@conusai-kit/ui/download"
|
||||
import { FormPanel, Page as PageFrame, PageHeader } from "@conusai-kit/ui/patterns"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { ReactElement } from "react"
|
||||
import type { DocumentsData, ProjectParams } from "../server/org-pages"
|
||||
|
||||
const ROUTE = "/$org/$workspace/$project/documents/"
|
||||
|
||||
const read = createServerFn({ method: "GET" })
|
||||
.validator((params: ProjectParams) => params)
|
||||
.handler(async ({ data }): Promise<DocumentsData> => {
|
||||
const [{ reported }, { documentsData }] = await Promise.all([
|
||||
import("../server/page"),
|
||||
import("../server/org-pages"),
|
||||
])
|
||||
return reported(ROUTE, () => documentsData(data))
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/$org/$workspace/$project/documents/")({
|
||||
loader: ({ params }) => read({ data: { org: params.org, workspace: params.workspace, project: params.project } }),
|
||||
head: ({ loaderData }) => (loaderData?.title ? { meta: [{ title: loaderData.title }] } : {}),
|
||||
component: Page,
|
||||
})
|
||||
|
||||
function Page(): ReactElement {
|
||||
const slugs = Route.useParams()
|
||||
const data = Route.useLoaderData()
|
||||
const live = useLiveDocuments(slugs, data.documents)
|
||||
// As the shell formats a date (`i18n.date`), in the locale the server rendered in.
|
||||
const day = new Intl.DateTimeFormat(data.locale).format
|
||||
return (
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
title={data.title}
|
||||
description={data.intro}
|
||||
actions={
|
||||
<FormPanel label={data.upload.heading}>
|
||||
{() => <UploadForm slugs={slugs} accept={data.accept} labels={data.upload} onUploaded={live.refetch} />}
|
||||
</FormPanel>
|
||||
}
|
||||
/>
|
||||
<DocumentList
|
||||
documents={live.documents}
|
||||
hrefFor={(fileId) => `/${slugs.org}/${slugs.workspace}/${slugs.project}/documents/${fileId}`}
|
||||
downloadHrefFor={(fileId) => downloadHref("files.file.download", { ...slugs, fileId })}
|
||||
labels={data.list}
|
||||
formatDate={(iso) => day(new Date(iso))}
|
||||
/>
|
||||
</PageFrame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// A project's home: what it is, and the pages of the modules that work in it (spec §3.1, §4.1).
|
||||
// The entries come from the modules' manifests (src/server/project-entries.ts), so a module added
|
||||
// tomorrow appears here with no edit to this file.
|
||||
|
||||
import { Page as PageFrame, PageHeader, PageSection, PageState } from "@conusai-kit/ui/patterns"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { ReactElement } from "react"
|
||||
import type { ProjectHomeData, ProjectParams } from "../server/org-pages"
|
||||
import { projectHref } from "../ui/projects"
|
||||
|
||||
const ROUTE = "/$org/$workspace/$project/"
|
||||
|
||||
const read = createServerFn({ method: "GET" })
|
||||
.validator((params: ProjectParams) => params)
|
||||
.handler(async ({ data }): Promise<ProjectHomeData> => {
|
||||
const [{ reported }, { projectHomeData }] = await Promise.all([
|
||||
import("../server/page"),
|
||||
import("../server/org-pages"),
|
||||
])
|
||||
return reported(ROUTE, () => projectHomeData(data))
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/$org/$workspace/$project/")({
|
||||
loader: ({ params }) => read({ data: { org: params.org, workspace: params.workspace, project: params.project } }),
|
||||
head: ({ loaderData }) => ({ meta: loaderData ? [{ title: loaderData.title }] : [] }),
|
||||
component: Page,
|
||||
})
|
||||
|
||||
function Page(): ReactElement {
|
||||
const { org, workspace, project } = Route.useParams()
|
||||
const data = Route.useLoaderData()
|
||||
return (
|
||||
<PageFrame>
|
||||
<PageHeader title={data.title} description={data.module}>
|
||||
{data.archived ? <span role="status">{data.archived}</span> : null}
|
||||
</PageHeader>
|
||||
<PageSection title={data.work}>
|
||||
{data.entries.length ? (
|
||||
<ul>
|
||||
{data.entries.map((entry) => (
|
||||
<li key={entry.href}>
|
||||
<a href={projectHref(org, workspace, project, entry)}>{entry.label}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<PageState title={data.noSections} />
|
||||
)}
|
||||
</PageSection>
|
||||
</PageFrame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The project route: the project has to exist in this workspace of this organization, and a project of
|
||||
// another tenant, an unknown slug and a project the person has no place in are one 404, answered before the
|
||||
// page renders (`projectLayoutData`, spec §3.3).
|
||||
//
|
||||
// Every project page -- a module's, mounted through the one catch-all route, and the documents pages --
|
||||
// renders inside the app's transport with the person's permissions in the project, read once here and
|
||||
// handed to the browser as the seed of `useCan` and `<Can>` (`@conusai-kit/ui/data`). The sections of a project
|
||||
// are tabs above its page; the frame reads them, and the project's name, from this route's loader data.
|
||||
|
||||
import { createFileRoute, Outlet } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { ReactElement } from "react"
|
||||
import type { ProjectParams } from "../server/org-pages"
|
||||
import type { ProjectLayoutData } from "../server/page"
|
||||
import { AppTransport } from "../ui/transport"
|
||||
|
||||
const ROUTE = "/$org/$workspace/$project"
|
||||
|
||||
const readProject = createServerFn({ method: "GET" })
|
||||
.validator((params: ProjectParams) => params)
|
||||
.handler(async ({ data }): Promise<ProjectLayoutData> => {
|
||||
const { projectLayoutData, reported } = await import("../server/page")
|
||||
return reported(ROUTE, () => projectLayoutData(data))
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/$org/$workspace/$project")({
|
||||
loader: ({ params }) =>
|
||||
readProject({ data: { org: params.org, workspace: params.workspace, project: params.project } }),
|
||||
component: ProjectLayout,
|
||||
})
|
||||
|
||||
function ProjectLayout(): ReactElement {
|
||||
const { scope, seed } = Route.useLoaderData()
|
||||
return (
|
||||
<AppTransport key={`${scope.org}/${scope.workspace}/${scope.project}`} permissions={{ scope, seed }}>
|
||||
<Outlet />
|
||||
</AppTransport>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// A workspace: its projects, and a form to create another (spec §3.1). The projects are listed
|
||||
// through `tenancy.project.list`, which reads them inside the workspace's own scope -- a workspace of
|
||||
// another organization, or one the person has no place in, is the not-found page.
|
||||
|
||||
import { moduleSelected } from "@app/modules/inclusion"
|
||||
import { GuidesSetupCard } from "@conusai-kit/guides/panel" // kit:guides
|
||||
import { FormPanel, Page as PageFrame, PageHeader, PageSection, ResourceList } from "@conusai-kit/ui/patterns"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { ReactElement } from "react"
|
||||
import type { WorkspaceHomeData, WorkspaceParams } from "../server/org-pages"
|
||||
import { useFrameRouter } from "../ui/frame-router"
|
||||
import { ProcedureForm } from "../ui/procedure-form"
|
||||
import { projectHref } from "../ui/projects"
|
||||
|
||||
const ROUTE = "/$org/$workspace/"
|
||||
|
||||
const read = createServerFn({ method: "GET" })
|
||||
.validator((params: WorkspaceParams) => params)
|
||||
.handler(async ({ data }): Promise<WorkspaceHomeData> => {
|
||||
const [{ reported }, { workspaceHomeData }] = await Promise.all([
|
||||
import("../server/page"),
|
||||
import("../server/org-pages"),
|
||||
])
|
||||
return reported(ROUTE, () => workspaceHomeData(data))
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/$org/$workspace/")({
|
||||
loader: ({ params }) => read({ data: { org: params.org, workspace: params.workspace } }),
|
||||
head: ({ loaderData }) => ({ meta: loaderData ? [{ title: loaderData.title }] : [] }),
|
||||
component: Page,
|
||||
})
|
||||
|
||||
function Page(): ReactElement {
|
||||
const { org, workspace } = Route.useParams()
|
||||
const data = Route.useLoaderData()
|
||||
const create = data.create
|
||||
const { navigate } = useFrameRouter()
|
||||
return (
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
title={data.title}
|
||||
description={data.description}
|
||||
actions={
|
||||
create ? (
|
||||
<FormPanel label={create.title}>
|
||||
{(complete) => (
|
||||
<ProcedureForm
|
||||
procedure="tenancy.project.create"
|
||||
onComplete={complete}
|
||||
hidden={{ org, workspace }}
|
||||
submitLabel={create.submit}
|
||||
fields={create.fields}
|
||||
/>
|
||||
)}
|
||||
</FormPanel>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{/* kit:guides:begin */}
|
||||
{moduleSelected("guides") && <GuidesSetupCard locale={data.locale} scope={{ org, workspace }} />}
|
||||
{/* kit:guides:end */}
|
||||
<PageSection title={data.projectsHeading}>
|
||||
<ResourceList
|
||||
label={data.projectsHeading}
|
||||
columns={[
|
||||
{ id: "name", header: data.columnName, wrap: true },
|
||||
{ id: "moduleKey", header: data.columnModule },
|
||||
]}
|
||||
data={data.projects}
|
||||
rowId={(project) => project.slug}
|
||||
rowHref={(project) => projectHref(org, workspace, project.slug)}
|
||||
navigate={navigate}
|
||||
rowLabel={(project) => project.name}
|
||||
empty={{ title: data.none }}
|
||||
/>
|
||||
</PageSection>
|
||||
</PageFrame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The one route of the modules' pages above a project: `/<org>/<workspace>/modules/<module>/...` (F10).
|
||||
//
|
||||
// `modules` is a segment no project can take (RESERVED_PROJECT_SLUGS, test/reserved-routes.test.ts): a bare
|
||||
// `/<org>/<workspace>/<module>` would be a project's slug. The loader is a server function, as on the project
|
||||
// route: it checks the session and the workspace, then runs the module's `load` (`workspacePages` of
|
||||
// `modules/pages.ts`) with the request's own caller, messages and query string. A new page adds no route file.
|
||||
// A page the module placed in Settings (`placement: "settings"`) renders as that tab of Settings.
|
||||
|
||||
import { createFileRoute, useLocation } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import { type ReactElement, Suspense } from "react"
|
||||
import type { WorkspacePageData, WorkspacePageParams } from "../server/page"
|
||||
import { workspacePageViews } from "../ui/page-views"
|
||||
import { RoutePending } from "../ui/route-pending"
|
||||
import { isSettingsPage, SettingsFrame, useSettingsSections } from "../ui/settings-frame"
|
||||
import { AppTransport } from "../ui/transport"
|
||||
|
||||
const ROUTE = "/$org/$workspace/modules/$module/$"
|
||||
|
||||
const readWorkspacePage = createServerFn({ method: "GET" })
|
||||
.validator((params: WorkspacePageParams) => params)
|
||||
.handler(async ({ data }): Promise<WorkspacePageData> => {
|
||||
const { reported, workspacePageData } = await import("../server/page")
|
||||
return reported(ROUTE, () => workspacePageData(data))
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/$org/$workspace/modules/$module/$")({
|
||||
loaderDeps: ({ search }) => ({ search }),
|
||||
loader: ({ params, location }) =>
|
||||
readWorkspacePage({
|
||||
data: {
|
||||
org: params.org,
|
||||
workspace: params.workspace,
|
||||
module: params.module,
|
||||
...(params._splat ? { _splat: params._splat } : {}),
|
||||
...(location.searchStr ? { search: location.searchStr } : {}),
|
||||
},
|
||||
}),
|
||||
head: ({ loaderData }) => (loaderData?.title ? { meta: [{ title: loaderData.title }] } : {}),
|
||||
component: WorkspaceModuleRoute,
|
||||
})
|
||||
|
||||
function WorkspaceModuleRoute(): ReactElement {
|
||||
const { org, workspace } = Route.useParams()
|
||||
const page = Route.useLoaderData()
|
||||
const View = workspacePageViews.viewOf(page.module)
|
||||
const view = (
|
||||
<AppTransport permissions={{ scope: page.scope, seed: page.seed }}>
|
||||
<Suspense fallback={<RoutePending />}>
|
||||
<View
|
||||
key={`${org}:${workspace}:${page.module}:${page.segments.join("/")}`}
|
||||
slugs={{ org, workspace }}
|
||||
segments={page.segments}
|
||||
locale={page.locale}
|
||||
data={page.data}
|
||||
/>
|
||||
</Suspense>
|
||||
</AppTransport>
|
||||
)
|
||||
const inSettings = isSettingsPage(useSettingsSections(), useLocation().pathname)
|
||||
return inSettings ? <SettingsFrame>{view}</SettingsFrame> : view
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import { Page, PageHeader, PageSection, ResourceList } from "@conusai-kit/ui/patterns"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { WorkspaceHomeData, WorkspaceParams } from "../server/org-pages"
|
||||
import { useFrameRouter } from "../ui/frame-router"
|
||||
import { ProcedureForm } from "../ui/procedure-form"
|
||||
import { projectHref } from "../ui/projects"
|
||||
|
||||
const read = createServerFn({ method: "GET" })
|
||||
.validator((params: WorkspaceParams) => params)
|
||||
.handler(async ({ data }): Promise<WorkspaceHomeData> => {
|
||||
const [{ reported }, { workspaceHomeData }] = await Promise.all([
|
||||
import("../server/page"),
|
||||
import("../server/org-pages"),
|
||||
])
|
||||
return reported("/$org/$workspace/modules/", () => workspaceHomeData(data))
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/$org/$workspace/modules/")({
|
||||
validateSearch: (search: Record<string, unknown>): { create?: true } =>
|
||||
search.create === true || search.create === 1 || search.create === "1" ? { create: true } : {},
|
||||
loader: ({ params }) => read({ data: { org: params.org, workspace: params.workspace } }),
|
||||
head: ({ loaderData }) => ({ meta: loaderData ? [{ title: loaderData.projectsHeading }] : [] }),
|
||||
component: Projects,
|
||||
})
|
||||
|
||||
function Projects() {
|
||||
const { org, workspace } = Route.useParams()
|
||||
const data = Route.useLoaderData()
|
||||
const search = Route.useSearch()
|
||||
const { navigate } = useFrameRouter()
|
||||
const create = search.create ? data.create : undefined
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader title={data.projectsHeading} />
|
||||
{create ? (
|
||||
<PageSection title={create.title}>
|
||||
<ProcedureForm
|
||||
key={`${org}/${workspace}`}
|
||||
procedure="tenancy.project.create"
|
||||
hidden={{ org, workspace }}
|
||||
submitLabel={create.submit}
|
||||
fields={create.fields}
|
||||
after={projectHref(org, workspace, "{slug}")}
|
||||
/>
|
||||
</PageSection>
|
||||
) : data.create ? (
|
||||
<a href={`/${org}/${workspace}/modules?create=true`}>{data.create.title}</a>
|
||||
) : null}
|
||||
<ResourceList
|
||||
label={data.projectsHeading}
|
||||
columns={[
|
||||
{ id: "name", header: data.columnName, wrap: true },
|
||||
{ id: "moduleKey", header: data.columnModule },
|
||||
]}
|
||||
data={data.projects}
|
||||
rowId={(project) => project.slug}
|
||||
rowHref={(project) => projectHref(org, workspace, project.slug)}
|
||||
rowLabel={(project) => project.name}
|
||||
navigate={navigate}
|
||||
empty={{ title: data.none }}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The workspace route: the workspace has to be one this person can see, and the page says 404 before it
|
||||
// renders if it is not (`requireWorkspace`, spec §3.3). Its loader data is what the frame shows of the
|
||||
// workspace (`$org.tsx`): its name and the sidebar at this scope, the modules' `ui.nav` with `scope:
|
||||
// "workspace"`, filtered on the server by the person's permissions in the workspace.
|
||||
|
||||
import { createFileRoute, Outlet } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { WorkspaceLayoutData, WorkspaceParams } from "../server/org-pages"
|
||||
|
||||
const ROUTE = "/$org/$workspace"
|
||||
|
||||
const readWorkspace = createServerFn({ method: "GET" })
|
||||
.validator((params: WorkspaceParams) => params)
|
||||
.handler(async ({ data }): Promise<WorkspaceLayoutData> => {
|
||||
const [{ reported }, { requireWorkspace }] = await Promise.all([
|
||||
import("../server/page"),
|
||||
import("../server/org-pages"),
|
||||
])
|
||||
return reported(ROUTE, () => requireWorkspace(data))
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/$org/$workspace")({
|
||||
loader: ({ params }) => readWorkspace({ data: { org: params.org, workspace: params.workspace } }),
|
||||
component: Outlet,
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The access audit (kit-tenancy's access pages) is for whoever manages the organization's members. Asked in
|
||||
// the loader, before anything renders, so a member without it gets a real 403 and a stranger a real 404.
|
||||
|
||||
import { accessAuditPage } from "@conusai-kit/tenancy/ui"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { ReactElement } from "react"
|
||||
import type { AccessAuditData, OrgParams } from "../server/org-pages"
|
||||
import { RoutePending } from "../ui/route-pending"
|
||||
import { tenancyStyles } from "../ui/tenancy-styles"
|
||||
|
||||
const ROUTE = "/$org/access-audit"
|
||||
|
||||
const read = createServerFn({ method: "GET" })
|
||||
.validator((params: OrgParams) => params)
|
||||
.handler(async ({ data }): Promise<AccessAuditData> => {
|
||||
const [{ reported }, { accessAuditData }] = await Promise.all([
|
||||
import("../server/page"),
|
||||
import("../server/org-pages"),
|
||||
])
|
||||
return reported(ROUTE, () => accessAuditData(data))
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/$org/_settings/access-audit")({
|
||||
loader: ({ params }) => read({ data: { org: params.org } }),
|
||||
head: tenancyStyles,
|
||||
pendingComponent: RoutePending,
|
||||
component: Page,
|
||||
})
|
||||
|
||||
function Page(): ReactElement {
|
||||
return <accessAuditPage.View data={Route.useLoaderData()} />
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The audit viewer (spec §9.4), a tab of Settings, for whoever holds `tenancy.audit.read`: the loader asks before
|
||||
// anything renders, so a member without it gets a real 403 and a stranger a real 404 (src/server/org-pages.ts).
|
||||
|
||||
import { PageSection, PageState } from "@conusai-kit/ui/patterns"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@conusai-kit/ui/primitives"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { ReactElement } from "react"
|
||||
import type { AuditData, OrgParams } from "../server/org-pages"
|
||||
import { RoutePending } from "../ui/route-pending"
|
||||
|
||||
const ROUTE = "/$org/audit"
|
||||
|
||||
const read = createServerFn({ method: "GET" })
|
||||
.validator((params: OrgParams) => params)
|
||||
.handler(async ({ data }): Promise<AuditData> => {
|
||||
const [{ reported }, { auditData }] = await Promise.all([import("../server/page"), import("../server/org-pages")])
|
||||
return reported(ROUTE, () => auditData(data))
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/$org/_settings/audit")({
|
||||
loader: ({ params }) => read({ data: { org: params.org } }),
|
||||
pendingComponent: RoutePending,
|
||||
component: Page,
|
||||
})
|
||||
|
||||
function Page(): ReactElement {
|
||||
const data = Route.useLoaderData()
|
||||
return (
|
||||
<PageSection title={data.title}>
|
||||
{data.rows.length === 0 ? (
|
||||
<PageState title={data.empty} />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead scope="col">{data.when}</TableHead>
|
||||
<TableHead scope="col">{data.action}</TableHead>
|
||||
<TableHead scope="col">{data.outcome}</TableHead>
|
||||
<TableHead scope="col">{data.resource}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>{row.when}</TableCell>
|
||||
<TableCell>{row.action}</TableCell>
|
||||
<TableCell>{row.outcome}</TableCell>
|
||||
<TableCell>{row.resource}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The organization's brand: name, colours and logos, with a preview of both themes and the contrast
|
||||
// each colour has there. Every member sees it; an administrator changes it (`branding.brand.write`,
|
||||
// which the kernel checks before a handler runs), and a member without that permission is shown the
|
||||
// same page read-only.
|
||||
|
||||
import { BrandForm } from "@app/modules/branding/ui"
|
||||
import { PageSection } from "@conusai-kit/ui/patterns"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { ReactElement } from "react"
|
||||
import type { BrandingData, OrgParams } from "../server/org-pages"
|
||||
|
||||
const ROUTE = "/$org/branding"
|
||||
|
||||
const read = createServerFn({ method: "GET" })
|
||||
.validator((params: OrgParams) => params)
|
||||
.handler(async ({ data }): Promise<BrandingData> => {
|
||||
const [{ reported }, { brandingData }] = await Promise.all([
|
||||
import("../server/page"),
|
||||
import("../server/org-pages"),
|
||||
])
|
||||
return reported(ROUTE, () => brandingData(data))
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/$org/_settings/branding")({
|
||||
loader: ({ params }) => read({ data: { org: params.org } }),
|
||||
component: Branding,
|
||||
})
|
||||
|
||||
function Branding(): ReactElement {
|
||||
const { org } = Route.useParams()
|
||||
const data = Route.useLoaderData()
|
||||
return (
|
||||
<PageSection title={data.title} description={data.intro}>
|
||||
<BrandForm
|
||||
org={org}
|
||||
brand={data.brand}
|
||||
canEdit={data.canEdit}
|
||||
defaultPrimary={data.defaultPrimary}
|
||||
labels={data.labels}
|
||||
/>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The organization's invitations (src/server/org-pages.ts): the invite form, the link it makes and the pending
|
||||
// invitations, kit-tenancy's island. Who may see them is asked in the loader, before anything renders, so a
|
||||
// member without `tenancy.invitation.list` gets a real 403 and a stranger a real 404. An invitation link opens
|
||||
// `/invitations/<org>/<token>`.
|
||||
|
||||
import { invitationsPage } from "@conusai-kit/tenancy/ui"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { ReactElement } from "react"
|
||||
import type { InvitationsData, OrgParams } from "../server/org-pages"
|
||||
import { RoutePending } from "../ui/route-pending"
|
||||
import { tenancyStyles } from "../ui/tenancy-styles"
|
||||
|
||||
const ROUTE = "/$org/invitations"
|
||||
|
||||
const read = createServerFn({ method: "GET" })
|
||||
.validator((params: OrgParams) => params)
|
||||
.handler(async ({ data }): Promise<InvitationsData> => {
|
||||
const [{ reported }, { invitationsData }] = await Promise.all([
|
||||
import("../server/page"),
|
||||
import("../server/org-pages"),
|
||||
])
|
||||
return reported(ROUTE, () => invitationsData(data))
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/$org/_settings/invitations")({
|
||||
loader: ({ params }) => read({ data: { org: params.org } }),
|
||||
head: tenancyStyles,
|
||||
pendingComponent: RoutePending,
|
||||
component: Page,
|
||||
})
|
||||
|
||||
function Page(): ReactElement {
|
||||
return <invitationsPage.View data={Route.useLoaderData()} />
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// A project's members: a project role of their own, set or cleared with `tenancy.project.set-member`
|
||||
// (kit-tenancy's access pages); without one, the workspace's or the organization's applies.
|
||||
|
||||
import { membersPage } from "@conusai-kit/tenancy/ui"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { ReactElement } from "react"
|
||||
import type { MembersData, MembersParams } from "../server/org-pages"
|
||||
|
||||
const ROUTE = "/$org/members/$workspace/$project"
|
||||
|
||||
const read = createServerFn({ method: "GET" })
|
||||
.validator((params: MembersParams) => params)
|
||||
.handler(async ({ data }): Promise<MembersData> => {
|
||||
const [{ reported }, { membersData }] = await Promise.all([import("../server/page"), import("../server/org-pages")])
|
||||
return reported(ROUTE, () => membersData(data))
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/$org/_settings/members/$workspace/$project")({
|
||||
loader: ({ params }) => read({ data: { org: params.org, workspace: params.workspace, project: params.project } }),
|
||||
component: Page,
|
||||
})
|
||||
|
||||
function Page(): ReactElement {
|
||||
return <membersPage.View data={Route.useLoaderData()} />
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// A workspace's members: the role each holds there of their own, or the organization's (kit-tenancy's
|
||||
// access pages). A workspace slug that is not there, or not visible, is the not-found page.
|
||||
|
||||
import { membersPage } from "@conusai-kit/tenancy/ui"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { ReactElement } from "react"
|
||||
import type { MembersData, MembersParams } from "../server/org-pages"
|
||||
|
||||
const ROUTE = "/$org/members/$workspace/"
|
||||
|
||||
const read = createServerFn({ method: "GET" })
|
||||
.validator((params: MembersParams) => params)
|
||||
.handler(async ({ data }): Promise<MembersData> => {
|
||||
const [{ reported }, { membersData }] = await Promise.all([import("../server/page"), import("../server/org-pages")])
|
||||
return reported(ROUTE, () => membersData(data))
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/$org/_settings/members/$workspace/")({
|
||||
loader: ({ params }) => read({ data: { org: params.org, workspace: params.workspace } }),
|
||||
component: Page,
|
||||
})
|
||||
|
||||
function Page(): ReactElement {
|
||||
return <membersPage.View data={Route.useLoaderData()} />
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The organization's members and the role each holds (kit-tenancy's access pages). Every change is
|
||||
// a procedure call -- `tenancy.member.assign` -- so the ceilings, the last owner's protection and the
|
||||
// audit event are kit-tenancy's, not this page's. A workspace's and a project's members are the pages
|
||||
// below this one.
|
||||
|
||||
import { membersPage } from "@conusai-kit/tenancy/ui"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { ReactElement } from "react"
|
||||
import type { MembersData, MembersParams } from "../server/org-pages"
|
||||
|
||||
const ROUTE = "/$org/members/"
|
||||
|
||||
const read = createServerFn({ method: "GET" })
|
||||
.validator((params: MembersParams) => params)
|
||||
.handler(async ({ data }): Promise<MembersData> => {
|
||||
const [{ reported }, { membersData }] = await Promise.all([import("../server/page"), import("../server/org-pages")])
|
||||
return reported(ROUTE, () => membersData(data))
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/$org/_settings/members/")({
|
||||
loader: ({ params }) => read({ data: { org: params.org } }),
|
||||
component: Page,
|
||||
})
|
||||
|
||||
function Page(): ReactElement {
|
||||
return <membersPage.View data={Route.useLoaderData()} />
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The members pages (kit-tenancy's access pages, `@conusai-kit/tenancy/ui`) are for whoever holds
|
||||
// `tenancy.member.read`. Asked in the loader, before anything renders, so a member without the permission
|
||||
// gets a real 403 and a stranger a real 404 (src/server/guard.ts).
|
||||
|
||||
import { createFileRoute, Outlet } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { OrgParams } from "../server/org-pages"
|
||||
import { RoutePending } from "../ui/route-pending"
|
||||
import { tenancyStyles } from "../ui/tenancy-styles"
|
||||
|
||||
const ROUTE = "/$org/members"
|
||||
|
||||
const requireAccess = createServerFn({ method: "GET" })
|
||||
.validator((params: OrgParams) => params)
|
||||
.handler(async ({ data }): Promise<null> => {
|
||||
const [{ reported }, { requireAccessSection }] = await Promise.all([
|
||||
import("../server/page"),
|
||||
import("../server/org-pages"),
|
||||
])
|
||||
return reported(ROUTE, () => requireAccessSection({ ...data, section: "members" }))
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/$org/_settings/members")({
|
||||
loader: ({ params }) => requireAccess({ data: { org: params.org } }),
|
||||
head: tenancyStyles,
|
||||
pendingComponent: RoutePending,
|
||||
component: Outlet,
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// One role: its permission matrix by module, and for a custom role its name, its permissions and its
|
||||
// deletion with the role its members move to (kit-tenancy's access pages). A key that is not one of
|
||||
// this organization's roles is the not-found page.
|
||||
|
||||
import { rolePage } from "@conusai-kit/tenancy/ui"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { ReactElement } from "react"
|
||||
import type { RoleData, RoleParams } from "../server/org-pages"
|
||||
|
||||
const ROUTE = "/$org/roles/$role"
|
||||
|
||||
const read = createServerFn({ method: "GET" })
|
||||
.validator((params: RoleParams) => params)
|
||||
.handler(async ({ data }): Promise<RoleData> => {
|
||||
const [{ reported }, { roleData }] = await Promise.all([import("../server/page"), import("../server/org-pages")])
|
||||
return reported(ROUTE, () => roleData(data))
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/$org/_settings/roles/$role")({
|
||||
loader: ({ params }) => read({ data: { org: params.org, role: params.role } }),
|
||||
component: Page,
|
||||
})
|
||||
|
||||
function Page(): ReactElement {
|
||||
return <rolePage.View data={Route.useLoaderData()} />
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The organization's roles: the built-in ones its modules define and its own custom ones, with the
|
||||
// form that creates a role as a copy of another (kit-tenancy's access pages).
|
||||
|
||||
import { rolesPage } from "@conusai-kit/tenancy/ui"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { ReactElement } from "react"
|
||||
import type { OrgParams, RolesData } from "../server/org-pages"
|
||||
|
||||
const ROUTE = "/$org/roles/"
|
||||
|
||||
const read = createServerFn({ method: "GET" })
|
||||
.validator((params: OrgParams) => params)
|
||||
.handler(async ({ data }): Promise<RolesData> => {
|
||||
const [{ reported }, { rolesData }] = await Promise.all([import("../server/page"), import("../server/org-pages")])
|
||||
return reported(ROUTE, () => rolesData(data))
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/$org/_settings/roles/")({
|
||||
loader: ({ params }) => read({ data: { org: params.org } }),
|
||||
component: Page,
|
||||
})
|
||||
|
||||
function Page(): ReactElement {
|
||||
return <rolesPage.View data={Route.useLoaderData()} />
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The roles pages (kit-tenancy's access pages) are for whoever holds `tenancy.role.read`; changing a role
|
||||
// takes `tenancy.role.manage`, which the procedures check. Asked in the loader, before anything renders, so
|
||||
// a member without it gets a real 403 and a stranger a real 404.
|
||||
|
||||
import { createFileRoute, Outlet } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { OrgParams } from "../server/org-pages"
|
||||
import { RoutePending } from "../ui/route-pending"
|
||||
import { tenancyStyles } from "../ui/tenancy-styles"
|
||||
|
||||
const ROUTE = "/$org/roles"
|
||||
|
||||
const requireAccess = createServerFn({ method: "GET" })
|
||||
.validator((params: OrgParams) => params)
|
||||
.handler(async ({ data }): Promise<null> => {
|
||||
const [{ reported }, { requireAccessSection }] = await Promise.all([
|
||||
import("../server/page"),
|
||||
import("../server/org-pages"),
|
||||
])
|
||||
return reported(ROUTE, () => requireAccessSection({ ...data, section: "roles" }))
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/$org/_settings/roles")({
|
||||
loader: ({ params }) => requireAccess({ data: { org: params.org } }),
|
||||
head: tenancyStyles,
|
||||
pendingComponent: RoutePending,
|
||||
component: Outlet,
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The person's own account, as the Personal section of Settings: the same data and panels as `/settings/account`,
|
||||
// the page a person with no organization has.
|
||||
|
||||
import { FactList, PageSection } from "@conusai-kit/ui/patterns"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { ReactElement } from "react"
|
||||
import type { AccountData } from "../server/account-pages"
|
||||
import { AccountPanels } from "../ui/account"
|
||||
|
||||
const ROUTE = "/$org/settings/account"
|
||||
|
||||
const readAccount = createServerFn({ method: "GET" }).handler(async (): Promise<AccountData> => {
|
||||
const [{ reported }, { accountData }] = await Promise.all([
|
||||
import("../server/page"),
|
||||
import("../server/account-pages"),
|
||||
])
|
||||
return reported(ROUTE, () => accountData())
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/$org/_settings/settings/account")({
|
||||
loader: () => readAccount(),
|
||||
component: Account,
|
||||
})
|
||||
|
||||
function Account(): ReactElement {
|
||||
const { page, displayName, email, exportLabels, analyticsLabels, deletion } = Route.useLoaderData()
|
||||
return (
|
||||
<PageSection title={page.title} description={page.intro}>
|
||||
<FactList
|
||||
facts={[
|
||||
{ label: page.name, value: displayName },
|
||||
{ label: page.email, value: email },
|
||||
]}
|
||||
/>
|
||||
<AccountPanels exportLabels={exportLabels} analyticsLabels={analyticsLabels} deletion={deletion} />
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The organization's own settings, for whoever holds `tenancy.org.update`: a member without it gets the
|
||||
// forbidden page, a stranger the not-found page. Its name and default language are saved here, through
|
||||
// `tenancy.org.update`; workspaces are created on their own page (`/{org}/workspaces`).
|
||||
|
||||
import { SettingsSection } from "@app/modules/knowledge/ui/settings-section" // kit:knowledge
|
||||
import { FactList, PageSection } from "@conusai-kit/ui/patterns"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { ReactElement } from "react"
|
||||
import type { OrgParams, OrgSettingsData } from "../server/org-pages"
|
||||
import { OrganizationSettings } from "../ui/organization-settings"
|
||||
|
||||
const ROUTE = "/$org/settings"
|
||||
|
||||
const read = createServerFn({ method: "GET" })
|
||||
.validator((params: OrgParams) => params)
|
||||
.handler(async ({ data }): Promise<OrgSettingsData> => {
|
||||
const [{ reported }, { orgSettingsData }] = await Promise.all([
|
||||
import("../server/page"),
|
||||
import("../server/org-pages"),
|
||||
])
|
||||
return reported(ROUTE, () => orgSettingsData(data))
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/$org/_settings/settings/")({
|
||||
loader: ({ params }) => read({ data: { org: params.org } }),
|
||||
component: Page,
|
||||
})
|
||||
|
||||
function Page(): ReactElement {
|
||||
const { org } = Route.useParams()
|
||||
const data = Route.useLoaderData()
|
||||
return (
|
||||
<>
|
||||
<OrganizationSettings org={org} organization={data.organization} labels={data.organizationLabels} />
|
||||
<PageSection variant="card" title={data.title}>
|
||||
<FactList
|
||||
facts={[
|
||||
{ label: data.slugLabel, value: data.slug },
|
||||
{ label: data.statusLabel, value: data.status },
|
||||
]}
|
||||
/>
|
||||
</PageSection>
|
||||
{/* kit:knowledge:begin */}
|
||||
{data.knowledge && <SettingsSection org={org} initial={data.knowledge} labels={data.labels} />}
|
||||
{/* kit:knowledge:end */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Settings: one page, a row of tabs (the organization's sections, the workspace's, the person's account), shown by
|
||||
// permission. This route has no path of its own: each section keeps its address and renders inside `SettingsFrame`.
|
||||
// Reflection only: every section's loader asks again, so a deep link to one the person lacks is still that loader's 403.
|
||||
|
||||
import { createFileRoute, Outlet } from "@tanstack/react-router"
|
||||
import type { ReactElement } from "react"
|
||||
import { SettingsFrame } from "../ui/settings-frame"
|
||||
|
||||
export const Route = createFileRoute("/$org/_settings")({ component: Settings })
|
||||
|
||||
function Settings(): ReactElement {
|
||||
return (
|
||||
<SettingsFrame>
|
||||
<Outlet />
|
||||
</SettingsFrame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The workspaces of the organization, and a form to create one (spec §3.1). A workspace is where
|
||||
// projects live and where a restricted group of people works apart from the rest of the
|
||||
// organization; creating one is for administrators (`tenancy.workspace.manage`), so the form is shown
|
||||
// to them and the kernel refuses everybody else regardless.
|
||||
|
||||
import { FormPanel, PageSection, ResourceList } from "@conusai-kit/ui/patterns"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { ReactElement } from "react"
|
||||
import type { OrgParams, WorkspacesData } from "../server/org-pages"
|
||||
import { useFrameRouter } from "../ui/frame-router"
|
||||
import { ProcedureForm } from "../ui/procedure-form"
|
||||
|
||||
const ROUTE = "/$org/workspaces"
|
||||
|
||||
const read = createServerFn({ method: "GET" })
|
||||
.validator((params: OrgParams) => params)
|
||||
.handler(async ({ data }): Promise<WorkspacesData> => {
|
||||
const [{ reported }, { workspacesData }] = await Promise.all([
|
||||
import("../server/page"),
|
||||
import("../server/org-pages"),
|
||||
])
|
||||
return reported(ROUTE, () => workspacesData(data))
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/$org/_settings/workspaces")({
|
||||
loader: ({ params }) => read({ data: { org: params.org } }),
|
||||
component: Page,
|
||||
})
|
||||
|
||||
function Page(): ReactElement {
|
||||
const { org } = Route.useParams()
|
||||
const data = Route.useLoaderData()
|
||||
const create = data.create
|
||||
const { navigate } = useFrameRouter()
|
||||
return (
|
||||
<PageSection
|
||||
title={data.title}
|
||||
actions={
|
||||
create ? (
|
||||
<FormPanel label={create.title}>
|
||||
{(complete) => (
|
||||
<ProcedureForm
|
||||
procedure="tenancy.workspace.create"
|
||||
onComplete={complete}
|
||||
hidden={{ org }}
|
||||
submitLabel={create.submit}
|
||||
fields={create.fields}
|
||||
/>
|
||||
)}
|
||||
</FormPanel>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<ResourceList
|
||||
label={data.title}
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
header: data.name,
|
||||
wrap: true,
|
||||
cell: (workspace) => `${workspace.name}${workspace.isDefault ? ` (${workspace.isDefault})` : ""}`,
|
||||
},
|
||||
{ id: "visibility", header: data.visibility },
|
||||
]}
|
||||
data={data.rows}
|
||||
rowId={(workspace) => workspace.slug}
|
||||
rowHref={(workspace) => `/${org}/${workspace.slug}`}
|
||||
navigate={navigate}
|
||||
rowLabel={(workspace) => workspace.name}
|
||||
empty={{ title: data.none }}
|
||||
/>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// An organization's logo, served to its members from this app's own origin (src/server/brand-routes.ts).
|
||||
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/$org/brand/logo/$variant")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request, params }) =>
|
||||
(await import("../server/brand-routes")).organizationLogo(request, params.org, params.variant),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// An organization's share image (src/server/brand-routes.ts): only a member draws it.
|
||||
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/$org/brand/og")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ params }) => (await import("../server/brand-routes")).organizationShareImage(params.org),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The organization's workspaces and the projects of each (spec §3.1).
|
||||
|
||||
import { Page as PageFrame, PageHeader, ResourceList } from "@conusai-kit/ui/patterns"
|
||||
import { Button } from "@conusai-kit/ui/primitives"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { ReactElement } from "react"
|
||||
import type { OrgHomeData, OrgParams } from "../server/org-pages"
|
||||
import { useFrameRouter } from "../ui/frame-router"
|
||||
|
||||
const ROUTE = "/$org/"
|
||||
|
||||
const read = createServerFn({ method: "GET" })
|
||||
.validator((params: OrgParams) => params)
|
||||
.handler(async ({ data }): Promise<OrgHomeData> => {
|
||||
const [{ reported }, { orgHomeData }] = await Promise.all([import("../server/page"), import("../server/org-pages")])
|
||||
return reported(ROUTE, () => orgHomeData(data))
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/$org/")({
|
||||
loader: ({ params }) => read({ data: { org: params.org } }),
|
||||
component: Page,
|
||||
})
|
||||
|
||||
function Page(): ReactElement {
|
||||
const { org } = Route.useParams()
|
||||
const data = Route.useLoaderData()
|
||||
const { navigate } = useFrameRouter()
|
||||
return (
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
title={data.title}
|
||||
description={data.description}
|
||||
actions={
|
||||
data.createWorkspace ? (
|
||||
<Button asChild>
|
||||
<a href={`/${org}/workspaces`}>{data.createWorkspace}</a>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<ResourceList
|
||||
label={data.title}
|
||||
columns={[
|
||||
{ id: "name", header: data.name, wrap: true },
|
||||
{ id: "projectCount", header: data.projects },
|
||||
]}
|
||||
data={data.workspaces}
|
||||
rowId={(workspace) => workspace.slug}
|
||||
rowHref={(workspace) => `/${org}/${workspace.slug}`}
|
||||
rowLabel={(workspace) => workspace.name}
|
||||
navigate={navigate}
|
||||
empty={{ title: data.none }}
|
||||
/>
|
||||
</PageFrame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// kit:custom-ui the organization's logo per theme, as the frame's mark
|
||||
// The organization route. The scope is resolved on the server, once, by calling an organization-scoped
|
||||
// procedure: a person who is not a member gets the same NotFound a stranger does, before anything renders
|
||||
// (spec §3.3, §6.4; `orgLayoutData`).
|
||||
//
|
||||
// It is where every page of an organization gets the app frame (`AppFrame` of `@conusai-kit/ui/frame`), and
|
||||
// it stays mounted while the person moves between them: an open right panel stays open. The routes below hand
|
||||
// the frame what only they know (the workspace, the project); each is read here from its route's loader data,
|
||||
// so the frame has one place. The organization's own brand (spec §10.3) goes on <body>, from the root route.
|
||||
//
|
||||
// The frame renders the <main> every page below this route fills, so an organization page returns its content
|
||||
// and not a second landmark. An organization's own page (Settings) is shown in the workspace the person was last in:
|
||||
// its sidebar, and the breadcrumb starting from it. Opening a workspace's page remembers it (`app_workspace`).
|
||||
|
||||
import { moduleSelected } from "@app/modules/inclusion"
|
||||
import { useNotifications } from "@conusai-kit/notifications/client"
|
||||
import { AppFrame, type AppFrameProps, type FrameScope, type ModuleManifest } from "@conusai-kit/ui/frame"
|
||||
import { Alert, AlertDescription } from "@conusai-kit/ui/primitives"
|
||||
import { createFileRoute, Outlet, useMatch } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { ReactElement, ReactNode } from "react"
|
||||
import type { OrgLayoutData, OrgParams, TenantLogoData } from "../server/org-pages"
|
||||
import { useAgent, useShownRecord } from "../ui/agent" // kit:agent
|
||||
import { ClientModules } from "../ui/client-modules"
|
||||
import type { ShellData } from "../ui/document"
|
||||
import { useFrameLayout } from "../ui/frame-layout"
|
||||
import { useFrameLinks, useFrameNavigationFocus, useFrameRouter } from "../ui/frame-router"
|
||||
import { helpFor } from "../ui/help"
|
||||
import { isSettingsPage } from "../ui/settings-frame"
|
||||
import { usePersonMenu } from "../ui/shell-controls"
|
||||
import { SupportCommand } from "../ui/support-command" // kit:support
|
||||
import { useShell } from "../ui/use-shell"
|
||||
import { useRememberedWorkspace, WorkspaceContext } from "../ui/workspace-context"
|
||||
|
||||
const ROUTE = "/$org"
|
||||
|
||||
const readOrganization = createServerFn({ method: "GET" })
|
||||
.validator((params: OrgParams) => params)
|
||||
.handler(async ({ data }): Promise<OrgLayoutData> => {
|
||||
const [{ reported }, { orgLayoutData }] = await Promise.all([
|
||||
import("../server/page"),
|
||||
import("../server/org-pages"),
|
||||
])
|
||||
return reported(ROUTE, () => orgLayoutData(data))
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/$org")({
|
||||
loader: ({ params }) => readOrganization({ data: { org: params.org } }),
|
||||
component: OrgLayout,
|
||||
})
|
||||
|
||||
function TenantLogo({ className, logo }: { className: string; logo: TenantLogoData }): ReactElement {
|
||||
return <img className={className} src={logo.src} srcSet={logo.srcSet} alt="" />
|
||||
}
|
||||
|
||||
/** The organization's logo in each theme, or the app's own. */
|
||||
function BrandMark({ data, brand }: { data: OrgLayoutData; brand: ShellData["brand"] }): ReactElement {
|
||||
return (
|
||||
<>
|
||||
{data.logoDark ? (
|
||||
<TenantLogo className="app-logo app-logo-dark" logo={data.logoDark} />
|
||||
) : (
|
||||
<img className="app-logo app-logo-dark" src={brand.logo.dark} alt="" />
|
||||
)}
|
||||
{data.logoLight ? (
|
||||
<TenantLogo className="app-logo app-logo-light" logo={data.logoLight} />
|
||||
) : (
|
||||
<img className="app-logo app-logo-light" src={brand.logo.light} alt="" />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** The route's data is translated and already filtered by the person's permissions in each scope. */
|
||||
const translated = (key: string): string => key
|
||||
|
||||
function Frame({ shell, children }: { shell: ShellData; children: ReactNode }): ReactElement {
|
||||
const { org } = Route.useParams()
|
||||
const data = Route.useLoaderData()
|
||||
const router = useFrameRouter()
|
||||
const layout = useFrameLayout(shell)
|
||||
const { menu, problem } = usePersonMenu(shell, data.accountHref)
|
||||
const notifications = useNotifications({
|
||||
org,
|
||||
user: data.client.user,
|
||||
locale: shell.locale,
|
||||
organizationName: data.name,
|
||||
enabled: data.permissions.includes("notifications.inbox.read"),
|
||||
})
|
||||
useFrameLinks(router)
|
||||
useFrameNavigationFocus()
|
||||
const help = helpFor(data.client, shell.help)
|
||||
const panels: Pick<AppFrameProps, "agent" | "help" | "onRecordChange"> = help.any
|
||||
? { help: () => help.occupant() }
|
||||
: {}
|
||||
const opened = useMatch({ from: "/$org/$workspace", shouldThrow: false })?.loaderData
|
||||
const remembered = useRememberedWorkspace(org, opened, data.workspaces)
|
||||
// Settings is shown in the workspace; the organization's home is where a workspace is chosen, so it has none.
|
||||
const inSettings = useMatch({ from: "/$org/_settings", shouldThrow: false }) !== undefined
|
||||
const workspace = opened ?? (inSettings ? (remembered ?? data.workspace) : undefined)
|
||||
const settingsSections = [...data.settings.sections, ...(workspace?.settings ?? [])]
|
||||
const project = useMatch({ from: "/$org/$workspace/$project", shouldThrow: false })?.loaderData
|
||||
const scope: FrameScope = {
|
||||
organization: { id: org, label: data.name },
|
||||
...(workspace ? { workspace: workspace.workspace } : {}),
|
||||
...(project ? { project: project.project } : {}),
|
||||
}
|
||||
// On an organization's own page its own permissions: the sidebar it borrows from the workspace is already filtered.
|
||||
const permissions = project?.seed.permissions ?? opened?.permissions ?? data.permissions
|
||||
// kit:agent:begin
|
||||
const [, , , , section] = router.pathname.split("/")
|
||||
// The row the side panel shows (a source viewer's) before the record the address names: neither reloads the page.
|
||||
const [shown, setShown] = useShownRecord()
|
||||
const record = shown ?? router.search.get("record")
|
||||
panels.onRecordChange = setShown
|
||||
const agent = useAgent({
|
||||
// Every page of a workspace: the workspace's own conversation, or the project's when one is open.
|
||||
...(opened
|
||||
? {
|
||||
slugs: { org, workspace: opened.workspace.id, ...(project ? { project: project.project.id } : {}) },
|
||||
workspaceName: opened.workspace.label,
|
||||
...(project ? { projectName: project.project.label } : {}),
|
||||
}
|
||||
: {}),
|
||||
user: data.client.user,
|
||||
locale: shell.locale,
|
||||
permissions,
|
||||
...(section ? { resource: section } : {}),
|
||||
...(record ? { recordId: record } : {}),
|
||||
})
|
||||
if (moduleSelected("agent") && agent) panels.agent = agent
|
||||
// kit:agent:end
|
||||
const modules: ModuleManifest[] = [
|
||||
{ id: "pages", ui: { nav: [...(workspace?.nav ?? [data.home])] } },
|
||||
{ id: "project", ui: { nav: [...(project?.tabs ?? [])] } },
|
||||
]
|
||||
return (
|
||||
<WorkspaceContext.Provider value={workspace}>
|
||||
<AppFrame
|
||||
brand={<BrandMark data={data} brand={shell.brand} />}
|
||||
scope={scope}
|
||||
organizations={data.organizations}
|
||||
workspaces={data.workspaces}
|
||||
{...(data.workspaceCreateHref ? { workspaceCreateHref: data.workspaceCreateHref } : {})}
|
||||
{...(data.organizationCreateHref ? { organizationCreateHref: data.organizationCreateHref } : {})}
|
||||
{...(opened?.projectNavigation ? { projectNavigation: opened.projectNavigation } : {})}
|
||||
modules={modules}
|
||||
permissions={permissions}
|
||||
person={{ ...data.person, menu }}
|
||||
settingsHref={data.settingsHref}
|
||||
settingsLabel={data.settingsHref === data.accountHref ? shell.labels.account : data.settingsLabel}
|
||||
settingsActive={inSettings || isSettingsPage(settingsSections, router.pathname)}
|
||||
router={router}
|
||||
t={translated}
|
||||
{...layout}
|
||||
{...panels}
|
||||
{...(notifications ? { notifications } : {})}
|
||||
{...(problem
|
||||
? {
|
||||
footer: (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{problem}</AlertDescription>
|
||||
</Alert>
|
||||
),
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{/* kit:support:begin */}
|
||||
{moduleSelected("support") && <SupportCommand open={() => help.occupant("support")} />}
|
||||
{/* kit:support:end */}
|
||||
{children}
|
||||
</AppFrame>
|
||||
</WorkspaceContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function OrgLayout(): ReactElement | null {
|
||||
const shell = useShell()
|
||||
const { client } = Route.useLoaderData()
|
||||
if (!shell) return null
|
||||
return (
|
||||
// The enabled modules' browser providers (an in-app guide's overlay) go beside the page, and the app's transport
|
||||
// around it: the kit's access pages, the brand form and whatever opens in the right panel read through it.
|
||||
<ClientModules context={client}>
|
||||
<Frame shell={shell}>
|
||||
<Outlet />
|
||||
</Frame>
|
||||
</ClientModules>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The root route: the document, the brand layer and the app shell (spec §10.2), on TanStack Start (F10).
|
||||
//
|
||||
// Its loader is a server function: it reads the session and translates every label the shell shows
|
||||
// (`shellData`, src/server/page.ts), so the browser receives strings, never a catalogue. The brand's
|
||||
// `--brand-*` declarations are a style attribute on <html> (src/ui/document.tsx), the layer a tenant brand
|
||||
// overrides at runtime without any stylesheet being rebuilt.
|
||||
//
|
||||
// Not found and forbidden are one component: guard.ts's forbidden page is a not-found with
|
||||
// `data.forbidden`, answered 403 (Start has no `forbidden()`).
|
||||
|
||||
import moduleCss from "@app/modules/styles.css?url"
|
||||
import { Button, UiLabelsProvider } from "@conusai-kit/ui/primitives"
|
||||
import {
|
||||
createRootRoute,
|
||||
type ErrorComponentProps,
|
||||
HeadContent,
|
||||
type NotFoundRouteProps,
|
||||
Outlet,
|
||||
Scripts,
|
||||
useMatch,
|
||||
} from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { ReactElement, ReactNode } from "react"
|
||||
import type { StatusPageData } from "../server/guard"
|
||||
import { BoundaryMessages, RootErrorView } from "../ui/boundary"
|
||||
import { brandHead, brandMetadata, brandTokensOnly, Document, type DocumentProps, type ShellData } from "../ui/document"
|
||||
import appCss from "../ui/globals.css?url"
|
||||
import { useHydratedSignal } from "../ui/hydrated"
|
||||
import { LanguageSwitch, ThemeSwitch } from "../ui/shell-controls"
|
||||
import { SignOut } from "../ui/sign-out"
|
||||
import { StateView } from "../ui/state-view"
|
||||
import { useShell } from "../ui/use-shell"
|
||||
|
||||
const readShell = createServerFn({ method: "GET" }).handler(async (): Promise<ShellData> => {
|
||||
const { reported, shellData } = await import("../server/page")
|
||||
return reported("__root__", () => shellData())
|
||||
})
|
||||
|
||||
export const Route = createRootRoute({
|
||||
loader: () => readShell(),
|
||||
head: ({ loaderData }) => {
|
||||
const head = loaderData
|
||||
? brandHead(brandMetadata(loaderData.brand), loaderData.publicUrl)
|
||||
: { meta: [{ charSet: "utf-8" }], links: [] }
|
||||
return {
|
||||
meta: head.meta,
|
||||
links: [...head.links, { rel: "stylesheet", href: appCss }, { rel: "stylesheet", href: moduleCss }],
|
||||
}
|
||||
},
|
||||
shellComponent: Shell,
|
||||
component: Outlet,
|
||||
notFoundComponent: StatusPage,
|
||||
errorComponent: RootError,
|
||||
})
|
||||
|
||||
/**
|
||||
* The organization theme for this request. The document keeps a stable tree for success and
|
||||
* not-found hydration; CSS delegates visible controls to a rendered AppFrame.
|
||||
*/
|
||||
function useOrganizationPage(): Pick<DocumentProps, "tenant"> {
|
||||
const organization = useMatch({ from: "/$org", shouldThrow: false })
|
||||
const data = organization?.loaderData
|
||||
if (!data) return {}
|
||||
return {
|
||||
tenant: { style: data.style, ...(data.brandMark ? { mark: data.brandMark } : {}) },
|
||||
}
|
||||
}
|
||||
|
||||
function Shell({ children }: { readonly children: ReactNode }): ReactElement {
|
||||
useHydratedSignal()
|
||||
const shell = useShell()
|
||||
const page = useOrganizationPage()
|
||||
if (!shell) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
const { labels } = shell
|
||||
return (
|
||||
<Document
|
||||
brand={shell.organizationTheme ? { ...shell.brand, style: brandTokensOnly(shell.brand.style) } : shell.brand}
|
||||
locale={shell.locale}
|
||||
theme={shell.theme}
|
||||
signedIn={shell.signedIn}
|
||||
{...(shell.csrf ? { csrf: shell.csrf } : {})}
|
||||
{...(shell.observe ? { observe: shell.observe } : {})}
|
||||
{...(shell.nonce ? { nonce: shell.nonce } : {})}
|
||||
signIn={labels.signIn}
|
||||
privacy={shell.privacy}
|
||||
{...page}
|
||||
head={<HeadContent />}
|
||||
scripts={<Scripts />}
|
||||
signOut={
|
||||
<>
|
||||
<a href={shell.accountHref}>{labels.account}</a>
|
||||
<SignOut label={labels.signOut} />
|
||||
</>
|
||||
}
|
||||
controls={
|
||||
<>
|
||||
<LanguageSwitch
|
||||
current={shell.locale}
|
||||
signedIn={shell.signedIn}
|
||||
label={labels.language}
|
||||
options={[
|
||||
{ value: "en", label: labels.languageEn },
|
||||
{ value: "lt", label: labels.languageLt },
|
||||
]}
|
||||
failedTemplate={labels.failed}
|
||||
/>
|
||||
<ThemeSwitch
|
||||
initial={shell.theme}
|
||||
label={labels.theme}
|
||||
options={[
|
||||
{ value: "system", label: labels.themeSystem },
|
||||
{ value: "dark", label: labels.themeDark },
|
||||
{ value: "light", label: labels.themeLight },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{shell.organizationTheme ? (
|
||||
<style
|
||||
data-org-theme="platform"
|
||||
{...(shell.nonce ? { nonce: shell.nonce } : {})}
|
||||
dangerouslySetInnerHTML={{ __html: shell.organizationTheme }}
|
||||
/>
|
||||
) : null}
|
||||
<UiLabelsProvider labels={shell.ui}>
|
||||
<BoundaryMessages strings={shell.boundary}>{children}</BoundaryMessages>
|
||||
</UiLabelsProvider>
|
||||
</Document>
|
||||
)
|
||||
}
|
||||
|
||||
/** The not-found page, and the forbidden page when guard.ts asked for it (`data.forbidden`). */
|
||||
export function StatusPage({ data }: NotFoundRouteProps): ReactElement {
|
||||
const shell = useShell()
|
||||
const forbidden = (data as StatusPageData | undefined)?.forbidden === true
|
||||
const labels = shell?.labels
|
||||
const title = forbidden ? labels?.forbiddenTitle : labels?.notFoundTitle
|
||||
const body = forbidden ? labels?.forbiddenBody : labels?.notFoundBody
|
||||
return (
|
||||
<StateView
|
||||
title={title ?? (forbidden ? "Forbidden" : "Not found")}
|
||||
actions={
|
||||
<Button asChild variant="outline">
|
||||
<a href="/">{shell?.boundary.home ?? "/"}</a>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p>{body ?? ""}</p>
|
||||
</StateView>
|
||||
)
|
||||
}
|
||||
|
||||
function RootError({ error }: ErrorComponentProps): ReactElement {
|
||||
const shell = useShell()
|
||||
return (
|
||||
<RootErrorView
|
||||
error={error as Error & { digest?: string }}
|
||||
// A failed module import remains cached by the browser. A new document
|
||||
// retries its code and loaders together, under the current session.
|
||||
retry={() => window.location.reload()}
|
||||
{...(shell ? { strings: shell.boundary, locale: shell.locale } : {})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Every HTTP entry point under /api: /api/health, /api/ready, /api/openapi.json, /api/v1/<procedure>, the
|
||||
// live stream and MCP -- one file, because there is one mount (spec §6.2, `@conusai-kit/host/http`) -- and
|
||||
// the Better Auth adapter's /api/auth/* when the deployment signs in through it.
|
||||
//
|
||||
// A more specific route file (`api.brand.og.ts`) answers its own path first. The handlers are imported on
|
||||
// the first request, so the browser's bundle never sees the mount.
|
||||
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
async function serve({ request }: { readonly request: Request }): Promise<Response> {
|
||||
// kit:convex-better-auth:begin
|
||||
if (new URL(request.url).pathname.startsWith("/api/auth/")) {
|
||||
const [{ loadAuthConfig }, { betterAuthRuntime }] = await Promise.all([
|
||||
import("@conusai-kit/auth"),
|
||||
import("../server/app"),
|
||||
])
|
||||
if (loadAuthConfig().provider !== "convex-better-auth") return new Response(null, { status: 404 })
|
||||
return betterAuthRuntime().proxy(request)
|
||||
}
|
||||
// kit:convex-better-auth:end
|
||||
const { handle } = await import("../server/router")
|
||||
return handle(request)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/$")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: serve,
|
||||
POST: serve,
|
||||
// A procedure whose payload is a byte stream (kit-core .stream(field)) is answered on PUT.
|
||||
PUT: serve,
|
||||
DELETE: serve,
|
||||
HEAD: serve,
|
||||
OPTIONS: serve,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// This app's brand for its managed identity service's sign-in pages (src/server/brand-routes.ts): public, because
|
||||
// the service reads it with no session. A more specific file than `api.$.ts`, so it answers its own path first.
|
||||
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/api/brand/identity")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async () => (await import("../server/brand-routes")).identityBrand(),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The deployment's organization's logo from Cloud (src/server/brand-routes.ts): public, because the sign-in shows it.
|
||||
// A more specific file than `api.$.ts`, so it answers its own path first.
|
||||
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/api/brand/logo")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async () => (await import("../server/brand-routes")).organizationLogoImage(),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The app's own share image (src/server/brand-routes.ts): public, because a link preview is fetched without
|
||||
// a session. A more specific file than `api.$.ts`, so it answers its own path first.
|
||||
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/api/brand/og")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async () => (await import("../server/brand-routes")).appShareImage(),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The sign-in routes the host answers: /auth/sign-in/start (to the provider), /auth/callback (the code is
|
||||
// exchanged on the server and one sealed cookie set), /auth/sign-out and, in development only,
|
||||
// /auth/dev-login. A failure of any step ends at the sign-in page with a code to read, never at a 500
|
||||
// (`createHostSignIn`). The pages under /auth (`auth.sign-in.tsx`, `auth.sign-up.tsx`,
|
||||
// `auth.signed-out.tsx`) are more specific route files and answer their own paths first.
|
||||
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
async function serve({ request }: { readonly request: Request }): Promise<Response> {
|
||||
const [{ authRoute }, { requestContext }] = await Promise.all([import("../server/app"), import("../server/request")])
|
||||
return authRoute(request, requestContext)
|
||||
}
|
||||
|
||||
async function signOut(request: Request): Promise<Response> {
|
||||
const site = request.headers.get("sec-fetch-site")
|
||||
if (site === null || site === "same-origin" || site === "none") {
|
||||
const { betterAuthSignOut } = await import("../server/better-auth-session") // kit:convex-better-auth
|
||||
const betterAuth = await betterAuthSignOut(request) // kit:convex-better-auth
|
||||
if (betterAuth) return betterAuth // kit:convex-better-auth
|
||||
}
|
||||
return serve({ request })
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/auth/$")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: serve,
|
||||
POST: ({ request }) =>
|
||||
new URL(request.url).pathname === "/auth/sign-out" ? signOut(request) : serve({ request }),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The sign-in page (spec §7): where a visitor lands when they need to be signed in, and where a
|
||||
// failed sign-in ends (`createHostSignIn`). It says what went wrong in the person's language -- from a
|
||||
// fixed set of codes, never from the provider's text -- and offers the way in the deployment has: its
|
||||
// identity provider, or, in development with none, any e-mail address.
|
||||
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { ReactElement } from "react"
|
||||
import type { SignInData } from "../server/account-pages"
|
||||
import { SignInCard } from "../ui/sign-in-card"
|
||||
import { signInSearch } from "../ui/sign-in-search"
|
||||
|
||||
const ROUTE = "/auth/sign-in"
|
||||
|
||||
const readSignIn = createServerFn({ method: "GET" })
|
||||
.validator(signInSearch)
|
||||
.handler(async ({ data }): Promise<SignInData> => {
|
||||
const [{ reported }, { signInData }] = await Promise.all([
|
||||
import("../server/page"),
|
||||
import("../server/account-pages"),
|
||||
])
|
||||
return reported(ROUTE, () => signInData(data))
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/auth/sign-in")({
|
||||
validateSearch: signInSearch,
|
||||
loaderDeps: ({ search }) => signInSearch(search),
|
||||
loader: ({ deps }) => readSignIn({ data: deps }),
|
||||
component: SignIn,
|
||||
})
|
||||
|
||||
function SignIn(): ReactElement {
|
||||
return <SignInCard data={Route.useLoaderData()} />
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The sign-up page of the Convex Better Auth adapter (AUTH_PROVIDER=convex-better-auth): a new
|
||||
// account with an e-mail address and a password. An OIDC deployment has its identity provider's own
|
||||
// registration, so this page is not found there.
|
||||
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { ReactElement } from "react"
|
||||
import type { SignUpData } from "../server/account-pages"
|
||||
import { BetterAuthForm } from "../ui/better-auth-form"
|
||||
import { signInSearch } from "../ui/sign-in-search"
|
||||
import { StateView } from "../ui/state-view"
|
||||
|
||||
const ROUTE = "/auth/sign-up"
|
||||
|
||||
const readSignUp = createServerFn({ method: "GET" })
|
||||
.validator(signInSearch)
|
||||
.handler(async ({ data }): Promise<SignUpData> => {
|
||||
const [{ reported }, { signUpData }] = await Promise.all([
|
||||
import("../server/page"),
|
||||
import("../server/account-pages"),
|
||||
])
|
||||
return reported(ROUTE, () => signUpData(data))
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/auth/sign-up")({
|
||||
validateSearch: signInSearch,
|
||||
loaderDeps: ({ search }) => signInSearch(search),
|
||||
loader: ({ deps }) => readSignUp({ data: deps }),
|
||||
component: SignUp,
|
||||
})
|
||||
|
||||
function SignUp(): ReactElement {
|
||||
const data = Route.useLoaderData()
|
||||
return (
|
||||
<StateView title={data.title}>
|
||||
<p>{data.intro}</p>
|
||||
<BetterAuthForm
|
||||
mode="sign-up"
|
||||
returnTo={data.returnTo}
|
||||
minPasswordLength={data.minPasswordLength}
|
||||
labels={data.labels}
|
||||
/>
|
||||
<p>
|
||||
<a href={data.toSignIn.href}>{data.toSignIn.label}</a>
|
||||
</p>
|
||||
</StateView>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Where the identity provider sends the browser after an RP-initiated sign-out (MIB I3's path).
|
||||
|
||||
import { Button } from "@conusai-kit/ui/primitives"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { ReactElement } from "react"
|
||||
import type { SignedOutData } from "../server/account-pages"
|
||||
import { AuthScreen } from "../ui/auth-screen"
|
||||
|
||||
const ROUTE = "/auth/signed-out"
|
||||
|
||||
const readSignedOut = createServerFn({ method: "GET" }).handler(async (): Promise<SignedOutData> => {
|
||||
const [{ reported }, { signedOutData }] = await Promise.all([
|
||||
import("../server/page"),
|
||||
import("../server/account-pages"),
|
||||
])
|
||||
return reported(ROUTE, () => signedOutData())
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/auth/signed-out")({
|
||||
loader: () => readSignedOut(),
|
||||
component: SignedOut,
|
||||
})
|
||||
|
||||
function SignedOut(): ReactElement {
|
||||
const data = Route.useLoaderData()
|
||||
return (
|
||||
<AuthScreen title={data.title} description={data.body}>
|
||||
<Button asChild>
|
||||
<a href={data.href}>{data.signIn}</a>
|
||||
</Button>
|
||||
</AuthScreen>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// kit:custom-ui the organization chooser shares onboarding's centered card and compact account controls
|
||||
// The entry page. A visitor gets the way in, on the brand's picture; a person with one organization goes to it and
|
||||
// one with none to onboarding, so only somebody with several organizations sees this list (spec §7).
|
||||
|
||||
import { AuthCard, AuthPage, PageSection, PageState } from "@conusai-kit/ui/patterns"
|
||||
import { Button } from "@conusai-kit/ui/primitives"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { ReactElement } from "react"
|
||||
import type { HomeData } from "../server/account-pages"
|
||||
import { AuthMark } from "../ui/auth-screen"
|
||||
import { LanguageSwitch, ThemeSwitch } from "../ui/shell-controls"
|
||||
import { SignInCard } from "../ui/sign-in-card"
|
||||
import { SignOut } from "../ui/sign-out"
|
||||
import { useShell } from "../ui/use-shell"
|
||||
|
||||
const ROUTE = "/"
|
||||
|
||||
const readHome = createServerFn({ method: "GET" }).handler(async (): Promise<HomeData> => {
|
||||
const [{ reported }, { homeData }] = await Promise.all([import("../server/page"), import("../server/account-pages")])
|
||||
return reported(ROUTE, () => homeData())
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
loader: () => readHome(),
|
||||
component: Home,
|
||||
})
|
||||
|
||||
function Home(): ReactElement {
|
||||
const data = Route.useLoaderData()
|
||||
const shell = useShell()
|
||||
if (!data.signedIn) return <SignInCard data={data.signIn} />
|
||||
if (!shell) return <AuthPage />
|
||||
const { labels } = shell
|
||||
return (
|
||||
<AuthPage>
|
||||
<AuthCard
|
||||
variant="plain"
|
||||
name={shell.brand.name}
|
||||
logo={<AuthMark {...shell.brand.logo} />}
|
||||
title={data.title}
|
||||
description={data.organizations.length ? data.description : undefined}
|
||||
footer={
|
||||
<div className="app-chooser-footer">
|
||||
<div className="app-chooser-preferences">
|
||||
<LanguageSwitch
|
||||
current={shell.locale}
|
||||
signedIn
|
||||
label={labels.language}
|
||||
options={[
|
||||
{ value: "en", label: labels.languageEn },
|
||||
{ value: "lt", label: labels.languageLt },
|
||||
]}
|
||||
failedTemplate={labels.failed}
|
||||
/>
|
||||
<ThemeSwitch
|
||||
initial={shell.theme}
|
||||
label={labels.theme}
|
||||
options={[
|
||||
{ value: "system", label: labels.themeSystem },
|
||||
{ value: "dark", label: labels.themeDark },
|
||||
{ value: "light", label: labels.themeLight },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="app-chooser-account">
|
||||
<a href={shell.accountHref}>{labels.account}</a>
|
||||
<SignOut label={labels.signOut} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="app-chooser-list">
|
||||
{data.organizations.length === 0 ? (
|
||||
<PageState title={data.empty} {...(data.emptyHint ? { description: data.emptyHint } : {})} />
|
||||
) : (
|
||||
data.organizations.map((organization) => (
|
||||
<PageSection
|
||||
key={organization.slug}
|
||||
data-guide="home.organization"
|
||||
variant="card"
|
||||
title={<a href={`/${organization.slug}`}>{organization.name}</a>}
|
||||
description={organization.role}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{data.create ? (
|
||||
<Button asChild variant="outline">
|
||||
<a href="/onboarding">{data.create}</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</AuthCard>
|
||||
</AuthPage>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The page an invitation link leads to: /invitations/<organization>/<token> (spec §3.1), on the way-in layout
|
||||
// of the first page. It reads the invitation (`tenancy.invitation.read`, which needs no account) and says what
|
||||
// it is: who it is for, the organization, the role and until when, or plainly that it was used, revoked or has
|
||||
// expired. Opening it changes nothing. A visitor chooses "Create your account" or "Sign in": a form POST to
|
||||
// /auth/invitation (kit-host), which starts the identity service's sign-up with the invited address and lands
|
||||
// the person in the organization's workspace. A signed-in person with the invited address accepts here.
|
||||
|
||||
import { Alert, AlertDescription, Button, Input } from "@conusai-kit/ui/primitives"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { ReactElement } from "react"
|
||||
import type { InvitationData } from "../server/account-pages"
|
||||
import { AuthScreen } from "../ui/auth-screen"
|
||||
import { InviteAccept } from "../ui/invite-accept"
|
||||
import { SignOut } from "../ui/sign-out"
|
||||
|
||||
const ROUTE = "/invitations/$org/$token"
|
||||
|
||||
const readInvitation = createServerFn({ method: "GET" })
|
||||
.validator((params: { readonly org: string; readonly token: string }) => params)
|
||||
.handler(async ({ data }): Promise<InvitationData> => {
|
||||
const [{ reported }, { invitationData }] = await Promise.all([
|
||||
import("../server/page"),
|
||||
import("../server/account-pages"),
|
||||
])
|
||||
return reported(ROUTE, () => invitationData(data))
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/invitations/$org/$token")({
|
||||
loader: ({ params }) => readInvitation({ data: { org: params.org, token: params.token } }),
|
||||
// The token is the credential: it must not leave in a Referer header, and the page is not for search.
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ name: "referrer", content: "no-referrer" },
|
||||
{ name: "robots", content: "noindex, nofollow" },
|
||||
],
|
||||
}),
|
||||
component: Invitation,
|
||||
})
|
||||
|
||||
/** One way in: a plain form POST, so it works before the page's script has loaded. */
|
||||
function StartForm({
|
||||
start,
|
||||
intent,
|
||||
label,
|
||||
primary,
|
||||
}: {
|
||||
start: NonNullable<InvitationData["start"]>
|
||||
intent: "sign-up" | "sign-in"
|
||||
label: string
|
||||
primary?: boolean
|
||||
}): ReactElement {
|
||||
return (
|
||||
<form method="post" action="/auth/invitation">
|
||||
<Input type="hidden" name="org" value={start.org} />
|
||||
<Input type="hidden" name="token" value={start.token} />
|
||||
<Input type="hidden" name="intent" value={intent} />
|
||||
<Input type="hidden" name="ui_locales" value={start.locale} />
|
||||
{start.theme ? <Input type="hidden" name="ui_theme" value={start.theme} /> : null}
|
||||
<Button type="submit" variant={primary ? "default" : "link"}>
|
||||
{label}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function Invitation(): ReactElement {
|
||||
const { org, token } = Route.useParams()
|
||||
const data = Route.useLoaderData()
|
||||
return (
|
||||
<AuthScreen
|
||||
title={data.title}
|
||||
footer={data.start ? <StartForm start={data.start} intent="sign-in" label={data.start.signIn} /> : undefined}
|
||||
>
|
||||
{data.state === "open" ? (
|
||||
<>
|
||||
<p>{data.lead}</p>
|
||||
{data.expires ? <p>{data.expires}</p> : null}
|
||||
</>
|
||||
) : (
|
||||
<Alert role="alert">
|
||||
<AlertDescription>{data.lead}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{data.start ? <StartForm start={data.start} intent="sign-up" label={data.start.createAccount} primary /> : null}
|
||||
{data.signInFirst ? (
|
||||
<>
|
||||
<p>{data.signInFirst.first}</p>
|
||||
<Button asChild>
|
||||
<a href={data.signInFirst.href}>{data.signInFirst.label}</a>
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{data.signedIn ? (
|
||||
<>
|
||||
<p>{data.signedIn.as}</p>
|
||||
{data.signedIn.otherAccount ? <p>{data.signedIn.otherAccount}</p> : null}
|
||||
{data.signedIn.labels ? <InviteAccept org={org} token={token} labels={data.signedIn.labels} /> : null}
|
||||
<SignOut label={data.signedIn.switchAccount} />
|
||||
</>
|
||||
) : null}
|
||||
</AuthScreen>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// `/members`: the one address of "the members page" that holds for every person, which the platform
|
||||
// console's identity card links to (ADR-062: the console says who may enter the app, the app's own
|
||||
// pages say what they may do in it). It leads to the members page of the person's organization:
|
||||
//
|
||||
// signed out to the sign-in page, which brings them back here
|
||||
// no organization to the entry page, which says so (and offers to create one where allowed)
|
||||
// one organization to `/<org>/members`, whose route decides what they may see there
|
||||
// several the organizations, each linking to its members page
|
||||
//
|
||||
// It reads `tenancy.me.get`, the person's own organizations, and nothing of any other.
|
||||
|
||||
import { Page, PageHeader } from "@conusai-kit/ui/patterns"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { ReactElement } from "react"
|
||||
import type { MembersEntryData } from "../server/account-pages"
|
||||
|
||||
const ROUTE = "/members"
|
||||
|
||||
const readMembers = createServerFn({ method: "GET" }).handler(async (): Promise<MembersEntryData> => {
|
||||
const [{ reported }, { membersEntryData }] = await Promise.all([
|
||||
import("../server/page"),
|
||||
import("../server/account-pages"),
|
||||
])
|
||||
return reported(ROUTE, () => membersEntryData())
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/members")({
|
||||
loader: () => readMembers(),
|
||||
component: Members,
|
||||
})
|
||||
|
||||
function Members(): ReactElement {
|
||||
const data = Route.useLoaderData()
|
||||
return (
|
||||
<main>
|
||||
<Page>
|
||||
<PageHeader title={data.title} />
|
||||
<ul>
|
||||
{data.organizations.map((organization) => (
|
||||
<li key={organization.slug}>
|
||||
<a href={`/${encodeURIComponent(organization.slug)}/members`}>{organization.name}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Page>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Onboarding: one card, the first organization, which creates its default workspace in one transaction
|
||||
// (kit-tenancy's use case), with the signed-in user as its owner, and lands in that workspace. Joining an
|
||||
// existing organization is not done by name (spec §3.3): it is an invitation, which has its own page.
|
||||
//
|
||||
// A deployment that keeps founding an organization to its operators (APP_ORGANIZATION_SELF_SERVICE=false)
|
||||
// shows this page only to a person the identity link grants the app-access role `admin`; anyone else
|
||||
// gets the forbidden page, and `tenancy.org.create` refuses them whatever page they call it from. So does a
|
||||
// one-organization deployment (APP_SINGLE_ORGANIZATION) once its organization exists.
|
||||
|
||||
import { AuthCard, AuthPage } from "@conusai-kit/ui/patterns"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { ReactElement } from "react"
|
||||
import type { OnboardingData } from "../server/account-pages"
|
||||
import { OnboardingForm } from "../ui/onboarding-form"
|
||||
|
||||
const ROUTE = "/onboarding"
|
||||
|
||||
const readOnboarding = createServerFn({ method: "GET" }).handler(async (): Promise<OnboardingData> => {
|
||||
const [{ reported }, { onboardingData }] = await Promise.all([
|
||||
import("../server/page"),
|
||||
import("../server/account-pages"),
|
||||
])
|
||||
return reported(ROUTE, () => onboardingData())
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/onboarding")({
|
||||
loader: () => readOnboarding(),
|
||||
component: Onboarding,
|
||||
})
|
||||
|
||||
function Onboarding(): ReactElement {
|
||||
const data = Route.useLoaderData()
|
||||
return (
|
||||
<AuthPage>
|
||||
<AuthCard variant="plain" name={data.name} title={data.title} description={data.description}>
|
||||
<OnboardingForm labels={data.labels} workspace={data.workspace} />
|
||||
</AuthCard>
|
||||
</AuthPage>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The person's own account (joint contract §12): what the app keeps about them, the export of it, and
|
||||
// the deletion of the account. The page is a user page, under `settings`, which is a reserved
|
||||
// organization slug, so no organization's path can shadow it.
|
||||
//
|
||||
// The export and the deletion are the account module's procedures; this page only renders them, and
|
||||
// the client host beside it ends the browser modules' context once a deletion answered. The analytics
|
||||
// panel (ADR-049) erases or exports what the platform's own stores keep about the person's visits,
|
||||
// through the account.analytics procedures: the deployment token stays on the server.
|
||||
|
||||
import { FactList, Page, PageHeader, PageSection } from "@conusai-kit/ui/patterns"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
import type { ReactElement } from "react"
|
||||
import type { AccountData } from "../server/account-pages"
|
||||
import { AccountPanels } from "../ui/account"
|
||||
|
||||
const ROUTE = "/settings/account"
|
||||
|
||||
const readAccount = createServerFn({ method: "GET" }).handler(async (): Promise<AccountData> => {
|
||||
const [{ reported }, { accountData }] = await Promise.all([
|
||||
import("../server/page"),
|
||||
import("../server/account-pages"),
|
||||
])
|
||||
return reported(ROUTE, () => accountData(true))
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/settings/account")({
|
||||
loader: () => readAccount(),
|
||||
component: Account,
|
||||
})
|
||||
|
||||
function Account(): ReactElement {
|
||||
const { page, displayName, email, exportLabels, analyticsLabels, deletion } = Route.useLoaderData()
|
||||
return (
|
||||
<main>
|
||||
<Page>
|
||||
<PageHeader title={page.title} description={page.intro} />
|
||||
<PageSection title={page.profileHeading} variant="card">
|
||||
<FactList
|
||||
facts={[
|
||||
{ label: page.name, value: displayName },
|
||||
{ label: page.email, value: email },
|
||||
]}
|
||||
/>
|
||||
</PageSection>
|
||||
<AccountPanels exportLabels={exportLabels} analyticsLabels={analyticsLabels} deletion={deletion} />
|
||||
</Page>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The server entry: what Next's `proxy.ts` and `instrumentation.ts` did, on TanStack Start (F10).
|
||||
//
|
||||
// - One nonce per request, handed to the renderer (src/router.tsx reads `x-nonce`) and named by the
|
||||
// content security policy, with the security headers of spec §6.4 on every response.
|
||||
// - `x-path`: where this request is, for a loader that sends a visitor to sign in and back.
|
||||
// - A page status a loader asked for (guard.ts's forbidden page) is the response's status.
|
||||
// - The deployment contract is parsed as the process starts: a missing or forbidden variable is logged by
|
||||
// name, `/api/ready` says what to set, and every route answers 503 (server/contract.ts).
|
||||
// - An error that escapes the handler is reported (report.ts); Start has no `onRequestError`.
|
||||
//
|
||||
// A request whose body is a byte stream (`PUT /api/v1/<procedure>` of a `.stream(field)` procedure) passes
|
||||
// through untouched: nothing here reads a body.
|
||||
|
||||
import { securityHeaders } from "@conusai-kit/host/headers"
|
||||
import handler, { createServerEntry } from "@tanstack/react-start/server-entry"
|
||||
import { type ContractRefusal, issuerOrigins, refusalOf, refusalResponse } from "./server/contract"
|
||||
import { PAGE_STATUS_HEADER } from "./server/guard"
|
||||
|
||||
function logContractRefusal(error: unknown): ContractRefusal {
|
||||
const refusal = refusalOf(error)
|
||||
console.error(`web: refused to serve: ${refusal.message}`)
|
||||
return refusal
|
||||
}
|
||||
|
||||
let contractChecked: Promise<ContractRefusal | null> | undefined
|
||||
const checkContract = (): Promise<ContractRefusal | null> => {
|
||||
contractChecked ??= Promise.all([import("@app/config"), import("@conusai-kit/auth")]).then(
|
||||
async ([config, { loadAuthConfig }]) => {
|
||||
try {
|
||||
config.loadAppEnv("web")
|
||||
loadAuthConfig()
|
||||
} catch (error: unknown) {
|
||||
return logContractRefusal(error)
|
||||
}
|
||||
// kit:profile-dialect:begin
|
||||
// Validate adopted module custody before lazy runtime/store construction. Only the
|
||||
// known composition refusal is configuration; registry/import bugs still throw.
|
||||
if (config.PROFILE === "mariadb" || config.PROFILE === "mongodb") {
|
||||
const { appModules } = await import("@app/modules")
|
||||
try {
|
||||
config.profileRegistry(appModules, config.PROFILE)
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof config.ProfileCompositionError)) throw error
|
||||
return logContractRefusal(error)
|
||||
}
|
||||
}
|
||||
// kit:profile-dialect:end
|
||||
return null
|
||||
},
|
||||
)
|
||||
return contractChecked
|
||||
}
|
||||
|
||||
// At start, not at the first request: the log says what is wrong before anything probes this process.
|
||||
void checkContract()
|
||||
|
||||
export default createServerEntry({
|
||||
async fetch(request, options) {
|
||||
const refused = await checkContract()
|
||||
if (refused) return refusalResponse(request, refused)
|
||||
const url = new URL(request.url)
|
||||
const nonce = Buffer.from(globalThis.crypto.randomUUID()).toString("base64")
|
||||
const development = process.env.NODE_ENV === "development"
|
||||
const secure = url.protocol === "https:" || request.headers.get("x-forwarded-proto") === "https"
|
||||
request.headers.set("x-nonce", nonce)
|
||||
request.headers.set("x-path", `${url.pathname}${url.search}`)
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await handler.fetch(request, { ...options, context: { ...options?.context, nonce } })
|
||||
} catch (error) {
|
||||
const { reportFailure } = await import("./server/report")
|
||||
reportFailure(error, request, { routePath: url.pathname, routeType: "handler" })
|
||||
throw error
|
||||
}
|
||||
|
||||
const asked = response.headers.get(PAGE_STATUS_HEADER)
|
||||
if (asked) {
|
||||
const headers = new Headers(response.headers)
|
||||
headers.delete(PAGE_STATUS_HEADER)
|
||||
response = new Response(response.body, { status: Number(asked), statusText: "", headers })
|
||||
}
|
||||
const formActions = issuerOrigins(process.env.OIDC_ISSUER)
|
||||
for (const [name, value] of Object.entries(securityHeaders({ nonce, secure, development, formActions }))) {
|
||||
response.headers.set(name, value)
|
||||
}
|
||||
return response
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,454 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The loaders of the pages outside an organization: the entry page, onboarding, the person's account, the
|
||||
// sign-in pages, an invitation and `/members` (F10). Each returns plain data with its labels already
|
||||
// translated; `src/routes/*` render it. Server-only: a route reaches this file through a server function
|
||||
// whose handler imports it.
|
||||
|
||||
import { appBrand } from "@app/config"
|
||||
import { ACCOUNT_PATH, accountDeletionStatus } from "@app/modules/account/contract"
|
||||
import {
|
||||
type AccountAnalyticsLabels,
|
||||
type AccountDeletionProps,
|
||||
type AccountExportLabels,
|
||||
analyticsLabels,
|
||||
deletionLabels,
|
||||
exportLabels,
|
||||
pageLabels,
|
||||
} from "@app/modules/account/ui"
|
||||
import { loadAuthConfig } from "@conusai-kit/auth"
|
||||
import { AppError } from "@conusai-kit/core"
|
||||
import { invitationRead, meGet } from "@conusai-kit/tenancy"
|
||||
import { builtInRoleName } from "@conusai-kit/tenancy/ui"
|
||||
import { getRequest } from "@tanstack/react-start/server"
|
||||
import type { BetterAuthFormProps } from "../ui/better-auth-form" // kit:convex-better-auth
|
||||
import type { InviteAcceptLabels } from "../ui/invite-accept"
|
||||
import type { OnboardingLabels } from "../ui/onboarding-form"
|
||||
import { WORKSPACE_COOKIE } from "../ui/preference-cookies"
|
||||
import type { SignInSearch } from "../ui/sign-in-search"
|
||||
import { AUTH_ERROR_MESSAGES, parseAuthError, safeReturnTo } from "./auth-errors"
|
||||
import { betterAuthFormLabels, MIN_PASSWORD_LENGTH } from "./better-auth" // kit:convex-better-auth
|
||||
import * as guard from "./guard" // kit:convex-better-auth
|
||||
import { orFallback, pageForbidden, signInHref, toPage, toSignIn } from "./guard"
|
||||
import { pageContext } from "./page"
|
||||
import { cookieValue } from "./preferences"
|
||||
|
||||
// ---------------------------------------------------------------- the entry page
|
||||
|
||||
export type HomeData =
|
||||
| { readonly signedIn: false; readonly signIn: SignInData }
|
||||
| {
|
||||
readonly signedIn: true
|
||||
readonly title: string
|
||||
readonly description: string
|
||||
readonly empty: string
|
||||
/** What a person with no organization, who may not found one, can do. */
|
||||
readonly emptyHint?: string
|
||||
readonly organizations: readonly { readonly slug: string; readonly name: string; readonly role: string }[]
|
||||
/** The link to found one, for a person who may. */
|
||||
readonly create?: string
|
||||
}
|
||||
|
||||
export async function homeData(): Promise<HomeData> {
|
||||
const { caller, signedIn, i18n, path, mayCreateOrganization } = await pageContext()
|
||||
if (!signedIn) return { signedIn: false, signIn: await signInData({}) }
|
||||
// The typed in-process client: the contract decides the input and the output types, and the
|
||||
// same procedure answers a browser, a CLI and a partner (spec §6.2).
|
||||
const me = await orFallback(path, () => caller.call(meGet, undefined))
|
||||
const [only] = me.organizations
|
||||
if (only && me.organizations.length === 1) toPage(`/${encodeURIComponent(only.slug)}`)
|
||||
if (!only && mayCreateOrganization) toPage("/onboarding")
|
||||
return {
|
||||
signedIn: true,
|
||||
title: i18n.message("app.organizations"),
|
||||
description: i18n.message("app.chooseOrganization"),
|
||||
empty: i18n.message("app.noOrganizations"),
|
||||
...(only ? {} : { emptyHint: i18n.message("app.askForInvitation") }),
|
||||
organizations: me.organizations.map((organization) => ({
|
||||
slug: organization.slug,
|
||||
name: organization.name,
|
||||
role: builtInRoleName(i18n, organization.role),
|
||||
})),
|
||||
...(mayCreateOrganization ? { create: i18n.message("app.createOrganization") } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- onboarding
|
||||
|
||||
export interface OnboardingData {
|
||||
readonly name: string
|
||||
readonly title: string
|
||||
readonly description: string
|
||||
readonly labels: OnboardingLabels
|
||||
/** The first workspace, in the deployment's language: it belongs to everybody who joins later. */
|
||||
readonly workspace: { readonly name: string; readonly slug: string }
|
||||
}
|
||||
|
||||
export async function onboardingData(): Promise<OnboardingData> {
|
||||
const { i18n, deploymentI18n, signedIn, path, mayCreateOrganization } = await pageContext()
|
||||
if (!signedIn) toSignIn(path)
|
||||
if (!mayCreateOrganization) pageForbidden()
|
||||
return {
|
||||
name: appBrand.name,
|
||||
title: i18n.message("onboarding.title"),
|
||||
description: i18n.message("onboarding.description"),
|
||||
labels: {
|
||||
name: i18n.message("onboarding.name"),
|
||||
nameHelp: i18n.message("onboarding.nameHelp"),
|
||||
address: i18n.message("onboarding.address"),
|
||||
addressHelp: i18n.message("onboarding.addressHelp"),
|
||||
addressPrefix: "app/",
|
||||
continue: i18n.message("onboarding.continue"),
|
||||
required: i18n.message("onboarding.required"),
|
||||
addressInvalid: i18n.message("onboarding.addressInvalid"),
|
||||
addressTaken: i18n.message("onboarding.addressTaken"),
|
||||
failed: i18n.message("onboarding.failed"),
|
||||
},
|
||||
workspace: {
|
||||
name: deploymentI18n.message("onboarding.workspaceName"),
|
||||
slug: deploymentI18n.message("onboarding.workspaceSlug"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- /members
|
||||
|
||||
export interface MembersEntryData {
|
||||
readonly title: string
|
||||
readonly organizations: readonly { readonly slug: string; readonly name: string }[]
|
||||
}
|
||||
|
||||
export async function membersEntryData(): Promise<MembersEntryData> {
|
||||
const { caller, signedIn, i18n, path } = await pageContext()
|
||||
if (!signedIn) toSignIn(path)
|
||||
const { organizations } = await orFallback(path, () => caller.call(meGet, undefined))
|
||||
const [only] = organizations
|
||||
if (organizations.length === 0) toPage("/")
|
||||
if (only && organizations.length === 1) toPage(`/${encodeURIComponent(only.slug)}/members`)
|
||||
return {
|
||||
title: i18n.message("app.organizations"),
|
||||
organizations: organizations.map(({ slug, name }) => ({ slug, name })),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the person's account
|
||||
|
||||
export interface AccountData {
|
||||
readonly page: ReturnType<typeof pageLabels>
|
||||
readonly displayName: string
|
||||
readonly email: string
|
||||
readonly exportLabels: AccountExportLabels
|
||||
readonly analyticsLabels: AccountAnalyticsLabels
|
||||
readonly deletion: Omit<AccountDeletionProps, "onDeleted">
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a person signs in again to delete their account: straight to the provider (the sign-in page
|
||||
* sends somebody already signed in away), or, in development, the dev login with their own address.
|
||||
* Either opens a new session, whose sign-in is then recent.
|
||||
*/
|
||||
function reauthHref(email: string): string {
|
||||
const back = `return_to=${encodeURIComponent(ACCOUNT_PATH)}`
|
||||
const config = loadAuthConfig()
|
||||
if (config.oidc) return `/auth/sign-in/start?${back}`
|
||||
if (config.devLogin) return `/auth/dev-login?email=${encodeURIComponent(email)}&${back}`
|
||||
return `/auth/sign-in?${back}`
|
||||
}
|
||||
|
||||
export async function accountData(preferOrganization = false): Promise<AccountData> {
|
||||
const { caller, i18n, signedIn, path } = await pageContext()
|
||||
if (!signedIn) toSignIn(path)
|
||||
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`account.${key}`, values)
|
||||
const [me, status] = await orFallback(path, () =>
|
||||
Promise.all([caller.call(meGet, undefined), caller.call(accountDeletionStatus, undefined)]),
|
||||
)
|
||||
if (preferOrganization) {
|
||||
const remembered = cookieValue(getRequest().headers.get("cookie"), WORKSPACE_COOKIE)?.split("/")
|
||||
const selected =
|
||||
remembered?.length === 2 && remembered[1]
|
||||
? me.organizations.find((organization) => organization.slug === remembered[0])
|
||||
: undefined
|
||||
const organization = selected ?? (me.organizations.length === 1 ? me.organizations[0] : undefined)
|
||||
if (organization) toPage(`/${encodeURIComponent(organization.slug)}/settings/account`)
|
||||
}
|
||||
const failed = i18n.message("app.failed")
|
||||
return {
|
||||
page: pageLabels(t),
|
||||
displayName: me.displayName,
|
||||
email: me.email,
|
||||
exportLabels: exportLabels(t, failed),
|
||||
analyticsLabels: analyticsLabels(t, failed),
|
||||
deletion: {
|
||||
labels: deletionLabels(t, status, me.email),
|
||||
interactive: status.interactive,
|
||||
recentSignIn: status.recentSignIn,
|
||||
reauthHref: reauthHref(me.email),
|
||||
deletion: status.deletion,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- signing in
|
||||
|
||||
export interface SignInData {
|
||||
readonly title: string
|
||||
/** What went wrong, from a fixed set of codes. */
|
||||
readonly error?: string
|
||||
/** The deployment's identity provider. */
|
||||
readonly provider?: { readonly href: string; readonly label: string }
|
||||
/** The provider's registration, for a person with no account. */
|
||||
readonly signUp?: { readonly href: string; readonly label: string }
|
||||
/** In development with no provider: any e-mail address. */
|
||||
readonly dev?: {
|
||||
readonly title: string
|
||||
readonly help: string
|
||||
readonly email: string
|
||||
readonly submit: string
|
||||
readonly returnTo?: string
|
||||
/** The address to start with: the one an invitation was sent to. */
|
||||
readonly address?: string
|
||||
}
|
||||
// kit:convex-better-auth:begin
|
||||
/** The Convex Better Auth adapter's form. */
|
||||
readonly betterAuth?: {
|
||||
readonly returnTo: string
|
||||
readonly minPasswordLength: number
|
||||
readonly labels: BetterAuthFormProps["labels"]
|
||||
readonly toSignUp: { readonly href: string; readonly label: string }
|
||||
}
|
||||
// kit:convex-better-auth:end
|
||||
readonly notConfigured?: string
|
||||
}
|
||||
|
||||
export async function signInData({
|
||||
error,
|
||||
return_to: returnToParam,
|
||||
login_hint: loginHint,
|
||||
}: SignInSearch): Promise<SignInData> {
|
||||
const { i18n, signedIn, theme } = await pageContext()
|
||||
const returnTo = safeReturnTo(returnToParam)
|
||||
// Somebody who is already in has nothing to do here, unless they were sent back with an error.
|
||||
const code = parseAuthError(error)
|
||||
if (signedIn && !code) toPage(returnTo)
|
||||
|
||||
const config = loadAuthConfig()
|
||||
const next = returnTo === "/" ? "" : `?return_to=${encodeURIComponent(returnTo)}` // kit:convex-better-auth
|
||||
// The page's language and theme go along, so the identity provider's form opens the way this one looks.
|
||||
const start = (intent?: "sign-up"): string => {
|
||||
const query = new URLSearchParams({ ui_locales: i18n.locale })
|
||||
if (theme === "light" || theme === "dark") query.set("ui_theme", theme)
|
||||
if (returnTo !== "/") query.set("return_to", returnTo)
|
||||
if (intent) query.set("intent", intent)
|
||||
return `/auth/sign-in/start?${query}`
|
||||
}
|
||||
return {
|
||||
title: i18n.message("signin.title"),
|
||||
...(code ? { error: i18n.message(AUTH_ERROR_MESSAGES[code]) } : {}),
|
||||
...(config.oidc
|
||||
? {
|
||||
provider: { href: start(), label: i18n.message("app.signIn") },
|
||||
signUp: { href: start("sign-up"), label: i18n.message("signin.createAccount") },
|
||||
}
|
||||
: {}),
|
||||
...(!config.oidc && config.devLogin
|
||||
? {
|
||||
dev: {
|
||||
title: i18n.message("signin.devTitle"),
|
||||
help: i18n.message("signin.devHelp"),
|
||||
email: i18n.message("app.email"),
|
||||
submit: i18n.message("signin.devSubmit"),
|
||||
...(returnTo === "/" ? {} : { returnTo }),
|
||||
...(loginHint ? { address: loginHint } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
// kit:convex-better-auth:begin
|
||||
...(config.provider === "convex-better-auth"
|
||||
? {
|
||||
betterAuth: {
|
||||
returnTo,
|
||||
minPasswordLength: MIN_PASSWORD_LENGTH,
|
||||
labels: betterAuthFormLabels(i18n, "sign-in"),
|
||||
toSignUp: { href: `/auth/sign-up${next}`, label: i18n.message("signin.account.toSignUp") },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
// kit:convex-better-auth:end
|
||||
...(!config.oidc && !config.devLogin && config.provider === "oidc"
|
||||
? { notConfigured: i18n.message("signin.notConfigured") }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
// kit:convex-better-auth:begin
|
||||
export interface SignUpData {
|
||||
readonly title: string
|
||||
readonly intro: string
|
||||
readonly returnTo: string
|
||||
readonly minPasswordLength: number
|
||||
readonly labels: BetterAuthFormProps["labels"]
|
||||
readonly toSignIn: { readonly href: string; readonly label: string }
|
||||
}
|
||||
|
||||
/** The Convex Better Auth adapter's own page: an OIDC deployment has its provider's registration. */
|
||||
export async function signUpData({ return_to: returnToParam }: SignInSearch): Promise<SignUpData> {
|
||||
if (loadAuthConfig().provider !== "convex-better-auth") guard.pageNotFound()
|
||||
const { i18n, signedIn } = await pageContext()
|
||||
const returnTo = safeReturnTo(returnToParam)
|
||||
if (signedIn) toPage(returnTo)
|
||||
const next = returnTo === "/" ? "" : `?return_to=${encodeURIComponent(returnTo)}`
|
||||
return {
|
||||
title: i18n.message("signin.account.signUpTitle"),
|
||||
intro: i18n.message("signin.account.signUpIntro"),
|
||||
returnTo,
|
||||
minPasswordLength: MIN_PASSWORD_LENGTH,
|
||||
labels: betterAuthFormLabels(i18n, "sign-up"),
|
||||
toSignIn: { href: `/auth/sign-in${next}`, label: i18n.message("signin.account.toSignIn") },
|
||||
}
|
||||
}
|
||||
|
||||
// kit:convex-better-auth:end
|
||||
export interface SignedOutData {
|
||||
readonly title: string
|
||||
readonly body: string
|
||||
readonly signIn: string
|
||||
readonly href: string
|
||||
}
|
||||
|
||||
export async function signedOutData(): Promise<SignedOutData> {
|
||||
const { i18n } = await pageContext()
|
||||
return {
|
||||
title: i18n.message("app.signedOut"),
|
||||
body: i18n.message("app.signedOutBody"),
|
||||
signIn: i18n.message("app.signIn"),
|
||||
href: "/auth/sign-in",
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- an invitation
|
||||
|
||||
/** The states an invitation link is in, and `invalid` for a link that is not one. */
|
||||
export type InvitationLinkState = "open" | "used" | "revoked" | "expired" | "invalid"
|
||||
|
||||
export interface InvitationData {
|
||||
readonly title: string
|
||||
readonly state: InvitationLinkState
|
||||
/** Who it is for and where it leads, or what is wrong with it, in the person's language. */
|
||||
readonly lead: string
|
||||
/** When an open link stops working. */
|
||||
readonly expires?: string
|
||||
/**
|
||||
* A visitor with an open link: "Create your account" and "Sign in" (POST /auth/invitation), the
|
||||
* page's language and theme going along to the identity service.
|
||||
*/
|
||||
readonly start?: {
|
||||
readonly org: string
|
||||
readonly token: string
|
||||
readonly locale: string
|
||||
readonly theme?: "light" | "dark"
|
||||
readonly createAccount: string
|
||||
readonly signIn: string
|
||||
}
|
||||
/** A deployment whose sign-in takes no invitation (Convex Better Auth): sign in and come back. */
|
||||
readonly signInFirst?: { readonly first: string; readonly href: string; readonly label: string }
|
||||
/** A signed-in person: who they are, and the accept button when the link is for them. */
|
||||
readonly signedIn?: {
|
||||
readonly as: string
|
||||
/** The address the link is for, when it is not the signed-in one. */
|
||||
readonly otherAccount?: string
|
||||
readonly labels?: InviteAcceptLabels
|
||||
readonly switchAccount: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The invitation a link names, as its holder may see it (`tenancy.invitation.read`, which needs no
|
||||
* account): whether it still works, who it is for, the organization and the role. Opening the page
|
||||
* changes nothing; the buttons are what start a sign-in or accept.
|
||||
*/
|
||||
export async function invitationData({
|
||||
org,
|
||||
token,
|
||||
}: {
|
||||
readonly org: string
|
||||
readonly token: string
|
||||
}): Promise<InvitationData> {
|
||||
const { i18n, signedIn, user, path, caller, theme } = await pageContext()
|
||||
const message = (key: string, values?: Record<string, string | number>): string => i18n.message(key, values)
|
||||
const title = message("invite.title")
|
||||
let invitation: Awaited<ReturnType<typeof readInvitationLink>> | undefined
|
||||
try {
|
||||
invitation = await readInvitationLink(caller, org, token)
|
||||
} catch (error) {
|
||||
// Not a link of this organization, or no link at all: one answer for both.
|
||||
if (!(error instanceof AppError) || error.status !== 404) throw error
|
||||
}
|
||||
if (!invitation) return { title, state: "invalid", lead: message("invite.error.unknown") }
|
||||
if (invitation.state !== "open") {
|
||||
return { title, state: invitation.state, lead: message(`invite.error.${invitation.state}`) }
|
||||
}
|
||||
const role = invitation.roleKey.startsWith("custom:") ? undefined : builtInRoleName(i18n, invitation.role)
|
||||
const values = { email: invitation.email, org: invitation.organization.name, ...(role ? { role } : {}) }
|
||||
const lead = message(role ? "invite.lead" : "invite.leadNoRole", values)
|
||||
const expires = message("invite.expires", {
|
||||
date: new Intl.DateTimeFormat(i18n.locale, { dateStyle: "long", timeStyle: "short" }).format(
|
||||
new Date(invitation.expiresAt),
|
||||
),
|
||||
})
|
||||
if (signedIn) {
|
||||
const mine = user?.email.trim().toLowerCase() === invitation.email
|
||||
return {
|
||||
title,
|
||||
state: "open",
|
||||
lead,
|
||||
expires,
|
||||
signedIn: {
|
||||
as: message("invite.signedInAs", { email: user?.email ?? "" }),
|
||||
...(mine
|
||||
? {
|
||||
labels: {
|
||||
accept: message("invite.accept"),
|
||||
accepted: message("invite.accepted", { org: invitation.organization.name }),
|
||||
invalid: message("invite.error.invalid"),
|
||||
used: message("invite.error.used"),
|
||||
revoked: message("invite.error.revoked"),
|
||||
expired: message("invite.error.expired"),
|
||||
member: message("invite.error.member"),
|
||||
failed: message("invite.error.failed"),
|
||||
},
|
||||
}
|
||||
: { otherAccount: message("invite.otherAccount", { email: invitation.email }) }),
|
||||
switchAccount: message("invite.switchAccount"),
|
||||
},
|
||||
}
|
||||
}
|
||||
const config = loadAuthConfig()
|
||||
if (!config.oidc && !config.devLogin) {
|
||||
return {
|
||||
title,
|
||||
state: "open",
|
||||
lead,
|
||||
expires,
|
||||
signInFirst: { first: message("invite.signInFirst"), href: signInHref(path), label: message("app.signIn") },
|
||||
}
|
||||
}
|
||||
return {
|
||||
title,
|
||||
state: "open",
|
||||
lead,
|
||||
expires,
|
||||
start: {
|
||||
org,
|
||||
token,
|
||||
locale: i18n.locale,
|
||||
...(theme === "light" || theme === "dark" ? { theme } : {}),
|
||||
createAccount: message("invite.createAccount"),
|
||||
signIn: message("invite.haveAccount"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** `tenancy.invitation.read` through the page's caller: the same procedure the API serves. */
|
||||
const readInvitationLink = (caller: PageCaller, org: string, token: string) =>
|
||||
caller.call(invitationRead, { org, token })
|
||||
|
||||
type PageCaller = Awaited<ReturnType<typeof pageContext>>["caller"]
|
||||
@@ -0,0 +1,508 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The web process's composition root (spec §2). On Postgres it is `createPostgresHost`: this app's
|
||||
// modules, ports and brand, handed to `@conusai-kit/host/postgres`. The Convex, MariaDB and MongoDB
|
||||
// profiles are composed here, to the same shape, from the same request and sign-in code.
|
||||
//
|
||||
// One runtime per process. `vite build` writes one server graph whose chunks share this file, and the kit
|
||||
// stays external, so every route reaches one copy of the kit and one runtime. A second copy of the kit would
|
||||
// hand `@conusai-kit/auth` the data source of another ("entity session is not the definition this data source
|
||||
// was built with"). In development Vite may evaluate this file again after an edit, and that copy builds a
|
||||
// runtime of its own, which is only a second pool.
|
||||
|
||||
import {
|
||||
type AppEnv,
|
||||
appBrand,
|
||||
businessServiceComposition,
|
||||
identityLinkOf,
|
||||
liveCaptureMigrations, // kit:profile-dialect
|
||||
loadAppEnv, // kit:profile-other
|
||||
organizationSelfServiceOf, // kit:profile-other
|
||||
type PlatformClients,
|
||||
PROFILE,
|
||||
platformClients, // kit:profile-other
|
||||
profileRegistry, // kit:profile-dialect
|
||||
type ServerObservability,
|
||||
startObservability, // kit:profile-other
|
||||
} from "@app/config"
|
||||
import { appModules } from "@app/modules"
|
||||
import { recordSessionRevocation } from "@app/modules/account"
|
||||
import type {
|
||||
AppSessionAdapter,
|
||||
AppSessionAdapterFactories,
|
||||
AuthRoutes,
|
||||
IdentityAdmission,
|
||||
SessionRef,
|
||||
} from "@conusai-kit/auth"
|
||||
import { loadAuthConfig } from "@conusai-kit/auth" // kit:convex-better-auth
|
||||
import {
|
||||
buildRegistry, // kit:profile-other
|
||||
type CallerOptions,
|
||||
Container,
|
||||
type Registry,
|
||||
} from "@conusai-kit/core"
|
||||
import {
|
||||
DATA_SOURCE, // kit:profile-dialect
|
||||
type DataSource,
|
||||
entitiesOf, // kit:profile-other
|
||||
IDEMPOTENCY, // kit:profile-dialect
|
||||
JOB_QUEUE, // kit:profile-dialect
|
||||
OUTBOX, // kit:profile-dialect
|
||||
SYSTEM_DATA, // kit:profile-dialect
|
||||
type SystemDataSource,
|
||||
} from "@conusai-kit/data"
|
||||
import type { KyselyDataSource } from "@conusai-kit/data-kysely"
|
||||
import {
|
||||
createHostSignIn,
|
||||
createPostgresHost,
|
||||
type HostSignIn,
|
||||
invitationCallsOf,
|
||||
kernelRequestContext,
|
||||
type PostgresHost,
|
||||
SESSION_ADAPTERS,
|
||||
SESSION_REVOKED,
|
||||
} from "@conusai-kit/host/postgres"
|
||||
import { KIT_JOBS_ENTITIES } from "@conusai-kit/jobs-pgboss" // kit:profile-dialect
|
||||
import type { LiveLog } from "@conusai-kit/live-server"
|
||||
import { GRANTS_CHANGED, GRANTS_CHANGED_FAILED } from "@conusai-kit/tenancy" // kit:profile-other
|
||||
import { observeRegister } from "@conusai-sdk/observe-start/register" // kit:profile-other
|
||||
import { ConvexHttpClient } from "convex/browser" // kit:profile-convex
|
||||
import { makeFunctionReference } from "convex/server" // kit:profile-convex
|
||||
import type { Pool } from "pg"
|
||||
import { CONVEX_PROCEDURES } from "../../../../convex/generated/procedures" // kit:profile-convex
|
||||
import { convexBffModules } from "../../../../convex/lib/convexModules.mjs" // kit:profile-convex
|
||||
import * as betterAuthAdapter from "./better-auth" // kit:convex-better-auth
|
||||
import { createConvexAuthDataSource } from "./convex-auth-data" // kit:profile-convex
|
||||
import { deliverConvexAccountEvent } from "./convex-caller" // kit:convex-better-auth
|
||||
import { revokeConvexSession } from "./convex-revocation" // kit:profile-convex
|
||||
import { revokeDerivedSessions } from "./derived-sessions"
|
||||
import { dialectStores } from "./dialect/stores" // kit:profile-dialect
|
||||
import { liveGrantsChanged } from "./live-grants" // kit:profile-other
|
||||
import { bindWebPorts, nativeHelpSelectors } from "./ports"
|
||||
import type { DialectDataSource, StoreProbes } from "./stores"
|
||||
|
||||
export interface WebRuntime extends Pick<CallerOptions, "prepareServices" | "serviceReadiness"> {
|
||||
readonly env: AppEnv
|
||||
readonly registry: Registry
|
||||
/** The data source of the postgres (Kysely), mariadb (Kysely MySQL) or mongodb profile. */
|
||||
readonly data?: KyselyDataSource | DialectDataSource
|
||||
/** The two pools, for the readiness probe: everything else reaches them through the data source. */
|
||||
readonly pools?: { readonly runtime: Pool; readonly reader: Pool }
|
||||
/** The mariadb and mongodb profiles' readiness probes (one login, so no reader). */
|
||||
readonly store?: StoreProbes
|
||||
/** The live log of a dialect profile (mariadb triggers, mongodb change stream), for live.ts. */
|
||||
readonly liveLog?: () => LiveLog
|
||||
readonly convex?: NonNullable<AppEnv["convex"]>
|
||||
readonly authData?: DataSource & SystemDataSource
|
||||
/** The process-wide container. A request resolves from a child of it (request.ts). */
|
||||
readonly root: Container
|
||||
/** The platform's services (AI, OCR, Blob, audit, usage), built once from the deployment contract. */
|
||||
readonly platform: PlatformClients
|
||||
/** Errors, traces and logs (config/src/observability.ts). */
|
||||
readonly observe: ServerObservability
|
||||
/** Whether any signed-in person may create an organization (APP_ORGANIZATION_SELF_SERVICE, ADR-062). */
|
||||
readonly selfServiceOrganizations: boolean
|
||||
/** One deployment, one organization (APP_SINGLE_ORGANIZATION). */
|
||||
readonly singleOrganization: boolean
|
||||
/** The identity link invitations admit their address through (OIDC_LINK_ID), or why there is none. */
|
||||
readonly identityLink: ReturnType<typeof identityLinkOf>
|
||||
}
|
||||
|
||||
let runtime: WebRuntime | undefined
|
||||
let checked: Promise<void> | undefined
|
||||
let host: PostgresHost | undefined
|
||||
let signIn: HostSignIn | undefined
|
||||
/**
|
||||
* The request context an invitation link's sign-in calls through: request.ts's, which `/auth/*` hands to
|
||||
* `authRoute`. request.ts imports this file, so this file never imports it (no cycle between the two).
|
||||
*/
|
||||
let invitationContext: Parameters<typeof invitationCallsOf>[0] | undefined
|
||||
let kernel: ReturnType<typeof kernelRequestContext> | undefined
|
||||
let betterAuth: betterAuthAdapter.BetterAuthRuntime | undefined // kit:convex-better-auth
|
||||
let draining: Promise<void> | undefined
|
||||
let closeDialect: (() => Promise<void>) | undefined
|
||||
|
||||
/**
|
||||
* What SIGTERM and SIGINT, which the platform sends before it replaces or stops the container, end in: flush the
|
||||
* queued usage events (an unflushed queue is unbilled usage), send what the observability exporters hold, and close
|
||||
* the pools. `server.mjs` owns the signal and the exit: it stops taking requests, waits for the ones in flight, awaits
|
||||
* this, and only then exits. So this registers no signal handler of its own, because a handler here would race the
|
||||
* exit and start closing pools under requests still running. `vite dev` calls nothing: a development server holds no
|
||||
* usage worth keeping.
|
||||
*/
|
||||
function drain(): Promise<void> {
|
||||
draining ??= (async () => {
|
||||
runtime?.observe.log.info("web: flushing and closing")
|
||||
await betterAuth?.stop().catch(() => undefined) // kit:convex-better-auth
|
||||
if (host) await host.close()
|
||||
else if (runtime) {
|
||||
const current = runtime
|
||||
await current.platform.usage.close().catch((error: unknown) => {
|
||||
current.observe.log.warn(
|
||||
`web: the usage queue was not flushed: ${error instanceof Error ? error.message : "failed"}`,
|
||||
)
|
||||
})
|
||||
await current.observe.shutdown().catch(() => undefined)
|
||||
// A dialect profile closes its queue connection with its store.
|
||||
await (closeDialect ? closeDialect() : current.data?.close())?.catch(() => undefined)
|
||||
}
|
||||
process.stdout.write("web: flushed and closed\n")
|
||||
})()
|
||||
return draining
|
||||
}
|
||||
|
||||
/** Where `server.mjs` finds the drain: the build's one bundle is not a module it can import a name from. */
|
||||
function offerDrain(): void {
|
||||
;(globalThis as unknown as Record<symbol, () => Promise<void>>)[Symbol.for("conusai.app.drain")] = drain
|
||||
}
|
||||
|
||||
/**
|
||||
* The process, built on its first request. The server entry (src/server.ts) parses the deployment contract
|
||||
* first, so a misconfigured deployment stops the server before it takes one: `loadAppEnv` throws a
|
||||
* typed error naming the variable (a missing platform URL or token, the fake platform in a production
|
||||
* run, an owner's database URL in the web container).
|
||||
*/
|
||||
export function webRuntime(): WebRuntime {
|
||||
if (runtime) return runtime
|
||||
if (PROFILE === "postgres") {
|
||||
const postgres = postgresHost()
|
||||
runtime = postgres.runtime()
|
||||
checked = postgres.rolesChecked()
|
||||
return runtime
|
||||
}
|
||||
// kit:profile-other:begin
|
||||
const env = loadAppEnv("web")
|
||||
const observe = startObservability(env, { initErrors: observeRegister })
|
||||
// Parsed at boot with the rest of the contract: a value that is neither true nor false, or a link id
|
||||
// without its admission credential, stops the server.
|
||||
const selfServiceOrganizations = organizationSelfServiceOf()
|
||||
const identityLink = identityLinkOf()
|
||||
// kit:profile-other:end
|
||||
// kit:profile-convex:begin
|
||||
if (PROFILE === "convex") {
|
||||
if (!env.convex) throw new Error("Convex profile environment was not loaded")
|
||||
// Keep the supported file effects in the HTTP registry so its routes pass the kernel's
|
||||
// input/scope/permission checks. createConvexCaller intercepts them and runs the composed
|
||||
// server action/mutation workflow; the Convex backend registry itself omits effect wrappers.
|
||||
const registry = buildRegistry([...convexBffModules(appModules)])
|
||||
const platform = platformClients(env, registry)
|
||||
const authData = createConvexAuthDataSource(env.convex)
|
||||
const root = new Container()
|
||||
bindSignInPorts(root)
|
||||
bindGrantChanges(root, observe)
|
||||
const current: WebRuntime = {
|
||||
env,
|
||||
registry,
|
||||
root,
|
||||
platform,
|
||||
observe,
|
||||
convex: env.convex,
|
||||
authData,
|
||||
selfServiceOrganizations,
|
||||
singleOrganization: false,
|
||||
identityLink,
|
||||
}
|
||||
runtime = current
|
||||
checked = verifyConvexCapabilities(current)
|
||||
offerDrain()
|
||||
return current
|
||||
}
|
||||
// kit:profile-convex:end
|
||||
// kit:profile-dialect:begin -- the mariadb and mongodb profiles (dialect/stores.ts)
|
||||
if (PROFILE === "mariadb" || PROFILE === "mongodb") {
|
||||
const registry = profileRegistry(appModules, PROFILE)
|
||||
const platform = platformClients(env, registry)
|
||||
const stores = dialectStores(
|
||||
env,
|
||||
[...entitiesOf(registry, PROFILE), ...KIT_JOBS_ENTITIES],
|
||||
liveCaptureMigrations(registry, PROFILE),
|
||||
)
|
||||
if (!stores) throw new Error(`${PROFILE} profile environment was not loaded`)
|
||||
const { data } = stores
|
||||
const root = new Container()
|
||||
root.value(DATA_SOURCE, data)
|
||||
root.value(SYSTEM_DATA, data)
|
||||
root.value(OUTBOX, data.outbox)
|
||||
root.value(IDEMPOTENCY, data.idempotency)
|
||||
bindWebPorts(root, platform, registry)
|
||||
bindSignInPorts(root)
|
||||
bindGrantChanges(root, observe)
|
||||
betterAuthAdapter.bindBetterAuthAccountEnd(root) // kit:convex-better-auth
|
||||
// BullMQ has no transactional enqueue: a job a write asks for is enqueued after it commits,
|
||||
// and a write that must enqueue atomically publishes an outbox event instead (the relay runs it).
|
||||
root.register(JOB_QUEUE, () => stores.queue())
|
||||
const current: WebRuntime = {
|
||||
...businessServiceComposition(env, appModules, PROFILE, { model: platform.model, ...nativeHelpSelectors }),
|
||||
env,
|
||||
registry,
|
||||
data,
|
||||
store: stores.probes,
|
||||
...(stores.liveLog ? { liveLog: stores.liveLog } : {}),
|
||||
root,
|
||||
platform,
|
||||
observe,
|
||||
selfServiceOrganizations,
|
||||
singleOrganization: false,
|
||||
identityLink,
|
||||
}
|
||||
runtime = current
|
||||
// AT-D15: a standalone MongoDB has no transactions, so the process stops rather than serve on it.
|
||||
checked = stores.probes.boot().catch((error: unknown) => {
|
||||
observe.log.error(`web: ${error instanceof Error ? error.message : String(error)}`)
|
||||
process.exit(78)
|
||||
})
|
||||
closeDialect = () => stores.close()
|
||||
offerDrain()
|
||||
return current
|
||||
}
|
||||
// kit:profile-dialect:end
|
||||
throw new Error(`the ${PROFILE} profile has no web composition`)
|
||||
}
|
||||
|
||||
/** The Postgres profile's host: this app's modules, its ports and its brand. Built once, connected on first use. */
|
||||
export function postgresHost(): PostgresHost {
|
||||
// Offered with the host, not with `webRuntime`: a process that has only answered `/api` holds a usage queue too.
|
||||
if (!host) offerDrain()
|
||||
host ??= createPostgresHost({
|
||||
callerServices: (env, platform) =>
|
||||
businessServiceComposition(env, appModules, "postgres", { model: platform.model, ...nativeHelpSelectors }),
|
||||
modules: appModules,
|
||||
brand: appBrand,
|
||||
bindPorts: (root, platform, registry) => {
|
||||
// The platform's services, bound to the tokens a module resolves them by (ports.ts).
|
||||
bindWebPorts(root, platform, registry)
|
||||
bindSignInPorts(root)
|
||||
// An account deletion ends the person's Better Auth account too (AUTH_PROVIDER=convex-better-auth).
|
||||
betterAuthAdapter.bindBetterAuthAccountEnd(root) // kit:convex-better-auth
|
||||
},
|
||||
})
|
||||
return host
|
||||
}
|
||||
|
||||
/** What the host's sign-in asks of this app: the end of what it derived from a session, and its other providers. */
|
||||
function bindSignInPorts(root: Container): void {
|
||||
root.value(SESSION_REVOKED, (ref) => sessionRevoked(webRuntime(), ref))
|
||||
const adapters: AppSessionAdapterFactories = {}
|
||||
adapters["convex-better-auth"] = () => betterAuthRuntime().adapter // kit:convex-better-auth
|
||||
root.value(SESSION_ADAPTERS, adapters)
|
||||
}
|
||||
|
||||
// kit:profile-other:begin
|
||||
/**
|
||||
* kit-tenancy's announcement of a committed grant change (a role, a membership, an assignment): to
|
||||
* this process's open live streams of the organization (live-grants.ts), and a failed announcement
|
||||
* to the log with the request's trace -- the write is done, so it is reported and never thrown.
|
||||
*/
|
||||
function bindGrantChanges(root: Container, observe: ServerObservability): void {
|
||||
root.value(GRANTS_CHANGED, liveGrantsChanged)
|
||||
root.value(GRANTS_CHANGED_FAILED, ({ error }) =>
|
||||
observe.log.error(
|
||||
`live: a grant change was not announced to the open streams: ${error instanceof Error ? error.name : "failed"}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
// kit:profile-other:end
|
||||
|
||||
/** This profile's sign-in: the host's on Postgres, the same code over the profile's data source otherwise. */
|
||||
export function hostSignIn(): HostSignIn {
|
||||
if (PROFILE === "postgres") return postgresHost().signIn
|
||||
signIn ??= createHostSignIn({
|
||||
runtime: webRuntime as () => WebRuntime & { data?: DataSource & SystemDataSource },
|
||||
// An invitation link's sign-in calls the invitation procedures through the request's own caller.
|
||||
invitations: () =>
|
||||
invitationCallsOf((request) => {
|
||||
if (!invitationContext) throw new Error("an invitation's sign-in is answered through authRoute")
|
||||
return invitationContext(request)
|
||||
}),
|
||||
})
|
||||
return signIn
|
||||
}
|
||||
|
||||
/** The identity link's admission client (ADR-062 decision 4), built on first use, or why there is none. */
|
||||
export function identityAdmission(): IdentityAdmission | { readonly skipped: string } {
|
||||
return hostSignIn().admission()
|
||||
}
|
||||
|
||||
/** `/auth/*` of this profile: what `src/routes/auth.$.ts` answers, with request.ts's request context. */
|
||||
export function authRoute(request: Request, context: Parameters<typeof invitationCallsOf>[0]): Promise<Response> {
|
||||
invitationContext ??= context
|
||||
return hostSignIn().handle(request)
|
||||
}
|
||||
|
||||
/** A request's context on a profile whose procedures run in this process. */
|
||||
export function kernelContext(): ReturnType<typeof kernelRequestContext> {
|
||||
if (PROFILE === "postgres") return postgresHost().requestContext
|
||||
kernel ??= kernelRequestContext({
|
||||
runtime: webRuntime as () => WebRuntime & { data?: DataSource & SystemDataSource },
|
||||
signIn: hostSignIn(),
|
||||
rolesChecked,
|
||||
})
|
||||
return kernel
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves once the two connections this process holds have been checked (decision AT-D20). A
|
||||
* request awaits it, so no request runs through a connection that is not bound by row-level
|
||||
* security; `/api/health` does not, because liveness must not depend on the database.
|
||||
*/
|
||||
export function rolesChecked(): Promise<void> {
|
||||
webRuntime()
|
||||
return checked ?? Promise.resolve()
|
||||
}
|
||||
|
||||
// kit:profile-convex:begin
|
||||
/** Check that this backend has the exact generated application contract before taking traffic. */
|
||||
export async function convexCapabilitiesReady(): Promise<boolean> {
|
||||
const current = webRuntime()
|
||||
if (!current.convex) return true
|
||||
try {
|
||||
return (await convexCapabilityStatus(current)).ready
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export interface ConvexCapabilityStatus {
|
||||
readonly ready: boolean
|
||||
readonly backend: boolean
|
||||
readonly modules: boolean
|
||||
readonly procedures: boolean
|
||||
readonly actions: boolean
|
||||
readonly schema: boolean
|
||||
}
|
||||
|
||||
export async function convexCapabilityStatus(current: WebRuntime = webRuntime()): Promise<ConvexCapabilityStatus> {
|
||||
if (!current.convex)
|
||||
return { ready: true, backend: true, modules: true, procedures: true, actions: true, schema: true }
|
||||
const client = new ConvexHttpClient(current.convex.url)
|
||||
type AdminAuthClient = ConvexHttpClient & { setAdminAuth(token: string): void }
|
||||
;(client as AdminAuthClient).setAdminAuth(current.convex.adminKey)
|
||||
try {
|
||||
const actual = (await client.query(makeFunctionReference("system:ready") as never, {} as never)) as {
|
||||
ready?: boolean
|
||||
modules?: string[]
|
||||
procedures?: string[]
|
||||
actions?: string[]
|
||||
tables?: string[]
|
||||
schema?: unknown
|
||||
}
|
||||
const actionProbe = (await client.action(
|
||||
makeFunctionReference("filesActions:readiness") as never,
|
||||
{} as never,
|
||||
)) as {
|
||||
ready?: boolean
|
||||
}
|
||||
const modules = current.registry.modules.map(({ id }) => id).sort()
|
||||
const procedures = Object.keys(CONVEX_PROCEDURES).sort()
|
||||
const tables = entitiesOf(current.registry, "convex")
|
||||
.map(({ name }) => name)
|
||||
.sort()
|
||||
const schema = entitiesOf(current.registry, "convex")
|
||||
.map((entity) => ({
|
||||
name: entity.name,
|
||||
table: entity.table,
|
||||
tenancy: entity.tenancy,
|
||||
columns: [...entity.columns].sort(),
|
||||
unique: entity.unique.map((fields) => [...fields]),
|
||||
indexes: entity.indexes.map((fields) => [...fields]),
|
||||
}))
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
const equal = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right)
|
||||
const checks = {
|
||||
backend: actual.ready === true,
|
||||
modules: equal(actual.modules, modules),
|
||||
procedures: equal(actual.procedures, procedures),
|
||||
actions:
|
||||
actionProbe.ready === true &&
|
||||
equal(actual.actions, [
|
||||
"filesActions:begin",
|
||||
"filesActions:complete",
|
||||
"filesActions:remove",
|
||||
"filesActions:cleanupDelete",
|
||||
"filesActions:recoverUpload",
|
||||
"filesActions:readiness",
|
||||
]),
|
||||
schema: equal(actual.tables, tables) && equal(actual.schema, schema),
|
||||
}
|
||||
return { ...checks, ready: Object.values(checks).every(Boolean) }
|
||||
} catch {
|
||||
return { ready: false, backend: false, modules: false, procedures: false, actions: false, schema: false }
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyConvexCapabilities(current: WebRuntime): Promise<void> {
|
||||
if (!(await convexCapabilityStatus(current)).ready) {
|
||||
throw new Error("Convex deployment does not expose the required module, procedure, action and schema capabilities")
|
||||
}
|
||||
}
|
||||
// kit:profile-convex:end
|
||||
|
||||
/** The sign-in backend-for-frontend (spec §7), built on first use, so `/api/health` answers without it. */
|
||||
export function authRoutes(): Promise<AuthRoutes> {
|
||||
return hostSignIn().authRoutes()
|
||||
}
|
||||
|
||||
/**
|
||||
* A revoked app session ends the derived service sessions minted for it (joint contract §14), whoever
|
||||
* reports it: kit-auth for an OIDC session, the Better Auth sign-out and revocation relay for one of
|
||||
* Better Auth's. Durable on both profiles: the account module's revocation outbox, retried by the
|
||||
* worker on Postgres and by the backend's one-minute cron on Convex (convex/account.ts).
|
||||
*/
|
||||
export function sessionRevoked(current: WebRuntime, ref: SessionRef): Promise<void> {
|
||||
const convex = current.convex // kit:profile-convex
|
||||
return revokeDerivedSessions(
|
||||
{
|
||||
registry: current.registry,
|
||||
root: current.root,
|
||||
log: current.observe.log,
|
||||
...(convex ? { durable: (session: string) => revokeConvexSession(convex, session) } : {}), // kit:profile-convex
|
||||
...(current.data
|
||||
? {
|
||||
durable: (session: string) => recordSessionRevocation(current.root, session, { log: current.observe.log }),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
ref,
|
||||
)
|
||||
}
|
||||
// kit:convex-better-auth:begin
|
||||
|
||||
/**
|
||||
* The Convex Better Auth adapter of this process (AUTH_PROVIDER=convex-better-auth), built on first
|
||||
* use with its revocation relay started (src/server/better-auth.ts). Refused by name in a deployment
|
||||
* that selected another provider.
|
||||
*/
|
||||
export function betterAuthRuntime(): betterAuthAdapter.BetterAuthRuntime {
|
||||
if (betterAuth) return betterAuth
|
||||
const config = loadAuthConfig()
|
||||
if (config.provider !== "convex-better-auth") {
|
||||
throw new Error(`AUTH_PROVIDER=${config.provider} is selected; the Convex Better Auth adapter is not in use`)
|
||||
}
|
||||
const current = webRuntime()
|
||||
const identities = current.authData ?? current.data
|
||||
if (!identities) throw new Error("The selected app profile has no auth data source")
|
||||
betterAuth = betterAuthAdapter.createBetterAuthRuntime({
|
||||
config,
|
||||
identities,
|
||||
onSessionRevoked: (ref) => sessionRevoked(current, ref),
|
||||
// The account module's flow: in this process on Postgres, in the backend's actions on Convex.
|
||||
onAccountEvent: (event) => {
|
||||
const convex = current.convex
|
||||
return betterAuthAdapter.deliverAccountEvent(
|
||||
{
|
||||
accountFlow: convex
|
||||
? (flowEvent) => deliverConvexAccountEvent(convex, flowEvent)
|
||||
: betterAuthAdapter.localAccountFlow(current.root),
|
||||
},
|
||||
event,
|
||||
)
|
||||
},
|
||||
log: current.observe.log,
|
||||
})
|
||||
return betterAuth
|
||||
}
|
||||
// kit:convex-better-auth:end
|
||||
|
||||
/** The one SG-11 session adapter, selected by AUTH_PROVIDER (`createHostSignIn`). */
|
||||
export function appSession(): Promise<AppSessionAdapter> {
|
||||
return hostSignIn().appSession()
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// What a failed sign-in becomes is `@conusai-kit/host/postgres`'s (spec §7): a short code in the URL of
|
||||
// the sign-in page, never the provider's text. What is here is which words a page shows for each code.
|
||||
|
||||
import type { AuthError } from "@conusai-kit/host/postgres"
|
||||
|
||||
export {
|
||||
AUTH_ERRORS,
|
||||
type AuthError,
|
||||
authErrorOf,
|
||||
parseAuthError,
|
||||
refusalOf,
|
||||
safeReturnTo,
|
||||
signInErrorHref,
|
||||
} from "@conusai-kit/host/postgres"
|
||||
|
||||
/** The message key of each code, so a page asks for them by name and a test can hold both catalogues to it. */
|
||||
export const AUTH_ERROR_MESSAGES: Readonly<Record<AuthError, string>> = {
|
||||
sessionExpired: "signin.error.sessionExpired",
|
||||
noTransaction: "signin.error.noTransaction",
|
||||
denied: "signin.error.denied",
|
||||
provider: "signin.error.provider",
|
||||
failed: "signin.error.failed",
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// What the sign-out route takes from the Convex Better Auth adapter of this process
|
||||
// (app.ts `betterAuthRuntime()`, AUTH_PROVIDER=convex-better-auth). `kit template:prune --auth oidc`
|
||||
// removes this file with the lines that call it.
|
||||
|
||||
import { loadAuthConfig } from "@conusai-kit/auth"
|
||||
import { betterAuthRuntime } from "./app"
|
||||
|
||||
/**
|
||||
* POST /auth/sign-out with Better Auth selected: its session ends at Better Auth, with the device's
|
||||
* derived service sessions. Null when another provider is selected, whose sign-out is kit-auth's.
|
||||
*/
|
||||
export async function betterAuthSignOut(request: Request): Promise<Response | null> {
|
||||
if (loadAuthConfig().provider !== "convex-better-auth") return null
|
||||
return betterAuthRuntime().signOut(request)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The Convex Better Auth adapter of this app (AUTH_PROVIDER=convex-better-auth, joint contract §14),
|
||||
// in one place: the session adapter app.ts registers (its `betterAuthRuntime()` builds this once per
|
||||
// process), the same-origin proxy of Better Auth's browser endpoints (/api/auth, src/routes/api.$.ts), the sign-out, the
|
||||
// revocation relay, and the end of a person's Better Auth account when the app's own account deletion
|
||||
// runs. `kit template:prune --auth oidc` removes this file with every line that names it.
|
||||
//
|
||||
// Better Auth itself runs in this app's Better Auth Convex deployment (better-auth/functions). This
|
||||
// server imports no Better Auth package: it validates every request's session there, through
|
||||
// @conusai-kit/auth-convex-better-auth, and holds no session of its own.
|
||||
//
|
||||
// BETTER_AUTH_CONVEX_URL the deployment's URL: the session validation query and the relay
|
||||
// BETTER_AUTH_CONVEX_SITE_URL its site URL: Better Auth's HTTP routes
|
||||
// BETTER_AUTH_CONVEX_ADMIN_KEY its admin key, server-only: the relay and the account's end
|
||||
// BETTER_AUTH_RELAY_INTERVAL_MS how often the relay drains the revocation outbox (default 2000)
|
||||
|
||||
import { publicUrlOf } from "@app/config"
|
||||
import { ACCOUNT_IDENTITY_PROVIDER, recordProviderAccountEvent } from "@app/modules/account"
|
||||
import type { AppSessionAdapter, AuthConfig, SessionRef, SessionRevocationHook } from "@conusai-kit/auth"
|
||||
import {
|
||||
type AccountLifecycleEvent,
|
||||
type BetterAuthRevocationRelay,
|
||||
betterAuthAccountEnd,
|
||||
betterAuthAccountEndFromEnv,
|
||||
clearedBetterAuthCookies,
|
||||
createBetterAuthProxy,
|
||||
createBetterAuthRevocationRelay,
|
||||
createConvexBetterAuthSessionAdapter,
|
||||
signOutBetterAuthSession,
|
||||
} from "@conusai-kit/auth-convex-better-auth"
|
||||
import type { Container } from "@conusai-kit/core"
|
||||
import type { DataSource } from "@conusai-kit/data"
|
||||
|
||||
export const DEFAULT_RELAY_INTERVAL_MS = 2000
|
||||
|
||||
/** Better Auth's `minPasswordLength` (better-auth/functions/betterAuth/options.ts), for the form's own check. */
|
||||
export const MIN_PASSWORD_LENGTH = 12
|
||||
|
||||
/** The labels of the sign-in and sign-up form (src/ui/better-auth-form.tsx), in the page's language. */
|
||||
export function betterAuthFormLabels(
|
||||
i18n: { message(key: string, values?: Record<string, string | number>): string },
|
||||
mode: "sign-in" | "sign-up",
|
||||
) {
|
||||
const errors = ["invalid", "exists", "disabled", "rateLimited", "unavailable", "failed"] as const
|
||||
return {
|
||||
name: i18n.message("signin.account.name"),
|
||||
email: i18n.message("app.email"),
|
||||
password: i18n.message("signin.account.password"),
|
||||
passwordHelp: i18n.message("signin.account.passwordHelp", { min: MIN_PASSWORD_LENGTH }),
|
||||
submit: i18n.message(mode === "sign-in" ? "signin.account.signIn" : "signin.account.signUp"),
|
||||
errors: Object.fromEntries(errors.map((code) => [code, i18n.message(`signin.account.error.${code}`)])) as Record<
|
||||
(typeof errors)[number],
|
||||
string
|
||||
>,
|
||||
}
|
||||
}
|
||||
|
||||
export interface BetterAuthEnv {
|
||||
readonly url: string
|
||||
readonly siteUrl: string
|
||||
readonly adminKey: string
|
||||
readonly relayIntervalMs: number
|
||||
}
|
||||
|
||||
const absolute = (name: string, value: string | undefined): string => {
|
||||
const trimmed = value?.trim()
|
||||
if (!trimmed)
|
||||
throw new Error(`${name} is not set: AUTH_PROVIDER=convex-better-auth needs this app's Better Auth deployment`)
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error()
|
||||
} catch {
|
||||
throw new Error(`${name} is not an absolute http(s) URL`)
|
||||
}
|
||||
return trimmed.replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
/** The deployment contract of the adapter, refused by name like the rest of the environment. */
|
||||
export function betterAuthEnv(env: Record<string, string | undefined> = process.env): BetterAuthEnv {
|
||||
const url = absolute("BETTER_AUTH_CONVEX_URL", env.BETTER_AUTH_CONVEX_URL)
|
||||
const siteUrl = absolute("BETTER_AUTH_CONVEX_SITE_URL", env.BETTER_AUTH_CONVEX_SITE_URL)
|
||||
const adminKey = env.BETTER_AUTH_CONVEX_ADMIN_KEY?.trim()
|
||||
if (!adminKey) {
|
||||
throw new Error("BETTER_AUTH_CONVEX_ADMIN_KEY is not set: the revocation relay reads the outbox with it")
|
||||
}
|
||||
const rawInterval = env.BETTER_AUTH_RELAY_INTERVAL_MS?.trim()
|
||||
const relayIntervalMs = rawInterval ? Number(rawInterval) : DEFAULT_RELAY_INTERVAL_MS
|
||||
if (!Number.isSafeInteger(relayIntervalMs) || relayIntervalMs < 100) {
|
||||
throw new Error("BETTER_AUTH_RELAY_INTERVAL_MS is not a whole number of milliseconds of at least 100")
|
||||
}
|
||||
return { url, siteUrl, adminKey, relayIntervalMs }
|
||||
}
|
||||
|
||||
export interface BetterAuthRuntime {
|
||||
readonly adapter: AppSessionAdapter
|
||||
readonly relay: BetterAuthRevocationRelay
|
||||
/** The handler of /api/auth/* (src/routes/api.$.ts): Better Auth's browser endpoints, same-origin. */
|
||||
proxy(request: Request): Promise<Response>
|
||||
/** POST /auth/sign-out: ends this device's session at Better Auth and its derived service sessions. */
|
||||
signOut(request: Request): Promise<Response>
|
||||
/** Deletes a Better Auth user (sessions first), by its Better Auth user ID. Idempotent. */
|
||||
endAccount(providerSubject: string): Promise<void>
|
||||
/** Stops the relay (a test, a shutdown). */
|
||||
stop(): Promise<void>
|
||||
}
|
||||
|
||||
export interface BetterAuthRuntimeOptions {
|
||||
readonly config: Pick<AuthConfig, "provider" | "identityNamespace">
|
||||
/** The kit data source that holds the identity mapping (app_user_identity). */
|
||||
readonly identities: DataSource
|
||||
/** The app's public origin: what Better Auth trusts, and where the sign-out sends the browser. Default CONUSAI_PUBLIC_URL. */
|
||||
readonly publicUrl?: string
|
||||
/** The derived service sessions of one device end with it (app.ts: the account module's outbox). */
|
||||
readonly onSessionRevoked: SessionRevocationHook
|
||||
/** Better Auth's account deletion and an administrator's disablement (app.ts: the account flow). */
|
||||
readonly onAccountEvent: (event: AccountLifecycleEvent) => Promise<void>
|
||||
readonly log: { warn(message: string): void }
|
||||
readonly env?: Record<string, string | undefined>
|
||||
readonly fetch?: typeof globalThis.fetch
|
||||
}
|
||||
|
||||
export function createBetterAuthRuntime(options: BetterAuthRuntimeOptions): BetterAuthRuntime {
|
||||
const env = betterAuthEnv(options.env)
|
||||
const transport = options.fetch ? { fetch: options.fetch } : {}
|
||||
const adapter = createConvexBetterAuthSessionAdapter({
|
||||
config: options.config,
|
||||
convexUrl: env.url,
|
||||
convexSiteUrl: env.siteUrl,
|
||||
identities: options.identities,
|
||||
...transport,
|
||||
})
|
||||
const relay = createBetterAuthRevocationRelay({
|
||||
config: options.config,
|
||||
convexUrl: env.url,
|
||||
adminKey: env.adminKey,
|
||||
identities: options.identities,
|
||||
onSessionRevoked: options.onSessionRevoked,
|
||||
onAccountEvent: options.onAccountEvent,
|
||||
// A row that failed stays pending and is retried on the next drain; the reason is logged, never a value.
|
||||
onError: (error, row) =>
|
||||
options.log.warn(
|
||||
`better-auth: the revocation relay ${row ? `could not deliver a ${row.reason} event (attempt ${row.attempts + 1})` : "could not drain the outbox"}: ${error instanceof Error ? error.message : "failed"}`,
|
||||
),
|
||||
...transport,
|
||||
})
|
||||
const proxy = createBetterAuthProxy({ convexSiteUrl: env.siteUrl, ...transport })
|
||||
const origin = new URL(options.publicUrl ?? publicUrlOf(options.env)).origin
|
||||
const stopRelay = relay.start(env.relayIntervalMs)
|
||||
|
||||
const signOut = async (request: Request): Promise<Response> => {
|
||||
// The session this device holds, so its derived service sessions end with it; an unreachable
|
||||
// provider must not keep the person signed in on this device, so the cookies go regardless.
|
||||
let ref: SessionRef | null = null
|
||||
try {
|
||||
ref = (await adapter.resolve(request))?.sessionRef ?? null
|
||||
} catch {
|
||||
options.log.warn("better-auth: the session being signed out could not be validated; its cookies are cleared")
|
||||
}
|
||||
if (ref) await options.onSessionRevoked(ref, "sign-out")
|
||||
if (!(await signOutBetterAuthSession(request, { convexSiteUrl: env.siteUrl, origin, ...transport }))) {
|
||||
options.log.warn(
|
||||
"better-auth: Better Auth did not confirm a sign-out; the relay reports the session when it ends",
|
||||
)
|
||||
}
|
||||
const headers = new Headers({ location: `${origin}/auth/signed-out`, "cache-control": "no-store" })
|
||||
for (const cookie of clearedBetterAuthCookies()) headers.append("set-cookie", cookie)
|
||||
return new Response(null, { status: 303, headers })
|
||||
}
|
||||
|
||||
const { endAccount } = betterAuthAccountEnd({
|
||||
convexUrl: env.url,
|
||||
adminKey: env.adminKey,
|
||||
// The runtime ends an account by its Better Auth user ID; the namespace is the mapping's, for a deletion.
|
||||
identityNamespace: options.config.identityNamespace ?? "",
|
||||
...transport,
|
||||
})
|
||||
|
||||
return { adapter, relay, proxy, signOut, endAccount, stop: stopRelay }
|
||||
}
|
||||
|
||||
/** An account event, as the account module's flow takes it: the mapped app user. */
|
||||
export interface AccountFlowEvent {
|
||||
readonly reason: AccountLifecycleEvent["reason"]
|
||||
readonly userId: string
|
||||
}
|
||||
|
||||
export interface AccountEventPorts {
|
||||
/**
|
||||
* The account module's flow for the event (app.ts): in this process on the Postgres profile
|
||||
* (`recordProviderAccountEvent`), in the backend on the Convex profile (convex/account.ts).
|
||||
*/
|
||||
readonly accountFlow: (event: AccountFlowEvent) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* An account event of Better Auth, from the revocation relay: the person deleted their Better Auth
|
||||
* account, or an administrator disabled it. On both profiles the account module's flow runs it: an
|
||||
* erasure through its deletion ledger (resumed by the sweep), or the revocation of every credential
|
||||
* and derived service session of the person. A subject that never signed in to this app has no app
|
||||
* user, and nothing here to end. A failure of the flow is thrown, so the relay keeps the row and
|
||||
* delivers it again.
|
||||
*/
|
||||
export async function deliverAccountEvent(ports: AccountEventPorts, event: AccountLifecycleEvent): Promise<void> {
|
||||
if (event.kitUserId === null) return
|
||||
await ports.accountFlow({ reason: event.reason, userId: event.kitUserId })
|
||||
}
|
||||
|
||||
/** The Postgres profile's flow: the account module in this process, in a child of its container. */
|
||||
export const localAccountFlow =
|
||||
(root: Container) =>
|
||||
(event: AccountFlowEvent): Promise<void> =>
|
||||
recordProviderAccountEvent(root.child(), event)
|
||||
|
||||
/**
|
||||
* Better Auth owns the person's accounts: every account deletion this process runs or resumes ends
|
||||
* the person's Better Auth account too, found by the identity mapping, before the identity is
|
||||
* detached, so a live Better Auth session cannot go on as a new, empty user. Bound in the process
|
||||
* container (app.ts), as the worker binds it for its sweep: the deletion ledger records the provider,
|
||||
* and a process without it would hold the deletion at that step. Any other provider binds nothing.
|
||||
*/
|
||||
export function bindBetterAuthAccountEnd(
|
||||
container: Container,
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): void {
|
||||
if (env.AUTH_PROVIDER?.trim() !== "convex-better-auth") return
|
||||
container.register(ACCOUNT_IDENTITY_PROVIDER, () => betterAuthAccountEndFromEnv(env))
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Where the brand's logos are read from for a share image and an email: the app brand's from the
|
||||
// app's own public folder, an organization's through the same caller every page uses (so only a
|
||||
// member reads it). The branding module decides which one is drawn (`shareBrand`); this file only
|
||||
// fetches bytes, and the module's kit checks decide whether they are a logo.
|
||||
|
||||
import { existsSync } from "node:fs"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { createRequire } from "node:module"
|
||||
import path from "node:path"
|
||||
import { type AppBrand, appBrand } from "@app/config"
|
||||
import { type AppIdentity, logoGet, type ShareReaders } from "@app/modules/branding"
|
||||
import type { LogoSource } from "@conusai-kit/brand/logo/check"
|
||||
import type { TransportCaller } from "@conusai-kit/core"
|
||||
|
||||
const TYPES: Readonly<Record<string, string>> = { ".png": "image/png", ".webp": "image/webp", ".svg": "image/svg+xml" }
|
||||
|
||||
const KIT_FAVICON = "/favicon.png"
|
||||
const KIT_FAVICON_FILE = "@conusai-kit/brand/logo/favicon.png"
|
||||
|
||||
/** The app's public folder: `node server.mjs` and `vite dev` run in apps/web. */
|
||||
export const publicDirectory = (): string => path.join(process.cwd(), "public")
|
||||
|
||||
/** A file of the public folder as a logo source. The path is the app brand's (`defineBrand` keeps it on this origin); it is still held inside the folder. */
|
||||
export async function appLogoSource(logoPath: string, directory: string = publicDirectory()): Promise<LogoSource> {
|
||||
const contentType = TYPES[path.extname(logoPath).toLowerCase()]
|
||||
if (contentType === undefined) throw new Error("the app brand's logo is a PNG, WebP or SVG file")
|
||||
const root = path.resolve(/*turbopackIgnore: true*/ directory)
|
||||
const file = path.resolve(/*turbopackIgnore: true*/ root, `.${logoPath}`)
|
||||
if (!file.startsWith(`${root}${path.sep}`)) throw new Error("the app brand's logo is a file of the public folder")
|
||||
// The tab icon the build emits (vite.config.ts) is not in the public folder: it is the kit's own file.
|
||||
const theKits = logoPath === KIT_FAVICON && !existsSync(file)
|
||||
const source = theKits ? createRequire(import.meta.url).resolve(KIT_FAVICON_FILE) : file
|
||||
return { contentType, bytes: new Uint8Array(await readFile(/*turbopackIgnore: true*/ source)) }
|
||||
}
|
||||
|
||||
export const appIdentity = (brand: AppBrand = appBrand): AppIdentity => ({
|
||||
name: brand.name,
|
||||
logo: { light: brand.logo.light, dark: brand.logo.dark },
|
||||
})
|
||||
|
||||
/** The readers for an organization's page: its own logos through the caller, the app's from the public folder. */
|
||||
export function readersFor(caller: TransportCaller, org: string, directory?: string): ShareReaders {
|
||||
return {
|
||||
appLogo: (logoPath) => appLogoSource(logoPath, directory),
|
||||
async tenantLogo(variant) {
|
||||
const logo = await caller.call(logoGet, { org, variant })
|
||||
return { contentType: logo.contentType, bytes: new Uint8Array(Buffer.from(logo.body, "base64")) }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** The readers for the app's own card: no organization, so an organization's logo is never asked for. */
|
||||
export const appOnlyReaders = (directory?: string): ShareReaders => ({
|
||||
appLogo: (logoPath) => appLogoSource(logoPath, directory),
|
||||
tenantLogo: () => Promise.reject(new Error("the app's own card has no organization")),
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The brand's images a route answers: the app's own share image, an organization's share image and an
|
||||
// organization's logo. Server-only: the route files import this on the first request.
|
||||
|
||||
import { appBrand, identityBrandDocument } from "@app/config"
|
||||
import { brandGet, logoGet, renderShareImage, shareBrand } from "@app/modules/branding"
|
||||
import { AppError } from "@conusai-kit/core"
|
||||
import { orgGet } from "@conusai-kit/tenancy"
|
||||
import { webRuntime } from "./app"
|
||||
import { appIdentity, appOnlyReaders, readersFor } from "./brand-assets"
|
||||
import { pageContext } from "./page"
|
||||
import { cloudOrganizationLogo, organizationIdentity, organizationThemeRoles } from "./platform-theme"
|
||||
|
||||
let drawn: Promise<Buffer<ArrayBuffer>> | undefined
|
||||
|
||||
/**
|
||||
* The app's own share image (Open Graph and Twitter card, spec §10.1): the app brand's name and logo in the
|
||||
* brandbook's treatment, 1200x630. It is public, because a link preview is fetched without a session, and it
|
||||
* carries nothing of any organization. The image only changes when the brand file does, so it is drawn once
|
||||
* per process.
|
||||
*/
|
||||
export async function appShareImage(): Promise<Response> {
|
||||
try {
|
||||
drawn ??= (async () => {
|
||||
const share = await shareBrand({ app: appIdentity(), tenant: null, readers: appOnlyReaders() })
|
||||
return Buffer.from(await renderShareImage(share, { title: appBrand.name }))
|
||||
})()
|
||||
return new Response(await drawn, {
|
||||
headers: {
|
||||
"content-type": "image/png",
|
||||
"x-content-type-options": "nosniff",
|
||||
"cache-control": "public, max-age=3600",
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
drawn = undefined
|
||||
return new Response(null, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a managed identity service's hosted sign-in draws (`images/better-auth/src/brand/app-brand.ts`): this app's
|
||||
* name, logo, picture and words, and its organization's theme. It is public, because the service reads it with no
|
||||
* session, and it carries nothing a sign-in page does not show.
|
||||
*/
|
||||
export async function identityBrand(): Promise<Response> {
|
||||
const deployment = webRuntime().env.deployment
|
||||
const [theme, organization] = await Promise.all([
|
||||
organizationThemeRoles(deployment),
|
||||
organizationIdentity(deployment),
|
||||
])
|
||||
return Response.json(identityBrandDocument(appBrand, theme ?? undefined, organization), {
|
||||
headers: { "x-content-type-options": "nosniff", "cache-control": "public, max-age=60" },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The deployment's organization's logo from Cloud, from this app's origin, so the page's image policy stays `'self'`.
|
||||
* Public: the sign-in shows it before anyone is signed in. An SVG is served as an image and runs nothing.
|
||||
*/
|
||||
export async function organizationLogoImage(): Promise<Response> {
|
||||
const found = await cloudOrganizationLogo(webRuntime().env.deployment)
|
||||
if (!found) return new Response(null, { status: 404 })
|
||||
return new Response(found.bytes as Uint8Array<ArrayBuffer>, {
|
||||
headers: {
|
||||
"content-type": found.type,
|
||||
"x-content-type-options": "nosniff",
|
||||
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
|
||||
"cache-control": "public, max-age=300",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* An organization's share image (spec §10.3): its display name and its logo for the dark page (its own, or
|
||||
* the one it has when it has only one, or the app brand's), in the brandbook's treatment, 1200x630.
|
||||
*
|
||||
* It goes through the same caller as every page, so only a member of the organization draws it, and a
|
||||
* non-member and a missing organization get the same 404. The logo is checked by the kit's rules wherever
|
||||
* it comes from; one that cannot be drawn is replaced by the app brand's, and the image is still served.
|
||||
*/
|
||||
export async function organizationShareImage(org: string): Promise<Response> {
|
||||
try {
|
||||
const { caller } = await pageContext()
|
||||
const organization = await caller.call(orgGet, { org })
|
||||
const brand = await caller.call(brandGet, { org })
|
||||
const share = await shareBrand({
|
||||
app: appIdentity(),
|
||||
tenant: { name: organization.name, brand },
|
||||
readers: readersFor(caller, org),
|
||||
})
|
||||
const png = await renderShareImage(share, { title: share.name, footer: appBrand.name })
|
||||
return new Response(Buffer.from(png), {
|
||||
headers: {
|
||||
"content-type": "image/png",
|
||||
"x-content-type-options": "nosniff",
|
||||
"cache-control": "private, max-age=300",
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
// A non-member, a missing organization and a logo nobody can draw are not told apart.
|
||||
return new Response(null, { status: error instanceof AppError && error.status < 500 ? 404 : 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An organization's logo, served to its members from this app's own origin.
|
||||
*
|
||||
* It goes through the same caller as every page, so only a member of the organization reads it
|
||||
* (`branding.logo.get`, `branding.brand.read`), and what comes back is bytes a checker approved when
|
||||
* they were uploaded. The response is still treated as hostile: `nosniff` so a browser does not
|
||||
* reinterpret it, a content security policy that allows nothing -- no script, no request, no
|
||||
* framing -- so even an SVG that slipped past the checker runs nothing when it is opened directly,
|
||||
* and `sandbox` for the same reason.
|
||||
*/
|
||||
export async function organizationLogo(request: Request, org: string, variant: string): Promise<Response> {
|
||||
if (variant !== "light" && variant !== "dark") return new Response(null, { status: 404 })
|
||||
// `?s=1x` or `?s=2x`: the 1x or the 2x rendition of a raster logo (2x when absent; an SVG has one size).
|
||||
const size = new URL(request.url).searchParams.get("s")
|
||||
if (size !== null && size !== "1x" && size !== "2x") return new Response(null, { status: 404 })
|
||||
try {
|
||||
const { caller } = await pageContext(request)
|
||||
const logo = await caller.call(logoGet, { org, variant, ...(size === null ? {} : { size }) })
|
||||
return new Response(Buffer.from(logo.body, "base64"), {
|
||||
headers: {
|
||||
"content-type": logo.contentType,
|
||||
"x-content-type-options": "nosniff",
|
||||
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
|
||||
// The logo's path carries its own content tag (`?v=`), so a changed logo is a new URL.
|
||||
"cache-control": "private, max-age=31536000, immutable",
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
// A non-member, a missing organization and a missing logo are all the same answer.
|
||||
return new Response(null, { status: error instanceof AppError && error.status < 500 ? 404 : 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// What a web process answers when the deployment contract refuses it: the process stays up, so `/api/ready` can say
|
||||
// why and the deployment's health check fails with a reason, while the previous deployment keeps serving. Every other
|
||||
// route is a plain 503, not the 500 of an exception on each request. And the origin the contract adds to the content
|
||||
// security policy.
|
||||
|
||||
export interface ContractRefusal {
|
||||
/** The whole message, for the process's log. */
|
||||
readonly message: string
|
||||
/** What `/api/ready` may say in public. A message that quotes a value is held back: the value is a customer's. */
|
||||
readonly reason: string
|
||||
}
|
||||
|
||||
export function refusalOf(error: unknown): ContractRefusal {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const reason = message.includes('"') ? "the deployment contract refused this process; its log says why" : message
|
||||
return { message, reason }
|
||||
}
|
||||
|
||||
export function refusalResponse(request: Request, refusal: ContractRefusal): Response {
|
||||
const body =
|
||||
new URL(request.url).pathname === "/api/ready"
|
||||
? { ready: false, checks: { contract: false }, reason: refusal.reason }
|
||||
: { status: 503, code: "not_configured" }
|
||||
return Response.json(body, { status: 503, headers: { "cache-control": "no-store", "retry-after": "30" } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a form may end besides this app. `/auth/sign-out` is a POST the server answers with a redirect to the provider's
|
||||
* end-session endpoint, and browsers apply `form-action` to that redirect. `loadAuthConfig` has refused an issuer that is
|
||||
* not a URL.
|
||||
*/
|
||||
export function issuerOrigins(issuer: string | undefined): readonly string[] {
|
||||
const named = issuer?.trim()
|
||||
return named ? [new URL(named).origin] : []
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The Next BFF's narrow kit-auth storage port over private Convex functions. Every operation is
|
||||
// one query or mutation (and therefore one Convex transaction); domain procedures never use this
|
||||
// adapter and are dispatched to their generated wrapper so multi-row work stays atomic.
|
||||
|
||||
import { apiKeys, sessions } from "@conusai-kit/auth/entities"
|
||||
import { internal } from "@conusai-kit/core"
|
||||
import type {
|
||||
DataSource,
|
||||
EntityBase,
|
||||
EntityDef,
|
||||
EntityTenancy,
|
||||
GlobalRepository,
|
||||
ReadOnlyRepository,
|
||||
SystemDataSource,
|
||||
} from "@conusai-kit/data"
|
||||
import type { AppUserResolverDataSource } from "@conusai-kit/tenancy"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
import { makeFunctionReference, type UserIdentityAttributes } from "convex/server"
|
||||
|
||||
type Args = Record<string, unknown>
|
||||
type AdminAuthClient = ConvexHttpClient & {
|
||||
/** Convex SDK 1.45.0 internal server transport, also used by `convex run --identity`. */
|
||||
setAdminAuth(token: string, identity?: UserIdentityAttributes): void
|
||||
}
|
||||
type Key =
|
||||
| "appUserResolve"
|
||||
| "identityMappingsApply"
|
||||
| "identityIssuerCoverage"
|
||||
| "sessionById"
|
||||
| "sessionByHandleHash"
|
||||
| "sessionInsert"
|
||||
| "sessionUpdate"
|
||||
| "apiKeyByHash"
|
||||
|
||||
const ref = (name: string) => makeFunctionReference(name)
|
||||
const notSupported = (what: string): never => {
|
||||
throw internal(`Convex BFF auth storage does not support ${what}`)
|
||||
}
|
||||
|
||||
const oneItem = <T>(item: T | null) => ({ items: item ? [item] : [], hasMore: false })
|
||||
|
||||
/**
|
||||
* Only the user and session global repositories and the API-key hash lookup are implemented.
|
||||
* Anything outside that auth contract fails closed instead of silently becoming a non-atomic
|
||||
* collection of backend mutations.
|
||||
*/
|
||||
export function createConvexAuthDataSource(options: {
|
||||
url: string
|
||||
adminKey: string
|
||||
}): AppUserResolverDataSource & SystemDataSource {
|
||||
const client = new ConvexHttpClient(options.url)
|
||||
;(client as AdminAuthClient).setAdminAuth(options.adminKey)
|
||||
|
||||
const query = <T>(name: Key, args: Args) => client.query(ref(`system:${name}`) as never, args as never) as Promise<T>
|
||||
const mutation = <T>(name: Key, args: Args) =>
|
||||
client.mutation(ref(`system:${name}`) as never, args as never) as Promise<T>
|
||||
|
||||
const global = <E extends EntityBase>(entity: EntityDef<E>): GlobalRepository<E> => {
|
||||
if ((entity as unknown) !== sessions) return notSupported(`global entity ${entity.name}`)
|
||||
const list = async (input: Parameters<GlobalRepository<E>["list"]>[0] = {}) => {
|
||||
const filters = (input?.where ?? []) as unknown as ReadonlyArray<{ field: string; value?: unknown }>
|
||||
const handleHash = filters.find((f) => f.field === "handleHash")?.value
|
||||
if (typeof handleHash !== "string") return notSupported("session list without a handleHash filter")
|
||||
return oneItem(await query<E>("sessionByHandleHash", { handleHash }))
|
||||
}
|
||||
return {
|
||||
get: (id) => query<E | null>("sessionById", { id }),
|
||||
require: async (id) => {
|
||||
const row = await query<E | null>("sessionById", { id })
|
||||
if (!row) throw internal(`no ${entity.name} ${id}`)
|
||||
return row
|
||||
},
|
||||
list,
|
||||
count: async (input) => (await list({ where: input?.where } as never)).items.length,
|
||||
insert: (input) => mutation<E>("sessionInsert", { input }),
|
||||
update: (id, patch, expectedVersion) => mutation<E>("sessionUpdate", { id, patch, expectedVersion }),
|
||||
delete: () => notSupported(`${entity.name}.delete`),
|
||||
} as GlobalRepository<E>
|
||||
}
|
||||
|
||||
const system: SystemDataSource = {
|
||||
acrossTenants<E extends EntityBase, T extends EntityTenancy>(entity: EntityDef<E, T>): ReadOnlyRepository<E, T> {
|
||||
if ((entity as unknown) !== apiKeys) return notSupported(`cross-tenant reads of ${entity.name}`)
|
||||
const list = async (input: Parameters<ReadOnlyRepository<E, T>["list"]>[0] = {}) => {
|
||||
const filters = (input?.where ?? []) as unknown as ReadonlyArray<{ field: string; value?: unknown }>
|
||||
const tokenHash = filters.find((filter) => filter.field === "tokenHash")?.value
|
||||
if (typeof tokenHash !== "string") return notSupported("API-key list without a tokenHash filter")
|
||||
return oneItem(await query<E>("apiKeyByHash", { tokenHash }))
|
||||
}
|
||||
return {
|
||||
get: () => notSupported("API-key reads by id"),
|
||||
require: () => notSupported("API-key reads by id"),
|
||||
list,
|
||||
count: async (input) => (await list({ where: input?.where } as never)).items.length,
|
||||
} as ReadOnlyRepository<E, T>
|
||||
},
|
||||
}
|
||||
|
||||
const unsupportedRepository = (): never => notSupported("tenant repositories from the Next BFF")
|
||||
const source: AppUserResolverDataSource & SystemDataSource = {
|
||||
global,
|
||||
appUserResolver: {
|
||||
resolve: (claims, options) => mutation("appUserResolve", { claims, create: options.create }),
|
||||
applyMappings: (options) => mutation("identityMappingsApply", { options }),
|
||||
issuerCoverage: (issuers) => query("identityIssuerCoverage", { issuers }),
|
||||
},
|
||||
forScope: unsupportedRepository as DataSource["forScope"],
|
||||
unitOfWork: { run: unsupportedRepository as DataSource["unitOfWork"]["run"] },
|
||||
outbox: {
|
||||
pending: unsupportedRepository,
|
||||
markPublished: unsupportedRepository,
|
||||
markFailed: unsupportedRepository,
|
||||
defer: unsupportedRepository,
|
||||
markStuck: unsupportedRepository,
|
||||
},
|
||||
idempotency: { remember: unsupportedRepository },
|
||||
readOnly: () => source,
|
||||
close: async () => undefined,
|
||||
acrossTenants: system.acrossTenants,
|
||||
}
|
||||
return source
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Server-only procedure transport for the Convex profile. The browser still uses the same Hono
|
||||
// BFF and session cookie as SQL profiles; the linked admin key never crosses that boundary.
|
||||
//
|
||||
// A write (a generated tenant mutation) is called with `LIVE_TXID_ARG`, so it answers its live log
|
||||
// position with its value; the position is recorded with the router's capture and answered as
|
||||
// `ConusAI-Txid`, as a Postgres transaction id is (umbrella TS6.3).
|
||||
|
||||
import type { AccountCredential } from "@app/modules/account"
|
||||
import type { Principal } from "@conusai-kit/auth"
|
||||
import type { CallOptions, TransportCaller } from "@conusai-kit/core"
|
||||
import { AppError, internal } from "@conusai-kit/core"
|
||||
import { LIVE_TXID_ARG, type LiveTxidEnvelope } from "@conusai-kit/data-convex/runtime"
|
||||
import { recordTxid } from "@conusai-kit/data-kysely"
|
||||
import { type FileScope, PartGuard, platformObjectStore } from "@conusai-kit/files"
|
||||
import type { ObjectStore as PlatformObjectStore } from "@conusai-kit/platform"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
import { makeFunctionReference, type UserIdentityAttributes } from "convex/server"
|
||||
import { ConvexError } from "convex/values"
|
||||
import { CONVEX_PROCEDURES } from "../../../../convex/generated/procedures"
|
||||
import { CONVEX_BFF_ISSUER } from "../../../../convex/lib/runtime"
|
||||
|
||||
const unavailableProcedure = (name: string): AppError =>
|
||||
new AppError({
|
||||
type: "/problems/not_found",
|
||||
title: "Not found",
|
||||
status: 404,
|
||||
code: "not_found",
|
||||
detail: `no procedure ${name}`,
|
||||
})
|
||||
|
||||
type AdminAuthClient = ConvexHttpClient & {
|
||||
/** Convex SDK 1.45.0 internal server transport, also used by `convex run --identity`. */
|
||||
setAdminAuth(token: string, identity?: UserIdentityAttributes): void
|
||||
}
|
||||
|
||||
/** A Convex refusal as the kernel's problem; anything else is an opaque internal error. */
|
||||
export const asProblem = (error: unknown): AppError => {
|
||||
if (error instanceof AppError) return error
|
||||
if (error instanceof ConvexError && typeof error.data === "object" && error.data !== null) {
|
||||
const value = error.data as Record<string, unknown>
|
||||
if (
|
||||
typeof value.type === "string" &&
|
||||
typeof value.title === "string" &&
|
||||
typeof value.status === "number" &&
|
||||
typeof value.code === "string" &&
|
||||
(value.detail === undefined || typeof value.detail === "string")
|
||||
) {
|
||||
return new AppError(value as unknown as ConstructorParameters<typeof AppError>[0])
|
||||
}
|
||||
}
|
||||
return internal()
|
||||
}
|
||||
|
||||
/** The account credential as identity claims: only for a person signed in to this app. */
|
||||
function credentialClaims(credential: AccountCredential | undefined): Record<string, string> {
|
||||
if (!credential?.interactive) return {}
|
||||
return {
|
||||
conusaiCredential: "interactive",
|
||||
...(credential.sessionId ? { conusaiSessionId: credential.sessionId } : {}),
|
||||
...(credential.signedInAt ? { conusaiSignedInAt: credential.signedInAt } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An account event of the app's identity provider (Better Auth's deletion or ban, from its revocation
|
||||
* relay) on the Convex profile: the account module's flow runs in the backend (convex/account.ts
|
||||
* `providerEvent`), called with the admin key and no identity. A failure is thrown, so the relay keeps
|
||||
* the event and delivers it again.
|
||||
*/
|
||||
export async function deliverConvexAccountEvent(
|
||||
convex: { url: string; adminKey: string },
|
||||
event: { readonly reason: "account-deleted" | "user-disabled"; readonly userId: string },
|
||||
): Promise<void> {
|
||||
const client = convexAdminClient(convex)
|
||||
try {
|
||||
await client.action(makeFunctionReference<"action">("account:providerEvent") as never, event as never)
|
||||
} catch (error) {
|
||||
throw asProblem(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Who a request's Convex calls are made as (request.ts): the facts the BFF verified. */
|
||||
export interface ConvexIdentityOptions {
|
||||
readonly principal: Principal | null
|
||||
readonly user?: { readonly email: string; readonly displayName: string }
|
||||
/** What the account module asks of the credential (request.ts), carried as identity claims (convex/lib/runtime.ts). */
|
||||
readonly credential?: AccountCredential
|
||||
/** The request's app session (kit-auth `sessionRefKey`): a derived service session is minted for it. */
|
||||
readonly identitySession?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The identity a request's Convex calls carry (convex/lib/runtime.ts `callContextOf` reads it), or
|
||||
* undefined for a request with no principal: its calls are anonymous.
|
||||
*/
|
||||
export function convexIdentity(options: ConvexIdentityOptions): UserIdentityAttributes | undefined {
|
||||
if (!options.principal) return undefined
|
||||
const subject = options.principal.actor.userId
|
||||
return {
|
||||
issuer: CONVEX_BFF_ISSUER,
|
||||
subject,
|
||||
tokenIdentifier: `${CONVEX_BFF_ISSUER}|${subject}`,
|
||||
...(options.user?.email ? { email: options.user.email } : {}),
|
||||
...(options.user?.displayName ? { name: options.user.displayName } : {}),
|
||||
...(options.principal.orgId ? { conusaiOrgId: options.principal.orgId } : {}),
|
||||
...credentialClaims(options.credential),
|
||||
...(options.identitySession ? { conusaiIdentitySession: options.identitySession } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A one-shot client holding the deployment's admin key, server-side only: with an identity it calls
|
||||
* as that person; without one it may call the deployment's internal functions too.
|
||||
*/
|
||||
export function convexAdminClient(
|
||||
convex: { readonly url: string; readonly adminKey: string },
|
||||
identity?: UserIdentityAttributes,
|
||||
): ConvexHttpClient {
|
||||
const client = new ConvexHttpClient(convex.url)
|
||||
;(client as AdminAuthClient).setAdminAuth(convex.adminKey, identity)
|
||||
return client
|
||||
}
|
||||
|
||||
/**
|
||||
* A tenant mutation's answer when it was called with `LIVE_TXID_ARG`: the procedure's value, with
|
||||
* the write's live log position recorded for the router's `ConusAI-Txid` (none when it wrote no
|
||||
* collection row).
|
||||
*/
|
||||
export function unwrapLiveTxid(answer: unknown): unknown {
|
||||
const envelope = answer as Partial<LiveTxidEnvelope> | null
|
||||
if (typeof envelope !== "object" || envelope === null || !("value" in envelope) || !("txid" in envelope)) {
|
||||
throw internal("a tenant mutation did not answer its live txid envelope")
|
||||
}
|
||||
if (typeof envelope.txid === "string") recordTxid(envelope.txid)
|
||||
return envelope.value
|
||||
}
|
||||
|
||||
export function createConvexCaller(options: {
|
||||
url: string
|
||||
adminKey: string
|
||||
principal: Principal | null
|
||||
objectStore: PlatformObjectStore
|
||||
/** The request's identity (`convexIdentity`); absent for a request with no principal. */
|
||||
identity?: UserIdentityAttributes
|
||||
}): TransportCaller {
|
||||
const client = convexAdminClient(options, options.identity)
|
||||
|
||||
const callByName = async (name: string, input: unknown, callOptions?: CallOptions): Promise<unknown> => {
|
||||
if (name === "files.file.download") {
|
||||
if (!options.principal) throw unavailableProcedure(name)
|
||||
const value = input as { org: string; workspace: string; project: string; fileId: string }
|
||||
try {
|
||||
const info = (await client.query(
|
||||
makeFunctionReference("files:downloadInfo") as never,
|
||||
{
|
||||
input: value,
|
||||
} as never,
|
||||
)) as {
|
||||
storageKey: string
|
||||
filename: string
|
||||
contentType: string
|
||||
size: number
|
||||
scope: { orgId: string; workspaceId: string; projectId: string }
|
||||
}
|
||||
const scope: FileScope = { level: "project", actor: options.principal.actor, ...info.scope }
|
||||
const object = await platformObjectStore({ blob: options.objectStore }).get(scope, info.storageKey)
|
||||
if (!object) {
|
||||
throw new AppError({
|
||||
type: "/problems/not_found",
|
||||
title: "Not found",
|
||||
status: 404,
|
||||
code: "not_found",
|
||||
detail: "no such file",
|
||||
})
|
||||
}
|
||||
const fallback = info.filename.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120) || "download"
|
||||
return new Response(object.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": info.contentType,
|
||||
"content-length": String(info.size),
|
||||
"content-disposition": `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(info.filename)}`,
|
||||
"x-content-type-options": "nosniff",
|
||||
"cache-control": "private, no-store",
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
throw asProblem(error)
|
||||
}
|
||||
}
|
||||
if (name === "files.file.putPart") {
|
||||
if (!options.principal) throw unavailableProcedure(name)
|
||||
const value = input as {
|
||||
org: string
|
||||
workspace: string
|
||||
project: string
|
||||
fileId: string
|
||||
part: number
|
||||
body: ReadableStream<Uint8Array> | Uint8Array
|
||||
}
|
||||
try {
|
||||
const info = (await client.query(
|
||||
makeFunctionReference("files:partInfo") as never,
|
||||
{
|
||||
input: {
|
||||
org: value.org,
|
||||
workspace: value.workspace,
|
||||
project: value.project,
|
||||
fileId: value.fileId,
|
||||
part: value.part,
|
||||
},
|
||||
} as never,
|
||||
)) as {
|
||||
uploadId: string
|
||||
storageKey: string
|
||||
part: number
|
||||
size: number
|
||||
contentType?: string
|
||||
scope: { orgId: string; workspaceId: string; projectId: string }
|
||||
}
|
||||
const scope: FileScope = {
|
||||
level: "project",
|
||||
actor: options.principal.actor,
|
||||
...info.scope,
|
||||
}
|
||||
const guard = new PartGuard({
|
||||
expectedBytes: info.size,
|
||||
...(info.contentType ? { contentType: info.contentType } : {}),
|
||||
})
|
||||
const store = platformObjectStore({ blob: options.objectStore })
|
||||
try {
|
||||
const sent = await store.putUploadPart(scope, {
|
||||
key: info.storageKey,
|
||||
uploadId: info.uploadId,
|
||||
number: info.part,
|
||||
size: info.size,
|
||||
body: guard.wrap(value.body),
|
||||
})
|
||||
return { part: info.part, etag: sent.etag }
|
||||
} catch (error) {
|
||||
throw guard.violation ?? error
|
||||
}
|
||||
} catch (error) {
|
||||
throw asProblem(error)
|
||||
}
|
||||
}
|
||||
const fileAction = {
|
||||
"files.file.beginUpload": "filesActions:begin",
|
||||
"files.file.completeUpload": "filesActions:complete",
|
||||
"files.file.delete": "filesActions:remove",
|
||||
}[name]
|
||||
if (fileAction) {
|
||||
const fn = makeFunctionReference(fileAction) as never
|
||||
try {
|
||||
return await client.action(fn, { input } as never)
|
||||
} catch (error) {
|
||||
throw asProblem(error)
|
||||
}
|
||||
}
|
||||
const definition = CONVEX_PROCEDURES[name as keyof typeof CONVEX_PROCEDURES]
|
||||
if (!definition) throw unavailableProcedure(name)
|
||||
const fn = makeFunctionReference(definition.reference) as never
|
||||
const args = { input, ...(callOptions?.idempotencyKey ? { idempotencyKey: callOptions.idempotencyKey } : {}) }
|
||||
try {
|
||||
// Widened: an app without guides and support generates no effect procedure at all.
|
||||
switch (definition.kind as "read" | "write" | "effect") {
|
||||
case "read":
|
||||
return await client.query(fn, args as never)
|
||||
// Every write is a generated tenant mutation, the one kind of function that takes LIVE_TXID_ARG.
|
||||
case "write":
|
||||
return unwrapLiveTxid(await client.mutation(fn, { ...args, [LIVE_TXID_ARG]: true } as never))
|
||||
// An effect of CONVEX_ACTION_EFFECT_MODULES (guides, support): a generated action that runs
|
||||
// the kernel with the module's platform port (convex/lib/effects.ts).
|
||||
case "effect":
|
||||
return await client.action(fn, args as never)
|
||||
}
|
||||
} catch (error) {
|
||||
throw asProblem(error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
call: (contract, input, options) => callByName(contract.name, input, options),
|
||||
callByName,
|
||||
} as TransportCaller
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The Convex profile's half of `POST /api/v1/orgs/{org}/live` (live.ts): the CLP/1 stream is served by
|
||||
// @conusai-kit/live-server in this process, over the deployment's own functions (convex/live.ts):
|
||||
//
|
||||
// - the log: `live:head` and `live:changes` (internal queries) read with the admin key, and the wake-up
|
||||
// is a reactive subscription to `live:head` per organization a stream reads (`convexLiveLog`
|
||||
// refcounts it), over one websocket client opened by the first stream
|
||||
// - who may stream what: `live:open`, a public query run under the caller's identity (the same claims
|
||||
// every procedure call carries, convex-caller.ts), decides with the kernel's tenancy over the
|
||||
// deployment's rows (`decideLiveGrants`); its refusal comes back as the live problem the Postgres
|
||||
// profile answers (401 signed out, 404 not reached), and each heartbeat asks `live:recheck`
|
||||
//
|
||||
// The admin key stays in this process; the browser holds no Convex credential.
|
||||
|
||||
import {
|
||||
type ConvexLiveHead,
|
||||
type ConvexLiveRow,
|
||||
type ConvexLiveSource,
|
||||
type LiveAuthorization,
|
||||
type LiveRecheck,
|
||||
type OpenRequest,
|
||||
problems,
|
||||
type ResolvedTopic,
|
||||
} from "@conusai-kit/live-server"
|
||||
import { ConvexClient } from "convex/browser"
|
||||
import { makeFunctionReference, type UserIdentityAttributes } from "convex/server"
|
||||
import { ConvexError } from "convex/values"
|
||||
import { asProblem, convexAdminClient } from "./convex-caller"
|
||||
|
||||
const OPEN = makeFunctionReference<"query">("live:open")
|
||||
const RECHECK = makeFunctionReference<"query">("live:recheck")
|
||||
const HEAD = makeFunctionReference<"query">("live:head")
|
||||
const CHANGES = makeFunctionReference<"query">("live:changes")
|
||||
|
||||
/** What the source and the authorization ask of a one-shot client (`ConvexHttpClient`). */
|
||||
export interface LiveQueryClient {
|
||||
query(reference: never, args: never): Promise<unknown>
|
||||
}
|
||||
|
||||
/** What the source asks of a subscribing client (`ConvexClient`, a websocket). */
|
||||
export interface LiveWatchClient {
|
||||
onUpdate(
|
||||
reference: never,
|
||||
args: never,
|
||||
callback: (value: unknown) => unknown,
|
||||
onError?: (error: Error) => unknown,
|
||||
): () => void
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
type AdminAuthWatchClient = ConvexClient & {
|
||||
/** Convex SDK 1.45.0 internal, as `ConvexHttpClient.setAdminAuth` (convex-caller.ts). */
|
||||
setAdminAuth(token: string, identity?: UserIdentityAttributes): void
|
||||
}
|
||||
|
||||
/**
|
||||
* The log source over two clients: `admin` reads (it holds the admin key and no identity, so it may call
|
||||
* the internal `live:head` and `live:changes`), and `watcher` opens the subscribing client on the first
|
||||
* `watch`; `close` closes it.
|
||||
*/
|
||||
export function convexLiveSource(clients: {
|
||||
readonly admin: LiveQueryClient
|
||||
readonly watcher: () => LiveWatchClient
|
||||
}): ConvexLiveSource {
|
||||
let watcher: LiveWatchClient | undefined
|
||||
return {
|
||||
head: async (orgId) => (await clients.admin.query(HEAD as never, { orgId } as never)) as ConvexLiveHead,
|
||||
changes: async (orgId, after, limit) =>
|
||||
(await clients.admin.query(
|
||||
CHANGES as never,
|
||||
{ orgId, after: { txid: after.txid, seq: after.seq }, limit } as never,
|
||||
)) as {
|
||||
rows: readonly ConvexLiveRow[]
|
||||
head: ConvexLiveHead
|
||||
more: boolean
|
||||
},
|
||||
watch(orgId, onChange, onError) {
|
||||
watcher ??= clients.watcher()
|
||||
// Convex answers the current head first, then every new one: each is a wake-up, and a wake-up
|
||||
// with nothing new is one empty read.
|
||||
return watcher.onUpdate(HEAD as never, { orgId } as never, () => onChange(), onError)
|
||||
},
|
||||
async close() {
|
||||
const open = watcher
|
||||
watcher = undefined
|
||||
await open?.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** The log source over the linked deployment, with its admin key (env.convex). */
|
||||
export function deploymentLiveSource(convex: { readonly url: string; readonly adminKey: string }): ConvexLiveSource {
|
||||
return convexLiveSource({
|
||||
admin: convexAdminClient(convex) as unknown as LiveQueryClient,
|
||||
watcher: () => {
|
||||
const client = new ConvexClient(convex.url, { unsavedChangesWarning: false })
|
||||
;(client as AdminAuthWatchClient).setAdminAuth(convex.adminKey)
|
||||
return client as unknown as LiveWatchClient
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** The verified caller of one stream, as live.ts resolves it from the request. */
|
||||
export interface LiveCaller {
|
||||
readonly userId: string
|
||||
/** The session (or credential) key of the per-session stream limit. */
|
||||
readonly session: string
|
||||
/** The identity its Convex calls carry (convex-caller.ts `convexIdentity`). */
|
||||
readonly identity: UserIdentityAttributes
|
||||
}
|
||||
|
||||
export interface ConvexLiveAuthorizeOptions {
|
||||
/** The request's caller: signed in (or the live 401) and its CSRF checked. */
|
||||
caller(request: Request): Promise<LiveCaller>
|
||||
/** A one-shot client that calls as `identity` (`convexAdminClient`). */
|
||||
clientFor(identity: UserIdentityAttributes): LiveQueryClient
|
||||
/** The org slug of `/api/v1/orgs/{org}/live`. */
|
||||
orgSlugOf(request: Request): string
|
||||
/** Where an unexpected re-check failure is reported (it does not end the stream). */
|
||||
warn(message: string): void
|
||||
}
|
||||
|
||||
/** The status of a refusal: a live problem's own, or the problem a Convex function threw. */
|
||||
function statusOf(error: unknown): number | undefined {
|
||||
if (error instanceof ConvexError) {
|
||||
const status = (error.data as { status?: unknown } | null)?.status
|
||||
return typeof status === "number" ? status : undefined
|
||||
}
|
||||
const status = (error as { status?: unknown } | null)?.status
|
||||
return typeof status === "number" ? status : undefined
|
||||
}
|
||||
|
||||
/** A refusal of `live:open` as the Postgres profile answers it; another Convex problem as the kernel's. */
|
||||
function refusalOf(error: unknown): unknown {
|
||||
if (!(error instanceof ConvexError)) return error
|
||||
const status = statusOf(error)
|
||||
if (status === 401) return problems.unauthenticated()
|
||||
if (status === 404) return problems.notFound()
|
||||
return asProblem(error)
|
||||
}
|
||||
|
||||
interface OpenAnswer {
|
||||
readonly org: string
|
||||
readonly topics: readonly ResolvedTopic[]
|
||||
readonly entities: readonly string[]
|
||||
readonly denied: ReadonlyArray<{ readonly entity: string; readonly reason: string }>
|
||||
}
|
||||
|
||||
/** The authorize function of `createLiveServer` on the Convex profile. */
|
||||
export function convexLiveAuthorize(
|
||||
options: ConvexLiveAuthorizeOptions,
|
||||
): (request: Request, open: OpenRequest) => Promise<LiveAuthorization> {
|
||||
return async (request, open) => {
|
||||
const caller = await options.caller(request)
|
||||
const slug = options.orgSlugOf(request)
|
||||
// The validator of `live:open` takes these fields only.
|
||||
const topics = open.topics.map((topic) => ({ ws: topic.ws, ...(topic.project ? { project: topic.project } : {}) }))
|
||||
let answer: OpenAnswer
|
||||
try {
|
||||
answer = (await options
|
||||
.clientFor(caller.identity)
|
||||
.query(OPEN as never, { org: slug, topics, entities: [...open.entities] } as never)) as OpenAnswer
|
||||
} catch (error) {
|
||||
throw refusalOf(error)
|
||||
}
|
||||
return {
|
||||
org: answer.org,
|
||||
session: caller.session,
|
||||
granted: { entities: answer.entities, topics: answer.topics },
|
||||
denied: answer.denied,
|
||||
// Every heartbeat and on a grant change: the credential is still valid, the caller still a member of
|
||||
// every scope, and still holds the read permission of every entity the stream was granted.
|
||||
recheck: async (): Promise<LiveRecheck> => {
|
||||
try {
|
||||
const again = await options.caller(new Request(request.url, { method: "GET", headers: request.headers }))
|
||||
if (again.userId !== caller.userId) return "session"
|
||||
return (await options
|
||||
.clientFor(again.identity)
|
||||
.query(RECHECK as never, { org: slug, topics, granted: [...answer.entities] } as never)) as LiveRecheck
|
||||
} catch (error) {
|
||||
const status = statusOf(error)
|
||||
if (status === 401) return "session"
|
||||
if (status === 403 || status === 404) return "membership"
|
||||
options.warn(`live: the re-check failed: ${error instanceof Error ? error.name : "failed"}`)
|
||||
return null
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The Convex profile's durable sign-out revocation (joint contract §14): the web hands a revoked app
|
||||
// session to the backend's revocation outbox (convex/account.ts), server-only, with the admin key.
|
||||
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
import { makeFunctionReference } from "convex/server"
|
||||
|
||||
type AdminAuthClient = ConvexHttpClient & {
|
||||
/** Convex SDK 1.45.0 internal server transport, also used by `convex run --identity`. */
|
||||
setAdminAuth(token: string): void
|
||||
}
|
||||
|
||||
/** How long a sign-out waits for the backend to record a revocation before it asks the modules itself. */
|
||||
export const CONVEX_REVOCATION_MS = 5000
|
||||
|
||||
/**
|
||||
* A revoked app session on the Convex profile (joint contract §14): recorded in the backend's
|
||||
* revocation outbox and delivered from there (convex/account.ts `revokeSession`, called with the
|
||||
* admin key and no identity), retried by the backend's one-minute cron. Bounded: a refusal, or a
|
||||
* backend that does not answer within `timeoutMs`, is thrown, and the caller asks the modules
|
||||
* directly (derived-sessions.ts).
|
||||
*/
|
||||
export async function revokeConvexSession(
|
||||
convex: { url: string; adminKey: string },
|
||||
session: string,
|
||||
options: { timeoutMs?: number } = {},
|
||||
): Promise<void> {
|
||||
const client = new ConvexHttpClient(convex.url)
|
||||
;(client as AdminAuthClient).setAdminAuth(convex.adminKey)
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error("the backend did not record the revocation in time")),
|
||||
options.timeoutMs ?? CONVEX_REVOCATION_MS,
|
||||
)
|
||||
})
|
||||
try {
|
||||
await Promise.race([
|
||||
client.action(makeFunctionReference<"action">("account:revokeSession") as never, { session } as never),
|
||||
timeout,
|
||||
])
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The derived service sessions of one device (joint contract §14 "Lifecycle, CSP and revocation"):
|
||||
// when kit-auth revokes an app session -- sign-out, a refused refresh, expiry -- every installed
|
||||
// module's `tenancy.account` share revokes, server-side, the service sessions it minted for that
|
||||
// session (a Support widget session records it as its parent). An account deletion revokes the
|
||||
// subject's sessions itself, at its start (modules/account/core/deletion.ts).
|
||||
//
|
||||
// Durable on both profiles: the revocation is written to the account module's revocation outbox and
|
||||
// delivered at once, and what a platform did not confirm is retried with backoff
|
||||
// (modules/account/core/revocation.ts) -- by the worker on Postgres, and on Convex by the backend,
|
||||
// whose `account:revokeSession` action records and delivers it and whose one-minute cron retries it
|
||||
// (convex/account.ts). When nothing was recorded (a refused write, a backend that did not answer)
|
||||
// the modules are asked directly, bounded, once.
|
||||
|
||||
import { type SessionRef, sessionRefKey } from "@conusai-kit/auth"
|
||||
import type { Container, Registry } from "@conusai-kit/core"
|
||||
import { accountDataOf } from "@conusai-kit/tenancy"
|
||||
|
||||
/** How long a sign-out waits for one module's revocation before it goes on. */
|
||||
export const DERIVED_REVOCATION_MS = 3000
|
||||
|
||||
export interface DerivedSessionPorts {
|
||||
readonly registry: Registry
|
||||
/** The process container: a child of it resolves each module's platform port. */
|
||||
readonly root: Container
|
||||
readonly log: { warn(message: string): void }
|
||||
readonly timeoutMs?: number
|
||||
/**
|
||||
* Records the revocation durably and delivers it (the account module's `recordSessionRevocation`),
|
||||
* when this profile has the outbox. Throws only when nothing was recorded.
|
||||
*/
|
||||
readonly durable?: (session: string) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounded and never thrown: kit-auth keeps a session live when its hook throws, which would fail the
|
||||
* sign-out. With the outbox, a module that could not be reached is retried by the worker until its
|
||||
* platform confirms. Without it, such a module is logged by name and its sessions end at their own
|
||||
* expiry (at most 60 minutes); this app already refuses the revoked session's requests.
|
||||
*/
|
||||
export async function revokeDerivedSessions(ports: DerivedSessionPorts, ref: SessionRef): Promise<void> {
|
||||
const session = sessionRefKey(ref)
|
||||
if (ports.durable) {
|
||||
try {
|
||||
await ports.durable(session)
|
||||
return
|
||||
} catch {
|
||||
ports.log.warn("auth: the revocation of an app session could not be recorded; asking the modules directly")
|
||||
}
|
||||
}
|
||||
const timeoutMs = ports.timeoutMs ?? DERIVED_REVOCATION_MS
|
||||
const contributions = accountDataOf(ports.registry).filter((contribution) => contribution.revokeSessions)
|
||||
const settled = await Promise.allSettled(
|
||||
contributions.map((contribution) => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("timed out")), timeoutMs)
|
||||
})
|
||||
return Promise.race([contribution.revokeSessions?.({ session, container: ports.root.child() }), timeout]).finally(
|
||||
() => clearTimeout(timer),
|
||||
)
|
||||
}),
|
||||
)
|
||||
settled.forEach((result, index) => {
|
||||
if (result.status === "rejected") {
|
||||
ports.log.warn(
|
||||
`auth: ${contributions[index]?.key ?? "a module"} did not revoke the sessions of a revoked app session`,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The web shell's MariaDB store: the Kysely MySQL dialect over MARIADB_URL (the pool is pinned to
|
||||
// UTC by `mysqlPool`), and the readiness probes of `/api/ready`.
|
||||
|
||||
import type { AnyEntityDef } from "@conusai-kit/data"
|
||||
import { createMysqlDataSource, MYSQL_MIGRATIONS_TABLE, mysqlPool } from "@conusai-kit/data-kysely/mysql"
|
||||
import { type LiveLog, mariadbLiveLog } from "@conusai-kit/live-server"
|
||||
import type { EngineStore } from "../../stores"
|
||||
|
||||
export function mariadbStore(url: string, entities: readonly AnyEntityDef[]): EngineStore {
|
||||
const pool = mysqlPool(url, { connectionLimit: 10 })
|
||||
const data = createMysqlDataSource({ entities, pool })
|
||||
const promise = pool.promise()
|
||||
let live: LiveLog | undefined
|
||||
return {
|
||||
data,
|
||||
probes: {
|
||||
store: () => promise.query("select 1"),
|
||||
applied: async () => {
|
||||
const [rows] = await promise.query(`select \`name\` from \`${MYSQL_MIGRATIONS_TABLE}\``)
|
||||
return new Set((rows as { name: string }[]).map((row) => row.name))
|
||||
},
|
||||
boot: async () => undefined,
|
||||
},
|
||||
// Row triggers write the log in the writer's transaction; the stream polls it (TS6.2).
|
||||
liveLog: () => {
|
||||
live ??= mariadbLiveLog({ pool: promise })
|
||||
return live
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The web shell's MongoDB store over MONGODB_URL. The driver connects on first use, so the process
|
||||
// builds synchronously; `boot` makes the replica-set check of AT-D15 before a request is served, and
|
||||
// app.ts stops the process when it fails.
|
||||
|
||||
import type { AnyEntityDef } from "@conusai-kit/data"
|
||||
import { connectMongoDataSource, createMongoDataSource, MONGO_MIGRATIONS_COLLECTION } from "@conusai-kit/data-mongo"
|
||||
import { type LiveLog, type MongoClientLike, mongodbLiveLog } from "@conusai-kit/live-server"
|
||||
import { MongoClient } from "mongodb"
|
||||
import type { EngineStore } from "../../stores"
|
||||
|
||||
export function mongodbStore(
|
||||
url: string,
|
||||
entities: readonly AnyEntityDef[],
|
||||
collections: ReadonlyArray<{ readonly entity: string; readonly table: string }> = [],
|
||||
): EngineStore {
|
||||
const client = new MongoClient(url)
|
||||
let live: LiveLog | undefined
|
||||
const data = createMongoDataSource({ entities, client })
|
||||
return {
|
||||
data,
|
||||
probes: {
|
||||
store: () => client.db().command({ ping: 1 }),
|
||||
applied: async () => {
|
||||
const rows = await client
|
||||
.db()
|
||||
.collection<{ _id: string }>(MONGO_MIGRATIONS_COLLECTION)
|
||||
.find({}, { projection: { _id: 1 } })
|
||||
.toArray()
|
||||
return new Set(rows.map((row) => String(row._id)))
|
||||
},
|
||||
boot: async () => {
|
||||
await connectMongoDataSource(client, { entities })
|
||||
},
|
||||
},
|
||||
// The replica set's change stream on the live collections (TS6.2); opened on the first stream.
|
||||
liveLog: () => {
|
||||
live ??= mongodbLiveLog({ client: client as unknown as MongoClientLike, collections })
|
||||
return live
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The stores of the mariadb and mongodb profiles (spec §5.1; profiles/<name>/README.md), built once
|
||||
// per web process beside the Postgres pair app.ts builds. One login, no row-level security to bind
|
||||
// (AT-D20 is Postgres-only): the repository predicate is the tenant wall, which the adapters' shared
|
||||
// contract suites prove. The queue is BullMQ on the linked Valkey, and a request only enqueues.
|
||||
//
|
||||
// `kit template:prune --profile` keeps this folder for mariadb and mongodb (and the chosen engine's
|
||||
// folder); postgres and convex remove it with every `kit:profile-dialect` line.
|
||||
|
||||
import type { AppEnv } from "@app/config"
|
||||
import type { AnyEntityDef, JobQueue } from "@conusai-kit/data"
|
||||
import { createBullmqJobQueue } from "@conusai-kit/jobs-bullmq"
|
||||
import { Redis } from "ioredis"
|
||||
import type { EngineStore } from "../stores"
|
||||
import { mariadbStore } from "./mariadb/index" // kit:profile-mariadb
|
||||
import { mongodbStore } from "./mongodb/index" // kit:profile-mongodb
|
||||
|
||||
export interface DialectStores extends EngineStore {
|
||||
/** The queue a request enqueues on; it connects on the first enqueue. */
|
||||
queue(): JobQueue
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* The BullMQ key prefix of this app: `kit-jobs-<slug>`, from the project the platform injects, so
|
||||
* two apps sharing one Valkey service never meet (the worker's `queueSlug` is the same function).
|
||||
*/
|
||||
export function queueSlug(env: Pick<AppEnv, "deployment">): string {
|
||||
const id = env.deployment.CONUSAI_PROJECT_ID.toLowerCase().replace(/[^a-z0-9-]+/g, "-")
|
||||
return `p-${id}`.replace(/-+$/, "")
|
||||
}
|
||||
|
||||
type Collections = ReadonlyArray<{ readonly entity: string; readonly table: string }>
|
||||
|
||||
function engineStore(env: AppEnv, entities: readonly AnyEntityDef[], collections: Collections): EngineStore | null {
|
||||
if (env.mariadb) return mariadbStore(env.mariadb.url, entities) // kit:profile-mariadb
|
||||
if (env.mongodb) return mongodbStore(env.mongodb.url, entities, collections) // kit:profile-mongodb
|
||||
return null
|
||||
}
|
||||
|
||||
/** The stores of the mariadb or mongodb profile, or null on any other. */
|
||||
export function dialectStores(
|
||||
env: AppEnv,
|
||||
entities: readonly AnyEntityDef[],
|
||||
collections: Collections = [],
|
||||
): DialectStores | null {
|
||||
const store = engineStore(env, entities, collections)
|
||||
if (!store) return null
|
||||
const redisUrl = env.redis?.url
|
||||
if (!redisUrl) throw new Error("the mariadb and mongodb profiles need REDIS_URL for their job queue")
|
||||
let connection: Redis | undefined
|
||||
let queue: ReturnType<typeof createBullmqJobQueue> | undefined
|
||||
return {
|
||||
...store,
|
||||
queue: () => {
|
||||
// BullMQ needs `maxRetriesPerRequest: null` on the connection it is given.
|
||||
connection ??= new Redis(redisUrl, { maxRetriesPerRequest: null })
|
||||
queue ??= createBullmqJobQueue({ connection, slug: queueSlug(env) })
|
||||
return queue
|
||||
},
|
||||
close: async () => {
|
||||
await queue?.close().catch(() => undefined)
|
||||
await connection?.quit().catch(() => undefined)
|
||||
await store.data.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// What a loader does when a procedure it called says no, so every page says the same thing (spec §3.3).
|
||||
//
|
||||
// 401 the visitor is not signed in: a 302 to the sign-in page, which brings them back here
|
||||
// 404 a stranger, a missing organization or a record of another tenant: the not-found page. The
|
||||
// kernel gives these one answer on purpose, and the page keeps it.
|
||||
// 403 a member whose permissions do not allow it: the forbidden page, shown only to a member
|
||||
//
|
||||
// Anything else is a fault and goes on to the error boundary. Each status is set before anything streams:
|
||||
// a loader runs before the render starts (F10).
|
||||
//
|
||||
// Start has no `forbidden()` (M5). The forbidden page is the not-found page's component with
|
||||
// `data.forbidden`, and its 403 is asked for with `PAGE_STATUS_HEADER`, which src/server.ts turns into the
|
||||
// response's status and removes, so the router's own 404 for a not-found does not win.
|
||||
|
||||
import { AppError } from "@conusai-kit/core"
|
||||
import { notFound, redirect } from "@tanstack/react-router"
|
||||
import { setResponseHeader, setResponseStatus } from "@tanstack/react-start/server"
|
||||
|
||||
/** A response header a loader sets to fix the page's status; src/server.ts applies it and strips it. */
|
||||
export const PAGE_STATUS_HEADER = "x-conusai-page-status"
|
||||
|
||||
/** What the not-found component is handed when the page is the forbidden one. */
|
||||
export interface StatusPageData {
|
||||
readonly forbidden?: boolean
|
||||
}
|
||||
|
||||
/** The sign-in page, with where to come back to and, when there is one, why the visitor is there. */
|
||||
export function signInHref(returnTo: string, error?: string): string {
|
||||
const query = new URLSearchParams()
|
||||
if (returnTo !== "/") query.set("return_to", returnTo)
|
||||
if (error) query.set("error", error)
|
||||
const text = query.toString()
|
||||
return `/auth/sign-in${text ? `?${text}` : ""}`
|
||||
}
|
||||
|
||||
function pageStatus(status: number): void {
|
||||
setResponseStatus(status)
|
||||
setResponseHeader(PAGE_STATUS_HEADER, String(status))
|
||||
}
|
||||
|
||||
/** The not-found page, answered 404. */
|
||||
export function pageNotFound(): never {
|
||||
pageStatus(404)
|
||||
throw notFound()
|
||||
}
|
||||
|
||||
/** The forbidden page, answered 403: Next's `forbidden()`, which Start does not have. */
|
||||
export function pageForbidden(): never {
|
||||
pageStatus(403)
|
||||
throw notFound({ data: { forbidden: true } satisfies StatusPageData })
|
||||
}
|
||||
|
||||
/** To the sign-in page and back, answered 302. */
|
||||
export function toSignIn(path: string): never {
|
||||
throw redirect({ href: signInHref(path), statusCode: 302 })
|
||||
}
|
||||
|
||||
/** To another page of this app, answered 302. */
|
||||
export function toPage(href: string): never {
|
||||
throw redirect({ href, statusCode: 302 })
|
||||
}
|
||||
|
||||
/** Runs a read and turns a refusal into the page that belongs to it. */
|
||||
export async function orFallback<T>(path: string, run: () => Promise<T>): Promise<T> {
|
||||
let failure: unknown
|
||||
try {
|
||||
return await run()
|
||||
} catch (error) {
|
||||
failure = error
|
||||
}
|
||||
if (failure instanceof AppError) {
|
||||
if (failure.status === 404) pageNotFound()
|
||||
if (failure.status === 403) pageForbidden()
|
||||
if (failure.status === 401) toSignIn(path)
|
||||
}
|
||||
throw failure
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Who in this process hears that an organization's grants changed, on a profile other than Postgres,
|
||||
// whose host keeps its own (`createPostgresHost`). It sits below live.ts and request.ts: the live server
|
||||
// authorizes its streams with a request's caller, and a request's tenancy writes announce to the live
|
||||
// server, so neither may import the other (dependency-cruiser's no-circular).
|
||||
|
||||
import { createLiveGrants } from "@conusai-kit/host/postgres"
|
||||
|
||||
export const liveGrants = createLiveGrants()
|
||||
|
||||
/** The id of an organization this process streams, by slug. */
|
||||
export const streamedOrgId = liveGrants.streamedOrgId
|
||||
|
||||
/** The binding of kit-tenancy's GRANTS_CHANGED port (app.ts). */
|
||||
export const liveGrantsChanged = liveGrants.port
|
||||
@@ -0,0 +1,71 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// `POST /api/v1/orgs/{org}/live` (umbrella TS6.2). On Postgres it is `createPostgresHost`'s. The mariadb
|
||||
// and mongodb profiles serve the same mount over their store's live log (dialect/), and the Convex
|
||||
// profile over the deployment's log functions, with the deployment deciding the grants (convex-live.ts).
|
||||
|
||||
import { PROFILE } from "@app/config"
|
||||
import {
|
||||
createHostLive,
|
||||
type HostLive,
|
||||
type LiveAuthorize, // kit:profile-convex
|
||||
liveOrgSlugOf, // kit:profile-convex
|
||||
liveSessionOf, // kit:profile-convex
|
||||
liveSignedIn, // kit:profile-convex
|
||||
} from "@conusai-kit/host/postgres"
|
||||
import { convexLiveLog, problems } from "@conusai-kit/live-server" // kit:profile-convex
|
||||
import { postgresHost, webRuntime } from "./app"
|
||||
import { convexAdminClient } from "./convex-caller" // kit:profile-convex
|
||||
import { convexLiveAuthorize, deploymentLiveSource, type LiveQueryClient } from "./convex-live" // kit:profile-convex
|
||||
import { liveGrants } from "./live-grants"
|
||||
import {
|
||||
type RequestContext, // kit:profile-convex
|
||||
requestContext,
|
||||
} from "./request"
|
||||
|
||||
let mount: HostLive | undefined
|
||||
|
||||
// kit:profile-convex:begin
|
||||
/** The deployment decides a Convex stream's grants; this process only says who is asking. */
|
||||
function convexAuthorize(convex: NonNullable<ReturnType<typeof webRuntime>["convex"]>): LiveAuthorize {
|
||||
const { observe } = webRuntime()
|
||||
return convexLiveAuthorize({
|
||||
caller: async (request) => {
|
||||
const { principal, convexIdentity } = await liveSignedIn<RequestContext>(requestContext, request)
|
||||
if (!convexIdentity) throw problems.unauthenticated()
|
||||
return { userId: principal.actor.userId, session: liveSessionOf(principal), identity: convexIdentity }
|
||||
},
|
||||
clientFor: (identity) => convexAdminClient(convex, identity) as unknown as LiveQueryClient,
|
||||
orgSlugOf: liveOrgSlugOf,
|
||||
warn: (message) => observe.log.warn(message),
|
||||
})
|
||||
}
|
||||
// kit:profile-convex:end
|
||||
|
||||
/** The live mount of a profile other than Postgres, built on the first stream. */
|
||||
function liveMount(): HostLive {
|
||||
mount ??= createHostLive({
|
||||
runtime: webRuntime,
|
||||
requestContext,
|
||||
grants: liveGrants,
|
||||
log: (_onError) => {
|
||||
// kit:profile-convex:begin
|
||||
const { convex } = webRuntime()
|
||||
if (convex) return convexLiveLog({ source: deploymentLiveSource(convex), onError: _onError })
|
||||
// kit:profile-convex:end
|
||||
return webRuntime().liveLog?.()
|
||||
},
|
||||
authorize: () => {
|
||||
// kit:profile-convex:begin
|
||||
const { convex } = webRuntime()
|
||||
if (convex) return convexAuthorize(convex)
|
||||
// kit:profile-convex:end
|
||||
return (mount as HostLive).authorize
|
||||
},
|
||||
})
|
||||
return mount
|
||||
}
|
||||
|
||||
/** Answers one `POST …/live`; a profile with no live log answers 501. */
|
||||
export function serveLive(request: Request): Promise<Response> {
|
||||
return PROFILE === "postgres" ? postgresHost().live(request) : liveMount().serve(request)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Which nav items a flag hides (core-14). A manifest's nav item may name a feature flag some module declares;
|
||||
// the sidebar and a project's tabs list it only while that flag is on for the scope the person is in, so a
|
||||
// switched-off page is not a link to its 404. The page itself still answers through its module's own check.
|
||||
|
||||
import { type Container, token } from "@conusai-kit/core"
|
||||
import { webRuntime } from "./app"
|
||||
|
||||
/** The scope a flag is read for: the workspace's sidebar, or a project's tabs. */
|
||||
export interface NavFlagScope {
|
||||
readonly org: string
|
||||
readonly workspace: string
|
||||
readonly project?: string
|
||||
}
|
||||
|
||||
/** What a flag service answers for a scope. */
|
||||
export interface NavFlags {
|
||||
enabled(flag: string, scope: NavFlagScope): boolean | Promise<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound by an app that evaluates its modules' flags (the platform's flag service, through
|
||||
* `@conusai-sdk/node-sdk/flags`): bind the same service here as in each module's flag port, so the nav and the
|
||||
* module's procedures agree. With nothing bound, a flag is its manifest default, as in a module's port.
|
||||
*/
|
||||
export const NAV_FLAGS = token<NavFlags>("app.nav.flags")
|
||||
|
||||
/** The flags the items name that are on for the scope. */
|
||||
export async function flagsOn(
|
||||
container: Container,
|
||||
items: readonly { readonly flag?: string }[],
|
||||
scope: NavFlagScope,
|
||||
): Promise<readonly string[]> {
|
||||
const named = [...new Set(items.flatMap((item) => (item.flag === undefined ? [] : [item.flag])))]
|
||||
if (named.length === 0) return []
|
||||
const declared = webRuntime().registry.flags
|
||||
const flags: NavFlags = container.has(NAV_FLAGS)
|
||||
? container.resolve(NAV_FLAGS)
|
||||
: { enabled: (flag) => declared[flag]?.default === true }
|
||||
const on = await Promise.all(named.map(async (flag) => [flag, (await flags.enabled(flag, scope)) === true] as const))
|
||||
return on.filter(([, enabled]) => enabled).map(([flag]) => flag)
|
||||
}
|
||||
|
||||
/** The items whose flag, if they name one, is on. */
|
||||
export const flagged = <Item extends { readonly flag?: string }>(
|
||||
items: readonly Item[],
|
||||
on: readonly string[],
|
||||
): Item[] => items.filter((item) => item.flag === undefined || on.includes(item.flag))
|
||||
@@ -0,0 +1,990 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The loaders of the pages under an organization (F10): the organization's shell, its workspaces and
|
||||
// projects, the documents pages, its brand and settings, the audit viewers and kit-tenancy's access pages.
|
||||
// Each returns plain data with its labels already translated; `src/routes/$org*` render it. Server-only: a
|
||||
// route reaches this file through a server function whose handler imports it.
|
||||
//
|
||||
// Every read goes through `orFallback`: the loaders of one page run side by side, so each answers a refusal
|
||||
// with the same status, whichever of them finishes first (spec §3.3).
|
||||
|
||||
import { PROFILE } from "@app/config"
|
||||
// Each optional module's shell code asks for its own selection through its own name, so pruning one keeps the other's.
|
||||
import {
|
||||
appModules,
|
||||
moduleSelected as documentsSelected, // kit:documents
|
||||
moduleSelected as knowledgeSelected, // kit:knowledge
|
||||
} from "@app/modules"
|
||||
import { type BrandView, brandGet, logoUrl, tenantLogo, tenantStyle } from "@app/modules/branding"
|
||||
import type { BrandFormLabels } from "@app/modules/branding/ui"
|
||||
import { brandFormLabels } from "@app/modules/branding/ui"
|
||||
import * as documentsApi from "@app/modules/documents" // kit:documents
|
||||
import * as documentsUi from "@app/modules/documents/ui" // kit:documents
|
||||
import { settingsGet } from "@app/modules/knowledge" // kit:knowledge
|
||||
import type { SettingsSectionLabels } from "@app/modules/knowledge/ui" // kit:knowledge
|
||||
import { tokens } from "@conusai-kit/brand"
|
||||
import { isOwnedBy } from "@conusai-kit/host/http"
|
||||
import { brandingSchema, meGet, orgGet, projectGet, projectList, workspaceList } from "@conusai-kit/tenancy"
|
||||
import {
|
||||
type AdminPageContext,
|
||||
accessAuditPage,
|
||||
collectAccessEvents,
|
||||
invitationsPage,
|
||||
membersPage,
|
||||
requireSection,
|
||||
rolePage,
|
||||
rolesPage,
|
||||
} from "@conusai-kit/tenancy/ui"
|
||||
import type { ClientHostContext } from "@conusai-kit/ui"
|
||||
import type { PermissionKey } from "@conusai-kit/ui/data/server"
|
||||
import type { SwitcherItem } from "@conusai-kit/ui/frame"
|
||||
import type { SettingsEntry } from "@conusai-kit/ui/patterns"
|
||||
import { ConusAIClient } from "@conusai-sdk/node-sdk"
|
||||
import { getRequest } from "@tanstack/react-start/server"
|
||||
import { DEFAULT_LOCALE_SETTING, type OrganizationFields, type OrganizationLabels } from "../ui/organization-language"
|
||||
import type { Field } from "../ui/procedure-form"
|
||||
import { webRuntime } from "./app"
|
||||
import { orFallback, pageNotFound, toPage, toSignIn } from "./guard"
|
||||
import { flagged, flagsOn } from "./nav-flags"
|
||||
import { holds, type NavLink, type PageContext, pageContext, permissionsIn, requirePermission } from "./page"
|
||||
import { lastWorkspaceOf } from "./preferences"
|
||||
import { PROJECT_ENTRIES, PROJECT_MODULE_KEYS } from "./project-entries"
|
||||
|
||||
export interface OrgParams {
|
||||
readonly org: string
|
||||
}
|
||||
export interface WorkspaceParams extends OrgParams {
|
||||
readonly workspace: string
|
||||
}
|
||||
export interface ProjectParams extends WorkspaceParams {
|
||||
readonly project: string
|
||||
}
|
||||
|
||||
/** A project's page, with its label translated for the project home. */
|
||||
export interface ProjectEntryLink {
|
||||
readonly href: string
|
||||
readonly label: string
|
||||
}
|
||||
|
||||
const projectLinks = async (
|
||||
page: PageContext,
|
||||
permissions: readonly string[],
|
||||
scope: ProjectParams,
|
||||
): Promise<readonly ProjectEntryLink[]> =>
|
||||
flagged(PROJECT_ENTRIES, await flagsOn(page.container, PROJECT_ENTRIES, scope))
|
||||
.filter((entry) => !entry.permission || permissions.includes(entry.permission))
|
||||
.map((entry) => ({
|
||||
href: entry.href,
|
||||
label: page.i18n.message(entry.label),
|
||||
}))
|
||||
|
||||
const adminContext = (page: PageContext, org: string): AdminPageContext => ({
|
||||
org,
|
||||
caller: page.caller,
|
||||
i18n: page.i18n,
|
||||
request: getRequest(),
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- the organization's shell
|
||||
|
||||
export interface TenantLogoData {
|
||||
readonly src: string
|
||||
readonly srcSet: string
|
||||
}
|
||||
|
||||
export interface OrgLayoutData {
|
||||
/** The organization's name as the switcher shows it. */
|
||||
readonly name: string
|
||||
readonly brandMark?: string
|
||||
/** The organization's colours, as custom properties on the <body> of everything it sees. */
|
||||
readonly style: Readonly<Record<string, string>>
|
||||
readonly logoDark?: TenantLogoData
|
||||
readonly logoLight?: TenantLogoData
|
||||
/** The organization's home: in the sidebar on a page with no workspace. */
|
||||
readonly home: NavLink
|
||||
readonly settingsLabel: string
|
||||
/**
|
||||
* The workspace an organization's own page (Settings) is shown in: the one the person was last in, else the
|
||||
* default, else the first. Its sidebar and the breadcrumb's first step, as on a page of the workspace itself.
|
||||
*/
|
||||
readonly workspace?: WorkspaceLayoutData
|
||||
readonly permissions: readonly string[]
|
||||
/** Every organization this person belongs to: the switcher offers them when there is more than one. */
|
||||
readonly organizations: SwitcherItem[]
|
||||
readonly workspaces: SwitcherItem[]
|
||||
/** Where someone who may create a workspace goes to: the switcher offers it. */
|
||||
readonly workspaceCreateHref?: string
|
||||
/** Where someone who may found an organization goes to: the switcher offers it beside the organizations. */
|
||||
readonly organizationCreateHref?: string
|
||||
readonly person: { readonly name: string; readonly detail: string }
|
||||
readonly accountHref: string
|
||||
/** Settings opens on the first of its tabs the person has: the organization's, then the workspace's, then theirs. */
|
||||
readonly settingsHref: string
|
||||
/**
|
||||
* The settings pages' layout: the organization's and the person's sections, each with the permission that shows
|
||||
* it. A workspace's own sections (a module's page placed in Settings) come with the workspace (`settings`).
|
||||
*/
|
||||
readonly settings: {
|
||||
readonly title: string
|
||||
readonly label: string
|
||||
readonly sections: readonly SettingsEntry[]
|
||||
}
|
||||
readonly client: ClientHostContext
|
||||
}
|
||||
|
||||
/** One stored logo, at 1x and at 2x for a high-density screen. */
|
||||
const logoOf = (path: string): TenantLogoData => ({
|
||||
src: logoUrl(path, "1x"),
|
||||
srcSet: `${logoUrl(path, "1x")} 1x, ${logoUrl(path, "2x")} 2x`,
|
||||
})
|
||||
|
||||
const accountHrefOf = (org: string): string => `/${org}/settings/account`
|
||||
/** Where an organization is founded (`routes/onboarding.tsx`). */
|
||||
const ONBOARDING_PATH = "/onboarding"
|
||||
|
||||
const enabledModules = () =>
|
||||
appModules.filter((module) => PROFILE !== "convex" || ["tenancy", "auth", "files", "notes"].includes(module.id))
|
||||
|
||||
/**
|
||||
* The organization's pages the shell has a route for, by the `href` of a module's `org` item, in the order of their
|
||||
* Settings tabs: the organization itself first, as on stage, then its people, its brand and its records.
|
||||
*/
|
||||
const ORGANIZATION_PAGES: Readonly<Record<string, Pick<SettingsEntry, "group"> & { label?: string; open?: boolean }>> =
|
||||
{
|
||||
settings: { group: "organization", label: "shell.general" },
|
||||
members: { group: "organization" },
|
||||
roles: { group: "organization" },
|
||||
invitations: { group: "organization" },
|
||||
// Every member reads the brand, and `permission` is who changes it.
|
||||
branding: { group: "organization", open: true },
|
||||
"access-audit": { group: "organization" },
|
||||
workspaces: { group: "workspace" },
|
||||
}
|
||||
const PAGE_ORDER = Object.keys(ORGANIZATION_PAGES)
|
||||
|
||||
/** The tabs' order in `SettingsLayout`: the first the person may open is where Settings opens. */
|
||||
const SETTINGS_ORDER: readonly SettingsEntry["group"][] = ["organization", "workspace", "personal"]
|
||||
|
||||
function firstSection(sections: readonly SettingsEntry[], permissions: readonly string[]): string | undefined {
|
||||
return SETTINGS_ORDER.flatMap((group) => sections.filter((entry) => entry.group === group)).find(
|
||||
(entry) => entry.permission === undefined || permissions.includes(entry.permission),
|
||||
)?.href
|
||||
}
|
||||
|
||||
/**
|
||||
* The left of the frame holds what a person works in; an organization's own pages are Settings, one entry at
|
||||
* the sidebar's foot, a row of tabs under the permission each manifest names, the audit viewer among them.
|
||||
*/
|
||||
function settingsOf(org: string, i18n: PageContext["i18n"]): OrgLayoutData["settings"] {
|
||||
const sections = enabledModules().flatMap((module) =>
|
||||
(module.ui?.nav ?? []).flatMap((item) => {
|
||||
const page = item.scope === "org" && Object.hasOwn(ORGANIZATION_PAGES, item.href) && ORGANIZATION_PAGES[item.href]
|
||||
if (!page) return []
|
||||
return [
|
||||
{
|
||||
group: page.group,
|
||||
label: i18n.message(page.label ?? item.label),
|
||||
href: `/${org}/${item.href}`,
|
||||
...(item.permission === undefined || page.open ? {} : { permission: item.permission }),
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
sections.sort(
|
||||
(a, b) => PAGE_ORDER.indexOf(a.href.split("/")[2] ?? "") - PAGE_ORDER.indexOf(b.href.split("/")[2] ?? ""),
|
||||
)
|
||||
return {
|
||||
title: i18n.message("app.settings"),
|
||||
label: i18n.message("shell.sections"),
|
||||
sections: [
|
||||
...sections,
|
||||
{
|
||||
group: "organization",
|
||||
label: i18n.message("app.audit"),
|
||||
href: `/${org}/audit`,
|
||||
permission: "tenancy.audit.read",
|
||||
},
|
||||
{ group: "personal", label: i18n.message("account.link"), href: accountHrefOf(org) },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The organization route's loader. The scope is resolved on the server, once, by calling an
|
||||
* organization-scoped procedure: a stranger and a missing organization both end at the not-found page
|
||||
* (guard.ts), so the route cannot be used to ask which organizations exist (spec §3.3, §6.4). It is also where
|
||||
* the organization's own brand is read (spec §10.3).
|
||||
*
|
||||
* There is no share image of the organization in the page's head: `/{org}/brand/og` answers 404 to anyone who
|
||||
* is not a signed-in member, which is every link-preview crawler. The root route's head stands: the app's own
|
||||
* public share image.
|
||||
*/
|
||||
export async function orgLayoutData({ org }: OrgParams): Promise<OrgLayoutData> {
|
||||
const { caller, i18n, signedIn, path, mayCreateOrganization } = await pageContext()
|
||||
if (!signedIn) toSignIn(path)
|
||||
const [organization, me, held, { workspaces }] = await orFallback(path, () =>
|
||||
Promise.all([
|
||||
caller.call(orgGet, { org }),
|
||||
caller.call(meGet, undefined),
|
||||
permissionsIn({ org }),
|
||||
caller.call(workspaceList, { org }),
|
||||
]),
|
||||
)
|
||||
// The permissions this person holds in the organization -- a built-in role's or a custom one's
|
||||
// (`tenancy.me.permissions`) -- so the shell drops what would only be refused. Reflection only: each
|
||||
// page and procedure checks again.
|
||||
const permissions = held.permissions
|
||||
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")
|
||||
const brandMark = brand.primary ?? brand.accent ?? undefined
|
||||
const remembered = lastWorkspaceOf(getRequest().headers.get("cookie"), org)
|
||||
const current =
|
||||
workspaces.find((entry) => entry.slug === remembered) ??
|
||||
workspaces.find((entry) => entry.isDefault) ??
|
||||
workspaces[0]
|
||||
const workspace = current ? await orFallback(path, () => workspaceLayout(org, current, i18n)) : undefined
|
||||
const settings = settingsOf(org, i18n)
|
||||
return {
|
||||
name: brand.displayName ?? organization.name,
|
||||
...(brandMark ? { brandMark } : {}),
|
||||
style: tenantStyle(brand).style,
|
||||
...(darkLogo ? { logoDark: logoOf(darkLogo.path) } : {}),
|
||||
...(lightLogo ? { logoLight: logoOf(lightLogo.path) } : {}),
|
||||
settingsLabel: i18n.message("workspace.organizationSettings"),
|
||||
home: { scope: "org", label: i18n.message("app.workspaces"), href: `/${org}`, icon: "folder" },
|
||||
...(workspace ? { workspace } : {}),
|
||||
permissions,
|
||||
organizations: me.organizations.map((entry) => ({ id: entry.slug, label: entry.name })),
|
||||
workspaces: workspaces.map((entry) => ({ id: entry.slug, label: entry.name })),
|
||||
// From the switcher, as on stage; a one-organization deployment (APP_SINGLE_ORGANIZATION) can have more than
|
||||
// one workspace too. Founding an organization is `pageContext`'s rule, which that deployment closes once it has one.
|
||||
...(permissions.includes("tenancy.workspace.manage") ? { workspaceCreateHref: `/${org}/workspaces` } : {}),
|
||||
...(mayCreateOrganization ? { organizationCreateHref: ONBOARDING_PATH } : {}),
|
||||
person: { name: me.displayName, detail: me.email },
|
||||
accountHref: accountHrefOf(org),
|
||||
settingsHref:
|
||||
firstSection([...settings.sections, ...(workspace?.settings ?? [])], permissions) ?? accountHrefOf(org),
|
||||
settings,
|
||||
client: { org, locale: i18n.locale, user: me.userId },
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the organization's home
|
||||
|
||||
export interface OrgHomeData {
|
||||
readonly title: string
|
||||
readonly description: string
|
||||
readonly createWorkspace?: string
|
||||
readonly none: string
|
||||
readonly name: string
|
||||
readonly projects: string
|
||||
readonly workspaces: readonly {
|
||||
readonly slug: string
|
||||
readonly name: string
|
||||
readonly projectCount: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export async function orgHomeData({ org }: OrgParams): Promise<OrgHomeData> {
|
||||
const page = await pageContext()
|
||||
const { caller, i18n, path } = page
|
||||
const rows = await orFallback(path, async () => {
|
||||
const { workspaces } = await caller.call(workspaceList, { org })
|
||||
const [only] = workspaces
|
||||
if (only && workspaces.length === 1 && webRuntime().singleOrganization) {
|
||||
toPage(`/${encodeURIComponent(org)}/${encodeURIComponent(only.slug)}`)
|
||||
}
|
||||
return Promise.all(
|
||||
workspaces.map(async (workspace) => ({
|
||||
workspace,
|
||||
projects: (await caller.call(projectList, { org, workspace: workspace.slug })).projects,
|
||||
})),
|
||||
)
|
||||
})
|
||||
return {
|
||||
title: i18n.message("app.workspaces"),
|
||||
description: i18n.message("app.chooseWorkspace"),
|
||||
...((await orFallback(path, () => holds(page, { org }, "tenancy.workspace.manage")))
|
||||
? { createWorkspace: i18n.message("workspace.create") }
|
||||
: {}),
|
||||
none: i18n.message("workspace.none"),
|
||||
name: i18n.message("app.name"),
|
||||
projects: i18n.message("app.projects"),
|
||||
workspaces: rows.map(({ workspace, projects }) => ({
|
||||
slug: workspace.slug,
|
||||
name: workspace.name,
|
||||
projectCount: i18n.message("workspace.projectCount", { count: projects.length }),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- workspaces
|
||||
|
||||
export interface WorkspacesData {
|
||||
readonly title: string
|
||||
readonly none: string
|
||||
readonly name: string
|
||||
readonly visibility: string
|
||||
readonly rows: readonly {
|
||||
readonly slug: string
|
||||
readonly name: string
|
||||
readonly isDefault?: string
|
||||
readonly visibility: string
|
||||
}[]
|
||||
/** The form, for whoever holds `tenancy.workspace.manage`; the kernel refuses everybody else regardless. */
|
||||
readonly create?: {
|
||||
readonly title: string
|
||||
readonly submit: string
|
||||
readonly fields: Field[]
|
||||
}
|
||||
}
|
||||
|
||||
export async function workspacesData({ org }: OrgParams): Promise<WorkspacesData> {
|
||||
const page = await pageContext()
|
||||
const { caller, i18n, path } = page
|
||||
const { workspaces } = await orFallback(path, () => caller.call(workspaceList, { org }))
|
||||
const mayCreate = await orFallback(path, () => holds(page, { org }, "tenancy.workspace.manage"))
|
||||
const visibilityOrg = i18n.message("workspace.visibilityOrg")
|
||||
const visibilityRestricted = i18n.message("workspace.visibilityRestricted")
|
||||
return {
|
||||
title: i18n.message("workspace.list"),
|
||||
none: i18n.message("workspace.none"),
|
||||
name: i18n.message("app.name"),
|
||||
visibility: i18n.message("workspace.visibility"),
|
||||
rows: workspaces.map((workspace) => ({
|
||||
slug: workspace.slug,
|
||||
name: workspace.name,
|
||||
...(workspace.isDefault ? { isDefault: i18n.message("workspace.default") } : {}),
|
||||
visibility: workspace.visibility === "org" ? visibilityOrg : visibilityRestricted,
|
||||
})),
|
||||
...(mayCreate
|
||||
? {
|
||||
create: {
|
||||
title: i18n.message("workspace.create"),
|
||||
submit: i18n.message("app.create"),
|
||||
fields: [
|
||||
{ name: "name", label: i18n.message("app.name"), required: true },
|
||||
{ name: "slug", label: i18n.message("app.slug"), required: true },
|
||||
{
|
||||
name: "visibility",
|
||||
label: i18n.message("workspace.visibility"),
|
||||
required: true,
|
||||
options: [
|
||||
{ value: "org", label: visibilityOrg },
|
||||
{ value: "restricted", label: visibilityRestricted },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace route's loader: the workspace has to be one this person can see, and the page says 404 before
|
||||
* it starts if it is not (spec §3.3). A restricted workspace the person is not in is not in
|
||||
* `tenancy.workspace.list`, so it is the same not-found as one that does not exist.
|
||||
*/
|
||||
export async function requireWorkspace({ org, workspace }: WorkspaceParams): Promise<WorkspaceLayoutData> {
|
||||
const { caller, i18n, path } = await pageContext()
|
||||
const { workspaces } = await orFallback(path, () => caller.call(workspaceList, { org }))
|
||||
const found = workspaces.find((entry) => entry.slug === workspace)
|
||||
if (!found) pageNotFound()
|
||||
const layout = await orFallback(path, () => workspaceLayout(org, found, i18n))
|
||||
const base = `/${org}/${workspace}`
|
||||
const projects = layout.permissions.includes("tenancy.project.read")
|
||||
? await orFallback(path, () => caller.call(projectList, { org, workspace, sort: "name" }))
|
||||
: { projects: [] }
|
||||
return {
|
||||
...layout,
|
||||
projectNavigation: {
|
||||
groupLabel: i18n.message("workspace.projectGroup"),
|
||||
pickerLabel: i18n.message("workspace.switchProject"),
|
||||
chooseLabel: i18n.message("workspace.chooseProject"),
|
||||
projects: projects.projects.map(({ slug, name }) => ({ id: slug, label: name, href: `${base}/${slug}` })),
|
||||
...(layout.permissions.includes("tenancy.project.manage")
|
||||
? { create: { label: i18n.message("workspace.createProject"), href: `${base}/modules?create=true` } }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** A workspace this person can see, as the frame shows it: its name, their permissions in it, and its sidebar. */
|
||||
async function workspaceLayout(
|
||||
org: string,
|
||||
found: { readonly slug: string; readonly name: string },
|
||||
i18n: PageContext["i18n"],
|
||||
): Promise<WorkspaceLayoutData> {
|
||||
const workspace = found.slug
|
||||
const { permissions } = await permissionsIn({ org, workspace })
|
||||
// The registry's nav is already in `order` within the scope (core-5); a flagged page is listed while its flag is on (core-14).
|
||||
const inWorkspace = webRuntime().registry.nav.filter((item) => item.scope === "workspace")
|
||||
const on = await flagsOn((await pageContext()).container, inWorkspace, { org, workspace })
|
||||
const pages = flagged(inWorkspace, on).filter(
|
||||
(item) => item.permission === undefined || permissions.includes(item.permission as PermissionKey),
|
||||
)
|
||||
const groupLabel = i18n.message("workspace.group")
|
||||
const base = `/${org}/${workspace}`
|
||||
const hrefOf = (item: { readonly href: string }) => `/${org}/${workspace}/modules/${item.href}`
|
||||
return {
|
||||
workspace: { id: found.slug, label: found.name },
|
||||
permissions,
|
||||
nav: [
|
||||
{
|
||||
scope: "workspace",
|
||||
label: i18n.message("workspace.overview"),
|
||||
href: base,
|
||||
icon: "chart",
|
||||
group: groupLabel,
|
||||
end: true,
|
||||
},
|
||||
...(permissions.includes("tenancy.project.read")
|
||||
? [
|
||||
{
|
||||
scope: "workspace" as const,
|
||||
label: i18n.message("shell.projects"),
|
||||
href: `${base}/modules`,
|
||||
icon: "folder",
|
||||
group: groupLabel,
|
||||
end: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...pages
|
||||
.filter((item) => item.placement !== "settings")
|
||||
.map(({ icon, ...item }) => ({
|
||||
scope: "workspace" as const,
|
||||
label: i18n.message(item.label),
|
||||
href: hrefOf(item),
|
||||
...(icon ? { icon } : {}),
|
||||
group: groupLabel,
|
||||
})),
|
||||
],
|
||||
settings: pages
|
||||
.filter((item) => item.placement === "settings")
|
||||
.map((item) => ({ group: "workspace" as const, label: i18n.message(item.label), href: hrefOf(item) })),
|
||||
}
|
||||
}
|
||||
|
||||
/** The workspace as the frame shows it, and what its sidebar lists: Projects, then the modules' pages. */
|
||||
export interface WorkspaceLayoutData {
|
||||
readonly workspace: SwitcherItem
|
||||
readonly permissions: readonly string[]
|
||||
readonly nav: readonly NavLink[]
|
||||
readonly projectNavigation?: import("@conusai-kit/ui/frame").ProjectNavigation
|
||||
/** The modules' pages placed in Settings (`placement: "settings"`): tabs there, already filtered by permission. */
|
||||
readonly settings: readonly SettingsEntry[]
|
||||
}
|
||||
|
||||
export interface WorkspaceHomeData {
|
||||
readonly locale: string
|
||||
readonly name: string
|
||||
readonly title: string
|
||||
readonly projectsHeading: string
|
||||
readonly description: string
|
||||
readonly none: string
|
||||
readonly columnName: string
|
||||
readonly columnModule: string
|
||||
readonly projects: readonly { readonly slug: string; readonly name: string; readonly moduleKey: string }[]
|
||||
/** The form, for whoever holds `tenancy.project.manage`. */
|
||||
readonly create?: {
|
||||
readonly title: string
|
||||
readonly submit: string
|
||||
readonly fields: Field[]
|
||||
}
|
||||
}
|
||||
|
||||
export async function workspaceHomeData({ org, workspace }: WorkspaceParams): Promise<WorkspaceHomeData> {
|
||||
const page = await pageContext()
|
||||
const { caller, i18n, path } = page
|
||||
const { permissions } = await orFallback(path, () => permissionsIn({ org, workspace }))
|
||||
const { name, projects } = await orFallback(path, async () => {
|
||||
const [{ projects }, { workspaces }] = await Promise.all([
|
||||
permissions.includes("tenancy.project.read") ? caller.call(projectList, { org, workspace }) : { projects: [] },
|
||||
caller.call(workspaceList, { org }),
|
||||
])
|
||||
// `projectList` answers for the workspace's scope; the name is the list's own, so the page does
|
||||
// not need a procedure of its own to say it.
|
||||
return { projects, name: workspaces.find((entry) => entry.slug === workspace)?.name ?? workspace }
|
||||
})
|
||||
const mayCreate = permissions.includes("tenancy.project.manage")
|
||||
return {
|
||||
name,
|
||||
locale: i18n.locale,
|
||||
title: i18n.message("workspace.title", { name }),
|
||||
projectsHeading: i18n.message("workspace.projects"),
|
||||
description: i18n.message("workspace.nextAction"),
|
||||
none: i18n.message("workspace.noProjects"),
|
||||
columnName: i18n.message("app.name"),
|
||||
columnModule: i18n.message("workspace.module"),
|
||||
projects: projects.map(({ slug, name, moduleKey }) => ({ slug, name, moduleKey })),
|
||||
...(mayCreate
|
||||
? {
|
||||
create: {
|
||||
title: i18n.message("workspace.createProject"),
|
||||
submit: i18n.message("app.create"),
|
||||
fields: [
|
||||
{ name: "name", label: i18n.message("app.name"), required: true },
|
||||
{ name: "slug", label: i18n.message("app.slug"), required: true },
|
||||
{
|
||||
name: "moduleKey",
|
||||
label: i18n.message("workspace.module"),
|
||||
required: true,
|
||||
options: PROJECT_MODULE_KEYS.map((key) => ({ value: key, label: key })),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- a project
|
||||
|
||||
export interface ProjectHomeData {
|
||||
readonly workspaceName: string
|
||||
readonly name: string
|
||||
readonly title: string
|
||||
readonly module: string
|
||||
readonly archived?: string
|
||||
readonly work: string
|
||||
readonly noSections: string
|
||||
readonly entries: readonly ProjectEntryLink[]
|
||||
}
|
||||
|
||||
export async function projectHomeData({ org, workspace, project }: ProjectParams): Promise<ProjectHomeData> {
|
||||
const page = await pageContext()
|
||||
const { caller, i18n, path } = page
|
||||
const { found, workspaceName, permissions } = await orFallback(path, async () => {
|
||||
const [found, { workspaces }, { permissions }] = await Promise.all([
|
||||
caller.call(projectGet, { org, workspace, project }),
|
||||
caller.call(workspaceList, { org }),
|
||||
permissionsIn({ org, workspace, project }),
|
||||
])
|
||||
return {
|
||||
found,
|
||||
permissions,
|
||||
workspaceName: workspaces.find((entry) => entry.slug === workspace)?.name ?? workspace,
|
||||
}
|
||||
})
|
||||
return {
|
||||
workspaceName,
|
||||
name: found.name,
|
||||
title: i18n.message("project.title", { name: found.name }),
|
||||
module: found.description ?? i18n.message("project.module", { module: found.moduleKey }),
|
||||
...(found.archived ? { archived: i18n.message("project.archived") } : {}),
|
||||
work: i18n.message("project.work"),
|
||||
noSections: i18n.message("project.noSections"),
|
||||
entries: await projectLinks(page, permissions, { org, workspace, project }),
|
||||
}
|
||||
}
|
||||
|
||||
type Translate = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
// kit:documents:begin
|
||||
// ---------------------------------------------------------------- documents
|
||||
|
||||
export interface DocumentsData {
|
||||
readonly title: string
|
||||
readonly intro: string
|
||||
readonly accept: readonly string[]
|
||||
readonly upload: documentsUi.UploadLabels
|
||||
readonly list: documentsUi.DocumentListLabels
|
||||
/** The live list's seed. */
|
||||
readonly documents: Awaited<ReturnType<typeof readDocuments>>["documents"]
|
||||
/** The locale the dates are written in: the list is live, so the browser formats each row's. */
|
||||
readonly locale: string
|
||||
}
|
||||
|
||||
const readDocuments = (page: PageContext, slugs: ProjectParams) => page.caller.call(documentsApi.documentList, slugs)
|
||||
|
||||
/** The project's documents: every read a procedure through the same caller the API uses (spec §6.2). */
|
||||
export async function documentsData(slugs: ProjectParams): Promise<DocumentsData> {
|
||||
if (!documentsSelected("documents")) pageNotFound()
|
||||
if (PROFILE === "convex") pageNotFound()
|
||||
const page = await pageContext()
|
||||
const { i18n, path } = page
|
||||
const { documents } = await orFallback(path, () => readDocuments(page, slugs))
|
||||
const t: Translate = (key, values) => i18n.message(`documents.${key}`, values)
|
||||
return {
|
||||
title: i18n.message("documents.title"),
|
||||
intro: i18n.message("documents.intro"),
|
||||
accept: documentsApi.READABLE_CONTENT_TYPES,
|
||||
upload: documentsUi.uploadLabels(t),
|
||||
list: documentsUi.listLabels(t),
|
||||
documents,
|
||||
locale: i18n.locale,
|
||||
}
|
||||
}
|
||||
|
||||
export interface DocumentParams extends ProjectParams {
|
||||
readonly fileId: string
|
||||
}
|
||||
|
||||
export interface DocumentData {
|
||||
readonly back: string
|
||||
readonly title: string
|
||||
/** The live document's seed: the header and the lines are built from it wherever the page renders. */
|
||||
readonly document: Awaited<ReturnType<typeof readDocument>>["document"]
|
||||
readonly lines: Awaited<ReturnType<typeof readDocument>>["lines"]
|
||||
readonly header: documentsUi.DocumentHeaderLabels
|
||||
readonly review: documentsUi.ReviewLabels
|
||||
readonly exportLabels: documentsUi.ExportLabels
|
||||
readonly locale: string
|
||||
}
|
||||
|
||||
const readDocument = (page: PageContext, input: DocumentParams) => page.caller.call(documentsApi.documentGet, input)
|
||||
|
||||
/** One document: a document of another organization and one that does not exist are the same page. */
|
||||
export async function documentData(input: DocumentParams): Promise<DocumentData> {
|
||||
if (!documentsSelected("documents")) pageNotFound()
|
||||
if (PROFILE === "convex") pageNotFound()
|
||||
const page = await pageContext()
|
||||
const { i18n, path } = page
|
||||
const { document, lines } = await orFallback(path, () => readDocument(page, input))
|
||||
const t: Translate = (key, values) => i18n.message(`documents.${key}`, values)
|
||||
const failed = i18n.message("app.failed")
|
||||
return {
|
||||
back: i18n.message("documents.review.back"),
|
||||
title: i18n.message("documents.review.title", { filename: document.filename }),
|
||||
document,
|
||||
lines,
|
||||
header: documentsUi.headerLabels(t),
|
||||
review: documentsUi.reviewLabels(t, {
|
||||
failed,
|
||||
aiDisclosure: i18n.message("ui.aiDisclosure"),
|
||||
pageWord: i18n.message("ui.page"),
|
||||
}),
|
||||
exportLabels: documentsUi.exportLabels(t, failed),
|
||||
locale: i18n.locale,
|
||||
}
|
||||
}
|
||||
|
||||
// kit:documents:end
|
||||
// ---------------------------------------------------------------- the organization's brand and settings
|
||||
|
||||
export interface BrandingData {
|
||||
readonly title: string
|
||||
readonly intro: string
|
||||
readonly brand: BrandView
|
||||
readonly canEdit: boolean
|
||||
readonly defaultPrimary: string
|
||||
readonly labels: BrandFormLabels
|
||||
}
|
||||
|
||||
/**
|
||||
* Every member sees it; an administrator changes it (`branding.brand.write`, which the kernel checks before a
|
||||
* handler runs), and a member without that permission is shown the same page read-only.
|
||||
*/
|
||||
export async function brandingData({ org }: OrgParams): Promise<BrandingData> {
|
||||
if (PROFILE === "convex") pageNotFound()
|
||||
const page = await pageContext()
|
||||
const { caller, i18n, path } = page
|
||||
const brand = await orFallback(path, () => caller.call(brandGet, { org }))
|
||||
const canEdit = await orFallback(path, () => holds(page, { org }, "branding.brand.write"))
|
||||
const t: Translate = (key, values) => i18n.message(`branding.${key}`, values)
|
||||
return {
|
||||
title: i18n.message("branding.title"),
|
||||
intro: i18n.message("branding.intro"),
|
||||
brand,
|
||||
canEdit,
|
||||
defaultPrimary: tokens.color.dark.accent,
|
||||
labels: brandFormLabels(t),
|
||||
}
|
||||
}
|
||||
|
||||
export interface OrgSettingsData {
|
||||
readonly title: string
|
||||
readonly slugLabel: string
|
||||
readonly statusLabel: string
|
||||
readonly slug: string
|
||||
readonly status: string
|
||||
readonly organization: OrganizationFields
|
||||
readonly organizationLabels: OrganizationLabels
|
||||
readonly knowledge: Awaited<ReturnType<typeof readKnowledge>> | undefined // kit:knowledge
|
||||
readonly labels: SettingsSectionLabels // kit:knowledge
|
||||
}
|
||||
|
||||
const readKnowledge = (page: PageContext, org: string) => page.caller.call(settingsGet, { org }) // kit:knowledge
|
||||
|
||||
/** For whoever holds `tenancy.org.update`: a member without it gets the forbidden page, a stranger the not-found page. */
|
||||
export async function orgSettingsData({ org }: OrgParams): Promise<OrgSettingsData> {
|
||||
const page = await pageContext()
|
||||
const { caller, i18n, path } = page
|
||||
await requirePermission(page, org, "tenancy.org.update")
|
||||
const organization = await orFallback(path, () => caller.call(orgGet, { org }))
|
||||
const { [DEFAULT_LOCALE_SETTING]: defaultLocale } = organization.settings
|
||||
// kit:knowledge:begin
|
||||
const knowledge = knowledgeSelected("knowledge") ? await orFallback(path, () => readKnowledge(page, org)) : undefined
|
||||
// The request's i18n already carries every module's catalogue (kit-i18n merges them from the
|
||||
// manifests), so the module's own keys resolve with no import of its messages here.
|
||||
const t = (key: string) => i18n.message(`knowledge.${key}`)
|
||||
// kit:knowledge:end
|
||||
return {
|
||||
title: i18n.message("shell.general"),
|
||||
slugLabel: i18n.message("app.slug"),
|
||||
statusLabel: i18n.message("app.status"),
|
||||
slug: organization.slug,
|
||||
status: organization.status,
|
||||
organization: {
|
||||
name: organization.name,
|
||||
defaultLocale: typeof defaultLocale === "string" ? defaultLocale : i18n.locale,
|
||||
version: organization.version,
|
||||
},
|
||||
organizationLabels: {
|
||||
title: i18n.message("shell.organizationTitle"),
|
||||
name: i18n.message("app.name"),
|
||||
language: i18n.message("shell.defaultLanguage"),
|
||||
languageHint: i18n.message("shell.defaultLanguageHint"),
|
||||
languages: [
|
||||
{ value: "en", label: i18n.message("shell.languageEn") },
|
||||
{ value: "lt", label: i18n.message("shell.languageLt") },
|
||||
],
|
||||
save: i18n.message("ui.save"),
|
||||
saved: i18n.message("shell.saved"),
|
||||
},
|
||||
// kit:knowledge:begin
|
||||
knowledge,
|
||||
labels: {
|
||||
heading: t("settings.heading"),
|
||||
state: t("settings.state"),
|
||||
stateOn: t("settings.stateOn"),
|
||||
stateOff: t("settings.stateOff"),
|
||||
backend: t("settings.backend"),
|
||||
backendPlatform: t("settings.backendPlatform"),
|
||||
enable: t("settings.enable"),
|
||||
enabling: t("settings.enabling"),
|
||||
disable: t("settings.disable"),
|
||||
disabling: t("settings.disabling"),
|
||||
enabledDone: t("settings.enabledDone"),
|
||||
disabledDone: t("settings.disabledDone"),
|
||||
planRequired: t("settings.planRequired"),
|
||||
featureDisabled: t("settings.featureDisabled"),
|
||||
quotaExhausted: t("settings.quotaExhausted"),
|
||||
upgrade: t("settings.upgrade"),
|
||||
hint: t("settings.hint"),
|
||||
failed: i18n.message("app.failed"),
|
||||
},
|
||||
// kit:knowledge:end
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the audit viewer
|
||||
|
||||
interface AuditRow {
|
||||
id: number
|
||||
action: string
|
||||
outcome: string
|
||||
occurred_at: string
|
||||
resource_type?: string | null
|
||||
resource_id?: string | null
|
||||
}
|
||||
|
||||
/** How many of this organization's events the viewer shows, and how far back it scans for them. */
|
||||
const PAGE_LIMIT = 200
|
||||
const MAX_PAGES = 25
|
||||
|
||||
const platformClient = (): ConusAIClient => {
|
||||
const { env } = webRuntime()
|
||||
return new ConusAIClient({
|
||||
baseUrl: env.deployment.CONUSAI_API_URL,
|
||||
apiKey: env.deployment.CONUSAI_API_TOKEN,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The platform's log is deployment-wide and pages newest-first with no server-side filter, so the
|
||||
* organization's rows are collected by walking pages until the viewer's own limit is filled or the
|
||||
* log ends -- a filter over the first page alone would show nothing once 200 newer events of other
|
||||
* organizations exist.
|
||||
*/
|
||||
async function organizationEvents(org: { slug: string; id: string }): Promise<AuditRow[]> {
|
||||
const platform = platformClient()
|
||||
const rows: AuditRow[] = []
|
||||
let cursor: string | null | undefined
|
||||
for (let page = 0; page < MAX_PAGES && rows.length < PAGE_LIMIT; page += 1) {
|
||||
const { data } = await platform.readEvents({
|
||||
query: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT },
|
||||
throwOnError: true,
|
||||
})
|
||||
for (const row of (data?.items ?? []) as AuditRow[]) {
|
||||
if (isOwnedBy(org, row.resource_id)) rows.push(row)
|
||||
}
|
||||
if (!data?.has_more || !data.next_cursor) break
|
||||
cursor = data.next_cursor
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
export interface AuditData {
|
||||
readonly title: string
|
||||
readonly empty: string
|
||||
readonly when: string
|
||||
readonly action: string
|
||||
readonly outcome: string
|
||||
readonly resource: string
|
||||
readonly rows: readonly {
|
||||
readonly id: number
|
||||
readonly when: string
|
||||
readonly action: string
|
||||
readonly outcome: string
|
||||
readonly resource: string
|
||||
}[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The audit viewer (spec §9.4), for whoever holds `tenancy.audit.read` (administrators, by default). It reads
|
||||
* the platform's audit log for this deployment with the deployment token, which carries `audit:read`, and
|
||||
* shows only the events of this organization: every write goes through one function that puts the
|
||||
* organization's slug in the event's target, and `isOwnedBy` is that rule read backwards.
|
||||
*/
|
||||
export async function auditData({ org }: OrgParams): Promise<AuditData> {
|
||||
const page = await pageContext()
|
||||
const { caller, i18n, path } = page
|
||||
await requirePermission(page, org, "tenancy.audit.read")
|
||||
// The id is what subscriber-written events carry.
|
||||
const organization = await orFallback(path, () => caller.call(orgGet, { org }))
|
||||
const { observe } = webRuntime()
|
||||
const rows = await organizationEvents({ slug: org, id: organization.orgId }).catch((error: unknown) => {
|
||||
// The platform being unreachable is not the viewer's failure to explain to a customer; it is
|
||||
// ours to know about, so it is logged, and the page says there is nothing to show.
|
||||
observe.log.warn(
|
||||
`audit viewer: the platform's log could not be read: ${error instanceof Error ? error.name : "failed"}`,
|
||||
)
|
||||
return []
|
||||
})
|
||||
return {
|
||||
title: i18n.message("app.audit"),
|
||||
empty: i18n.message("app.auditEmpty"),
|
||||
when: i18n.message("app.when"),
|
||||
action: i18n.message("app.action"),
|
||||
outcome: i18n.message("app.outcome"),
|
||||
resource: i18n.message("app.resource"),
|
||||
rows: rows.map((row) => ({
|
||||
id: row.id,
|
||||
when: i18n.date(new Date(row.occurred_at)),
|
||||
action: row.action,
|
||||
outcome: row.outcome,
|
||||
resource: row.resource_type ?? "",
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- kit-tenancy's access pages
|
||||
|
||||
/**
|
||||
* The check of an access section, in the route's own loader so that a member without its permission gets a
|
||||
* real 403 and a stranger a real 404 (`SECTION_PERMISSION`).
|
||||
*/
|
||||
export async function requireAccessSection({
|
||||
org,
|
||||
section,
|
||||
}: OrgParams & { readonly section: "members" | "roles" }): Promise<null> {
|
||||
const page = await pageContext()
|
||||
if (!page.signedIn) toSignIn(page.path)
|
||||
await orFallback(page.path, () => requireSection(adminContext(page, org), section))
|
||||
return null
|
||||
}
|
||||
|
||||
export type MembersData = Awaited<ReturnType<typeof membersPage.load>>
|
||||
export type RolesData = Awaited<ReturnType<typeof rolesPage.load>>
|
||||
export type RoleData = Awaited<ReturnType<typeof rolePage.load>>
|
||||
export type AccessAuditData = Awaited<ReturnType<typeof accessAuditPage.load>>
|
||||
|
||||
export interface MembersParams extends OrgParams {
|
||||
readonly workspace?: string
|
||||
readonly project?: string
|
||||
}
|
||||
|
||||
/** The members of the organization, of a workspace or of a project: every change is a procedure call. */
|
||||
export async function membersData({ org, workspace, project }: MembersParams): Promise<MembersData> {
|
||||
const page = await pageContext()
|
||||
const scope =
|
||||
workspace === undefined
|
||||
? ({ level: "org" } as const)
|
||||
: project === undefined
|
||||
? ({ level: "workspace", workspace } as const)
|
||||
: ({ level: "project", workspace, project } as const)
|
||||
return orFallback(page.path, () => membersPage.load(adminContext(page, org), scope))
|
||||
}
|
||||
|
||||
export async function rolesData({ org }: OrgParams): Promise<RolesData> {
|
||||
const page = await pageContext()
|
||||
return orFallback(page.path, () => rolesPage.load(adminContext(page, org)))
|
||||
}
|
||||
|
||||
export interface RoleParams extends OrgParams {
|
||||
readonly role: string
|
||||
}
|
||||
|
||||
export async function roleData({ org, role }: RoleParams): Promise<RoleData> {
|
||||
const page = await pageContext()
|
||||
return orFallback(page.path, () => rolePage.load(adminContext(page, org), role))
|
||||
}
|
||||
|
||||
export type InvitationsData = Awaited<ReturnType<typeof invitationsPage.load>>
|
||||
|
||||
/**
|
||||
* The organization's invitations: the invite form, the link it makes (with Copy and its expiry) and the pending
|
||||
* invitations, with a new link and revoke (kit-tenancy's access pages). The members page shows the same.
|
||||
*/
|
||||
export async function invitationsData({ org }: OrgParams): Promise<InvitationsData> {
|
||||
const page = await pageContext()
|
||||
if (!page.signedIn) toSignIn(page.path)
|
||||
const context = adminContext(page, org)
|
||||
await orFallback(page.path, () => requireSection(context, "invitations"))
|
||||
return orFallback(page.path, () => invitationsPage.load(context, { linkPath: `/invitations/${org}/` }))
|
||||
}
|
||||
|
||||
/**
|
||||
* The access audit: this organization's `tenancy.*` audit events -- roles created, changed and deleted,
|
||||
* members' roles changed, invitations made and revoked -- newest first (kit-tenancy's access pages). The
|
||||
* events are the ones the router records for every tenancy procedure that declares one, in the platform's
|
||||
* audit log of this deployment; they are read with the deployment token, which carries `audit:read`, and kept
|
||||
* to this organization by the same rule its writes follow and to the tenancy module by the action's prefix
|
||||
* (`collectAccessEvents`).
|
||||
*/
|
||||
export async function accessAuditData({ org }: OrgParams): Promise<AccessAuditData> {
|
||||
const page = await pageContext()
|
||||
const { caller, path } = page
|
||||
if (!page.signedIn) toSignIn(path)
|
||||
const context = adminContext(page, org)
|
||||
await orFallback(path, () => requireSection(context, "audit"))
|
||||
// The id is what subscriber-written events carry.
|
||||
const organization = await orFallback(path, () => caller.call(orgGet, { org }))
|
||||
const { observe } = webRuntime()
|
||||
const platform = platformClient()
|
||||
const events = await collectAccessEvents(
|
||||
async (cursor) => {
|
||||
const { data } = await platform.readEvents({
|
||||
query: cursor ? { limit: 200, cursor } : { limit: 200 },
|
||||
throwOnError: true,
|
||||
})
|
||||
return { items: data?.items ?? [], has_more: data?.has_more ?? false, next_cursor: data?.next_cursor ?? null }
|
||||
},
|
||||
(resourceId) => isOwnedBy({ slug: org, id: organization.orgId }, resourceId),
|
||||
).catch((error: unknown) => {
|
||||
// The platform being unreachable is ours to know about, not the viewer's to explain: it is
|
||||
// logged, and the page says there is nothing to show.
|
||||
observe.log.warn(
|
||||
`access audit: the platform's log could not be read: ${error instanceof Error ? error.name : "failed"}`,
|
||||
)
|
||||
return []
|
||||
})
|
||||
return orFallback(path, () => accessAuditPage.load(context, events))
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// What a route loader reads on the server (F10): the request's caller, its language and theme, its CSRF
|
||||
// token, and the shell's labels already translated. Server-only: a route reaches it through a server
|
||||
// function (`createServerFn`) whose handler imports this file, so neither `pg` nor a secret reaches the
|
||||
// browser's bundle.
|
||||
//
|
||||
// Every loader takes exactly the path a procedure takes -- the mount's own `requestContext` on the
|
||||
// request's own `Request` -- so a page is authorized as the API is (spec §6.4).
|
||||
//
|
||||
// React's `cache` memoizes nothing outside server components (M5), so the context is kept per request in a
|
||||
// WeakMap keyed by the `Request`: the root route and the page below it read the session row once.
|
||||
|
||||
import { appBrand, authBrand, brandTokenStyle, publicUrlOf } from "@app/config"
|
||||
import { ACCOUNT_PATH } from "@app/modules/account/contract"
|
||||
import { modulePages, workspacePages } from "@app/modules/pages"
|
||||
import { type Actor, AppError, type Container, type TransportCaller } from "@conusai-kit/core"
|
||||
import { csrfSessionId, csrfToken } from "@conusai-kit/host/http"
|
||||
import { createI18n, type I18n, uiLabels } from "@conusai-kit/i18n"
|
||||
import { projectGet } from "@conusai-kit/tenancy"
|
||||
import {
|
||||
allows,
|
||||
type PermissionKey,
|
||||
type PermissionScope,
|
||||
type PermissionSnapshot,
|
||||
readPermissions,
|
||||
} from "@conusai-kit/ui/data/server"
|
||||
import type { NavItem } from "@conusai-kit/ui/frame"
|
||||
import { getRequest } from "@tanstack/react-start/server"
|
||||
import type { ShellData } from "../ui/document"
|
||||
import { appCatalogues } from "../ui/messages"
|
||||
import { webRuntime } from "./app"
|
||||
import { orFallback, pageForbidden, pageNotFound, toSignIn } from "./guard"
|
||||
import { flagged, flagsOn } from "./nav-flags"
|
||||
import { organizationIdentity, organizationThemeStyle } from "./platform-theme"
|
||||
import { localeOf, panelWidthOf, sidebarOpenOf, type Theme, themeOf } from "./preferences"
|
||||
import { PROJECT_ENTRIES } from "./project-entries"
|
||||
import { requestContext } from "./request"
|
||||
import { mayCreateOrganization, organizationExists } from "./tenancy-calls"
|
||||
|
||||
export interface PageContext {
|
||||
readonly caller: TransportCaller
|
||||
readonly container: Container
|
||||
readonly signedIn: boolean
|
||||
readonly actor?: Actor
|
||||
/** The signed-in person, for the shell: a name to show, never an id. */
|
||||
readonly user?: { readonly email: string; readonly displayName: string; readonly locale: string }
|
||||
/** The organization this credential is limited to, when it is an organization API key. */
|
||||
readonly pinnedOrgId?: string
|
||||
/** Whether this person may found an organization here (APP_ORGANIZATION_SELF_SERVICE, the app-access role). */
|
||||
readonly mayCreateOrganization: boolean
|
||||
readonly csrf?: string
|
||||
readonly i18n: I18n
|
||||
/** The deployment's own language, for what belongs to everybody who joins later. */
|
||||
readonly deploymentI18n: I18n
|
||||
readonly theme: Theme
|
||||
/** The path and query of this request (src/server.ts's `x-path`): where to come back to after signing in. */
|
||||
readonly path: string
|
||||
/** What the browser needs to start observability, when the deployment turned it on. */
|
||||
readonly observe?: { readonly release: string; readonly environment: string }
|
||||
readonly nonce?: string
|
||||
}
|
||||
|
||||
/** A navigation item as a loader hands it to the frame: its label translated, its link absolute. */
|
||||
export type NavLink = NavItem
|
||||
|
||||
const contexts = new WeakMap<Request, Promise<PageContext>>()
|
||||
const permissions = new WeakMap<Request, Map<string, Promise<PermissionSnapshot>>>()
|
||||
|
||||
async function buildContext(request: Request): Promise<PageContext> {
|
||||
const incoming = request.headers
|
||||
const { caller, container, principal, user } = await requestContext(request)
|
||||
const { observe, selfServiceOrganizations, singleOrganization } = webRuntime()
|
||||
const locale = localeOf({
|
||||
appLocale: appBrand.defaultLocale,
|
||||
...(user ? { userLocale: user.locale } : {}),
|
||||
cookieHeader: incoming.get("cookie"),
|
||||
acceptLanguage: incoming.get("accept-language"),
|
||||
})
|
||||
const csrf = csrfSessionId(principal)
|
||||
return {
|
||||
caller,
|
||||
container,
|
||||
signedIn: principal !== null,
|
||||
...(principal ? { actor: principal.actor } : {}),
|
||||
...(user ? { user } : {}),
|
||||
...(principal?.orgId === undefined ? {} : { pinnedOrgId: principal.orgId }),
|
||||
mayCreateOrganization:
|
||||
mayCreateOrganization(principal, selfServiceOrganizations) &&
|
||||
!(singleOrganization && (await organizationExists(container))),
|
||||
...(csrf ? { csrf: csrfToken(csrf) } : {}),
|
||||
i18n: createI18n(locale, appCatalogues),
|
||||
deploymentI18n: createI18n(appBrand.defaultLocale, appCatalogues),
|
||||
theme: themeOf(incoming.get("cookie"), appBrand.defaultTheme),
|
||||
path: incoming.get("x-path") ?? "/",
|
||||
...(observe.config.errors
|
||||
? { observe: { release: observe.config.release, environment: observe.config.environment } }
|
||||
: {}),
|
||||
...(incoming.get("x-nonce") ? { nonce: incoming.get("x-nonce") as string } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** The request's page context, built once per request. `request` defaults to the one Start is serving. */
|
||||
export function pageContext(request: Request = getRequest()): Promise<PageContext> {
|
||||
let context = contexts.get(request)
|
||||
if (!context) {
|
||||
context = buildContext(request)
|
||||
contexts.set(request, context)
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller's permissions in a scope, through the procedure every client asks (`tenancy.me.permissions`),
|
||||
* read once per request and scope: a layout's loader and the page's share the answer, and a page hands it
|
||||
* to the browser as the seed of `PermissionsProvider`, so `useCan` answers on the first render.
|
||||
*
|
||||
* A page that is not a procedure -- the audit viewer -- still has to answer "may this person see it", and
|
||||
* this is the one way to ask: a person who is not a member gets the procedure's NotFound exactly as from any
|
||||
* other read (spec §3.3). Reflection only for a control: the procedure behind it checks its permission again.
|
||||
*/
|
||||
export function permissionsIn(scope: PermissionScope, request: Request = getRequest()): Promise<PermissionSnapshot> {
|
||||
let byScope = permissions.get(request)
|
||||
if (!byScope) {
|
||||
byScope = new Map()
|
||||
permissions.set(request, byScope)
|
||||
}
|
||||
const key = JSON.stringify(scope)
|
||||
let snapshot = byScope.get(key)
|
||||
if (!snapshot) {
|
||||
snapshot = pageContext(request).then(({ caller }) => readPermissions(caller, scope))
|
||||
byScope.set(key, snapshot)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/** Whether the signed-in person holds `permission` in the scope (false when signed out). */
|
||||
export async function holds(page: PageContext, scope: PermissionScope, permission: PermissionKey): Promise<boolean> {
|
||||
if (!page.actor) return false
|
||||
return allows(await permissionsIn(scope), permission)
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires a permission in an organization for a page that is not a procedure. A person who is not in
|
||||
* the organization gets the not-found page; a member without the permission gets the forbidden page.
|
||||
*/
|
||||
export async function requirePermission(page: PageContext, org: string, permission: PermissionKey): Promise<void> {
|
||||
if (!page.actor) toSignIn(page.path)
|
||||
const held = await orFallback(page.path, () => permissionsIn({ org }))
|
||||
if (!held.permissions.includes(permission)) pageForbidden()
|
||||
}
|
||||
|
||||
/**
|
||||
* The root route's loader data: the document's facts and every label the shell renders, translated here
|
||||
* (F10: labels are loader data built on the server). The brand's token style is computed here too, so the
|
||||
* browser's bundle never imports the app's config.
|
||||
*/
|
||||
export async function shellData(request: Request = getRequest()): Promise<ShellData> {
|
||||
const { i18n, signedIn, csrf, theme, observe, nonce } = await pageContext(request)
|
||||
const cookies = request.headers.get("cookie")
|
||||
const panelWidth = panelWidthOf(cookies)
|
||||
// The deployment's organization theme (BK12): the kit's defaults when the platform has none to give.
|
||||
const deployment = webRuntime().env.deployment
|
||||
const [organizationTheme, cloud] = await Promise.all([
|
||||
organizationThemeStyle(deployment),
|
||||
organizationIdentity(deployment),
|
||||
])
|
||||
const message = (key: string): string => i18n.message(key)
|
||||
return {
|
||||
// The organization's name and logo on Cloud, else the app's own.
|
||||
brand: {
|
||||
name: cloud.name ?? appBrand.name,
|
||||
shortName: appBrand.shortName,
|
||||
logo: {
|
||||
dark: cloud.logo ?? appBrand.logo.dark,
|
||||
light: cloud.logo ?? appBrand.logo.light,
|
||||
favicon: appBrand.logo.favicon,
|
||||
},
|
||||
style: brandTokenStyle(appBrand),
|
||||
},
|
||||
auth: authBrand(appBrand, i18n.locale),
|
||||
publicUrl: publicUrlOf(),
|
||||
locale: i18n.locale,
|
||||
theme,
|
||||
sidebarOpen: sidebarOpenOf(cookies),
|
||||
...(panelWidth === undefined ? {} : { panelWidth }),
|
||||
signedIn,
|
||||
...(csrf ? { csrf } : {}),
|
||||
...(observe ? { observe } : {}),
|
||||
...(nonce ? { nonce } : {}),
|
||||
...(organizationTheme ? { organizationTheme } : {}),
|
||||
accountHref: ACCOUNT_PATH,
|
||||
ui: uiLabels(i18n),
|
||||
labels: {
|
||||
signIn: message("app.signIn"),
|
||||
signOut: message("app.signOut"),
|
||||
account: message("account.link"),
|
||||
failed: message("app.failed"),
|
||||
language: message("shell.language"),
|
||||
languageEn: message("shell.languageEn"),
|
||||
languageLt: message("shell.languageLt"),
|
||||
theme: message("shell.theme"),
|
||||
themeDark: message("shell.themeDark"),
|
||||
themeLight: message("shell.themeLight"),
|
||||
themeSystem: message("shell.themeSystem"),
|
||||
switchToLight: message("shell.switchToLight"),
|
||||
switchToDark: message("shell.switchToDark"),
|
||||
loading: message("boundary.loading"),
|
||||
notFoundTitle: message("boundary.notFoundTitle"),
|
||||
notFoundBody: message("boundary.notFoundBody"),
|
||||
forbiddenTitle: message("boundary.forbiddenTitle"),
|
||||
forbiddenBody: message("boundary.forbiddenBody"),
|
||||
},
|
||||
help: {
|
||||
title: message("ui.help"),
|
||||
sections: message("shell.helpSections"),
|
||||
guides: message("shell.guides"),
|
||||
support: message("shell.support"),
|
||||
},
|
||||
privacy: {
|
||||
title: message("consent.title"),
|
||||
body: message("consent.body"),
|
||||
replay: message("consent.replay"),
|
||||
accept: message("consent.accept"),
|
||||
reject: message("consent.reject"),
|
||||
reopen: message("consent.reopen"),
|
||||
gpc: message("consent.gpc"),
|
||||
},
|
||||
boundary: {
|
||||
title: message("boundary.errorTitle"),
|
||||
body: message("boundary.errorBody"),
|
||||
code: message("boundary.errorCode"),
|
||||
retry: message("boundary.retry"),
|
||||
home: message("boundary.home"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** The module route's params: the project's slugs, the module's segment and the rest of the path. */
|
||||
export interface ModulePageParams {
|
||||
readonly org: string
|
||||
readonly workspace: string
|
||||
readonly project: string
|
||||
readonly module: string
|
||||
readonly _splat?: string
|
||||
/** The query string as the location has it (`?q=a`), read by the page's `load`. */
|
||||
readonly search?: string
|
||||
}
|
||||
|
||||
/** The query string by name, as `load` receives it. A repeated name keeps its last value. */
|
||||
const searchOf = (search: string | undefined): Readonly<Record<string, string>> =>
|
||||
Object.fromEntries(new URLSearchParams(search ?? ""))
|
||||
|
||||
/** The path below a module's segment, without empty parts. */
|
||||
const segmentsOf = (splat: string | undefined): readonly string[] =>
|
||||
(splat ?? "").split("/").filter((segment) => segment.length > 0)
|
||||
|
||||
/** Runs a page's `load`, answering its 404 and 403 as those pages. */
|
||||
const loaded = <Data>(run: Promise<Data>): Promise<Data> =>
|
||||
run.catch((error: unknown) => {
|
||||
if (error instanceof AppError && error.status === 404) pageNotFound()
|
||||
if (error instanceof AppError && error.status === 403) pageForbidden()
|
||||
throw error
|
||||
})
|
||||
|
||||
/** What every page under a project is rendered with: its scope and the person's permissions there. */
|
||||
export interface ProjectLayoutData {
|
||||
readonly scope: PermissionScope
|
||||
readonly seed: PermissionSnapshot
|
||||
/** The project as the frame shows it. */
|
||||
readonly project: { readonly id: string; readonly label: string }
|
||||
/** Its tabs: the modules' pages in a project this person may open, translated and absolute. */
|
||||
readonly tabs: readonly NavLink[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The project route's loader (`$org.$workspace.$project.tsx`). The project has to exist in this workspace of
|
||||
* this organization: a project of another tenant, an unknown slug and a project the person has no place in
|
||||
* are one 404 (`tenancy.project.get` resolves the whole path, each step a scoped read). The answer seeds the
|
||||
* browser's `useCan` and `<Can>` (`AppTransport`).
|
||||
*/
|
||||
export async function projectLayoutData(
|
||||
slugs: { readonly org: string; readonly workspace: string; readonly project: string },
|
||||
request: Request = getRequest(),
|
||||
): Promise<ProjectLayoutData> {
|
||||
const page = await pageContext(request)
|
||||
if (!page.signedIn) toSignIn(page.path)
|
||||
const scope = { org: slugs.org, workspace: slugs.workspace, project: slugs.project }
|
||||
const [project, seed] = await orFallback(page.path, () =>
|
||||
Promise.all([page.caller.call(projectGet, scope), permissionsIn(scope, request)]),
|
||||
)
|
||||
const base = `/${slugs.org}/${slugs.workspace}/${slugs.project}`
|
||||
const on = await orFallback(page.path, () => flagsOn(page.container, PROJECT_ENTRIES, scope))
|
||||
return {
|
||||
scope,
|
||||
seed,
|
||||
project: { id: slugs.project, label: project.name },
|
||||
tabs: [
|
||||
{ scope: "project", label: page.i18n.message("project.overview"), href: base, end: true, icon: "chart" },
|
||||
...flagged(PROJECT_ENTRIES, on)
|
||||
.filter((entry) => entry.permission === undefined || allows(seed, entry.permission as PermissionKey))
|
||||
.map((entry) => ({
|
||||
scope: "project" as const,
|
||||
label: page.i18n.message(entry.label),
|
||||
href: `${base}/${entry.href}`,
|
||||
...(entry.icon ? { icon: entry.icon } : {}),
|
||||
})),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/** The module route's loader data: the module's own `load` result and where it is. */
|
||||
export interface ModulePageData {
|
||||
readonly title?: string
|
||||
readonly module: string
|
||||
readonly segments: readonly string[]
|
||||
readonly locale: string
|
||||
/** The module's own data type, serializable by F10; the route only hands it to the module's view. */
|
||||
// biome-ignore lint/suspicious/noExplicitAny: each module's data type is its own
|
||||
readonly data: any
|
||||
}
|
||||
|
||||
function sectionTitle(
|
||||
i18n: PageContext["i18n"],
|
||||
scope: "workspace" | "project",
|
||||
module: string,
|
||||
segments: readonly string[],
|
||||
): string | undefined {
|
||||
const nav = webRuntime().registry.nav.filter((entry) => entry.scope === scope && entry.module === module)
|
||||
const path = [module, ...segments].join("/")
|
||||
const section = nav.find((entry) => entry.href === path) ?? nav.find((entry) => entry.href === module)
|
||||
return section ? `${i18n.message(section.label)} · ${appBrand.name}` : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The one module route's loader (`/<org>/<workspace>/<project>/<module>/...`), below the project route, whose
|
||||
* loader has the project check and the permission seed (`projectLayoutData`). A signed-out visitor is sent
|
||||
* to sign in (302). A segment no registered module answers, a path the module does not know and a record of
|
||||
* another tenant are the same not-found page; a missing permission is the forbidden page (403). Each status
|
||||
* is set before the render starts.
|
||||
*/
|
||||
export async function modulePageData(
|
||||
params: ModulePageParams,
|
||||
request: Request = getRequest(),
|
||||
): Promise<ModulePageData> {
|
||||
const page = await pageContext(request)
|
||||
if (!page.signedIn) toSignIn(page.path)
|
||||
const { module, _splat, search, ...slugs } = params
|
||||
if (!webRuntime().registry.modules.some((entry) => entry.id === module)) pageNotFound()
|
||||
// `Object.hasOwn`: a segment named `constructor` is not a module.
|
||||
const load = Object.hasOwn(modulePages, module) ? modulePages[module] : undefined
|
||||
if (!load) pageNotFound()
|
||||
const segments = segmentsOf(_splat)
|
||||
const definition = await load()
|
||||
const data = await loaded(
|
||||
definition.load({
|
||||
slugs,
|
||||
segments,
|
||||
search: searchOf(search),
|
||||
caller: page.caller,
|
||||
i18n: page.i18n,
|
||||
container: page.container,
|
||||
}),
|
||||
)
|
||||
const title = sectionTitle(page.i18n, "project", module, segments)
|
||||
return { module, segments, locale: page.i18n.locale, data, ...(title ? { title } : {}) }
|
||||
}
|
||||
|
||||
/** The route of a module's pages above a project: `/<org>/<workspace>/modules/<module>/...`. */
|
||||
export interface WorkspacePageParams {
|
||||
readonly org: string
|
||||
readonly workspace: string
|
||||
readonly module: string
|
||||
readonly _splat?: string
|
||||
readonly search?: string
|
||||
}
|
||||
|
||||
/** The workspace page's loader data: the module's own, and the seed of `useCan` in the workspace. */
|
||||
export interface WorkspacePageData extends ModulePageData {
|
||||
readonly scope: PermissionScope
|
||||
readonly seed: PermissionSnapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* The one route of the modules' pages above a project, below the workspace route. A signed-out visitor is sent
|
||||
* to sign in (302). A workspace of another organization, a segment no registered module answers and a path the
|
||||
* module does not know are one not-found page; a page's `permission` the person does not hold in the workspace
|
||||
* is the forbidden page, before `load` runs. Each status is set before the render starts.
|
||||
*/
|
||||
export async function workspacePageData(
|
||||
params: WorkspacePageParams,
|
||||
request: Request = getRequest(),
|
||||
): Promise<WorkspacePageData> {
|
||||
const page = await pageContext(request)
|
||||
if (!page.signedIn) toSignIn(page.path)
|
||||
const { module, _splat, search, ...slugs } = params
|
||||
if (!webRuntime().registry.modules.some((entry) => entry.id === module)) pageNotFound()
|
||||
// `Object.hasOwn`: a segment named `constructor` is not a module.
|
||||
const load = Object.hasOwn(workspacePages, module) ? workspacePages[module] : undefined
|
||||
if (!load) pageNotFound()
|
||||
const seed = await orFallback(page.path, () => permissionsIn(slugs, request))
|
||||
const definition = await load()
|
||||
if (definition.permission !== undefined && !allows(seed, definition.permission as PermissionKey)) pageForbidden()
|
||||
const segments = segmentsOf(_splat)
|
||||
const data = await loaded(
|
||||
definition.load({
|
||||
slugs,
|
||||
segments,
|
||||
search: searchOf(search),
|
||||
caller: page.caller,
|
||||
i18n: page.i18n,
|
||||
container: page.container,
|
||||
}),
|
||||
)
|
||||
const title = sectionTitle(page.i18n, "workspace", module, segments)
|
||||
return { module, segments, locale: page.i18n.locale, data, scope: slugs, seed, ...(title ? { title } : {}) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a loader and reports what it leaves unhandled, as Next's `onRequestError` did (Start has no such
|
||||
* hook, M5). A redirect and a not-found are control flow and pass through unreported (report.ts).
|
||||
*/
|
||||
export async function reported<T>(
|
||||
routePath: string,
|
||||
run: () => Promise<T>,
|
||||
request: Request = getRequest(),
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await run()
|
||||
} catch (error) {
|
||||
const { reportFailure } = await import("./report")
|
||||
reportFailure(error, request, { routePath, routeType: "loader" })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The organization's theme from the platform, read on the server for every page (brand-kit BK12). The
|
||||
// deployment's organization is the one its token belongs to; its branding is what the console saved,
|
||||
// and a change there applies to the next page without a rebuild. A server-side cache keeps the last
|
||||
// answer per organization and revalidates it with its ETag; no cookie and no browser storage holds it.
|
||||
//
|
||||
// An answer that is missing, refused, late or malformed gives the empty string, so the page keeps the
|
||||
// kit's defaults from the stylesheet. The platform's branding never blocks a page for longer than the
|
||||
// kit's timeout.
|
||||
|
||||
import { checkLogo, LOGO_LIMIT_BYTES } from "@conusai-kit/brand/logo/check"
|
||||
import {
|
||||
createPlatformThemeCache,
|
||||
fetchPlatformTheme,
|
||||
type PlatformTheme,
|
||||
type PlatformThemeCache,
|
||||
platformThemeCss,
|
||||
} from "@conusai-kit/brand/platform-theme"
|
||||
|
||||
/** The deployment contract fields the theme read needs. */
|
||||
export interface PlatformThemeDeployment {
|
||||
readonly CONUSAI_API_URL: string
|
||||
readonly CONUSAI_API_TOKEN: string
|
||||
readonly CONUSAI_ORG_SLUG?: string | undefined
|
||||
}
|
||||
|
||||
export interface PlatformThemeReaderOptions {
|
||||
readonly cache?: PlatformThemeCache
|
||||
readonly fetch?: typeof fetch
|
||||
}
|
||||
|
||||
/**
|
||||
* A reader of the deployment's organization theme as a stylesheet, with its own cache. The process
|
||||
* shares one reader (below); a test builds its own with a fake fetch.
|
||||
*/
|
||||
export function createPlatformThemeReader(options: PlatformThemeReaderOptions = {}) {
|
||||
const cache = options.cache ?? createPlatformThemeCache()
|
||||
return async (deployment: PlatformThemeDeployment): Promise<string> => {
|
||||
const orgSlug = deployment.CONUSAI_ORG_SLUG?.trim() ?? ""
|
||||
if (orgSlug === "") return ""
|
||||
const theme = await fetchPlatformTheme({
|
||||
apiUrl: deployment.CONUSAI_API_URL,
|
||||
token: deployment.CONUSAI_API_TOKEN,
|
||||
orgSlug,
|
||||
cache,
|
||||
...(options.fetch ? { fetch: options.fetch } : {}),
|
||||
})
|
||||
return theme ? platformThemeCss(theme) : ""
|
||||
}
|
||||
}
|
||||
|
||||
const themeCache = createPlatformThemeCache()
|
||||
const readOrganizationTheme = createPlatformThemeReader({ cache: themeCache })
|
||||
|
||||
/** The organization's theme roles, for the identity service's hosted pages; nothing without one. */
|
||||
export async function organizationThemeRoles(deployment: PlatformThemeDeployment): Promise<PlatformTheme | null> {
|
||||
const orgSlug = deployment.CONUSAI_ORG_SLUG?.trim() ?? ""
|
||||
if (orgSlug === "") return null
|
||||
return fetchPlatformTheme({
|
||||
apiUrl: deployment.CONUSAI_API_URL,
|
||||
token: deployment.CONUSAI_API_TOKEN,
|
||||
orgSlug,
|
||||
cache: themeCache,
|
||||
})
|
||||
}
|
||||
|
||||
/** The organization's theme as the page's stylesheet, or nothing when the page should keep the kit's defaults. */
|
||||
export function organizationThemeStyle(deployment: PlatformThemeDeployment): Promise<string> {
|
||||
return readOrganizationTheme(deployment)
|
||||
}
|
||||
|
||||
/** Where the app serves the organization's logo from (`api.brand.logo.ts`): the platform's address stays on the server. */
|
||||
export const ORGANIZATION_LOGO_PATH = "/api/brand/logo"
|
||||
|
||||
/**
|
||||
* The organization's name and logo on Cloud, which the app's own (`config/src/brand.ts`) stand in for. One read with
|
||||
* the theme's, from the same cache.
|
||||
*/
|
||||
export async function organizationIdentity(
|
||||
deployment: PlatformThemeDeployment,
|
||||
): Promise<{ readonly name?: string; readonly logo?: string }> {
|
||||
const theme = await organizationThemeRoles(deployment)
|
||||
return {
|
||||
...(theme?.name ? { name: theme.name } : {}),
|
||||
...(theme?.logoUrl ? { logo: ORGANIZATION_LOGO_PATH } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const LOGO_TTL_MS = 5 * 60_000
|
||||
let logo: { readonly url: string; readonly at: number; readonly type: string; readonly bytes: Uint8Array } | undefined
|
||||
|
||||
/**
|
||||
* The organization's logo, fetched on the server and checked as an uploaded logo is (`checkLogo`), or null. The
|
||||
* deployment token goes only to the platform's own origin; a logo elsewhere is fetched without it.
|
||||
*/
|
||||
export async function cloudOrganizationLogo(
|
||||
deployment: PlatformThemeDeployment,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<{ readonly type: string; readonly bytes: Uint8Array } | null> {
|
||||
const theme = await organizationThemeRoles(deployment)
|
||||
if (!theme?.logoUrl) return null
|
||||
const platform = new URL(deployment.CONUSAI_API_URL)
|
||||
const url = new URL(theme.logoUrl, platform.origin)
|
||||
if (logo && logo.url === url.href && Date.now() - logo.at < LOGO_TTL_MS) return logo
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(2000),
|
||||
headers: url.origin === platform.origin ? { authorization: `Bearer ${deployment.CONUSAI_API_TOKEN}` } : {},
|
||||
})
|
||||
if (!response.ok) return null
|
||||
const bytes = new Uint8Array(await response.arrayBuffer())
|
||||
if (bytes.byteLength > LOGO_LIMIT_BYTES) return null
|
||||
const type = response.headers.get("content-type")?.split(";")[0]?.trim() ?? ""
|
||||
if (!checkLogo(type, bytes).ok) return null
|
||||
logo = { url: url.href, at: Date.now(), type, bytes }
|
||||
return logo
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// biome-ignore-all assist/source/organizeImports: keep optional imports inside template prune markers.
|
||||
// The ports the web process binds, by token, from the platform clients (config/src/platform.ts) that
|
||||
// the worker's `bindModulePorts` is built from too. A module resolves a port and never imports an
|
||||
// adapter (spec §5.6), so what a procedure finds in a request is decided here.
|
||||
//
|
||||
// What a request does not get is on purpose: the audit and usage sinks of the files and proposals
|
||||
// modules, and the job scope. They belong to the outbox and the queue, which only the worker runs
|
||||
// (apps/worker/src/ports.ts); a request writes its audit event and its usage fact itself, in the
|
||||
// router, after the procedure has answered.
|
||||
|
||||
import {
|
||||
currentObservability,
|
||||
excludedPageClassNamesOf, // kit:documents
|
||||
type PlatformClients,
|
||||
} from "@app/config"
|
||||
import { moduleAdopted, moduleSelected } from "@app/modules"
|
||||
import { ACCOUNT_CREDENTIAL, ACCOUNT_LOG, ACCOUNT_PRIVACY, ACCOUNT_REGISTRY } from "@app/modules/account"
|
||||
import { AGENT_MODEL, gatewayAgentModel } from "@app/modules/agent" // kit:agent
|
||||
import * as documents from "@app/modules/documents" // kit:documents
|
||||
import * as knowledge from "@app/modules/knowledge" // kit:knowledge
|
||||
import type { Container, Registry } from "@conusai-kit/core"
|
||||
import * as data from "@conusai-kit/data"
|
||||
import {
|
||||
FILES_REGISTRY,
|
||||
filesDeps, // kit:knowledge
|
||||
platformObjectStore,
|
||||
} from "@conusai-kit/files"
|
||||
import { GUIDES_PLATFORM, GUIDES_REGISTRY, guidesPlatform } from "@conusai-kit/guides" // kit:guides
|
||||
import { REQUEST_CREDENTIAL } from "@conusai-kit/host/postgres"
|
||||
import {
|
||||
createKnowledge, // kit:knowledge
|
||||
createPrivacy,
|
||||
} from "@conusai-kit/platform"
|
||||
import { PROPOSALS_REGISTRY } from "@conusai-kit/proposals"
|
||||
import * as support from "@conusai-kit/support" // kit:support
|
||||
|
||||
// kit:support:begin
|
||||
type ProcessingScope = Parameters<support.SupportProcessingStatusReader["read"]>[0]
|
||||
|
||||
interface ProcessingCounts {
|
||||
readonly processing: number
|
||||
readonly extracted: number
|
||||
readonly failed: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's pipelines, each counting what a project has in process, for Support's processing status
|
||||
* (`support.projectProcessingStatus.get`). The documents module's extraction is the template's one; an app without
|
||||
* it has nothing in process, and the status says so ("empty").
|
||||
*/
|
||||
const PROCESSING_SOURCES: ReadonlyArray<(container: Container, scope: ProcessingScope) => Promise<ProcessingCounts>> = [
|
||||
// kit:documents:begin
|
||||
async (container, scope) => {
|
||||
if (!moduleSelected("documents")) return { processing: 0, extracted: 0, failed: 0 }
|
||||
const rows = await container
|
||||
.resolve(data.DATA_SOURCE)
|
||||
.forScope(scope)
|
||||
.of(documents.documents)
|
||||
.list({ limit: 1_000 })
|
||||
const count = (status: string): number => rows.items.filter((row) => row.status === status).length
|
||||
return { processing: count("processing"), extracted: count("extracted"), failed: count("failed") }
|
||||
},
|
||||
// kit:documents:end
|
||||
]
|
||||
|
||||
/** A project's processing status: the sum over the app's pipelines. */
|
||||
function processingStatus(container: Container): support.SupportProcessingStatusReader {
|
||||
return {
|
||||
read: async (scope) => {
|
||||
const counts = await Promise.all(PROCESSING_SOURCES.map((source) => source(container, scope)))
|
||||
const sum = (key: keyof ProcessingCounts): number => counts.reduce((total, each) => total + each[key], 0)
|
||||
const processing = sum("processing")
|
||||
const extracted = sum("extracted")
|
||||
const failed = sum("failed")
|
||||
const state = failed > 0 ? "failed" : processing > 0 ? "processing" : extracted > 0 ? "ready" : "empty"
|
||||
return { state, processing, extracted, failed }
|
||||
},
|
||||
}
|
||||
}
|
||||
// kit:support:end
|
||||
|
||||
export function bindWebPorts(container: Container, platform: PlatformClients, registry: Registry): void {
|
||||
// Blob, the AI gateway and OCR. A procedure that needs a short model call or an OCR read inside a
|
||||
// request finds them here exactly as a job finds them in the worker. Long work is still a job
|
||||
// (AGENTS.md): this is the capability, not a licence to wait on it.
|
||||
container.value(data.OBJECT_STORE, platformObjectStore({ blob: platform.objectStore }))
|
||||
if (moduleSelected("documents")) {
|
||||
// kit:documents:begin
|
||||
container.value(documents.MODEL_GATEWAY, platform.models)
|
||||
container.value(documents.DOCUMENT_MODEL, platform.model)
|
||||
const excluded = excludedPageClassNamesOf()
|
||||
if (excluded) container.value(documents.DOCUMENT_EXCLUDED_PAGE_CLASSES, documents.pageClassesOf(excluded))
|
||||
container.value(documents.DOCUMENT_OCR, documents.sdkDocumentOcr(platform.documents))
|
||||
// kit:documents:end
|
||||
}
|
||||
if (moduleSelected("agent")) {
|
||||
// kit:agent:begin
|
||||
// The model of the Agent in the right panel: the AI gateway's, so metering and the spend cap stay there. A turn
|
||||
// streams to the person from this process, so the worker's binding does not reach it.
|
||||
container.value(
|
||||
AGENT_MODEL,
|
||||
gatewayAgentModel({
|
||||
apiUrl: platform.deployment.CONUSAI_API_URL,
|
||||
token: platform.deployment.CONUSAI_API_TOKEN,
|
||||
model: platform.model,
|
||||
}),
|
||||
)
|
||||
// kit:agent:end
|
||||
}
|
||||
// The registry the files and proposals modules read their extension points from.
|
||||
container.value(FILES_REGISTRY, registry)
|
||||
container.value(PROPOSALS_REGISTRY, registry)
|
||||
// The registry the account module reads every installed module's share of an account from.
|
||||
container.value(ACCOUNT_REGISTRY, registry)
|
||||
// The request's credential under the account module's own name for it: the host binds it per request.
|
||||
container.register(ACCOUNT_CREDENTIAL, (c) => c.resolve(REQUEST_CREDENTIAL), { lifetime: "scoped" })
|
||||
// Why a module's share of an export failed: the operator's, redacted like every other line.
|
||||
container.value(ACCOUNT_LOG, { warn: (message) => currentObservability()?.log.warn(message) })
|
||||
// The account page's platform analytics erasure and export (ADR-049): the deployment token
|
||||
// (`privacy:subjects` in conusai.json) on the server only, and the same token as the secret the
|
||||
// module derives its ticket MAC key from (HKDF, a purpose-specific label: the token itself never
|
||||
// keys a MAC; a rotated token makes open tickets NotFound and the page asks again). The fake
|
||||
// platform answers the same routes, so an export is complete offline too.
|
||||
const privacy = createPrivacy(platform.deployment)
|
||||
container.value(ACCOUNT_PRIVACY, { ...privacy, ticketKey: platform.deployment.CONUSAI_API_TOKEN })
|
||||
|
||||
if (moduleSelected("knowledge")) {
|
||||
// kit:knowledge:begin
|
||||
// The knowledge module: the platform's knowledge service a request may call, and the enablement
|
||||
// platform the settings procedures check against. The index/remove jobs stay in the worker; a request
|
||||
// only writes the outbox event. Lazy: the files deps resolve the request container's data source.
|
||||
container.register(knowledge.KNOWLEDGE_SEARCH, (c) =>
|
||||
knowledge.platformKnowledgeSearch(createKnowledge(platform.deployment), filesDeps(c), {
|
||||
environment: platform.deployment.CONUSAI_ENVIRONMENT,
|
||||
}),
|
||||
)
|
||||
// What the status read asks: a read may not resolve KNOWLEDGE_SEARCH.
|
||||
container.register(knowledge.KNOWLEDGE_READINESS, () =>
|
||||
knowledge.platformKnowledgeReadiness(createKnowledge(platform.deployment)),
|
||||
)
|
||||
container.value(knowledge.KNOWLEDGE_ENABLEMENT, knowledgeEnablement(platform))
|
||||
// kit:knowledge:end
|
||||
}
|
||||
if (moduleAdopted("guides")) {
|
||||
// kit:guides:begin -- the guides module: the platform's Guides runtime with the deployment token
|
||||
// (`guides:runtime` in conusai.json), or the offline mode of the fake platform (spec §7.3).
|
||||
container.value(GUIDES_REGISTRY, registry)
|
||||
container.register(GUIDES_PLATFORM, () =>
|
||||
guidesPlatform({
|
||||
apiUrl: platform.deployment.CONUSAI_API_URL,
|
||||
token: platform.deployment.CONUSAI_API_TOKEN,
|
||||
fake: platform.mode === "fake",
|
||||
}),
|
||||
)
|
||||
// kit:guides:end
|
||||
}
|
||||
if (moduleAdopted("support")) {
|
||||
// kit:support:begin -- the support module: widget sessions minted with the deployment token
|
||||
// (`support:widget-sessions` in conusai.json) for the channel `CONUSAI_SUPPORT_CHANNEL` names; the fake
|
||||
// platform has no Support service, so the launcher stays away there.
|
||||
container.register(support.SUPPORT_PROCESSING_STATUS, (c) => processingStatus(c))
|
||||
container.register(support.SUPPORT_PLATFORM, () =>
|
||||
support.supportPlatform({
|
||||
apiUrl: platform.deployment.CONUSAI_API_URL,
|
||||
token: platform.deployment.CONUSAI_API_TOKEN,
|
||||
channel: process.env.CONUSAI_SUPPORT_CHANNEL,
|
||||
fake: platform.mode === "fake",
|
||||
}),
|
||||
)
|
||||
// kit:support:end
|
||||
}
|
||||
}
|
||||
|
||||
// kit:knowledge:begin
|
||||
function knowledgeEnablement(platform: PlatformClients) {
|
||||
// The enablement flow runs against the platform's own APIs with the deployment credentials.
|
||||
return knowledge.sdkDocumentAiPlatform({
|
||||
apiUrl: platform.deployment.CONUSAI_API_URL,
|
||||
token: platform.deployment.CONUSAI_API_TOKEN,
|
||||
})
|
||||
}
|
||||
// kit:knowledge:end
|
||||
|
||||
// Read the server-selected runtime ports at authorized dispatch, never browser input.
|
||||
export const nativeHelpSelectors = {
|
||||
// kit:support:begin
|
||||
supportChannel: (container: Container): string | undefined => {
|
||||
if (!container.has(support.SUPPORT_PLATFORM)) return undefined
|
||||
const port = container.resolve(support.SUPPORT_PLATFORM)
|
||||
return port.mode === "platform" ? port.channel : undefined
|
||||
},
|
||||
// kit:support:end
|
||||
// kit:guides:begin
|
||||
guidesIdentityNamespace: (container: Container): string | undefined => {
|
||||
if (!container.has(GUIDES_PLATFORM)) return undefined
|
||||
const port = container.resolve(GUIDES_PLATFORM)
|
||||
return port.mode === "platform" ? port.identityNamespace : undefined
|
||||
},
|
||||
// kit:guides:end
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The choices a person makes about how the app looks to them: the language, the theme and the frame's layout.
|
||||
//
|
||||
// language A signed-in person's choice is their own setting, kept on their user row
|
||||
// (`tenancy.me.update`) so it follows them to every browser; a visitor's choice is a
|
||||
// cookie. Precedence: the person's setting, then the browser's Accept-Language when it
|
||||
// names a language the app serves, then the app's default -- and for a visitor the
|
||||
// cookie takes the place of the person's setting. An organization's default language
|
||||
// reaches a person once, as their own setting, when they join (`organization-language.ts`).
|
||||
// theme A cookie, because it is about this screen. The server reads it, so the first paint
|
||||
// is already in the chosen theme and nothing flashes. `system` follows the device: only the
|
||||
// browser knows that, so a script in the head sets the class before the first paint.
|
||||
// layout Two cookies, for the same reason: the first paint has the sidebar as it was left and the
|
||||
// right panel at its width. The kit stores nothing; the frame tells the host (`frame-layout.ts`).
|
||||
// workspace The workspace the person was last in, per organization: an organization's own page (Settings) is
|
||||
// shown in it, its sidebar and its breadcrumb, as the reference screen does.
|
||||
//
|
||||
// No cookie holds anything but a short fixed word or a number, so none needs to be signed or sealed.
|
||||
|
||||
import { type Locale, negotiateLocale } from "@conusai-kit/i18n"
|
||||
import {
|
||||
LOCALE_COOKIE,
|
||||
PANEL_WIDTH_COOKIE,
|
||||
SIDEBAR_COOKIE,
|
||||
THEME_COOKIE,
|
||||
type Theme,
|
||||
WORKSPACE_COOKIE,
|
||||
} from "../ui/preference-cookies"
|
||||
|
||||
export type { Theme }
|
||||
|
||||
export const LOCALES: readonly Locale[] = ["en", "lt"]
|
||||
|
||||
/** One cookie's value out of a `Cookie` header. */
|
||||
export function cookieValue(header: string | null | undefined, name: string): string | undefined {
|
||||
for (const part of (header ?? "").split(";")) {
|
||||
const [key, ...rest] = part.trim().split("=")
|
||||
if (key === name) {
|
||||
try {
|
||||
return decodeURIComponent(rest.join("="))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** The theme the cookie names; anything else is the brand's default, and the brand is dark-first. */
|
||||
export function themeOf(cookieHeader: string | null | undefined, fallback: Theme = "dark"): Theme {
|
||||
const theme = cookieValue(cookieHeader, THEME_COOKIE)
|
||||
return theme === "light" || theme === "system" || theme === "dark" ? theme : fallback
|
||||
}
|
||||
|
||||
/** The sidebar, open unless the person closed it. */
|
||||
export function sidebarOpenOf(cookieHeader: string | null | undefined): boolean {
|
||||
return cookieValue(cookieHeader, SIDEBAR_COOKIE) !== "closed"
|
||||
}
|
||||
|
||||
/** The right panel's width, inside what the panel can take (320 to 640 px); anything else is its default. */
|
||||
export function panelWidthOf(cookieHeader: string | null | undefined): number | undefined {
|
||||
const width = Number(cookieValue(cookieHeader, PANEL_WIDTH_COOKIE))
|
||||
return Number.isInteger(width) && width >= 320 && width <= 640 ? width : undefined
|
||||
}
|
||||
|
||||
/** The slug of the workspace the person was last in, in this organization; a cookie of another organization is none. */
|
||||
export function lastWorkspaceOf(cookieHeader: string | null | undefined, org: string): string | undefined {
|
||||
const [organization, workspace, ...rest] = (cookieValue(cookieHeader, WORKSPACE_COOKIE) ?? "").split("/")
|
||||
return organization === org && workspace && rest.length === 0 ? workspace : undefined
|
||||
}
|
||||
|
||||
export interface LocaleInputs {
|
||||
/** The signed-in person's own setting (their user row). */
|
||||
readonly userLocale?: string
|
||||
readonly cookieHeader?: string | null
|
||||
readonly acceptLanguage?: string | null
|
||||
/** The app's default (`appBrand.defaultLocale`). */
|
||||
readonly appLocale: string
|
||||
}
|
||||
|
||||
/** The language a page is rendered in. */
|
||||
export function localeOf(inputs: LocaleInputs): Locale {
|
||||
const chosen = inputs.userLocale ?? cookieValue(inputs.cookieHeader, LOCALE_COOKIE)
|
||||
// The app's default is the browser's least wanted language, so it is the answer only when the browser names none the
|
||||
// app serves: the kit's negotiation reads the header by its own weights.
|
||||
const acceptLanguage = [inputs.acceptLanguage, `${inputs.appLocale};q=0.001`].filter(Boolean).join(",")
|
||||
return negotiateLocale({ ...(chosen ? { userLocale: chosen } : {}), acceptLanguage })
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// What a project offers, in one place: the pages of the modules, as the organization home, the
|
||||
// workspace page and the project home all list them (spec §3.1, §4.1). Server-only: it reads the manifests,
|
||||
// so a page's loader hands the entries to its view (F10).
|
||||
//
|
||||
// A module with pages under a project says so in its manifest (`ui.nav`, scope `project`) and
|
||||
// `modules/pages.ts` mounts them (`modulePages`); nothing here is edited to add one. The documents
|
||||
// module is the exception that proves the rule: its two pages are routes of the shell
|
||||
// (`src/routes/$org.$workspace.$project.documents.*`), so the shell lists it by name.
|
||||
|
||||
import { PROFILE } from "@app/config"
|
||||
import { appModules } from "@app/modules"
|
||||
import { moduleSelected } from "@app/modules/inclusion" // kit:documents
|
||||
import type { Profile } from "@conusai-kit/platform"
|
||||
import type { ProjectEntry } from "../ui/projects"
|
||||
|
||||
/**
|
||||
* Documents, then every project-level entry the manifests declare, by their `order` (core-5): lower first across
|
||||
* modules, equal or absent ones (Documents among them, at 0) in this order.
|
||||
*/
|
||||
export function projectEntriesFor(profile: Profile): readonly ProjectEntry[] {
|
||||
return [
|
||||
...(moduleSelected("documents") ? [{ href: "documents", label: "documents.title", module: "documents" }] : []), // kit:documents
|
||||
...appModules.flatMap((module) =>
|
||||
(module.ui?.nav ?? [])
|
||||
.filter((item) => item.scope === "project")
|
||||
.filter(() => profile !== "convex" || ["notes", "files"].includes(module.id))
|
||||
.map((item) => ({
|
||||
href: item.href,
|
||||
label: item.label,
|
||||
module: module.id,
|
||||
...(item.icon ? { icon: item.icon } : {}),
|
||||
...(item.permission === undefined ? {} : { permission: item.permission }),
|
||||
...(item.order === undefined ? {} : { order: item.order }),
|
||||
...(item.flag === undefined ? {} : { flag: item.flag }),
|
||||
})),
|
||||
),
|
||||
].sort((a: ProjectEntry, b: ProjectEntry) => (a.order ?? 0) - (b.order ?? 0))
|
||||
}
|
||||
|
||||
export const PROJECT_ENTRIES: readonly ProjectEntry[] = projectEntriesFor(PROFILE)
|
||||
|
||||
/** The values a project's `moduleKey` can take when it is created from the shell. */
|
||||
const moduleKeys = [...new Set(PROJECT_ENTRIES.map((entry) => entry.module))]
|
||||
export const PROJECT_MODULE_KEYS: readonly string[] = moduleKeys.length ? moduleKeys : ["general"]
|
||||
@@ -0,0 +1,15 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// An error a page's loader or the Start handler left unhandled, sent to observability by
|
||||
// `@conusai-sdk/observe-start/report`. Start has no `onRequestError` hook (M5), so the two places that see such
|
||||
// an error call this: `reported` (src/server/page.ts) for a loader, and the server entry (src/server.ts) for
|
||||
// anything that escapes the handler. A redirect and a not-found the app raised on purpose (`redirect()`,
|
||||
// `notFound()`, the forbidden page of guard.ts) are control flow, not errors, and the report leaves them out.
|
||||
|
||||
import { currentObservability } from "@app/config"
|
||||
import { type RequestErrorContext, reportRequestError } from "@conusai-sdk/observe-start/report"
|
||||
|
||||
/** Through the process's redacting `observe` handle; a process that never started one reports nothing. */
|
||||
export function reportFailure(error: unknown, request: Request, context: RequestErrorContext): void {
|
||||
const observe = currentObservability()
|
||||
if (observe) reportRequestError(error, request, context, observe)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// What one request gets: its principal, its container and its caller (spec §6.2). On Postgres and the
|
||||
// one-login profiles that is the host's `kernelRequestContext`; on Convex the procedures run in the
|
||||
// deployment, so the caller is the deployment's, with the same credential and the same tenancy rules.
|
||||
|
||||
import {
|
||||
type AuthCheckOptions,
|
||||
authenticateRequest, // kit:profile-convex
|
||||
sessionRefKey, // kit:profile-convex
|
||||
} from "@conusai-kit/auth"
|
||||
import type { TransportCaller } from "@conusai-kit/core"
|
||||
import {
|
||||
credentialOf, // kit:profile-convex
|
||||
hostTenancyCalls, // kit:profile-convex
|
||||
type KernelRequestContext,
|
||||
} from "@conusai-kit/host/postgres"
|
||||
import type { UserIdentityAttributes } from "convex/server"
|
||||
import {
|
||||
appSession, // kit:profile-convex
|
||||
authRoutes, // kit:profile-convex
|
||||
hostSignIn, // kit:profile-convex
|
||||
kernelContext,
|
||||
rolesChecked, // kit:profile-convex
|
||||
webRuntime, // kit:profile-convex
|
||||
} from "./app"
|
||||
import { convexIdentity, createConvexCaller } from "./convex-caller" // kit:profile-convex
|
||||
import { streamedOrgId } from "./live-grants" // kit:profile-convex
|
||||
import { supportOboCaller } from "./support-obo" // kit:support
|
||||
|
||||
export interface RequestContext extends KernelRequestContext {
|
||||
/** The Convex profile: the identity this request's Convex calls carry (convex-caller.ts `convexIdentity`). */
|
||||
readonly convexIdentity?: UserIdentityAttributes
|
||||
}
|
||||
|
||||
export async function requestContext(
|
||||
request: Request,
|
||||
requestId?: string,
|
||||
authCheck: AuthCheckOptions = {},
|
||||
): Promise<RequestContext> {
|
||||
// kit:profile-convex:begin
|
||||
const current = webRuntime()
|
||||
if (current.convex) return convexRequestContext(current, current.convex, request, requestId, authCheck)
|
||||
// kit:profile-convex:end
|
||||
const context = await kernelContext()(request, requestId, authCheck)
|
||||
const obo = await supportOboCaller(request, context.container) // kit:support
|
||||
if (obo) return { ...context, caller: obo } // kit:support
|
||||
return context
|
||||
}
|
||||
|
||||
// kit:profile-convex:begin
|
||||
async function convexRequestContext(
|
||||
current: ReturnType<typeof webRuntime>,
|
||||
convex: NonNullable<ReturnType<typeof webRuntime>["convex"]>,
|
||||
request: Request,
|
||||
requestId: string | undefined,
|
||||
authCheck: AuthCheckOptions,
|
||||
): Promise<RequestContext> {
|
||||
const auth = await authRoutes()
|
||||
// API credentials come first. A deliberately invalid or empty bearer throws here and can never
|
||||
// fall back to an ambient cookie; only a request with no API credential uses the selected
|
||||
// session adapter, and a session whose principal does not match it fails closed.
|
||||
const { principal, session } = await authenticateRequest(
|
||||
request,
|
||||
{ credentials: auth, session: await appSession() },
|
||||
authCheck,
|
||||
)
|
||||
|
||||
await rolesChecked()
|
||||
const correlationId = requestId ?? request.headers.get("x-request-id") ?? globalThis.crypto.randomUUID()
|
||||
const identity = convexIdentity({
|
||||
principal,
|
||||
credential: credentialOf(principal, session),
|
||||
// The device session a derived service session is minted for, so its sign-out ends it.
|
||||
...(session ? { identitySession: sessionRefKey(session.sessionRef) } : {}),
|
||||
...(session ? { user: { email: session.profile.email, displayName: session.profile.displayName } } : {}),
|
||||
})
|
||||
const container = current.root.child()
|
||||
return {
|
||||
principal,
|
||||
container,
|
||||
// The tenancy rules around the deployment's caller; a grant change the write made there is
|
||||
// announced to this process's own open streams of the organization.
|
||||
caller: hostTenancyCalls(
|
||||
current,
|
||||
hostSignIn(),
|
||||
principal,
|
||||
container,
|
||||
createConvexCaller({
|
||||
...convex,
|
||||
principal,
|
||||
objectStore: current.platform.objectStore,
|
||||
...(identity ? { identity } : {}),
|
||||
}),
|
||||
{ orgIdOf: streamedOrgId },
|
||||
),
|
||||
...(identity ? { convexIdentity: identity } : {}),
|
||||
correlationId,
|
||||
...(session
|
||||
? {
|
||||
user: {
|
||||
email: session.profile.email,
|
||||
displayName: session.profile.displayName,
|
||||
locale: session.profile.locale,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
// kit:profile-convex:end
|
||||
|
||||
/** The caller for a route's loader: the same path a route handler takes. */
|
||||
export async function callerFor(request: Request): Promise<TransportCaller> {
|
||||
return (await requestContext(request)).caller
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// This app's HTTP mount: `@conusai-kit/host/http`, given what only this app knows. The routes, CSRF,
|
||||
// the rate limit, `ConusAI-Txid`, the span, the audit event and the usage meter are the package's; what
|
||||
// is here is how this app is composed, which profile it runs on and what it reports as ready.
|
||||
|
||||
import { appBrand, appRoot, expectedDialectMigrations, PROFILE, probePlatform } from "@app/config"
|
||||
import { moduleSelected } from "@app/modules/inclusion"
|
||||
import { isInteractiveSession } from "@conusai-kit/auth" // kit:support
|
||||
import { AppError } from "@conusai-kit/core" // kit:support
|
||||
import {
|
||||
checkReadiness,
|
||||
type HostReadiness,
|
||||
hostHandler,
|
||||
limitFor, // kit:support
|
||||
problemResponse, // kit:support
|
||||
requireCsrf, // kit:support
|
||||
take, // kit:support
|
||||
} from "@conusai-kit/host/http"
|
||||
import { createSupportProxy } from "@conusai-kit/support/proxy" // kit:support
|
||||
import {
|
||||
convexCapabilityStatus, // kit:profile-convex
|
||||
postgresHost,
|
||||
webRuntime,
|
||||
} from "./app"
|
||||
import { serveLive } from "./live"
|
||||
import { requestContext } from "./request"
|
||||
|
||||
const NOT_READY = { ready: false, checks: { store: false, reader: false, migrations: false, platform: false } }
|
||||
|
||||
/**
|
||||
* `/api/ready`: the store, the reader and the migration level are there, and the platform API is
|
||||
* reported (`checkReadiness` says why it is reported rather than required, and why the answer carries
|
||||
* no reason). On Postgres it is the host's own; the other profiles answer the same shape here.
|
||||
*/
|
||||
async function readiness(): Promise<HostReadiness> {
|
||||
if (PROFILE === "postgres") return postgresHost().readiness()
|
||||
const { env, registry, observe, store } = webRuntime()
|
||||
if (PROFILE === "mariadb" || PROFILE === "mongodb") {
|
||||
if (!store) return NOT_READY
|
||||
const profile = PROFILE
|
||||
// One login: the reader check is the store's own, there being no second role to prove.
|
||||
return checkReadiness({
|
||||
store: () => store.store(),
|
||||
reader: () => store.store(),
|
||||
applied: () => store.applied(),
|
||||
expected: () => expectedDialectMigrations(registry, profile, appRoot()),
|
||||
platform: () => probePlatform(env.deployment),
|
||||
warn: (message) => observe.log.warn(message),
|
||||
})
|
||||
}
|
||||
// kit:profile-convex:begin
|
||||
if (PROFILE === "convex") {
|
||||
const capabilities = await convexCapabilityStatus()
|
||||
const platform = await probePlatform(env.deployment)
|
||||
return { ready: capabilities.ready, checks: { ...capabilities, platform } }
|
||||
}
|
||||
// kit:profile-convex:end
|
||||
return NOT_READY
|
||||
}
|
||||
|
||||
// kit:support:begin -- the support widget's same-origin proxy (joint contract §12, @conusai-kit/support):
|
||||
// the widget's contact requests and its event stream reach the platform through this origin, so the
|
||||
// page keeps `connect-src 'self'` and `frame-src 'none'`. Only a member this app signed in passes
|
||||
// (the session cookie, never an API key, and CSRF on a POST); the widget's own bearer is forwarded.
|
||||
let supportProxy: ((request: Request) => Promise<Response>) | undefined
|
||||
function serveSupport(request: Request, requestId: string): Promise<Response> {
|
||||
const { platform } = webRuntime()
|
||||
if (!moduleSelected("support") || platform.mode === "fake")
|
||||
return Promise.resolve(
|
||||
problemResponse(
|
||||
new AppError({
|
||||
type: "/problems/not_found",
|
||||
title: "Not found",
|
||||
status: 404,
|
||||
code: "not_found",
|
||||
detail: "no procedure support",
|
||||
}),
|
||||
),
|
||||
)
|
||||
supportProxy ??= createSupportProxy({
|
||||
apiUrl: platform.deployment.CONUSAI_API_URL,
|
||||
prefix: "/api/support",
|
||||
authorize: async (admission) => {
|
||||
const { principal } = await requestContext(admission, requestId)
|
||||
if (!principal || !isInteractiveSession(principal))
|
||||
throw new AppError({
|
||||
type: "/problems/unauthenticated",
|
||||
title: "Unauthenticated",
|
||||
status: 401,
|
||||
code: "unauthenticated",
|
||||
})
|
||||
requireCsrf(admission, principal)
|
||||
const limit = limitFor({ kind: "effect", scope: "user", rateLimit: "support" }, principal)
|
||||
take(limit.key, limit.bucket)
|
||||
},
|
||||
refuse: problemResponse,
|
||||
})
|
||||
return supportProxy(request)
|
||||
}
|
||||
|
||||
// kit:support:end
|
||||
export const handle = hostHandler({
|
||||
runtime: webRuntime,
|
||||
requestContext,
|
||||
readiness,
|
||||
openapiTitle: `${appBrand.name} API`,
|
||||
live: serveLive,
|
||||
support: serveSupport, // kit:support
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The shape of a one-login store (the mariadb and mongodb profiles, dialect/): what app.ts keeps of it
|
||||
// and what `/api/ready` asks of it. Types only, so the composition root and the engines meet here.
|
||||
|
||||
import type { DataSource, SystemDataSource } from "@conusai-kit/data"
|
||||
import type { LiveLog } from "@conusai-kit/live-server"
|
||||
|
||||
/** A data source of the mariadb or mongodb profile: what the request container and the kit's ports need. */
|
||||
export type DialectDataSource = DataSource & SystemDataSource & { close(): Promise<void> }
|
||||
|
||||
/** The readiness probes of a one-login store (router.ts `/api/ready`): its query and its recorded migrations. */
|
||||
export interface StoreProbes {
|
||||
store(): Promise<unknown>
|
||||
applied(): Promise<ReadonlySet<string>>
|
||||
/** Resolves once the store is proven usable at boot (MongoDB: a replica set, AT-D15). */
|
||||
boot(): Promise<void>
|
||||
}
|
||||
|
||||
/** One engine's data source and probes, over the URL of its profile. */
|
||||
export interface EngineStore {
|
||||
readonly data: DialectDataSource
|
||||
readonly probes: StoreProbes
|
||||
/** The engine's live log (mariadb, mongodb); built once, on the first stream. */
|
||||
readonly liveLog?: () => LiveLog
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Support's OBO-only MCP caller. Every other request and every non-MCP route
|
||||
// uses the ordinary session adapter in request.ts.
|
||||
|
||||
import type { Container, TransportCaller } from "@conusai-kit/core"
|
||||
import { createSupportCallerResolver, createSupportToolCaller, ON_BEHALF_OF_HEADER } from "@conusai-kit/support"
|
||||
import { createTenancyHooks } from "@conusai-kit/tenancy"
|
||||
import { createOboVerifier, InMemoryReplayStore } from "@conusai-sdk/support"
|
||||
import { webRuntime } from "./app"
|
||||
|
||||
// Request-local verifiers must share replay protection across this web process.
|
||||
// Multiple web replicas require a shared atomic ReplayStore (see the SDK contract).
|
||||
const replayStore = new InMemoryReplayStore()
|
||||
|
||||
export async function supportOboCaller(request: Request, container: Container): Promise<TransportCaller | null> {
|
||||
if (new URL(request.url).pathname !== "/api/mcp" || !request.headers.has(ON_BEHALF_OF_HEADER)) return null
|
||||
const runtime = webRuntime()
|
||||
if (!runtime.data) return null
|
||||
const deployment = runtime.env.deployment
|
||||
const issuer = deployment.CONUSAI_API_URL.replace(/\/api\/?$/, "")
|
||||
const resolve = createSupportCallerResolver({
|
||||
verify: createOboVerifier({
|
||||
jwksUrl: `${issuer}/.well-known/conusai-obo-jwks.json`,
|
||||
audience: deployment.CONUSAI_PUBLIC_URL ?? "",
|
||||
issuer,
|
||||
identityNamespace: "app",
|
||||
organization: deployment.CONUSAI_ORG_ID ?? "",
|
||||
project: deployment.CONUSAI_PROJECT_ID,
|
||||
environment: deployment.CONUSAI_ENVIRONMENT_ID ?? "",
|
||||
replayStore,
|
||||
}),
|
||||
organization: deployment.CONUSAI_ORG_ID ?? "",
|
||||
project: deployment.CONUSAI_PROJECT_ID,
|
||||
environment: deployment.CONUSAI_ENVIRONMENT_ID ?? "",
|
||||
resolveMember: async (delegation) => ({
|
||||
organizationId: delegation.appTenant ?? deployment.CONUSAI_ORG_ID ?? "",
|
||||
userId: delegation.subject,
|
||||
}),
|
||||
})
|
||||
return createSupportToolCaller(
|
||||
{
|
||||
registry: runtime.registry,
|
||||
container,
|
||||
tenancy: createTenancyHooks({ data: runtime.data, registry: runtime.registry }),
|
||||
...(runtime.serviceReadiness ? { serviceReadiness: runtime.serviceReadiness } : {}),
|
||||
...(runtime.prepareServices ? { prepareServices: runtime.prepareServices } : {}),
|
||||
},
|
||||
resolve,
|
||||
request,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// What a web process adds to a request's tenancy calls is `@conusai-kit/host/postgres`'s, on every profile.
|
||||
|
||||
export {
|
||||
ADMISSION_AUDIT,
|
||||
GRANT_CHANGING_PROCEDURES,
|
||||
MEMBER_INVITE,
|
||||
mayCreateOrganization,
|
||||
ORGANIZATION_CREATE,
|
||||
organizationExists,
|
||||
type TenancyCallLog,
|
||||
type TenancyCallOptions,
|
||||
withTenancyCalls,
|
||||
} from "@conusai-kit/host/postgres"
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
"use client"
|
||||
|
||||
// The account page's interactive half: the account module's export and deletion panels over this
|
||||
// app's transport, and, once a deletion answered, the end of the browser modules' context -- every
|
||||
// enabled module's `account-deletion` lifecycle handler (joint contract §12), which clears what it
|
||||
// kept in the browser for the account.
|
||||
|
||||
import {
|
||||
type AccountAnalyticsLabels,
|
||||
AccountAnalyticsPanel,
|
||||
AccountDeletionPanel,
|
||||
type AccountDeletionProps,
|
||||
type AccountExportLabels,
|
||||
AccountExportPanel,
|
||||
} from "@app/modules/account/ui"
|
||||
import type { ReactElement } from "react"
|
||||
import { endClientModules } from "./client-module-list"
|
||||
import { AppTransport } from "./transport"
|
||||
|
||||
export function AccountPanels({
|
||||
exportLabels,
|
||||
analyticsLabels,
|
||||
deletion,
|
||||
}: {
|
||||
exportLabels: AccountExportLabels
|
||||
/** The platform analytics panel (ADR-049); absent when this deployment has no privacy service. */
|
||||
analyticsLabels?: AccountAnalyticsLabels
|
||||
deletion: Omit<AccountDeletionProps, "onDeleted">
|
||||
}): ReactElement {
|
||||
return (
|
||||
<AppTransport>
|
||||
<AccountExportPanel labels={exportLabels} />
|
||||
{analyticsLabels ? <AccountAnalyticsPanel labels={analyticsLabels} /> : null}
|
||||
<AccountDeletionPanel {...deletion} onDeleted={() => endClientModules("account-deletion")} />
|
||||
</AppTransport>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
"use client"
|
||||
|
||||
// The Agent in the frame's right panel, on every page of a workspace: a conversation in the project the person is
|
||||
// in, or the workspace's own on a page with no project open (the projects list), where it is offered the
|
||||
// workspace's tools. It is told where they are on every message, and its context line says where it works: the
|
||||
// project's name, or the workspace's. Outside a workspace there is no button; a panel left open keeps its
|
||||
// conversation for its place. What it is told is what is on screen, sent as `pageContext`, never stored.
|
||||
|
||||
import { moduleSelected } from "@app/modules/inclusion"
|
||||
import { type AgentPanel, agentOccupant, createAgentPage, rootActionLabels } from "@conusai-kit/agent/assistant"
|
||||
import { assistantClient } from "@conusai-kit/agent/thread"
|
||||
import { useTransport } from "@conusai-kit/ui"
|
||||
import { assistantLabels } from "@conusai-kit/ui/assistant"
|
||||
import type { PanelOccupant } from "@conusai-kit/ui/patterns"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
|
||||
export interface AgentScope {
|
||||
/** Where it works: the workspace, and the project when one is open. */
|
||||
readonly slugs?: { readonly org: string; readonly workspace: string; readonly project?: string }
|
||||
readonly workspaceName?: string
|
||||
readonly projectName?: string
|
||||
readonly user: string
|
||||
readonly locale: string
|
||||
readonly permissions: readonly string[]
|
||||
/** The module's page on screen, by its path segment. */
|
||||
readonly resource?: string
|
||||
/** The record the right panel or the page has open. */
|
||||
readonly recordId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The record the right panel shows as the person moves along a list (the source viewer's row), which the frame
|
||||
* reports through `AppFrame`'s `onRecordChange` without touching the address (core-10).
|
||||
*/
|
||||
export function useShownRecord(): readonly [string | null, (recordId: string | null) => void] {
|
||||
return useState<string | null>(null)
|
||||
}
|
||||
|
||||
export function useAgent(scope: AgentScope): (() => PanelOccupant) | undefined {
|
||||
const { slugs, workspaceName, projectName, user, locale, permissions, resource, recordId } = scope
|
||||
const { call, fetch } = useTransport()
|
||||
const [page] = useState(() => createAgentPage())
|
||||
const { org, workspace, project } = slugs ?? {}
|
||||
useEffect(() => {
|
||||
if (!org || !workspace) {
|
||||
page.show(null)
|
||||
return
|
||||
}
|
||||
const place = project ? projectName : workspaceName
|
||||
page.show({
|
||||
scope: { organization: org, workspace, ...(project ? { project } : {}) },
|
||||
...(resource ? { resource } : {}),
|
||||
...(recordId ? { recordId } : {}),
|
||||
...(place ? { summary: place } : {}),
|
||||
})
|
||||
}, [page, org, workspace, project, workspaceName, projectName, resource, recordId])
|
||||
const client = useMemo(
|
||||
() =>
|
||||
moduleSelected("agent") && org && workspace && fetch
|
||||
? assistantClient({ slugs: { org, workspace, ...(project ? { project } : {}) }, locale, call, fetch })
|
||||
: undefined,
|
||||
[org, workspace, project, locale, call, fetch],
|
||||
)
|
||||
const allowed = moduleSelected("agent") && permissions.includes("assistant.turn.send")
|
||||
const canApprove = permissions.includes("proposals.proposal.approve")
|
||||
const canReject = permissions.includes("proposals.proposal.reject")
|
||||
const panel = useMemo<AgentPanel | undefined>(
|
||||
() =>
|
||||
client && allowed
|
||||
? {
|
||||
client,
|
||||
threadKey: `${user}:${org}:${workspace}:${project ?? ""}`,
|
||||
labels: assistantLabels(locale),
|
||||
rootActions: { labels: rootActionLabels(locale) },
|
||||
canSend: true,
|
||||
canApprove,
|
||||
canReject,
|
||||
}
|
||||
: undefined,
|
||||
[client, allowed, user, org, workspace, project, locale, canApprove, canReject],
|
||||
)
|
||||
// A panel left open follows the person to another project, or to the workspace with none open.
|
||||
useEffect(() => {
|
||||
if (panel) page.follow(panel)
|
||||
}, [page, panel])
|
||||
if (!panel) return undefined
|
||||
return () => agentOccupant({ ...panel, page })
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
"use client"
|
||||
// kit:custom-ui the first page, the sign-in and the signed-out page, on the kit's `AuthLayout`
|
||||
|
||||
// The way in: the brand's picture and words from `config/src/brand.ts`, the languages and the theme at the top, and
|
||||
// the page's own card. The identity service's hosted pages draw the same layout, so a person does not notice
|
||||
// leaving the app. Every word is a message the server passed in.
|
||||
|
||||
import { AuthCard, AuthLayout } from "@conusai-kit/ui/patterns"
|
||||
import type { ReactElement, ReactNode } from "react"
|
||||
import { AuthControls } from "./shell-controls"
|
||||
import { useShell } from "./use-shell"
|
||||
|
||||
/** The app's logo in each theme, when the brand shows one on the way in. */
|
||||
export function AuthMark({ light, dark }: { readonly light: string; readonly dark: string }): ReactElement {
|
||||
return (
|
||||
<>
|
||||
<img className="kit-auth-logo app-logo-dark" src={dark} alt="" />
|
||||
<img className="kit-auth-logo app-logo-light" src={light} alt="" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function AuthScreen({
|
||||
title,
|
||||
description,
|
||||
footer,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
description?: ReactNode
|
||||
footer?: ReactNode
|
||||
children?: ReactNode
|
||||
}): ReactElement | null {
|
||||
const shell = useShell()
|
||||
if (!shell) return null
|
||||
const { auth } = shell
|
||||
const logo = auth.logo ? <AuthMark {...auth.logo} /> : undefined
|
||||
return (
|
||||
<AuthLayout
|
||||
name={auth.name}
|
||||
{...(logo ? { logo } : {})}
|
||||
{...(auth.picture ? { picture: auth.picture } : {})}
|
||||
{...(auth.tagline ? { tagline: auth.tagline } : {})}
|
||||
highlights={auth.highlights}
|
||||
controls={<AuthControls shell={shell} />}
|
||||
>
|
||||
<AuthCard
|
||||
name={auth.name}
|
||||
{...(logo ? { logo } : {})}
|
||||
title={title}
|
||||
{...(description ? { description } : {})}
|
||||
{...(footer ? { footer } : {})}
|
||||
>
|
||||
{children}
|
||||
</AuthCard>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
"use client"
|
||||
|
||||
// The e-mail and password form of the Convex Better Auth adapter (AUTH_PROVIDER=convex-better-auth):
|
||||
// sign-in and sign-up. It posts to this app's own /api/auth, never to another origin, so the content
|
||||
// security policy stays `connect-src 'self'`; the session cookie Better Auth sets is HTTP-only and the
|
||||
// answer carries no token (the proxy removes it). No Better Auth client library is loaded: a sign-in
|
||||
// is one request. Every message is one of a fixed set of keys, never the provider's own text.
|
||||
|
||||
import { FormLayout, TextField } from "@conusai-kit/ui/patterns"
|
||||
import type { ReactElement } from "react"
|
||||
|
||||
export type BetterAuthFormError = "invalid" | "exists" | "disabled" | "rateLimited" | "unavailable" | "failed"
|
||||
|
||||
export interface BetterAuthFormProps {
|
||||
readonly mode: "sign-in" | "sign-up"
|
||||
readonly returnTo: string
|
||||
readonly minPasswordLength: number
|
||||
readonly labels: {
|
||||
readonly name: string
|
||||
readonly email: string
|
||||
readonly password: string
|
||||
readonly passwordHelp: string
|
||||
readonly submit: string
|
||||
readonly errors: Readonly<Record<BetterAuthFormError, string>>
|
||||
}
|
||||
}
|
||||
|
||||
const errorOf = (mode: BetterAuthFormProps["mode"], status: number, code: string | undefined): BetterAuthFormError => {
|
||||
if (status === 429) return "rateLimited"
|
||||
if (status >= 500) return "unavailable"
|
||||
if (code === "BANNED_USER" || (status === 403 && mode === "sign-in")) return "disabled"
|
||||
if (mode === "sign-up" && (status === 422 || code?.startsWith("USER_ALREADY_EXISTS"))) return "exists"
|
||||
if (status === 401 || code === "INVALID_EMAIL_OR_PASSWORD") return "invalid"
|
||||
return "failed"
|
||||
}
|
||||
|
||||
/** The refusal in the form's own words, or null when the person is in. */
|
||||
async function send(
|
||||
mode: BetterAuthFormProps["mode"],
|
||||
body: Record<string, string>,
|
||||
): Promise<BetterAuthFormError | null> {
|
||||
try {
|
||||
const response = await fetch(`/api/auth/${mode}/email`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (response.ok) return null
|
||||
const answer = (await response.json().catch(() => ({}))) as { code?: string }
|
||||
return errorOf(mode, response.status, answer.code)
|
||||
} catch {
|
||||
return "unavailable"
|
||||
}
|
||||
}
|
||||
|
||||
export function BetterAuthForm({ mode, returnTo, minPasswordLength, labels }: BetterAuthFormProps): ReactElement {
|
||||
const signUp = mode === "sign-up"
|
||||
|
||||
// From the form data, not the trimmed values: a password is exactly what was typed.
|
||||
const submit = async (_: unknown, data: FormData): Promise<void> => {
|
||||
const field = (name: string) => String(data.get(name) ?? "")
|
||||
const body = { email: field("email"), password: field("password"), ...(signUp ? { name: field("name") } : {}) }
|
||||
const error = await send(mode, body)
|
||||
if (error) throw new Error(labels.errors[error])
|
||||
// A full navigation: the next page is rendered on the server with the new session.
|
||||
window.location.assign(returnTo)
|
||||
}
|
||||
|
||||
return (
|
||||
<FormLayout
|
||||
method="post"
|
||||
submitLabel={labels.submit}
|
||||
onSubmit={submit}
|
||||
describeError={(error) => (error as Error).message}
|
||||
>
|
||||
{signUp ? <TextField name="name" label={labels.name} required autoComplete="name" /> : null}
|
||||
<TextField name="email" type="email" label={labels.email} required autoComplete="email" />
|
||||
<TextField
|
||||
name="password"
|
||||
type="password"
|
||||
label={labels.password}
|
||||
description={signUp ? labels.passwordHelp : undefined}
|
||||
required
|
||||
minLength={minPasswordLength}
|
||||
autoComplete={signUp ? "new-password" : "current-password"}
|
||||
/>
|
||||
</FormLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
|
||||
// The error boundary's side of the page states. A route's `errorComponent` renders in the browser too, so
|
||||
// it cannot ask the server for the person's language; the root route renders `BoundaryMessages` with the
|
||||
// strings its loader already translated (F10), and the boundary below it reads them back. When the root
|
||||
// loader itself failed there is no provider, and `RootErrorView` carries its own two languages.
|
||||
|
||||
import { Button } from "@conusai-kit/ui/primitives"
|
||||
import { createContext, type ReactElement, type ReactNode, useContext, useEffect } from "react"
|
||||
import { reportClientError } from "../observe/browser"
|
||||
import { StateView } from "./state-view"
|
||||
|
||||
/** The page states' own strings, for when the root loader that translates them is what failed. */
|
||||
const FALLBACK: Readonly<Record<"en" | "lt", BoundaryStrings>> = {
|
||||
en: {
|
||||
title: "Something went wrong",
|
||||
body: "The app could not be shown. Try again.",
|
||||
code: "Error code: {digest}",
|
||||
retry: "Try again",
|
||||
home: "Back to the start",
|
||||
},
|
||||
lt: {
|
||||
title: "Kažkas nepavyko",
|
||||
body: "Programos parodyti nepavyko. Bandykite dar kartą.",
|
||||
code: "Klaidos kodas: {digest}",
|
||||
retry: "Bandyti dar kartą",
|
||||
home: "Grįžti į pradžią",
|
||||
},
|
||||
}
|
||||
|
||||
export interface BoundaryStrings {
|
||||
readonly title: string
|
||||
readonly body: string
|
||||
/** `{digest}` is replaced with the error's digest, the code an operator finds in the server's log. */
|
||||
readonly code: string
|
||||
readonly retry: string
|
||||
readonly home: string
|
||||
}
|
||||
|
||||
const Strings = createContext<BoundaryStrings | undefined>(undefined)
|
||||
|
||||
export function BoundaryMessages({
|
||||
strings,
|
||||
children,
|
||||
}: {
|
||||
strings: BoundaryStrings
|
||||
children: ReactNode
|
||||
}): ReactElement {
|
||||
return <Strings.Provider value={strings}>{children}</Strings.Provider>
|
||||
}
|
||||
|
||||
export function ErrorView({ error, retry }: { error: Error & { digest?: string }; retry: () => void }): ReactElement {
|
||||
const strings = useContext(Strings)
|
||||
useEffect(() => {
|
||||
reportClientError(error)
|
||||
}, [error])
|
||||
if (!strings) throw error
|
||||
return (
|
||||
<StateView
|
||||
title={strings.title}
|
||||
actions={
|
||||
<>
|
||||
<Button type="button" onClick={retry}>
|
||||
{strings.retry}
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<a href="/">{strings.home}</a>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p role="alert">{strings.body}</p>
|
||||
{error.digest ? <p>{strings.code.replace("{digest}", error.digest)}</p> : null}
|
||||
</StateView>
|
||||
)
|
||||
}
|
||||
|
||||
/** The root route's error component: the boundary's strings when the root loader gave them, its own otherwise. */
|
||||
export function RootErrorView({
|
||||
error,
|
||||
retry,
|
||||
strings,
|
||||
locale,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
retry: () => void
|
||||
strings?: BoundaryStrings
|
||||
locale?: string
|
||||
}): ReactElement {
|
||||
return (
|
||||
<BoundaryMessages strings={strings ?? FALLBACK[locale === "lt" ? "lt" : "en"]}>
|
||||
<ErrorView error={error} retry={retry} />
|
||||
</BoundaryMessages>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The client entries of the modules this app enabled (joint contract §12), composed once. The host
|
||||
// (client-modules.tsx) mounts them beside every organization page; the sign-out control ends their
|
||||
// context before the browser leaves the page. A module that is not listed contributes no browser
|
||||
// code; a listed one's client entry is small and loads its provider or surface lazily. They are the
|
||||
// panel entries: on the app frame nothing floats over the page, and Help is the way in (help.tsx).
|
||||
|
||||
import { moduleAdopted, moduleSelected } from "@app/modules/inclusion"
|
||||
import {
|
||||
type ClientLifecycleEvent,
|
||||
type ClientModule,
|
||||
composeClientModules,
|
||||
type LifecycleReport,
|
||||
} from "@conusai-kit/core"
|
||||
import { guidesPanelClientModule } from "@conusai-kit/guides/client" // kit:guides
|
||||
import { supportPanelClientModule } from "@conusai-kit/support/client" // kit:support
|
||||
|
||||
const enabled: ClientModule[] = []
|
||||
// kit:guides:begin
|
||||
if (moduleAdopted("guides"))
|
||||
enabled.push(
|
||||
moduleSelected("guides") ? guidesPanelClientModule : { id: "guides", lifecycle: guidesPanelClientModule.lifecycle },
|
||||
)
|
||||
// kit:guides:end
|
||||
// kit:support:begin
|
||||
if (moduleAdopted("support"))
|
||||
enabled.push(
|
||||
moduleSelected("support")
|
||||
? supportPanelClientModule
|
||||
: { id: "support", lifecycle: supportPanelClientModule.lifecycle },
|
||||
)
|
||||
// kit:support:end
|
||||
|
||||
export const composedClientModules = composeClientModules(enabled)
|
||||
|
||||
/**
|
||||
* Ends the signed-in context of every enabled module in this tab. Sign-out is a full navigation, so
|
||||
* no provider unmounts and no cleanup of theirs runs: each module's own `sign-out` (or
|
||||
* `account-deletion`) handler clears what it keeps in the browser -- the guides progress and unsent
|
||||
* events in `sessionStorage`, any stored support widget session. Every handler runs even when
|
||||
* another fails, and the report names each outcome.
|
||||
*/
|
||||
export function endClientModules(event: Exclude<ClientLifecycleEvent, "tenant-switch">): Promise<LifecycleReport> {
|
||||
return composedClientModules.runLifecycle(event)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
"use client"
|
||||
|
||||
// The browser half of the modules (joint contract §12): the client entries of the modules this app
|
||||
// enabled (client-module-list.ts), mounted by kit-ui's generic host around every organization page.
|
||||
// A module that is not listed there contributes no browser code at all; one that is listed loads its
|
||||
// provider lazily, after the page has rendered. These providers are the kit's own browser packages
|
||||
// (guides, support); they reach the API through the kit's transport (`useTransport`), on this
|
||||
// origin. Module components use the data facade (`@conusai-kit/ui/data`) instead.
|
||||
|
||||
import { type ClientHostContext, ClientModuleHost } from "@conusai-kit/ui"
|
||||
import type { ReactElement, ReactNode } from "react"
|
||||
import { composedClientModules } from "./client-module-list"
|
||||
import { AppTransport } from "./transport"
|
||||
|
||||
export function ClientModules({
|
||||
context,
|
||||
children,
|
||||
}: {
|
||||
context: ClientHostContext
|
||||
/** The page: what asks the modules' surface coordinator for something sits below the host. */
|
||||
children?: ReactNode
|
||||
}): ReactElement {
|
||||
return (
|
||||
<AppTransport>
|
||||
<ClientModuleHost client={composedClientModules} context={context}>
|
||||
{children}
|
||||
</ClientModuleHost>
|
||||
</AppTransport>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
"use client"
|
||||
|
||||
// The browser's half of the one API path (spec §6.3). A page's form calls a procedure over the
|
||||
// same mount a CLI or a partner uses; there is no server action for a domain write, so one set of
|
||||
// rules applies to every caller (decision AT-D1).
|
||||
//
|
||||
// Two shapes, one transport: `callContract` is typed by the procedure's own zod schemas -- the
|
||||
// typed client of spec §6.2, from the contracts and not from a second description of them -- and
|
||||
// `call` takes a name, for a form that is built from data. A module's `contract/` folder is
|
||||
// client-safe by the layer rules (it imports no driver, no SDK and no server code), so a page may
|
||||
// import its contracts; the kernel's own contracts live beside their handlers and are called on
|
||||
// the server (`caller.call(contract, input)`).
|
||||
//
|
||||
// Modules call through `@conusai-kit/ui/data` (`useRead`, `useLive`, `useWrite`, `useUpload`), whose
|
||||
// client this file's `csrfToken` and `sendBody` feed (transport.tsx); `call` remains for the shell's
|
||||
// own forms. The shape of a call -- a contract, an input, problem+json out -- is the same in both.
|
||||
|
||||
import { keepaliveBeacon } from "@conusai-kit/ui"
|
||||
import { DataError } from "@conusai-kit/ui/data"
|
||||
|
||||
export interface Problem {
|
||||
title: string
|
||||
status: number
|
||||
code: string
|
||||
detail?: string
|
||||
errors?: { path: string; message: string }[]
|
||||
}
|
||||
|
||||
const csrf = (): string => document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") ?? ""
|
||||
|
||||
/** The page's CSRF token, for the data facade's client (transport.tsx). */
|
||||
export const csrfToken = csrf
|
||||
|
||||
/**
|
||||
* The page's unload path (kit-ui's `Beacon`): `call`'s mount, session cookie and CSRF token on a
|
||||
* same-origin keep-alive request, so a call made as the page goes away (the guides engine's unsent
|
||||
* events on `pagehide`) still arrives, and passes the CSRF wall like any other POST of this page.
|
||||
*/
|
||||
export const beacon = keepaliveBeacon({
|
||||
url: (procedure) => `/api/v1/${procedure}`,
|
||||
headers: () => ({ "x-csrf-token": csrf() }),
|
||||
})
|
||||
|
||||
/**
|
||||
* A procedure's contract, as much of it as a browser needs: the name to call and the two schemas
|
||||
* that type the call. The shape is structural on purpose, so the browser bundle carries no import
|
||||
* of the kernel at all.
|
||||
*/
|
||||
export interface ClientContract<I, O> {
|
||||
readonly name: string
|
||||
readonly input: { readonly _input: I }
|
||||
readonly output: { readonly _output: O }
|
||||
}
|
||||
|
||||
/** Calls a procedure, typed by its contract: the input and the answer are the schemas' own types. */
|
||||
export async function callContract<I, O>(
|
||||
contract: ClientContract<I, O>,
|
||||
input: I,
|
||||
options: { idempotencyKey?: string } = {},
|
||||
): Promise<O> {
|
||||
return call<O>(contract.name, input, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls a procedure by name, for a form whose fields are data rather than code. The session cookie
|
||||
* travels on its own; the CSRF token is the page's.
|
||||
*/
|
||||
export async function call<T>(
|
||||
procedure: string,
|
||||
input: unknown,
|
||||
options: { idempotencyKey?: string } = {},
|
||||
): Promise<T> {
|
||||
const response = await fetch(`/api/v1/${procedure}`, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-csrf-token": csrf(),
|
||||
...(options.idempotencyKey ? { "idempotency-key": options.idempotencyKey } : {}),
|
||||
},
|
||||
body: JSON.stringify(input ?? null),
|
||||
})
|
||||
const body: unknown = await response.json().catch(() => null)
|
||||
if (!response.ok) throw refusalOf(response.status, body)
|
||||
return body as T
|
||||
}
|
||||
|
||||
/**
|
||||
* This app's own `fetch` for a module route that is not a procedure call (a module's same-origin
|
||||
* proxy and its event stream): the session cookie and the page's CSRF token, and nothing else. It
|
||||
* refuses another origin, so a module cannot use it to send the token anywhere but here.
|
||||
*/
|
||||
export function authenticatedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const request = new Request(input instanceof Request ? input : new URL(String(input), window.location.href), init)
|
||||
if (new URL(request.url).origin !== window.location.origin) {
|
||||
return Promise.reject(new TypeError("authenticatedFetch only reaches this app's own origin"))
|
||||
}
|
||||
const headers = new Headers(request.headers)
|
||||
headers.set("x-csrf-token", csrf())
|
||||
return fetch(new Request(request, { headers, credentials: "same-origin" }))
|
||||
}
|
||||
|
||||
/** A refusal as the server sent it (problem+json), or a bare status when it sent nothing readable. */
|
||||
function refusalOf(status: number, body: unknown): DataError {
|
||||
const problem = (body ?? {}) as Partial<Problem>
|
||||
const normalized: Problem = {
|
||||
title: problem.title ?? "Request failed",
|
||||
status,
|
||||
code: problem.code ?? "unknown",
|
||||
...(problem.detail ? { detail: problem.detail } : {}),
|
||||
...(problem.errors ? { errors: problem.errors } : {}),
|
||||
}
|
||||
return new DataError(status, normalized)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the body of a procedure that is a stream of bytes (kit-core `.stream(field)`): a `PUT` to
|
||||
* the same mount with the other fields of the input as the query. It is an `XMLHttpRequest` and not
|
||||
* `fetch` because only a request can report how much of a body has left the browser, which is what
|
||||
* an upload's progress bar is; the session cookie and the CSRF token travel as they do for `call`.
|
||||
*
|
||||
* A network failure rejects with a `TypeError`, the way `fetch` does, so a caller can tell it from
|
||||
* a refusal (a `DataError`) and send the piece again.
|
||||
*/
|
||||
export function sendBody(
|
||||
procedure: string,
|
||||
query: Readonly<Record<string, string | number>>,
|
||||
body: Blob,
|
||||
options: { onProgress?: (sentBytes: number) => void; signal?: AbortSignal } = {},
|
||||
): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (options.signal?.aborted) {
|
||||
reject(new DOMException("the upload was cancelled", "AbortError"))
|
||||
return
|
||||
}
|
||||
const params = new URLSearchParams(Object.entries(query).map(([key, value]) => [key, String(value)]))
|
||||
const request = new XMLHttpRequest()
|
||||
request.open("PUT", `/api/v1/${procedure}?${params.toString()}`)
|
||||
request.withCredentials = true
|
||||
request.setRequestHeader("content-type", "application/octet-stream")
|
||||
request.setRequestHeader("x-csrf-token", csrf())
|
||||
request.upload.onprogress = (event) => options.onProgress?.(event.loaded)
|
||||
request.onerror = () => reject(new TypeError("network error"))
|
||||
request.ontimeout = () => reject(new TypeError("network timeout"))
|
||||
request.onabort = () => reject(new DOMException("the upload was cancelled", "AbortError"))
|
||||
request.onload = () => {
|
||||
let parsed: unknown = null
|
||||
try {
|
||||
parsed = JSON.parse(request.responseText)
|
||||
} catch {
|
||||
// A body that is not JSON (a gateway's error page) is a refusal with only a status.
|
||||
}
|
||||
if (request.status >= 200 && request.status < 300) resolve(parsed)
|
||||
else reject(refusalOf(request.status, parsed))
|
||||
}
|
||||
options.signal?.addEventListener("abort", () => request.abort(), { once: true })
|
||||
request.send(body)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// kit:custom-ui the HTML document and the app header, around every page
|
||||
// The HTML document every page is rendered into: the brand layer on <html>, an organization's brand on
|
||||
// <body>, and, on a page outside the app frame, the header with the app's name and logo. It takes its brand
|
||||
// as a value so a test can render it with a customer's brand and prove what the page says; the root route
|
||||
// (src/routes/__root.tsx) supplies the request's facts from its loader (locale, session, CSRF token, labels:
|
||||
// `ShellData`, F10).
|
||||
//
|
||||
// The brand layer (`brand.style`: the roles the app's colours derive, light and dark, and its `--brand-*` tokens) is
|
||||
// a stylesheet in <head> whose rule (`:root:root`) outranks the kit's theme in either mode, so the first paint already
|
||||
// carries it: a branded app never paints the ConusAI palette first. When the platform gives the organization's theme,
|
||||
// that theme is the one source of the colours and the brand layer keeps only its `--brand-*` tokens (`__root.tsx`), so
|
||||
// the theme's own dark roles show; before, an inline brand colour on <html> beat them. It is computed on the server,
|
||||
// so the browser's bundle never imports the brand package. An
|
||||
// organization's colours go on <body> and not on an element below it: a menu and the phone's sheet are portals
|
||||
// into <body>, and they keep its colours that way.
|
||||
|
||||
import type { AuthBrand } from "@app/config"
|
||||
import type { UiLabels } from "@conusai-kit/ui/primitives"
|
||||
import type { CSSProperties, ReactElement, ReactNode } from "react"
|
||||
import { type BrowserObserveSettings, observeMeta } from "../observe/browser"
|
||||
import type { BoundaryStrings } from "./boundary"
|
||||
import type { HelpLabels } from "./help"
|
||||
import type { Theme } from "./preference-cookies"
|
||||
import { type PrivacyChoiceMessages, PrivacyChoices, PrivacyFooter } from "./privacy-choices"
|
||||
|
||||
/**
|
||||
* The brand layer as a rule on the root, above the kit's theme (`:root` and `.light`) whatever order the stylesheets
|
||||
* load in. Its values were checked when the brand was defined (colours and lengths only); a `<` is dropped all the
|
||||
* same, so nothing in it can close the element.
|
||||
*/
|
||||
export function brandLayerCss(style: Readonly<Record<string, string>>): string {
|
||||
const declarations = Object.entries(style).map(([name, value]) => `${name}:${value}`)
|
||||
return declarations.length === 0 ? "" : `:root:root{${declarations.join(";").replaceAll("<", "")}}`
|
||||
}
|
||||
|
||||
/** The brand without its colour roles, for a page the platform's organization theme colours: its `--brand-*` tokens. */
|
||||
export function brandTokensOnly(style: Readonly<Record<string, string>>): Record<string, string> {
|
||||
return Object.fromEntries(Object.entries(style).filter(([name]) => name.startsWith("--brand-")))
|
||||
}
|
||||
|
||||
/** The app's own share image (src/routes/api.brand.og.ts): public, because a link preview is fetched without a session. */
|
||||
export const APP_SHARE_IMAGE = "/api/brand/og"
|
||||
|
||||
/** What the document needs of the brand: its names, its logos and the token style the server computed. */
|
||||
export interface ShellBrand {
|
||||
readonly name: string
|
||||
readonly shortName: string
|
||||
readonly logo: { readonly light: string; readonly dark: string; readonly favicon: string }
|
||||
/** `brandTokenStyle(brand)` of `@app/config`, computed on the server. */
|
||||
readonly style: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
/** What a page says about itself to a browser tab, a share card and a search result. */
|
||||
export interface BrandMetadata {
|
||||
readonly title: string
|
||||
readonly description: string
|
||||
readonly applicationName: string
|
||||
readonly icons: { readonly icon: string }
|
||||
readonly openGraph: {
|
||||
readonly siteName: string
|
||||
readonly title: string
|
||||
readonly type: "website"
|
||||
readonly images: readonly {
|
||||
readonly url: string
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly alt: string
|
||||
}[]
|
||||
}
|
||||
readonly twitter: { readonly card: "summary_large_image"; readonly title: string; readonly images: readonly string[] }
|
||||
}
|
||||
|
||||
/**
|
||||
* The brand's own name, and nothing else. `image` is the share image of a page; an organization's pages pass
|
||||
* their own.
|
||||
*/
|
||||
export function brandMetadata(
|
||||
brand: Pick<ShellBrand, "name" | "logo">,
|
||||
image: string = APP_SHARE_IMAGE,
|
||||
): BrandMetadata {
|
||||
return {
|
||||
title: brand.name,
|
||||
description: brand.name,
|
||||
applicationName: brand.name,
|
||||
icons: { icon: brand.logo.favicon },
|
||||
openGraph: {
|
||||
siteName: brand.name,
|
||||
title: brand.name,
|
||||
type: "website",
|
||||
images: [{ url: image, width: 1200, height: 630, alt: brand.name }],
|
||||
},
|
||||
twitter: { card: "summary_large_image", title: brand.name, images: [image] },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `brandMetadata` as a route's `head()`: the meta tags and the icon link. `base` is the deployment's public
|
||||
* URL, so a link preview gets an absolute image address.
|
||||
*/
|
||||
export function brandHead(
|
||||
metadata: BrandMetadata,
|
||||
base?: string,
|
||||
): {
|
||||
meta: Record<string, string>[]
|
||||
links: Record<string, string>[]
|
||||
} {
|
||||
const absolute = (url: string): string => (base ? new URL(url, base).href : url)
|
||||
const image = metadata.openGraph.images[0]
|
||||
return {
|
||||
meta: [
|
||||
{ charSet: "utf-8" },
|
||||
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
||||
{ title: metadata.title },
|
||||
{ name: "description", content: metadata.description },
|
||||
{ name: "application-name", content: metadata.applicationName },
|
||||
{ property: "og:site_name", content: metadata.openGraph.siteName },
|
||||
{ property: "og:title", content: metadata.openGraph.title },
|
||||
{ property: "og:type", content: metadata.openGraph.type },
|
||||
...(image
|
||||
? [
|
||||
{ property: "og:image", content: absolute(image.url) },
|
||||
{ property: "og:image:width", content: String(image.width) },
|
||||
{ property: "og:image:height", content: String(image.height) },
|
||||
{ property: "og:image:alt", content: image.alt },
|
||||
]
|
||||
: []),
|
||||
{ name: "twitter:card", content: metadata.twitter.card },
|
||||
{ name: "twitter:title", content: metadata.twitter.title },
|
||||
...metadata.twitter.images.map((url) => ({ name: "twitter:image", content: absolute(url) })),
|
||||
],
|
||||
links: [{ rel: "icon", href: metadata.icons.icon }],
|
||||
}
|
||||
}
|
||||
|
||||
/** The labels the shell renders, translated by the root loader (src/server/page.ts `shellData`). */
|
||||
export interface ShellLabels {
|
||||
readonly signIn: string
|
||||
readonly signOut: string
|
||||
readonly account: string
|
||||
readonly failed: string
|
||||
readonly language: string
|
||||
readonly languageEn: string
|
||||
readonly languageLt: string
|
||||
readonly theme: string
|
||||
readonly themeDark: string
|
||||
readonly themeLight: string
|
||||
readonly themeSystem: string
|
||||
readonly switchToLight: string
|
||||
readonly switchToDark: string
|
||||
readonly loading: string
|
||||
readonly notFoundTitle: string
|
||||
readonly notFoundBody: string
|
||||
readonly forbiddenTitle: string
|
||||
readonly forbiddenBody: string
|
||||
}
|
||||
|
||||
/** The root route's loader data: everything the document and the page states render, as plain values. */
|
||||
export interface ShellData {
|
||||
readonly brand: ShellBrand
|
||||
/** The way in, in the page's language (`authBrand` of `config/src/brand.ts`). */
|
||||
readonly auth: AuthBrand
|
||||
/** The deployment's public URL: what makes a share image's address absolute for a link preview. */
|
||||
readonly publicUrl: string
|
||||
readonly locale: string
|
||||
readonly theme: Theme
|
||||
/** The frame's layout as this browser left it (`app_sidebar`, `app_panel_width`). */
|
||||
readonly sidebarOpen: boolean
|
||||
readonly panelWidth?: number
|
||||
readonly signedIn: boolean
|
||||
readonly csrf?: string
|
||||
readonly observe?: BrowserObserveSettings
|
||||
readonly nonce?: string
|
||||
/** The organization theme's stylesheet (BK12), when the platform gives one. */
|
||||
readonly organizationTheme?: string
|
||||
readonly accountHref: string
|
||||
readonly labels: ShellLabels
|
||||
/** The strings the kit's primitives and patterns render themselves, in the page's language. */
|
||||
readonly ui: UiLabels
|
||||
readonly help: HelpLabels
|
||||
readonly privacy: PrivacyChoiceMessages
|
||||
readonly boundary: BoundaryStrings
|
||||
}
|
||||
|
||||
/** The class `system` means on this device, set before anything paints. The server cannot know it. */
|
||||
const SYSTEM_THEME_SCRIPT = `document.documentElement.classList.toggle("light",matchMedia("(prefers-color-scheme: light)").matches)`
|
||||
|
||||
export interface DocumentProps {
|
||||
readonly brand: ShellBrand
|
||||
readonly locale: string
|
||||
/** `light` puts the theme class on the root, the one element that carries it (spec §10.3). */
|
||||
readonly theme: Theme
|
||||
readonly signedIn: boolean
|
||||
readonly csrf?: string
|
||||
/** Set when the deployment turned observability on: the browser starts its SDK from this (src/observe). */
|
||||
readonly observe?: BrowserObserveSettings
|
||||
/** The request's script nonce: `system` needs a script in the head. */
|
||||
readonly nonce?: string
|
||||
readonly signIn: string
|
||||
/** The sign-out control, already rendered with its label. */
|
||||
readonly signOut: ReactNode
|
||||
/** The theme and language controls, already rendered with their labels. */
|
||||
readonly controls: ReactNode
|
||||
readonly privacy: PrivacyChoiceMessages
|
||||
/** The organization whose page this is: its colours, and the mark a test finds it by. */
|
||||
readonly tenant?: { readonly style: Readonly<Record<string, string>>; readonly mark?: string }
|
||||
/** The router's `<HeadContent />`: title, meta and the stylesheet links. */
|
||||
readonly head?: ReactNode
|
||||
/** The router's `<Scripts />`, last in the body. */
|
||||
readonly scripts?: ReactNode
|
||||
readonly children: ReactNode
|
||||
}
|
||||
|
||||
export function Document({
|
||||
brand,
|
||||
locale,
|
||||
theme,
|
||||
signedIn,
|
||||
csrf,
|
||||
observe,
|
||||
nonce,
|
||||
signIn,
|
||||
signOut,
|
||||
controls,
|
||||
privacy,
|
||||
tenant,
|
||||
head,
|
||||
scripts,
|
||||
children,
|
||||
}: DocumentProps): ReactElement {
|
||||
return (
|
||||
<html
|
||||
lang={locale}
|
||||
className={theme === "light" ? "light" : undefined}
|
||||
data-brand={brand.shortName}
|
||||
suppressHydrationWarning={theme === "system"}
|
||||
>
|
||||
<head>
|
||||
{theme === "system" ? (
|
||||
<script
|
||||
{...(nonce ? { nonce } : {})}
|
||||
suppressHydrationWarning
|
||||
dangerouslySetInnerHTML={{ __html: SYSTEM_THEME_SCRIPT }}
|
||||
/>
|
||||
) : null}
|
||||
{head}
|
||||
<style
|
||||
data-brand-layer="app"
|
||||
{...(nonce ? { nonce } : {})}
|
||||
dangerouslySetInnerHTML={{ __html: brandLayerCss(brand.style) }}
|
||||
/>
|
||||
{csrf ? <meta name="csrf-token" content={csrf} /> : null}
|
||||
{observe ? <meta {...observeMeta(observe)} /> : null}
|
||||
</head>
|
||||
<body {...(tenant?.mark ? { "data-tenant-brand": tenant.mark } : {})} style={tenant?.style as CSSProperties}>
|
||||
<PrivacyChoices
|
||||
messages={privacy}
|
||||
footer={
|
||||
<div data-slot="document-footer">
|
||||
<PrivacyFooter />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<header className="app-shell-header">
|
||||
<a href="/" className="app-brand-link">
|
||||
<img className="app-logo app-logo-dark" src={brand.logo.dark} alt="" />
|
||||
<img className="app-logo app-logo-light" src={brand.logo.light} alt="" />
|
||||
{brand.name}
|
||||
</a>
|
||||
<div className="app-shell-controls">
|
||||
{controls}
|
||||
{signedIn ? signOut : <a href="/auth/sign-in">{signIn}</a>}
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</PrivacyChoices>
|
||||
{scripts}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
"use client"
|
||||
|
||||
// What the frame leaves to the host: the sidebar's state and the right panel's width. The kit stores
|
||||
// nothing; both are preference cookies, so the first paint has them (docs/reference/compliance/cookies-and-storage.md).
|
||||
|
||||
import { useState } from "react"
|
||||
import type { ShellData } from "./document"
|
||||
import { PANEL_WIDTH_COOKIE, preferenceCookie, SIDEBAR_COOKIE } from "./preference-cookies"
|
||||
|
||||
export interface FrameLayout {
|
||||
readonly sidebarOpen: boolean
|
||||
onSidebarOpenChange(open: boolean): void
|
||||
readonly panelWidth?: number
|
||||
onPanelWidthChange(width: number): void
|
||||
}
|
||||
|
||||
export function useFrameLayout(shell: Pick<ShellData, "sidebarOpen" | "panelWidth">): FrameLayout {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(shell.sidebarOpen)
|
||||
const [panelWidth, setPanelWidth] = useState(shell.panelWidth)
|
||||
return {
|
||||
sidebarOpen,
|
||||
onSidebarOpenChange(open) {
|
||||
setSidebarOpen(open)
|
||||
// biome-ignore lint/suspicious/noDocumentCookie: a preference cookie of a few bytes; the Cookie Store API is not in every browser this app serves
|
||||
document.cookie = preferenceCookie(SIDEBAR_COOKIE, open ? "open" : "closed")
|
||||
},
|
||||
...(panelWidth === undefined ? {} : { panelWidth }),
|
||||
onPanelWidthChange(width) {
|
||||
setPanelWidth(width)
|
||||
// biome-ignore lint/suspicious/noDocumentCookie: as above
|
||||
document.cookie = preferenceCookie(PANEL_WIDTH_COOKIE, String(width))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// TanStack Router as the frame's `FrameRouter`, in this one place: the frame, the settings layout and a
|
||||
// `ResourcePage` read the address and move through it, and none of them knows which router this is.
|
||||
|
||||
import type { FrameRouter } from "@conusai-kit/ui/frame"
|
||||
import { useLocation, useRouter } from "@tanstack/react-router"
|
||||
import { useEffect, useMemo } from "react"
|
||||
|
||||
export function useFrameRouter(): FrameRouter {
|
||||
const router = useRouter()
|
||||
const { pathname, searchStr } = useLocation()
|
||||
return useMemo(
|
||||
() => ({
|
||||
pathname,
|
||||
search: new URLSearchParams(searchStr),
|
||||
async refresh() {
|
||||
await router.invalidate()
|
||||
},
|
||||
navigate(to, options) {
|
||||
void router.navigate({ href: to, ...(options?.replace ? { replace: true } : {}) })
|
||||
},
|
||||
}),
|
||||
[router, pathname, searchStr],
|
||||
)
|
||||
}
|
||||
|
||||
/** Restore window scroll for same-document history entries. Reload uses native browser restoration. */
|
||||
export function useFrameNavigationFocus(): void {
|
||||
const router = useRouter()
|
||||
useEffect(() => {
|
||||
const positions = new Map<string, number>()
|
||||
let restore: number | undefined
|
||||
const capture = router.subscribe("onBeforeLoad", ({ fromLocation, hrefChanged }) => {
|
||||
const key = fromLocation?.state.__TSR_key
|
||||
if (!hrefChanged || !key) return
|
||||
if (restore !== undefined) cancelAnimationFrame(restore)
|
||||
positions.delete(key)
|
||||
positions.set(key, window.scrollY)
|
||||
if (positions.size > 100) {
|
||||
const oldest = positions.keys().next().value
|
||||
if (oldest) positions.delete(oldest)
|
||||
}
|
||||
})
|
||||
const rendered = router.subscribe("onRendered", ({ pathChanged, toLocation }) => {
|
||||
if (toLocation.hash) return
|
||||
const key = toLocation.state.__TSR_key
|
||||
const position = key ? positions.get(key) : undefined
|
||||
if (position !== undefined) restore = requestAnimationFrame(() => window.scrollTo(0, position))
|
||||
if (!pathChanged || document.activeElement?.closest('[data-slot="side-panel-content"]')) return
|
||||
const heading = document.querySelector<HTMLElement>(".kit-frame-page h1")
|
||||
if (heading) {
|
||||
heading.tabIndex = -1
|
||||
heading.focus({ preventScroll: true })
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
capture()
|
||||
rendered()
|
||||
if (restore !== undefined) cancelAnimationFrame(restore)
|
||||
}
|
||||
}, [router])
|
||||
}
|
||||
|
||||
/** What is served outside the router: a sign-in, a sign-out, an API read. */
|
||||
const OUTSIDE_THE_ROUTER = /^\/(api|auth)(\/|$)/
|
||||
|
||||
/**
|
||||
* A plain link of a page, in the frame, moves through the router: a full load would close the right panel, and the
|
||||
* Agent's conversation with it. A press the browser keeps for itself stays the browser's: a new tab, a download.
|
||||
*/
|
||||
export function useFrameLinks(router: FrameRouter): void {
|
||||
useEffect(() => {
|
||||
const follow = (event: MouseEvent): void => {
|
||||
if (event.defaultPrevented || event.button !== 0) return
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
|
||||
const link = (event.target as Element | null)?.closest?.("a[href]")
|
||||
if (!(link instanceof HTMLAnchorElement) || !link.closest('[data-slot="app-frame"]')) return
|
||||
if (link.target !== "" || link.hasAttribute("download") || link.origin !== window.location.origin) return
|
||||
if (OUTSIDE_THE_ROUTER.test(link.pathname)) return
|
||||
if (link.pathname === location.pathname && link.search === location.search && link.hash) return
|
||||
event.preventDefault()
|
||||
router.navigate(`${link.pathname}${link.search}${link.hash}`)
|
||||
}
|
||||
document.addEventListener("click", follow)
|
||||
return () => document.removeEventListener("click", follow)
|
||||
}, [router])
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
/* SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 */
|
||||
/* The app's stylesheet. Every colour is a shadcn role of the generated ConusAI theme
|
||||
(@conusai-kit/brand/theme-dark.css: dark at :root, light under `.light`, each with its
|
||||
color-scheme), and fonts and radii are brand tokens: this file declares no colour of its own, so a
|
||||
white-label change is a change in config/brand.ts and nothing else (decision AT-D11). The app
|
||||
brand and an organization's brand write the runtime roles (`--primary`, `--ring`,
|
||||
`--sidebar-primary`, ...) through `themeStyle()`; this file only reads roles (brand-kit BK6). The
|
||||
fonts are the self-hosted woff2 files of @conusai-kit/brand; no external stylesheet or font host
|
||||
is referenced anywhere (self-hosted D20). */
|
||||
@layer base, components;
|
||||
@import "@conusai-kit/brand/tokens.css";
|
||||
@import "@conusai-kit/brand/theme-dark.css";
|
||||
@import "@conusai-kit/ui/styles.css";
|
||||
@import "@conusai-kit/ui/primitives.css";
|
||||
@import "@conusai-kit/ui/patterns.css";
|
||||
@import "@conusai-kit/ui/assistant.css";
|
||||
@import "@conusai-kit/ui/side-panel.css";
|
||||
@import "@conusai-kit/ui/frame.css";
|
||||
@import "@conusai-kit/ui/auth.css";
|
||||
|
||||
:root {
|
||||
/* A separator between rows: the border role at a lower opacity, never a colour literal. */
|
||||
--app-separator: color-mix(in oklab, var(--border) 45%, transparent);
|
||||
}
|
||||
|
||||
/* The bare elements, for the few surfaces outside the kit's patterns (the sign-in pages, the shell's own
|
||||
controls). In the `base` layer: an unlayered rule would beat every rule of the kit's `components` layer. */
|
||||
@layer base {
|
||||
a {
|
||||
/* The ring, not the fill: the kit holds the ring at text contrast (4.5:1) on the page in both modes
|
||||
(the brand teal is 1.8:1 as text on white, so the light ring is the teal ink). */
|
||||
color: var(--ring);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: var(--brand-font-sans);
|
||||
margin-block: 0 0.75rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: start;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--app-separator);
|
||||
}
|
||||
|
||||
/* The kit's tables' weight, so a bare table reads as a DataTable does. */
|
||||
th {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
button,
|
||||
select,
|
||||
input:not([type="checkbox"], [type="radio"], [type="hidden"]) {
|
||||
font: inherit;
|
||||
color: var(--secondary-foreground);
|
||||
background: var(--secondary);
|
||||
border: 1px solid var(--input);
|
||||
border-radius: var(--brand-radius-button);
|
||||
padding: 0.4rem 0.75rem;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
button[aria-pressed="true"] {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* A page outside the frame: the frame's own main has the kit's spacing, and an auto margin would shrink it. */
|
||||
main:not(.kit-frame-page) {
|
||||
padding: 1.5rem;
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* A list of links is a list of targets: each is at least 24px high (WCAG 2.2, 2.5.8). */
|
||||
main li > a {
|
||||
display: inline-block;
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* The product UI keeps its app root when a lazy browser provider also loads the brand's site tokens.
|
||||
Specificity, rather than stylesheet arrival order, keeps every rem-sized hit target steady during hydration. */
|
||||
html:root {
|
||||
font-size: var(--brand-app-font-size-root);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--brand-font-sans);
|
||||
font-size: var(--brand-text-base);
|
||||
line-height: var(--brand-leading-base);
|
||||
}
|
||||
|
||||
.app-shell-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-bottom: 1px solid var(--app-separator);
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
/* The way in has its own controls and its own brand, so the document's header stands aside. */
|
||||
body:has(.kit-auth, .kit-auth-page, [data-slot="app-state"]) .app-shell-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-error {
|
||||
border: 1px solid var(--app-separator);
|
||||
border-radius: var(--brand-radius-panel, 8px);
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
/* The app's logo and an organization's, one image per theme: the other is hidden by the theme class. */
|
||||
.app-logo {
|
||||
height: 1.75rem;
|
||||
width: auto;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* In the frame the mark fills the box the kit gives it. */
|
||||
.kit-frame-brand .app-logo {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app-logo-light {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.light .app-logo-light {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.light .app-logo-dark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-brand-link {
|
||||
display: inline-flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
color: var(--foreground);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* The organization chooser is a list of whole-card links, with a visible keyboard focus. */
|
||||
.app-chooser-list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.app-chooser-footer {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--app-separator);
|
||||
}
|
||||
|
||||
.app-chooser-preferences,
|
||||
.app-chooser-account {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.app-chooser-account {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
[data-guide="home.organization"] {
|
||||
position: relative;
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
background-color 150ms ease;
|
||||
}
|
||||
|
||||
[data-guide="home.organization"]:hover,
|
||||
[data-guide="home.organization"]:focus-within {
|
||||
border-color: var(--ring);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
[data-guide="home.organization"] h2 a {
|
||||
color: var(--foreground);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
[data-guide="home.organization"] h2 a::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-guide="home.organization"] {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The shell's controls, the form controls the pages use, and the states a page can be in. Brand
|
||||
tokens only: a white-label change is still a change in config/brand.ts and nothing else. */
|
||||
.app-shell-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-control {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.kit-field {
|
||||
margin-block-end: 0.75rem;
|
||||
}
|
||||
|
||||
.app-link-field {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-family: var(--brand-font-mono);
|
||||
}
|
||||
|
||||
/*
|
||||
* Outside the frame, in the page's flow and held at the foot by `sticky`: the page gains the choices' height, so
|
||||
* it can always be scrolled past them. Fixed, they sat over the sign-in form on a page too short to scroll.
|
||||
* The scroll padding brings a focused control out from under them (WCAG 2.2, 2.4.11).
|
||||
*/
|
||||
:root:has(.app-consent) {
|
||||
--kit-bottom-inset: min(60dvh, 30rem);
|
||||
scroll-padding-block-end: var(--kit-bottom-inset);
|
||||
}
|
||||
|
||||
.app-consent {
|
||||
position: sticky;
|
||||
bottom: 1rem;
|
||||
z-index: 20;
|
||||
box-sizing: border-box;
|
||||
width: min(42rem, 100% - 2rem);
|
||||
max-height: calc(100dvh - 2rem);
|
||||
overflow-y: auto;
|
||||
margin-block-start: 1rem;
|
||||
margin-inline: auto;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--brand-radius-panel);
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* The frame reserves this fixed notice's clearance in its page and panel scroll regions.
|
||||
Keeping it inside the viewport avoids shortening the sticky panel's containing block. */
|
||||
body:has([data-slot="app-frame"]) .app-consent {
|
||||
position: fixed;
|
||||
z-index: 45;
|
||||
inset-inline-end: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Beside the frame's sidebar, whose foot holds the person's menu: the choices must not sit over it. */
|
||||
@media (min-width: 48rem) {
|
||||
body:has([data-slot="app-frame"]) .app-consent {
|
||||
width: min(64rem, 100% - 19rem);
|
||||
margin-inline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* A section's heading on the type scale, as the kit's are, not the browser's 1.5em. */
|
||||
.app-consent h2 {
|
||||
margin-block: 0 0.25rem;
|
||||
font-size: var(--brand-text-base);
|
||||
font-weight: 600;
|
||||
line-height: var(--brand-leading-base);
|
||||
}
|
||||
|
||||
.app-consent-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-block-start: 1rem;
|
||||
}
|
||||
|
||||
.app-consent-choice {
|
||||
flex: 1 1 12rem;
|
||||
min-height: 2.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* On a wide screen the choices stand beside the words, which keeps them low enough to leave a form clear. */
|
||||
@media (min-width: 60rem) {
|
||||
.app-consent {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 16rem;
|
||||
column-gap: 1.5rem;
|
||||
width: min(64rem, 100% - 2rem);
|
||||
padding-block: 0.625rem;
|
||||
}
|
||||
|
||||
.app-consent > :not(.app-consent-actions) {
|
||||
grid-column: 1;
|
||||
margin-block: 0 0.25rem;
|
||||
}
|
||||
|
||||
.app-consent-actions {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 3;
|
||||
align-content: center;
|
||||
margin-block-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.app-privacy-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.kit-frame-footer .app-privacy-footer {
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* The shell has a stable SSR tree even for root not-found outcomes. The rendered frame owns its
|
||||
controls when present; the document keeps them on recovery/sign-in pages without route-status branches. */
|
||||
body:has(.kit-frame) .app-shell-header,
|
||||
body:has(.kit-frame) [data-slot="document-footer"] {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
"use client"
|
||||
|
||||
// Help in the right panel: the frame's one Help button, whatever modules this app enabled. Each section is one
|
||||
// line `kit template:prune --without` can delete, and with neither left there is no button.
|
||||
|
||||
import { moduleSelected } from "@app/modules/inclusion"
|
||||
import { GuidesPanel } from "@conusai-kit/guides/panel" // kit:guides
|
||||
import { supportSection } from "@conusai-kit/support/panel" // kit:support
|
||||
import type { ClientHostContext } from "@conusai-kit/ui"
|
||||
import { type HelpSection, helpOccupant, type PanelOccupant } from "@conusai-kit/ui/patterns"
|
||||
|
||||
export interface HelpLabels {
|
||||
readonly title: string
|
||||
readonly sections: string
|
||||
readonly guides: string
|
||||
readonly support: string
|
||||
}
|
||||
|
||||
export function helpFor(
|
||||
context: ClientHostContext,
|
||||
labels: HelpLabels,
|
||||
): { readonly any: boolean; occupant(section?: string): PanelOccupant } {
|
||||
const sections: HelpSection[] = []
|
||||
// kit:guides:begin
|
||||
if (moduleSelected("guides"))
|
||||
sections.push({ id: "guides", label: labels.guides, render: () => <GuidesPanel locale={context.locale} /> })
|
||||
// kit:guides:end
|
||||
if (moduleSelected("support")) sections.push(supportSection({ label: labels.support, context })) // kit:support
|
||||
return {
|
||||
any: sections.length > 0,
|
||||
occupant: (section) =>
|
||||
helpOccupant({ title: labels.title, label: labels.sections, sections, ...(section ? { section } : {}) }),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The document says when it is interactive: `data-hydrated="true"` on <html>, set once React has hydrated the
|
||||
// page. The server never renders it, so its presence means a press reaches a handler. A press before that is
|
||||
// lost (a tab stays unselected, a checkbox ignores Space), which is why browser tests wait for it
|
||||
// (`ready(page)` in e2e/support/app.ts) rather than retrying their clicks.
|
||||
|
||||
import { useEffect } from "react"
|
||||
|
||||
export function useHydratedSignal(): void {
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.hydrated = "true"
|
||||
}, [])
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
"use client"
|
||||
|
||||
// The button that accepts an invitation (spec §3.1). Accepting is a POST, never the page load: a link that is
|
||||
// opened by a mail scanner, a preview or a prefetch must not use up a token that is good once. Accepted, the
|
||||
// person goes straight to the organization's workspace the kernel names. A refusal is worded per state -- the
|
||||
// kernel gives each a code of its own for the person holding the invitee's token, and one NotFound for
|
||||
// everybody else (kit-tenancy) -- and none of them says more than the person already knows.
|
||||
|
||||
import { DataError } from "@conusai-kit/ui/data"
|
||||
import { Alert, AlertDescription, Button } from "@conusai-kit/ui/primitives"
|
||||
import { type ReactElement, useState } from "react"
|
||||
import { call } from "./client"
|
||||
import { adoptOrganizationLanguage } from "./organization-language"
|
||||
|
||||
export interface InviteAcceptLabels {
|
||||
readonly accept: string
|
||||
readonly accepted: string
|
||||
readonly invalid: string
|
||||
readonly used: string
|
||||
readonly revoked: string
|
||||
readonly expired: string
|
||||
readonly member: string
|
||||
readonly failed: string
|
||||
}
|
||||
|
||||
/** Which message a refusal gets: the problem's `code`, which the kernel keeps stable. */
|
||||
export function inviteErrorKey(
|
||||
code: string,
|
||||
): keyof Pick<InviteAcceptLabels, "invalid" | "used" | "revoked" | "expired" | "member" | "failed"> {
|
||||
switch (code) {
|
||||
case "not_found":
|
||||
return "invalid"
|
||||
case "invitation_used":
|
||||
return "used"
|
||||
case "invitation_revoked":
|
||||
return "revoked"
|
||||
case "invitation_expired":
|
||||
return "expired"
|
||||
case "already_member":
|
||||
return "member"
|
||||
default:
|
||||
return "failed"
|
||||
}
|
||||
}
|
||||
|
||||
export function InviteAccept({
|
||||
org,
|
||||
token,
|
||||
labels,
|
||||
}: {
|
||||
org: string
|
||||
token: string
|
||||
labels: InviteAcceptLabels
|
||||
}): ReactElement {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [joined, setJoined] = useState(false)
|
||||
const [problem, setProblem] = useState<string | undefined>(undefined)
|
||||
|
||||
const accept = async (): Promise<void> => {
|
||||
setBusy(true)
|
||||
setProblem(undefined)
|
||||
try {
|
||||
const { workspace } = await call<{ workspace: string }>("tenancy.invitation.accept", { org, token })
|
||||
await adoptOrganizationLanguage(org)
|
||||
setJoined(true)
|
||||
window.location.assign(`/${encodeURIComponent(org)}/${encodeURIComponent(workspace)}`)
|
||||
} catch (error) {
|
||||
setProblem(labels[inviteErrorKey(error instanceof DataError ? (error.problem.code ?? "") : "")])
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (joined) return <p role="status">{labels.accepted}</p>
|
||||
return (
|
||||
<>
|
||||
<Button disabled={busy} onClick={accept}>
|
||||
{labels.accept}
|
||||
</Button>
|
||||
{problem ? (
|
||||
<Alert variant="destructive" role="alert">
|
||||
<AlertDescription>{problem}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// What shows while a page that has real work to do is being fetched: a route's `pendingComponent`. It must
|
||||
// not wait for anything itself, so its label comes from the root route's loader data (`ShellData`), which
|
||||
// is already in the page.
|
||||
|
||||
import { PageSkeleton } from "@conusai-kit/ui/patterns"
|
||||
import type { ReactElement } from "react"
|
||||
|
||||
/** A pattern and not `StateView`: every route that shows it is inside the frame, whose <main> this is. */
|
||||
export function LoadingPage({ label }: { readonly label: string }): ReactElement {
|
||||
return <PageSkeleton label={label} />
|
||||
}
|
||||
|
||||
// Where it is mounted, and where it is not, is deliberate. Every navigation is a full server render (F10)
|
||||
// and a loader finishes before the response starts, so the status a loader set -- a 302 to sign in, a 404
|
||||
// for another tenant's slug, a 403 for a member without the permission -- is the response's status. A
|
||||
// pending component shows only in the browser, for a loader that runs there; the server never streams one
|
||||
// in front of a status. It is mounted with `pendingComponent: RoutePending` (src/ui/route-pending.tsx) on the
|
||||
// routes whose loaders are slow: the members, the roles, the invitations and the audit logs.
|
||||
@@ -0,0 +1,418 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// This app's own message catalogue, merged over the kit's (spec §10.4). Every string a page shows
|
||||
// is a key here, in English and in Lithuanian, so a locale is a catalogue and not a code change.
|
||||
//
|
||||
// Every module's messages -- the kernel's included -- come from its manifest (`i18n: { en, lt }`) and are
|
||||
// merged by kit-i18n from the registry, so a module's strings reach the pages with no edit outside the
|
||||
// module. What is here is the app shell's own: the `app.*` messages.
|
||||
|
||||
import { appModules } from "@app/modules"
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { type Catalogues, moduleCatalogues } from "@conusai-kit/i18n"
|
||||
|
||||
const en = {
|
||||
consent: {
|
||||
title: "Privacy choices",
|
||||
body: "Accept optional analytics, or reject them. Accepting and rejecting are equally easy. Session replay is separate and stays off unless you select it.",
|
||||
replay: "Also allow session replay, with text, inputs and media masked",
|
||||
accept: "Accept analytics",
|
||||
reject: "Reject analytics and replay",
|
||||
reopen: "Privacy choices",
|
||||
gpc: "Global Privacy Control is on: analytics and replay stay off.",
|
||||
},
|
||||
app: {
|
||||
signIn: "Sign in",
|
||||
signOut: "Sign out",
|
||||
signedOut: "You are signed out.",
|
||||
signedOutBody: "Close this tab, or sign in again.",
|
||||
home: "Home",
|
||||
organizations: "Organizations",
|
||||
chooseOrganization: "Choose an organization to continue.",
|
||||
noOrganizations: "You are not a member of an organization yet.",
|
||||
askForInvitation: "Ask your organization's administrator for an invitation.",
|
||||
createOrganization: "Create an organization",
|
||||
name: "Name",
|
||||
slug: "Short name",
|
||||
create: "Create",
|
||||
cancel: "Cancel",
|
||||
role: "Role",
|
||||
email: "E-mail address",
|
||||
invite: "Invite",
|
||||
remove: "Remove",
|
||||
save: "Save",
|
||||
workspaces: "Workspaces",
|
||||
chooseWorkspace: "Choose a workspace to view its projects.",
|
||||
projects: "Projects",
|
||||
members: "Members",
|
||||
invitations: "Invitations",
|
||||
settings: "Settings",
|
||||
audit: "Audit",
|
||||
auditEmpty: "No audit events for this organization yet.",
|
||||
action: "Action",
|
||||
outcome: "Outcome",
|
||||
when: "When",
|
||||
resource: "Resource",
|
||||
status: "Status",
|
||||
failed: "That did not work: {detail}",
|
||||
open: "Open",
|
||||
workspace: "Workspace",
|
||||
project: "Project",
|
||||
},
|
||||
shell: {
|
||||
organization: "Organization",
|
||||
language: "Language",
|
||||
languageEn: "English",
|
||||
languageLt: "Lietuvių",
|
||||
theme: "Theme",
|
||||
themeDark: "Dark",
|
||||
themeLight: "Light",
|
||||
themeSystem: "System",
|
||||
switchToLight: "Switch to light theme",
|
||||
switchToDark: "Switch to dark theme",
|
||||
account: "Signed in as {name}",
|
||||
sections: "Settings sections",
|
||||
helpSections: "Help sections",
|
||||
guides: "Guides",
|
||||
support: "Support",
|
||||
general: "General",
|
||||
organizationTitle: "Organization",
|
||||
defaultLanguage: "Default language",
|
||||
defaultLanguageHint: "New members start in this language.",
|
||||
saved: "Saved",
|
||||
projects: "Projects",
|
||||
},
|
||||
onboarding: {
|
||||
title: "Create your organization",
|
||||
description: "Your organization holds members, workspaces and projects. You will be its owner.",
|
||||
name: "Organization name",
|
||||
nameHelp: "Usually your company or team. You can change it later.",
|
||||
address: "Address",
|
||||
addressHelp: "Lowercase letters, numbers and hyphens. Used in links.",
|
||||
continue: "Continue",
|
||||
required: "Fill this in.",
|
||||
addressInvalid: "Use lowercase letters, numbers and single hyphens.",
|
||||
addressTaken: "This address is taken. Choose another.",
|
||||
failed: "The organization could not be created. Try again.",
|
||||
workspaceName: "General",
|
||||
workspaceSlug: "general",
|
||||
},
|
||||
signin: {
|
||||
title: "Sign in",
|
||||
createAccount: "Create an account",
|
||||
devTitle: "Development sign-in",
|
||||
devHelp:
|
||||
"This deployment has no identity provider, so any e-mail address signs in. It exists for development and is refused in production.",
|
||||
devSubmit: "Sign in",
|
||||
notConfigured:
|
||||
"Sign-in is not configured for this deployment. Ask the person who runs it to set an identity provider.",
|
||||
error: {
|
||||
sessionExpired: "Your session has ended. Sign in again.",
|
||||
noTransaction: "That sign-in was not started in this browser, or it took too long. Start again.",
|
||||
denied: "The identity provider did not let you in. Try again, or use another account.",
|
||||
provider: "The identity provider could not complete the sign-in. Try again in a moment.",
|
||||
failed: "Signing in did not work. Try again.",
|
||||
},
|
||||
// kit:convex-better-auth:begin
|
||||
account: {
|
||||
name: "Name",
|
||||
password: "Password",
|
||||
passwordHelp: "At least {min} characters.",
|
||||
signIn: "Sign in",
|
||||
signUp: "Create the account",
|
||||
toSignUp: "No account yet? Create one",
|
||||
toSignIn: "Already have an account? Sign in",
|
||||
signUpTitle: "Create an account",
|
||||
signUpIntro: "Create an account with your e-mail address and a password.",
|
||||
error: {
|
||||
invalid: "That e-mail address and password do not match an account.",
|
||||
exists: "An account with that e-mail address exists already. Sign in instead.",
|
||||
disabled: "This account is disabled. Ask the people who run this app.",
|
||||
rateLimited: "Too many attempts. Wait a moment and try again.",
|
||||
unavailable: "Signing in is not possible right now. Try again in a moment.",
|
||||
failed: "That did not work. Check the form and try again.",
|
||||
},
|
||||
},
|
||||
// kit:convex-better-auth:end
|
||||
},
|
||||
invite: {
|
||||
title: "You are invited",
|
||||
lead: "{email} is invited to join {org} as {role}.",
|
||||
leadNoRole: "{email} is invited to join {org}.",
|
||||
expires: "The link works once, until {date}.",
|
||||
createAccount: "Create your account",
|
||||
haveAccount: "I have an account: sign in",
|
||||
signInFirst: "Sign in with the e-mail address the invitation was sent to, then come back to this page.",
|
||||
signedInAs: "You are signed in as {email}.",
|
||||
otherAccount: "This invitation is for {email}. Sign in with that address to accept it.",
|
||||
accept: "Accept the invitation",
|
||||
accepted: "You joined {org}.",
|
||||
switchAccount: "Sign in with another account",
|
||||
error: {
|
||||
unknown: "This invitation link is not valid. Check that you have all of it, or ask for a new one.",
|
||||
invalid:
|
||||
"This invitation is not valid for the account you are signed in with. Sign in with the address it was sent to, or ask for a new one.",
|
||||
used: "This invitation has already been used. Sign in to open the organization.",
|
||||
revoked: "This invitation was revoked. Ask an administrator of the organization for a new one.",
|
||||
expired: "This invitation has expired. Ask an administrator of the organization for a new one.",
|
||||
member: "You are already a member of this organization.",
|
||||
failed: "The invitation could not be accepted. Try again.",
|
||||
},
|
||||
},
|
||||
boundary: {
|
||||
errorTitle: "Something went wrong",
|
||||
errorBody: "The page could not be shown. Try again. If it keeps happening, tell the person who runs this app.",
|
||||
errorCode: "Error code: {digest}",
|
||||
retry: "Try again",
|
||||
notFoundTitle: "Page not found",
|
||||
notFoundBody: "There is nothing at this address, or you do not have access to it.",
|
||||
forbiddenTitle: "You do not have access",
|
||||
forbiddenBody: "Your role does not allow this page. Ask an administrator of the organization.",
|
||||
home: "Back to the start",
|
||||
loading: "Loading",
|
||||
},
|
||||
workspace: {
|
||||
title: "Workspace {name}",
|
||||
list: "Workspaces",
|
||||
none: "There are no workspaces you can see.",
|
||||
create: "Create a workspace",
|
||||
visibility: "Visibility",
|
||||
visibilityOrg: "Everyone in the organization",
|
||||
visibilityRestricted: "Invited members only",
|
||||
default: "Default",
|
||||
projects: "Projects",
|
||||
overview: "Workspace overview",
|
||||
group: "Workspace",
|
||||
projectGroup: "Project",
|
||||
chooseProject: "Choose a project",
|
||||
switchProject: "Switch project",
|
||||
organizationSettings: "Organization settings",
|
||||
nextAction: "Open a project to continue its work, or choose a workspace tool from the sidebar.",
|
||||
projectCount: "{count, plural, one {# project} other {# projects}}",
|
||||
noProjects: "There are no projects in this workspace yet.",
|
||||
createProject: "Create a project",
|
||||
module: "Module",
|
||||
},
|
||||
project: {
|
||||
title: "Project {name}",
|
||||
overview: "Project overview",
|
||||
work: "Work in this project",
|
||||
noSections: "This project has no sections available to you.",
|
||||
archived: "This project is archived.",
|
||||
module: "Created by the module {module}",
|
||||
},
|
||||
} as const
|
||||
|
||||
const lt = {
|
||||
consent: {
|
||||
title: "Privatumo pasirinkimai",
|
||||
body: "Priimkite neprivalomą analitiką arba ją atmeskite. Priimti ir atmesti vienodai paprasta. Seanso atkūrimas yra atskiras ir išlieka išjungtas, jei jo nepasirinksite.",
|
||||
replay: "Taip pat leisti seanso atkūrimą, paslėpus tekstą, įvestis ir vaizdinę medžiagą",
|
||||
accept: "Priimti analitiką",
|
||||
reject: "Atmesti analitiką ir atkūrimą",
|
||||
reopen: "Privatumo pasirinkimai",
|
||||
gpc: "Įjungtas Global Privacy Control: analitika ir atkūrimas išlieka išjungti.",
|
||||
},
|
||||
app: {
|
||||
signIn: "Prisijungti",
|
||||
signOut: "Atsijungti",
|
||||
signedOut: "Jūs atsijungėte.",
|
||||
signedOutBody: "Užverkite šią kortelę arba prisijunkite iš naujo.",
|
||||
home: "Pradžia",
|
||||
organizations: "Organizacijos",
|
||||
chooseOrganization: "Pasirinkite organizaciją, kad tęstumėte.",
|
||||
noOrganizations: "Kol kas nesate jokios organizacijos narys.",
|
||||
askForInvitation: "Paprašykite savo organizacijos administratoriaus pakvietimo.",
|
||||
createOrganization: "Sukurti organizaciją",
|
||||
name: "Pavadinimas",
|
||||
slug: "Trumpasis pavadinimas",
|
||||
create: "Sukurti",
|
||||
cancel: "Atsisakyti",
|
||||
role: "Rolė",
|
||||
email: "El. pašto adresas",
|
||||
invite: "Pakviesti",
|
||||
remove: "Pašalinti",
|
||||
save: "Išsaugoti",
|
||||
workspaces: "Darbo erdvės",
|
||||
chooseWorkspace: "Pasirinkite darbo erdvę, kad peržiūrėtumėte jos projektus.",
|
||||
projects: "Projektai",
|
||||
members: "Nariai",
|
||||
invitations: "Pakvietimai",
|
||||
settings: "Nustatymai",
|
||||
audit: "Auditas",
|
||||
auditEmpty: "Šiai organizacijai audito įrašų dar nėra.",
|
||||
action: "Veiksmas",
|
||||
outcome: "Rezultatas",
|
||||
when: "Kada",
|
||||
resource: "Objektas",
|
||||
status: "Būsena",
|
||||
failed: "Nepavyko: {detail}",
|
||||
open: "Atverti",
|
||||
workspace: "Darbo erdvė",
|
||||
project: "Projektas",
|
||||
},
|
||||
shell: {
|
||||
organization: "Organizacija",
|
||||
language: "Kalba",
|
||||
languageEn: "English",
|
||||
languageLt: "Lietuvių",
|
||||
theme: "Tema",
|
||||
themeDark: "Tamsi",
|
||||
themeLight: "Šviesi",
|
||||
themeSystem: "Sistemos",
|
||||
switchToLight: "Perjungti į šviesią temą",
|
||||
switchToDark: "Perjungti į tamsią temą",
|
||||
account: "Prisijungęs: {name}",
|
||||
sections: "Nustatymų skyriai",
|
||||
helpSections: "Pagalbos skyriai",
|
||||
guides: "Gidai",
|
||||
support: "Pagalba",
|
||||
general: "Bendrieji",
|
||||
organizationTitle: "Organizacija",
|
||||
defaultLanguage: "Numatytoji kalba",
|
||||
defaultLanguageHint: "Nauji nariai pradeda šia kalba.",
|
||||
saved: "Išsaugota",
|
||||
projects: "Projektai",
|
||||
},
|
||||
onboarding: {
|
||||
title: "Sukurkite savo organizaciją",
|
||||
description: "Organizacijoje yra nariai, darbo erdvės ir projektai. Jūsų vaidmuo bus „Savininkas“.",
|
||||
name: "Organizacijos pavadinimas",
|
||||
nameHelp: "Dažniausiai tai jūsų įmonė arba komanda. Vėliau galėsite pakeisti.",
|
||||
address: "Adresas",
|
||||
addressHelp: "Mažosios raidės, skaičiai ir brūkšneliai. Naudojama nuorodose.",
|
||||
continue: "Tęsti",
|
||||
required: "Užpildykite šį lauką.",
|
||||
addressInvalid: "Naudokite mažąsias raides, skaičius ir pavienius brūkšnelius.",
|
||||
addressTaken: "Šis adresas užimtas. Pasirinkite kitą.",
|
||||
failed: "Organizacijos sukurti nepavyko. Bandykite dar kartą.",
|
||||
workspaceName: "Bendra",
|
||||
workspaceSlug: "bendra",
|
||||
},
|
||||
signin: {
|
||||
title: "Prisijungimas",
|
||||
createAccount: "Sukurti paskyrą",
|
||||
devTitle: "Prisijungimas kūrimo aplinkoje",
|
||||
devHelp:
|
||||
"Šioje diegtyje nėra tapatybės teikėjo, todėl prisijungti galima su bet kuriuo el. pašto adresu. Tai skirta kūrimui, o produkcinėje aplinkoje neveikia.",
|
||||
devSubmit: "Prisijungti",
|
||||
notConfigured:
|
||||
"Prisijungimas šioje diegtyje nesukonfigūruotas. Paprašykite ją administruojančio asmens nurodyti tapatybės teikėją.",
|
||||
error: {
|
||||
sessionExpired: "Jūsų sesija baigėsi. Prisijunkite iš naujo.",
|
||||
noTransaction: "Šis prisijungimas nebuvo pradėtas šioje naršyklėje arba užtruko per ilgai. Pradėkite iš naujo.",
|
||||
denied: "Tapatybės teikėjas jūsų neįleido. Bandykite dar kartą arba naudokite kitą paskyrą.",
|
||||
provider: "Tapatybės teikėjas nesugebėjo užbaigti prisijungimo. Bandykite dar kartą už kelių akimirkų.",
|
||||
failed: "Prisijungti nepavyko. Bandykite dar kartą.",
|
||||
},
|
||||
// kit:convex-better-auth:begin
|
||||
account: {
|
||||
name: "Vardas",
|
||||
password: "Slaptažodis",
|
||||
passwordHelp: "Ne mažiau kaip {min} simbolių.",
|
||||
signIn: "Prisijungti",
|
||||
signUp: "Sukurti paskyrą",
|
||||
toSignUp: "Dar neturite paskyros? Sukurkite",
|
||||
toSignIn: "Jau turite paskyrą? Prisijunkite",
|
||||
signUpTitle: "Paskyros kūrimas",
|
||||
signUpIntro: "Sukurkite paskyrą su savo el. pašto adresu ir slaptažodžiu.",
|
||||
error: {
|
||||
invalid: "Toks el. pašto adresas ir slaptažodis neatitinka jokios paskyros.",
|
||||
exists: "Paskyra su šiuo el. pašto adresu jau yra. Prisijunkite.",
|
||||
disabled: "Ši paskyra išjungta. Kreipkitės į šios programos administratorius.",
|
||||
rateLimited: "Per daug bandymų. Palaukite ir bandykite dar kartą.",
|
||||
unavailable: "Šiuo metu prisijungti neįmanoma. Bandykite dar kartą už kelių akimirkų.",
|
||||
failed: "Nepavyko. Patikrinkite formą ir bandykite dar kartą.",
|
||||
},
|
||||
},
|
||||
// kit:convex-better-auth:end
|
||||
},
|
||||
invite: {
|
||||
title: "Esate pakviesti",
|
||||
lead: "Adresas {email} pakviestas prisijungti prie „{org}“ su role „{role}“.",
|
||||
leadNoRole: "Adresas {email} pakviestas prisijungti prie „{org}“.",
|
||||
expires: "Nuoroda veikia vieną kartą, iki {date}.",
|
||||
createAccount: "Susikurti paskyrą",
|
||||
haveAccount: "Jau turiu paskyrą: prisijungti",
|
||||
signInFirst: "Prisijunkite su el. pašto adresu, kuriuo gavote kvietimą, ir grįžkite į šį puslapį.",
|
||||
signedInAs: "Esate prisijungę kaip {email}.",
|
||||
otherAccount: "Šis kvietimas skirtas adresui {email}. Prisijunkite šiuo adresu, kad jį priimtumėte.",
|
||||
accept: "Priimti kvietimą",
|
||||
accepted: "Prisijungėte prie „{org}“.",
|
||||
switchAccount: "Prisijungti kita paskyra",
|
||||
error: {
|
||||
unknown: "Ši kvietimo nuoroda negalioja. Patikrinkite, ar nukopijavote ją visą, arba paprašykite naujos.",
|
||||
invalid:
|
||||
"Šis kvietimas netinka paskyrai, su kuria esate prisijungę. Prisijunkite su adresu, kuriuo jį gavote, arba paprašykite naujo.",
|
||||
used: "Šis kvietimas jau panaudotas. Prisijunkite, kad atvertumėte organizaciją.",
|
||||
revoked: "Šis kvietimas atšauktas. Paprašykite organizacijos administratoriaus naujo.",
|
||||
expired: "Šio kvietimo galiojimas baigėsi. Paprašykite organizacijos administratoriaus naujo.",
|
||||
member: "Jau esate šios organizacijos narys.",
|
||||
failed: "Kvietimo priimti nepavyko. Bandykite dar kartą.",
|
||||
},
|
||||
},
|
||||
boundary: {
|
||||
errorTitle: "Kažkas nepavyko",
|
||||
errorBody:
|
||||
"Puslapio parodyti nepavyko. Bandykite dar kartą. Jei klaida kartosis, praneškite šią programą administruojančiam asmeniui.",
|
||||
errorCode: "Klaidos kodas: {digest}",
|
||||
retry: "Bandyti dar kartą",
|
||||
notFoundTitle: "Puslapis nerastas",
|
||||
notFoundBody: "Šiuo adresu nieko nėra arba neturite prieigos.",
|
||||
forbiddenTitle: "Neturite prieigos",
|
||||
forbiddenBody: "Jūsų rolė šio puslapio neleidžia. Kreipkitės į organizacijos administratorių.",
|
||||
home: "Grįžti į pradžią",
|
||||
loading: "Kraunama",
|
||||
},
|
||||
workspace: {
|
||||
title: "Darbo erdvė „{name}“",
|
||||
list: "Darbo erdvės",
|
||||
none: "Matomų darbo erdvių nėra.",
|
||||
create: "Sukurti darbo erdvę",
|
||||
visibility: "Matomumas",
|
||||
visibilityOrg: "Visi organizacijos nariai",
|
||||
visibilityRestricted: "Tik pakviesti nariai",
|
||||
default: "Numatytoji",
|
||||
projects: "Projektai",
|
||||
overview: "Darbo erdvės apžvalga",
|
||||
group: "Darbo erdvė",
|
||||
projectGroup: "Projektas",
|
||||
chooseProject: "Pasirinkite projektą",
|
||||
switchProject: "Perjungti projektą",
|
||||
organizationSettings: "Organizacijos nustatymai",
|
||||
nextAction: "Atverkite projektą ir tęskite darbą arba šoninėje juostoje pasirinkite darbo erdvės įrankį.",
|
||||
projectCount: "{count, plural, one {# projektas} few {# projektai} other {# projektų}}",
|
||||
noProjects: "Šioje darbo erdvėje projektų dar nėra.",
|
||||
createProject: "Sukurti projektą",
|
||||
module: "Modulis",
|
||||
},
|
||||
project: {
|
||||
title: "Projektas „{name}“",
|
||||
overview: "Projekto apžvalga",
|
||||
work: "Darbas šiame projekte",
|
||||
noSections: "Šiame projekte jums prieinamų skyrių nėra.",
|
||||
archived: "Šis projektas suarchyvuotas.",
|
||||
module: "Sukūrė modulis: {module}",
|
||||
},
|
||||
} as const
|
||||
|
||||
/** `tenancy.nav.members` is one key with a dot inside the module's own part, so it is flattened. */
|
||||
const flatten = (input: Record<string, unknown>, prefix = ""): Record<string, string> => {
|
||||
const out: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
const name = prefix ? `${prefix}.${key}` : key
|
||||
if (typeof value === "string") out[name] = value
|
||||
else Object.assign(out, flatten(value as Record<string, unknown>, name))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const ownCatalogues = (source: Record<string, Record<string, unknown>>): Record<string, Record<string, string>> =>
|
||||
Object.fromEntries(Object.entries(source).map(([module, messages]) => [module, flatten(messages)]))
|
||||
|
||||
/** The kit's built-in messages and every module's, from the registry this app boots with. */
|
||||
const fromModules = moduleCatalogues(buildRegistry([...appModules]))
|
||||
|
||||
export const appCatalogues: Catalogues = {
|
||||
en: { ...fromModules.en, ...ownCatalogues(en) },
|
||||
lt: { ...fromModules.lt, ...ownCatalogues(lt) },
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
"use client"
|
||||
|
||||
// Onboarding's one card: the organization's name and its address. The address follows the name until the person
|
||||
// edits it, in the change handler and never in an effect: a stage page froze when an effect re-ran after a taken
|
||||
// address. The first workspace comes with the organization, named in the deployment's language.
|
||||
|
||||
import { DataError } from "@conusai-kit/ui/data"
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
Button,
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
Spinner,
|
||||
} from "@conusai-kit/ui/primitives"
|
||||
import { type FormEvent, type ReactElement, useState } from "react"
|
||||
import { call } from "./client"
|
||||
|
||||
export interface OnboardingLabels {
|
||||
readonly name: string
|
||||
readonly nameHelp: string
|
||||
readonly address: string
|
||||
readonly addressHelp: string
|
||||
readonly addressPrefix: string
|
||||
readonly continue: string
|
||||
readonly required: string
|
||||
readonly addressInvalid: string
|
||||
readonly addressTaken: string
|
||||
readonly failed: string
|
||||
}
|
||||
|
||||
/** The same rule as kit-tenancy's `slugSchema`: 1 to 48 lower-case letters, digits and inner dashes. */
|
||||
const ADDRESS = /^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$/
|
||||
|
||||
/** An address from a name: without its accents, lower-case, dashes for the rest. */
|
||||
export function addressFrom(name: string): string {
|
||||
return name
|
||||
.normalize("NFD")
|
||||
.replace(/\p{Mark}/gu, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+/, "")
|
||||
.slice(0, 48)
|
||||
.replace(/-+$/, "")
|
||||
}
|
||||
|
||||
interface Failure {
|
||||
readonly field?: "name" | "address"
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
export function OnboardingForm({
|
||||
labels,
|
||||
workspace,
|
||||
}: {
|
||||
readonly labels: OnboardingLabels
|
||||
/** The first workspace: its name in the deployment's language, and its address. */
|
||||
readonly workspace: { readonly name: string; readonly slug: string }
|
||||
}): ReactElement {
|
||||
const [name, setName] = useState("")
|
||||
const [address, setAddress] = useState("")
|
||||
const [edited, setEdited] = useState(false)
|
||||
const [pending, setPending] = useState(false)
|
||||
const [failure, setFailure] = useState<Failure | null>(null)
|
||||
|
||||
const submit = async (event: FormEvent): Promise<void> => {
|
||||
event.preventDefault()
|
||||
if (name.trim() === "") return setFailure({ field: "name", message: labels.required })
|
||||
if (!ADDRESS.test(address)) return setFailure({ field: "address", message: labels.addressInvalid })
|
||||
setPending(true)
|
||||
setFailure(null)
|
||||
try {
|
||||
const created = (await call("tenancy.org.create", { name: name.trim(), slug: address, workspace })) as {
|
||||
slug: string
|
||||
workspaceSlug: string
|
||||
}
|
||||
window.location.assign(`/${encodeURIComponent(created.slug)}/${encodeURIComponent(created.workspaceSlug)}`)
|
||||
} catch (error) {
|
||||
setPending(false)
|
||||
const taken = error instanceof DataError && error.status === 409
|
||||
setFailure(taken ? { field: "address", message: labels.addressTaken } : { message: labels.failed })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={(event) => void submit(event)} noValidate>
|
||||
{failure && !failure.field ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{failure.message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<FieldGroup>
|
||||
<Field data-invalid={failure?.field === "name"}>
|
||||
<FieldLabel>{labels.name}</FieldLabel>
|
||||
<Input
|
||||
name="name"
|
||||
autoComplete="organization"
|
||||
autoFocus
|
||||
maxLength={120}
|
||||
value={name}
|
||||
disabled={pending}
|
||||
onChange={(event) => {
|
||||
setName(event.currentTarget.value)
|
||||
if (!edited) setAddress(addressFrom(event.currentTarget.value))
|
||||
}}
|
||||
/>
|
||||
<FieldDescription>{labels.nameHelp}</FieldDescription>
|
||||
{failure?.field === "name" ? <FieldError>{failure.message}</FieldError> : null}
|
||||
</Field>
|
||||
<Field data-invalid={failure?.field === "address"}>
|
||||
<FieldLabel>{labels.address}</FieldLabel>
|
||||
<InputGroup>
|
||||
<InputGroupAddon>
|
||||
<InputGroupText>{labels.addressPrefix}</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
name="slug"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
maxLength={48}
|
||||
value={address}
|
||||
disabled={pending}
|
||||
onChange={(event) => {
|
||||
setEdited(true)
|
||||
setAddress(event.currentTarget.value)
|
||||
if (failure?.field === "address") setFailure(null)
|
||||
}}
|
||||
/>
|
||||
</InputGroup>
|
||||
<FieldDescription>{labels.addressHelp}</FieldDescription>
|
||||
{failure?.field === "address" ? <FieldError>{failure.message}</FieldError> : null}
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? <Spinner aria-hidden="true" /> : null}
|
||||
{labels.continue}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
"use client"
|
||||
|
||||
// An organization's default language: `settings.defaultLocale` on its row, through `tenancy.org.update`, so
|
||||
// nothing but the settings page writes it. A person's language is their own (a signed-in person's row always
|
||||
// holds one), so the default takes effect when someone joins: `adoptOrganizationLanguage` runs once, as an
|
||||
// invitation is accepted, and leaves alone anyone who has chosen a language in this browser.
|
||||
|
||||
import { call } from "./client"
|
||||
import { LOCALE_COOKIE, preferenceCookie } from "./preference-cookies"
|
||||
|
||||
export const DEFAULT_LOCALE_SETTING = "defaultLocale"
|
||||
|
||||
/** What the settings form shows, and the version it saves over. */
|
||||
export interface OrganizationFields {
|
||||
readonly name: string
|
||||
readonly defaultLocale: string
|
||||
readonly version: number
|
||||
}
|
||||
|
||||
export interface OrganizationLabels {
|
||||
readonly title: string
|
||||
readonly name: string
|
||||
readonly language: string
|
||||
readonly languageHint: string
|
||||
readonly languages: readonly { readonly value: string; readonly label: string }[]
|
||||
readonly save: string
|
||||
readonly saved: string
|
||||
}
|
||||
|
||||
export async function adoptOrganizationLanguage(org: string): Promise<void> {
|
||||
if (document.cookie.split("; ").some((cookie) => cookie.startsWith(`${LOCALE_COOKIE}=`))) return
|
||||
try {
|
||||
const { settings } = (await call("tenancy.org.get", { org })) as { settings: Record<string, unknown> }
|
||||
const locale = settings[DEFAULT_LOCALE_SETTING]
|
||||
if (typeof locale !== "string") return
|
||||
await call("tenancy.me.update", { locale })
|
||||
// biome-ignore lint/suspicious/noDocumentCookie: a preference cookie of a few bytes; the Cookie Store API is not in every browser this app serves
|
||||
document.cookie = preferenceCookie(LOCALE_COOKIE, locale)
|
||||
} catch {
|
||||
// The person has joined; the language is theirs to set from the menu.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
"use client"
|
||||
|
||||
// The organization's name and default language: one section, saved through `tenancy.org.update`. The page
|
||||
// is read again after a save, so the frame shows the new name and the next save carries the new version.
|
||||
|
||||
import { SelectField, SettingsSection, TextField } from "@conusai-kit/ui/patterns"
|
||||
import { useRouter } from "@tanstack/react-router"
|
||||
import type { ReactElement } from "react"
|
||||
import { call } from "./client"
|
||||
import { DEFAULT_LOCALE_SETTING, type OrganizationFields, type OrganizationLabels } from "./organization-language"
|
||||
|
||||
export function OrganizationSettings({
|
||||
org,
|
||||
organization,
|
||||
labels,
|
||||
}: {
|
||||
org: string
|
||||
organization: OrganizationFields
|
||||
labels: OrganizationLabels
|
||||
}): ReactElement {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<SettingsSection
|
||||
title={labels.title}
|
||||
saveLabel={labels.save}
|
||||
savedLabel={labels.saved}
|
||||
onSave={async ({ name, language }) => {
|
||||
// The row's settings hold what other modules keep there, so they are read here and written back whole.
|
||||
const { settings } = (await call("tenancy.org.get", { org })) as { settings: Record<string, unknown> }
|
||||
await call("tenancy.org.update", {
|
||||
org,
|
||||
name,
|
||||
settings: { ...settings, [DEFAULT_LOCALE_SETTING]: language },
|
||||
expectedVersion: organization.version,
|
||||
})
|
||||
await router.invalidate()
|
||||
}}
|
||||
>
|
||||
<TextField name="name" label={labels.name} required maxLength={200} defaultValue={organization.name} />
|
||||
<SelectField
|
||||
name="language"
|
||||
label={labels.language}
|
||||
description={labels.languageHint}
|
||||
defaultValue={organization.defaultLocale}
|
||||
options={[...labels.languages]}
|
||||
/>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// A matched server-rendered page must hydrate without suspending on its own code:
|
||||
// replacing that markup with a lazy fallback can discard a person's first click.
|
||||
// Prepare only the initial matched module; later navigations keep loading on demand.
|
||||
|
||||
import { modulePages, workspacePages } from "@app/modules/pages"
|
||||
import { type ComponentType, lazy } from "react"
|
||||
|
||||
type PageLoader<Props> = () => Promise<{ readonly View: ComponentType<Props> }>
|
||||
|
||||
export function createPageViews<Props>(pages: Readonly<Record<string, PageLoader<Props>>>) {
|
||||
const entries = new Map<string, { component?: ComponentType<Props>; pending?: Promise<ComponentType<Props>> }>()
|
||||
const has = (id: string): boolean => Object.hasOwn(pages, id)
|
||||
const entryOf = (id: string) => {
|
||||
if (!has(id)) throw new Error(`no page for module ${id}`)
|
||||
let entry = entries.get(id)
|
||||
if (!entry) {
|
||||
entry = {}
|
||||
entries.set(id, entry)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
const prepare = (id: string): Promise<ComponentType<Props>> => {
|
||||
const entry = entryOf(id)
|
||||
const load = pages[id]
|
||||
if (!load) throw new Error(`no page for module ${id}`)
|
||||
entry.pending ??= load().then(({ View }) => {
|
||||
// When prepared before the first render, no React.lazy promise sits
|
||||
// between the existing server markup and its first client render.
|
||||
entry.component ??= View
|
||||
return View
|
||||
})
|
||||
return entry.pending
|
||||
}
|
||||
return {
|
||||
has,
|
||||
prepare,
|
||||
viewOf(id: string): ComponentType<Props> {
|
||||
const entry = entryOf(id)
|
||||
entry.component ??= lazy(async () => ({ default: await prepare(id) }))
|
||||
return entry.component
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const projectPageViews = createPageViews(modulePages)
|
||||
export const workspacePageViews = createPageViews(workspacePages)
|
||||
|
||||
interface HydratedMatch {
|
||||
readonly routeId: string
|
||||
readonly status: string
|
||||
readonly loaderData?: unknown
|
||||
}
|
||||
|
||||
export async function prepareInitialModuleViews(matches: readonly HydratedMatch[]): Promise<void> {
|
||||
// A failed view import belongs to its route's error boundary. The router must
|
||||
// still mount; rejecting the root hydration promise would remove the whole shell.
|
||||
await Promise.allSettled(
|
||||
matches.map(async ({ routeId, status, loaderData }) => {
|
||||
if (status !== "success" || !loaderData || typeof loaderData !== "object") return
|
||||
const module = "module" in loaderData ? loaderData.module : undefined
|
||||
if (typeof module !== "string") return
|
||||
const pages =
|
||||
routeId === "/$org/$workspace/modules/$module/$"
|
||||
? workspacePageViews
|
||||
: routeId === "/$org/$workspace/$project/$module/$"
|
||||
? projectPageViews
|
||||
: undefined
|
||||
if (pages?.has(module)) await pages.prepare(module)
|
||||
}),
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user