This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The one route every module's pages are mounted through: `/<org>/<workspace>/<project>/<module>/...`.
|
||||
//
|
||||
// A module that brings pages lists them in `modules/index.ts` (`modulePages`), keyed by the segment
|
||||
// it answers; this route looks the segment up, calls the module's server component with the
|
||||
// request's own caller and messages, and wraps what it returns in the transport its client
|
||||
// components call procedures through. 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 { modulePages } from "@app/modules"
|
||||
import { AppError } from "@conusai-kit/core"
|
||||
import { notFound } from "next/navigation"
|
||||
import type { ReactElement } from "react"
|
||||
import { webRuntime } from "../../../../../../src/server/app"
|
||||
import { pageContext } from "../../../../../../src/server/page"
|
||||
import { AppTransport } from "../../../../../../src/ui/transport"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function ModulePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ org: string; workspace: string; project: string; module: string; rest?: string[] }>
|
||||
}): Promise<ReactElement> {
|
||||
const { module, rest, ...slugs } = await params
|
||||
if (!webRuntime().registry.modules.some((entry) => entry.id === module)) notFound()
|
||||
// `Object.hasOwn`: a segment named `constructor` is not a module.
|
||||
const load = Object.hasOwn(modulePages, module) ? modulePages[module] : undefined
|
||||
if (!load) notFound()
|
||||
const { caller, i18n, container } = await pageContext()
|
||||
const page = await load()
|
||||
const content = await page({ slugs, segments: rest ?? [], caller, i18n, container }).catch((error: unknown) => {
|
||||
if (error instanceof AppError && error.status === 404) notFound()
|
||||
throw error
|
||||
})
|
||||
return <AppTransport>{content}</AppTransport>
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// 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.
|
||||
|
||||
import { PROFILE } from "@app/config"
|
||||
import { documentGet } from "@app/modules/documents"
|
||||
import { ExportButton, exportLabels, LineReview, reviewLabels } from "@app/modules/documents/ui"
|
||||
import { AppError } from "@conusai-kit/core"
|
||||
import { notFound } from "next/navigation"
|
||||
import type { ReactElement } from "react"
|
||||
import { pageContext } from "../../../../../../src/server/page"
|
||||
import { AppTransport } from "../../../../../../src/ui/transport"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function DocumentPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ org: string; workspace: string; project: string; fileId: string }>
|
||||
}): Promise<ReactElement> {
|
||||
const { fileId, ...slugs } = await params
|
||||
if (PROFILE === "convex") notFound()
|
||||
const { caller, i18n } = await pageContext()
|
||||
const detail = await caller.call(documentGet, { ...slugs, fileId }).catch((error: unknown) => {
|
||||
// A document of another organization and one that does not exist are the same page.
|
||||
if (error instanceof AppError && error.status === 404) notFound()
|
||||
throw error
|
||||
})
|
||||
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`documents.${key}`, values)
|
||||
const failed = i18n.message("app.failed")
|
||||
const { document, lines } = detail
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
<a href={`/${slugs.org}/${slugs.workspace}/${slugs.project}/documents`}>
|
||||
{i18n.message("documents.review.back")}
|
||||
</a>
|
||||
</p>
|
||||
<h1>{i18n.message("documents.review.title", { filename: document.filename })}</h1>
|
||||
<p>
|
||||
{i18n.message("documents.list.status")}: {i18n.message(`documents.status.${document.status}`)}
|
||||
{document.failureCode ? ` ${i18n.message(`documents.failure.${document.failureCode}`)}` : ""}
|
||||
</p>
|
||||
<p>
|
||||
{i18n.message("documents.review.pendingCount", { count: document.pending })}.{" "}
|
||||
{i18n.message("documents.review.approvedCount", { count: document.approved })}.
|
||||
</p>
|
||||
{document.status === "queued" || document.status === "processing" ? (
|
||||
<p role="status">{i18n.message("documents.review.waiting")}</p>
|
||||
) : null}
|
||||
{document.skippedPages.length > 0 ? (
|
||||
<section aria-labelledby="skipped-pages">
|
||||
<h2 id="skipped-pages">{i18n.message("documents.review.skippedTitle")}</h2>
|
||||
<ul>
|
||||
{document.skippedPages.map((skipped) => (
|
||||
<li key={skipped.page}>
|
||||
{i18n.message("documents.review.skippedPage", {
|
||||
page: skipped.page,
|
||||
kind: i18n.message(`documents.review.pageClass.${skipped.pageClass}`),
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
<AppTransport>
|
||||
<LineReview
|
||||
slugs={slugs}
|
||||
fileId={fileId}
|
||||
lines={lines}
|
||||
labels={reviewLabels(t, {
|
||||
failed,
|
||||
aiDisclosure: i18n.message("ui.aiDisclosure"),
|
||||
pageWord: i18n.message("ui.page"),
|
||||
})}
|
||||
locale={i18n.locale}
|
||||
/>
|
||||
<ExportButton slugs={slugs} fileId={fileId} approved={document.approved} labels={exportLabels(t, failed)} />
|
||||
</AppTransport>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// 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).
|
||||
|
||||
import { PROFILE } from "@app/config"
|
||||
import { documentList, READABLE_CONTENT_TYPES } from "@app/modules/documents"
|
||||
import { DocumentList, listLabels, UploadForm, uploadLabels } from "@app/modules/documents/ui"
|
||||
import { downloadHref } from "@conusai-kit/ui/download"
|
||||
import { notFound } from "next/navigation"
|
||||
import type { ReactElement } from "react"
|
||||
import { pageContext } from "../../../../../src/server/page"
|
||||
import { AppTransport } from "../../../../../src/ui/transport"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function DocumentsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ org: string; workspace: string; project: string }>
|
||||
}): Promise<ReactElement> {
|
||||
const slugs = await params
|
||||
if (PROFILE === "convex") notFound()
|
||||
const { caller, i18n } = await pageContext()
|
||||
const { documents } = await caller.call(documentList, slugs)
|
||||
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`documents.${key}`, values)
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>{i18n.message("documents.title")}</h1>
|
||||
<p>{i18n.message("documents.intro")}</p>
|
||||
<AppTransport>
|
||||
<UploadForm
|
||||
slugs={slugs}
|
||||
accept={READABLE_CONTENT_TYPES}
|
||||
labels={uploadLabels(t, i18n.message("app.failed"))}
|
||||
/>
|
||||
</AppTransport>
|
||||
<DocumentList
|
||||
documents={documents}
|
||||
hrefFor={(fileId) => `/${slugs.org}/${slugs.workspace}/${slugs.project}/documents/${fileId}`}
|
||||
downloadHrefFor={(fileId) => downloadHref("files.file.download", { ...slugs, fileId })}
|
||||
labels={listLabels(t)}
|
||||
formatDate={(iso) => i18n.date(new Date(iso))}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The project layout: 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 response streams (spec §3.3). `tenancy.project.get` resolves the whole path from the
|
||||
// organization down, each step a scoped read, so nothing here re-implements the wall.
|
||||
|
||||
import { projectGet } from "@conusai-kit/tenancy"
|
||||
import type { ReactNode } from "react"
|
||||
import { orFallback } from "../../../../src/server/guard"
|
||||
import { pageContext } from "../../../../src/server/page"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function ProjectLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: ReactNode
|
||||
params: Promise<{ org: string; workspace: string; project: string }>
|
||||
}): Promise<ReactNode> {
|
||||
const { org, workspace, project } = await params
|
||||
const { caller, path } = await pageContext()
|
||||
await orFallback(path, () => caller.call(projectGet, { org, workspace, project }))
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// 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/ui/projects.ts), so a module added tomorrow
|
||||
// appears here with no edit to this file.
|
||||
|
||||
import { projectGet, workspaceList } from "@conusai-kit/tenancy"
|
||||
import type { ReactElement } from "react"
|
||||
import { orFallback } from "../../../../src/server/guard"
|
||||
import { pageContext } from "../../../../src/server/page"
|
||||
import { Breadcrumb } from "../../../../src/ui/breadcrumb"
|
||||
import { PROJECT_ENTRIES, projectHref } from "../../../../src/ui/projects"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function ProjectHome({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ org: string; workspace: string; project: string }>
|
||||
}): Promise<ReactElement> {
|
||||
const { org, workspace, project } = await params
|
||||
const { caller, i18n, path } = await pageContext()
|
||||
const { found, workspaceName } = await orFallback(path, async () => {
|
||||
const [found, { workspaces }] = await Promise.all([
|
||||
caller.call(projectGet, { org, workspace, project }),
|
||||
caller.call(workspaceList, { org }),
|
||||
])
|
||||
return { found, workspaceName: workspaces.find((entry) => entry.slug === workspace)?.name ?? workspace }
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumb
|
||||
label={i18n.message("shell.breadcrumb")}
|
||||
crumbs={[
|
||||
{ text: i18n.message("app.workspaces"), href: `/${org}/workspaces` },
|
||||
{ text: workspaceName, href: `/${org}/${workspace}` },
|
||||
{ text: found.name, href: projectHref(org, workspace, project) },
|
||||
]}
|
||||
/>
|
||||
<h1>{i18n.message("project.title", { name: found.name })}</h1>
|
||||
<p>{i18n.message("project.module", { module: found.moduleKey })}</p>
|
||||
{found.archived ? <p role="status">{i18n.message("project.archived")}</p> : null}
|
||||
<h2>{i18n.message("project.work")}</h2>
|
||||
<ul>
|
||||
{PROJECT_ENTRIES.map((entry) => (
|
||||
<li key={entry.href}>
|
||||
<a href={projectHref(org, workspace, project, entry)}>{i18n.message(entry.label)}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The workspace layout: the workspace has to be one this person can see, and the page says 404 before
|
||||
// it starts streaming 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.
|
||||
|
||||
import { workspaceList } from "@conusai-kit/tenancy"
|
||||
import { notFound } from "next/navigation"
|
||||
import type { ReactNode } from "react"
|
||||
import { orFallback } from "../../../src/server/guard"
|
||||
import { pageContext } from "../../../src/server/page"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function WorkspaceLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: ReactNode
|
||||
params: Promise<{ org: string; workspace: string }>
|
||||
}): Promise<ReactNode> {
|
||||
const { org, workspace } = await params
|
||||
const { caller, path } = await pageContext()
|
||||
const { workspaces } = await orFallback(path, () => caller.call(workspaceList, { org }))
|
||||
if (!workspaces.some((entry) => entry.slug === workspace)) notFound()
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// 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 { projectList, workspaceList } from "@conusai-kit/tenancy"
|
||||
import type { ReactElement } from "react"
|
||||
import { orFallback } from "../../../src/server/guard"
|
||||
import { holdsAtLeast, pageContext } from "../../../src/server/page"
|
||||
import { Breadcrumb } from "../../../src/ui/breadcrumb"
|
||||
import { ProcedureForm } from "../../../src/ui/procedure-form"
|
||||
import { PROJECT_MODULE_KEYS, projectHref } from "../../../src/ui/projects"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function WorkspaceHome({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ org: string; workspace: string }>
|
||||
}): Promise<ReactElement> {
|
||||
const { org, workspace } = await params
|
||||
const page = await pageContext()
|
||||
const { caller, i18n, path } = page
|
||||
const { name, projects } = await orFallback(path, async () => {
|
||||
const [{ projects }, { workspaces }] = await Promise.all([
|
||||
caller.call(projectList, { org, workspace }),
|
||||
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 = await holdsAtLeast(page, org, "editor")
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumb
|
||||
label={i18n.message("shell.breadcrumb")}
|
||||
crumbs={[
|
||||
{ text: i18n.message("app.workspaces"), href: `/${org}/workspaces` },
|
||||
{ text: name, href: `/${org}/${workspace}` },
|
||||
]}
|
||||
/>
|
||||
<h1>{i18n.message("workspace.title", { name })}</h1>
|
||||
<h2>{i18n.message("workspace.projects")}</h2>
|
||||
{projects.length === 0 ? (
|
||||
<p>{i18n.message("workspace.noProjects")}</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{i18n.message("app.name")}</th>
|
||||
<th scope="col">{i18n.message("workspace.module")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projects.map((project) => (
|
||||
<tr key={project.slug}>
|
||||
<td>
|
||||
<a href={projectHref(org, workspace, project.slug)}>{project.name}</a>
|
||||
</td>
|
||||
<td>{project.moduleKey}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{mayCreate ? (
|
||||
<>
|
||||
<h2>{i18n.message("workspace.createProject")}</h2>
|
||||
<ProcedureForm
|
||||
procedure="tenancy.project.create"
|
||||
hidden={{ org, workspace }}
|
||||
submitLabel={i18n.message("app.create")}
|
||||
failedTemplate={i18n.message("app.failed")}
|
||||
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 })),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The audit log is for administrators (spec §9.4). The role is asked for in this layout and not in the
|
||||
// page: the page has a loading state, a loading state starts the response, and the response's status
|
||||
// is fixed once it starts -- a member below `admin` must get a real 403, and a stranger a real 404.
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { requireRole } from "../../../src/server/guard"
|
||||
import { pageContext } from "../../../src/server/page"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AuditLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: ReactNode
|
||||
params: Promise<{ org: string }>
|
||||
}): Promise<ReactNode> {
|
||||
const { org } = await params
|
||||
await requireRole(await pageContext(), org, "admin")
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The loading state of this page (src/ui/loading-page.tsx says why it is here and not above the layouts).
|
||||
|
||||
export { LoadingPage as default } from "../../../src/ui/loading-page"
|
||||
@@ -0,0 +1,105 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The audit viewer (spec §9.4). 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 (src/server/audit.ts).
|
||||
//
|
||||
// Administrators only, which the layout beside this page asks of the tenancy resolver
|
||||
// (src/server/guard.ts) before the response starts.
|
||||
|
||||
import { orgGet } from "@conusai-kit/tenancy"
|
||||
import { ConusAIClient } from "@conusai-sdk/node-sdk"
|
||||
import type { ReactElement } from "react"
|
||||
import { webRuntime } from "../../../src/server/app"
|
||||
import { isOwnedBy } from "../../../src/server/audit"
|
||||
import { pageContext } from "../../../src/server/page"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* 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 { env } = webRuntime()
|
||||
const platform = new ConusAIClient({
|
||||
baseUrl: env.deployment.CONUSAI_API_URL,
|
||||
apiKey: env.deployment.CONUSAI_API_TOKEN,
|
||||
})
|
||||
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 default async function Audit({ params }: { params: Promise<{ org: string }> }): Promise<ReactElement> {
|
||||
const { org } = await params
|
||||
const { caller, i18n } = await pageContext()
|
||||
|
||||
// The layout already established membership; the id is what subscriber-written events carry.
|
||||
const organization = await 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 (
|
||||
<>
|
||||
<h1>{i18n.message("app.audit")}</h1>
|
||||
{rows.length === 0 ? (
|
||||
<p>{i18n.message("app.auditEmpty")}</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{i18n.message("app.when")}</th>
|
||||
<th scope="col">{i18n.message("app.action")}</th>
|
||||
<th scope="col">{i18n.message("app.outcome")}</th>
|
||||
<th scope="col">{i18n.message("app.resource")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>{i18n.date(new Date(row.occurred_at))}</td>
|
||||
<td>{row.action}</td>
|
||||
<td>{row.outcome}</td>
|
||||
<td>{row.resource_type ?? ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// 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.
|
||||
|
||||
import { logoGet } from "@app/modules/branding"
|
||||
import { AppError } from "@conusai-kit/core"
|
||||
import { pageContext } from "../../../../../src/server/page"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ org: string; variant: string }> },
|
||||
): Promise<Response> {
|
||||
const { org, variant } = await params
|
||||
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()
|
||||
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,41 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// 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.
|
||||
|
||||
import { appBrand } from "@app/config"
|
||||
import { brandGet, renderShareImage, shareBrand } from "@app/modules/branding"
|
||||
import { AppError } from "@conusai-kit/core"
|
||||
import { orgGet } from "@conusai-kit/tenancy"
|
||||
import { appIdentity, readersFor } from "../../../../src/server/brand-assets"
|
||||
import { pageContext } from "../../../../src/server/page"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ org: string }> }): Promise<Response> {
|
||||
const { org } = await params
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// 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 { PROFILE } from "@app/config"
|
||||
import { brandGet } from "@app/modules/branding"
|
||||
import { BrandForm, brandFormLabels } from "@app/modules/branding/ui"
|
||||
import { tokens } from "@conusai-kit/brand"
|
||||
import { notFound } from "next/navigation"
|
||||
import type { ReactElement } from "react"
|
||||
import { orFallback } from "../../../src/server/guard"
|
||||
import { holdsAtLeast, pageContext } from "../../../src/server/page"
|
||||
import { AppTransport } from "../../../src/ui/transport"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function BrandingPage({ params }: { params: Promise<{ org: string }> }): Promise<ReactElement> {
|
||||
const { org } = await params
|
||||
if (PROFILE === "convex") notFound()
|
||||
const page = await pageContext()
|
||||
const { caller, i18n, path } = page
|
||||
const brand = await orFallback(path, () => caller.call(brandGet, { org }))
|
||||
const canEdit = await holdsAtLeast(page, org, "admin")
|
||||
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`branding.${key}`, values)
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>{i18n.message("branding.title")}</h1>
|
||||
<p>{i18n.message("branding.intro")}</p>
|
||||
<AppTransport>
|
||||
<BrandForm
|
||||
org={org}
|
||||
brand={brand}
|
||||
canEdit={canEdit}
|
||||
defaultPrimary={tokens.color.dark.accent}
|
||||
labels={brandFormLabels(t, i18n.message("app.failed"))}
|
||||
/>
|
||||
</AppTransport>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The organization layout. The scope is resolved on the server, once, by calling an
|
||||
// organization-scoped procedure: a member who is not a member gets the same NotFound a stranger
|
||||
// does, and nothing below this layout has to check again (spec §3.3, §6.4).
|
||||
//
|
||||
// It is also where the organization's own brand is applied (spec §10.3): the colours as custom
|
||||
// properties on an element wrapped around everything the organization sees, so the first paint of
|
||||
// every page is already branded, and the display name and logo in the switcher -- beside the
|
||||
// organization switcher itself, for a person who belongs to more than one.
|
||||
|
||||
import { PROFILE } from "@app/config"
|
||||
import { appModules } from "@app/modules"
|
||||
import { type BrandView, logoUrl, tenantLogo, tenantStyle } from "@app/modules/branding"
|
||||
import { atLeast, brandingSchema, meGet, ORG_ROLE_AS_ROLE, orgGet } from "@conusai-kit/tenancy"
|
||||
import { AppShell } from "@conusai-kit/ui"
|
||||
import { redirect } from "next/navigation"
|
||||
import type { CSSProperties, ReactElement, ReactNode } from "react"
|
||||
import { webRuntime } from "../../src/server/app"
|
||||
import { orFallback, signInHref } from "../../src/server/guard"
|
||||
import { pageContext } from "../../src/server/page"
|
||||
import { ClientModules } from "../../src/ui/client-modules"
|
||||
import { OrganizationSwitch } from "../../src/ui/shell-controls"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
// No generateMetadata here: the organization's share image (`/{org}/brand/og`) answers 404 to
|
||||
// anyone who is not a signed-in member, which is every link-preview crawler, and org existence is
|
||||
// deliberately indistinguishable from the outside. The root layout's metadata therefore stands:
|
||||
// the app's own public share image.
|
||||
|
||||
/** One stored logo, at 1x and at 2x for a high-density screen. */
|
||||
function TenantLogo({ className, path }: { className: string; path: string }): ReactElement {
|
||||
return (
|
||||
<img
|
||||
className={className}
|
||||
src={logoUrl(path, "1x")}
|
||||
srcSet={`${logoUrl(path, "1x")} 1x, ${logoUrl(path, "2x")} 2x`}
|
||||
alt=""
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** The modules' navigation, with each link made absolute inside this organization. */
|
||||
const navigationFor = (org: string) =>
|
||||
appModules
|
||||
.filter((module) => PROFILE !== "convex" || ["tenancy", "auth", "files", "notes"].includes(module.id))
|
||||
.map((module) =>
|
||||
module.ui?.nav
|
||||
? {
|
||||
...module,
|
||||
ui: { ...module.ui, nav: module.ui.nav.map((item) => ({ ...item, href: `/${org}/${item.href}` })) },
|
||||
}
|
||||
: module,
|
||||
)
|
||||
|
||||
/** What `organizations.branding` holds, or nothing: a value that does not parse is not applied, whatever wrote it. */
|
||||
function brandOf(raw: unknown): BrandView {
|
||||
const parsed = brandingSchema.safeParse(raw)
|
||||
const branding = parsed.success ? parsed.data : {}
|
||||
return {
|
||||
displayName: branding.displayName ?? null,
|
||||
primary: branding.primary ?? null,
|
||||
accent: branding.accent ?? null,
|
||||
logoLight: branding.logoLight ?? null,
|
||||
logoDark: branding.logoDark ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function OrgLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: ReactNode
|
||||
params: Promise<{ org: string }>
|
||||
}): Promise<ReactElement> {
|
||||
const { org } = await params
|
||||
const { caller, i18n, signedIn, path } = await pageContext()
|
||||
if (!signedIn) redirect(signInHref(path))
|
||||
|
||||
// A stranger and a missing organization both end at the not-found page (guard.ts): the words and
|
||||
// the status are the same, so the layout cannot be used to ask which organizations exist.
|
||||
const [organization, me] = await orFallback(path, () =>
|
||||
Promise.all([caller.call(orgGet, { org }), caller.call(meGet, undefined)]),
|
||||
)
|
||||
const role = me.organizations.find((entry) => entry.slug === org)?.role
|
||||
// The permissions this membership holds, so the shell drops nav items that would only be
|
||||
// refused (kit-ui filters when the prop is given; the registry is the permission source).
|
||||
const heldPermissions =
|
||||
role === undefined
|
||||
? []
|
||||
: Object.entries(webRuntime().registry.permissions)
|
||||
.filter(([, declared]) => atLeast(ORG_ROLE_AS_ROLE[role], declared.role))
|
||||
.map(([name]) => name)
|
||||
// The audit log and the brand are for administrators; a link a member would only be refused at is noise.
|
||||
const administers = role === "admin" || role === "owner"
|
||||
const brand = brandOf(organization.branding)
|
||||
// A theme shows its own logo, or the other one when only one is uploaded (`tenantLogo`, the branding contract).
|
||||
const lightLogo = tenantLogo(brand, "light")
|
||||
const darkLogo = tenantLogo(brand, "dark")
|
||||
// The shell renders the <main> every page inside this layout fills, so an organization page
|
||||
// returns its content and not a second landmark (kit-ui's AppShell is the accessible one).
|
||||
return (
|
||||
<div
|
||||
data-tenant-brand={brand.primary ?? brand.accent ?? undefined}
|
||||
style={tenantStyle(brand).style as CSSProperties}
|
||||
>
|
||||
<AppShell
|
||||
modules={navigationFor(org)}
|
||||
scope="org"
|
||||
t={(key) => i18n.message(key)}
|
||||
navigationLabel={i18n.message("ui.navigation")}
|
||||
permissions={heldPermissions}
|
||||
switcher={
|
||||
<>
|
||||
<a href={`/${org}`} className="app-brand-link">
|
||||
{darkLogo ? <TenantLogo className="app-logo app-logo-dark" path={darkLogo.path} /> : null}
|
||||
{lightLogo ? <TenantLogo className="app-logo app-logo-light" path={lightLogo.path} /> : null}
|
||||
{brand.displayName ?? organization.name}
|
||||
</a>
|
||||
{me.organizations.length > 1 ? (
|
||||
<OrganizationSwitch
|
||||
current={org}
|
||||
label={i18n.message("shell.switchOrganization")}
|
||||
organizations={me.organizations.map((entry) => ({ slug: entry.slug, name: entry.name }))}
|
||||
/>
|
||||
) : null}
|
||||
{administers ? <a href={`/${org}/audit`}>{i18n.message("app.audit")}</a> : null}
|
||||
{administers && PROFILE !== "convex" ? (
|
||||
<a href={`/${org}/branding`}>{i18n.message("branding.title")}</a>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</AppShell>
|
||||
{/* The enabled modules' browser providers (an in-app guide's overlay), beside the page. */}
|
||||
<ClientModules context={{ org, locale: i18n.locale, user: me.userId }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The loading state of this page (src/ui/loading-page.tsx says why it is here and not above the layouts).
|
||||
|
||||
export { LoadingPage as default } from "../../../src/ui/loading-page"
|
||||
@@ -0,0 +1,113 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Members, their roles and the organization's open invitations (spec §3.2). Every change is a
|
||||
// procedure call: the role scale, the "nobody grants a role above their own" rule and the last
|
||||
// owner's protection are kit-tenancy's, not this page's. What this page decides is only who is shown
|
||||
// the controls: administrators, who hold `tenancy.member.invite` and `tenancy.member.manage`.
|
||||
|
||||
import { invitationList, memberList, ORG_ROLES } from "@conusai-kit/tenancy"
|
||||
import type { ReactElement } from "react"
|
||||
import { orFallback } from "../../../src/server/guard"
|
||||
import { holdsAtLeast, pageContext } from "../../../src/server/page"
|
||||
import { InviteMember } from "../../../src/ui/invite-member"
|
||||
import { ProcedureForm } from "../../../src/ui/procedure-form"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function Members({ params }: { params: Promise<{ org: string }> }): Promise<ReactElement> {
|
||||
const { org } = await params
|
||||
const page = await pageContext()
|
||||
const { caller, i18n, path } = page
|
||||
const { members } = await orFallback(path, () => caller.call(memberList, { org }))
|
||||
const administers = await holdsAtLeast(page, org, "admin")
|
||||
const { invitations } = administers
|
||||
? await orFallback(path, () => caller.call(invitationList, { org }))
|
||||
: { invitations: [] }
|
||||
const roleLabel = (role: string): string => i18n.message(`roles.${role}`)
|
||||
const roleOptions = ORG_ROLES.map((role) => ({ value: role, label: roleLabel(role) }))
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>{i18n.message("app.members")}</h1>
|
||||
<p>{i18n.message("app.memberCount", { count: members.length })}</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{i18n.message("app.name")}</th>
|
||||
<th scope="col">{i18n.message("app.email")}</th>
|
||||
<th scope="col">{i18n.message("app.role")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{members.map((member) => (
|
||||
<tr key={member.userId}>
|
||||
<td>{member.displayName}</td>
|
||||
<td>{member.email}</td>
|
||||
<td>{roleLabel(member.role)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{administers ? (
|
||||
<>
|
||||
<h2>{i18n.message("app.invite")}</h2>
|
||||
<InviteMember
|
||||
org={org}
|
||||
roles={roleOptions}
|
||||
labels={{
|
||||
email: i18n.message("app.email"),
|
||||
role: i18n.message("app.role"),
|
||||
submit: i18n.message("app.invite"),
|
||||
created: i18n.message("members.created"),
|
||||
link: i18n.message("members.link"),
|
||||
failed: i18n.message("app.failed"),
|
||||
}}
|
||||
/>
|
||||
|
||||
<h2>{i18n.message("members.openInvitations")}</h2>
|
||||
{invitations.length === 0 ? (
|
||||
<p>{i18n.message("members.noInvitations")}</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{i18n.message("app.email")}</th>
|
||||
<th scope="col">{i18n.message("app.role")}</th>
|
||||
<th scope="col">{i18n.message("members.invitedBy")}</th>
|
||||
<th scope="col">{i18n.message("members.expires")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invitations.map((invitation) => (
|
||||
<tr key={invitation.invitationId}>
|
||||
<td>{invitation.email}</td>
|
||||
<td>{roleLabel(invitation.role)}</td>
|
||||
<td>{invitation.inviter.displayName}</td>
|
||||
<td>{i18n.date(new Date(invitation.expiresAt))}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<h2>{i18n.message("members.changeRole")}</h2>
|
||||
<ProcedureForm
|
||||
procedure="tenancy.member.change-role"
|
||||
hidden={{ org }}
|
||||
submitLabel={i18n.message("app.save")}
|
||||
failedTemplate={i18n.message("app.failed")}
|
||||
fields={[
|
||||
{
|
||||
name: "userId",
|
||||
label: i18n.message("app.members"),
|
||||
required: true,
|
||||
options: members.map((member) => ({ value: member.userId, label: member.email })),
|
||||
},
|
||||
{ name: "role", label: i18n.message("app.role"), required: true, options: roleOptions },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The organization's workspaces and the projects of each (spec §3.1).
|
||||
|
||||
import { projectList, workspaceList } from "@conusai-kit/tenancy"
|
||||
import type { ReactElement } from "react"
|
||||
import { orFallback } from "../../src/server/guard"
|
||||
import { pageContext } from "../../src/server/page"
|
||||
import { PROJECT_ENTRIES, projectHref } from "../../src/ui/projects"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function OrgHome({ params }: { params: Promise<{ org: string }> }): Promise<ReactElement> {
|
||||
const { org } = await params
|
||||
const { caller, i18n, path } = await pageContext()
|
||||
const projects = await orFallback(path, async () => {
|
||||
const { workspaces } = await caller.call(workspaceList, { org })
|
||||
return Promise.all(
|
||||
workspaces.map(async (workspace) => ({
|
||||
workspace,
|
||||
projects: (await caller.call(projectList, { org, workspace: workspace.slug })).projects,
|
||||
})),
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>{i18n.message("app.workspaces")}</h1>
|
||||
<p>
|
||||
<a href={`/${org}/workspaces`}>{i18n.message("workspace.create")}</a>
|
||||
</p>
|
||||
{projects.length === 0 ? <p>{i18n.message("workspace.none")}</p> : null}
|
||||
{projects.map(({ workspace, projects: inWorkspace }) => (
|
||||
<section key={workspace.slug}>
|
||||
<h2>
|
||||
<a href={`/${org}/${workspace.slug}`}>{workspace.name}</a>
|
||||
</h2>
|
||||
<p>{i18n.message("workspace.projectCount", { count: inWorkspace.length })}</p>
|
||||
<ul>
|
||||
{inWorkspace.map((project) => (
|
||||
<li key={project.slug}>
|
||||
<a href={projectHref(org, workspace.slug, project.slug)}>{project.name}</a>:
|
||||
{PROJECT_ENTRIES.map((entry) => (
|
||||
<span key={entry.href}>
|
||||
{" "}
|
||||
<a href={projectHref(org, workspace.slug, project.slug, entry)}>{i18n.message(entry.label)}</a>
|
||||
</span>
|
||||
))}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The organization's own settings. `tenancy.org.update` needs the admin role, so the page asks for
|
||||
// it (src/server/guard.ts): a member below it gets the forbidden page, a stranger the not-found
|
||||
// page. Workspaces are created on their own page (`/{org}/workspaces`).
|
||||
|
||||
import { settingsGet } from "@app/modules/knowledge"
|
||||
import { SettingsSection } from "@app/modules/knowledge/ui"
|
||||
import { orgGet } from "@conusai-kit/tenancy"
|
||||
import type { ReactElement } from "react"
|
||||
import { orFallback, requireRole } from "../../../src/server/guard"
|
||||
import { pageContext } from "../../../src/server/page"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function Settings({ params }: { params: Promise<{ org: string }> }): Promise<ReactElement> {
|
||||
const { org } = await params
|
||||
const page = await pageContext()
|
||||
const { caller, i18n, path } = page
|
||||
await requireRole(page, org, "admin")
|
||||
const organization = await orFallback(path, () => caller.call(orgGet, { org }))
|
||||
const knowledge = await orFallback(path, () => caller.call(settingsGet, { org }))
|
||||
// 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}`)
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>{i18n.message("app.settings")}</h1>
|
||||
<dl>
|
||||
<dt>{i18n.message("app.name")}</dt>
|
||||
<dd>{organization.name}</dd>
|
||||
<dt>{i18n.message("app.slug")}</dt>
|
||||
<dd>{organization.slug}</dd>
|
||||
<dt>{i18n.message("app.status")}</dt>
|
||||
<dd>{organization.status}</dd>
|
||||
</dl>
|
||||
<p>
|
||||
<a href={`/${org}/workspaces`}>{i18n.message("app.workspaces")}</a>
|
||||
</p>
|
||||
<SettingsSection
|
||||
org={org}
|
||||
initial={knowledge}
|
||||
labels={{
|
||||
heading: t("settings.heading"),
|
||||
state: t("settings.state"),
|
||||
stateOn: t("settings.stateOn"),
|
||||
stateOff: t("settings.stateOff"),
|
||||
backend: t("settings.backend"),
|
||||
backendVectors: t("settings.backendVectors"),
|
||||
source: t("settings.source"),
|
||||
sourceOperator: t("settings.sourceOperator"),
|
||||
sourceManaged: t("settings.sourceManaged"),
|
||||
endpoint: t("settings.endpoint"),
|
||||
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"),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// 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 { workspaceList } from "@conusai-kit/tenancy"
|
||||
import type { ReactElement } from "react"
|
||||
import { orFallback } from "../../../src/server/guard"
|
||||
import { holdsAtLeast, pageContext } from "../../../src/server/page"
|
||||
import { ProcedureForm } from "../../../src/ui/procedure-form"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function Workspaces({ params }: { params: Promise<{ org: string }> }): Promise<ReactElement> {
|
||||
const { org } = await params
|
||||
const page = await pageContext()
|
||||
const { caller, i18n, path } = page
|
||||
const { workspaces } = await orFallback(path, () => caller.call(workspaceList, { org }))
|
||||
const mayCreate = await holdsAtLeast(page, org, "admin")
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>{i18n.message("workspace.list")}</h1>
|
||||
{workspaces.length === 0 ? (
|
||||
<p>{i18n.message("workspace.none")}</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{i18n.message("app.name")}</th>
|
||||
<th scope="col">{i18n.message("workspace.visibility")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{workspaces.map((workspace) => (
|
||||
<tr key={workspace.slug}>
|
||||
<td>
|
||||
<a href={`/${org}/${workspace.slug}`}>{workspace.name}</a>
|
||||
{workspace.isDefault ? ` (${i18n.message("workspace.default")})` : ""}
|
||||
</td>
|
||||
<td>
|
||||
{workspace.visibility === "org"
|
||||
? i18n.message("workspace.visibilityOrg")
|
||||
: i18n.message("workspace.visibilityRestricted")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{mayCreate ? (
|
||||
<>
|
||||
<h2>{i18n.message("workspace.create")}</h2>
|
||||
<ProcedureForm
|
||||
procedure="tenancy.workspace.create"
|
||||
hidden={{ org }}
|
||||
submitLabel={i18n.message("app.create")}
|
||||
failedTemplate={i18n.message("app.failed")}
|
||||
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: i18n.message("workspace.visibilityOrg") },
|
||||
{ value: "restricted", label: i18n.message("workspace.visibilityRestricted") },
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Every HTTP entry point of this app but the sign-in redirects: /api/health, /api/ready,
|
||||
// /api/openapi.json and /api/v1/<procedure>. One file, because there is one mount (spec §6.2).
|
||||
|
||||
import { handle } from "../../../src/server/router"
|
||||
|
||||
// A procedure call reads a session and a database; nothing here may be cached or prerendered.
|
||||
export const dynamic = "force-dynamic"
|
||||
export const runtime = "nodejs"
|
||||
|
||||
export const GET = handle
|
||||
export const POST = handle
|
||||
// A procedure whose payload is a byte stream (kit-core .stream(field)) is answered on PUT.
|
||||
export const PUT = handle
|
||||
@@ -0,0 +1,19 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// /api/auth/*: the same-origin route from this app's pages to Better Auth on its Convex deployment
|
||||
// (AUTH_PROVIDER=convex-better-auth). Only the endpoints a sign-in, sign-up, sign-out, password
|
||||
// change, device revocation or account deletion needs are forwarded, and no bearer comes back
|
||||
// (@conusai-kit/auth-convex-better-auth's proxy). A deployment that selected another provider has no
|
||||
// such route.
|
||||
import { loadAuthConfig } from "@conusai-kit/auth"
|
||||
import { betterAuthRuntime } from "../../../../src/server/app"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
export const runtime = "nodejs"
|
||||
|
||||
async function handle(request: Request): Promise<Response> {
|
||||
if (loadAuthConfig().provider !== "convex-better-auth") return new Response(null, { status: 404 })
|
||||
return betterAuthRuntime().proxy(request)
|
||||
}
|
||||
|
||||
export const GET = handle
|
||||
export const POST = handle
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// 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.
|
||||
|
||||
import { appBrand } from "@app/config"
|
||||
import { renderShareImage, shareBrand } from "@app/modules/branding"
|
||||
import { appIdentity, appOnlyReaders } from "../../../../src/server/brand-assets"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
let drawn: Promise<Buffer<ArrayBuffer>> | undefined
|
||||
|
||||
export async function GET(): 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 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// GET /auth/callback: the provider sends the browser back here (MIB I3's path). The code is
|
||||
// exchanged on the server, the tokens are sealed into the session row, and the browser receives
|
||||
// one sealed cookie whose plaintext is a random handle. A failure of any step ends at the sign-in
|
||||
// page with a code for the person to read, never at a 500 (src/server/auth-response.ts).
|
||||
import { authRoutes } from "../../../src/server/app"
|
||||
import { authResponse } from "../../../src/server/auth-response"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
export const runtime = "nodejs"
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
return authResponse(request, async () => (await authRoutes()).callback(request))
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// GET /auth/dev-login?email=…&return_to=…: a sign-in for a developer with no identity provider.
|
||||
// @conusai-kit/auth refuses to build it in a production deployment, and refuses again at boot if
|
||||
// AUTH_DEV_LOGIN is set there, so this route cannot become a way in (spec §7).
|
||||
import { createDevLogin, loadAuthConfig } from "@conusai-kit/auth"
|
||||
import { authRoutes, webRuntime } from "../../../src/server/app"
|
||||
import { safeReturnTo, signInErrorHref } from "../../../src/server/auth-errors"
|
||||
import { authResponse, redirectTo } from "../../../src/server/auth-response"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
export const runtime = "nodejs"
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const config = loadAuthConfig()
|
||||
if (!config.devLogin) return new Response("dev login is not enabled for this deployment", { status: 404 })
|
||||
return authResponse(request, async () => {
|
||||
const runtime = webRuntime()
|
||||
const data = runtime.authData ?? runtime.data
|
||||
if (!data) throw new Error("The selected profile has no auth data source")
|
||||
const { sessions } = await authRoutes()
|
||||
const response = await createDevLogin({ config, data, sessions }).signIn(request)
|
||||
// An address the adapter would not take is a failed sign-in like any other, not a bare 400.
|
||||
if (response.status === 400) return redirectTo(signInErrorHref("failed"))
|
||||
// The adapter always lands on `/`; the person was going somewhere (the invitation they were sent).
|
||||
const target = safeReturnTo(new URL(request.url).searchParams.get("return_to"))
|
||||
if (response.status !== 302 || target === "/") return response
|
||||
const headers = new Headers(response.headers)
|
||||
headers.set("location", target)
|
||||
return new Response(null, { status: 302, headers })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// 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 (src/server/auth-response.ts). 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 { loadAuthConfig } from "@conusai-kit/auth"
|
||||
import { redirect } from "next/navigation"
|
||||
import type { ReactElement } from "react"
|
||||
import { AUTH_ERROR_MESSAGES, parseAuthError, safeReturnTo } from "../../../src/server/auth-errors"
|
||||
import { betterAuthFormLabels, MIN_PASSWORD_LENGTH } from "../../../src/server/better-auth" // kit:convex-better-auth
|
||||
import { pageContext } from "../../../src/server/page"
|
||||
import { BetterAuthForm } from "../../../src/ui/better-auth-form" // kit:convex-better-auth
|
||||
import { StateView } from "../../../src/ui/state-view"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function SignIn({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ error?: string; return_to?: string }>
|
||||
}): Promise<ReactElement> {
|
||||
const { error, return_to: returnToParam } = await searchParams
|
||||
const { i18n, signedIn } = 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) redirect(returnTo)
|
||||
|
||||
const config = loadAuthConfig()
|
||||
const next = returnTo === "/" ? "" : `?return_to=${encodeURIComponent(returnTo)}`
|
||||
const betterAuth = config.provider === "convex-better-auth" // kit:convex-better-auth
|
||||
return (
|
||||
<StateView title={i18n.message("signin.title")}>
|
||||
{code ? (
|
||||
<p className="app-error" role="alert">
|
||||
{i18n.message(AUTH_ERROR_MESSAGES[code])}
|
||||
</p>
|
||||
) : (
|
||||
<p>{i18n.message("signin.intro")}</p>
|
||||
)}
|
||||
{config.oidc ? (
|
||||
<p>
|
||||
<a href={`/auth/sign-in/start${next}`}>{i18n.message("signin.continue")}</a>
|
||||
</p>
|
||||
) : null}
|
||||
{!config.oidc && config.devLogin ? (
|
||||
<form method="get" action="/auth/dev-login">
|
||||
<h2>{i18n.message("signin.devTitle")}</h2>
|
||||
<p>{i18n.message("signin.devHelp")}</p>
|
||||
{returnTo === "/" ? null : <input type="hidden" name="return_to" value={returnTo} />}
|
||||
<label className="kit-field" htmlFor="dev-email">
|
||||
{i18n.message("app.email")}
|
||||
<input id="dev-email" name="email" type="email" required autoComplete="email" />
|
||||
</label>
|
||||
<button type="submit">{i18n.message("signin.devSubmit")}</button>
|
||||
</form>
|
||||
) : null}
|
||||
{/* kit:convex-better-auth:begin */}
|
||||
{betterAuth ? (
|
||||
<>
|
||||
<BetterAuthForm
|
||||
mode="sign-in"
|
||||
returnTo={returnTo}
|
||||
minPasswordLength={MIN_PASSWORD_LENGTH}
|
||||
labels={betterAuthFormLabels(i18n, "sign-in")}
|
||||
/>
|
||||
<p>
|
||||
<a href={`/auth/sign-up${next}`}>{i18n.message("signin.account.toSignUp")}</a>
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
{/* kit:convex-better-auth:end */}
|
||||
{!config.oidc && !config.devLogin && config.provider === "oidc" ? (
|
||||
<p role="status">{i18n.message("signin.notConfigured")}</p>
|
||||
) : null}
|
||||
</StateView>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// GET /auth/sign-in/start: the redirect to the identity provider (spec §7). The transaction cookie it
|
||||
// sets carries the state, the nonce and the PKCE verifier, sealed; the browser gets no token. The
|
||||
// page people see before it is /auth/sign-in. A deployment with no provider has nothing to redirect
|
||||
// to, and sends the visitor back to that page, which says so.
|
||||
import { loadAuthConfig } from "@conusai-kit/auth"
|
||||
import { authRoutes } from "../../../../src/server/app"
|
||||
import { authResponse, redirectTo } from "../../../../src/server/auth-response"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
export const runtime = "nodejs"
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
if (!loadAuthConfig().oidc) return redirectTo("/auth/sign-in")
|
||||
return authResponse(request, async () => (await authRoutes()).signIn(request))
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// POST /auth/sign-out: revokes the session row and redirects to the provider's end-session
|
||||
// endpoint, which sends the browser to /auth/signed-out.
|
||||
//
|
||||
// POST, with the fetch-metadata half of csrf.ts's check: revoking a session is a state change, so
|
||||
// it must not hang on a GET, which a prefetch, a crawler or an <img> fires unprompted, and a
|
||||
// cross-site form POST is refused the same way the API refuses one. The synchronizer token itself
|
||||
// cannot be checked here -- a plain HTML form cannot send a header -- so the metadata a browser
|
||||
// always sends is the check.
|
||||
import { authRoutes } from "../../../src/server/app"
|
||||
import { authResponse } from "../../../src/server/auth-response"
|
||||
import { betterAuthSignOut } from "../../../src/server/better-auth-session" // kit:convex-better-auth
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
export const runtime = "nodejs"
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
return new Response("signing out is a POST", { status: 405, headers: { allow: "POST" } })
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const site = request.headers.get("sec-fetch-site")
|
||||
if (site !== null && site !== "same-origin" && site !== "none") {
|
||||
return new Response("a cross-site sign-out is refused", { status: 403 })
|
||||
}
|
||||
const betterAuth = await betterAuthSignOut(request) // kit:convex-better-auth
|
||||
if (betterAuth) return betterAuth // kit:convex-better-auth
|
||||
return authResponse(request, async () => (await authRoutes()).signOut(request))
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// 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 { loadAuthConfig } from "@conusai-kit/auth"
|
||||
import { notFound, redirect } from "next/navigation"
|
||||
import type { ReactElement } from "react"
|
||||
import { safeReturnTo } from "../../../src/server/auth-errors"
|
||||
import { betterAuthFormLabels, MIN_PASSWORD_LENGTH } from "../../../src/server/better-auth"
|
||||
import { pageContext } from "../../../src/server/page"
|
||||
import { BetterAuthForm } from "../../../src/ui/better-auth-form"
|
||||
import { StateView } from "../../../src/ui/state-view"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function SignUp({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ return_to?: string }>
|
||||
}): Promise<ReactElement> {
|
||||
if (loadAuthConfig().provider !== "convex-better-auth") notFound()
|
||||
const { return_to: returnToParam } = await searchParams
|
||||
const { i18n, signedIn } = await pageContext()
|
||||
const returnTo = safeReturnTo(returnToParam)
|
||||
if (signedIn) redirect(returnTo)
|
||||
const next = returnTo === "/" ? "" : `?return_to=${encodeURIComponent(returnTo)}`
|
||||
return (
|
||||
<StateView title={i18n.message("signin.account.signUpTitle")}>
|
||||
<p>{i18n.message("signin.account.signUpIntro")}</p>
|
||||
<BetterAuthForm
|
||||
mode="sign-up"
|
||||
returnTo={returnTo}
|
||||
minPasswordLength={MIN_PASSWORD_LENGTH}
|
||||
labels={betterAuthFormLabels(i18n, "sign-up")}
|
||||
/>
|
||||
<p>
|
||||
<a href={`/auth/sign-in${next}`}>{i18n.message("signin.account.toSignIn")}</a>
|
||||
</p>
|
||||
</StateView>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// 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 type { ReactElement } from "react"
|
||||
import { pageContext } from "../../../src/server/page"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function SignedOut(): Promise<ReactElement> {
|
||||
const { i18n } = await pageContext()
|
||||
return (
|
||||
<main>
|
||||
<h1>{i18n.message("app.signedOut")}</h1>
|
||||
<p>{i18n.message("app.signedOutBody")}</p>
|
||||
<a href="/auth/sign-in">{i18n.message("app.signIn")}</a>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
"use client"
|
||||
|
||||
// What a person sees when a page throws (spec §10.4). The strings come from the root layout's
|
||||
// `BoundaryMessages`, in the person's language; the error is reported to observability from here
|
||||
// because React swallows what a boundary catches (src/ui/boundary.tsx).
|
||||
|
||||
import { ErrorView } from "../src/ui/boundary"
|
||||
|
||||
export default function ErrorPage({ error, retry }: { error: Error & { digest?: string }; retry: () => void }) {
|
||||
return <ErrorView error={error} retry={retry} />
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The page for a member whose role does not allow what they asked for (spec §3.2). It is only ever
|
||||
// shown to somebody who is in the organization -- a stranger gets the not-found page -- so it says
|
||||
// nothing that the person did not already know.
|
||||
|
||||
import type { ReactElement } from "react"
|
||||
import { pageContext } from "../src/server/page"
|
||||
import { StateView } from "../src/ui/state-view"
|
||||
|
||||
export default async function Forbidden(): Promise<ReactElement> {
|
||||
const { i18n } = await pageContext()
|
||||
return (
|
||||
<StateView
|
||||
title={i18n.message("boundary.forbiddenTitle")}
|
||||
actions={<a href="/">{i18n.message("boundary.home")}</a>}
|
||||
>
|
||||
<p>{i18n.message("boundary.forbiddenBody")}</p>
|
||||
</StateView>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
"use client"
|
||||
|
||||
// The last boundary: the root layout itself threw, so there is no document, no stylesheet and no
|
||||
// provider of strings -- this file renders its own <html> and carries its own two languages, chosen
|
||||
// from the browser's. The server has already logged the error; this reports the browser's side.
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { reportClientError } from "../src/observe/browser"
|
||||
|
||||
const STRINGS = {
|
||||
en: { title: "Something went wrong", body: "The app could not be shown. Try again.", retry: "Try again" },
|
||||
lt: {
|
||||
title: "Kažkas nepavyko",
|
||||
body: "Programos parodyti nepavyko. Bandykite dar kartą.",
|
||||
retry: "Bandyti dar kartą",
|
||||
},
|
||||
} as const
|
||||
|
||||
export default function GlobalError({ error, retry }: { error: Error & { digest?: string }; retry: () => void }) {
|
||||
useEffect(() => {
|
||||
reportClientError(error)
|
||||
}, [error])
|
||||
const locale = typeof navigator !== "undefined" && navigator.language.toLowerCase().startsWith("lt") ? "lt" : "en"
|
||||
const strings = STRINGS[locale]
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<body>
|
||||
<main>
|
||||
<h1>{strings.title}</h1>
|
||||
<p role="alert">{strings.body}</p>
|
||||
<button type="button" onClick={retry}>
|
||||
{strings.retry}
|
||||
</button>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
/* SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 */
|
||||
/* The app's stylesheet. Every colour, font and radius is a brand token: this file declares none
|
||||
of its own, so a white-label change is a change in config/brand.ts and nothing else
|
||||
(decision AT-D11). The fonts are the self-hosted woff2 files of @conusai-kit/brand; no external
|
||||
stylesheet or font host is referenced anywhere (self-hosted D20). */
|
||||
@import "@conusai-kit/brand/tokens.css";
|
||||
@import "@conusai-kit/ui/styles.css";
|
||||
|
||||
:root {
|
||||
/* The brand's tokens are dark by default and `.light` carries the light variant, so the browser's
|
||||
own colour scheme follows the class: a tenant brand's `light-dark()` ink (modules/branding)
|
||||
resolves against the same choice the tokens make, and never against the operating system's. */
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.light {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--brand-background);
|
||||
color: var(--brand-foreground);
|
||||
font-family: var(--brand-font-sans);
|
||||
font-size: var(--brand-font-size-root, 16px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
a {
|
||||
/* The ink, not the fill: the brand's own teal is 1.8:1 as text on white, which is why the ink exists. */
|
||||
color: var(--brand-accent-ink);
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 1.5rem;
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: var(--brand-font-display);
|
||||
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(--brand-border-soft);
|
||||
}
|
||||
|
||||
.app-shell-header {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-bottom: 1px solid var(--brand-border-soft);
|
||||
}
|
||||
|
||||
.app-error {
|
||||
border: 1px solid var(--brand-border-soft);
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* The brand settings' preview: the page's own tokens on a panel per theme, with the brand layer the
|
||||
organization layout will write drawn on the panel itself. */
|
||||
.brand-preview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.brand-preview-panel {
|
||||
background: var(--brand-background);
|
||||
color: var(--brand-foreground);
|
||||
border: 1px solid var(--brand-border-strong);
|
||||
border-radius: var(--brand-radius-panel);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.brand-preview-action {
|
||||
background: var(--brand-accent);
|
||||
color: var(--brand-accent-foreground);
|
||||
border: 0;
|
||||
border-radius: var(--brand-radius-button);
|
||||
padding: 0.5rem 1rem;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.brand-preview-link {
|
||||
color: var(--brand-accent-ink);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
button,
|
||||
select,
|
||||
input {
|
||||
font: inherit;
|
||||
color: var(--brand-foreground);
|
||||
background: var(--brand-panel);
|
||||
border: 1px solid var(--brand-border-strong);
|
||||
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(--brand-accent);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--brand-accent-ink);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.kit-field {
|
||||
margin-block-end: 0.75rem;
|
||||
}
|
||||
|
||||
.app-state {
|
||||
max-width: 40rem;
|
||||
margin: 3rem auto;
|
||||
}
|
||||
|
||||
.app-breadcrumb ol {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
list-style: none;
|
||||
margin: 0 0 1rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.app-breadcrumb li + li::before {
|
||||
content: "/";
|
||||
margin-inline-end: 0.5rem;
|
||||
color: var(--brand-text-70);
|
||||
}
|
||||
|
||||
.app-link-field {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-family: var(--brand-font-mono);
|
||||
}
|
||||
|
||||
.app-loading {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
color: var(--brand-text-80);
|
||||
}
|
||||
|
||||
.app-spinner {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid var(--brand-border-strong);
|
||||
border-top-color: var(--brand-accent);
|
||||
border-radius: 50%;
|
||||
animation: app-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes app-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.app-spinner {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The organization shell of @conusai-kit/ui: the bar with the organization's name, its switcher and
|
||||
its links, and the navigation of the modules, as two rows rather than a pile of unstyled elements. */
|
||||
.kit-shell-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem 1.5rem;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-bottom: 1px solid var(--brand-border-soft);
|
||||
}
|
||||
|
||||
.kit-shell nav {
|
||||
padding: 0.5rem 1.5rem;
|
||||
border-bottom: 1px solid var(--brand-border-soft);
|
||||
}
|
||||
|
||||
.kit-shell nav ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.5rem;
|
||||
}
|
||||
|
||||
/* The account page's analytics panel (ADR-049): its two actions side by side, the irreversible one
|
||||
marked with the brand's warning tokens, and the erasure's confirmation set apart. */
|
||||
.app-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* The text stays the foreground colour, which keeps its contrast on the button's panel fill in both
|
||||
themes (the warning ink on that fill falls under WCAG's 4.5:1); the danger is carried by the
|
||||
heavier warning border and weight, not by colour alone, and the confirmation states it. */
|
||||
.app-danger {
|
||||
border: 2px solid var(--brand-warning);
|
||||
color: var(--brand-foreground);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-confirm {
|
||||
border: 1px solid var(--brand-warning);
|
||||
border-radius: var(--brand-radius-panel, 8px);
|
||||
padding: 0.75rem 1rem;
|
||||
margin-block: 0.75rem;
|
||||
}
|
||||
|
||||
.app-stack {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin-block: 0.75rem;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The page an invitation link leads to: /invitations/<organization>/<token> (spec §3.1).
|
||||
//
|
||||
// Opening it changes nothing. A visitor who is not signed in is asked to sign in -- with the address
|
||||
// the invitation was sent to -- and comes back here; a signed-in person sees who they are signed in
|
||||
// as and a button, and the button is what accepts. The page does not look the token up: only the
|
||||
// invitee's own account may learn anything about it, and the answer to accepting is worded per state
|
||||
// (used, expired, already a member) for exactly that account and as a plain "not valid" for everyone
|
||||
// else (kit-tenancy), so this page cannot be used to find out which tokens or organizations exist.
|
||||
|
||||
import type { Metadata } from "next"
|
||||
import type { ReactElement } from "react"
|
||||
import { signInHref } from "../../../../src/server/guard"
|
||||
import { pageContext } from "../../../../src/server/page"
|
||||
import { InviteAccept } from "../../../../src/ui/invite-accept"
|
||||
import { SignOut } from "../../../../src/ui/sign-out"
|
||||
import { StateView } from "../../../../src/ui/state-view"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
// The token is the credential: it must not leave in a Referer header, and the page is not for search.
|
||||
export const metadata: Metadata = { referrer: "no-referrer", robots: { index: false, follow: false } }
|
||||
|
||||
export default async function Invitation({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ org: string; token: string }>
|
||||
}): Promise<ReactElement> {
|
||||
const { org, token } = await params
|
||||
const { i18n, signedIn, user, path } = await pageContext()
|
||||
const message = (key: string, values?: Record<string, string | number>): string => i18n.message(key, values)
|
||||
|
||||
return (
|
||||
<StateView title={message("invite.title")}>
|
||||
<p>{message("invite.intro", { org })}</p>
|
||||
{signedIn ? (
|
||||
<>
|
||||
{user ? <p>{message("invite.signedInAs", { email: user.email })}</p> : null}
|
||||
<InviteAccept
|
||||
org={org}
|
||||
token={token}
|
||||
labels={{
|
||||
accept: message("invite.accept"),
|
||||
accepted: message("invite.accepted", { org }),
|
||||
open: message("invite.open", { org }),
|
||||
invalid: message("invite.error.invalid"),
|
||||
used: message("invite.error.used"),
|
||||
expired: message("invite.error.expired"),
|
||||
member: message("invite.error.member"),
|
||||
failed: message("invite.error.failed"),
|
||||
}}
|
||||
/>
|
||||
<SignOut label={message("invite.switchAccount")} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>{message("invite.signInFirst")}</p>
|
||||
<p>
|
||||
<a href={signInHref(path)}>{message("app.signIn")}</a>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</StateView>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The root layout: the document, the brand layer and the app shell (spec §10.2).
|
||||
//
|
||||
// The brand's `--brand-*` declarations are written as a style attribute on <html> (src/ui/document.tsx),
|
||||
// which is the layer a tenant brand overrides at runtime without any stylesheet being rebuilt.
|
||||
|
||||
import { appBrand, publicUrlOf } from "@app/config"
|
||||
import { ACCOUNT_PATH } from "@app/modules/account/contract"
|
||||
import type { Metadata, Viewport } from "next"
|
||||
import type { ReactNode } from "react"
|
||||
import { pageContext } from "../src/server/page"
|
||||
import { BoundaryMessages } from "../src/ui/boundary"
|
||||
import { brandMetadata, Document } from "../src/ui/document"
|
||||
import { LanguageSwitch, ThemeToggle } from "../src/ui/shell-controls"
|
||||
import { SignOut } from "../src/ui/sign-out"
|
||||
import "./globals.css"
|
||||
|
||||
/**
|
||||
* The app's own name and share image. `metadataBase` makes the image's address absolute, as a link preview needs, from
|
||||
* the public URL the deployment states; it is read when the page is asked for, never at build (one image, every environment).
|
||||
*/
|
||||
export function generateMetadata(): Metadata {
|
||||
return {
|
||||
...brandMetadata(appBrand),
|
||||
metadataBase: new URL(publicUrlOf()),
|
||||
}
|
||||
}
|
||||
|
||||
export const viewport: Viewport = { width: "device-width", initialScale: 1 }
|
||||
|
||||
export default async function RootLayout({ children }: { children: ReactNode }): Promise<ReactNode> {
|
||||
const { i18n, signedIn, csrf, theme, observe } = await pageContext()
|
||||
const message = (key: string): string => i18n.message(key)
|
||||
return (
|
||||
<Document
|
||||
brand={appBrand}
|
||||
locale={i18n.locale}
|
||||
theme={theme}
|
||||
signedIn={signedIn}
|
||||
{...(csrf ? { csrf } : {})}
|
||||
{...(observe ? { observe } : {})}
|
||||
signIn={message("app.signIn")}
|
||||
signOut={
|
||||
<>
|
||||
<a href={ACCOUNT_PATH}>{message("account.link")}</a>
|
||||
<SignOut label={message("app.signOut")} />
|
||||
</>
|
||||
}
|
||||
controls={
|
||||
<>
|
||||
<LanguageSwitch
|
||||
current={i18n.locale}
|
||||
signedIn={signedIn}
|
||||
label={message("shell.language")}
|
||||
options={[
|
||||
{ value: "en", label: message("shell.languageEn") },
|
||||
{ value: "lt", label: message("shell.languageLt") },
|
||||
]}
|
||||
failedTemplate={message("app.failed")}
|
||||
/>
|
||||
<ThemeToggle
|
||||
initial={theme}
|
||||
label={message("shell.theme")}
|
||||
darkLabel={message("shell.themeDark")}
|
||||
lightLabel={message("shell.themeLight")}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<BoundaryMessages
|
||||
strings={{
|
||||
title: message("boundary.errorTitle"),
|
||||
body: message("boundary.errorBody"),
|
||||
code: message("boundary.errorCode"),
|
||||
retry: message("boundary.retry"),
|
||||
home: message("boundary.home"),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</BoundaryMessages>
|
||||
</Document>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The page for an address that leads nowhere (spec §3.3). It is the one answer for an organization
|
||||
// that does not exist, an organization the person is not in, a workspace or a project of another
|
||||
// organization, and a path no module answers: the words and the 404 status are the same for all of
|
||||
// them, so the page cannot be used to find out which organizations exist.
|
||||
|
||||
import type { ReactElement } from "react"
|
||||
import { pageContext } from "../src/server/page"
|
||||
import { StateView } from "../src/ui/state-view"
|
||||
|
||||
export default async function NotFound(): Promise<ReactElement> {
|
||||
const { i18n } = await pageContext()
|
||||
return (
|
||||
<StateView title={i18n.message("boundary.notFoundTitle")} actions={<a href="/">{i18n.message("boundary.home")}</a>}>
|
||||
<p>{i18n.message("boundary.notFoundBody")}</p>
|
||||
</StateView>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Onboarding: the first organization, which creates its default workspace and project in one
|
||||
// transaction (kit-tenancy's use case), with the signed-in user as its owner. Joining an existing
|
||||
// organization is not done by name (spec §3.3): it is an invitation, which has its own page.
|
||||
|
||||
import { redirect } from "next/navigation"
|
||||
import type { ReactElement } from "react"
|
||||
import { signInHref } from "../../src/server/guard"
|
||||
import { pageContext } from "../../src/server/page"
|
||||
import { ProcedureForm } from "../../src/ui/procedure-form"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function Onboarding(): Promise<ReactElement> {
|
||||
const { i18n, signedIn, path } = await pageContext()
|
||||
if (!signedIn) redirect(signInHref(path))
|
||||
return (
|
||||
<main>
|
||||
<h1>{i18n.message("app.createOrganization")}</h1>
|
||||
<ProcedureForm
|
||||
procedure="tenancy.org.create"
|
||||
submitLabel={i18n.message("app.create")}
|
||||
failedTemplate={i18n.message("app.failed")}
|
||||
after="/{slug}"
|
||||
fields={[
|
||||
{ name: "name", label: i18n.message("app.name"), required: true },
|
||||
{ name: "slug", label: i18n.message("app.slug"), required: true },
|
||||
]}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The entry page: the organizations this user belongs to, or the way in (spec §7).
|
||||
|
||||
import { meGet } from "@conusai-kit/tenancy"
|
||||
import type { ReactElement } from "react"
|
||||
import { orFallback } from "../src/server/guard"
|
||||
import { pageContext } from "../src/server/page"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function Home(): Promise<ReactElement> {
|
||||
const { caller, signedIn, i18n, path } = await pageContext()
|
||||
if (!signedIn) {
|
||||
return (
|
||||
<main>
|
||||
<h1>{i18n.message("app.title")}</h1>
|
||||
<p>{i18n.message("app.needsSignIn")}</p>
|
||||
<a href="/auth/sign-in">{i18n.message("app.signIn")}</a>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
// 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))
|
||||
return (
|
||||
<main>
|
||||
<h1>{i18n.message("app.organizations")}</h1>
|
||||
{me.organizations.length === 0 ? (
|
||||
<p>{i18n.message("app.noOrganizations")}</p>
|
||||
) : (
|
||||
<ul>
|
||||
{me.organizations.map((organization) => (
|
||||
<li key={organization.slug}>
|
||||
<a href={`/${organization.slug}`}>{organization.name}</a>{" "}
|
||||
<span>{i18n.message(`roles.${organization.role}`)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<a href="/onboarding">{i18n.message("app.createOrganization")}</a>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// 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. With the fake
|
||||
// platform its procedures answer "unavailable".
|
||||
|
||||
import { ACCOUNT_PATH, accountDeletionStatus } from "@app/modules/account/contract"
|
||||
import { analyticsLabels, deletionLabels, exportLabels, pageLabels } from "@app/modules/account/ui"
|
||||
import { loadAuthConfig } from "@conusai-kit/auth"
|
||||
import { meGet } from "@conusai-kit/tenancy"
|
||||
import { redirect } from "next/navigation"
|
||||
import type { ReactElement } from "react"
|
||||
import { orFallback, signInHref } from "../../../src/server/guard"
|
||||
import { pageContext } from "../../../src/server/page"
|
||||
import { AccountPanels } from "../../../src/ui/account"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
/**
|
||||
* 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 default async function AccountPage(): Promise<ReactElement> {
|
||||
const { caller, i18n, signedIn, path } = await pageContext()
|
||||
if (!signedIn) redirect(signInHref(path))
|
||||
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`account.${key}`, values)
|
||||
const page = pageLabels(t)
|
||||
const [me, status] = await orFallback(path, () =>
|
||||
Promise.all([caller.call(meGet, undefined), caller.call(accountDeletionStatus, undefined)]),
|
||||
)
|
||||
const failed = i18n.message("app.failed")
|
||||
return (
|
||||
<main>
|
||||
<h1>{page.title}</h1>
|
||||
<p>{page.intro}</p>
|
||||
<section aria-labelledby="account-profile">
|
||||
<h2 id="account-profile">{page.profileHeading}</h2>
|
||||
<dl>
|
||||
<dt>{page.name}</dt>
|
||||
<dd>{me.displayName}</dd>
|
||||
<dt>{page.email}</dt>
|
||||
<dd>{me.email}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
<AccountPanels
|
||||
exportLabels={exportLabels(t, failed)}
|
||||
analyticsLabels={analyticsLabels(t, failed)}
|
||||
deletion={{
|
||||
labels: deletionLabels(t, failed, status, me.email),
|
||||
interactive: status.interactive,
|
||||
recentSignIn: status.recentSignIn,
|
||||
reauthHref: reauthHref(me.email),
|
||||
deletion: status.deletion,
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user