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:
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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>",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 isn’t 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 app’s 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 isn’t connected yet</Text>
|
||||
<Text fontSize="$2" color="$color11">
|
||||
No live ERPNext instance answered this summary. Deploy ERP from the Overview tab; once it’s 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 isn’t 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
|
||||
|
||||
@@ -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 they’re 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 isn’t 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 brand’s 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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)),
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
),
|
||||
}
|
||||
@@ -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\./)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
},
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user