feat(erp+help): native ERP + Help Center over the generic DocType renderer; kill the last 2 iframes (8.4.64)

ERP and Help Center join CMS as NATIVE lanes on the Hanzo Framework — thin
hosts scoping the SAME generic renderer (components/doctype/*) to module=erp /
module=help, with ZERO per-doctype UI code (the DRY proof). This finishes the
Great Unification: CMS + CRM + ERP + Help all native DocTypes, all iframes dead.

- ErpModule/HelpModule rewritten as thin hosts (like CmsModule): collections
  browser + records list + record detail, routed under /erp/collections and
  /helpdesk/collections. Install CTA installs the lane's DocTypes/hooks;
  submit/cancel + status flow come from the schema (no ERP/Help-specific UI).
- registry: erp + helpdesk -> native module routes (collections/:doctype +
  :name), repo hanzoai/cloud, native descriptions.
- CollectionsBrowser: additive optional setupDescription/setupBullets so the
  pre-install empty state reads correctly per lane. CMS default byte-identical
  -- no behavior/permission/proxy change (the RED-passed path is unchanged).
- Kill the iframe/embed subtree ENTIRELY (finishes the unification): the
  Frappe/Payload proxy route handlers app/erp + app/cms are Next catch-alls that
  SHADOWED the native /*/collections SPA routes (a route handler wins over the
  [...slug] page) -> deleting them unshadows native ERP AND native CMS (CMS was
  latently shadow-broken since 8.4.63). Removed the now-dead EmbeddedApp /
  ProvisionPanel / EmbedApi / CmsApi / ErpApi + embed-hosts + embed-probe + their
  tests. No iframe/embed path remains anywhere in the console.

typecheck clean; vitest 1360/1360 (109 files); next build green (the /cms +
/erp proxy routes are gone from the manifest, so /*/collections reach the SPA).
This commit is contained in:
2026-07-03 14:39:58 -07:00
parent 24acef7b72
commit 3289cb01e2
19 changed files with 150 additions and 1874 deletions
-61
View File
@@ -1,61 +0,0 @@
/**
* Same-origin user-bearer proxy to the brand's Payload CMS (`cms.<brand>`) REST API —
* the READ path that powers the console's NATIVE Content views (Collections + Media/DAM)
* alongside the embedded Studio.
*
* The browser calls this OWN-origin route (`/cms/api/pages`, `/cms/api/media`,
* `/cms/api/media/file/<f>`) with just its session cookie. `forwardWithUserBearer`
* resolves the user, mints a short-lived user-bound IAM token, and forwards it to the
* per-brand Payload host as `Authorization: Bearer`. Payload's `hanzoIAMStrategy` verifies
* the JWKS-signed hanzo.id token and its multi-tenant plugin scopes every `pages`/`media`
* row to the token's `owner` claim — so a caller reads ONLY their own org's content,
* SERVER-SIDE and BACKEND-enforced (no org is ever browser-supplied). No token reaches
* the browser.
*
* SSRF-safe by construction: the target host is `cms.<brand>` where `<brand>` is the
* request host CLAMPED to the known brand domains (`clampedBrandDomain`, unit-tested) —
* a forged Host header can never steer this to an arbitrary origin. Least privilege on
* the path: `allowCmsSurface` admits ONLY the two tenant-scoped collections (pages/media)
* + the per-file media bytes route, never `api/users`/`api/tenants` (the cross-org
* registry). READ-ONLY: only GET/HEAD are exposed — the native views never mutate; all
* authoring stays in the Studio.
*/
import { type NextRequest } from 'next/server'
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
import { allowCmsSurface } from '~/lib/server/proxy-allow'
import { clampedBrandDomain } from '~/lib/server/embed-probe'
export const runtime = 'nodejs'
const trim = (s: string) => s.replace(/\/+$/, '')
/** The per-brand Payload origin for this request, SSRF-clamped. An optional `CMS_URL`
* env pins a single in-cluster origin (a single-brand deploy); otherwise it is
* `https://cms.<clamped-brand-domain>`, so a Lux/Zoo console reaches ITS OWN CMS. */
function cmsTarget(host: string | null): string {
const override = process.env.CMS_URL?.trim()
if (override) return trim(override)
return `https://cms.${clampedBrandDomain(host)}`
}
type Ctx = { params: Promise<{ path: string[] }> }
function handle(req: NextRequest, ctx: Ctx) {
return (async () => {
const path = (await ctx.params).path.join('/')
return forwardWithUserBearer(req, {
target: cmsTarget(req.headers.get('host')),
path,
allow: allowCmsSurface,
unauthorizedMessage: 'Sign in to view content.',
})
})()
}
export async function GET(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
export async function HEAD(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
-90
View File
@@ -1,90 +0,0 @@
/**
* Same-origin proxy to the brand's ERPNext/Frappe (`erp.<brand>`) REST API — the READ
* path behind the console's NATIVE ERP summary views (Accounting / Items / Sales Orders).
*
* ERP is a SINGLE shared per-BRAND Frappe instance (verified ground truth: one site,
* `erp.hanzo.ai`, NOT per-customer-org and NOT row-scoped per org). So — unlike the
* per-org CMS proxy — this is ENTITLEMENT-GATED: only a member of the owning brand org
* (or a global admin) may read it. A customer org receives a 403 and no ERP data — never
* a cross-tenant read of the brand's accounting/items/sales.
*
* Frappe does NOT accept a Hanzo IAM Bearer on `/api/*` (its OAuth-provider check rejects
* it); the real REST credential is a Frappe `token <api_key>:<api_secret>`. This proxy
* forwards that as a SERVER-ONLY secret (`ERP_API_TOKEN`, KMS-provisioned) when set — it
* never reaches the browser. When ERP isn't deployed yet (today `erp.<brand>` is 502) or
* the token isn't provisioned, the upstream simply errors and the native views render the
* honest "connect / deploy ERP" state — never fabricated ERP data.
*
* SSRF-safe: the target host is `erp.<brand>` with `<brand>` CLAMPED to the known brand
* domains. Least privilege on the path: `allowErpSurface` admits ONLY `GET
* /api/resource/<DocType>` list reads; `pathIsClean` rejects traversal. GET/HEAD only.
*/
import { type NextRequest, NextResponse } from 'next/server'
import { resolveUser } from '~/lib/server/identity'
import { clampedBrandDomain, brandOrgForHost, isEntitled } from '~/lib/server/embed-probe'
import { pathIsClean } from '~/lib/server/bearer-proxy'
import { allowErpSurface } from '~/lib/server/proxy-allow'
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
export const runtime = 'nodejs'
const trimR = (s: string) => s.replace(/\/+$/, '')
const trimL = (s: string) => s.replace(/^\/+/, '')
/** The per-brand Frappe origin for this request, SSRF-clamped. `ERP_URL` pins a single
* in-cluster origin; otherwise `https://erp.<clamped-brand-domain>`. */
function erpTarget(host: string | null): string {
const override = process.env.ERP_URL?.trim()
if (override) return trimR(override)
return `https://erp.${clampedBrandDomain(host)}`
}
type Ctx = { params: Promise<{ path: string[] }> }
async function handle(req: NextRequest, ctx: Ctx): Promise<NextResponse> {
const path = trimL((await ctx.params).path.join('/')).replace(/\/+$/, '')
if (!pathIsClean(path) || !allowErpSurface(path)) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const user = await resolveUser(req)
if (!user) return NextResponse.json({ error: 'Sign in to view ERP.' }, { status: 401 })
// Entitlement: the shared brand ERP is not per-org isolated, so only the owning brand
// org / a global admin may read it. A customer org gets an honest 403 (the module then
// shows the provision panel), never the brand's ERP data.
const host = req.headers.get('host')
if (!isEntitled('erp', user.owner, brandOrgForHost(host), user.isGlobalAdmin)) {
return NextResponse.json({ error: 'ERP is not provisioned for your organization.', entitled: false }, { status: 403 })
}
const token = process.env.ERP_API_TOKEN?.trim()
const headers: Record<string, string> = { Accept: 'application/json' }
if (token) headers.Authorization = `token ${token}` // Frappe key:secret, server-only
let dest: URL
try {
dest = new URL(`${trimR(erpTarget(host))}/${path}${req.nextUrl.search}`)
} catch {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
try {
const res = await fetchWithTimeout(dest, { method: 'GET', headers, cache: 'no-store', signal: req.signal })
return new NextResponse(res.body, {
status: res.status,
headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json', 'Cache-Control': 'no-store' },
})
} catch {
// 502/timeout/DNS → the module renders the honest "ERP isn't connected — deploy it" state.
return NextResponse.json({ error: 'ERP is not reachable.' }, { status: 502 })
}
}
export async function GET(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
export async function HEAD(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/console",
"version": "8.4.63",
"version": "8.4.64",
"private": true,
"license": "BSD-3-Clause",
"author": "Hanzo AI <dev@hanzo.ai>",
+11 -3
View File
@@ -33,6 +33,14 @@ export interface CollectionsBrowserProps {
subtitle: string
/** Open a collection's records. */
onOpen: (doctype: string) => void
/**
* Lane-appropriate copy for the first-run (pre-install) empty state. Optional and
* defaulted to the CMS wording, so this component stays generic over the lane: a
* CMS caller renders identically, while ERP/Help pass their own description +
* bullets. Additive only — no behavior/permission/proxy change.
*/
setupDescription?: string
setupBullets?: string[]
}
type LoadState =
@@ -40,7 +48,7 @@ type LoadState =
| { phase: 'error'; error: BackendState }
| { phase: 'ready'; collections: DocType[]; registered: boolean }
export function CollectionsBrowser({ client, module, label, subtitle, onOpen }: CollectionsBrowserProps) {
export function CollectionsBrowser({ client, module, label, subtitle, onOpen, setupDescription, setupBullets }: CollectionsBrowserProps) {
const [state, setState] = useState<LoadState>({ phase: 'loading' })
const [busy, setBusy] = useState(false)
const [creating, setCreating] = useState(false)
@@ -150,8 +158,8 @@ export function CollectionsBrowser({ client, module, label, subtitle, onOpen }:
<EmptyState
icon={Boxes}
title={`Set up ${label}`}
description={`${label} is a set of content collections — Pages, Posts, Articles, Media, and Navigation — as DocTypes on the Hanzo Framework, per organization.`}
bullets={[
description={setupDescription ?? `${label} is a set of content collections — Pages, Posts, Articles, Media, and Navigation — as DocTypes on the Hanzo Framework, per organization.`}
bullets={setupBullets ?? [
'Installs the default collections into your organization',
'Content is documents on the framework — versioned, permissioned, per-org',
'Add your own collections and fields any time',
+59 -393
View File
@@ -1,417 +1,83 @@
'use client'
/**
* ERP — accounting, inventory, sales, and HR on the canonical ERPNext (Frappe) backend,
* surfaced IN the console. Honest by construction (verified against the live cluster +
* the hanzoai/erp repo): ERP is a SINGLE shared per-brand Frappe instance and there is
* NO ERP backend running today (`erp.<brand>` is 502). So this module:
* ERP — NATIVE over the live Hanzo Framework DocType engine (/v1/framework/*), NO
* iframe. An ERP master (Item/Customer/Account…) IS a framework DocType tagged with
* module "erp"; a transaction (Sales Order/Sales Invoice/Stock Entry…) IS a
* submittable framework document with child Tables; posting (GL entries on invoice
* submit, the stock ledger on stock-entry submit) IS a native-Go lifecycle hook on
* the engine (clients/erp). This module is a THIN host: it routes between the SAME
* three generic, metadata-driven DocType surfaces CMS uses — the collections
* browser, the records list, and the record detail/editor — with ZERO per-doctype
* UI code. The renderer + client are the DRY foundation; ERP is "that UI scoped to
* module=erp", and the whole ERPNext-core model is data + Go hooks, not bespoke UI.
*
* - is ENTITLEMENT-GATED (server-side `/embed-status`): only the owning brand org / a
* global admin sees ERP — a customer org gets the honest provision panel, never the
* brand's ERP data (Frappe is single-tenant, not per-org isolated).
* - Overview — a REAL deploy: "Deploy ERP" drives `/v1/platform` to provision the
* ERPNext app for the org (idempotent create-project + create-app + deploy), showing
* the live deploy/build status. (A full ERPNext needs its bundled data services — the
* single-image deploy proves the real provisioning path; status reflects that honestly.)
* - Accounting / Items / Sales — NATIVE summary views over Frappe's REST
* (`/api/resource/<DocType>`); real rows the moment an instance is live, an honest
* "connect / deploy ERP" state until then — never fabricated.
* - Desk — the real ERPNext desk EMBEDDED (SSO iframe) once `erp.<brand>` is reachable.
*
* Binds to canonical ERPNext (real Frappe REST reads + the real PaaS deploy + the real
* desk embed) — never reimplemented.
* Per-org and honest by construction: the engine resolves the org from the validated
* bearer (via the `/cloud` proxy) and enforces per-DocType permissions, so a customer
* only ever sees + edits their OWN ERP data, and an un-set-up org sees the "Set up
* ERP" install CTA — never a fabricated record and never the old cross-tenant desk.
*/
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { Boxes, Calculator, LayoutDashboard, Rocket, ShoppingCart, Users } from '@hanzogui/lucide-icons-2'
import { config } from '~/config'
import { EmbedApi, type EmbedStatus } from '~/lib/api/embed'
import { ErpApi, ERP_IMAGE, type ErpAccount, type ErpItem, type ErpSalesOrder } from '~/lib/api/erp'
import type { PaasApp } from '~/lib/api/paas'
import { fmtUsd, fmtAbs } from '~/lib/api/functions'
import { PageHeader } from '~/components/ui/PageHeader'
import { DataTable, type Column } from '~/components/ui/DataTable'
import { StatusTag } from '~/components/ui/StatusTag'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { Loader } from '~/components/ui/Loader'
import { EmbeddedApp } from './embed/EmbeddedApp'
import { ProvisionPanel, type ProvisionFeature } from './embed/ProvisionPanel'
import { FrameworkApi } from '~/lib/framework/client'
import { CollectionsBrowser } from '~/components/doctype/CollectionsBrowser'
import { DocTypeRecords } from '~/components/doctype/DocTypeRecords'
import { DocTypeDetail } from '~/components/doctype/DocTypeDetail'
type Async<T> = { phase: 'loading' } | { phase: 'error'; error: BackendState } | { phase: 'ready'; data: T }
const MODULE = 'erp'
const enc = encodeURIComponent
const MANAGES: ProvisionFeature[] = [
{ icon: Calculator, label: 'Accounting', body: 'General ledger, invoices, payments, and financial statements.' },
{ icon: Boxes, label: 'Inventory', body: 'Items, warehouses, stock levels, and valuation across your catalog.' },
{ icon: ShoppingCart, label: 'Sales & Buying', body: 'Sales and purchase orders, quotations, and customer/supplier records.' },
{ icon: Users, label: 'HR & Payroll', body: 'Employees, attendance, leave, and payroll on one Business-OS.' },
]
const TABS = [
{ id: '', label: 'Overview', icon: LayoutDashboard },
{ id: 'accounting', label: 'Accounting', icon: Calculator },
{ id: 'items', label: 'Items', icon: Boxes },
{ id: 'sales', label: 'Sales Orders', icon: ShoppingCart },
{ id: 'desk', label: 'Desk', icon: LayoutDashboard },
] as const
export function ErpModule({ params }: { params: Record<string, string> }) {
export function ErpModule({ params = {} }: { params?: Record<string, string> }) {
const router = useRouter()
const [state, setState] = useState<Async<EmbedStatus>>({ phase: 'loading' })
const client = FrameworkApi
const { doctype, name } = params
const load = useCallback(() => {
setState({ phase: 'loading' })
EmbedApi.status('erp')
.then((data) => setState({ phase: 'ready', data }))
.catch((e) => setState({ phase: 'error', error: classifyBackend(e) }))
}, [])
useEffect(() => { load() }, [load])
const openCollection = (dt: string) => router.push(`/erp/collections/${enc(dt)}`)
const openRecord = (dt: string, n: string) => router.push(`/erp/collections/${enc(dt)}/${enc(n)}`)
const tab = useMemo(() => {
const t = params.tab ?? ''
return TABS.some((x) => x.id === t) ? t : ''
}, [params.tab])
if (state.phase === 'loading') return <Loader label="Checking ERP…" />
if (state.phase === 'error') {
// /erp/collections/:doctype/:name → the record detail / create form (submit/cancel
// on a submittable transaction come from the schema — zero ERP-specific code).
if (doctype && name) {
return (
<>
<PageHeader title="ERP" subtitle="Accounting, inventory, and HR — your Business-OS ERP." />
<BackendStateCard state={state.error} onRetry={load} hint="probe · GET /embed-status?app=erp" />
</>
)
}
const status = state.data
// Not entitled (a customer org): honest provision panel — the shared brand ERP is not
// per-org isolated, so a customer never reads it. Request a deployment instead.
if (!status.entitled) {
return (
<ProvisionPanel
title="ERP"
subtitle="Accounting, inventory, sales, and HR — your Business-OS ERP on ERPNext."
heroTitle="Deploy Hanzo ERP"
heroBody={
'Hanzo ERP is the full ERPNext (Frappe) business suite — accounting, inventory, sales, ' +
'purchasing, manufacturing, and HR — signed in with your Hanzo identity. It isnt provisioned ' +
'for your organization yet; request a deployment and it will appear here, embedded in the console.'
}
features={MANAGES}
intakeSlug="erp"
intakeLabel="ERP"
cta="Request ERP"
docsHref={config.docsUrl ? `${config.docsUrl}/docs/erp` : undefined}
sourceLabel="hanzoai/erp · ERPNext (Frappe)"
note="Binds to the canonical ERPNext backend — ERP is not reimplemented in the console."
<DocTypeDetail
client={client}
doctype={doctype}
name={name}
onBack={() => openCollection(doctype)}
onView={(n) => openRecord(doctype, n)}
/>
)
}
return (
<YStack gap="$4">
<PageHeader
title="ERP"
subtitle="Accounting, inventory, sales, and HR — your ERPNext desk, in the console."
actions={
<XStack gap="$1" flexWrap="wrap">
{TABS.map((t) => (
<XStack
key={t.id || 'overview'}
onPress={() => router.push(t.id ? `/erp/${t.id}` : '/erp')}
cursor="pointer"
items="center"
gap="$1.5"
px="$3"
height={34}
rounded="$3"
borderWidth={1}
borderColor="$borderColor"
bg={t.id === tab ? '$color5' : 'transparent'}
hoverStyle={{ bg: '$color3' }}
>
<t.icon size={15} />
<Text fontSize="$3" fontWeight="600" color="$color12">{t.label}</Text>
</XStack>
))}
</XStack>
}
/>
{tab === 'accounting' ? <AccountingTab /> : null}
{tab === 'items' ? <ItemsTab /> : null}
{tab === 'sales' ? <SalesTab /> : null}
{tab === 'desk' ? <DeskTab status={status} onRetry={load} /> : null}
{tab === '' ? <OverviewTab status={status} /> : null}
</YStack>
)
}
// ── Overview — reachability + REAL deploy + what-it-is ────────────────────────
function OverviewTab({ status }: { status: EmbedStatus }) {
return (
<YStack gap="$4" maxW={900}>
<DeployPanel reachable={status.reachable} origin={status.origin} />
<XStack gap="$3" flexWrap="wrap">
{MANAGES.map(({ icon: Icon, label, body }) => (
<Card key={label} borderWidth={1} borderColor="$borderColor" p="$4" gap="$2" flex={1} minWidth={240}>
<XStack gap="$2" items="center">
<Icon size={18} />
<Text fontSize="$4" fontWeight="700" color="$color12">{label}</Text>
</XStack>
<Text fontSize="$2" color="$color11">{body}</Text>
</Card>
))}
</XStack>
<Text fontSize="$2" color="$color9">hanzoai/erp · ERPNext (Frappe) bound to the canonical backend, not reimplemented.</Text>
</YStack>
)
}
/** The REAL deploy control — provisions the ERPNext app on Hanzo PaaS and shows live status. */
function DeployPanel({ reachable, origin }: { reachable: boolean; origin: string }) {
const [app, setApp] = useState<PaasApp | null>(null)
const [checking, setChecking] = useState(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [note, setNote] = useState<string | null>(null)
const refresh = useCallback(async () => {
try {
setApp(await ErpApi.app())
} catch {
setApp(null) // platform not reachable / no project yet → deploy CTA
} finally {
setChecking(false)
}
}, [])
useEffect(() => { void refresh() }, [refresh])
const deploy = async () => {
setBusy(true); setError(null); setNote(null)
try {
const d = await ErpApi.deploy()
setNote(`Deployment started — status: ${d.status ?? 'queued'}.`)
await refresh()
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not start the deployment.')
} finally {
setBusy(false)
}
}
const appStatus = app?.phase || app?.status
return (
<Card borderWidth={1} borderColor="$borderColor" p="$5" gap="$3">
<XStack items="center" gap="$2" flexWrap="wrap">
<Text fontSize="$6" fontWeight="800" color="$color12">Hanzo ERP</Text>
{reachable ? (
<StatusTag status="live" />
) : app ? (
<StatusTag status={appStatus || 'provisioning'} />
) : (
<Text fontSize="$2" color="$color10">not deployed</Text>
)}
</XStack>
<Text fontSize="$3" color="$color11">
The full ERPNext (Frappe) business suite accounting, inventory, sales, purchasing, and HR signed in with
your Hanzo identity. Deploy provisions the ERP app on Hanzo PaaS for your organization. A complete ERPNext also
needs its bundled data services (MariaDB/Redis); the apps status below reflects the real deployment.
</Text>
{reachable ? (
<Text fontSize="$2" color="$green10">ERP is live at {origin} open the Desk tab to use it.</Text>
) : null}
{app ? (
<XStack gap="$4" flexWrap="wrap">
<Fact label="App" value={app.slug || app.name || 'erp'} />
<Fact label="Image" value={`${ERP_IMAGE.repository}:${ERP_IMAGE.tag}`} />
<Fact label="Status" value={appStatus || '—'} />
{app.health ? <Fact label="Health" value={app.health} /> : null}
</XStack>
) : null}
<XStack gap="$2" items="center" flexWrap="wrap">
<Button
size="$3"
theme="light"
disabled={busy || checking}
icon={busy ? <Spinner color="$color1" /> : <Rocket size={16} />}
onPress={() => void deploy()}
>
{busy ? 'Deploying…' : app ? 'Redeploy ERP' : 'Deploy ERP'}
</Button>
<Button size="$3" chromeless disabled={checking || busy} onPress={() => void refresh()}>Refresh status</Button>
</XStack>
{note ? <Text fontSize="$2" color="$green10">{note}</Text> : null}
{error ? <Text fontSize="$2" color="$red10">{error}</Text> : null}
</Card>
)
}
function Fact({ label, value }: { label: string; value: string }) {
return (
<YStack minW={120}>
<Text fontSize="$1" color="$color10">{label}</Text>
<Text fontSize="$3" fontWeight="700" color="$color12" numberOfLines={1}>{value}</Text>
</YStack>
)
}
// ── Native Frappe summary views (honest-until-live) ──────────────────────────
/** Shared summary shell: fetch → honest "not connected" (502/401) / empty / table. */
function ErpSummary<T>({
title,
subtitle,
load,
columns,
rowKey,
empty,
hint,
}: {
title: string
subtitle: string
load: () => Promise<T[]>
columns: Column<T>[]
rowKey: (r: T) => string
empty: string
hint: string
}) {
const [state, setState] = useState<Async<T[]>>({ phase: 'loading' })
const run = useCallback(() => {
setState({ phase: 'loading' })
load()
.then((data) => setState({ phase: 'ready', data }))
.catch((e) => setState({ phase: 'error', error: classifyBackend(e) }))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => { run() }, [run])
return (
<YStack gap="$3">
<YStack gap="$1">
<Text fontSize="$5" fontWeight="800" color="$color12">{title}</Text>
<Text fontSize="$2" color="$color10">{subtitle}</Text>
</YStack>
{state.phase === 'error' ? (
<NotConnected error={state.error} onRetry={run} hint={hint} />
) : (
<DataTable
columns={columns}
rows={state.phase === 'ready' ? state.data : []}
loading={state.phase === 'loading'}
rowKey={rowKey}
empty={empty}
/>
)}
</YStack>
)
}
/** Honest "ERP isn't connected yet" — a 502/401/403 on a summary read means no live
* ERP instance (or no Frappe credential). Points the user at the Overview deploy. */
function NotConnected({ error, onRetry, hint }: { error: BackendState; onRetry: () => void; hint: string }) {
return (
<Card p="$5" gap="$2" borderWidth={1} borderColor="$borderColor">
<Text fontSize="$4" fontWeight="800" color="$color12">ERP isnt connected yet</Text>
<Text fontSize="$2" color="$color11">
No live ERPNext instance answered this summary. Deploy ERP from the Overview tab; once its live, the real
accounting, items, and sales data appear here nothing is fabricated in the meantime.
</Text>
<Text fontSize="$1" color="$color9">{hint}</Text>
<XStack pt="$2"><Button size="$2" onPress={onRetry}>Retry</Button></XStack>
{/* keep the classified reason available to assistive tech / debugging */}
<Text fontSize="$1" color="$color9">{error.message}</Text>
</Card>
)
}
function AccountingTab() {
const columns: Column<ErpAccount>[] = [
{ key: 'account', header: 'Account', render: (r) => <Text fontSize="$3" fontWeight="600" color="$color12">{r.accountName || r.name}</Text> },
{ key: 'type', header: 'Type', width: 150, render: (r) => <Text fontSize="$3" color="$color11">{r.accountType || '—'}</Text> },
{ key: 'root', header: 'Root', width: 130, render: (r) => <Text fontSize="$3" color="$color11">{r.rootType || '—'}</Text> },
{ key: 'currency', header: 'Currency', width: 110, render: (r) => <Text fontSize="$3" color="$color11">{r.currency || '—'}</Text> },
]
return (
<ErpSummary<ErpAccount>
title="Accounting"
subtitle="Chart of accounts — the general-ledger accounts on your ERPNext instance."
load={() => ErpApi.accounts()}
columns={columns}
rowKey={(r) => r.name}
empty="No accounts yet."
hint="endpoint · GET /erp/api/resource/Account (ERPNext)"
/>
)
}
function ItemsTab() {
const columns: Column<ErpItem>[] = [
{ key: 'item', header: 'Item', render: (r) => <Text fontSize="$3" fontWeight="600" color="$color12">{r.itemName || r.itemCode || r.name}</Text> },
{ key: 'code', header: 'Code', width: 160, render: (r) => <Text fontSize="$3" color="$color11">{r.itemCode || '—'}</Text> },
{ key: 'group', header: 'Group', width: 150, render: (r) => <Text fontSize="$3" color="$color11">{r.itemGroup || '—'}</Text> },
{ key: 'uom', header: 'UOM', width: 90, render: (r) => <Text fontSize="$3" color="$color11">{r.uom || '—'}</Text> },
{ key: 'valuation', header: 'Valuation', width: 120, render: (r) => <Text fontSize="$3" color="$color11">{r.valuationRate != null ? r.valuationRate.toLocaleString() : '—'}</Text> },
{ key: 'stock', header: 'Stock item', width: 110, render: (r) => <StatusTag status={r.disabled ? 'inactive' : r.stockItem ? 'active' : 'inactive'} /> },
]
return (
<ErpSummary<ErpItem>
title="Items & Inventory"
subtitle="Your catalog items — code, group, unit, and valuation on ERPNext."
load={() => ErpApi.items()}
columns={columns}
rowKey={(r) => r.name}
empty="No items yet."
hint="endpoint · GET /erp/api/resource/Item (ERPNext)"
/>
)
}
function SalesTab() {
const columns: Column<ErpSalesOrder>[] = [
{ key: 'order', header: 'Order', width: 160, render: (r) => <Text fontSize="$3" fontWeight="600" color="$color12">{r.name}</Text> },
{ key: 'customer', header: 'Customer', render: (r) => <Text fontSize="$3" color="$color11">{r.customer || '—'}</Text> },
{ key: 'date', header: 'Date', width: 130, render: (r) => <Text fontSize="$3" color="$color11">{fmtAbs(r.date)}</Text> },
{ key: 'total', header: 'Total', width: 130, render: (r) => <Text fontSize="$3" color="$color11">{r.grandTotal != null ? `${r.currency ? r.currency + ' ' : ''}${r.grandTotal.toLocaleString()}` : '—'}</Text> },
{ key: 'status', header: 'Status', width: 150, render: (r) => (r.status ? <StatusTag status={r.status} /> : <Text fontSize="$3" color="$color10"></Text>) },
]
return (
<ErpSummary<ErpSalesOrder>
title="Sales Orders"
subtitle="Recent sales orders — customer, total, and status from ERPNext."
load={() => ErpApi.salesOrders()}
columns={columns}
rowKey={(r) => r.name}
empty="No sales orders yet."
hint="endpoint · GET /erp/api/resource/Sales Order (ERPNext)"
/>
)
}
// ── Desk — the real ERPNext desk embedded once reachable ─────────────────────
function DeskTab({ status, onRetry }: { status: EmbedStatus; onRetry: () => void }) {
if (status.reachable) {
// /erp/collections/:doctype → the records list (table) for one DocType.
if (doctype) {
return (
<EmbeddedApp
title="ERP Desk"
subtitle="Your ERPNext desk, embedded with IAM single sign-on."
src={status.embedUrl}
openLabel="Open ERP"
sourceLabel="hanzoai/erp"
note="Your ERPNext desk, signed in with your Hanzo identity (IAM SSO)."
<DocTypeRecords
client={client}
doctype={doctype}
onOpen={(n) => openRecord(doctype, n)}
onCreate={() => openRecord(doctype, 'new')}
/>
)
}
// /erp → the DocType browser (Items/Customers/Sales Orders/…), with the setup CTA
// that installs the ERP lane's DocTypes + hooks into the org.
return (
<Card p="$5" gap="$2" borderWidth={1} borderColor="$borderColor">
<Text fontSize="$4" fontWeight="800" color="$color12">The ERP desk isnt live yet</Text>
<Text fontSize="$2" color="$color11">
Deploy ERP from the Overview tab. Once {status.origin} is reachable, the full ERPNext desk embeds here, signed
in with your Hanzo identity.
</Text>
<XStack pt="$2"><Button size="$2" onPress={onRetry}>Check again</Button></XStack>
</Card>
<CollectionsBrowser
client={client}
module={MODULE}
label="ERP"
subtitle="A native, metadata-driven ERP on the Hanzo Framework — items, warehouses, customers, sales & purchasing, accounting, and HR as DocTypes, per organization. Business logic (line totals, stock ledger, double-entry GL) runs as native hooks on the engine."
setupDescription="ERP is your accounting, inventory, sales, purchasing, and HR model — items, warehouses, sales orders, invoices, stock entries, journal entries, and payments — as DocTypes on the Hanzo Framework, per organization."
setupBullets={[
'Installs the ERPNext-core DocTypes into your organization',
'Transactions submit/cancel with real business hooks — line totals, stock ledger, and balanced GL postings',
'Every record is a document on the framework — versioned, permissioned, per-org',
]}
onOpen={openCollection}
/>
)
}
export default ErpModule
+58 -93
View File
@@ -1,116 +1,81 @@
'use client'
/**
* Help Center — the live Hanzo Help Center (a Frappe Helpdesk, deployed at
* help.<brand> and confirmed live at help.hanzo.ai) rendered IN the console.
* Help Center — NATIVE over the live Hanzo Framework DocType engine (/v1/framework/*),
* NO iframe. A ticket IS a framework document tagged with module "help"; its lifecycle
* (Open → Pending → Resolved → Closed) IS a status field; agents, teams, SLAs, and
* canned responses are framework documents (clients/help). This module is a THIN host:
* it routes between the SAME generic, metadata-driven DocType surfaces CMS and ERP use
* — the collections browser, the records list, and the record detail/editor — with
* ZERO per-doctype UI code. The Help Center is the purest DRY proof: it is fixtures
* only (no Go hooks, no console UI), yet it renders a full support desk.
*
* Not a link-out: for the org that OWNS it, the Help Center is EMBEDDED (SSO iframe)
* inside the console shell, so submitting a ticket or reading the knowledge base
* never leaves console.<brand>. It is wired to the brand IAM as a Frappe social login
* ("Login with hanzo", client_id `<brand>-helpdesk`), so it opens signed-in with the
* same identity the console holds.
*
* Honest tenancy (verified): the Help Center is today a SINGLE shared per-BRAND
* Frappe Helpdesk (`HANZO_ORG=hanzo`), NOT per-customer-org. Ticket confidentiality
* on a shared desk rests on the Helpdesk's own role mapping, which the console can't
* verify — so rather than trust that blind, entitlement is decided SERVER-SIDE by
* `/embed-status` (brand org / global admin only). A CUSTOMER org receives
* `entitled:false` and NO embed URL, and sees an honest "a Help Center for your org
* isn't provisioned yet" panel — it is never framed into the brand's support desk.
* When a per-org Help Center exists the SAME gate embeds it. Binds to the canonical
* Frappe Helpdesk — not reimplemented here.
* Per-org and honest by construction: the engine resolves the org from the validated
* bearer (via the `/cloud` proxy) and enforces per-DocType permissions, so each org
* sees + edits ONLY its own tickets/agents, and an un-set-up org sees the "Set up Help
* Center" install CTA — never a fabricated ticket and never the old shared-desk iframe.
*/
import { useCallback, useEffect, useState } from 'react'
import { LifeBuoy, BookOpen, Inbox, Clock } from '@hanzogui/lucide-icons-2'
import { useRouter } from 'next/navigation'
import { config } from '~/config'
import { EmbedApi, type EmbedStatus } from '~/lib/api/embed'
import { PageHeader } from '~/components/ui/PageHeader'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { Loader } from '~/components/ui/Loader'
import { EmbeddedApp } from './embed/EmbeddedApp'
import { ProvisionPanel, type ProvisionFeature } from './embed/ProvisionPanel'
import { FrameworkApi } from '~/lib/framework/client'
import { CollectionsBrowser } from '~/components/doctype/CollectionsBrowser'
import { DocTypeRecords } from '~/components/doctype/DocTypeRecords'
import { DocTypeDetail } from '~/components/doctype/DocTypeDetail'
const MANAGES: ProvisionFeature[] = [
{ icon: Inbox, label: 'Tickets', body: 'A shared inbox for customer requests, with assignment, status, and replies.' },
{ icon: BookOpen, label: 'Knowledge Base', body: 'Public help articles your customers can search before they file a ticket.' },
{ icon: Clock, label: 'SLAs', body: 'Response and resolution targets, with escalation when theyre at risk.' },
{ icon: LifeBuoy, label: 'Portal', body: 'A branded self-service portal where users track their own requests.' },
]
const MODULE = 'help'
const enc = encodeURIComponent
type Async = { phase: 'loading' } | { phase: 'error'; error: BackendState } | { phase: 'ready'; data: EmbedStatus }
export function HelpModule({ params = {} }: { params?: Record<string, string> }) {
const router = useRouter()
const client = FrameworkApi
const { doctype, name } = params
export function HelpModule() {
const [state, setState] = useState<Async>({ phase: 'loading' })
// The registry id for this lane is 'helpdesk' (the URL prefix); the framework
// module tag is 'help'. Keep the route prefix aligned to the registry id.
const openCollection = (dt: string) => router.push(`/helpdesk/collections/${enc(dt)}`)
const openRecord = (dt: string, n: string) => router.push(`/helpdesk/collections/${enc(dt)}/${enc(n)}`)
const load = useCallback(() => {
setState({ phase: 'loading' })
EmbedApi.status('help')
.then((data) => setState({ phase: 'ready', data }))
.catch((e) => setState({ phase: 'error', error: classifyBackend(e) }))
}, [])
useEffect(() => { load() }, [load])
if (state.phase === 'loading') return <Loader label="Loading Help Center…" />
if (state.phase === 'error') {
// /helpdesk/collections/:doctype/:name → the record detail / create form.
if (doctype && name) {
return (
<>
<PageHeader title="Help Center" subtitle="Support tickets and a knowledge base for your users." />
<BackendStateCard state={state.error} onRetry={load} hint="probe · GET /embed-status?app=help" />
</>
)
}
const status = state.data
// Not entitled (a customer org): honest provision panel — NEVER an embed of the
// brand's shared support desk (which would risk cross-org ticket visibility).
if (!status.entitled) {
return (
<ProvisionPanel
title="Help Center"
subtitle="A customer helpdesk — tickets and a knowledge base for your users, with IAM single sign-on."
heroTitle="Help Center for your organization"
heroBody={
'The Help Center is a Frappe Helpdesk — a shared ticket inbox, SLAs, and a searchable ' +
'knowledge base for your users. Today it runs as a shared per-brand desk, so a dedicated ' +
'Help Center for your organization isnt provisioned yet — request one and it will appear ' +
'here, embedded and signed in with your Hanzo identity.'
}
features={MANAGES}
intakeSlug="helpdesk"
intakeLabel="Help Center"
cta="Request Help Center"
docsHref={config.docsUrl ? `${config.docsUrl}/docs/helpdesk` : undefined}
sourceLabel="hanzoai/helpdesk · Frappe Helpdesk"
note="Binds to the canonical Frappe Helpdesk — the Help Center is not reimplemented in the console."
<DocTypeDetail
client={client}
doctype={doctype}
name={name}
onBack={() => openCollection(doctype)}
onView={(n) => openRecord(doctype, n)}
/>
)
}
// Entitled (brand org / global admin) and the desk is live → embed it.
if (status.reachable) {
// /helpdesk/collections/:doctype → the records list (Tickets/Agents/Teams/…).
if (doctype) {
return (
<EmbeddedApp
title="Help Center"
subtitle="Support tickets and a knowledge base — embedded with IAM single sign-on."
src={status.embedUrl}
openLabel="Open Help Center"
sourceLabel="hanzoai/helpdesk"
note="Your brands Help Center, signed in with your Hanzo identity (IAM SSO)."
<DocTypeRecords
client={client}
doctype={doctype}
onOpen={(n) => openRecord(doctype, n)}
onCreate={() => openRecord(doctype, 'new')}
/>
)
}
// Entitled but the desk isn't answering — honest "unavailable".
// /helpdesk → the DocType browser, with the setup CTA that installs the Help lane.
return (
<>
<PageHeader title="Help Center" subtitle="Support tickets and a knowledge base for your users." />
<BackendStateCard
state={{ kind: 'unavailable', message: `The Help Center (${status.origin}) is not reachable right now.` }}
onRetry={load}
hint={`host · ${status.origin}`}
/>
</>
<CollectionsBrowser
client={client}
module={MODULE}
label="Help Center"
subtitle="A native, metadata-driven support desk on the Hanzo Framework — tickets, agents, teams, SLAs, and canned responses as DocTypes, per organization. The ticket lifecycle is a status field on the engine; no separate helpdesk to run."
setupDescription="The Help Center is your support desk — tickets, agents, teams, SLAs, and canned responses — as DocTypes on the Hanzo Framework, per organization. A ticket's lifecycle (Open → Pending → Resolved → Closed) is a status field."
setupBullets={[
'Installs the helpdesk DocTypes into your organization',
'Tickets move through their status workflow on the framework — assigned to agents and teams',
'Every ticket is a document on the framework — versioned, permissioned, per-org',
]}
onOpen={openCollection}
/>
)
}
export default HelpModule
@@ -1,153 +0,0 @@
'use client'
/**
* EmbeddedApp — the ONE way to surface a canonical Hanzo app (Content Studio /
* ERP / Help Center) INSIDE the console shell. Not a link-out: the real app renders
* in an iframe within the dashboard chrome (sidebar + top bar + breadcrumb stay),
* so it reads as a console product, per-org, over the SAME IAM single-sign-on the
* console already established.
*
* SSO, not an open frame: the embedded app runs its OWN IAM SSO, and the console
* gates the embed to the OWNING org SERVER-SIDE (`/embed-status` only returns an
* embed URL to a brand-org member / global admin — a customer org gets a provision
* panel, never this frame). The console injects NO credential and the iframe carries
* none. The user is already signed in to the shared brand IAM, so the app's SSO
* completes silently. (This gates who the CONSOLE frames; a shared single-tenant app
* still owes its own per-org isolation — see the module docstrings.)
*
* Honest by construction: an iframe can't report a cross-origin load failure to the
* parent (same-origin policy), so this NEVER fabricates a "loaded" or "failed"
* verdict it can't observe. It shows a real loading state until the first `load`
* event, always offers "Open full screen" as a truthful fallback, and prints the
* exact origin — if a brand ever blocks framing, the user still has a working path.
*
* DRY: CMS, ERP, and Help all render through this one component; adding another
* embedded app is a thin module that resolves its per-brand origin + calls this.
*/
import { useCallback, useState, type ReactNode } from 'react'
import { Button, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { ArrowUpRight, RefreshCw } from '@hanzogui/lucide-icons-2'
import { PageHeader } from '~/components/ui/PageHeader'
/**
* The MINIMAL iframe permission set for an embedded first-party app: run its own
* scripts, treat its OWN origin as same-origin (needed for its session + XHR — this
* does NOT grant access to the cross-origin console parent, which SOP still blocks),
* submit forms (login), open a popup (OAuth/consent), and download. Deliberately
* withheld: `allow-top-navigation-*` (the framed app must never redirect the console
* tab — SSO redirects happen INSIDE the frame) and `allow-popups-to-escape-sandbox`
* (a popup inherits the sandbox). Trimmed per RED review.
*/
const SANDBOX = ['allow-scripts', 'allow-same-origin', 'allow-forms', 'allow-popups', 'allow-downloads'].join(' ')
export function EmbeddedApp({
title,
subtitle,
src,
openLabel = 'Open full screen',
sourceLabel,
note,
actions,
}: {
title: string
subtitle?: string
/** The embedded app's fully-qualified URL (per-brand origin + path). */
src: string
/** Label for the secondary "open in a new tab" action. */
openLabel?: string
/** Optional short source/repo label shown in the footer (e.g. `hanzoai/cms`). */
sourceLabel?: string
/** Optional one-line note under the frame (e.g. the SSO/tenancy explanation). */
note?: ReactNode
/** Optional extra header actions rendered before Reload / Open. */
actions?: ReactNode
}) {
const [loading, setLoading] = useState(true)
// Bump to force a fresh iframe (remount) on Reload.
const [nonce, setNonce] = useState(0)
const reload = useCallback(() => {
setLoading(true)
setNonce((n) => n + 1)
}, [])
const openTab = useCallback(() => {
if (typeof window !== 'undefined') window.open(src, '_blank', 'noopener,noreferrer')
}, [src])
return (
<YStack gap="$3" flex={1} minH={0}>
<PageHeader
title={title}
subtitle={subtitle}
actions={
<XStack gap="$2" items="center">
{actions}
<Button size="$2" chromeless icon={<RefreshCw size={15} />} onPress={reload}>
Reload
</Button>
<Button size="$2" iconAfter={<ArrowUpRight size={15} />} onPress={openTab}>
{openLabel}
</Button>
</XStack>
}
/>
<YStack
borderWidth={1}
borderColor="$borderColor"
rounded="$4"
overflow="hidden"
position="relative"
bg="$color2"
// Fill the visible content area (viewport minus the top bar, page padding,
// and this header). The outer ScrollView still scrolls if a viewport is short.
style={{ height: 'calc(100vh - 184px)', minHeight: 480 }}
>
{loading ? (
<YStack
position="absolute"
t={0}
l={0}
r={0}
b={0}
items="center"
justify="center"
gap="$2"
pointerEvents="none"
style={{ zIndex: 1 }}
>
<Spinner color="$color11" />
<Text fontSize="$2" color="$color10">
Loading {title}
</Text>
</YStack>
) : null}
<iframe
key={nonce}
src={src}
title={title}
onLoad={() => setLoading(false)}
sandbox={SANDBOX}
referrerPolicy="no-referrer-when-downgrade"
allow="clipboard-write; fullscreen"
style={{ border: 0, width: '100%', height: '100%', display: 'block' }}
/>
</YStack>
<XStack gap="$3" items="center" flexWrap="wrap">
{note ? (
<Text fontSize="$2" color="$color10" flex={1} minW={220}>
{note}
</Text>
) : (
<YStack flex={1} minW={220} />
)}
<Text fontSize="$2" color="$color9">
{sourceLabel ? `${sourceLabel} · ` : ''}
{src}
</Text>
</XStack>
</YStack>
)
}
@@ -1,125 +0,0 @@
'use client'
/**
* ProvisionPanel — the honest "this app isn't provisioned for you yet" surface for
* an embeddable product (ERP, or the Content Studio for a customer org). NOT a fake
* product and NOT a dead link: it states what the app is, what it will manage, and
* offers a REAL provisioning request (the same-origin `/waitlist` intake, which
* records the request server-side and returns an honest 501 when the intake isn't
* open — never a fabricated "deployed"). The moment a real instance is live, the
* module embeds it instead (EmbeddedApp) — this panel is only the pre-provision state.
*
* DRY: both the ERP module (no instance anywhere yet) and the CMS module (a shared
* per-brand Studio, so a CUSTOMER org has no instance of its own) render through
* this one panel; they differ only in copy, features, and the intake slug.
*/
import type { ComponentType, ReactNode } from 'react'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { ArrowUpRight } from '@hanzogui/lucide-icons-2'
import { PageHeader } from '~/components/ui/PageHeader'
import { FadeIn } from '~/components/ui/FadeIn'
import { WaitlistForm } from '~/components/products/WaitlistForm'
export type ProvisionFeature = {
icon: ComponentType<{ size?: number }>
label: string
body: string
}
export function ProvisionPanel({
title,
subtitle,
heroTitle,
heroBody,
features,
intakeSlug,
intakeLabel,
cta,
docsHref,
sourceLabel,
note,
}: {
title: string
subtitle?: string
/** Hero card heading (e.g. "Hanzo ERP" / "Content Studio for your org"). */
heroTitle: string
/** Hero card body — what it is + the honest provisioning status. */
heroBody: ReactNode
/** The "what it manages" cards. */
features: ProvisionFeature[]
/** `/waitlist` intake slug (a real per-product provisioning-request list). */
intakeSlug: string
/** Human label for the intake copy (e.g. "ERP"). */
intakeLabel: string
/** Provision button label (e.g. "Request ERP"). */
cta: string
docsHref?: string
/** Optional repo/source label shown in the footer (e.g. `hanzoai/erp`). */
sourceLabel?: string
note?: ReactNode
}) {
const openDocs = () => {
if (docsHref && typeof window !== 'undefined') window.open(docsHref, '_blank', 'noopener')
}
return (
<>
<PageHeader
title={title}
subtitle={subtitle}
actions={
docsHref ? (
<Button size="$2" chromeless iconAfter={<ArrowUpRight size={15} />} onPress={openDocs}>
Docs
</Button>
) : undefined
}
/>
<FadeIn>
<YStack gap="$4" maxW={860}>
<Card borderWidth={1} borderColor="$borderColor" p="$5" gap="$3">
<Text fontSize="$6" fontWeight="800" color="$color12">
{heroTitle}
</Text>
<Text fontSize="$3" color="$color11">
{heroBody}
</Text>
<YStack gap="$2" mt="$2">
<Text fontSize="$2" fontWeight="700" color="$color11">
Provision {intakeLabel} for your organization
</Text>
<WaitlistForm waitlist={intakeSlug} label={intakeLabel} cta={cta} busyCta="Requesting…" />
</YStack>
</Card>
<XStack gap="$3" flexWrap="wrap">
{features.map(({ icon: Icon, label, body }) => (
<Card key={label} borderWidth={1} borderColor="$borderColor" p="$4" gap="$2" flex={1} minWidth={240}>
<XStack gap="$2" items="center">
<Icon size={18} />
<Text fontSize="$4" fontWeight="700" color="$color12">
{label}
</Text>
</XStack>
<Text fontSize="$2" color="$color11">
{body}
</Text>
</Card>
))}
</XStack>
{note ? (
<Text fontSize="$2" color="$color10">
{note}
</Text>
) : null}
{sourceLabel ? (
<Text fontSize="$2" color="$color9">
{sourceLabel}
</Text>
) : null}
</YStack>
</FadeIn>
</>
)
}
-76
View File
@@ -1,76 +0,0 @@
import { describe, it, expect } from 'vitest'
import { normalizePage, normalizeMedia, cmsMediaFileUrl, cmsMediaSrc } from './cms'
describe('cms — live-shape fixes (numeric ids + prefixed media url)', () => {
it('coerces Payload SQLite INTEGER ids to a stable string (id:3 → "3", not "")', () => {
// Live prod shape: `{"id":3,"title":"Prove-in-prod v2",...}` — numeric id.
expect(normalizePage({ id: 3, title: 'Prove-in-prod v2', slug: 'prove-v2', _status: 'published' }).id).toBe('3')
expect(normalizeMedia({ id: 2, filename: 'prove.png' }).id).toBe('2')
// still works for string ids and is honest-empty when truly absent
expect(normalizePage({ id: 'abc' }).id).toBe('abc')
expect(normalizeMedia({}).id).toBe('')
})
it('cmsMediaSrc proxies the doc url WITH its ?prefix=<tenant> query (bytes need it)', () => {
// Live prod shape: url carries the multi-tenant prefix query.
const m = normalizeMedia({ id: 2, filename: 'prove.png', mimeType: 'image/png', url: '/api/media/file/prove.png?prefix=hanzo' })
const src = cmsMediaSrc(m)
expect(src).toBe('/cms/api/media/file/prove.png?prefix=hanzo') // OWN-origin proxy + prefix preserved
expect(src).not.toContain('cms.hanzo.ai') // never the cross-origin auth-required host
})
it('cmsMediaSrc extracts the /api path from an ABSOLUTE url too, then proxies it', () => {
const src = cmsMediaSrc({ url: 'https://cms.hanzo.ai/api/media/file/x.png?prefix=hanzo' })
expect(src).toBe('/cms/api/media/file/x.png?prefix=hanzo')
})
it('cmsMediaSrc falls back to the filename when the doc has no url', () => {
expect(cmsMediaSrc({ filename: 'logo.png' })).toBe('/cms/api/media/file/logo.png')
expect(cmsMediaSrc({})).toBe('')
})
})
describe('cms normalizers — bind to the REAL Payload collection shapes', () => {
it('normalizePage reads title/slug/_status/updatedAt', () => {
const p = normalizePage({ id: 'p1', title: 'Home', slug: 'home', _status: 'published', updatedAt: '2026-06-01T00:00:00Z' })
expect(p.id).toBe('p1')
expect(p.title).toBe('Home')
expect(p.slug).toBe('home')
expect(p.status).toBe('published')
expect(p.updatedAt).toBe('2026-06-01T00:00:00Z')
})
it('normalizePage degrades a title-less doc to "(untitled)" (never throws / never blank)', () => {
const p = normalizePage({ id: 'p2' })
expect(p.title).toBe('(untitled)')
expect(p.slug).toBeUndefined()
})
it('normalizeMedia reads filename/mimeType/filesize/dimensions', () => {
const m = normalizeMedia({ id: 'm1', filename: 'logo.png', mimeType: 'image/png', filesize: 20480, width: 512, height: 512, alt: 'Logo' })
expect(m.filename).toBe('logo.png')
expect(m.mimeType).toBe('image/png')
expect(m.filesize).toBe(20480)
expect(m.width).toBe(512)
expect(m.height).toBe(512)
expect(m.alt).toBe('Logo')
})
it('normalizeMedia tolerates missing numeric fields (honest undefined, not NaN)', () => {
const m = normalizeMedia({ id: 'm2', filename: 'doc.pdf', mimeType: 'application/pdf' })
expect(m.filesize).toBeUndefined()
expect(m.width).toBeUndefined()
})
it('cmsMediaFileUrl routes bytes through the OWN-origin /cms proxy (never the cross-origin cms host)', () => {
// SSR (no window) → root-relative proxy path; never an absolute cms.<brand> URL.
const url = cmsMediaFileUrl('photo.jpg')
expect(url).toBe('/cms/api/media/file/photo.jpg')
expect(url.startsWith('/cms/')).toBe(true)
expect(url).not.toContain('cms.hanzo.ai')
})
it('cmsMediaFileUrl returns "" for an empty filename (no dangling proxy URL)', () => {
expect(cmsMediaFileUrl('')).toBe('')
})
})
-136
View File
@@ -1,136 +0,0 @@
/**
* Content (Payload CMS) API — the NATIVE console read of the brand's Content Studio
* collections, over the console's OWN same-origin user-bearer `/cms` proxy
* (`<origin>/cms/api/<collection>`). The proxy mints a short-lived user IAM Bearer and
* forwards it to `cms.<brand>`; Payload's multi-tenant plugin scopes every row to the
* token's `owner` claim, so each org reads ONLY its own pages/media — org isolation is
* BACKEND-enforced (never a browser-supplied org). No credential reaches the browser.
*
* This is the NATIVE half of the Content product (Collections list + Media/DAM grid);
* the full block editor stays the embedded Studio (`CmsModule` Studio tab). We do NOT
* reimplement Payload — we read its real REST API. Payload returns its standard
* paginated envelope `{ docs, totalDocs, ... }`; rows are normalized DEFENSIVELY (a
* field rename degrades a cell to "—", never throws) and an empty tenant renders an
* honest empty state, never a placeholder.
*/
import { restGet } from './client'
/** The console's OWN same-origin CMS proxy base (`<origin>/cms`). */
const cmsBase = (): string => (typeof window !== 'undefined' ? `${window.location.origin}/cms` : '/cms')
/** A Payload REST URL on the `/cms` proxy (`<origin>/cms/api/<path>`). */
const cmsApiUrl = (path: string): string => `${cmsBase()}/api/${path.replace(/^\/+/, '')}`
/**
* The console-proxied URL for a media file's bytes. Payload serves media at
* `/api/media/file/<filename>` with the SAME per-tenant access control as the JSON, so
* an `<img>` must point at this proxy route (which forwards the bearer) — never at the
* cross-origin, auth-required `media.url` directly. Filenames are slugified by Payload
* (no spaces/encoding needed); a `<img>` load failure is an honest broken image, never
* a fabricated one.
*/
export const cmsMediaFileUrl = (filename: string): string =>
filename ? `${cmsBase()}/api/media/file/${filename}` : ''
/**
* The console-proxied `<img>` src for a media asset. PREFERS the doc's real `url` — it
* carries the `?prefix=<tenant>` query the multi-tenant storage needs to resolve the bytes
* (dropping it 404s / mis-resolves) — routed through the OWN-origin `/cms` proxy (take the
* `/api/...` path+query from a relative OR absolute url; never load the cross-origin,
* auth-required cms host directly). Falls back to reconstructing from the filename.
*/
export const cmsMediaSrc = (media: { url?: string; filename?: string }): string => {
const u = media.url
if (u) {
const apiIdx = u.indexOf('/api/')
if (apiIdx >= 0) return `${cmsBase()}${u.slice(apiIdx)}`
}
return media.filename ? cmsMediaFileUrl(media.filename) : ''
}
// ── Defensive coercion ───────────────────────────────────────────────────────
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined)
const num = (v: unknown): number | undefined =>
typeof v === 'number' && Number.isFinite(v) ? v : typeof v === 'string' && v.trim() && Number.isFinite(Number(v)) ? Number(v) : undefined
const asRecord = (v: unknown): Record<string, unknown> =>
v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {}
const pick = (r: Record<string, unknown>, keys: string[]): string | undefined => {
for (const k of keys) { const s = str(r[k]); if (s) return s }
return undefined
}
/** Payload's paginated envelope — the `docs` array is the rows, `totalDocs` the count. */
export type CmsList<T> = { rows: T[]; total: number }
const listFrom = <T,>(payload: unknown, normalize: (r: Record<string, unknown>) => T): CmsList<T> => {
const r = asRecord(payload)
const docs = Array.isArray(r.docs) ? r.docs : Array.isArray(payload) ? (payload as unknown[]) : []
const rows = docs.filter((x) => x && typeof x === 'object').map((x) => normalize(x as Record<string, unknown>))
return { rows, total: num(r.totalDocs) ?? rows.length }
}
// ── Domain types ─────────────────────────────────────────────────────────────
/** A Content page (the `pages` collection). */
export type CmsPage = {
id: string
title: string
slug?: string
/** draft | published (Payload `_status`). */
status?: string
updatedAt?: string
createdAt?: string
}
/** A media asset (the `media` collection — the DAM grid). */
export type CmsMedia = {
id: string
filename?: string
mimeType?: string
filesize?: number
width?: number
height?: number
alt?: string
/** Payload's bytes URL (`/api/media/file/<f>?prefix=<tenant>`) — proxy via `cmsMediaSrc`. */
url?: string
createdAt?: string
}
// ── Normalizers (pure — exported for unit tests) ─────────────────────────────
/** Stable string id — Payload on SQLite uses INTEGER ids (`{"id":3}`), so coerce a number
* (or string) to a string; falls back to '' only when truly absent. */
const idStr = (r: Record<string, unknown>): string => {
const v = r.id ?? r._id
return typeof v === 'number' && Number.isFinite(v) ? String(v) : (str(v) ?? '')
}
export const normalizePage = (raw: Record<string, unknown>): CmsPage => ({
id: idStr(raw),
title: pick(raw, ['title', 'name', 'slug']) ?? '(untitled)',
slug: pick(raw, ['slug']),
status: pick(raw, ['_status', 'status']),
updatedAt: pick(raw, ['updatedAt', 'updated_at']),
createdAt: pick(raw, ['createdAt', 'created_at']),
})
export const normalizeMedia = (raw: Record<string, unknown>): CmsMedia => ({
id: idStr(raw),
filename: pick(raw, ['filename', 'name']),
mimeType: pick(raw, ['mimeType', 'mime_type']),
filesize: num(raw.filesize),
width: num(raw.width),
height: num(raw.height),
alt: pick(raw, ['alt']),
url: pick(raw, ['url']),
createdAt: pick(raw, ['createdAt', 'created_at']),
})
/** CmsApi — read the brand's tenant-scoped Content collections. `depth=0` keeps the
* payload flat (relations as ids, not expanded), so the transport stays small. */
export const CmsApi = {
pages: (limit = 100): Promise<CmsList<CmsPage>> =>
restGet<unknown>(cmsApiUrl(`pages?limit=${limit}&depth=0&sort=-updatedAt`)).then((p) => listFrom(p, normalizePage)),
media: (limit = 100): Promise<CmsList<CmsMedia>> =>
restGet<unknown>(cmsApiUrl(`media?limit=${limit}&depth=0&sort=-createdAt`)).then((p) => listFrom(p, normalizeMedia)),
}
-77
View File
@@ -1,77 +0,0 @@
import { describe, it, expect } from 'vitest'
import { normalizeEmbedStatus } from './embed'
/**
* The embed status client normalizer. It must degrade a drifted/partial payload to
* "not entitled / not reachable" (never throw, never claim an app is live or that a
* caller may frame it when the shape is off — fail closed, no cross-tenant frame).
*/
describe('normalizeEmbedStatus', () => {
it('normalizes an entitled + reachable payload', () => {
expect(
normalizeEmbedStatus('cms', {
app: 'cms',
origin: 'https://cms.hanzo.ai',
embedUrl: 'https://cms.hanzo.ai/admin',
reachable: true,
entitled: true,
phase: 'ready',
}),
).toEqual({
app: 'cms',
origin: 'https://cms.hanzo.ai',
embedUrl: 'https://cms.hanzo.ai/admin',
reachable: true,
entitled: true,
phase: 'ready',
})
})
it('normalizes a NOT-entitled payload: entitled false, empty embedUrl kept', () => {
const s = normalizeEmbedStatus('cms', {
origin: 'https://cms.hanzo.ai',
embedUrl: '',
reachable: false,
entitled: false,
phase: 'not-entitled',
})
expect(s.entitled).toBe(false)
expect(s.embedUrl).toBe('') // an explicit '' is kept — never falls back to origin
expect(s.reachable).toBe(false)
expect(s.phase).toBe('not-entitled')
})
it('treats a not-provisioned payload as not reachable (entitled but down)', () => {
const s = normalizeEmbedStatus('erp', {
origin: 'https://erp.hanzo.ai',
embedUrl: 'https://erp.hanzo.ai/app',
reachable: false,
entitled: true,
phase: 'not-provisioned',
})
expect(s.reachable).toBe(false)
expect(s.entitled).toBe(true)
expect(s.phase).toBe('not-provisioned')
})
it('falls back embedUrl to origin only when the field is MISSING, and defaults the phase', () => {
const s = normalizeEmbedStatus('help', { origin: 'https://help.hanzo.ai', reachable: true, entitled: true })
expect(s.embedUrl).toBe('https://help.hanzo.ai')
expect(s.phase).toBe('ready')
})
it('fails closed: garbage/empty/partial payloads are NOT entitled and NOT reachable', () => {
for (const raw of [null, undefined, 42, 'nope', {}, { reachable: 'true' }, { reachable: 1 }, { entitled: 'true' }]) {
const s = normalizeEmbedStatus('cms', raw)
expect(s.reachable).toBe(false)
expect(s.entitled).toBe(false) // a stale server (no `entitled`) → provision panel, never a frame
expect(s.app).toBe('cms')
}
})
it('a reachable-but-not-entitled payload still yields NO entitlement (no frame)', () => {
const s = normalizeEmbedStatus('cms', { reachable: true, origin: 'https://cms.hanzo.ai' })
expect(s.entitled).toBe(false)
})
})
-58
View File
@@ -1,58 +0,0 @@
/**
* Embed API — the console's read of whether a brand's embedded app (Content Studio,
* ERP, Help Center) is provisioned and reachable, over the same-origin
* `/embed-status` route.
*
* The console does NOT reimplement Payload/Frappe: when an instance is live it
* EMBEDS the real app (SSO iframe, over the brand IAM session); until then it shows
* an honest state (provision CTA / not available). `/embed-status` is the ONE
* server-side probe that decides which — it returns the exact server-vetted URL to
* embed and a reachability boolean, and never fabricates an app.
*/
import { restGet, v1Url } from './client'
export type EmbedAppId = 'cms' | 'erp' | 'help'
export type EmbedPhase = 'ready' | 'not-provisioned' | 'not-entitled' | (string & {})
export type EmbedStatus = {
app: EmbedAppId
/** The brand origin, e.g. `https://cms.hanzo.ai`. */
origin: string
/** The full URL to embed (origin + the app's landing path); '' when not entitled. */
embedUrl: string
/** True iff a live app answers on this brand's host. */
reachable: boolean
/**
* True iff the caller OWNS this brand's shared instance (server-authoritative:
* a brand-org member or global admin). A non-owning (customer) org is `false` and
* receives no embed URL — the module shows the provision panel, never a
* cross-tenant frame.
*/
entitled: boolean
phase: EmbedPhase
}
/** Defensive normalizer — a shape drift degrades to "not entitled / not reachable", never throws. */
export function normalizeEmbedStatus(app: EmbedAppId, raw: unknown): EmbedStatus {
const r = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}
const reachable = r.reachable === true
// Strict: only an explicit true entitles. A missing field (or a stale server)
// degrades to NOT entitled → the module shows the provision panel (fail-closed, no
// cross-tenant frame).
const entitled = r.entitled === true
const origin = typeof r.origin === 'string' ? r.origin : ''
// Keep an explicit '' (not entitled) as '' — only a MISSING url falls back to origin.
const embedUrl = typeof r.embedUrl === 'string' ? r.embedUrl : origin
const phase = typeof r.phase === 'string' && r.phase ? (r.phase as EmbedPhase) : reachable ? 'ready' : 'not-provisioned'
return { app, origin, embedUrl, reachable, entitled, phase }
}
export const EmbedApi = {
status: (app: EmbedAppId): Promise<EmbedStatus> =>
// Same-origin `/v1/console/embed-status` — the cloud console subsystem's probe
// (task #41). Entitlement + reachability are decided server-side; a non-entitled
// caller never receives the embed URL.
restGet<unknown>(v1Url(`console/embed-status?app=${encodeURIComponent(app)}`)).then((r) =>
normalizeEmbedStatus(app, r),
),
}
-43
View File
@@ -1,43 +0,0 @@
import { describe, it, expect } from 'vitest'
import { normalizeAccount, normalizeItem, normalizeSalesOrder, ERP_IMAGE } from './erp'
describe('erp normalizers — bind to the REAL erpnext v15 DocType field names', () => {
it('normalizeAccount reads the Account DocType (account_name/root_type/account_type/account_currency)', () => {
const a = normalizeAccount({ name: 'Cash - HC', account_name: 'Cash', root_type: 'Asset', account_type: 'Cash', account_currency: 'USD' })
expect(a.name).toBe('Cash - HC')
expect(a.accountName).toBe('Cash')
expect(a.rootType).toBe('Asset')
expect(a.accountType).toBe('Cash')
expect(a.currency).toBe('USD')
})
it('normalizeItem reads the Item DocType incl. the Frappe int-bool is_stock_item/disabled', () => {
const i = normalizeItem({ name: 'WIDGET-1', item_code: 'WIDGET-1', item_name: 'Widget', item_group: 'Products', stock_uom: 'Nos', is_stock_item: 1, disabled: 0, valuation_rate: 12.5 })
expect(i.itemCode).toBe('WIDGET-1')
expect(i.itemName).toBe('Widget')
expect(i.uom).toBe('Nos')
expect(i.stockItem).toBe(true) // 1 → true
expect(i.disabled).toBe(false) // 0 → false
expect(i.valuationRate).toBe(12.5)
})
it('normalizeSalesOrder reads the Sales Order DocType (customer/transaction_date/grand_total/status)', () => {
const s = normalizeSalesOrder({ name: 'SO-0001', customer: 'Acme', transaction_date: '2026-06-01', grand_total: 5000, currency: 'USD', status: 'To Deliver and Bill' })
expect(s.name).toBe('SO-0001')
expect(s.customer).toBe('Acme')
expect(s.date).toBe('2026-06-01')
expect(s.grandTotal).toBe(5000)
expect(s.status).toBe('To Deliver and Bill')
})
it('normalizers never throw on a blank/drifted row (honest fields)', () => {
expect(normalizeAccount({}).name).toBe('')
expect(normalizeItem({}).stockItem).toBe(false)
expect(normalizeSalesOrder({}).grandTotal).toBeUndefined()
})
it('the deploy image is the canonical stock ERPNext image (never a fictional ghcr tag)', () => {
expect(ERP_IMAGE.repository).toBe('frappe/erpnext')
expect(ERP_IMAGE.tag).toMatch(/^v15\./)
})
})
-186
View File
@@ -1,186 +0,0 @@
/**
* ERP (ERPNext/Frappe) API — the NATIVE console summary reads (Accounting / Items /
* Sales Orders) over Frappe's REST, through the console's OWN entitlement-gated `/erp`
* proxy (`<origin>/erp/api/resource/<DocType>`), PLUS the REAL platform deploy that
* provisions the brand ERP app.
*
* We do NOT reimplement ERPNext — we read its real `/api/resource/<DocType>` lists (the
* exact field sets verified against the erpnext v15 DocType JSON) and drive the real
* Hanzo PaaS to deploy it. Frappe returns `{ data: [...] }`; rows are normalized
* DEFENSIVELY. Until an ERP instance is live (today `erp.<brand>` is 502) the reads
* error and the module shows the honest "deploy ERP" state — never fabricated ERP data.
*/
import { restGet } from './client'
import { PaasApi, type PaasApp, type PaasDeployment } from './paas'
const erpBase = (): string => (typeof window !== 'undefined' ? `${window.location.origin}/erp` : '/erp')
/** Build a Frappe list URL on the `/erp` proxy. `fields`/`filters` are JSON per Frappe. */
function resourceUrl(
doctype: string,
fields: string[],
opts?: { filters?: unknown[]; orderBy?: string; limit?: number },
): string {
const sp = new URLSearchParams()
sp.set('fields', JSON.stringify(fields))
if (opts?.filters) sp.set('filters', JSON.stringify(opts.filters))
if (opts?.orderBy) sp.set('order_by', opts.orderBy)
sp.set('limit_page_length', String(opts?.limit ?? 20))
return `${erpBase()}/api/resource/${doctype}?${sp.toString()}`
}
// ── Defensive coercion ───────────────────────────────────────────────────────
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined)
const num = (v: unknown): number | undefined =>
typeof v === 'number' && Number.isFinite(v) ? v : typeof v === 'string' && v.trim() && Number.isFinite(Number(v)) ? Number(v) : undefined
const bool = (v: unknown): boolean => v === 1 || v === true || v === '1'
const rowsOf = (payload: unknown): Record<string, unknown>[] => {
const data = payload && typeof payload === 'object' ? (payload as Record<string, unknown>).data : payload
return Array.isArray(data) ? (data.filter((x) => x && typeof x === 'object') as Record<string, unknown>[]) : []
}
const pick = (r: Record<string, unknown>, k: string): string | undefined => str(r[k])
// ── Domain types (only the summary fields shown) ─────────────────────────────
/** A ledger account (the Accounting summary — DocType `Account`). */
export type ErpAccount = {
name: string
accountName?: string
rootType?: string
accountType?: string
currency?: string
}
/** A catalog item (the Items summary — DocType `Item`). */
export type ErpItem = {
name: string
itemCode?: string
itemName?: string
itemGroup?: string
uom?: string
stockItem: boolean
disabled: boolean
valuationRate?: number
}
/** A sales order (the Sales summary — DocType `Sales Order`). */
export type ErpSalesOrder = {
name: string
customer?: string
date?: string
grandTotal?: number
currency?: string
status?: string
}
// ── Normalizers (pure — exported for unit tests) ─────────────────────────────
export const normalizeAccount = (r: Record<string, unknown>): ErpAccount => ({
name: pick(r, 'name') ?? '',
accountName: pick(r, 'account_name'),
rootType: pick(r, 'root_type'),
accountType: pick(r, 'account_type'),
currency: pick(r, 'account_currency'),
})
export const normalizeItem = (r: Record<string, unknown>): ErpItem => ({
name: pick(r, 'name') ?? '',
itemCode: pick(r, 'item_code'),
itemName: pick(r, 'item_name'),
itemGroup: pick(r, 'item_group'),
uom: pick(r, 'stock_uom'),
stockItem: bool(r.is_stock_item),
disabled: bool(r.disabled),
valuationRate: num(r.valuation_rate),
})
export const normalizeSalesOrder = (r: Record<string, unknown>): ErpSalesOrder => ({
name: pick(r, 'name') ?? '',
customer: pick(r, 'customer'),
date: pick(r, 'transaction_date'),
grandTotal: num(r.grand_total),
currency: pick(r, 'currency'),
status: pick(r, 'status'),
})
/** The canonical ERPNext image (the hanzoai/erp repo consumes stock frappe/erpnext). */
export const ERP_IMAGE = { repository: 'frappe/erpnext', tag: 'v15.62.0' } as const
const ERP_PROJECT = 'erp'
const ERP_APP = 'erp'
/**
* ErpApi — native Frappe summary reads + the real deploy.
*
* Every read is a real `GET /api/resource/<DocType>` through the entitlement-gated
* `/erp` proxy; the field lists mirror the erpnext v15 DocType JSON exactly. A 403
* (not entitled), 502 (ERP not deployed), or 401 (no Frappe token) surfaces to the
* caller's honest state — never a fabricated row.
*/
export const ErpApi = {
accounts: (limit = 100): Promise<ErpAccount[]> =>
restGet<unknown>(
resourceUrl('Account', ['name', 'account_name', 'root_type', 'account_type', 'account_currency'], {
filters: [['is_group', '=', 0]],
limit,
}),
).then((p) => rowsOf(p).map(normalizeAccount)),
items: (limit = 50): Promise<ErpItem[]> =>
restGet<unknown>(
resourceUrl('Item', ['name', 'item_code', 'item_name', 'item_group', 'stock_uom', 'is_stock_item', 'disabled', 'valuation_rate'], {
limit,
}),
).then((p) => rowsOf(p).map(normalizeItem)),
salesOrders: (limit = 50): Promise<ErpSalesOrder[]> =>
restGet<unknown>(
resourceUrl('Sales Order', ['name', 'customer', 'transaction_date', 'grand_total', 'currency', 'status'], {
orderBy: 'transaction_date desc',
limit,
}),
).then((p) => rowsOf(p).map(normalizeSalesOrder)),
/**
* The org's ERP app on the platform, if provisioned (find in the `erp` project).
* Returns null when no ERP app exists yet — the module shows the deploy CTA.
*/
app: async (): Promise<PaasApp | null> => {
const projects = await PaasApi.listProjects()
const project = projects.find((p) => p.slug === ERP_PROJECT || p.name?.toLowerCase() === 'erp')
if (!project) return null
const apps = await PaasApi.listApps(project.slug || project.id)
return apps.find((a) => a.slug === ERP_APP) ?? null
},
/**
* REAL deploy — provisions the ERPNext app on Hanzo PaaS for the caller's org
* (`/v1/platform`), idempotently: find-or-create the `erp` project + `erp` image app,
* then trigger a deploy. Returns the real deployment record (status building/deploying/
* …). NOTE: a full ERPNext needs its bundled data services (MariaDB/Redis) — a
* multi-service chart the single-image platform deploy doesn't include yet — so this
* proves the real provisioning path; the app's health reflects that reality honestly.
*/
deploy: async (): Promise<PaasDeployment> => {
const projects = await PaasApi.listProjects()
let project = projects.find((p) => p.slug === ERP_PROJECT || p.name?.toLowerCase() === 'erp')
if (!project) {
project = await PaasApi.createProject({ name: 'ERP', slug: ERP_PROJECT, description: 'ERPNext (Frappe) business suite' })
}
const projKey = project.slug || project.id
const apps = await PaasApi.listApps(projKey)
let app = apps.find((a) => a.slug === ERP_APP)
if (!app) {
app = await PaasApi.createApp(projKey, {
name: 'erp',
slug: ERP_APP,
environment: 'production',
source: 'image',
image: { repository: ERP_IMAGE.repository, tag: ERP_IMAGE.tag },
buildType: 'image',
port: 8080,
replicas: 1,
})
}
return PaasApi.deploy(projKey, app.slug || app.id, { tag: ERP_IMAGE.tag })
},
}
-68
View File
@@ -1,68 +0,0 @@
import { describe, it, expect } from 'vitest'
import { brandDomain, serviceOrigin, studioOrigin, erpOrigin, helpOrigin, EMBED_FALLBACK_DOMAIN } from './embed-hosts'
/**
* White-label host derivation for the embedded apps. These pin that a Lux/Zoo
* console frames ITS OWN brand's Studio/ERP/Help (never Hanzo's), that dev/bare
* hosts fall back safely, and that the service label is prefixed onto the brand's
* registrable domain (not the full console host).
*/
describe('brandDomain', () => {
it('drops the leading service label to the registrable domain', () => {
expect(brandDomain('console.hanzo.ai')).toBe('hanzo.ai')
expect(brandDomain('cloud.hanzo.ai')).toBe('hanzo.ai')
expect(brandDomain('admin.hanzo.ai')).toBe('hanzo.ai')
expect(brandDomain('cloud.lux.cloud')).toBe('lux.cloud')
expect(brandDomain('admin.zoo.cloud')).toBe('zoo.cloud')
})
it('returns an apex host unchanged', () => {
expect(brandDomain('hanzo.ai')).toBe('hanzo.ai')
expect(brandDomain('lux.cloud')).toBe('lux.cloud')
})
it('strips a port', () => {
expect(brandDomain('console.hanzo.ai:443')).toBe('hanzo.ai')
expect(brandDomain('localhost:4000')).toBe(EMBED_FALLBACK_DOMAIN)
})
it('falls back for dev / single-label / IP / empty hosts', () => {
expect(brandDomain('localhost')).toBe(EMBED_FALLBACK_DOMAIN)
expect(brandDomain('127.0.0.1')).toBe(EMBED_FALLBACK_DOMAIN)
expect(brandDomain('')).toBe(EMBED_FALLBACK_DOMAIN)
expect(brandDomain(null)).toBe(EMBED_FALLBACK_DOMAIN)
expect(brandDomain(undefined)).toBe(EMBED_FALLBACK_DOMAIN)
})
it('honors a custom fallback', () => {
expect(brandDomain('localhost', 'lux.cloud')).toBe('lux.cloud')
})
})
describe('serviceOrigin / per-service helpers', () => {
it('prefixes the service label onto the brand domain', () => {
expect(serviceOrigin('cms', 'console.hanzo.ai')).toBe('https://cms.hanzo.ai')
expect(serviceOrigin('erp', 'cloud.lux.cloud')).toBe('https://erp.lux.cloud')
})
it('lower-cases the service label', () => {
expect(serviceOrigin('CMS', 'console.hanzo.ai')).toBe('https://cms.hanzo.ai')
})
it('studio/erp/help resolve per brand (never cross-brand)', () => {
// Hanzo console → Hanzo services.
expect(studioOrigin('console.hanzo.ai')).toBe('https://cms.hanzo.ai')
expect(erpOrigin('console.hanzo.ai')).toBe('https://erp.hanzo.ai')
expect(helpOrigin('console.hanzo.ai')).toBe('https://help.hanzo.ai')
// Lux console → Lux services, NOT Hanzo's.
expect(studioOrigin('cloud.lux.cloud')).toBe('https://cms.lux.cloud')
expect(erpOrigin('cloud.lux.cloud')).toBe('https://erp.lux.cloud')
expect(helpOrigin('cloud.lux.cloud')).toBe('https://help.lux.cloud')
})
it('dev host resolves to the hanzo brand default', () => {
expect(studioOrigin('localhost')).toBe('https://cms.hanzo.ai')
expect(erpOrigin(null)).toBe('https://erp.hanzo.ai')
})
})
-69
View File
@@ -1,69 +0,0 @@
/**
* White-label host derivation for the console's EMBEDDED canonical apps — the
* Content Studio (Payload CMS), ERP (ERPNext/Frappe), and the Help Center (Frappe
* Helpdesk). Each of these is a REAL Hanzo product served on its own subdomain of
* the current brand's domain, embedded IN the console shell (not a link-out).
*
* The console is one image serving every brand (cloud.hanzo.ai → hanzo,
* cloud.lux.cloud → lux). An embedded app must resolve to ITS OWN brand's host, so
* a Lux/Zoo console never frames Hanzo's Studio. The rule is purely host-derived:
* drop the leading service label (`console`/`cloud`/`admin`/…) to get the brand's
* registrable domain, then prefix the target service label (`cms`/`erp`/`help`).
* PURE + unit-tested (embed-hosts.test.ts) — no window access here; the caller
* passes the current host (`currentHost()`), so it is SSR-safe and testable.
*
* Tenancy is NOT encoded in the host: these apps are SINGLE shared per-BRAND
* instances (verified — NOT per-customer-org). So the host is per-BRAND, and the
* console decides WHO may frame it via a server-side entitlement gate
* (`/embed-status` + `embed-probe.ts`: brand-org member / global admin only) — a
* customer org gets a provision panel, never a cross-tenant frame. This module only
* derives the per-brand host; it makes NO isolation claim about the backing app.
*/
/** The brand domain used when the host can't be parsed (dev/localhost/IP). */
export const EMBED_FALLBACK_DOMAIN = 'hanzo.ai'
/**
* The registrable brand domain for a console host: everything after the first
* label. `console.hanzo.ai` → `hanzo.ai`, `cloud.lux.cloud` → `lux.cloud`,
* `admin.zoo.cloud` → `zoo.cloud`. A bare apex (`hanzo.ai`) is returned as-is; a
* single-label host (`localhost`) or an IPv4 literal falls back to the brand
* default. A port is stripped. PURE.
*/
export function brandDomain(host: string | null | undefined, fallback: string = EMBED_FALLBACK_DOMAIN): string {
const h = (host ?? '').split(':')[0].trim().toLowerCase()
if (!h) return fallback
// An IPv4 literal has no registrable domain — fall back to the brand default.
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(h)) return fallback
const parts = h.split('.').filter(Boolean)
if (parts.length >= 3) return parts.slice(1).join('.') // sub.example.com → example.com
if (parts.length === 2) return parts.join('.') // example.com (already the apex)
return fallback // single label (localhost) / empty
}
/**
* The HTTPS origin of a brand service for the given console host, e.g.
* `serviceOrigin('cms', 'console.hanzo.ai')` → `https://cms.hanzo.ai`. The service
* label is lower-cased; the brand domain is derived by `brandDomain`.
*/
export function serviceOrigin(
service: string,
host: string | null | undefined,
fallback: string = EMBED_FALLBACK_DOMAIN,
): string {
return `https://${service.trim().toLowerCase()}.${brandDomain(host, fallback)}`
}
/** The Content Studio (Payload CMS) origin for this brand — `cms.<brand-domain>`. */
export const studioOrigin = (host?: string | null): string => serviceOrigin('cms', host)
/** The ERP (ERPNext/Frappe) origin for this brand — `erp.<brand-domain>`. */
export const erpOrigin = (host?: string | null): string => serviceOrigin('erp', host)
/** The Help Center (Frappe Helpdesk) origin for this brand — `help.<brand-domain>`. */
export const helpOrigin = (host?: string | null): string => serviceOrigin('help', host)
/** The current browser host, or null on the server (so callers stay SSR-safe). */
export function currentHost(): string | null {
return typeof window === 'undefined' ? null : window.location.hostname
}
+21 -22
View File
@@ -2018,47 +2018,46 @@ export const catalog: CatalogEntry[] = [
],
},
{
// ERP — the canonical ERPNext (Frappe) suite surfaced IN the console (ErpModule),
// entitlement-gated to the owning brand org (Frappe is single-tenant). Overview
// drives a REAL /v1/platform deploy with live status; Accounting/Items/Sales are
// NATIVE summary views over Frappe REST (honest "deploy ERP" until an instance is
// live); Desk embeds the real desk once erp.<brand> is reachable. Never fabricated.
// ERP — a NATIVE ERP on the Hanzo Framework DocType engine (/v1/framework/*), NOT
// an iframe. A master/transaction is a framework DocType (module "erp"); posting
// (stock ledger, balanced GL) is a native-Go hook on the engine (clients/erp).
// Rendered by the generic metadata-driven DocType renderer (src/components/doctype/*),
// per-org — the SAME renderer as CMS, with zero ERP-specific UI code.
id: 'erp',
label: 'ERP',
icon: Boxes,
description: 'Accounting, inventory, sales, and HR — your Business-OS ERP on ERPNext.',
description: 'Native ERP on the Hanzo Framework — items, sales, purchasing, accounting, and HR as DocTypes, per organization.',
category: 'Apps',
status: 'enabled',
repo: 'hanzoai/erp',
repo: 'hanzoai/cloud',
docs: `${DOCS}/erp`,
kind: 'module',
routes: [
{ path: '', component: ErpModule },
{ path: ':tab', component: ErpModule },
],
subpages: [
{ slug: 'accounting', label: 'Accounting' },
{ slug: 'items', label: 'Items' },
{ slug: 'sales', label: 'Sales Orders' },
{ slug: 'desk', label: 'Desk' },
{ path: 'collections/:doctype', component: ErpModule },
{ path: 'collections/:doctype/:name', component: ErpModule },
],
},
{
// Help Center — the live Hanzo Help Center (Frappe Helpdesk at help.<brand>,
// confirmed live at help.hanzo.ai), EMBEDDED in the console (HelpModule) over
// the brand IAM SSO ("Login with hanzo"). A shared brand support desk — the
// Helpdesk scopes each caller to their own tickets — so it's embedded for every
// signed-in user; honest "not available" if the host isn't reachable.
// Help Center — a NATIVE support desk on the Hanzo Framework DocType engine
// (/v1/framework/*), NOT an iframe. A ticket is a framework document (module
// "help"); its lifecycle is a status field; agents/teams/SLAs are DocTypes
// (clients/help — pure fixtures, no hooks). Rendered by the generic
// metadata-driven DocType renderer, per-org — the SAME renderer as CMS/ERP.
id: 'helpdesk',
label: 'Help Center',
icon: LifeBuoy,
description: 'Customer support — tickets and a knowledge base for your users.',
description: 'Native support desk on the Hanzo Framework — tickets, agents, teams, and SLAs as DocTypes, per organization.',
category: 'Apps',
status: 'enabled',
repo: 'hanzoai/helpdesk',
repo: 'hanzoai/cloud',
docs: `${DOCS}/helpdesk`,
kind: 'module',
routes: [{ path: '', component: HelpModule }],
routes: [
{ path: '', component: HelpModule },
{ path: 'collections/:doctype', component: HelpModule },
{ path: 'collections/:doctype/:name', component: HelpModule },
],
},
{
// Accessibility — a Wix-style WCAG checker for the site Dave is building. Runs
-123
View File
@@ -1,123 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
clampedBrandDomain,
embedOrigin,
embedTarget,
isEmbedApp,
isUp,
EMBED_APPS,
brandOrgForHost,
isEntitled,
EMBED_OWNERSHIP,
} from './embed-probe'
/**
* The `/embed-status` probe logic. The SSRF clamp is the security-critical part —
* a forged Host header must never steer the server probe to an attacker host.
*/
describe('clampedBrandDomain (SSRF clamp)', () => {
it('keeps a known brand domain', () => {
expect(clampedBrandDomain('console.hanzo.ai')).toBe('hanzo.ai')
expect(clampedBrandDomain('cloud.lux.cloud')).toBe('lux.cloud')
expect(clampedBrandDomain('admin.zoo.cloud')).toBe('zoo.cloud')
expect(clampedBrandDomain('cloud.pars.cloud')).toBe('pars.cloud')
})
it('falls back to the hanzo brand for an UNKNOWN / forged host (no SSRF)', () => {
expect(clampedBrandDomain('console.evil.com')).toBe('hanzo.ai')
expect(clampedBrandDomain('attacker.internal')).toBe('hanzo.ai')
expect(clampedBrandDomain('169.254.169.254')).toBe('hanzo.ai') // cloud metadata IP
expect(clampedBrandDomain('localhost')).toBe('hanzo.ai')
expect(clampedBrandDomain('')).toBe('hanzo.ai')
expect(clampedBrandDomain(null)).toBe('hanzo.ai')
})
})
describe('embedOrigin / embedTarget', () => {
it('builds `<app>.<clamped brand domain>` + the landing path', () => {
expect(embedOrigin('cms', 'console.hanzo.ai')).toBe('https://cms.hanzo.ai')
expect(embedTarget('cms', 'console.hanzo.ai')).toEqual({
origin: 'https://cms.hanzo.ai',
embedUrl: 'https://cms.hanzo.ai/admin',
})
expect(embedTarget('erp', 'cloud.lux.cloud')).toEqual({
origin: 'https://erp.lux.cloud',
embedUrl: 'https://erp.lux.cloud/app',
})
expect(embedTarget('help', 'console.hanzo.ai')).toEqual({
origin: 'https://help.hanzo.ai',
embedUrl: 'https://help.hanzo.ai/helpdesk',
})
})
it('an unknown host still resolves to a hanzo-brand target (never the forged host)', () => {
expect(embedOrigin('cms', 'console.evil.com')).toBe('https://cms.hanzo.ai')
})
})
describe('isEmbedApp', () => {
it('admits only the three real apps', () => {
expect(isEmbedApp('cms')).toBe(true)
expect(isEmbedApp('erp')).toBe(true)
expect(isEmbedApp('help')).toBe(true)
expect(isEmbedApp('iam')).toBe(false)
expect(isEmbedApp('')).toBe(false)
expect(isEmbedApp('__proto__')).toBe(false)
expect(Object.keys(EMBED_APPS).sort()).toEqual(['cms', 'erp', 'help'])
})
})
describe('isUp (liveness classifier)', () => {
it('treats app responses (2xx/3xx/401/403) as up', () => {
for (const s of [200, 201, 204, 301, 302, 303, 307, 401, 403]) expect(isUp(s)).toBe(true)
})
it('treats 404 and 5xx as down (the unprovisioned/erroring state)', () => {
for (const s of [404, 500, 502, 503, 504]) expect(isUp(s)).toBe(false)
})
it('treats a zero/invalid status (network error sentinel) as down', () => {
expect(isUp(0)).toBe(false)
})
})
describe('brandOrgForHost', () => {
it('maps a console host to its owning brand org', () => {
expect(brandOrgForHost('console.hanzo.ai')).toBe('hanzo')
expect(brandOrgForHost('cloud.lux.cloud')).toBe('lux')
expect(brandOrgForHost('admin.zoo.cloud')).toBe('zoo')
expect(brandOrgForHost('cloud.pars.cloud')).toBe('pars')
})
it('a forged/unknown host maps to the hanzo brand org (clamped, never attacker-chosen)', () => {
expect(brandOrgForHost('console.evil.com')).toBe('hanzo')
expect(brandOrgForHost('localhost')).toBe('hanzo')
expect(brandOrgForHost(null)).toBe('hanzo')
})
})
describe('isEntitled (server-side embed gate)', () => {
it('every app is brand-owned today (no shared app)', () => {
expect(EMBED_OWNERSHIP).toEqual({ cms: 'brand', erp: 'brand', help: 'brand' })
})
it('a brand-org member is entitled to their brand app', () => {
expect(isEntitled('cms', 'hanzo', 'hanzo', false)).toBe(true)
expect(isEntitled('erp', 'lux', 'lux', false)).toBe(true)
expect(isEntitled('help', 'hanzo', 'hanzo', false)).toBe(true)
})
it('a CUSTOMER org is NOT entitled (no cross-tenant frame)', () => {
expect(isEntitled('cms', 'maxpower', 'hanzo', false)).toBe(false)
expect(isEntitled('erp', 'maxpower', 'hanzo', false)).toBe(false)
expect(isEntitled('help', 'maxpower', 'hanzo', false)).toBe(false)
})
it('a global admin is entitled regardless of their own org', () => {
expect(isEntitled('cms', 'admin', 'hanzo', true)).toBe(true)
expect(isEntitled('help', 'maxpower', 'hanzo', true)).toBe(true)
})
it('an empty/blank caller org is NEVER entitled (fail closed)', () => {
expect(isEntitled('cms', '', 'hanzo', false)).toBe(false)
expect(isEntitled('cms', '', '', false)).toBe(false) // no org both sides → still refused
})
})
-97
View File
@@ -1,97 +0,0 @@
/**
* Pure logic for the `/embed-status` reachability probe (SSRF clamp + liveness
* classifier + per-app landing path). Extracted from the route so the
* security-critical clamp is unit-tested in isolation, with no server deps.
*
* The clamp is the SSRF control: the probe target is ALWAYS `<app>.<known brand
* domain>`. A forged Host header that derives to an unknown domain falls back to
* the hanzo brand default, so this can never be steered to fetch an attacker host.
*/
import { brandDomain } from '~/lib/products/embed-hosts'
/** The registrable domains of the real brands this one console image serves. */
export const KNOWN_BRAND_DOMAINS = new Set(['hanzo.ai', 'lux.cloud', 'zoo.cloud', 'pars.cloud'])
export const DEFAULT_BRAND_DOMAIN = 'hanzo.ai'
/** The apps this route resolves, and the in-app landing path each embeds. */
export const EMBED_APPS = {
cms: '/admin', // Payload admin
erp: '/app', // ERPNext desk
help: '/helpdesk', // Frappe Helpdesk
} as const
export type EmbedAppId = keyof typeof EMBED_APPS
/** True iff `x` names an app this route can resolve. */
export function isEmbedApp(x: string): x is EmbedAppId {
return Object.prototype.hasOwnProperty.call(EMBED_APPS, x)
}
/** The brand domain for a host, CLAMPED to a known brand (SSRF-safe fallback). */
export function clampedBrandDomain(host: string | null | undefined): string {
const d = brandDomain(host, DEFAULT_BRAND_DOMAIN)
return KNOWN_BRAND_DOMAINS.has(d) ? d : DEFAULT_BRAND_DOMAIN
}
/** The clamped brand origin for an app + host, e.g. `https://cms.hanzo.ai`. */
export function embedOrigin(app: EmbedAppId, host: string | null | undefined): string {
return `https://${app}.${clampedBrandDomain(host)}`
}
/** The origin + full embed URL (origin + the app's landing path) for an app + host. */
export function embedTarget(app: EmbedAppId, host: string | null | undefined): { origin: string; embedUrl: string } {
const origin = embedOrigin(app, host)
return { origin, embedUrl: `${origin}${EMBED_APPS[app]}` }
}
/**
* Classify a probe response as "app is up". Up = a 2xx/3xx (landing or SSO
* redirect) or an app-level 401/403 (running, wants login). Down = 404 (no such
* app on that host) or any 5xx (502/503/504 — the unprovisioned state); a
* network/timeout error is handled by the caller as down.
*/
export function isUp(status: number): boolean {
if (status >= 500) return false
if (status === 404) return false
return status > 0
}
/** The IAM org that OWNS each brand's shared app instances (single-tenant today). */
export const BRAND_DOMAIN_TO_ORG: Record<string, string> = {
'hanzo.ai': 'hanzo',
'lux.cloud': 'lux',
'zoo.cloud': 'zoo',
'pars.cloud': 'pars',
}
/** The brand-owning IAM org for a console host (clamped), e.g. console.hanzo.ai → 'hanzo'. */
export function brandOrgForHost(host: string | null | undefined): string {
return BRAND_DOMAIN_TO_ORG[clampedBrandDomain(host)] ?? 'hanzo'
}
/**
* Ownership model per app — VERIFIED ground truth, not aspiration: cms/erp/help are
* each a SINGLE shared per-BRAND instance (`HANZO_ORG=hanzo`, one store), NOT a
* per-customer-org instance. So only a member of the owning BRAND org (or a global
* admin) may frame them — embedding a brand's shared Studio/ERP/Helpdesk for a
* CUSTOMER org would expose the brand's content/tickets (cross-tenant). A customer
* org gets an honest provision panel instead.
*/
export const EMBED_OWNERSHIP: Record<EmbedAppId, 'brand'> = {
cms: 'brand',
erp: 'brand',
help: 'brand',
}
/**
* Is the caller ENTITLED to embed this app? The SERVER-SIDE gate (defense beyond the
* cosmetic client check): a 'brand'-owned app embeds only for a member of the owning
* brand org or a global admin. A non-entitled caller NEVER receives the embed URL —
* the route returns entitled:false and the module shows the provision panel. The org
* is the token owner (server-resolved), never a browser claim.
*/
export function isEntitled(app: EmbedAppId, callerOrg: string, brandOrg: string, isGlobalAdmin: boolean): boolean {
// EMBED_OWNERSHIP[app] is 'brand' for every app today; the guard keeps the door
// open for a future genuinely-shared app without changing callers.
if (EMBED_OWNERSHIP[app] !== 'brand') return true
return (callerOrg !== '' && callerOrg === brandOrg) || isGlobalAdmin === true
}