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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user