Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1597f34870 | ||
|
|
b64b4b6b7f | ||
|
|
c9e72dd52a | ||
|
|
22ea663bf3 | ||
|
|
66abdb9d51 | ||
|
|
378ccbecd9 | ||
|
|
7dfe32fa0e | ||
|
|
bd6e583cb6 | ||
|
|
fbe44a937f | ||
|
|
c742e107e5 | ||
|
|
dd2c96fe14 | ||
|
|
2fdb6b87c6 | ||
|
|
b97e2ba263 | ||
|
|
b9f570db74 | ||
|
|
ae3a9418dc | ||
|
|
06d563b32e | ||
|
|
0468e5d5f3 | ||
|
|
84bea92dfa | ||
|
|
467605c2fd | ||
|
|
a132fd0c66 | ||
|
|
cce2646d30 | ||
|
|
6f5bced5b9 | ||
|
|
d8e898f0a6 | ||
|
|
fa88ea4c64 | ||
|
|
62cb43944d | ||
|
|
63c45abda0 | ||
|
|
dceeecbafb | ||
|
|
a571f9f4ae | ||
|
|
34c4161048 | ||
|
|
166f927c66 | ||
|
|
848f7e2f90 | ||
|
|
8e39b62167 | ||
|
|
f21cb0a5df | ||
|
|
5baeb5b114 | ||
|
|
7e5e065d93 | ||
|
|
6a401479ad | ||
|
|
267d512875 | ||
|
|
d34ff8eb1c | ||
|
|
cf30e199b9 | ||
|
|
8604da5eac | ||
|
|
6f3ff57ce8 | ||
|
|
c1c9df9a05 | ||
|
|
acb72dd6be | ||
|
|
5c7e476f61 | ||
|
|
8d5766062b | ||
|
|
09c481e0ac | ||
|
|
3923cf2fe0 | ||
|
|
f5b24fe295 | ||
|
|
cf653a87b2 | ||
|
|
e1e19059b7 | ||
|
|
52102a9abb | ||
|
|
414792c55e | ||
|
|
df0ce10be3 | ||
|
|
2dc4169a7f | ||
|
|
855a290c69 | ||
|
|
ed34d24c47 | ||
|
|
354c5b3f41 | ||
|
|
793a4f31d1 | ||
|
|
456e417589 | ||
|
|
0624f8055a | ||
|
|
c40e33e5c0 | ||
|
|
5eb511b896 | ||
|
|
09c2ab7ce9 | ||
|
|
b3c4376834 | ||
|
|
f8415b116b | ||
|
|
1127f85100 | ||
|
|
ede309ed2f | ||
|
|
1e1c6b37d8 | ||
|
|
1515df9802 | ||
|
|
0806575a96 | ||
|
|
d4526fb854 | ||
|
|
7bbe95038c | ||
|
|
643ad2e8c8 | ||
|
|
ac2faaaa82 | ||
|
|
1beb6fca9b | ||
|
|
d750e83aca | ||
|
|
db1648dff4 | ||
|
|
ecf06ae606 | ||
|
|
5486885b69 | ||
|
|
8f10ccb5ad | ||
|
|
ff2aa1eb87 | ||
|
|
a2265dd34e | ||
|
|
0db1e9db64 | ||
|
|
d25ddcc5e2 | ||
|
|
fdb45e6dc5 | ||
|
|
b96db9c320 | ||
|
|
eebb3b9560 | ||
|
|
adc0e9a55b | ||
|
|
f7880f1831 | ||
|
|
a3f194de5f | ||
|
|
becdc3c597 | ||
|
|
6518808537 | ||
|
|
0a26473b52 | ||
|
|
a98db5eb2e | ||
|
|
972a3e70b1 | ||
|
|
c15fdc48f4 | ||
|
|
aaecae8c8d | ||
|
|
821cb266ad | ||
|
|
9d3fa34cb2 | ||
|
|
c7241900a4 | ||
|
|
d3d1690d87 | ||
|
|
f122b5bdc9 | ||
|
|
26b4a02921 | ||
|
|
5d031fb55f | ||
|
|
afeed80c01 | ||
|
|
86aafd1fb5 | ||
|
|
9da0dbe492 | ||
|
|
6174ea4d73 | ||
|
|
066baf1977 | ||
|
|
ee980c8fba | ||
|
|
effcc7fc13 | ||
|
|
af33afa321 | ||
|
|
322ca05e40 | ||
|
|
ee2638c777 | ||
|
|
24260390c4 | ||
|
|
6275707e57 | ||
|
|
893f9a846e | ||
|
|
5ea2efc881 | ||
|
|
10030b9001 | ||
|
|
2c564cf698 | ||
|
|
9a1f0ba32f | ||
|
|
70b38c0b93 | ||
|
|
c5ca7fb1f2 | ||
|
|
8f96a115a9 | ||
|
|
fc4c48bc32 | ||
|
|
b8a088d797 | ||
|
|
9f916dd52e | ||
|
|
de14e749b2 | ||
|
|
b25b0005c1 | ||
|
|
4fb1c08fc1 | ||
|
|
f92fa2cb57 | ||
|
|
eacffd81f6 | ||
|
|
6eed04b972 | ||
|
|
ace1bf12db | ||
|
|
fdce8b3253 | ||
|
|
67e0bde60f | ||
|
|
08d3d233e6 | ||
|
|
659a0231aa | ||
|
|
b2bd4f5152 | ||
|
|
70a4053749 | ||
|
|
7fcedda84b | ||
|
|
2d777c7cd6 | ||
|
|
ef3a2d087c | ||
|
|
18269d908b | ||
|
|
2df1ebd052 | ||
|
|
d8c77eeb5c | ||
|
|
cb44d80dea | ||
|
|
0ccc964850 | ||
|
|
23ec09b338 | ||
|
|
48de8e18bd | ||
|
|
d0bd4390c4 | ||
|
|
5d68f27825 | ||
|
|
686b017474 | ||
|
|
abb87e5d0e | ||
|
|
0c822f9aa0 | ||
|
|
f16987b298 | ||
|
|
fd1fcb73ab | ||
|
|
a2d2d1fa9b | ||
|
|
909514e4cc | ||
|
|
d052760732 | ||
|
|
063c5700cb | ||
|
|
7afda87461 | ||
|
|
65c7bf6a3e | ||
|
|
c0eb0011b5 | ||
|
|
ef7ff6dd0f | ||
|
|
1f6dd3e3bc | ||
|
|
a7a69a2c65 | ||
|
|
dd09e7f4be | ||
|
|
bec70dbdd6 | ||
|
|
3a597f2dc9 | ||
|
|
84e131fecc | ||
|
|
70196abf76 | ||
|
|
82952d9a39 | ||
|
|
dff40364ce | ||
|
|
b6f3b4c731 | ||
|
|
323f565722 | ||
|
|
4f69460a44 | ||
|
|
dbf107e0a1 | ||
|
|
42d8d44ad5 | ||
|
|
492f6b48ed | ||
|
|
32e41fb3c3 | ||
|
|
a14fff2785 | ||
|
|
a3ed821eea | ||
|
|
72c6c2384e | ||
|
|
abbce51bdb | ||
|
|
bf88b1f253 | ||
|
|
6977258f3d | ||
|
|
c80f3719d0 | ||
|
|
119fb7abb2 | ||
|
|
b99c7506ad | ||
|
|
a6d2c2b812 | ||
|
|
cafa9c692e | ||
|
|
549730d0ac | ||
|
|
5051e16414 | ||
|
|
ddde6818c6 | ||
|
|
0b5fa4a205 | ||
|
|
a29e164323 | ||
|
|
a5fef9c37e | ||
|
|
17d3b048d9 | ||
|
|
d76975b3d7 | ||
|
|
3fa78b8c82 | ||
|
|
9f84043b18 | ||
|
|
8d31438a84 | ||
|
|
59f793f8ea | ||
|
|
dddab4a0b5 | ||
|
|
422b54d145 | ||
|
|
b94cafd099 | ||
|
|
1b1b529806 | ||
|
|
8ed465491a | ||
|
|
a8d07bfaa6 | ||
|
|
1b4cbbdebe | ||
|
|
ab6b13ae74 | ||
|
|
c732df5917 | ||
|
|
f88d36f32e | ||
|
|
7dc17c8399 | ||
|
|
769d21ffc7 | ||
|
|
33ab064acc | ||
|
|
68b4cb71a7 | ||
|
|
8b6f0ebb6a | ||
|
|
b36f2ad450 | ||
|
|
bf26207abd | ||
|
|
8ee5d0289b | ||
|
|
a8b2a41d73 | ||
|
|
c458efa8f2 | ||
|
|
1b07d623bf | ||
|
|
8b278dc279 | ||
|
|
f930c0f504 | ||
|
|
1edd87c226 | ||
|
|
c100ba40c7 | ||
|
|
9ae33beea4 | ||
|
|
dcc8a3f8dd | ||
|
|
105656e4fb | ||
|
|
139a9890fd | ||
|
|
11370836a3 | ||
|
|
8a25a1c287 | ||
|
|
fb49a9522f | ||
|
|
a743e75ae4 | ||
|
|
62901a6f3a | ||
|
|
f1e97b20f9 | ||
|
|
84b53753a6 | ||
|
|
8e2718f98d | ||
|
|
482251e389 | ||
|
|
79d495efe8 | ||
|
|
3595ad2644 | ||
|
|
e2baba7c8e | ||
|
|
af7ae5a085 | ||
|
|
6dfa9c0598 | ||
|
|
2d7f5b1512 | ||
|
|
bb88a5bdbc | ||
|
|
d00eef1853 | ||
|
|
90c7501c8e | ||
|
|
38a588f905 | ||
|
|
98fcb41e13 | ||
|
|
49eff594c1 | ||
|
|
eca3c36e9f | ||
|
|
098a236645 | ||
|
|
8c95f1ba33 |
@@ -73,6 +73,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
--build-arg SOURCE_COMMIT=${{ github.sha }} \
|
||||
--push \
|
||||
-t ghcr.io/hanzoai/console:${{ steps.ver.outputs.tag }} \
|
||||
-f Dockerfile .
|
||||
|
||||
+4
-1
@@ -1,4 +1,4 @@
|
||||
node_modules/
|
||||
node_modules
|
||||
.next/
|
||||
out/
|
||||
dist/
|
||||
@@ -20,3 +20,6 @@ e2e-shots/
|
||||
test-results/
|
||||
playwright-report/
|
||||
.claude/
|
||||
|
||||
# blank-audit generated report
|
||||
e2e/blank-report.json
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
# NEXT_PUBLIC_* are inlined at build time (browser config), so they are build args.
|
||||
FROM public.ecr.aws/docker/library/node:24-alpine AS build
|
||||
WORKDIR /app
|
||||
# Exact commit for a deterministic Next build id (next.config.mjs generateBuildId).
|
||||
# The alpine image has no git binary, so CI passes the SHA as a build arg -> ENV,
|
||||
# baked into .next/BUILD_ID so every replica of this image shares ONE build id.
|
||||
ARG SOURCE_COMMIT=""
|
||||
ENV SOURCE_COMMIT=$SOURCE_COMMIT
|
||||
# Copy ALL source FIRST, then install — order matters under Kaniko --single-snapshot:
|
||||
# a `COPY` that FOLLOWS `RUN npm install` in the same stage drops the RUN's freshly
|
||||
# created node_modules (the 'next not found' cause — the install's own `test -f next`
|
||||
@@ -33,6 +38,8 @@ COPY --from=build /app/public ./public
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/package.json ./package.json
|
||||
COPY --from=build /app/next.config.mjs ./next.config.mjs
|
||||
# next.config.mjs imports this at load time (build AND standalone runtime); copy it or the server ERR_MODULE_NOT_FOUND-crashes on boot.
|
||||
COPY --from=build /app/src/config/build-id.mjs ./src/config/build-id.mjs
|
||||
USER app
|
||||
EXPOSE 4000
|
||||
CMD ["node", "node_modules/next/dist/bin/next", "start", "-p", "4000"]
|
||||
|
||||
@@ -1,69 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
import { resolveView, isAdminRoute } from '~/lib/products/match'
|
||||
import { findEntry } from '~/lib/products/registry'
|
||||
import { useIsGlobalAdmin } from '~/lib/auth/admin'
|
||||
import { ProductSubpageStub } from '~/components/products/ProductSubpageStub'
|
||||
import { ProductSubpageModule } from '~/components/products/subpage/ProductSubpageModule'
|
||||
import { AdminManagedNotice } from '~/components/products/AdminManagedNotice'
|
||||
import { ProductErrorBoundary } from '~/components/errors/ProductErrorBoundary'
|
||||
import { ProductRoute } from '~/components/ProductRoute'
|
||||
|
||||
/**
|
||||
* Catch-all product route. Resolves the module + route from the registry and
|
||||
* renders its component. Adding a product anywhere in the registry makes its
|
||||
* routes live here — no per-product page files.
|
||||
* renders its component via the shared `ProductRoute` (the ONE renderer, also used
|
||||
* by the dashboard home for the static embed). Adding a product anywhere in the
|
||||
* registry makes its routes live here — no per-product page files.
|
||||
*
|
||||
* Two honest gates on top of the resolver:
|
||||
* - A known product SUB-PAGE (a declared specific or a uniform base sub-page:
|
||||
* Overview · Settings · Status · Logs · Metrics) with no backend route yet
|
||||
* renders a placeholder stub — never a 404, never a fabricated surface.
|
||||
* - A CUSTOMER (non-global-admin) reaching an admin-only surface (cross-tenant
|
||||
* IAM/KMS, provider + routing config) gets a graceful "managed by Hanzo" notice
|
||||
* instead of the module's hostile 403 red error. Access is enforced
|
||||
* server-side regardless.
|
||||
*
|
||||
* The resolved module renders inside `ProductErrorBoundary`: modules mount
|
||||
* client-only (the authed shell renders a loader during SSR), so a throw in one
|
||||
* module's first render had no boundary and white-screened the whole console
|
||||
* ("Application error: a client-side exception") on a direct load / refresh. The
|
||||
* boundary keeps the shell + nav and shows an honest, retryable card instead —
|
||||
* one place, every product route (DRY).
|
||||
* `ProductRoute` applies the two honest gates (sub-page stub, admin "managed by
|
||||
* Hanzo" notice), the external-product interstitial, and the per-route error
|
||||
* boundary. See that component.
|
||||
*/
|
||||
export default function ProductPage({ params }: { params: Promise<{ slug: string[] }> }) {
|
||||
const { slug } = use(params)
|
||||
const showAdmin = useIsGlobalAdmin()
|
||||
const view = resolveView(slug)
|
||||
|
||||
if (view.kind === 'notfound') notFound()
|
||||
|
||||
if (!showAdmin && isAdminRoute(slug)) {
|
||||
const entry = findEntry(slug[0])
|
||||
if (entry && entry.kind === 'module') {
|
||||
const seg = slug[1]
|
||||
const subpage = seg ? (entry.subpages ?? []).find((s) => s.slug === seg && s.admin) : undefined
|
||||
return <AdminManagedNotice entry={entry} subpage={subpage} />
|
||||
}
|
||||
}
|
||||
|
||||
if (view.kind === 'stub') return <ProductSubpageStub entry={view.entry} subpage={view.subpage} />
|
||||
|
||||
// A uniform base sub-page (Status/Logs/Metrics/Settings) → the shared per-product
|
||||
// sub-page system (real feed or honest state), inside the same error boundary so
|
||||
// a data fetch that throws shows the retryable card, never a white screen.
|
||||
if (view.kind === 'subpage')
|
||||
return (
|
||||
<ProductErrorBoundary resetKey={slug.join('/')}>
|
||||
<ProductSubpageModule entry={view.entry} subpage={view.subpage} />
|
||||
</ProductErrorBoundary>
|
||||
)
|
||||
|
||||
const Component = view.matched.route.component
|
||||
return (
|
||||
<ProductErrorBoundary resetKey={slug.join('/')}>
|
||||
<Component params={view.matched.params} />
|
||||
</ProductErrorBoundary>
|
||||
)
|
||||
return <ProductRoute slug={slug} />
|
||||
}
|
||||
|
||||
@@ -14,10 +14,7 @@ import { useEffect } from 'react'
|
||||
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { RefreshCw, TriangleAlert } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { isChunkLoadError, shouldReloadForChunk } from '~/components/errors/boundary-logic'
|
||||
|
||||
/** Shared once-per-window guard key (same as ProductErrorBoundary — never double-reload). */
|
||||
const RELOAD_AT_KEY = 'hz.console.chunkReloadAt'
|
||||
import { isChunkLoadError, shouldReloadForChunk, CHUNK_RELOAD_AT_KEY } from '~/components/errors/boundary-logic'
|
||||
|
||||
export default function DashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const chunk = isChunkLoadError(error)
|
||||
@@ -29,10 +26,10 @@ export default function DashboardError({ error, reset }: { error: Error & { dige
|
||||
// crash at the segment level auto-recovers instead of stranding a manual card.
|
||||
if (!chunk || typeof window === 'undefined') return
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(RELOAD_AT_KEY)
|
||||
const raw = window.sessionStorage.getItem(CHUNK_RELOAD_AT_KEY)
|
||||
const last = raw ? Number(raw) : null
|
||||
if (shouldReloadForChunk(Date.now(), last)) {
|
||||
window.sessionStorage.setItem(RELOAD_AT_KEY, String(Date.now()))
|
||||
window.sessionStorage.setItem(CHUNK_RELOAD_AT_KEY, String(Date.now()))
|
||||
window.location.reload()
|
||||
}
|
||||
} catch {
|
||||
|
||||
+39
-20
@@ -1,11 +1,14 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { AuthGate } from '~/components/AuthGate'
|
||||
import { WaitlistGate } from '~/components/WaitlistGate'
|
||||
import { OrgGate } from '~/components/OrgGate'
|
||||
import { DashboardShell } from '~/components/DashboardShell'
|
||||
import { PreferencesProvider } from '~/lib/products/preferences'
|
||||
import { ScopeProvider } from '~/lib/scope-context'
|
||||
import { ProjectDeepLink } from '~/components/ProjectDeepLink'
|
||||
import { ToastProvider } from '~/components/ui/Toast'
|
||||
import { OnboardingGate } from '~/components/onboarding/OnboardingGate'
|
||||
import { CommandPaletteProvider } from '~/components/CommandPalette'
|
||||
import { AppLauncherProvider } from '~/components/AppLauncher'
|
||||
import { DetailPaneProvider } from '~/components/DetailPane'
|
||||
@@ -14,26 +17,42 @@ import { FloatingChatProvider } from '~/components/FloatingChat'
|
||||
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<AuthGate>
|
||||
<OrgGate>
|
||||
<ScopeProvider>
|
||||
<PreferencesProvider>
|
||||
<ToastProvider>
|
||||
{/* AppLauncher wraps the palette so the palette can open the launcher. */}
|
||||
<AppLauncherProvider>
|
||||
<CommandPaletteProvider>
|
||||
{/* FloatingChat floats the assistant bubble over every page. */}
|
||||
<FloatingChatProvider>
|
||||
{/* DetailPane hosts the ONE right-side item detail/edit pane. */}
|
||||
<DetailPaneProvider>
|
||||
<DashboardShell>{children}</DashboardShell>
|
||||
</DetailPaneProvider>
|
||||
</FloatingChatProvider>
|
||||
</CommandPaletteProvider>
|
||||
</AppLauncherProvider>
|
||||
</ToastProvider>
|
||||
</PreferencesProvider>
|
||||
</ScopeProvider>
|
||||
</OrgGate>
|
||||
{/* Signed in ≠ product access. WaitlistGate renders the product only when the
|
||||
user is at the front of the waitlist (or the gate is off/open); otherwise it
|
||||
shows the waitlist panel (position + run-a-node / invite move-up paths). It
|
||||
fails open, so a waitlist blip never traps a signed-in user. */}
|
||||
<WaitlistGate>
|
||||
<OrgGate>
|
||||
<ScopeProvider>
|
||||
{/* Honor an inbound ?project=<iamProjectId> (opened from hanzo.app/chat):
|
||||
select that project scope + open its Platform hub. Renders nothing. */}
|
||||
<ProjectDeepLink />
|
||||
<PreferencesProvider>
|
||||
<ToastProvider>
|
||||
{/* First-run onboarding takes over the whole surface for a user who
|
||||
hasn't finished it; otherwise it renders the console below. Placed
|
||||
ABOVE the launcher/palette/chat providers so those overlays never
|
||||
float over the wizard, but inside Preferences+Toast so the wizard
|
||||
can persist choices and report feedback. */}
|
||||
<OnboardingGate>
|
||||
{/* AppLauncher wraps the palette so the palette can open the launcher. */}
|
||||
<AppLauncherProvider>
|
||||
<CommandPaletteProvider>
|
||||
{/* FloatingChat floats the assistant bubble over every page. */}
|
||||
<FloatingChatProvider>
|
||||
{/* DetailPane hosts the ONE right-side item detail/edit pane. */}
|
||||
<DetailPaneProvider>
|
||||
<DashboardShell>{children}</DashboardShell>
|
||||
</DetailPaneProvider>
|
||||
</FloatingChatProvider>
|
||||
</CommandPaletteProvider>
|
||||
</AppLauncherProvider>
|
||||
</OnboardingGate>
|
||||
</ToastProvider>
|
||||
</PreferencesProvider>
|
||||
</ScopeProvider>
|
||||
</OrgGate>
|
||||
</WaitlistGate>
|
||||
</AuthGate>
|
||||
)
|
||||
}
|
||||
|
||||
+39
-10
@@ -8,20 +8,24 @@
|
||||
* no external bounce. Each card can be pinned to the sidebar (persisted to the
|
||||
* account). Rendered entirely from the catalog registry.
|
||||
*/
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter, usePathname } from 'next/navigation'
|
||||
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Star, Lock, ArrowRight, BookOpen, KeyRound } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { config } from '~/config'
|
||||
import { shellFor } from '~/lib/products/shell'
|
||||
import { visibleCatalogByCategory, categorySlug, type CatalogEntry } from '~/lib/products/registry'
|
||||
import { resolveView } from '~/lib/products/match'
|
||||
import { ProductRoute } from '~/components/ProductRoute'
|
||||
import { openProduct } from '~/lib/products/open'
|
||||
import { useFavorites } from '~/lib/products/favorites'
|
||||
import { useIsGlobalAdmin } from '~/lib/auth/admin'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { PageHeader } from '~/components/ui/PageHeader'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { FadeIn } from '~/components/ui/FadeIn'
|
||||
import { livingOverviewModule } from '~/components/products/overview/living/LivingOverviewModule'
|
||||
import { ResourceOverview } from '~/components/products/overview/ResourceOverview'
|
||||
|
||||
// The home centerpiece is the reusable LivingOverview (count-up KPIs, live
|
||||
// sparklines, streaming activity) — the SAME component every product overview uses.
|
||||
@@ -132,18 +136,42 @@ function GetApiKeyCta({ onOpen }: { onOpen: () => void }) {
|
||||
|
||||
export default function DashboardHome() {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { toggle, isPinned } = useFavorites()
|
||||
const showAdmin = useIsGlobalAdmin()
|
||||
const showAdmin = useIsSuperAdmin()
|
||||
const push = (path: string) => router.push(path)
|
||||
const groups = visibleCatalogByCategory(showAdmin)
|
||||
|
||||
// Billing-only shell (billing.<brand> / NEXT_PUBLIC_BILLING_ONLY): the default
|
||||
// route IS the Billing Center — redirect the catalog home to the billing overview
|
||||
// so people who only ever see billing.hanzo.ai land straight on billing.
|
||||
useEffect(() => setMounted(true), [])
|
||||
|
||||
// Product-shell face (billing.<brand> / sentry.<brand> / an override): the default
|
||||
// route IS the face's home — redirect the catalog home there so people who only ever
|
||||
// see billing.hanzo.ai land on billing, and sentry.hanzo.ai on Issues. ONE redirect
|
||||
// for every face, driven by the shell descriptor.
|
||||
const shellHome = shellFor(config.shell).home
|
||||
useEffect(() => {
|
||||
if (config.billingOnly) router.replace('/billing')
|
||||
}, [router])
|
||||
if (config.billingOnly) {
|
||||
if (shellHome) router.replace(`/${shellHome}`)
|
||||
}, [router, shellHome])
|
||||
|
||||
// One-binary STATIC embed: cloud serves THIS page's index.html for EVERY deep
|
||||
// link (a static export can't pre-generate arbitrary product slugs), so a direct
|
||||
// load / refresh — or a client nav that hard-falls-back — of /models, /chat,
|
||||
// /tracker … would otherwise render the home instead of the module. Resolve the
|
||||
// LIVE path client-side and hand any real product route to the shared
|
||||
// ProductRoute. Gated on `mounted` so the first client render matches the
|
||||
// server-exported home ("/") — no hydration mismatch; it then swaps to the
|
||||
// resolved module. On a real Next server this page only renders for "/", so
|
||||
// `segments` is empty and the home always shows; an unknown/non-product deep path
|
||||
// (e.g. /category/*, /discover/*) resolves to notfound here and falls through to
|
||||
// the home rather than a hard 404 in the embed.
|
||||
const segments =
|
||||
mounted && pathname ? pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean) : []
|
||||
if (segments.length > 0 && resolveView(segments).kind !== 'notfound') {
|
||||
return <ProductRoute slug={segments} />
|
||||
}
|
||||
|
||||
if (shellHome) {
|
||||
return (
|
||||
<XStack flex={1} justify="center" items="center" p="$8">
|
||||
<Spinner size="large" color="$color11" />
|
||||
@@ -155,6 +183,7 @@ export default function DashboardHome() {
|
||||
<YStack gap="$7">
|
||||
<GetApiKeyCta onOpen={() => push('/api-keys')} />
|
||||
<OverviewDashboard params={{}} />
|
||||
<ResourceOverview />
|
||||
<YStack gap="$4">
|
||||
<PageHeader
|
||||
title="Explore products"
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* /accept — the invitee's landing page for a team invite (PUBLIC, no session).
|
||||
*
|
||||
* The org admin shares this link (email/OTP delivery isn't wired on this
|
||||
* deployment). The invitee opens it, sees the org they've been invited to, sets a
|
||||
* password (IAM hashes it server-side — never plaintext), then signs in and lands
|
||||
* in that org with the role the admin assigned. Honest states throughout: an
|
||||
* invalid/expired link, an already-accepted link, and IAM errors are all truthful,
|
||||
* never a fake success.
|
||||
*/
|
||||
import { Suspense, useCallback, useEffect, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Button, Card, Input, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { CheckCircle2, ArrowRight, ShieldAlert, UserPlus } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { MIN_PASSWORD } from '~/lib/server/onboarding'
|
||||
|
||||
type Info =
|
||||
| { phase: 'loading' }
|
||||
| { phase: 'error'; message: string }
|
||||
| { phase: 'accepted'; org: string }
|
||||
| { phase: 'form'; org: string; email: string; displayName: string; role: string }
|
||||
|
||||
function Center({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<YStack flex={1} minH="100vh" items="center" justify="center" p="$4">
|
||||
{children}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
function AcceptFlow() {
|
||||
const router = useRouter()
|
||||
const params = useSearchParams()
|
||||
const token = params?.get('t') ?? ''
|
||||
|
||||
const [info, setInfo] = useState<Info>({ phase: 'loading' })
|
||||
const [password, setPassword] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [done, setDone] = useState<false | string>(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setInfo({ phase: 'error', message: 'This invitation link is missing its token.' })
|
||||
return
|
||||
}
|
||||
let live = true
|
||||
;(async () => {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`/console/accept?t=${encodeURIComponent(token)}`, { credentials: 'include' })
|
||||
} catch {
|
||||
if (live) setInfo({ phase: 'error', message: 'Network error — please try again.' })
|
||||
return
|
||||
}
|
||||
const j = (await res.json().catch(() => null)) as
|
||||
| { org?: string; email?: string; displayName?: string; role?: string; accepted?: boolean; error?: string }
|
||||
| null
|
||||
if (!live) return
|
||||
if (!res.ok || !j?.org) {
|
||||
setInfo({ phase: 'error', message: j?.error || 'This invitation link is invalid or has expired.' })
|
||||
return
|
||||
}
|
||||
if (j.accepted) {
|
||||
setInfo({ phase: 'accepted', org: j.org })
|
||||
return
|
||||
}
|
||||
setInfo({ phase: 'form', org: j.org, email: j.email || '', displayName: j.displayName || '', role: j.role || 'member' })
|
||||
setName(j.displayName || '')
|
||||
})()
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [token])
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (password.length < MIN_PASSWORD) {
|
||||
setErr(`Use a password of at least ${MIN_PASSWORD} characters.`)
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch('/console/accept', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ t: token, password, displayName: name.trim() || undefined }),
|
||||
})
|
||||
} catch {
|
||||
setErr('Network error — please try again.')
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
const j = (await res.json().catch(() => null)) as { ok?: boolean; org?: string; error?: string } | null
|
||||
if (!res.ok || !j?.ok) {
|
||||
setErr(j?.error || `Could not activate your account (HTTP ${res.status}).`)
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
setDone(j.org || (info.phase === 'form' ? info.org : ''))
|
||||
}, [password, name, token, info])
|
||||
|
||||
if (done !== false) {
|
||||
return (
|
||||
<Center>
|
||||
<Card p="$5" gap="$4" width={440} maxW="92vw" borderWidth={1} borderColor="$borderColor" bg="$color1" items="center">
|
||||
<CheckCircle2 size={40} color="$green10" />
|
||||
<YStack gap="$1" items="center">
|
||||
<Text fontSize="$7" fontWeight="800">You're in</Text>
|
||||
<Text fontSize="$3" color="$color11" text="center">
|
||||
Your account for <Text color="$color12" fontWeight="700">{done}</Text> is ready. Sign in to continue.
|
||||
</Text>
|
||||
</YStack>
|
||||
<Button
|
||||
size="$4"
|
||||
theme="light"
|
||||
width="100%"
|
||||
iconAfter={<ArrowRight size={16} />}
|
||||
onPress={() => router.push('/signin')}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</Card>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
if (info.phase === 'loading') {
|
||||
return (
|
||||
<Center>
|
||||
<Spinner size="large" color="$color11" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
if (info.phase === 'error') {
|
||||
return (
|
||||
<Center>
|
||||
<Card p="$5" gap="$3" width={440} maxW="92vw" borderWidth={1} borderColor="$borderColor" bg="$color1" items="center">
|
||||
<ShieldAlert size={36} color="$red10" />
|
||||
<Text fontSize="$6" fontWeight="800">Invitation unavailable</Text>
|
||||
<Text fontSize="$3" color="$color11" text="center">{info.message}</Text>
|
||||
<Button size="$3" onPress={() => router.push('/signin')}>Go to sign in</Button>
|
||||
</Card>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
if (info.phase === 'accepted') {
|
||||
return (
|
||||
<Center>
|
||||
<Card p="$5" gap="$3" width={440} maxW="92vw" borderWidth={1} borderColor="$borderColor" bg="$color1" items="center">
|
||||
<CheckCircle2 size={36} color="$green10" />
|
||||
<Text fontSize="$6" fontWeight="800">Already accepted</Text>
|
||||
<Text fontSize="$3" color="$color11" text="center">
|
||||
This invitation to <Text color="$color12" fontWeight="700">{info.org}</Text> was already used. Sign in to continue.
|
||||
</Text>
|
||||
<Button size="$4" theme="light" iconAfter={<ArrowRight size={16} />} onPress={() => router.push('/signin')}>
|
||||
Sign in
|
||||
</Button>
|
||||
</Card>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Center>
|
||||
<Card p="$5" gap="$4" width={440} maxW="92vw" borderWidth={1} borderColor="$borderColor" bg="$color1">
|
||||
<YStack gap="$2">
|
||||
<XStack gap="$2" items="center">
|
||||
<UserPlus size={20} />
|
||||
<Text fontSize="$7" fontWeight="800">Join {info.org}</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
You've been invited to <Text color="$color12" fontWeight="700">{info.org}</Text> as a{' '}
|
||||
<Text color="$color12" fontWeight="700">{info.role}</Text>. Set a password to activate{' '}
|
||||
<Text color="$color12">{info.email}</Text> and sign in.
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$2">
|
||||
<Text fontSize="$2" color="$color11" fontWeight="600">Your name</Text>
|
||||
<Input value={name} onChangeText={setName} placeholder="Your name" autoCapitalize="words" />
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$2">
|
||||
<Text fontSize="$2" color="$color11" fontWeight="600">Password</Text>
|
||||
<Input
|
||||
value={password}
|
||||
onChangeText={(v) => {
|
||||
setPassword(v)
|
||||
if (err) setErr(null)
|
||||
}}
|
||||
placeholder={`At least ${MIN_PASSWORD} characters`}
|
||||
// secureTextEntry alone does not mask in this @hanzo/gui build; set the
|
||||
// web input type explicitly (RNW passthrough) — same as SignInForm.
|
||||
secureTextEntry
|
||||
{...{ type: 'password' }}
|
||||
autoComplete="new-password"
|
||||
onSubmitEditing={() => void submit()}
|
||||
/>
|
||||
</YStack>
|
||||
|
||||
{err ? <Text fontSize="$2" color="$red10">{err}</Text> : null}
|
||||
|
||||
<Button
|
||||
size="$4"
|
||||
theme="light"
|
||||
disabled={busy || password.length < MIN_PASSWORD}
|
||||
iconAfter={busy ? <Spinner color="$color1" /> : <ArrowRight size={16} />}
|
||||
onPress={() => void submit()}
|
||||
>
|
||||
{busy ? 'Activating…' : 'Set password & join'}
|
||||
</Button>
|
||||
</Card>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcceptPage() {
|
||||
return (
|
||||
<Suspense fallback={<Center><Spinner size="large" color="$color11" /></Center>}>
|
||||
<AcceptFlow />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
* routing that affects every org).
|
||||
*
|
||||
* The admin business board is an ALL-ORGS god view (`?org=all`) over IAM + commerce
|
||||
* + o11y. So — unlike the per-tenant `/cloud` proxy, which authorizes on the bearer
|
||||
* + o11y. So — unlike the per-tenant `/v1` proxy, which authorizes on the bearer
|
||||
* `owner` claim and is safe for any authenticated user — this MUST be gated to a
|
||||
* GLOBAL admin BEFORE anything is forwarded: a tenant customer (even one who is
|
||||
* `isAdmin` of their own org) must NOT read another org's revenue/spend/customers,
|
||||
@@ -37,6 +37,7 @@
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { cloudAudience } from '~/config'
|
||||
import { getAdminGate } from '~/lib/server/identity'
|
||||
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
|
||||
import { allowAdminSurface } from '~/lib/server/admin-aggregate'
|
||||
@@ -73,6 +74,14 @@ async function handle(req: NextRequest, ctx: Ctx): Promise<NextResponse> {
|
||||
target: CLOUD_API_URL,
|
||||
path,
|
||||
allow: allowAdminSurface,
|
||||
// Scope the minted user bearer to the brand's cloud audience (`<brand>-cloud`).
|
||||
// The operator is a member of the reserved `admin` org, whose OWN app is
|
||||
// `admin-console` — NOT in cloud's audience allowlist — so a default-audience
|
||||
// bearer is rejected (anonymous → 403 on every /v1/admin/*). With the cloud
|
||||
// audience, cloud validates the token and, seeing owner=admin + isAdmin=true,
|
||||
// sets X-User-IsAdmin=true. Host-aware so a lux/zoo admin host scopes to its own
|
||||
// brand cloud audience. (Tenant proxies are unchanged — they omit this.)
|
||||
audience: cloudAudience(req.headers.get('host')),
|
||||
// The AdminApi client unwraps the casibase `{status,msg,data}` envelope, so this
|
||||
// proxy's own 401/404 must speak the same shape (an honest state, never a throw).
|
||||
errorShape: 'casibase',
|
||||
@@ -95,3 +104,12 @@ export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT — the GLOBAL-admin enablement set (`PUT /v1/admin/enablement`, flip an item
|
||||
* off|beta|ga + grant orgs). Same gate + same CSRF/traversal hardening as POST;
|
||||
* `allowAdminSurface` admits only `v1/admin/enablement`, nothing else.
|
||||
*/
|
||||
export async function PUT(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
|
||||
@@ -31,9 +31,24 @@ const GET_SEGMENTS = new Set([
|
||||
'get-provider',
|
||||
'get-roles',
|
||||
'get-records',
|
||||
// Waitlist approval queue (iam#104) — the Pending-Users board reads this. The
|
||||
// /admin/iam gate is already global-admin-only, matching IAM's own
|
||||
// GetPendingUsers auth (global admin or org admin). REUSED, not rebuilt.
|
||||
'get-pending-users',
|
||||
])
|
||||
|
||||
/** Mutation segments — reachable via POST only (JSON body forwarded). */
|
||||
/**
|
||||
* Mutation segments — reachable via POST only (JSON body forwarded).
|
||||
*
|
||||
* The org-metadata WRITES (`add-organization`/`update-organization`/`delete-organization`)
|
||||
* are the DATA-DRIVEN white-label backbone: a tenant IS an org record, and its BRAND
|
||||
* (logo / favicon / themeData) is a real writable IAM field on that record. This is
|
||||
* how the Tenants board CREATES a tenant and WRITES its brand — no hardcoded brand map.
|
||||
* These are safe on THIS proxy because the gate is already GLOBAL-ADMIN-ONLY and
|
||||
* `forwardIam` pins the org NAME (`orgNameSegments` below) so the write is scoped
|
||||
* (a non-global caller — who can't reach this route anyway — could never retarget
|
||||
* another tenant's org via the id or the body `name`).
|
||||
*/
|
||||
const POST_SEGMENTS = new Set([
|
||||
'add-user',
|
||||
'update-user',
|
||||
@@ -47,14 +62,40 @@ const POST_SEGMENTS = new Set([
|
||||
'add-role',
|
||||
'update-role',
|
||||
'delete-role',
|
||||
'add-organization',
|
||||
'update-organization',
|
||||
'delete-organization',
|
||||
// Waitlist approval actions (iam#104) — approve/reject a pending user. Body is
|
||||
// `{id:"owner/name"}`; the global-admin gate + forwardIam's owner scoping apply.
|
||||
'approve-user',
|
||||
'reject-user',
|
||||
])
|
||||
|
||||
/**
|
||||
* Organization objects are owned by IAM's built-in `admin`, and the org
|
||||
* list/get endpoints scope results to the caller's org server-side — so `admin`
|
||||
* is an acceptable owner THERE (never for tenant data like users/roles).
|
||||
* is an acceptable owner THERE (never for tenant data like users/roles). The
|
||||
* org-metadata WRITES join it: they operate on the `admin`-owned org record.
|
||||
*/
|
||||
const ORG_ENDPOINTS = new Set(['get-organizations', 'get-organization'])
|
||||
const ORG_ENDPOINTS = new Set([
|
||||
'get-organizations',
|
||||
'get-organization',
|
||||
'add-organization',
|
||||
'update-organization',
|
||||
'delete-organization',
|
||||
])
|
||||
|
||||
/**
|
||||
* Segments carrying an org NAME to guard — a non-global admin can't read/write
|
||||
* another org's settings via the `admin` metadata owner. (This route's gate is
|
||||
* already global-only, so this is defense-in-depth: it keeps the org-name scoping
|
||||
* identical to the `/org/iam` self-service proxy, one policy for both.)
|
||||
*/
|
||||
const ORG_NAME_SEGMENTS = new Set([
|
||||
'get-organization',
|
||||
'update-organization',
|
||||
'delete-organization',
|
||||
])
|
||||
|
||||
const forbidden = () => NextResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||
|
||||
@@ -63,12 +104,13 @@ async function handle(req: NextRequest, path: string[], method: 'GET' | 'POST'):
|
||||
if (!gate) return forbidden()
|
||||
return forwardIam(
|
||||
req,
|
||||
{ user: gate.user, isGlobalAdmin: gate.user.isGlobalAdmin, orgScope: gate.orgScope },
|
||||
{ user: gate.user, isSuperAdmin: gate.user.isSuperAdmin, orgScope: gate.orgScope },
|
||||
{
|
||||
segment: path.join('/'),
|
||||
method,
|
||||
allowed: method === 'GET' ? GET_SEGMENTS : POST_SEGMENTS,
|
||||
orgMetaSegments: ORG_ENDPOINTS,
|
||||
orgNameSegments: ORG_NAME_SEGMENTS,
|
||||
// The gate is already global-only; global admins may write to any org.
|
||||
requireAdminForWrite: false,
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAdminGate, adminBearer, kmsBaseUrl, type AdminGate } from '~/lib/server/identity'
|
||||
import { orgFor as policyOrgFor } from '~/lib/server/admin-policy'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
@@ -36,16 +37,23 @@ function secretRest(path: string, name: string): string {
|
||||
return [...path.split('/').filter(Boolean), name].map(encodeURIComponent).join('/')
|
||||
}
|
||||
|
||||
/** Org the operator acts on — the brand org, unless a global admin passes ?org=
|
||||
/** Org the operator acts on — the brand org, unless a SuperAdmin passes ?org=
|
||||
* (the pure `admin-policy` predicate, tested in admin-policy.test.ts). */
|
||||
function orgFor(gate: AdminGate, req: NextRequest): string {
|
||||
return policyOrgFor(
|
||||
{ isGlobalAdmin: gate.user.isGlobalAdmin, orgScope: gate.orgScope },
|
||||
{ isSuperAdmin: gate.user.isSuperAdmin, orgScope: gate.orgScope },
|
||||
req.nextUrl.searchParams.get('org'),
|
||||
)
|
||||
}
|
||||
|
||||
async function handle(req: NextRequest, segments: string[]): Promise<NextResponse> {
|
||||
// CSRF: a cross-site page carrying the admin's auto-sent cookie must never be able
|
||||
// to create / rotate / delete a KMS secret. Refuse a cross-origin MUTATION before
|
||||
// the admin gate or any body read (safe GET reveals pass). Defense in depth on top
|
||||
// of the session cookie's own SameSite attribute.
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
const gate = await getAdminGate(req)
|
||||
if (!gate) return forbidden()
|
||||
if (segments.length !== 1 || segments[0] !== 'secrets') return notFound()
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Server-gated GLOBAL-admin proxy for the commerce SaaS-operations god-view
|
||||
* (`GET /v1/metrics/saas`) — the cross-tenant revenue / subscription / customer
|
||||
* snapshot computed IN commerce (the money system of record). This is the exact
|
||||
* console→commerce pattern commerce's own `api/costs` gate documents: the console's
|
||||
* OWN global-admin gate runs FIRST, then it forwards with the `COMMERCE_SERVICE_TOKEN`
|
||||
* and NO user identity — commerce's `RequirePlatformAdmin` admits that trusted M2M
|
||||
* token (Admin bit, empty Subject) for the fleet god-view.
|
||||
*
|
||||
* Gated fail-closed BEFORE any cross-tenant row is read: `getAdminGate` requires a
|
||||
* VERIFIED `@<brand.adminDomain>` email AND an IAM global-admin flag (the SAME gate
|
||||
* the IAM/KMS/aggregate admin proxies use), → 403 on any miss. A tenant customer —
|
||||
* even one who is `isAdmin` of their OWN org — can never read another org's revenue.
|
||||
* The client-side `admin: true` nav gate + module `OperatorAccessRequired` are
|
||||
* UI-only defense-in-depth; this server gate is the boundary.
|
||||
*
|
||||
* The path is FIXED (`/v1/metrics/saas`) — there is no client-controlled path
|
||||
* segment, so no traversal surface. Only the allow-listed `window`/`limit` query
|
||||
* params are forwarded (validated here), never the raw query string. The commerce
|
||||
* SERVICE token comes from server-only env (never `NEXT_PUBLIC_`, never the browser
|
||||
* bundle); unset → honest 501 (the board shows "not configured", never a fabricated
|
||||
* MRR). The commerce raw JSON is wrapped in the casibase `{status,msg,data}`
|
||||
* envelope the admin client (`originGet`) unwraps.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAdminGate } from '~/lib/server/identity'
|
||||
import { commerceBaseUrl, commerceServiceToken } from '~/lib/server/billing-proxy'
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/** The commerce SaaS god-view — the internal `/v1` bundle path (cloud/costs siblings
|
||||
* live here too), reached directly at the in-cluster commerce address. */
|
||||
const METRICS_PATH = '/v1/metrics/saas'
|
||||
|
||||
/** The windows commerce accepts; anything else is dropped (commerce defaults 30d). */
|
||||
const WINDOWS = new Set(['7d', '30d', '90d', 'mtd', 'all'])
|
||||
|
||||
const NO_STORE = 'no-store, must-revalidate'
|
||||
const envelope = (msg: string, status: number) =>
|
||||
NextResponse.json({ status: 'error', msg, data: null }, { status, headers: { 'Cache-Control': NO_STORE } })
|
||||
|
||||
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||
// AUTHORIZE FIRST — global-admin only, fail-closed. A non-global-admin never
|
||||
// triggers the cross-tenant commerce walk.
|
||||
const gate = await getAdminGate(req)
|
||||
if (!gate) return envelope('forbidden', 403)
|
||||
|
||||
const token = commerceServiceToken()
|
||||
if (!token) return envelope('SaaS metrics are not configured (COMMERCE_TOKEN missing).', 501)
|
||||
|
||||
// Forward ONLY the allow-listed, validated params — never the raw query string.
|
||||
const q = new URLSearchParams()
|
||||
const window = (req.nextUrl.searchParams.get('window') ?? '').trim()
|
||||
if (WINDOWS.has(window)) q.set('window', window)
|
||||
const limit = Number(req.nextUrl.searchParams.get('limit'))
|
||||
if (Number.isInteger(limit) && limit > 0 && limit <= 200) q.set('limit', String(limit))
|
||||
|
||||
const url = `${commerceBaseUrl()}${METRICS_PATH}${q.toString() ? `?${q}` : ''}`
|
||||
try {
|
||||
const res = await fetchWithTimeout(url, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
signal: req.signal,
|
||||
})
|
||||
if (!res.ok) {
|
||||
// Forward commerce's status class as an honest error; the board shows the
|
||||
// failure state (never a fabricated snapshot).
|
||||
return envelope(`SaaS metrics upstream returned ${res.status}.`, res.status === 403 ? 403 : 502)
|
||||
}
|
||||
const data = await res.json()
|
||||
return NextResponse.json({ status: 'ok', msg: '', data }, { headers: { 'Cache-Control': NO_STORE } })
|
||||
} catch (e) {
|
||||
// Redact the exception (it carries the internal commerce host/port) — log
|
||||
// server-side only; return a generic client message.
|
||||
console.error('saas-metrics proxy: upstream unreachable:', commerceBaseUrl(), e instanceof Error ? e.message : String(e))
|
||||
return envelope('SaaS metrics upstream is unavailable.', 502)
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,11 @@
|
||||
*
|
||||
* `/v1/chat/completions` (and friends) REQUIRE an `Authorization: Bearer` token; a
|
||||
* browser session cookie alone is rejected. Rather than ship the user's durable
|
||||
* `hk-` key to the browser, the console calls its OWN origin (`/ai/v1/...`) with just
|
||||
* the session cookie; `forwardWithUserBearer` resolves the user, mints a SHORT-LIVED,
|
||||
* user-bound IAM token (shared per-user cache in identity.ts), and forwards to the
|
||||
* gateway with that token. No key in the browser, no rotation on a chat turn, and
|
||||
* `hk-` key to the browser, the console calls its OWN origin at the canonical, prefix-free
|
||||
* `/v1/<aihead>` (the /v1-first law); `next.config.mjs` dispatches those heads to THIS `/ai`
|
||||
* proxy (re-rooting the upstream at `v1/` — invisible to the client). `forwardWithUserBearer`
|
||||
* resolves the user, mints a SHORT-LIVED, user-bound IAM token (shared per-user cache in
|
||||
* identity.ts), and forwards to the gateway with that token. No key in the browser, and
|
||||
* every call is billed to the user's own org. The response STREAMS through, so
|
||||
* `chat/completions` SSE (and the multi-model TTFT measurement) is preserved.
|
||||
*
|
||||
@@ -36,17 +37,91 @@ const ALLOWED = new Set([
|
||||
'v1/embeddings',
|
||||
'v1/rerank',
|
||||
'v1/audio/speech', // text-to-speech (JSON in → audio bytes out) for the Playground Audio tab
|
||||
'v1/images/generations', // text-to-image (JSON in → image url/b64 out) for the Playground Image tab
|
||||
'v1/videos/generations', // text-to-video CREATE — async: JSON in → a queued job object out (Sora-style)
|
||||
'v1/ai/connections', // AI Login Manager (ai#79/#80): GET list + POST link a BYO provider key (KMS-sealed server-side)
|
||||
'v1/training/clients', // Interactive Training: GET list clients + POST create a LoRA training client (engine plane)
|
||||
'v1/get-router-policy', // Router: the caller's org policy (org-admin gated upstream, self-scoped)
|
||||
'v1/update-router-policy', // Router: upsert the caller's OWN org policy (org-admin gated upstream)
|
||||
'v1/router/stats', // Router: the caller org's routing observability aggregate (RequirePrincipal upstream, self-scoped)
|
||||
'v1/get-training-contribution', // Router: the caller org's training opt-in flag (org-admin gated upstream)
|
||||
'v1/update-training-contribution', // Router: set the caller org's training opt-in flag (org-admin gated upstream)
|
||||
])
|
||||
|
||||
/**
|
||||
* Async video poll/download sub-paths: GET `/v1/videos/{id}` and
|
||||
* `/v1/videos/{id}/content`. Video generation is async (create returns a job id
|
||||
* immediately; the client polls the job and then downloads the finished MP4), so
|
||||
* the Playground must reach these two dynamic paths in addition to the exact
|
||||
* CREATE above. The job id is an opaque `video_<uuid>`; the charset is kept
|
||||
* conservative and the pattern is anchored to `v1/videos/`, so this stays a
|
||||
* narrow allow-list (the create POST is still only the exact
|
||||
* `v1/videos/generations`), never a general gateway tunnel. Method is enforced
|
||||
* by the backend (these are GET-only there).
|
||||
*/
|
||||
const VIDEO_JOB_PATH = /^v1\/videos\/[A-Za-z0-9._-]+(?:\/content)?$/
|
||||
|
||||
/**
|
||||
* Per-provider AI-connection sub-path: `/v1/ai/connections/<provider>` — the
|
||||
* disconnect (the AI router maps POST here to the delete). Anchored to the
|
||||
* connections head with a conservative provider charset, so it stays a narrow
|
||||
* allow-list, never a general tunnel.
|
||||
*/
|
||||
const AI_CONNECTION_PATH = /^v1\/ai\/connections\/[A-Za-z0-9_-]+$/
|
||||
|
||||
/**
|
||||
* Provider-login OAuth start (ai#85): `/v1/ai/connections/<provider>/authorize`.
|
||||
* GET returns the provider consent URL (`?format=json` → `{ authorizeUrl }`) that
|
||||
* the console redirects the browser to; the OAuth callback is handled server-side
|
||||
* by the backend (KMS-sealed), never through this proxy. Anchored to the
|
||||
* connections head with a conservative provider charset — a narrow allow-list, not
|
||||
* a general tunnel.
|
||||
*/
|
||||
const AI_CONNECTION_AUTHORIZE_PATH = /^v1\/ai\/connections\/[A-Za-z0-9_-]+\/authorize$/
|
||||
|
||||
/**
|
||||
* Import a connected account's usage: `/v1/ai/connections/<provider>/usage`. GET only —
|
||||
* the org's key is unsealed SERVER-SIDE and the provider's usage/cost API is called there;
|
||||
* the browser only reads the normalized ProviderUsage. Anchored to the connections head
|
||||
* with a conservative provider charset — a narrow allow-list, not a general tunnel.
|
||||
*/
|
||||
const AI_CONNECTION_USAGE_PATH = /^v1\/ai\/connections\/[A-Za-z0-9_-]+\/usage$/
|
||||
|
||||
/**
|
||||
* Interactive-training per-client sub-path: `/v1/training/clients/<id>` and its four
|
||||
* drive actions — `/forward_backward`, `/optim_step`, `/sample`, `/save_weights`. GET
|
||||
* reads a client, DELETE drops it, POST drives the actions. Anchored to the clients
|
||||
* head with a conservative id charset (opaque `client_<...>`) and an exact action set,
|
||||
* so it stays a narrow allow-list, never a general tunnel. The bare `v1/training/clients`
|
||||
* (list/create) is the exact entry above.
|
||||
*/
|
||||
const TRAINING_CLIENT_PATH = /^v1\/training\/clients\/[A-Za-z0-9._-]+(?:\/(?:forward_backward|optim_step|sample|save_weights))?$/
|
||||
|
||||
/** Whether a resolved `/v1/<...>` path is reachable through this proxy. */
|
||||
function isAllowedAiPath(p: string): boolean {
|
||||
return (
|
||||
ALLOWED.has(p) ||
|
||||
VIDEO_JOB_PATH.test(p) ||
|
||||
AI_CONNECTION_PATH.test(p) ||
|
||||
AI_CONNECTION_AUTHORIZE_PATH.test(p) ||
|
||||
AI_CONNECTION_USAGE_PATH.test(p) ||
|
||||
TRAINING_CLIENT_PATH.test(p)
|
||||
)
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
function handle(req: NextRequest, ctx: Ctx) {
|
||||
return (async () => {
|
||||
const path = (await ctx.params).path.join('/')
|
||||
// The client builds a clean `/v1/<aihead>` and `next.config.mjs` dispatches it here
|
||||
// WITHOUT a nested version (destination `/ai/<aihead>`), so the catch-all captures the
|
||||
// sub-path after `/ai/`. Re-root the upstream at `v1/` — the exact path `isAllowedAiPath`
|
||||
// and the gateway see (`v1/chat/completions`, `v1/images/generations`, `v1/ai/connections`).
|
||||
const path = `v1/${(await ctx.params).path.join('/')}`
|
||||
return forwardWithUserBearer(req, {
|
||||
target: AI_GATEWAY_URL,
|
||||
path,
|
||||
allow: (p) => ALLOWED.has(p),
|
||||
allow: isAllowedAiPath,
|
||||
// Forward the RAG retrieval switch when present; the store's org owner is still
|
||||
// resolved server-side from the session (the bearer), never the browser.
|
||||
extraHeaders: retrievalHeaders((h) => req.headers.get(h)),
|
||||
@@ -62,3 +137,8 @@ export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
// DELETE drops an interactive-training client (`/v1/training/clients/<id>`); the
|
||||
// same-origin CSRF guard in the bearer proxy gates it like every mutating verb.
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* IAM OAuth callback. IAM redirects here with `?code&state`; we exchange them
|
||||
* for a backend session (`/v1/iam/signin`) and land on the dashboard. On failure we
|
||||
* surface the error and offer a retry.
|
||||
* IAM OAuth callback. IAM redirects here with `?code&state`; we exchange them for a
|
||||
* backend session and land on the dashboard. On an ADMIN host the code was minted for
|
||||
* the PUBLIC `admin-console` client via PKCE, so we hand `completeSignIn` the verifier
|
||||
* `startAdminSignin` stashed for this browser — it then redeems the code through the
|
||||
* console's OWN BFF (`/auth/signin`, no secret). Tenant hosts carry no verifier and keep
|
||||
* the cloud-backend `/v1/iam/signin` exchange. On failure we surface the error and offer
|
||||
* a retry.
|
||||
*/
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
@@ -13,9 +17,10 @@ import { ApiError } from '~/lib/api'
|
||||
import { Loader } from '~/components/ui/Loader'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { takeReturnTo } from '~/lib/auth/iam'
|
||||
import { takeAdminPkceVerifier } from '~/lib/auth/iam-login'
|
||||
|
||||
function Callback() {
|
||||
const params = useSearchParams()
|
||||
const params = useSearchParams() ?? new URLSearchParams()
|
||||
const router = useRouter()
|
||||
const { completeSignIn } = useSession()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -27,7 +32,9 @@ function Callback() {
|
||||
setError('Missing authorization code.')
|
||||
return
|
||||
}
|
||||
completeSignIn(code, state)
|
||||
// The verifier is present ONLY after an admin-host silent SSO (startAdminSignin);
|
||||
// undefined on a tenant host, which then keeps the cloud-backend exchange.
|
||||
completeSignIn(code, state, takeAdminPkceVerifier())
|
||||
// Land the user back where a mid-task expiry interrupted them (default home).
|
||||
.then(() => router.replace(takeReturnTo()))
|
||||
.catch((e: unknown) => setError(e instanceof ApiError ? e.message : 'Sign-in failed.'))
|
||||
|
||||
+14
-16
@@ -17,33 +17,31 @@
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { readRefreshToken, refreshGrant, sealSession, setCookies, SessionError, type CookieDirective } from '~/lib/server/session'
|
||||
import { applyCookies, durableSessionClientId, readRefreshToken, refreshGrant, sealSession, setCookies, SessionError } from '~/lib/server/session'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
function withCookies(res: NextResponse, dirs: CookieDirective[]): NextResponse {
|
||||
for (const d of dirs) {
|
||||
res.cookies.set(d.name, d.value, {
|
||||
httpOnly: d.httpOnly,
|
||||
secure: d.secure,
|
||||
sameSite: d.sameSite,
|
||||
path: d.path,
|
||||
maxAge: d.maxAge,
|
||||
})
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
/** 401 WITHOUT clearing the cookies (see the multi-tab note above). */
|
||||
const fail = () => NextResponse.json({ error: 'refresh failed' }, { status: 401 })
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
// CSRF: uniform same-origin gate on every mutating BFF route (the hz_rt cookie is
|
||||
// already SameSite=lax + Path=/auth, so this is belt-and-suspenders).
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
const rt = readRefreshToken(req)
|
||||
if (!rt) return fail()
|
||||
|
||||
// Host-aware rotation: an admin host's session was minted by the PUBLIC admin-console
|
||||
// client, so it must refresh with that same client (no secret); every other host
|
||||
// refreshes the confidential hanzo-console session.
|
||||
const publicClientId = durableSessionClientId(req.headers.get('x-forwarded-host') ?? req.headers.get('host'))
|
||||
|
||||
let tokens
|
||||
try {
|
||||
tokens = await refreshGrant(rt)
|
||||
tokens = await refreshGrant(rt, publicClientId ?? undefined)
|
||||
} catch (e) {
|
||||
// A 502 (endpoint unreachable) is transient — surface it distinctly so the client
|
||||
// can retry, and never touch the cookies.
|
||||
@@ -59,5 +57,5 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
const sealed = sealSession(tokens)
|
||||
if (!sealed) return fail()
|
||||
const res = NextResponse.json({ expiresIn: Math.floor(sealed.expiresInMs / 1000) })
|
||||
return withCookies(res, setCookies(sealed.identity, sealed.refresh))
|
||||
return applyCookies(res, setCookies(sealed.identity, sealed.refresh))
|
||||
}
|
||||
|
||||
+14
-37
@@ -18,9 +18,11 @@
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { type Account } from '~/lib/api/types'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import {
|
||||
accountOf,
|
||||
applyCookies,
|
||||
clearCookies,
|
||||
consoleSession,
|
||||
passwordGrant,
|
||||
@@ -31,44 +33,10 @@ import {
|
||||
sessionConfigured,
|
||||
setCookies,
|
||||
SessionError,
|
||||
type ConsoleClaims,
|
||||
type CookieDirective,
|
||||
} from '~/lib/server/session'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/** Build the client-facing Account from console claims (display + admin fields only;
|
||||
* never the secret material Casdoor also packs into the token). `isGlobalAdmin` is
|
||||
* carried so the client nav/org gates (`isGlobalAdminAccount`) match the casibase
|
||||
* path; `owner === 'admin'` also implies it. */
|
||||
function accountOf(c: ConsoleClaims): Account {
|
||||
return {
|
||||
owner: c.owner ?? '',
|
||||
name: c.name ?? '',
|
||||
type: c.type,
|
||||
displayName: c.displayName,
|
||||
email: c.email,
|
||||
avatar: c.avatar,
|
||||
isAdmin: c.isAdmin,
|
||||
isGlobalAdmin: c.isGlobalAdmin || c.owner === 'admin',
|
||||
properties: c.properties,
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply cookie directives to a NextResponse. */
|
||||
function withCookies(res: NextResponse, dirs: CookieDirective[]): NextResponse {
|
||||
for (const d of dirs) {
|
||||
res.cookies.set(d.name, d.value, {
|
||||
httpOnly: d.httpOnly,
|
||||
secure: d.secure,
|
||||
sameSite: d.sameSite,
|
||||
path: d.path,
|
||||
maxAge: d.maxAge,
|
||||
})
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
/** GET — the account + remaining access lifetime from the live console session, or
|
||||
* 401 when there is none (the client then falls back to the casibase session). */
|
||||
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||
@@ -81,6 +49,11 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||
|
||||
/** POST { username, password } — establish the console session for the signed-in user. */
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
// CSRF: refuse a cross-origin login (login-CSRF fixes the victim into an attacker's
|
||||
// session) before touching credentials.
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
if (!sessionConfigured()) {
|
||||
// No confidential client wired: the console still runs on the casibase session;
|
||||
// report "not configured" so the client silently skips the console session.
|
||||
@@ -128,12 +101,16 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
account: accountOf(sealed.claims),
|
||||
expiresIn: Math.floor(sealed.expiresInMs / 1000),
|
||||
})
|
||||
return withCookies(res, setCookies(sealed.identity, sealed.refresh))
|
||||
return applyCookies(res, setCookies(sealed.identity, sealed.refresh))
|
||||
}
|
||||
|
||||
/** DELETE — sign out: best-effort revoke the refresh token, then clear the cookies. */
|
||||
export async function DELETE(req: NextRequest): Promise<NextResponse> {
|
||||
// CSRF: refuse a cross-origin forced sign-out.
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
const rt = readRefreshToken(req)
|
||||
if (rt) await revokeRefreshToken(rt)
|
||||
return withCookies(NextResponse.json({ ok: true }), clearCookies())
|
||||
return applyCookies(NextResponse.json({ ok: true }), clearCookies())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* /auth/signin — the console's OWN OAuth code exchange for ADMIN hosts (BFF).
|
||||
*
|
||||
* On admin.<brand> the credential login mints the code for the PUBLIC `admin-console`
|
||||
* client (the reserved `admin` org). The cloud backend's `/v1/iam/signin` redeems with
|
||||
* its CONFIDENTIAL `hanzo-cloud` client, so IAM rejects it ("the token is for wrong
|
||||
* application (client_id)"). This route redeems the code HERE as admin-console via
|
||||
* authorization_code + PKCE (RFC 7636 — the code_verifier authenticates the exchange,
|
||||
* NO client secret to provision), then seals the durable hz_session the AuthGate + the
|
||||
* admin gate read. Tenant hosts never call this — they keep the cloud-backend exchange.
|
||||
*
|
||||
* SECURITY. The code is single-use and IAM-minted only after a full credential (incl.
|
||||
* any MFA) check; PKCE binds this redemption to the browser that authorized it. Tokens
|
||||
* live ONLY inside the sealed httpOnly cookies — never returned to the browser, never
|
||||
* logged, never in a URL.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { isAdminHost } from '~/config'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import {
|
||||
accountOf,
|
||||
applyCookies,
|
||||
durableSessionClientId,
|
||||
pkceCodeGrant,
|
||||
sealSession,
|
||||
sessionConfigured,
|
||||
setCookies,
|
||||
SessionError,
|
||||
} from '~/lib/server/session'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/** The callback the admin credential login authorized against (mirrors iam.CALLBACK_PATH;
|
||||
* inlined so this server route pulls in no browser-only IAM SDK). */
|
||||
const CALLBACK_PATH = '/auth/callback'
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
// CSRF: refuse a cross-origin login (login-CSRF fixes a victim into an attacker's
|
||||
// session) before touching the code.
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
const host = req.headers.get('x-forwarded-host') ?? req.headers.get('host') ?? ''
|
||||
const clientId = durableSessionClientId(host)
|
||||
// This exchange is the admin-console (public-client) redemption ONLY. A tenant host
|
||||
// redeems via the cloud backend with its confidential client — refuse it here.
|
||||
if (!isAdminHost(host) || !clientId) {
|
||||
return NextResponse.json({ error: 'not an admin host' }, { status: 400 })
|
||||
}
|
||||
if (!sessionConfigured()) {
|
||||
// The session seal key rests on the confidential-client secret; without it the
|
||||
// cookie can't be minted stably across replicas — report "not configured" honestly.
|
||||
return NextResponse.json({ error: 'session not configured' }, { status: 501 })
|
||||
}
|
||||
|
||||
let body: { code?: unknown; codeVerifier?: unknown }
|
||||
try {
|
||||
body = (await req.json()) as typeof body
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'bad request' }, { status: 400 })
|
||||
}
|
||||
const code = typeof body.code === 'string' ? body.code : ''
|
||||
const codeVerifier = typeof body.codeVerifier === 'string' ? body.codeVerifier : ''
|
||||
if (!code || !codeVerifier) {
|
||||
return NextResponse.json({ error: 'missing code' }, { status: 400 })
|
||||
}
|
||||
|
||||
let tokens
|
||||
try {
|
||||
tokens = await pkceCodeGrant({
|
||||
clientId,
|
||||
code,
|
||||
codeVerifier,
|
||||
redirectUri: `https://${host.replace(/:\d+$/, '')}${CALLBACK_PATH}`,
|
||||
})
|
||||
} catch (e) {
|
||||
const status = e instanceof SessionError ? e.status : 502
|
||||
return NextResponse.json({ error: 'grant failed' }, { status })
|
||||
}
|
||||
|
||||
const sealed = sealSession(tokens)
|
||||
if (!sealed) return NextResponse.json({ error: 'no identity' }, { status: 401 })
|
||||
|
||||
const res = NextResponse.json({
|
||||
account: accountOf(sealed.claims),
|
||||
expiresIn: Math.floor(sealed.expiresInMs / 1000),
|
||||
})
|
||||
return applyCookies(res, setCookies(sealed.identity, sealed.refresh))
|
||||
}
|
||||
+56
-11
@@ -8,20 +8,25 @@
|
||||
* 2. the user as that org's ADMIN (IAM hashes the password server-side).
|
||||
* The client then signs in with the same credentials and lands as admin — no
|
||||
* separate onboarding step (IAM users always belong to an org, so "create then
|
||||
* onboard" is not possible against casibase; the org is minted here).
|
||||
* onboard" is not possible against casibase; the org is minted here). The new user's
|
||||
* `owner` is their OWN personal org (never the reserved `admin` org), so a public
|
||||
* signup is always a self-service customer org — never a platform SuperAdmin.
|
||||
*
|
||||
* Email uniqueness without a global user-lookup endpoint: the org slug is a
|
||||
* DETERMINISTIC, injective function of the email (`personalOrgFromEmail`), so a
|
||||
* repeat signup with the same email resolves to the same slug and is caught by
|
||||
* `getOrganization` (409) — two different emails never false-collide.
|
||||
* `getOrganization` (409); two different emails never false-collide.
|
||||
*
|
||||
* Honest states: 501 when the IAM client is unwired, 400 on bad input, 409 when
|
||||
* the account already exists, 502 on an IAM failure.
|
||||
*
|
||||
* NOTE (hardening, flagged not done here): this endpoint creates accounts from the
|
||||
* open internet. It validates input but has NO captcha / rate-limit / email-
|
||||
* verification gate yet — those are follow-ups (email verification especially
|
||||
* would add friction the go-live conversion goal explicitly avoids).
|
||||
* PUBLIC-LAUNCH ABUSE PROTECTIONS (this route mints an account + org from the open
|
||||
* internet, so the open path is guarded, in order):
|
||||
* - same-origin CSRF gate (a scripted cross-origin signup is refused),
|
||||
* - Turnstile bot wall (`verifyTurnstile`, enforced when the secret is provisioned),
|
||||
* - per-IP sliding-window rate limit (`signupLimiter`, default 5/IP/hr),
|
||||
* - disposable-email block (`isDisposableEmail`).
|
||||
* On success the account is JOINED to the brand waitlist (best-effort) so it holds a
|
||||
* position immediately — product access is then waitlist-gated (see WaitlistGate),
|
||||
* NOT signup. Honest states: 501 unwired, 400 bad input, 403 captcha, 409 exists,
|
||||
* 429 rate-limited, 502 IAM failure.
|
||||
*/
|
||||
import { createHash } from 'node:crypto'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
@@ -35,10 +40,20 @@ import {
|
||||
personalOrgFromEmail,
|
||||
validateSignup,
|
||||
} from '~/lib/server/onboarding'
|
||||
import { isDisposableEmail } from '~/lib/server/disposable'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { clientIp, signupLimiter } from '~/lib/server/rate-limit'
|
||||
import { verifyTurnstile } from '~/lib/server/turnstile'
|
||||
import { joinWaitlist } from '~/lib/server/waitlist'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
// Same-origin gate: the console's own signup form is same-origin; refuse scripted
|
||||
// cross-origin signups before doing any work.
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
if (!mintConfigured()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Account creation is not configured on this deployment (IAM client unset).' },
|
||||
@@ -46,9 +61,33 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
)
|
||||
}
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as { email?: string; password?: string }
|
||||
const body = (await req.json().catch(() => ({}))) as {
|
||||
email?: string
|
||||
password?: string
|
||||
turnstileToken?: string
|
||||
ref?: string
|
||||
}
|
||||
const v = validateSignup(body.email ?? '', body.password ?? '')
|
||||
if (!v.ok) return NextResponse.json({ error: v.error }, { status: 400 })
|
||||
if (isDisposableEmail(v.email)) {
|
||||
return NextResponse.json({ error: 'Please use a non-disposable email address.' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Bot wall (no-op until Turnstile is provisioned) BEFORE the per-IP throttle, so a
|
||||
// solved challenge is what a legitimate user spends their attempts on.
|
||||
const ip = clientIp(req.headers)
|
||||
const captcha = await verifyTurnstile((body.turnstileToken ?? '').trim(), ip)
|
||||
if (!captcha.ok) {
|
||||
return NextResponse.json({ error: 'Captcha verification failed. Please try again.' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Per-IP rate limit — the open-internet spam/cost floor.
|
||||
if (!signupLimiter.allow(`signup:${ip}`)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too many sign-up attempts. Please try again later.' },
|
||||
{ status: 429, headers: { 'Retry-After': '3600' } },
|
||||
)
|
||||
}
|
||||
|
||||
const brand = BRANDS[brandFromHost(req.headers.get('host'))]
|
||||
const brandOrg = brand.id // hanzo/lux/zoo/pars — cloned for password/locale policy
|
||||
@@ -65,11 +104,12 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
}
|
||||
|
||||
const displayName = displayNameFromEmail(v.email)
|
||||
const username = deriveUsername(v.email)
|
||||
try {
|
||||
await createOrganization({ name: orgSlug, displayName, personal: true, sourceOwner: brandOrg })
|
||||
await createUser({
|
||||
org: orgSlug,
|
||||
username: deriveUsername(v.email),
|
||||
username,
|
||||
email: v.email,
|
||||
password: v.password,
|
||||
displayName,
|
||||
@@ -82,5 +122,10 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
)
|
||||
}
|
||||
|
||||
// Join the brand waitlist so the account holds a POSITION immediately — product
|
||||
// access is waitlist-gated (WaitlistGate), and a `?ref=` from the landing link
|
||||
// credits the referrer's position. Best-effort, service-authed; never blocks signup.
|
||||
await joinWaitlist(v.email, { host: req.headers.get('host'), referrerCode: (body.ref ?? '').trim() })
|
||||
|
||||
return NextResponse.json({ ok: true, org: orgSlug })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* GET /auth/waitlist — the signed-in user's WAITLIST ACCESS + position (BFF).
|
||||
*
|
||||
* THE shared product-access check. The console shell (WaitlistGate) reads this to
|
||||
* decide whether to render the product or the waitlist status page; hanzo.chat and
|
||||
* hanzo.app gate on the SAME underlying `/v1/waitlist/status` for the same user, so
|
||||
* a user's access + position are identical across every surface.
|
||||
*
|
||||
* Resolves the caller's email from their established session (never trusts a
|
||||
* client-supplied email), then asks the waitlist plugin. FAIL-OPEN: when the waitlist
|
||||
* is unconfigured or unreachable, `waitlistAccess` grants access — the gate is
|
||||
* additive and never locks a signed-in user out of a paid product on a blip.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import { waitlistAccess } from '~/lib/server/waitlist'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return NextResponse.json({ error: 'not authenticated' }, { status: 401 })
|
||||
|
||||
// No email on the identity → cannot key a waitlist entry; fail OPEN (don't strand
|
||||
// a valid session behind a gate it can never satisfy).
|
||||
if (!user.email) return NextResponse.json({ hasAccess: true, status: null })
|
||||
|
||||
const { hasAccess, status } = await waitlistAccess(user.email, req.headers.get('host'))
|
||||
return NextResponse.json({ hasAccess, status })
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* Per-tenant billing DATA proxy → commerce. The browser calls console2's OWN origin
|
||||
* (`/billing/v1/...`); this server handler forwards to commerce's `/v1/billing/...`,
|
||||
* injecting the commerce SERVICE token from server-only env (never `NEXT_PUBLIC_`,
|
||||
* never in the browser bundle) AND scoping every request to the caller's OWN org.
|
||||
*
|
||||
* Namespaced under `/billing/v1/` (NOT bare `/billing/`) so the data plane never
|
||||
* shadows the billing UI tab URLs (`/billing/reports`, `/billing/invoices`, …): a
|
||||
* route handler always wins over the catch-all page for a matching path segment, so
|
||||
* the tab slugs and the data endpoints must live in disjoint path space. The tab
|
||||
* URLs now fall through to the SPA (`app/(dashboard)/[...slug]`).
|
||||
*
|
||||
* Same trust boundary as the `/admin/iam` + `/admin/kms` proxies, but the authz is
|
||||
* PER-TENANT, not admin: any authenticated session may read/act on ITS OWN billing
|
||||
* (balance / usage / invoices / credit-grants / subscriptions / payment-methods).
|
||||
* The org is resolved server-side from the validated session (`resolveUser`) and
|
||||
* stamped as `X-Org-Id` (the header commerce's service-token path actually reads —
|
||||
* `commerce/middleware/accesstoken.go`), and the server-resolved billing subject is
|
||||
* pinned onto the FULL commerce subject-key set (`user`/`userId`/`customerId`, via
|
||||
* `scopedBillingSearch`) while `?org=` is dropped. The client CANNOT widen scope: a
|
||||
* forged `?userId=`/`?customerId=`/`?org=` is overwritten, and because EVERY subject
|
||||
* param is pinned, no billing endpoint is left unfiltered regardless of which one it
|
||||
* reads (subscriptions filter `userId`, payment-methods `customerId`). So commerce's
|
||||
* per-tenant isolation can never be crossed from the browser. No session → 401.
|
||||
*
|
||||
* Billing-subject mirrors `object.BillingSubject` (hanzoai/ai) + chat's
|
||||
* `billingSubject`: a member of a PERSONAL-billing org (default the shared `hanzo`
|
||||
* catch-all) bills per-user as `<org>/<name>`; a dedicated org (maxpower, …) bills
|
||||
* per-org as `<org>`. The SAME subject the gateway debits — so the console shows
|
||||
* the exact balance/usage that gets charged.
|
||||
*
|
||||
* `COMMERCE_TOKEN` unset → honest 501 (the UI shows a truthful "not configured"
|
||||
* state; it never fabricates a balance).
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import { billingSubject, scopedBillingSearch, scopedBillingBody } from '~/lib/server/billing-scope'
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const isSafeSegment = (s: string): boolean =>
|
||||
s.length > 0 && s !== '.' && s !== '..' && !s.includes('/') && !s.includes('\\') && !s.includes('\0')
|
||||
|
||||
function commerceBaseUrl(): string {
|
||||
return (process.env.COMMERCE_URL ?? 'http://commerce.hanzo.svc:8001').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
|
||||
// Per-tenant authz: any valid session may see ITS OWN billing (no admin gate).
|
||||
const user = await resolveUser(req)
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Sign in to view billing.' }, { status: 401 })
|
||||
}
|
||||
if (!path.every(isSafeSegment)) {
|
||||
return NextResponse.json({ error: 'Invalid billing path.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const token = process.env.COMMERCE_TOKEN ?? process.env.COMMERCE_SERVICE_TOKEN ?? ''
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Billing is not configured (COMMERCE_TOKEN missing).' },
|
||||
{ status: 501 },
|
||||
)
|
||||
}
|
||||
|
||||
// Scope to the caller's OWN org — server-resolved, never client-supplied.
|
||||
const org = user.owner.trim()
|
||||
const subject = billingSubject(org, user.name)
|
||||
|
||||
// Pin the FULL billing-subject key set to the server-resolved subject, and
|
||||
// strip `org`, so the browser can never read another tenant's ledger. Commerce
|
||||
// filters each endpoint on a DIFFERENT param — subscriptions on `userId`,
|
||||
// payment-methods on `customerId` (or `user`), usage on `user` — so pinning
|
||||
// only ONE param leaves the others unfiltered (a cross-tenant read). This
|
||||
// mirrors commerce's own edge-auth `billingSubjectKeys`
|
||||
// (commerce/middleware/edgeauth.go: {"user","userId","customerId"}) exactly, so
|
||||
// every billing endpoint is scoped no matter which param it reads.
|
||||
const qs = scopedBillingSearch(req.nextUrl.search, subject)
|
||||
const url = `${commerceBaseUrl()}/v1/billing/${path.join('/')}${qs ? `?${qs}` : ''}`
|
||||
|
||||
const init: RequestInit = {
|
||||
method: req.method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
// Commerce resolves the tenant namespace from `X-Org-Id` on the service-token
|
||||
// path (commerce/middleware/accesstoken.go). It does NOT read `X-Hanzo-Org`,
|
||||
// so sending that alone silently falls back to the service org — every tenant
|
||||
// sharing one namespace. Send `X-Org-Id`, matching the `/ai` proxy.
|
||||
'X-Org-Id': org,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
}
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
// Scope the WRITE body to the caller's OWN subject too (not just the query):
|
||||
// commerce reads the subject from the JSON body on writes like create-spend-alert
|
||||
// (`userId`), so pin it server-side — the browser needn't know its subject and a
|
||||
// forged body subject cannot widen scope. Mirrors `scopedBillingSearch`.
|
||||
init.body = scopedBillingBody(await req.text(), subject)
|
||||
}
|
||||
try {
|
||||
const res = await fetchWithTimeout(url, init)
|
||||
const text = await res.text()
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
'Content-Type': res.headers.get('content-type') ?? 'application/json',
|
||||
// A per-tenant money response (balance/usage/invoices) must NEVER be cached
|
||||
// by the browser or any intermediary — otherwise the wallet shows a stale
|
||||
// number after a completion or a top-up. The live-balance store still polls,
|
||||
// but this guarantees each fetch hits commerce, not a cache.
|
||||
'Cache-Control': 'no-store, must-revalidate',
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `Billing upstream unreachable: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
/**
|
||||
* Wallet HUSD top-up — verify on-chain, then record to commerce (server route).
|
||||
*
|
||||
* Why this lives in console2 and not billing: billing.hanzo.ai is a Next static
|
||||
* export (`output: 'export'`) and cannot host a runtime POST handler, and the
|
||||
* commerce backend is owned elsewhere. So the verify-and-record seam lives here
|
||||
* as a same-origin server route — the same pattern as `app/paas/[...path]`: the
|
||||
* browser calls the console's OWN origin, the server does the privileged work,
|
||||
* and config comes from server-only env (sourced via KMS, never `NEXT_PUBLIC`).
|
||||
*
|
||||
* Flow: the client sends an HUSD ERC-20 transfer to the treasury and posts the
|
||||
* tx hash here. We read the receipt from the Hanzo EVM, confirm it is a mined,
|
||||
* successful HUSD `Transfer(from → treasury, value)`, derive USD cents from the
|
||||
* (18-decimal, USD-pegged) value, then record it to commerce as a `husd` crypto
|
||||
* payment and return the credited amount + the new balance. The on-chain amount
|
||||
* — never a client-supplied number — is what gets credited.
|
||||
*
|
||||
* Honest failure: if HUSD/treasury are unconfigured (greenfield — HUSD not yet
|
||||
* deployed) we return 501 so the UI shows a truthful "coming" state; if the tx
|
||||
* is missing/failed/not an HUSD-to-treasury transfer we return 400; if the chain
|
||||
* or commerce is unreachable we return 502. Never a fabricated credit.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { ethers } from 'ethers'
|
||||
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const RPC_URL = (process.env.HANZO_RPC_URL ?? 'https://rpc.hanzo.network').replace(/\/+$/, '')
|
||||
const HUSD_ADDRESS = (process.env.HANZO_HUSD_ADDRESS ?? '').trim()
|
||||
const TREASURY = (process.env.HANZO_HUSD_TREASURY ?? '').trim()
|
||||
const COMMERCE_URL = (process.env.COMMERCE_URL ?? 'https://api.hanzo.ai').replace(/\/+$/, '')
|
||||
const CHAIN_ID = Number(process.env.HANZO_CHAIN_ID ?? '36900')
|
||||
|
||||
const ERC20_TRANSFER_ABI = ['event Transfer(address indexed from, address indexed to, uint256 value)']
|
||||
const isAddr = (a: string): boolean => /^0x[0-9a-fA-F]{40}$/.test(a)
|
||||
|
||||
/** Forward the caller's identity (session cookie / bearer) to commerce. */
|
||||
function authHeaders(req: NextRequest): Record<string, string> {
|
||||
const h: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json' }
|
||||
const cookie = req.headers.get('cookie')
|
||||
if (cookie) h.Cookie = cookie
|
||||
const auth = req.headers.get('authorization')
|
||||
if (auth) h.Authorization = auth
|
||||
return h
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
// Greenfield gate: no HUSD contract / treasury ⇒ honest "not configured".
|
||||
if (!isAddr(HUSD_ADDRESS) || !isAddr(TREASURY)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'HUSD top-up is not configured yet (HUSD is not deployed on Hanzo Mainnet).' },
|
||||
{ status: 501 },
|
||||
)
|
||||
}
|
||||
|
||||
let body: { txHash?: string; fromAddress?: string; userId?: string }
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const txHash = (body.txHash ?? '').trim()
|
||||
const fromAddress = (body.fromAddress ?? '').trim()
|
||||
if (!/^0x[0-9a-fA-F]{64}$/.test(txHash)) {
|
||||
return NextResponse.json({ error: 'A valid transaction hash is required.' }, { status: 400 })
|
||||
}
|
||||
|
||||
// ── 1. Verify the HUSD transfer on-chain ────────────────────────────────────
|
||||
let creditedCents: number
|
||||
let verifiedFrom: string
|
||||
try {
|
||||
const provider = new ethers.JsonRpcProvider(RPC_URL, CHAIN_ID)
|
||||
const receipt = await provider.getTransactionReceipt(txHash)
|
||||
if (!receipt) {
|
||||
return NextResponse.json({ error: 'Transaction not found or not yet mined.' }, { status: 400 })
|
||||
}
|
||||
if (receipt.status !== 1) {
|
||||
return NextResponse.json({ error: 'Transaction failed on-chain.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const iface = new ethers.Interface(ERC20_TRANSFER_ABI)
|
||||
const husd = HUSD_ADDRESS.toLowerCase()
|
||||
const treasury = TREASURY.toLowerCase()
|
||||
let value: bigint | null = null
|
||||
for (const log of receipt.logs) {
|
||||
if (log.address.toLowerCase() !== husd) continue
|
||||
let parsed: ethers.LogDescription | null = null
|
||||
try {
|
||||
parsed = iface.parseLog({ topics: [...log.topics], data: log.data })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (parsed?.name !== 'Transfer') continue
|
||||
if (String(parsed.args.to).toLowerCase() !== treasury) continue
|
||||
value = parsed.args.value as bigint
|
||||
verifiedFrom = ethers.getAddress(String(parsed.args.from))
|
||||
break
|
||||
}
|
||||
if (value === null) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No HUSD transfer to the treasury was found in this transaction.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
if (fromAddress && isAddr(fromAddress) && verifiedFrom!.toLowerCase() !== fromAddress.toLowerCase()) {
|
||||
return NextResponse.json({ error: 'Transfer sender does not match the connected wallet.' }, { status: 400 })
|
||||
}
|
||||
// HUSD is an 18-decimal, USD-pegged stablecoin → 1e16 base units = 1 cent.
|
||||
creditedCents = Number(value / 10n ** 16n)
|
||||
if (creditedCents <= 0) {
|
||||
return NextResponse.json({ error: 'Transferred amount is below the minimum (1 cent).' }, { status: 400 })
|
||||
}
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `Could not verify the transaction on Hanzo Mainnet: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
// ── 2. Record to commerce as an HUSD crypto payment ─────────────────────────
|
||||
try {
|
||||
const recordRes = await fetchWithTimeout(`${COMMERCE_URL}/v1/billing/payment`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(req),
|
||||
cache: 'no-store',
|
||||
body: JSON.stringify({
|
||||
method: 'crypto',
|
||||
network: 'hanzo',
|
||||
chainId: CHAIN_ID,
|
||||
currency: 'husd',
|
||||
amount: creditedCents,
|
||||
txHash,
|
||||
fromAddress: verifiedFrom!,
|
||||
toAddress: TREASURY,
|
||||
userId: body.userId,
|
||||
}),
|
||||
})
|
||||
if (!recordRes.ok) {
|
||||
const text = await recordRes.text().catch(() => '')
|
||||
return NextResponse.json(
|
||||
{ error: `Commerce rejected the payment (HTTP ${recordRes.status}): ${text}`.trim() },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
const payment = (await recordRes.json().catch(() => ({}))) as { status?: string }
|
||||
|
||||
// New balance (USD ledger) — best-effort; the credit already landed.
|
||||
let balance = 0
|
||||
try {
|
||||
const balRes = await fetchWithTimeout(
|
||||
`${COMMERCE_URL}/v1/billing/balance?user=${encodeURIComponent(body.userId ?? '')}¤cy=usd`,
|
||||
{ headers: authHeaders(req), cache: 'no-store' },
|
||||
)
|
||||
if (balRes.ok) balance = ((await balRes.json()) as { balance?: number }).balance ?? 0
|
||||
} catch {
|
||||
/* balance is informational; the credit is recorded */
|
||||
}
|
||||
|
||||
return NextResponse.json({ creditedCents, balance, txHash, status: payment.status ?? 'recorded' })
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `Could not reach commerce to record the payment: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* Same-origin user-bearer proxy to the unified cloud-api `/v1/*` — the ONE path the
|
||||
* browser uses to reach the cloud surfaces that authorize on a Bearer JWT.
|
||||
*
|
||||
* The managed data resources (vector/sql/kv/s3/docdb/datastore/search) and the
|
||||
* serverless / prompt / agent surfaces resolve the org from the token's `owner`
|
||||
* claim and 403 a cookie-only call ("X-Org-Id required"). So — exactly like the
|
||||
* `/ai` proxy — the browser calls this OWN-origin route with just its session
|
||||
* cookie; `forwardWithUserBearer` resolves the user, mints a short-lived user-bound
|
||||
* IAM token (shared per-user cache), and forwards to cloud-api with that Bearer. No
|
||||
* credential reaches the browser, org is server-authoritative (never the browser's
|
||||
* claim), and every read/write is billed + scoped to the user's own org.
|
||||
*
|
||||
* Least privilege: only the data + serverless HEADS are reachable (`allowCloudSurface`);
|
||||
* `v1/iam/*`, `v1/admin/*`, etc. 404 here — this is not a general cloud-api tunnel.
|
||||
*/
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
|
||||
import { allowCloudSurface } from '~/lib/server/proxy-allow'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** The unified cloud backend (hanzoai/cloud). In-cluster ClusterIP — public egress is CF-403'd.
|
||||
* `|| default` (not `??`) so an env accidentally reconciled to an EMPTY string still falls
|
||||
* back to the in-cluster service (a blank CLOUD_API_URL would otherwise break every cloud page). */
|
||||
const CLOUD_API_URL = trim(process.env.CLOUD_API_URL?.trim() || 'http://cloud-api.hanzo.svc.cluster.local:8000')
|
||||
|
||||
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: CLOUD_API_URL,
|
||||
path,
|
||||
allow: allowCloudSurface,
|
||||
// Org is authoritative (Bearer owner). Do NOT forward the browser-controlled
|
||||
// X-Project-Id/X-Environment sub-scopes — the data/serverless resources are
|
||||
// org-keyed, and forwarding an unvalidated project id is an attack surface
|
||||
// (RED MEDIUM). A project-scoped feature must validate membership first.
|
||||
unauthorizedMessage: 'Sign in to use Hanzo Cloud.',
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function PUT(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* /console/accept — the invitee's side of the team-invite flow (UNAUTHENTICATED).
|
||||
*
|
||||
* GET ?t=<token> → validate the sealed invite; report whether the member is
|
||||
* still PENDING (no password) or already ACTIVATED, plus the
|
||||
* org + email to show. Never leaks anything a token-holder
|
||||
* shouldn't already know (the admin put them in the org).
|
||||
* POST { t, password, displayName? } → set the pending member's INITIAL password
|
||||
* (IAM hashes it — never plaintext) and mark them activated.
|
||||
*
|
||||
* The sealed token IS the authorization (it names exactly one `org/name`), so this
|
||||
* needs no session — the invitee has none yet. It refuses once the member already
|
||||
* has a password, so a link can never reset an active member's credential.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { brandFromHost } from '~/config'
|
||||
import { BRANDS } from '~/lib/branding/brands'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { getMember, memberHasPassword, activateMember, mintConfigured } from '~/lib/server/identity'
|
||||
import { readInvite, inviteUserId } from '~/lib/server/invite'
|
||||
import { MIN_PASSWORD } from '~/lib/server/onboarding'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const bad = (error: string, status: number) => NextResponse.json({ error }, { status })
|
||||
|
||||
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||
const inv = readInvite(req.nextUrl.searchParams.get('t'))
|
||||
if (!inv) return bad('This invitation link is invalid or has expired.', 400)
|
||||
|
||||
const member = await getMember(inviteUserId(inv))
|
||||
if (!member || member.owner !== inv.org) {
|
||||
return bad('This invitation is no longer valid — the member was removed.', 410)
|
||||
}
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
org: inv.org,
|
||||
email: member.email || inv.email,
|
||||
displayName: member.displayName || member.name,
|
||||
role: member.isAdmin ? 'admin' : 'member',
|
||||
accepted: memberHasPassword(member),
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
if (!mintConfigured()) {
|
||||
return bad('Invite acceptance is not configured on this deployment.', 501)
|
||||
}
|
||||
|
||||
let body: { t?: unknown; password?: unknown; displayName?: unknown }
|
||||
try {
|
||||
body = (await req.json()) as typeof body
|
||||
} catch {
|
||||
return bad('bad request', 400)
|
||||
}
|
||||
const inv = readInvite(typeof body.t === 'string' ? body.t : null)
|
||||
if (!inv) return bad('This invitation link is invalid or has expired.', 400)
|
||||
|
||||
const password = typeof body.password === 'string' ? body.password : ''
|
||||
if (password.length < MIN_PASSWORD) {
|
||||
return bad(`Use a password of at least ${MIN_PASSWORD} characters.`, 400)
|
||||
}
|
||||
if (/\s/.test(password)) return bad('Password cannot contain spaces.', 400)
|
||||
const displayName = typeof body.displayName === 'string' ? body.displayName.trim() : ''
|
||||
|
||||
const id = inviteUserId(inv)
|
||||
const member = await getMember(id)
|
||||
if (!member || member.owner !== inv.org) {
|
||||
return bad('This invitation is no longer valid — the member was removed.', 410)
|
||||
}
|
||||
// Single-use for activation: refuse if the member already has a credential, so a
|
||||
// stale/re-shared link can never reset an active member's password.
|
||||
if (memberHasPassword(member)) {
|
||||
return bad('This invitation was already accepted. Please sign in.', 409)
|
||||
}
|
||||
|
||||
const brand = BRANDS[brandFromHost(req.headers.get('host'))]
|
||||
const signupApplication = `${brand.id}-cloud`
|
||||
|
||||
try {
|
||||
await activateMember(id, { password, displayName: displayName || undefined, signupApplication })
|
||||
} catch (e) {
|
||||
return bad(`Could not activate the account: ${e instanceof Error ? e.message : String(e)}`, 502)
|
||||
}
|
||||
return NextResponse.json({ ok: true, org: inv.org, email: member.email || inv.email })
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* POST /console/invite-link — mint a shareable ACCEPT LINK for a pending member.
|
||||
*
|
||||
* The Team module creates the member row via the `/org/iam` proxy (Dave's own
|
||||
* user bearer, Casbin-scoped to his org) — that path is unchanged. This route then
|
||||
* mints the sealed, TTL-bound invite token so the invitee can set a password and
|
||||
* sign in, WITHOUT any email/OTP (delivery is a link hand-off; IAM `send-invitation`
|
||||
* is a documented stub on this deployment).
|
||||
*
|
||||
* Gate: any authenticated ORG ADMIN, pinned to a member of their OWN org (a global
|
||||
* admin may target any org — same policy as the `/org/iam` proxy). The member must
|
||||
* actually EXIST in that org (verified via the confidential client) — so an admin
|
||||
* can never mint an activation link for someone else's tenant or a phantom user.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getOrgGate, getMember } from '~/lib/server/identity'
|
||||
import { ownerAllowed, orgWriteAllowed } from '~/lib/server/admin-policy'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { signInvite, acceptLink, type Invite } from '~/lib/server/invite'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const bad = (msg: string, status: number) => NextResponse.json({ error: msg }, { status })
|
||||
|
||||
/** The public origin the invitee will open — from the ingress-set Host header. */
|
||||
function publicOrigin(req: NextRequest): string {
|
||||
const host = req.headers.get('host') ?? req.nextUrl.host
|
||||
const proto = req.headers.get('x-forwarded-proto') ?? (host.startsWith('localhost') ? 'http' : 'https')
|
||||
return `${proto}://${host}`
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
const gate = await getOrgGate(req)
|
||||
if (!gate) return bad('forbidden', 403)
|
||||
// Writes (an invite is one) require org admin — a member can view the roster only.
|
||||
if (!orgWriteAllowed({ isSuperAdmin: gate.isSuperAdmin, isAdmin: gate.user.isAdmin })) {
|
||||
return bad('forbidden', 403)
|
||||
}
|
||||
|
||||
let body: { org?: unknown; name?: unknown; email?: unknown }
|
||||
try {
|
||||
body = (await req.json()) as typeof body
|
||||
} catch {
|
||||
return bad('bad request', 400)
|
||||
}
|
||||
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
||||
const email = typeof body.email === 'string' ? body.email.trim() : ''
|
||||
// The org defaults to the caller's own scope; a SuperAdmin may pass another.
|
||||
const reqOrg = typeof body.org === 'string' && body.org.trim() ? body.org.trim() : gate.orgScope
|
||||
if (!name) return bad('missing member name', 400)
|
||||
|
||||
// Pin the org to the caller's scope (a non-SuperAdmin can only ever mint a link
|
||||
// for their OWN org) — the SAME guard as the /org/iam proxy.
|
||||
if (!ownerAllowed(reqOrg, { isSuperAdmin: gate.isSuperAdmin, orgScope: gate.orgScope, orgMetadataOk: false })) {
|
||||
return bad('forbidden', 403)
|
||||
}
|
||||
|
||||
const id = `${reqOrg}/${name}`
|
||||
const member = await getMember(id)
|
||||
if (!member || member.owner !== reqOrg) return bad('member not found', 404)
|
||||
|
||||
const inv: Invite = { org: reqOrg, name, email: email || member.email || '' }
|
||||
const token = signInvite(inv)
|
||||
return NextResponse.json({ ok: true, org: reqOrg, name, email: inv.email, link: acceptLink(publicOrigin(req), token) })
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* /console/mfa/<action> — console-native two-factor (TOTP) enrollment BFF.
|
||||
*
|
||||
* WHY console-native: the console delegated 2FA to hanzo.id's account page, but the
|
||||
* custom hanzo.id login worker doesn't establish a Casdoor account session, so a
|
||||
* user who signed in through it lands on an account page that can't manage MFA
|
||||
* (setup returns "Unauthorized operation"). This closes that gap: the user enrolls
|
||||
* 2FA IN the console. We forward each IAM MFA op as the caller's OWN user bearer
|
||||
* (the authz filter authenticates the JWT and Casbin authorizes self-service MFA),
|
||||
* with owner/name PINNED to the resolved session user — so a caller can only ever
|
||||
* manage THEIR OWN 2FA, never another account's.
|
||||
*
|
||||
* Actions (POST): initiate · verify · enable · disable — the standard TOTP flow.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser, adminBearer, iamBaseUrl } from '~/lib/server/identity'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const TOTP = 'app' // Casdoor TotpType
|
||||
|
||||
/**
|
||||
* IAM endpoint + the params each action sends. owner/name are ALWAYS included and
|
||||
* pinned to the resolved session user for TWO reasons: (1) the handler targets that
|
||||
* user, and (2) the IAM authz filter derives the request OBJECT from `owner`/`name`
|
||||
* (query) and grants self-access when it equals the bearer subject — the SAME rule
|
||||
* that lets `get-users?owner=<me>` through. We send these as the QUERY STRING with an
|
||||
* EMPTY body: the authz filter's object-derivation reads a form body as JSON, so a
|
||||
* form-encoded body yields an empty object (→ no self-match → denied); with the
|
||||
* params in the query and no body it reads owner/name and the self grant applies.
|
||||
*/
|
||||
const ACTIONS: Record<string, { path: string; params: (u: { owner: string; name: string }, b: Body) => Record<string, string> }> = {
|
||||
initiate: {
|
||||
path: '/v1/iam/mfa/setup/initiate',
|
||||
params: (u) => ({ owner: u.owner, name: u.name, mfaType: TOTP }),
|
||||
},
|
||||
verify: {
|
||||
path: '/v1/iam/mfa/setup/verify',
|
||||
params: (u, b) => ({ owner: u.owner, name: u.name, mfaType: TOTP, passcode: b.passcode ?? '', secret: b.secret ?? '' }),
|
||||
},
|
||||
enable: {
|
||||
path: '/v1/iam/mfa/setup/enable',
|
||||
params: (u, b) => ({ owner: u.owner, name: u.name, mfaType: TOTP, secret: b.secret ?? '', recoveryCodes: b.recoveryCodes ?? '' }),
|
||||
},
|
||||
disable: {
|
||||
path: '/v1/iam/delete-mfa',
|
||||
params: (u) => ({ owner: u.owner, name: u.name }),
|
||||
},
|
||||
}
|
||||
|
||||
type Body = { passcode?: string; secret?: string; recoveryCodes?: string }
|
||||
|
||||
export async function POST(req: NextRequest, ctx: { params: Promise<{ action: string }> }): Promise<NextResponse> {
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
const { action } = await ctx.params
|
||||
const spec = ACTIONS[action]
|
||||
if (!spec) return NextResponse.json({ error: 'unknown action' }, { status: 404 })
|
||||
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return NextResponse.json({ error: 'not authenticated' }, { status: 401 })
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as Body
|
||||
|
||||
let bearer: string
|
||||
try {
|
||||
bearer = await adminBearer(user)
|
||||
} catch {
|
||||
return NextResponse.json({ status: 'error', msg: 'Could not authorize the request.' }, { status: 502 })
|
||||
}
|
||||
|
||||
// Params ride the QUERY STRING (see ACTIONS doc) with an EMPTY body so the IAM
|
||||
// authz filter derives owner/name for the self-access grant.
|
||||
const qs = new URLSearchParams(spec.params({ owner: user.owner, name: user.name }, body)).toString()
|
||||
try {
|
||||
const res = await fetchWithTimeout(`${iamBaseUrl()}${spec.path}?${qs}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${bearer}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
const text = await res.text()
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' },
|
||||
})
|
||||
} catch {
|
||||
return NextResponse.json({ status: 'error', msg: 'Identity service is unavailable.' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* `/docs` → the brand documentation site (docs.hanzo.ai / docs.lux.network / …),
|
||||
* resolved CLIENT-side (task #41, "True 1-binary FE").
|
||||
*
|
||||
* Docs are an EXTERNAL product on their own domain, never an in-app route — so a
|
||||
* typed or bookmarked `<console-host>/docs` must land on the real docs, not the
|
||||
* catch-all not-found. The old app/docs/route.ts issued a server 308; in the
|
||||
* one-binary there is no Next runtime (the static export has no server, and a static
|
||||
* export cannot rewrite), so the redirect is resolved from the per-host brand
|
||||
* (`config.docsUrl`) in the browser — exactly what the sidebar "Docs" link and the
|
||||
* header "?" already open. One way, both topologies (embed + standalone).
|
||||
*
|
||||
* The target is set in an effect (not during render) so there is no SSR/CSR
|
||||
* hydration mismatch on the per-brand host between the build-time default and the
|
||||
* real browser host.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { config } from '~/config'
|
||||
|
||||
export default function DocsRedirect() {
|
||||
const [url, setUrl] = useState('')
|
||||
useEffect(() => {
|
||||
const target = config.docsUrl
|
||||
setUrl(target)
|
||||
window.location.replace(target)
|
||||
}, [])
|
||||
return (
|
||||
<main style={{ padding: 24, fontFamily: 'system-ui, sans-serif' }}>
|
||||
Opening documentation… {url ? <a href={url}>Continue</a> : null}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* `/docs` → the brand documentation site (new origin), server-side redirect.
|
||||
*
|
||||
* Docs are an EXTERNAL product on their own domain (docs.hanzo.ai / docs.lux.network
|
||||
* / …), never an in-app route — so a typed or bookmarked `console.<brand>/docs`
|
||||
* must land on the real docs, not the catch-all `notFound()` (the old 404). The
|
||||
* brand is resolved from the request Host so a lux/zoo console redirects to ITS
|
||||
* docs. The sidebar "Docs" entry and the header "?" open the same URL in a new tab;
|
||||
* this handler only covers the direct-navigation case.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveConfig } from '~/config'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
export function GET(req: NextRequest) {
|
||||
const host = req.headers.get('host')
|
||||
return NextResponse.redirect(resolveConfig(host ?? undefined).docsUrl, 308)
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* Embed status — is a brand's embedded app (Content Studio / ERP / Help Center)
|
||||
* provisioned and reachable, so the console can decide embed-vs-provision-CTA?
|
||||
*
|
||||
* The console embeds these REAL apps over the brand's own IAM SSO (never a
|
||||
* fabricated surface). A cross-origin browser can't read another origin's status
|
||||
* (SOP + CORS), so this SERVER route probes it once and returns an honest verdict:
|
||||
* `reachable:false` while an app is 502/unrouted, so the module shows the truthful
|
||||
* "not available / deploy" state instead of framing an error page.
|
||||
*
|
||||
* NO god-mode: unlike `/paas` (which forwards the control-plane service token and
|
||||
* is admin-gated), this route holds NO privileged credential. It resolves the
|
||||
* app's brand ORIGIN from the request host CLAMPED to the known brand domains
|
||||
* (`embed-probe.ts`, unit-tested) — so a forged Host header can never steer it into
|
||||
* an SSRF probe of an arbitrary host — and does a plain, time-boxed reachability
|
||||
* check. Session-gated so it is not an open relay. The exact origin + deep-link it
|
||||
* probed/derived are RETURNED, and the module embeds precisely that.
|
||||
*
|
||||
* GET /embed-status?app=cms|erp|help → { app, origin, embedUrl, reachable, phase }
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import { embedTarget, embedOrigin, isEmbedApp, isUp, brandOrgForHost, isEntitled } from '~/lib/server/embed-probe'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return NextResponse.json({ error: 'Sign in to continue.' }, { status: 401 })
|
||||
|
||||
const appParam = (req.nextUrl.searchParams.get('app') ?? '').trim().toLowerCase()
|
||||
if (!isEmbedApp(appParam)) {
|
||||
return NextResponse.json({ error: 'Unknown embed app.' }, { status: 400 })
|
||||
}
|
||||
const host = req.headers.get('host')
|
||||
const { origin, embedUrl } = embedTarget(appParam, host)
|
||||
|
||||
// SERVER-SIDE entitlement gate: these apps are single shared per-BRAND instances,
|
||||
// so only the owning brand org (or a global admin) may frame them. The org is the
|
||||
// TOKEN OWNER (server-resolved, never a browser claim). A non-entitled caller
|
||||
// NEVER receives the embed URL and we don't even probe — the module shows the
|
||||
// honest provision panel. This is the authoritative gate; the client check is only
|
||||
// to avoid a flash.
|
||||
const brandOrg = brandOrgForHost(host)
|
||||
if (!isEntitled(appParam, user.owner, brandOrg, user.isGlobalAdmin)) {
|
||||
return NextResponse.json(
|
||||
{ app: appParam, origin, embedUrl: '', reachable: false, entitled: false, phase: 'not-entitled' },
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
)
|
||||
}
|
||||
|
||||
let up = false
|
||||
try {
|
||||
// Time-boxed reachability probe of the origin root. `redirect: 'manual'` so an
|
||||
// SSO 302 counts as "up" (we don't follow it — only need the liveness signal).
|
||||
const res = await fetch(embedOrigin(appParam, host), {
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
cache: 'no-store',
|
||||
signal: AbortSignal.timeout(4500),
|
||||
headers: { Accept: 'text/html' },
|
||||
})
|
||||
up = isUp(res.status)
|
||||
} catch {
|
||||
up = false // DNS failure / connection refused / timeout → not provisioned yet.
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ app: appParam, origin, embedUrl, reachable: up, entitled: true, phase: up ? 'ready' : 'not-provisioned' },
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Top-level recovery boundary (Next App Router `global-error`).
|
||||
*
|
||||
* This REPLACES Next's built-in root fallback — the one that renders the bare,
|
||||
* dead-ended "Application error: a client-side exception has occurred" and leaves
|
||||
* the SPA wedged (no router, so a later in-app nav back to `/` stays dead until a
|
||||
* full reload). It is the OUTERMOST boundary: it catches throws in the root layout
|
||||
* and anything that bubbles past the segment boundaries — including a chunk-load
|
||||
* failure during the very first hydration, which is exactly the "deep-link /
|
||||
* refresh a sub-route → crash" the audit hit (a stale-deploy chunk 404s, falls
|
||||
* through to the app-shell HTML, and the browser throws parsing HTML as JS).
|
||||
*
|
||||
* On a chunk skew it SELF-HEALS: one full reload per window pulls the fresh HTML +
|
||||
* current chunks. The reload is bounded by the SAME sessionStorage key every other
|
||||
* recovery site uses (`CHUNK_RELOAD_AT_KEY`), so a skew that trips several
|
||||
* boundaries at once reloads ONCE, never in a loop. For a genuine (non-chunk)
|
||||
* crash it shows a minimal, self-contained recovery card — it runs with the root
|
||||
* layout torn down, so it owns its own `<html>`/`<body>` and uses inline styles
|
||||
* (no GUI provider is mounted here).
|
||||
*/
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { isChunkLoadError, shouldReloadForChunk, CHUNK_RELOAD_AT_KEY } from '~/components/errors/boundary-logic'
|
||||
|
||||
export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const chunk = isChunkLoadError(error)
|
||||
|
||||
useEffect(() => {
|
||||
console.error('[console] global error:', error)
|
||||
if (!chunk || typeof window === 'undefined') return
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(CHUNK_RELOAD_AT_KEY)
|
||||
const last = raw ? Number(raw) : null
|
||||
if (shouldReloadForChunk(Date.now(), last)) {
|
||||
window.sessionStorage.setItem(CHUNK_RELOAD_AT_KEY, String(Date.now()))
|
||||
window.location.reload()
|
||||
}
|
||||
} catch {
|
||||
/* sessionStorage blocked (private mode) — fall through to the manual card */
|
||||
}
|
||||
}, [error, chunk])
|
||||
|
||||
return (
|
||||
<html lang="en" style={{ backgroundColor: '#000', colorScheme: 'dark' }}>
|
||||
<body style={{ margin: 0, fontFamily: 'ui-sans-serif, system-ui, -apple-system, sans-serif', color: '#fff', backgroundColor: '#000' }}>
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
|
||||
<div style={{ maxWidth: 440, width: '100%', border: '1px solid #262626', borderRadius: 12, padding: 24, backgroundColor: '#0a0a0a' }}>
|
||||
<h1 style={{ margin: '0 0 8px', fontSize: 18, fontWeight: 700 }}>
|
||||
{chunk ? 'Updating to the latest version' : 'Something went wrong'}
|
||||
</h1>
|
||||
<p style={{ margin: '0 0 20px', fontSize: 14, lineHeight: 1.5, color: '#a3a3a3' }}>
|
||||
{chunk
|
||||
? 'A newer version of the console just shipped. Reloading to load the latest…'
|
||||
: 'The console hit an unexpected error. Reload to recover, or return home.'}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{!chunk ? (
|
||||
<button type="button" onClick={() => reset()} style={btn(true)}>
|
||||
Try again
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (typeof window !== 'undefined') window.location.reload() }}
|
||||
style={btn(chunk)}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (typeof window !== 'undefined') window.location.assign('/') }}
|
||||
style={btn(false)}
|
||||
>
|
||||
Go home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
/** Inline button style — primary (filled) vs chromeless (bordered). */
|
||||
function btn(primary: boolean): React.CSSProperties {
|
||||
return {
|
||||
appearance: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
padding: '8px 14px',
|
||||
borderRadius: 8,
|
||||
border: primary ? '1px solid #fff' : '1px solid #333',
|
||||
backgroundColor: primary ? '#fff' : 'transparent',
|
||||
color: primary ? '#000' : '#e5e5e5',
|
||||
}
|
||||
}
|
||||
+258
-37
@@ -1,26 +1,9 @@
|
||||
/* Geist Mono — canonical Hanzo mono face (code/data). */
|
||||
/* Canonical Hanzo faces — Geist Sans (UI/body/headings) + Geist Mono (code/data),
|
||||
loaded from the same CDN package so both faces resolve one way. Geist ships a full
|
||||
real weight range, so headings render a true heavier cut, never a synthesized face. */
|
||||
@import url('https://cdn.jsdelivr.net/npm/geist@1.3.1/dist/fonts/geist-sans/style.css');
|
||||
@import url('https://cdn.jsdelivr.net/npm/geist@1.3.1/dist/fonts/geist-mono/style.css');
|
||||
|
||||
/* Basel Grotesk — canonical Hanzo UI/body/display/heading face (self-hosted). */
|
||||
@font-face {
|
||||
font-family: 'Basel';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src:
|
||||
url('/fonts/Basel-Grotesk-Book.woff2') format('woff2'),
|
||||
url('/fonts/Basel-Grotesk-Book.woff') format('woff');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Basel';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src:
|
||||
url('/fonts/Basel-Grotesk-Medium.woff2') format('woff2'),
|
||||
url('/fonts/Basel-Grotesk-Medium.woff') format('woff');
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#__next {
|
||||
@@ -31,13 +14,16 @@ body {
|
||||
margin: 0;
|
||||
background-color: var(--background, #000000);
|
||||
color: var(--color, #ededf1);
|
||||
font-family:
|
||||
'Basel', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
font-family: 'Geist', system-ui, -apple-system, sans-serif;
|
||||
/* Calm type rendering — crisp, low-glare, comfortable rhythm for a full workday. */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
line-height: 1.5;
|
||||
/* Geist ships a full real weight range, so a requested 500/600/700 resolves to a
|
||||
genuine cut — never a browser-fabricated faux-bold/oblique. This bans synthesis
|
||||
outright as a floor. Inherited by every element; the ONE place the product sets it. */
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
code,
|
||||
@@ -54,10 +40,34 @@ samp {
|
||||
font-feature-settings: 'tnum' 1;
|
||||
}
|
||||
|
||||
/* Data/numeric face — Geist Mono + tabular figures for metric values, prices, IDs,
|
||||
counts and code-like tokens. The dashboard-grade "numbers are typeset" detail
|
||||
(Linear/Stripe): stat tiles, table numeric cells and monospace identifiers read
|
||||
as precise, column-aligned data — distinct from Geist prose. One class, whole
|
||||
product. `className` forwards to the DOM node on web, so a Gui <Text> can wear it. */
|
||||
.hz-mono {
|
||||
font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-feature-settings: 'tnum' 1;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Ban faux-bold/oblique EVERYWHERE. Geist ships real weight cuts, so a requested
|
||||
600/700/800/900 must map to a genuine face, never a browser-synthesized smear.
|
||||
Tamagui/RNW inject runtime styles that reset the
|
||||
inherited `font-synthesis` on Text nodes, so a body-level declaration loses —
|
||||
this universal rule (with !important, a true global invariant) wins on every
|
||||
element regardless of insertion order. One place, whole product. */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
font-synthesis: none !important;
|
||||
}
|
||||
|
||||
/* ── Console dark theme — TRUE-BLACK canvas + calm text/borders. The ONE place the
|
||||
console's dark/light palette is set. Overrides the generated @hanzo/gui (Tamagui)
|
||||
theme variables; the `html:root.t_*` selector is one step more specific than the
|
||||
@@ -75,7 +85,7 @@ samp {
|
||||
html:root.t_dark {
|
||||
--background: #000000;
|
||||
--backgroundStrong: #000000;
|
||||
--backgroundHover: #171717;
|
||||
--backgroundHover: #101010;
|
||||
--backgroundPress: #050505;
|
||||
--backgroundFocus: #171717;
|
||||
|
||||
@@ -85,21 +95,33 @@ html:root.t_dark {
|
||||
--color2: #0a0a0a;
|
||||
--color3: #171717;
|
||||
--color4: #1f1f1f;
|
||||
--color5: hsl(220 6% 16%);
|
||||
--color6: hsl(220 6% 22%);
|
||||
--color7: hsl(220 6% 30%);
|
||||
--color8: hsl(219 6% 42%);
|
||||
--color9: hsl(220 6% 55%);
|
||||
--color10: hsl(219 7% 68%);
|
||||
--color11: hsl(214 9% 83%);
|
||||
--color12: hsl(210 12% 95%);
|
||||
--color: hsl(210 12% 95%);
|
||||
--color5: hsl(0 0% 16%);
|
||||
--color6: hsl(0 0% 22%);
|
||||
--color7: hsl(0 0% 30%);
|
||||
--color8: hsl(0 0% 42%);
|
||||
--color9: hsl(0 0% 55%);
|
||||
--color10: hsl(0 0% 68%);
|
||||
--color11: hsl(0 0% 83%);
|
||||
--color12: hsl(0 0% 95%);
|
||||
--color: #ededed;
|
||||
|
||||
/* Gentle hairlines — present enough to define, quiet enough to disappear on black. */
|
||||
--borderColor: hsl(220 8% 15%);
|
||||
--borderColorHover: hsl(220 7% 23%);
|
||||
--borderColorPress: hsl(220 8% 13%);
|
||||
--borderColorFocus: hsl(220 7% 23%);
|
||||
--borderColor: #1f1f1f;
|
||||
--borderColorHover: #333333;
|
||||
--borderColorPress: hsl(0 0% 13%);
|
||||
--borderColorFocus: hsl(0 0% 23%);
|
||||
|
||||
/* Elevation ladder (Material-inspired: ambient + key light). On the true-black
|
||||
canvas a cast shadow alone is nearly invisible, so each level pairs a deep
|
||||
shadow with a faint top highlight + a hairline ring (set on .hz-paper) so a
|
||||
sheet lifts cleanly off black. Brand-neutral — color stays token-driven. */
|
||||
--hz-elevation-1: 0 1px 2px rgba(0, 0, 0, 0.6), 0 1px 1px rgba(0, 0, 0, 0.5);
|
||||
--hz-elevation-2: 0 3px 8px rgba(0, 0, 0, 0.62), 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
--hz-elevation-3: 0 8px 24px rgba(0, 0, 0, 0.64), 0 2px 6px rgba(0, 0, 0, 0.5);
|
||||
--hz-elevation-4: 0 16px 40px rgba(0, 0, 0, 0.68), 0 6px 14px rgba(0, 0, 0, 0.55);
|
||||
--hz-elevation-5: 0 28px 64px rgba(0, 0, 0, 0.72), 0 12px 24px rgba(0, 0, 0, 0.6);
|
||||
--hz-ring: 0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
--hz-paper-highlight: inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Light theme — the calm parallel: a warm off-white base (not stark #fff), soft
|
||||
@@ -119,6 +141,16 @@ html:root.t_light {
|
||||
--color: hsl(220 22% 12%);
|
||||
--borderColor: hsl(220 16% 90%);
|
||||
--borderColorHover: hsl(220 14% 82%);
|
||||
|
||||
/* Elevation ladder — light theme: soft, cool-grey Material shadows on the warm
|
||||
off-white base (the calm parallel of the dark ladder above). */
|
||||
--hz-elevation-1: 0 1px 2px rgba(16, 24, 40, 0.06), 0 1px 3px rgba(16, 24, 40, 0.1);
|
||||
--hz-elevation-2: 0 3px 8px rgba(16, 24, 40, 0.08), 0 1px 3px rgba(16, 24, 40, 0.06);
|
||||
--hz-elevation-3: 0 10px 24px rgba(16, 24, 40, 0.1), 0 3px 8px rgba(16, 24, 40, 0.07);
|
||||
--hz-elevation-4: 0 18px 40px rgba(16, 24, 40, 0.13), 0 6px 14px rgba(16, 24, 40, 0.08);
|
||||
--hz-elevation-5: 0 28px 60px rgba(16, 24, 40, 0.16), 0 12px 24px rgba(16, 24, 40, 0.1);
|
||||
--hz-ring: 0 0 0 1px rgba(16, 24, 40, 0.05);
|
||||
--hz-paper-highlight: inset 0 1px 0 0 rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
/* Motion — a single fade-up entrance (matches the hanzo.ai marketing feel:
|
||||
@@ -336,3 +368,192 @@ html:root.t_light {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* DataTable row — the hover fill eases in/out (Tamagui flips the bg instantly;
|
||||
this smooths it to the 140ms ease-out the rest of the product uses). */
|
||||
.hz-row {
|
||||
transition: background-color 140ms ease-out;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hz-row {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Touch targets — WCAG 2.5.5 (AAA) / Apple HIG ≥44px ──────────────────────
|
||||
On phones/tablets (<lg) every control inside the mobile nav drawer must be at
|
||||
least 44px tall to tap reliably. Scoped to `.hz-touch-target` (set on the drawer
|
||||
root only), so the dense DESKTOP sidebar — a separate mount at lg+ that never
|
||||
wears this class — keeps its Linear-grade density. A Gui <Button> renders a real
|
||||
<button>, so this one rule reaches every nav row / control within the drawer.
|
||||
One class, every touch surface (DRY). */
|
||||
@media (max-width: 1023.98px) {
|
||||
.hz-touch-target button,
|
||||
.hz-touch-target [role='button'] {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Chat composer dock — pinned to the viewport bottom on phones/tablets ─────
|
||||
The full-page chat scrolls inside the shell's content scroller; without this the
|
||||
composer sits at the end of a tall welcome/thread and first paints BELOW the fold.
|
||||
Made sticky it rides the bottom edge of the scrollport (the conversation scrolls
|
||||
under it), so the input is always reachable. From lg up the capped, centered
|
||||
column already keeps it in view, so it stays in normal flow. The element carries
|
||||
an opaque background so content scrolls cleanly beneath. */
|
||||
@media (max-width: 1023.98px) {
|
||||
.hz-chat-dock {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 5;
|
||||
/* Clear the iOS home indicator when Safari's bottom bar hides (viewport-fit=cover
|
||||
exposes the inset; 0 on devices without one, so no effect elsewhere). */
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Material paper / 3D elevation ─────────────────────────────────────────────
|
||||
A real depth system for the console's overlay surfaces (drawer, command palette,
|
||||
menus, dialog, support bubble). Layered box-shadow (ambient + key light) read
|
||||
from the per-theme --hz-elevation-* tokens (light AND dark aware). Brand-neutral:
|
||||
the shadow is monochrome and color stays token-driven, so lux/zoo/pars theme
|
||||
cleanly. One place defines the ladder; an overlay wears a class. `className`
|
||||
forwards to the DOM node on web, so a Gui surface can wear these. */
|
||||
.hz-elevation-1 { box-shadow: var(--hz-elevation-1); }
|
||||
.hz-elevation-2 { box-shadow: var(--hz-elevation-2); }
|
||||
.hz-elevation-3 { box-shadow: var(--hz-elevation-3); }
|
||||
.hz-elevation-4 { box-shadow: var(--hz-elevation-4); }
|
||||
.hz-elevation-5 { box-shadow: var(--hz-elevation-5); }
|
||||
|
||||
/* Paper = an elevated sheet: hairline ring + top highlight + a mid cast shadow, so
|
||||
a menu/palette/dialog reads as a physical sheet floating above the page. */
|
||||
.hz-paper { box-shadow: var(--hz-ring), var(--hz-paper-highlight), var(--hz-elevation-3); }
|
||||
.hz-paper-4 { box-shadow: var(--hz-ring), var(--hz-paper-highlight), var(--hz-elevation-4); }
|
||||
.hz-paper-5 { box-shadow: var(--hz-ring), var(--hz-paper-highlight), var(--hz-elevation-5); }
|
||||
|
||||
/* Overlay entrance — a fast, physical scale-fade from the origin (menus, palette,
|
||||
dialog, support sheet). 180ms ease-out enter; the overlay's own unmount handles
|
||||
exit. Reduced-motion → snap (no transform). */
|
||||
@keyframes hz-pop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.985);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
.hz-pop-in {
|
||||
animation: hz-pop-in 180ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
transform-origin: var(--hz-pop-origin, center);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* Popover MENU entrance — OPACITY-ONLY (never transform). floating-ui positions an
|
||||
anchored menu with an inline `transform: translate(x,y)`, and a CSS-animation that
|
||||
also drives `transform` (like hz-pop-in) OVERRIDES that inline value for the
|
||||
animation's duration — detaching the menu from its trigger. So anchored menus
|
||||
(SelectMenu / ComboBox Popover.Content) fade in with NO transform, keeping the
|
||||
floating-ui anchor exact. The transform-based hz-pop-in stays for the centered
|
||||
Dialog surfaces (CommandPalette / AppLauncher / FloatingChat), which are NOT
|
||||
floating-ui-positioned. Reduced-motion → snap. */
|
||||
@keyframes hz-menu-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.hz-menu-in {
|
||||
animation: hz-menu-in 140ms ease-out both;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
/* Scrim fade — the dimmed backdrop behind a dialog/palette eases in (Tamagui mounts
|
||||
the overlay instantly otherwise). */
|
||||
@keyframes hz-scrim-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.hz-scrim-in {
|
||||
animation: hz-scrim-in 160ms ease-out both;
|
||||
}
|
||||
|
||||
/* Support bubble — a gentle hover lift on the elevated brand-H bubble. */
|
||||
.hz-lift {
|
||||
transition:
|
||||
transform 160ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
box-shadow 160ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
.hz-lift:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Hover-paper — a subtle elevation lift on hover for a small affordance (the sidebar
|
||||
brand-H container). Only the H wears this, never the whole row. */
|
||||
.hz-hover-paper {
|
||||
transition:
|
||||
box-shadow 160ms ease,
|
||||
transform 160ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
background-color 140ms ease;
|
||||
}
|
||||
.hz-hover-paper:hover {
|
||||
box-shadow: var(--hz-elevation-2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hz-pop-in,
|
||||
.hz-menu-in,
|
||||
.hz-scrim-in {
|
||||
animation: none;
|
||||
}
|
||||
.hz-lift,
|
||||
.hz-hover-paper {
|
||||
transition: none;
|
||||
}
|
||||
.hz-lift:hover,
|
||||
.hz-hover-paper:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── A11y + responsive hardening ─────────────────────────────────────────────
|
||||
Global floors that hold across every product surface. One place, whole app. */
|
||||
|
||||
/* 1. The page body is a hard NO-horizontal-scroll surface. A stray fixed/overwide
|
||||
child (an off-screen drawer mid-transition, a wide table) must clip, never
|
||||
scroll the whole document sideways. `clip` (not `hidden`) does not create a
|
||||
scroll container, so sticky/fixed descendants keep working. */
|
||||
html,
|
||||
body {
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
/* 2. Visible keyboard focus, everywhere. `:focus-visible` fires ONLY for keyboard
|
||||
navigation (never a mouse/touch press), so this paints a crisp ring for
|
||||
tab-through without touching pointer interactions. Tamagui focusStyle handles
|
||||
some controls; this is the global floor so nothing is ever focus-invisible.
|
||||
Colour reads from the theme scale, so it adapts in light and dark. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color9, #6c6c6c);
|
||||
outline-offset: 2px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 3. Touch tap targets ≥44px (WCAG 2.5.5 / Apple HIG). On a COARSE pointer
|
||||
(phone/tablet) every top-bar control meets the 44×44 minimum; the desktop
|
||||
mouse density is deliberately left unchanged. Scoped to the top bar so table
|
||||
row-actions and inline chips are untouched. */
|
||||
@media (pointer: coarse) {
|
||||
.hz-topbar button {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
+67
-48
@@ -1,77 +1,96 @@
|
||||
/**
|
||||
* Cloud API key management — the per-user `hk-` credential, minted server-side.
|
||||
* Per-user `hk-` Cloud API key — the SAME-ORIGIN console route (the fix for the
|
||||
* API-keys "sign in to manage API keys" / CORS crack).
|
||||
*
|
||||
* Same-origin (`/keys`): the browser sends only its first-party session cookie;
|
||||
* this server handler resolves the user from that cookie and calls IAM as the
|
||||
* confidential `hanzo-console` client on the user's behalf. The `hk-` secret is
|
||||
* returned to the browser ONLY at creation (POST) — standard show-once handling —
|
||||
* and is otherwise never echoed. Authorization is the user's own session: a
|
||||
* caller can only ever mint/revoke their OWN key (IAM binds the id to the
|
||||
* resolved session), never another tenant's.
|
||||
* The browser calls this OWN-origin route (`/keys`) with just its first-party
|
||||
* session cookie. This handler resolves the signed-in user from that cookie
|
||||
* (`resolveUser`) and mints/reads/revokes the key through IAM as the confidential
|
||||
* `hanzo-console` client (`identity.ts` `mintUserKey`/`getUserKey`/`revokeUserKey`,
|
||||
* over IAM `mint-user-keys`/`get-user`/`revoke-user-keys` — the WORKING key path,
|
||||
* verified live). No credential ever reaches the browser; the `hk-` secret is
|
||||
* returned ONLY by POST (show once).
|
||||
*
|
||||
* POST /keys → mint (or rotate) the key; returns { accessKey } once.
|
||||
* DELETE /keys → revoke the key (the old key stops working).
|
||||
* GET /keys → whether a key exists (no secret material).
|
||||
* Why not `cloud.hanzo.ai/v1/iam/keys` (the old path): that is a DIFFERENT
|
||||
* ORIGIN than console.hanzo.ai, so a browser `fetch` is blocked by CORS ("Failed to
|
||||
* fetch") — and cloud-api's own keys handler 501s ("IAM client unset") on this
|
||||
* deployment anyway. The IAM confidential-client mint the console already uses for
|
||||
* `hk-` keys elsewhere (`app/ai` chat) is the ONE authoritative, same-origin,
|
||||
* always-working path — so the Org-Settings API-keys surface uses it too (DRY: the
|
||||
* exact primitives from `identity.ts`, no new IAM plumbing).
|
||||
*
|
||||
* GET → { hasKey, keyPrefix, createdAt } (no secret)
|
||||
* POST → { accessKey } (mint/rotate; full hk- shown ONCE)
|
||||
* DELETE → { ok: true } (revoke; the old key stops working)
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser, mintUserKey, revokeUserKey, mintConfigured, getUserKey } from '~/lib/server/identity'
|
||||
import { resolveUser, mintUserKey, getUserKey, revokeUserKey, mintConfigured } from '~/lib/server/identity'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const unauthorized = () =>
|
||||
NextResponse.json({ error: 'Sign in to manage API keys.' }, { status: 401 })
|
||||
const msgOf = (e: unknown) => (e instanceof Error ? e.message : String(e))
|
||||
|
||||
const notConfigured = () =>
|
||||
NextResponse.json(
|
||||
{ error: 'API key minting is not configured on this deployment (IAM client unset).' },
|
||||
{ status: 501 },
|
||||
)
|
||||
|
||||
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return unauthorized()
|
||||
// Read the key AUTHORITATIVELY from IAM (not the stale get-account claim, which
|
||||
// returns '' for a freshly-minted key → the "key never listed" bug). No secret
|
||||
// material on GET — only whether a key exists, its public prefix, and when the
|
||||
// key row last changed. Fail-soft to the session claim if IAM is unconfigured.
|
||||
const { accessKey, updatedAt } = mintConfigured()
|
||||
? await getUserKey(user)
|
||||
: { accessKey: user.accessKey, updatedAt: '' }
|
||||
const hasKey = Boolean(accessKey)
|
||||
return NextResponse.json({
|
||||
hasKey,
|
||||
keyPrefix: hasKey ? accessKey.slice(0, 11) : '',
|
||||
createdAt: hasKey ? updatedAt : '',
|
||||
})
|
||||
/** 401 (not signed in) — the honest state the UI shows to sign in. */
|
||||
function unauthorized() {
|
||||
return NextResponse.json({ error: 'Sign in to manage API keys.' }, { status: 401 })
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
/** GET — the user's current key state (existence + public prefix, NEVER the secret). */
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return unauthorized()
|
||||
if (!mintConfigured()) return notConfigured()
|
||||
if (!mintConfigured()) {
|
||||
// Honest, non-leaking: the confidential client isn't wired on this deployment.
|
||||
return NextResponse.json({ error: 'API key management is not configured on this deployment.' }, { status: 501 })
|
||||
}
|
||||
try {
|
||||
const { accessKey, updatedAt } = await getUserKey(user)
|
||||
return NextResponse.json({
|
||||
hasKey: Boolean(accessKey),
|
||||
keyPrefix: accessKey ? accessKey.slice(0, 11) : '',
|
||||
createdAt: updatedAt || '',
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('keys: could not read key state:', msgOf(e))
|
||||
return NextResponse.json({ error: 'Could not read the API key state.' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
||||
/** POST — mint (or rotate) the key. Returns the full `hk-` secret ONCE. */
|
||||
export async function POST(req: NextRequest) {
|
||||
// CSRF: minting mutates (and is billable-adjacent) from the auto-sent cookie —
|
||||
// refuse a cross-origin request before any work.
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return unauthorized()
|
||||
if (!mintConfigured()) {
|
||||
return NextResponse.json({ error: 'API key management is not configured on this deployment.' }, { status: 501 })
|
||||
}
|
||||
try {
|
||||
const accessKey = await mintUserKey(user)
|
||||
return NextResponse.json({ accessKey })
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `Could not mint an API key: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
console.error('keys: could not mint key:', msgOf(e))
|
||||
return NextResponse.json({ error: 'Could not create the API key.' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest): Promise<NextResponse> {
|
||||
/** DELETE — revoke the key (the old key stops working; gateway cache ~5m). */
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return unauthorized()
|
||||
if (!mintConfigured()) return notConfigured()
|
||||
if (!mintConfigured()) {
|
||||
return NextResponse.json({ error: 'API key management is not configured on this deployment.' }, { status: 501 })
|
||||
}
|
||||
try {
|
||||
await revokeUserKey(user)
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `Could not revoke the API key: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
console.error('keys: could not revoke key:', msgOf(e))
|
||||
return NextResponse.json({ error: 'Could not revoke the API key.' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { headers } from 'next/headers'
|
||||
|
||||
import { Provider } from '~/components/Provider'
|
||||
import { ChunkGuard } from '~/components/ChunkGuard'
|
||||
import { BrandTitle } from '~/components/BrandTitle'
|
||||
import { resolveConfig } from '~/config'
|
||||
|
||||
// The document <title> is SSR metadata, so it must reflect the REQUEST host's
|
||||
@@ -25,6 +26,10 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#000000',
|
||||
// Extend the layout into the display cutout / home-indicator area so the
|
||||
// `env(safe-area-inset-*)` values become non-zero on notched devices — the mobile
|
||||
// drawers + chat composer read them to keep content clear of the notch/indicator.
|
||||
viewportFit: 'cover',
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
@@ -32,6 +37,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
<html lang="en" className="t_dark" style={{ backgroundColor: '#000000', colorScheme: 'dark' }} suppressHydrationWarning>
|
||||
<body style={{ margin: 0 }}>
|
||||
<ChunkGuard />
|
||||
<BrandTitle />
|
||||
<Provider>{children}</Provider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
/**
|
||||
* Org onboarding — create the signed-in user's organization, server-side.
|
||||
*
|
||||
* Same-origin (`/onboard`): the browser sends only its first-party session
|
||||
* cookie; this handler resolves the user from that cookie and acts as the
|
||||
* confidential `hanzo-console` client (allowlisted for IAM_ORG_ADMIN_APPS +
|
||||
* IAM_USER_ADMIN_APPS). It creates a customer organization and makes the user
|
||||
* that org's admin (IAM membership = the user's `owner`, so the cloud's
|
||||
* GetEffectiveOrg scopes everything to the new org once the user re-authenticates).
|
||||
*
|
||||
* Fail-closed + safe:
|
||||
* - 401 with no session; 501 when the IAM client is unwired.
|
||||
* - ONLY a zero-org user may onboard (an existing member would have to be MOVED
|
||||
* out of their current org, orphaning its data) — anyone who already belongs
|
||||
* to an org gets 409 and uses the OrgSwitcher instead.
|
||||
* - reserved names (brand/staff + system orgs) are refused (pure policy).
|
||||
*
|
||||
* POST /onboard { name } → create + join org `slugify(name)`.
|
||||
* POST /onboard { personal: true } → create + join a personal `<username>` org.
|
||||
* Returns { org, displayName }; the client then re-auths so the new JWT carries
|
||||
* the new owner.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import {
|
||||
resolveAuthenticatedUser,
|
||||
mintConfigured,
|
||||
getOrganization,
|
||||
createOrganization,
|
||||
moveUserToOrg,
|
||||
} from '~/lib/server/identity'
|
||||
import {
|
||||
isReservedOrg,
|
||||
personalOrgSlug,
|
||||
slugifyOrg,
|
||||
validateOrgName,
|
||||
MAX_ORG_SLUG,
|
||||
MIN_ORG_SLUG,
|
||||
} from '~/lib/server/onboarding'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/** Title-case the base of a username for a personal org's display name. */
|
||||
function humanize(username: string): string {
|
||||
const base = (username.includes('@') ? username.slice(0, username.indexOf('@')) : username)
|
||||
.replace(/[._-]+/g, ' ')
|
||||
.trim()
|
||||
return base ? base.replace(/\b\w/g, (c) => c.toUpperCase()) : 'Personal'
|
||||
}
|
||||
|
||||
/** First free slug at/after `base` (`base`, `base-2`, …); null if all taken. */
|
||||
async function freeSlug(base: string): Promise<string | null> {
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
const candidate = i === 1 ? base : `${base.slice(0, MAX_ORG_SLUG - 3)}-${i}`.replace(/-+/g, '-')
|
||||
if (candidate.length < MIN_ORG_SLUG || isReservedOrg(candidate)) continue
|
||||
if (!(await getOrganization(candidate))) return candidate
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
const user = await resolveAuthenticatedUser(req)
|
||||
if (!user) return NextResponse.json({ error: 'Sign in to create an organization.' }, { status: 401 })
|
||||
if (!mintConfigured()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Organization creation is not configured on this deployment (IAM client unset).' },
|
||||
{ status: 501 },
|
||||
)
|
||||
}
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as { name?: string; personal?: boolean }
|
||||
const personal = body.personal === true
|
||||
|
||||
// Two flows, keyed on whether the user already has a home org:
|
||||
// - FIRST-RUN (no owner): create + MOVE the user in as admin, so their JWT
|
||||
// carries the new owner and the cloud scopes everything to it.
|
||||
// - ADDITIONAL (owner set): create the org but do NOT move the user — a move
|
||||
// changes their IAM `owner` (stripping a global admin's status and orphaning
|
||||
// their current org). They reach the new org via the OrgSwitcher, which
|
||||
// re-scopes X-Org-Id without touching IAM membership. A personal-org request
|
||||
// from someone who already has an org is meaningless, so refuse it.
|
||||
const additional = Boolean(user.owner)
|
||||
if (additional && personal) {
|
||||
return NextResponse.json(
|
||||
{ error: 'You already have an organization. Name the new one explicitly.' },
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
|
||||
let baseSlug: string
|
||||
let displayName: string
|
||||
if (personal) {
|
||||
baseSlug = personalOrgSlug(user.name) || slugifyOrg(user.email)
|
||||
if (!baseSlug || baseSlug.length < MIN_ORG_SLUG || isReservedOrg(baseSlug)) {
|
||||
baseSlug = `org-${slugifyOrg(user.name) || 'workspace'}`
|
||||
}
|
||||
displayName = humanize(user.name || user.email)
|
||||
} else {
|
||||
const v = validateOrgName(body.name ?? '')
|
||||
if (!v.ok) return NextResponse.json({ error: v.error }, { status: 400 })
|
||||
baseSlug = v.slug
|
||||
displayName = (body.name ?? '').trim()
|
||||
}
|
||||
|
||||
// Personal orgs auto-suffix to stay unique; an explicit name that's taken is an
|
||||
// honest conflict the user resolves by choosing another.
|
||||
let slug = baseSlug
|
||||
if (await getOrganization(slug)) {
|
||||
if (!personal) {
|
||||
return NextResponse.json({ error: `“${slug}” is taken. Choose a different name.` }, { status: 409 })
|
||||
}
|
||||
const free = await freeSlug(baseSlug)
|
||||
if (!free) return NextResponse.json({ error: 'Could not find an available name.' }, { status: 409 })
|
||||
slug = free
|
||||
}
|
||||
|
||||
try {
|
||||
await createOrganization({ name: slug, displayName, personal, sourceOwner: user.owner })
|
||||
// First-run only: make the zero-org user this org's admin. For an additional
|
||||
// org we deliberately do NOT move them (preserve their owner/admin + old org).
|
||||
if (!additional) await moveUserToOrg(user, slug)
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `Could not create the organization: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ org: slug, displayName, additional })
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAdminGate } from '~/lib/server/identity'
|
||||
import { orgFor as policyOrgFor } from '~/lib/server/admin-policy'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
@@ -36,7 +37,14 @@ const PLATFORM_URL = (process.env.PLATFORM_URL ?? 'https://platform.hanzo.ai').r
|
||||
const TOKEN = process.env.PAAS_SERVICE_TOKEN ?? ''
|
||||
|
||||
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
|
||||
// Brand-admin gate FIRST — the service token below is control-plane god-mode.
|
||||
// CSRF FIRST — the service token below is control-plane god-mode, so a cross-site
|
||||
// page carrying the admin's auto-sent cookie must never drive a deploy/scale/delete.
|
||||
// Refuse a cross-origin MUTATION before the admin gate or any body read (safe reads
|
||||
// pass). Defense in depth on top of the session cookie's own SameSite attribute.
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
// Brand-admin gate — the service token below is control-plane god-mode.
|
||||
const gate = await getAdminGate(req)
|
||||
if (!gate) {
|
||||
return NextResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||
@@ -47,12 +55,12 @@ async function forward(req: NextRequest, path: string[]): Promise<NextResponse>
|
||||
{ status: 501 },
|
||||
)
|
||||
}
|
||||
// Resolve the authoritative tenant path. Org: the admin policy honors a global
|
||||
// admin's switched org (the X-Org-Id the browser sends = currentOrg()) and pins
|
||||
// Resolve the authoritative tenant path. Org: the admin policy honors a
|
||||
// SuperAdmin's switched org (the X-Org-Id the browser sends = currentOrg()) and pins
|
||||
// a brand admin to their own — so we forward the resolved org, never the raw
|
||||
// claim. Project + environment are sub-scopes within that org, forwarded as-is.
|
||||
const org = policyOrgFor(
|
||||
{ isGlobalAdmin: gate.user.isGlobalAdmin, orgScope: gate.orgScope },
|
||||
{ isSuperAdmin: gate.user.isSuperAdmin, orgScope: gate.orgScope },
|
||||
req.headers.get('X-Org-Id'),
|
||||
)
|
||||
const projectId = req.headers.get('X-Project-Id')
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Design-reference route — renders the ProductLanding kit + the RailwayDeploy pipeline
|
||||
* in its lifecycle states OFFLINE (static status props, no backend, no auth), so the
|
||||
* landing/pipeline design can be reviewed and screenshotted from `next dev` without a
|
||||
* live session. Data-free by construction; not linked from the product nav.
|
||||
*/
|
||||
import { Boxes, DollarSign, FileText, Gauge, Layers, Plus, Search, Sparkles } from '@hanzogui/lucide-icons-2'
|
||||
import { Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
|
||||
import { ProductLanding, apiBaseFromDocs, type LandingMetric, type ProductLandingConfig } from '~/components/products/landing'
|
||||
import { RailwayDeploy } from '~/components/products/paas/RailwayDeploy'
|
||||
import { embeddingsCodeSamples } from '~/components/products/embeddings/logic'
|
||||
|
||||
const metrics: LandingMetric[] = [
|
||||
{ key: 'collections', label: 'Collections', value: 12, format: (n) => Math.round(n).toLocaleString(), icon: <Boxes size={14} opacity={0.6} /> },
|
||||
{ key: 'documents', label: 'Documents indexed', value: 3420, format: (n) => Math.round(n).toLocaleString(), icon: <FileText size={14} opacity={0.6} /> },
|
||||
{ key: 'vectors', label: 'Total vectors', value: 184213, format: (n) => Math.round(n).toLocaleString(), series: [120, 138, 150, 171, 184], deltaPct: 12, icon: <Layers size={14} opacity={0.6} /> },
|
||||
{ key: 'queries', label: 'Queries (7D)', value: 8241, format: (n) => Math.round(n).toLocaleString(), series: [900, 1100, 1050, 1300, 1450], deltaPct: 8, icon: <Search size={14} opacity={0.6} /> },
|
||||
{ key: 'latency', label: 'Avg latency', value: 42, format: (n) => `${Math.round(n)} ms`, series: [55, 50, 47, 44, 42], deltaPct: -6, icon: <Gauge size={14} opacity={0.6} /> },
|
||||
{ key: 'cost', label: 'Cost (7D)', value: null, format: (n) => `$${(n / 100).toFixed(2)}`, icon: <DollarSign size={14} opacity={0.6} />, hint: 'Awaiting metering' },
|
||||
]
|
||||
|
||||
const landingConfig: ProductLandingConfig = {
|
||||
productId: 'embeddings',
|
||||
title: 'Vector embeddings & semantic search',
|
||||
tagline: 'Generate, store, and search embeddings at scale — one API for semantic search and RAG, powered by Zen embedding models.',
|
||||
icon: Boxes,
|
||||
docsProduct: 'embeddings',
|
||||
primary: { label: 'Create collection', icon: <Plus size={16} />, onPress: () => {} },
|
||||
secondary: { label: 'Try search', icon: <Search size={15} />, onPress: () => {} },
|
||||
metrics,
|
||||
samples: embeddingsCodeSamples(apiBaseFromDocs('https://docs.hanzo.ai'), 'zen-embedding'),
|
||||
run: { label: 'Generate in console', icon: <Sparkles size={14} />, onPress: () => {} },
|
||||
actions: [
|
||||
{ label: 'Create collection', icon: <Plus size={15} />, onPress: () => {} },
|
||||
{ label: 'Explore search', icon: <Search size={15} />, onPress: () => {} },
|
||||
{ label: 'Generate embeddings', icon: <Sparkles size={15} />, onPress: () => {} },
|
||||
],
|
||||
}
|
||||
|
||||
function RailCard({ title, status }: { title: string; status: string }) {
|
||||
return (
|
||||
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor" bg="$color2" flex={1} minW={320}>
|
||||
<Text fontSize="$3" fontWeight="700" color="$color12">
|
||||
{title}
|
||||
</Text>
|
||||
<RailwayDeploy status={status} />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RailwayDemoPage() {
|
||||
return (
|
||||
<YStack gap="$6" p="$5" maxW={1180} self="center" width="100%">
|
||||
<Text fontSize="$9" fontWeight="900">RailwayDeploy pipeline</Text>
|
||||
<XStack gap="$4" flexWrap="wrap">
|
||||
<RailCard title="Building (in progress)" status="building" />
|
||||
<RailCard title="Deploying (in progress)" status="deploying" />
|
||||
<RailCard title="Live" status="live" />
|
||||
<RailCard title="Failed" status="error" />
|
||||
</XStack>
|
||||
|
||||
<Text fontSize="$9" fontWeight="900">Embeddings landing (ProductLanding kit)</Text>
|
||||
<ProductLanding config={landingConfig} />
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
+8
-13
@@ -1,18 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import { SignInForm } from '~/components/SignInForm'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
/**
|
||||
* Sign-in route. The whole experience (tenant credential form / admin silent SSO)
|
||||
* lives in the shared `<SignIn/>` component, which `AuthGate` also renders — so a
|
||||
* direct `/signin` load resolves to the form whether it mounts this route or the
|
||||
* dashboard shell (the deploy serves the SPA shell for every path).
|
||||
*/
|
||||
import { SignIn } from '~/components/SignIn'
|
||||
|
||||
export default function SignInPage() {
|
||||
const { account, loading } = useSession()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && account) router.replace('/')
|
||||
}, [loading, account, router])
|
||||
|
||||
return <SignInForm />
|
||||
return <SignIn />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* /system-status — same-origin BFF for the global status badge.
|
||||
*
|
||||
* status.<brand> (Gatus) serves its JSON at `/api/v1/endpoints/statuses` with NO
|
||||
* CORS header, so the browser can't read it cross-origin. This route fetches it
|
||||
* SERVER-SIDE (no CORS) and returns a small overall summary the badge renders
|
||||
* natively — the console's established BFF pattern (no iframe, no third-party
|
||||
* script). Public health data only; no auth, no secrets.
|
||||
*
|
||||
* Fail-soft by construction: any upstream error (down/slow/garbage) returns
|
||||
* `overall: 'unknown'` with HTTP 200, so the badge shows a neutral state and the
|
||||
* shell never breaks.
|
||||
*/
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
import { config } from '~/config'
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
import { summarizeStatuses, type StatusSummary } from '~/lib/status/summary'
|
||||
|
||||
// Health changes minute-to-minute — always evaluate fresh (short CDN cache below).
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const UNKNOWN: StatusSummary = { overall: 'unknown', total: 0, up: 0, down: [] }
|
||||
|
||||
export async function GET() {
|
||||
const statusUrl = config.statusUrl
|
||||
let summary = UNKNOWN
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
`${statusUrl}/api/v1/endpoints/statuses`,
|
||||
{ headers: { accept: 'application/json' }, cache: 'no-store' },
|
||||
{ timeoutMs: 4000 },
|
||||
)
|
||||
if (res.ok) summary = summarizeStatuses(await res.json())
|
||||
} catch {
|
||||
// fail-soft → UNKNOWN
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ ...summary, statusUrl, checkedAt: new Date().toISOString() },
|
||||
{ headers: { 'Cache-Control': 'public, max-age=30' } },
|
||||
)
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* Same-origin READ-ONLY proxy to VictoriaMetrics — the live platform telemetry
|
||||
* store (Prometheus-compatible TSDB, `vmsingle-victoria-metrics-single-server`).
|
||||
*
|
||||
* The browser calls this OWN-origin route (`/telemetry/api/v1/query?query=up`) with
|
||||
* just its first-party session cookie; this handler resolves the caller
|
||||
* (`resolveUser`) and — only for a signed-in user — forwards the READ query to
|
||||
* VictoriaMetrics. It powers Status (real `up{}` service health) and Metrics (real
|
||||
* infra time-series), replacing the empty `/paas/apps` board and the unwired
|
||||
* Metrics overview. VictoriaMetrics has no per-request auth of its own (it is an
|
||||
* internal ClusterIP service), so this route IS the access boundary — hence three
|
||||
* hard limits, all fail-closed:
|
||||
*
|
||||
* 1. Authenticated only — `resolveUser` (401 otherwise). Platform status/metrics
|
||||
* is not tenant-customer data; it is the health of the Hanzo Cloud platform the
|
||||
* user is signed into (a status-page concern), appropriate for any signed-in
|
||||
* console user and strictly READ-ONLY — no service token, far weaker than the
|
||||
* admin `/paas` control plane.
|
||||
* 2. READ-only — GET only, and only the allow-listed VictoriaMetrics query
|
||||
* endpoints (`allowTelemetrySurface`): `/api/v1/query`, `/query_range`,
|
||||
* `/series`, `/labels`, `/label/<name>/values`, `/status/tsdb`, `/metadata`.
|
||||
* Never `/api/v1/write`, `/import`, `/-/reload`, or any admin/mutating path.
|
||||
* 3. Traversal-hardened — `pathIsClean` rejects `.`/`..`/`%XX`/`;` segments and the
|
||||
* forward re-validates the WHATWG-normalized path, exactly like the bearer
|
||||
* proxies.
|
||||
*
|
||||
* Honest 501 when `VM_URL` is unset, so the UI shows a truthful "telemetry not
|
||||
* configured" state — never fabricated metrics.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import { pathIsClean } from '~/lib/server/bearer-proxy'
|
||||
import { allowTelemetrySurface } 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(/^\/+/, '')
|
||||
const msgOf = (e: unknown) => (e instanceof Error ? e.message : String(e))
|
||||
|
||||
/** VictoriaMetrics single-node read API. In-cluster ClusterIP :8428 (headless).
|
||||
* Override with VM_URL. `|| default` (not `??`) so a blank/whitespace env still
|
||||
* resolves the in-cluster service (the same env-drift guard the /vm proxy uses). */
|
||||
const VM_URL = trimR(
|
||||
process.env.VM_URL?.trim() || 'http://vmsingle-victoria-metrics-single-server.hanzo.svc:8428',
|
||||
)
|
||||
|
||||
const json = (body: unknown, status: number) => NextResponse.json(body, { status })
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx): Promise<NextResponse> {
|
||||
const rawPath = trimL((await ctx.params).path.join('/')).replace(/\/+$/, '')
|
||||
|
||||
// Traversal + least-privilege on the RAW path (literal `.`/`..`/`%XX`/`;` rejected).
|
||||
if (!pathIsClean(rawPath) || !allowTelemetrySurface(rawPath)) {
|
||||
return json({ status: 'error', errorType: 'not_allowed', error: 'Not a telemetry read endpoint.' }, 404)
|
||||
}
|
||||
|
||||
if (!process.env.VM_URL?.trim() && !VM_URL) {
|
||||
return json(
|
||||
{ status: 'error', errorType: 'not_configured', error: 'Telemetry store is not configured (VM_URL missing).' },
|
||||
501,
|
||||
)
|
||||
}
|
||||
|
||||
// Authenticated only — the query surface is the access boundary (VM has no auth).
|
||||
const user = await resolveUser(req)
|
||||
if (!user) {
|
||||
return json({ status: 'error', errorType: 'unauthenticated', error: 'Sign in to view platform telemetry.' }, 401)
|
||||
}
|
||||
|
||||
// Re-validate the WHATWG-normalized destination (undici resolves %2e/double-encoded
|
||||
// dot-segments a raw check can't see) — validate AND fetch the exact same URL.
|
||||
let dest: URL
|
||||
try {
|
||||
dest = new URL(`${VM_URL}/${rawPath}${req.nextUrl.search}`)
|
||||
} catch {
|
||||
return json({ status: 'error', errorType: 'not_allowed', error: 'Bad telemetry path.' }, 404)
|
||||
}
|
||||
const normPath = trimL(dest.pathname).replace(/\/+$/, '')
|
||||
if (!pathIsClean(normPath) || !allowTelemetrySurface(normPath)) {
|
||||
return json({ status: 'error', errorType: 'not_allowed', error: 'Not a telemetry read endpoint.' }, 404)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetchWithTimeout(dest, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
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-cache, no-transform',
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('telemetry-proxy: VictoriaMetrics unreachable:', msgOf(e))
|
||||
return json({ status: 'error', errorType: 'upstream_error', error: 'Telemetry store is unavailable.' }, 502)
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,24 @@
|
||||
*
|
||||
* The console's Training page calls its OWN origin (`/training/...`) with just the
|
||||
* first-party session cookie; this server handler resolves the signed-in user from
|
||||
* that cookie and forwards to the cloud backend's `/v1/...` surface, passing the
|
||||
* cookie through (the proven `get-account` server-to-server pattern in
|
||||
* lib/server/identity.ts) plus the active `X-Org-Id`. Training is a TENANT action —
|
||||
* any signed-in org user may run it — so this is user-scoped (resolveUser), NOT the
|
||||
* control-plane admin gate the `/paas` proxy uses. The cloud backend scopes by org
|
||||
* (GetEffectiveOrg / the X-Org-Id the plain-REST train sub-service requires), so a
|
||||
* caller can only ever touch their own org's jobs. `POST /v1/train/jobs` is
|
||||
* billing-gated by the live ResourceMeter and returns 402 on an unfunded org — that
|
||||
* status flows straight back so the UI can surface it honestly.
|
||||
* that cookie, mints a SHORT-LIVED, user-bound IAM Bearer (`adminBearer` — the ONE
|
||||
* per-user cache shared with the `/v1` bearer proxy), and forwards to the cloud
|
||||
* backend's `/v1/...` surface with `Authorization: Bearer <token>` + the active
|
||||
* `X-Org-Id`. Training is a TENANT action — any signed-in org user may run it — so
|
||||
* this is user-scoped (resolveUser), NOT the control-plane admin gate the `/paas`
|
||||
* proxy uses. The cloud backend resolves the org from the token's `owner` claim (and
|
||||
* the X-Org-Id the plain-REST train sub-service reads), so a caller can only ever
|
||||
* touch their own org's jobs. `POST /v1/train/jobs` is billing-gated by the live
|
||||
* ResourceMeter and returns 402 on an unfunded org — that status flows straight back
|
||||
* so the UI can surface it honestly.
|
||||
*
|
||||
* Why a Bearer and NOT the cookie (the fix for the "Not enabled" 403): cloud-api's
|
||||
* `/v1/train/*` authorizes on a VALIDATED JWT principal and returns 403 "no validated
|
||||
* principal" for a cookie-only call — the raw casibase session cookie is NOT a
|
||||
* principal it accepts (only the sanitizer's cookie-token names or a Bearer). Minting
|
||||
* the same user-bound token the `/v1` proxy uses is the ONE way a signed-in tenant
|
||||
* reaches the train surface; the cookie is deliberately dropped upstream (it can't
|
||||
* authenticate, and a cookie + JWT together risks the public-gateway 431).
|
||||
*
|
||||
* Least privilege: only the explicit ML/training sub-paths are forwarded; anything
|
||||
* else 404s, so this is not a general backend tunnel. No secret ever reaches the
|
||||
@@ -21,12 +30,15 @@
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import { resolveUser, adminBearer } from '~/lib/server/identity'
|
||||
import { orgFor } from '~/lib/server/admin-policy'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
const msgOf = (e: unknown) => (e instanceof Error ? e.message : String(e))
|
||||
/** Cloud `/v1` backend (hanzoai/ai) — same target lib/server/identity.ts resolves. */
|
||||
const CLOUD_API_URL = trim(process.env.CLOUD_API_URL ?? 'http://cloud.hanzo.svc.cluster.local:8000')
|
||||
|
||||
@@ -56,6 +68,11 @@ async function forward(req: NextRequest, path: string[]): Promise<NextResponse>
|
||||
return NextResponse.json({ status: 'error', msg: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// CSRF: `POST /train/jobs` mutates (and bills) from the auto-sent cookie — refuse a
|
||||
// cross-origin one before any work (safe reads pass).
|
||||
const csrf = csrfRefusal(req, 'casibase')
|
||||
if (csrf) return csrf
|
||||
|
||||
const user = await resolveUser(req)
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
@@ -64,15 +81,35 @@ async function forward(req: NextRequest, path: string[]): Promise<NextResponse>
|
||||
)
|
||||
}
|
||||
|
||||
const cookie = req.headers.get('cookie') ?? ''
|
||||
// Mint a short-lived, user-bound Bearer (the SAME per-user cache the `/v1`
|
||||
// proxy uses). cloud-api's `/v1/train/*` 403s a cookie-only call ("no validated
|
||||
// principal"); a Bearer is the one credential it accepts. Fail CLOSED with 502 if
|
||||
// the token can't be minted — never fall through to an unauthenticated forward.
|
||||
let bearer: string
|
||||
try {
|
||||
bearer = await adminBearer(user)
|
||||
} catch (e) {
|
||||
// Redact — the exception carries the internal IAM host/port. Log server-side only.
|
||||
console.error('training-proxy: could not mint user bearer:', msgOf(e))
|
||||
return NextResponse.json(
|
||||
{ status: 'error', msg: 'Could not authorize the request.' },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
const url = `${CLOUD_API_URL}/v1/${rel}${req.nextUrl.search}`
|
||||
const headers: Record<string, string> = {
|
||||
cookie,
|
||||
Authorization: `Bearer ${bearer}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
// The backend re-validates org from the session; forward the active scope so a
|
||||
// global admin's switched org is honored and a brand admin is pinned to theirs.
|
||||
'X-Org-Id': req.headers.get('X-Org-Id') ?? user.owner,
|
||||
// Org is SERVER-RESOLVED, not the raw browser header: a SuperAdmin's switched
|
||||
// org (?/X-Org-Id) is honored, a non-SuperAdmin caller is PINNED to their own — so a
|
||||
// brand admin can't drive another tenant's training jobs even if the backend
|
||||
// trusted the forwarded header. For a non-SuperAdmin caller this equals the token
|
||||
// owner (the Bearer's own claim), so header and token agree. Matches the /paas +
|
||||
// /admin/kms orgFor pin. The raw session cookie is NOT forwarded (cloud-api can't
|
||||
// validate it as a principal, and cookie + JWT together risks the gateway 431).
|
||||
'X-Org-Id': orgFor({ isSuperAdmin: user.isSuperAdmin, orgScope: user.owner }, req.headers.get('X-Org-Id')),
|
||||
}
|
||||
const projectId = req.headers.get('X-Project-Id')
|
||||
const environment = req.headers.get('X-Environment')
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Same-origin user-bearer proxy at the console's OWN `/v1/*` — the ONE prefix-free
|
||||
* path the browser uses to reach the unified cloud-api surfaces that authorize on a
|
||||
* Bearer JWT. CTO contract: every cloud API path is `/v1/`-rooted, ZERO prefix (no
|
||||
* `/cloud/`, no `/api/`).
|
||||
*
|
||||
* The browser holds NO credential: it calls `<origin>/v1/<head>/...` with just its
|
||||
* first-party session cookie. This catch-all resolves WHO the caller is from that
|
||||
* cookie (`resolveUser`), mints a SHORT-LIVED, user-bound IAM token (shared per-user
|
||||
* cache in identity.ts — ONE cache across every proxy), and forwards to cloud-api's
|
||||
* `/v1/*` with `Authorization: Bearer <token>`. The backend resolves the ORG from the
|
||||
* token's `owner` claim, so tenancy is server-authoritative — a browser can never
|
||||
* supply its own org — and the raw session cookie NEVER reaches cloud-api (no
|
||||
* cookie-CSRF surface upstream). This is the EXACT transport the `/ai` proxy proved
|
||||
* live; every service proxy shares the ONE `forwardWithUserBearer` implementation.
|
||||
*
|
||||
* DISPATCH: the AI (`models`/`chat`/…), admin-aggregate (`/v1/admin/*`), visor
|
||||
* (`regions`/`sizes`/`gpu-sizes`), billing (`/v1/billing/*`) and commerce
|
||||
* (`/v1/commerce/*`) heads are routed to their OWN backends by `next.config.mjs`
|
||||
* `beforeFiles` rewrites BEFORE they reach this catch-all — so this handler owns
|
||||
* exactly the cloud-api `/v1/<head>` surface.
|
||||
*
|
||||
* Least privilege: only the allow-listed cloud HEADS are reachable
|
||||
* (`allowCloudSurface`); `v1/iam/*`, `v1/admin/*`, etc. 404 here — this is not a
|
||||
* general cloud-api tunnel. The mutating same-origin (CSRF) guard, the path-traversal
|
||||
* rejection, and the bearer mint all live in `forwardWithUserBearer`.
|
||||
*/
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
|
||||
import { allowCloudSurface } from '~/lib/server/proxy-allow'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** The unified cloud backend (hanzoai/cloud). In-cluster ClusterIP — public egress is CF-403'd.
|
||||
* `|| default` (not `??`) so an env accidentally reconciled to an EMPTY string still falls
|
||||
* back to the in-cluster service (a blank CLOUD_API_URL would otherwise break every cloud page). */
|
||||
const CLOUD_API_URL = trim(process.env.CLOUD_API_URL?.trim() || 'http://cloud-api.hanzo.svc.cluster.local:8000')
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
function handle(req: NextRequest, ctx: Ctx) {
|
||||
return (async () => {
|
||||
// The `[...path]` catch-all sits UNDER `/v1`, so it captures the segments AFTER
|
||||
// `/v1`. Re-prepend the `v1/` root so the allow-list (matches `v1/<head>`) and the
|
||||
// upstream URL (`CLOUD_API_URL/v1/<head>/...`) both see the cloud-api contract path.
|
||||
const path = `v1/${(await ctx.params).path.join('/')}`
|
||||
return forwardWithUserBearer(req, {
|
||||
target: CLOUD_API_URL,
|
||||
path,
|
||||
allow: allowCloudSurface,
|
||||
// Org is authoritative (Bearer owner). Do NOT forward the browser-controlled
|
||||
// X-Project-Id/X-Environment sub-scopes — the data/serverless resources are
|
||||
// org-keyed, and forwarding an unvalidated project id is an attack surface
|
||||
// (RED MEDIUM). A project-scoped feature must validate membership first.
|
||||
unauthorizedMessage: 'Sign in to use Hanzo Cloud.',
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function PUT(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* AI-account credential store — the per-user connect/list/disconnect route.
|
||||
*
|
||||
* GET v1/accounts → { providers: masked[] } (existence + mode, NO secret)
|
||||
* POST v1/accounts/:providerId → seal a pasted API key / OAuth token / cookie header
|
||||
* DELETE v1/accounts/:providerId → drop the sealed credential
|
||||
*
|
||||
* The secret is sealed into an httpOnly cookie server-side (`lib/server/ai-accounts`)
|
||||
* and NEVER echoed back or logged. Every request is session-gated (`resolveUser`); the
|
||||
* two mutating verbs are CSRF-guarded (auto-sent cookie → refuse cross-origin first).
|
||||
*
|
||||
* Namespaced under `/v1/ai-accounts/` so the data plane never shadows the UI tab URLs
|
||||
* (`/ai-accounts`, `/ai-accounts/accounts`) — a route handler always wins over the
|
||||
* catch-all page, so the two live in disjoint path space (same rule as `/v1/billing/`).
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { applyCookies } from '~/lib/server/session'
|
||||
import {
|
||||
readAccounts,
|
||||
accountsCookie,
|
||||
maskAccounts,
|
||||
type AiAccountsStore,
|
||||
type StoredCredential,
|
||||
} from '~/lib/server/ai-accounts'
|
||||
import { isAiProvider, type ConnectMode } from '~/lib/products/ai-accounts'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
const MODES: ConnectMode[] = ['api', 'oauth', 'web']
|
||||
const unauthorized = () => NextResponse.json({ error: 'Sign in to manage AI accounts.' }, { status: 401 })
|
||||
const notFound = () => NextResponse.json({ error: 'Not found.' }, { status: 404 })
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return unauthorized()
|
||||
const seg = (await ctx.params).path
|
||||
if (seg[0] !== 'accounts' || seg.length !== 1) return notFound()
|
||||
return NextResponse.json({ providers: maskAccounts(readAccounts(req)) })
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return unauthorized()
|
||||
|
||||
const seg = (await ctx.params).path
|
||||
const id = seg[1]
|
||||
if (seg[0] !== 'accounts' || !id) return notFound()
|
||||
if (!isAiProvider(id)) return NextResponse.json({ error: 'Unknown provider.' }, { status: 400 })
|
||||
|
||||
const body = (await req.json().catch(() => null)) as { mode?: string; secret?: string; baseUrl?: string } | null
|
||||
const mode = body?.mode as ConnectMode
|
||||
const secret = typeof body?.secret === 'string' ? body.secret.trim() : ''
|
||||
if (!MODES.includes(mode) || !secret) {
|
||||
return NextResponse.json({ error: 'A link mode and a non-empty credential are required.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const cred: StoredCredential = {
|
||||
mode,
|
||||
secret, // sealed at rest by accountsCookie; never logged.
|
||||
baseUrl: body?.baseUrl?.trim() || undefined,
|
||||
connectedAt: new Date().toISOString(),
|
||||
}
|
||||
const next: AiAccountsStore = { ...readAccounts(req), [id]: cred }
|
||||
return applyCookies(NextResponse.json({ providers: maskAccounts(next) }), [accountsCookie(next)])
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return unauthorized()
|
||||
|
||||
const seg = (await ctx.params).path
|
||||
const id = seg[1]
|
||||
if (seg[0] !== 'accounts' || !id) return notFound()
|
||||
|
||||
const store = readAccounts(req)
|
||||
delete store[id]
|
||||
return applyCookies(NextResponse.json({ providers: maskAccounts(store) }), [accountsCookie(store)])
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* AI-accounts ORG routing defaults (READ-ONLY) — the server-driven default the
|
||||
* admin set for the whole org, surfaced so the Routing tab can show
|
||||
* "Organization default: On/Off" and fall back to it when the user has no explicit
|
||||
* override.
|
||||
*
|
||||
* GET v1/routing-defaults → cloud-api `{ status, data: { auto_routing_active,
|
||||
* default_session_routing } }` (streamed through verbatim)
|
||||
*
|
||||
* This is a pure READ. It forwards to cloud-api's org-scoped
|
||||
* `GET /v1/get-routing-defaults` with the caller's short-lived user bearer (org is
|
||||
* the token owner — never browser-supplied), the EXACT same auth pattern as the
|
||||
* `/v1` proxy. It deliberately does NOT touch the org-settings WRITE path: a
|
||||
* customer surface has no clean authenticated path to mint the global-admin write,
|
||||
* and forging one is a confused-deputy escalation (see the long note in
|
||||
* `settings/route.ts`). Reads are fine; writes stay out.
|
||||
*
|
||||
* FAIL-SOFT: an older cloud-api with no such endpoint 404s, which streams straight
|
||||
* through as a 404 the client treats as "no org default" — the tab then honors the
|
||||
* cookie preference alone, exactly as before this endpoint existed.
|
||||
*
|
||||
* A static route, so it wins over the sibling `[...path]` catch-all for this exact
|
||||
* path (same rule as `/v1/ai-accounts/usage` and `/v1/ai-accounts/settings`).
|
||||
*/
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** The unified cloud backend (hanzoai/cloud) — same in-cluster target as the `/v1` proxy. */
|
||||
const CLOUD_API_URL = trim(process.env.CLOUD_API_URL?.trim() || 'http://cloud-api.hanzo.svc.cluster.local:8000')
|
||||
|
||||
const UPSTREAM_PATH = 'v1/get-routing-defaults'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return forwardWithUserBearer(req, {
|
||||
target: CLOUD_API_URL,
|
||||
path: UPSTREAM_PATH,
|
||||
allow: (p) => p === UPSTREAM_PATH,
|
||||
errorShape: 'casibase',
|
||||
unauthorizedMessage: 'Sign in to read organization routing defaults.',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* AI-accounts NON-SECRET preferences — the org/user settings route.
|
||||
*
|
||||
* GET v1/settings → { settings: { routingEnabled } }
|
||||
* PUT v1/settings → persist { routingEnabled } (sealed), returns the new settings
|
||||
*
|
||||
* The one preference today is `routingEnabled` — the org's `model: "auto"` smart-
|
||||
* routing default that Hanzo surfaces read. Persisted with the SAME sealed-cookie
|
||||
* store as the credential blob (`lib/server/ai-accounts`); there is no secret here,
|
||||
* so the seal is for integrity, not confidentiality. Session-gated; the mutating
|
||||
* verb is CSRF-guarded (auto-sent cookie → refuse cross-origin first).
|
||||
*
|
||||
* A static route, so it wins over the sibling `[...path]` catch-all for this exact
|
||||
* path (same rule as `/v1/ai-accounts/usage`).
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { applyCookies } from '~/lib/server/session'
|
||||
import { readSettings, settingsCookie, normalizeSettings } from '~/lib/server/ai-accounts'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const unauthorized = () => NextResponse.json({ error: 'Sign in to manage AI settings.' }, { status: 401 })
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return unauthorized()
|
||||
return NextResponse.json({ settings: readSettings(req) })
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return unauthorized()
|
||||
|
||||
const body = (await req.json().catch(() => null)) as { routingEnabled?: unknown } | null
|
||||
if (typeof body?.routingEnabled !== 'boolean') {
|
||||
return NextResponse.json({ error: 'routingEnabled (boolean) is required.' }, { status: 400 })
|
||||
}
|
||||
const settings = normalizeSettings(body)
|
||||
|
||||
// Cookie-only, deliberately. cloud-api now enforces per-org auto-routing via
|
||||
// `OrgSettings.AutoRouting` (hanzoai/ai), toggled through
|
||||
// `POST /v1/update-org-settings`. But that endpoint is `RequireGlobalAdmin`-gated
|
||||
// (like every /v1/*-model-route admin route) and is NOT gateway-exposed — it is
|
||||
// reachable only on the direct api.cloud.hanzo.ai ingress with a global-admin
|
||||
// session. This Routing tab is a CUSTOMER surface: `resolveUser` here is a tenant
|
||||
// user whose minted `hanzo-console` bearer is NOT global-admin, and the console's
|
||||
// only admin proxy (`/admin/aggregate`) fail-closed-403s a non-global-admin. So
|
||||
// there is NO clean authenticated path for a customer to write cloud-side
|
||||
// OrgSettings, and forging one (a console service token asserting admin authority
|
||||
// for a client-supplied org) would be a confused-deputy privilege escalation —
|
||||
// refused per "do not bodge auth". The toggle therefore stays the sealed-cookie
|
||||
// org preference the Hanzo surfaces read; API `model:"auto"` still honors the
|
||||
// GLOBAL router flag. To make this write real, a global-admin must set the org's
|
||||
// AutoRouting via the admin console (the OrgSettings CRUD), OR cloud-api must add a
|
||||
// self-serve, org-scoped (owner-from-JWT, non-global-admin) auto-routing toggle the
|
||||
// `/ai` proxy can reach — at which point wire that call in here.
|
||||
return applyCookies(NextResponse.json({ settings }), [settingsCookie(settings)])
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Unified AI-account usage — the Overview data plane.
|
||||
*
|
||||
* For each CONNECTED provider it runs the headless `@hanzo/usage` pipeline
|
||||
* server-side over the Node host, decrypting the sealed credential into the
|
||||
* usage-engine settings (`settingsFor`) only in memory for the fetch. It ALSO
|
||||
* merges the org's own Hanzo lane — the REAL commerce usage ledger overview,
|
||||
* fetched through the tested `/billing` proxy (the SAME source the Billing/Overview
|
||||
* dashboards read), so `/ai-accounts` shows Hanzo + every linked provider side by side.
|
||||
*
|
||||
* A static route, so it wins over the sibling `[...path]` catch-all for this exact
|
||||
* path. Session-gated; a secret is never logged or returned.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { nodeHost } from '@hanzo/usage/node'
|
||||
import { runPipeline } from '@hanzo/usage'
|
||||
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import { readAccounts, descriptorFor, settingsFor } from '~/lib/server/ai-accounts'
|
||||
import { forwardBilling } from '~/lib/server/billing-proxy'
|
||||
import { normalizeUsageRecords } from '~/lib/api/aimetrics'
|
||||
import { buildCloudUsageOverview } from '~/lib/api/usage-adapter'
|
||||
import type { CloudUsageOverview } from '~/lib/api/usage'
|
||||
import type { ProviderUsage } from '~/lib/api/ai-accounts'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const msgOf = (e: unknown): string => (e instanceof Error ? e.message : 'Fetch failed.')
|
||||
|
||||
/** The org's own Hanzo Cloud lane: the real commerce ledger overview, null on any miss. */
|
||||
async function hanzoLane(req: NextRequest): Promise<CloudUsageOverview | null> {
|
||||
try {
|
||||
const res = await forwardBilling(req, ['usage'])
|
||||
if (!res.ok) return null
|
||||
const records = normalizeUsageRecords(await res.json())
|
||||
return buildCloudUsageOverview(records, {
|
||||
range: '30d',
|
||||
topModels: 6,
|
||||
activityType: 'all',
|
||||
activityLimit: 8,
|
||||
activityOffset: 0,
|
||||
now: Date.now(),
|
||||
product: null,
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Run the usage pipeline for one connected provider. */
|
||||
async function providerUsage(id: string, cred: ReturnType<typeof readAccounts>[string]): Promise<ProviderUsage> {
|
||||
const descriptor = descriptorFor(id)
|
||||
if (!descriptor) return { id, ok: false, error: 'Unknown provider.' }
|
||||
const { mode, settings } = settingsFor(cred)
|
||||
try {
|
||||
const outcome = await runPipeline(descriptor, { host: nodeHost, sourceMode: mode, settings })
|
||||
if (outcome.result) return { id, ok: true, usage: outcome.result.usage }
|
||||
return { id, ok: false, error: msgOf(outcome.error) }
|
||||
} catch (e) {
|
||||
return { id, ok: false, error: msgOf(e) }
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return NextResponse.json({ error: 'Sign in to view usage.' }, { status: 401 })
|
||||
|
||||
const store = readAccounts(req)
|
||||
const [providers, hanzo] = await Promise.all([
|
||||
Promise.all(Object.entries(store).map(([id, cred]) => providerUsage(id, cred))),
|
||||
hanzoLane(req),
|
||||
])
|
||||
|
||||
return NextResponse.json({ providers, hanzo })
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Per-tenant billing DATA proxy → commerce. Thin route wrapper: the trust boundary,
|
||||
* tenant scoping, CSRF guard, and binary (PDF) passthrough all live in the tested
|
||||
* `~/lib/server/billing-proxy` (`forwardBilling`) — this file only maps the HTTP verbs.
|
||||
*
|
||||
* Rooted at `/v1/billing/` (the /v1-first law) — this handler lives at
|
||||
* `app/v1/billing/[...path]`, MORE SPECIFIC than the cloud BFF catch-all
|
||||
* `app/v1/[...path]`, so `/v1/billing/*` (data) resolves here while `/v1/<other>/*`
|
||||
* falls through to the catch-all. And `/v1/billing/*` (data) never collides with the
|
||||
* billing UI tab URLs (`/billing/reports`, `/billing/invoices`, …) — they differ at
|
||||
* the FIRST path segment, so the tab slugs fall through to the SPA.
|
||||
*
|
||||
* Verbs: GET (reads: balance/usage/invoices/subscriptions/payment-methods, and the
|
||||
* per-invoice PDF), POST (writes: top-up, spend-alerts, save-a-method, cancel/
|
||||
* reactivate a subscription), PATCH (edit a budget/spend-alert), DELETE (detach a
|
||||
* saved payment method, remove a budget). Each is scoped to the caller's OWN org
|
||||
* server-side; a mutating verb is CSRF-guarded (`forwardBilling`).
|
||||
*/
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { forwardBilling } from '~/lib/server/billing-proxy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return forwardBilling(req, (await ctx.params).path)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return forwardBilling(req, (await ctx.params).path)
|
||||
}
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
return forwardBilling(req, (await ctx.params).path)
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return forwardBilling(req, (await ctx.params).path)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* Same-origin user-bearer proxy to commerce (`commerce.hanzo.svc`) — the store /
|
||||
* merchant admin surface (products / orders / customers / collections / variants /
|
||||
* discounts / store settings). The browser calls this OWN-origin route
|
||||
* (`/commerce/v1/...`) with just its session cookie; `forwardWithUserBearer` resolves
|
||||
* (`/v1/commerce/...`) with just its session cookie; `forwardWithUserBearer` resolves
|
||||
* the user, mints a short-lived user-bound IAM token, and forwards to commerce with
|
||||
* that Bearer. Commerce's EdgeAuth validates the JWT and resolves the org from its
|
||||
* `owner` claim (`middleware.TokenRequired` fast-paths IAM auth), so the store is
|
||||
@@ -33,7 +33,11 @@ type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
function handle(req: NextRequest, ctx: Ctx) {
|
||||
return (async () => {
|
||||
const path = (await ctx.params).path.join('/')
|
||||
// This handler lives under `app/v1/commerce/[...path]`, so the catch-all captures
|
||||
// ONLY the sub-path after `/v1/commerce/` (e.g. `product`). Commerce serves its REST
|
||||
// models under `/v1/<model>`, so re-root the upstream path at `v1/` — the same path
|
||||
// `allowCommerceSurface` (v1Head) and `forwardWithUserBearer` see (`v1/product`).
|
||||
const path = `v1/${(await ctx.params).path.join('/')}`
|
||||
return forwardWithUserBearer(req, {
|
||||
target: COMMERCE_URL,
|
||||
path,
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Per-user proxy to the Lux DEX indexer's `dex` subgraph — the Lux Economy /
|
||||
* Markets board's ONE transport. The browser calls console2's OWN origin
|
||||
* (`/v1/economy/overview`) with just the session cookie; this handler resolves the
|
||||
* caller, resolves the BRAND from the request host, and POSTs a FIXED, allowlisted
|
||||
* GraphQL query to the in-cluster graphd per brand-scoped network, returning the
|
||||
* NORMALIZED markets + fills + day-data. No graph host or GraphQL query ever reaches
|
||||
* the browser, and the browser can never compose one.
|
||||
*
|
||||
* Security (mirrors app/nodes/[...path]/route.ts):
|
||||
* - Session-gated: an unauthenticated caller gets 401.
|
||||
* - Org/brand-aware: the network set is scoped by `nodeNetworksForBrand(brand)`,
|
||||
* brand resolved from the host — cloud.lux.cloud sees only Lux networks.
|
||||
* - Least privilege: the ONLY path is `overview`, and the ONLY GraphQL query is
|
||||
* the fixed markets+fills+dayData read below — this is not a general GraphQL
|
||||
* tunnel (no client-supplied query, no mutations, no arbitrary entity).
|
||||
*
|
||||
* Honest by construction: an unset/unreachable graph host yields a `not-reporting`
|
||||
* snapshot with the real error — never fabricated markets. The native DEX is a CLOB,
|
||||
* so the query asks only for the fields the `dex` subgraph really exposes.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { brandFromHost } from '~/config'
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import { nodeNetworksForBrand, type NodeNetworkId } from '~/lib/products/brand-scope'
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
import {
|
||||
normalizeMarkets,
|
||||
normalizeTrades,
|
||||
normalizeDayData,
|
||||
type EconomySnapshot,
|
||||
type RawMarket,
|
||||
type RawFill,
|
||||
type RawMarketDayData,
|
||||
} from '~/lib/api/economy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
|
||||
/**
|
||||
* The `dex` subgraph GraphQL endpoint per network — the ONLY endpoint this proxy
|
||||
* will query. graphd serves the DEX subgraph at `<prefix>/graphql` (default prefix
|
||||
* `/v1/graph/cchain/dex`); each is overridable per-deploy. An unset/unreachable host
|
||||
* yields an honest `not-reporting` snapshot — never fake markets. (Cross-cluster
|
||||
* reach depends on network policy; when unreachable the board shows honest empty.)
|
||||
*/
|
||||
const GRAPH_HOSTS: Partial<Record<NodeNetworkId, string>> = {
|
||||
'lux-mainnet': trim(process.env.DEX_GRAPHQL_MAINNET ?? 'http://graph.lux-mainnet.svc:8080/v1/graph/cchain/dex/graphql'),
|
||||
'lux-testnet': trim(process.env.DEX_GRAPHQL_TESTNET ?? 'http://graph.lux-testnet.svc:8080/v1/graph/cchain/dex/graphql'),
|
||||
'lux-devnet': trim(process.env.DEX_GRAPHQL_DEVNET ?? 'http://graph.lux-devnet.svc:8080/v1/graph/cchain/dex/graphql'),
|
||||
}
|
||||
|
||||
/** Per-query timeout (ms). */
|
||||
const TIMEOUT_MS = Number(process.env.ECONOMY_TIMEOUT_MS ?? 8000)
|
||||
|
||||
/**
|
||||
* The ONE fixed GraphQL query — markets (book summary + accrued 24h aggregates),
|
||||
* recent fills (the trade feed), and day-data (the historical series, empty until a
|
||||
* MarketDayData producer emits). Only fields the `dex` subgraph really exposes.
|
||||
*/
|
||||
const QUERY = `query LuxEconomy {
|
||||
markets(first: 100) {
|
||||
id
|
||||
symbol
|
||||
baseToken
|
||||
quoteToken
|
||||
assetsBound
|
||||
openOrders
|
||||
remaining
|
||||
bestBid
|
||||
bestAsk
|
||||
volume24h
|
||||
tradeCount
|
||||
lastPrice
|
||||
feeTier
|
||||
}
|
||||
fills(first: 40) {
|
||||
id
|
||||
symbol
|
||||
price
|
||||
size
|
||||
side
|
||||
timestamp
|
||||
}
|
||||
marketDayDatas(first: 90) {
|
||||
id
|
||||
date
|
||||
symbol
|
||||
volumeUSD
|
||||
feesUSD
|
||||
tvlUSD
|
||||
}
|
||||
}`
|
||||
|
||||
interface GraphResp {
|
||||
data?: { markets?: RawMarket[]; fills?: RawFill[]; marketDayDatas?: RawMarketDayData[] }
|
||||
errors?: { message?: string }[]
|
||||
}
|
||||
|
||||
/** Query ONE network's `dex` subgraph → normalized snapshot. Honest not-reporting on failure. */
|
||||
async function probe(net: NodeNetworkId): Promise<EconomySnapshot> {
|
||||
const base: EconomySnapshot = { network: net, status: 'not-reporting', markets: [], trades: [], dayData: [] }
|
||||
const host = GRAPH_HOSTS[net]
|
||||
if (!host) {
|
||||
base.error = 'no DEX GraphQL host configured for this network'
|
||||
return base
|
||||
}
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
host,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
body: JSON.stringify({ query: QUERY }),
|
||||
cache: 'no-store',
|
||||
},
|
||||
{ timeoutMs: TIMEOUT_MS },
|
||||
)
|
||||
if (!res.ok) {
|
||||
base.error = `graphql ${res.status}`
|
||||
return base
|
||||
}
|
||||
const json = (await res.json()) as GraphResp
|
||||
if (json?.errors?.length) {
|
||||
base.error = json.errors[0]?.message ?? 'graphql error'
|
||||
return base
|
||||
}
|
||||
const d = json?.data ?? {}
|
||||
return {
|
||||
network: net,
|
||||
status: 'reporting',
|
||||
markets: normalizeMarkets(d.markets),
|
||||
trades: normalizeTrades(d.fills),
|
||||
dayData: normalizeDayData(d.marketDayDatas),
|
||||
}
|
||||
} catch (e) {
|
||||
base.error = e instanceof Error ? e.message : String(e)
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
|
||||
// This handler lives at `app/v1/economy/[...path]`, so the catch-all captures the
|
||||
// sub-path after `/v1/economy/`.
|
||||
if (path.join('/') !== 'overview') {
|
||||
return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const user = await resolveUser(req)
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Sign in to view the market economy.' }, { status: 401 })
|
||||
}
|
||||
|
||||
const brand = brandFromHost(req.headers.get('host'))
|
||||
let networks = nodeNetworksForBrand(brand)
|
||||
const only = req.nextUrl.searchParams.get('network') as NodeNetworkId | null
|
||||
if (only) networks = networks.filter((n) => n === only)
|
||||
|
||||
// Query the brand's networks and return the FIRST that reports markets (the live
|
||||
// economy), else the first reporting network, else the first (honest not-reporting).
|
||||
const snaps = await Promise.all(networks.map(probe))
|
||||
const withMarkets = snaps.find((s) => s.status === 'reporting' && s.markets.length > 0)
|
||||
const reporting = snaps.find((s) => s.status === 'reporting')
|
||||
const chosen = withMarkets ?? reporting ?? snaps[0]
|
||||
if (!chosen) {
|
||||
return NextResponse.json({ network: null, status: 'not-reporting', markets: [], trades: [], dayData: [], error: 'no network in scope' })
|
||||
}
|
||||
return NextResponse.json(chosen)
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Per-user proxy to the REAL luxd node RPC — the Nodes module's ONE transport.
|
||||
* The browser calls console2's OWN origin (`/nodes/v1/inventory`) with just the
|
||||
* The browser calls console2's OWN origin (`/v1/nodes/inventory`) with just the
|
||||
* session cookie; this handler resolves the caller, resolves the BRAND from the
|
||||
* request host, and fetches the allowlisted luxd RPC methods server-side for each
|
||||
* network that brand may see, returning NORMALIZED per-node rows. No RPC host or
|
||||
@@ -12,7 +12,7 @@
|
||||
* - Org/brand-aware: the network set is scoped by `nodeNetworksForBrand(brand)`,
|
||||
* brand resolved from the host — so cloud.lux.cloud sees only Lux networks,
|
||||
* console.hanzo.ai (hanzo) sees all.
|
||||
* - Least privilege: the ONLY path is `v1/inventory`, and the ONLY luxd methods
|
||||
* - Least privilege: the ONLY path is `inventory`, and the ONLY luxd methods
|
||||
* called are the four read methods below — this is not a general RPC tunnel.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
@@ -23,8 +23,10 @@ import { nodeNetworksForBrand, type NodeNetworkId } from '~/lib/products/brand-s
|
||||
import {
|
||||
NODE_NETWORK_META,
|
||||
combineInventory,
|
||||
normalizeChains,
|
||||
parseHeight,
|
||||
type NetworkInventory,
|
||||
type RawBlockchain,
|
||||
type RawPeer,
|
||||
type RawValidator,
|
||||
} from '~/lib/api/nodes'
|
||||
@@ -85,21 +87,31 @@ async function probe(net: NodeNetworkId): Promise<NetworkInventory> {
|
||||
validators: 0,
|
||||
peers: 0,
|
||||
nodes: [],
|
||||
chains: [],
|
||||
}
|
||||
|
||||
const ctrl = new AbortController()
|
||||
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS)
|
||||
try {
|
||||
const [valR, peerR, verR, hgtR] = await Promise.allSettled([
|
||||
const [valR, peerR, verR, hgtR, chainR] = await Promise.allSettled([
|
||||
rpc<{ validators?: RawValidator[] }>(host, '/ext/bc/P', 'platform.getCurrentValidators', ctrl.signal),
|
||||
rpc<{ numPeers?: string; peers?: RawPeer[] }>(host, '/ext/info', 'info.peers', ctrl.signal),
|
||||
rpc<{ version?: string }>(host, '/ext/info', 'info.getNodeVersion', ctrl.signal),
|
||||
rpc<{ height?: string }>(host, '/ext/bc/P', 'platform.getHeight', ctrl.signal),
|
||||
rpc<{ blockchains?: RawBlockchain[] }>(host, '/ext/bc/P', 'platform.getBlockchains', ctrl.signal),
|
||||
])
|
||||
|
||||
const reachable = valR.status === 'fulfilled' || peerR.status === 'fulfilled'
|
||||
const reachable =
|
||||
valR.status === 'fulfilled' || peerR.status === 'fulfilled' || chainR.status === 'fulfilled'
|
||||
if (!reachable) {
|
||||
const reason = valR.status === 'rejected' ? valR.reason : peerR.status === 'rejected' ? peerR.reason : null
|
||||
const reason =
|
||||
valR.status === 'rejected'
|
||||
? valR.reason
|
||||
: peerR.status === 'rejected'
|
||||
? peerR.reason
|
||||
: chainR.status === 'rejected'
|
||||
? chainR.reason
|
||||
: null
|
||||
base.error = reason instanceof Error ? reason.message : 'unreachable'
|
||||
return base
|
||||
}
|
||||
@@ -114,6 +126,9 @@ async function probe(net: NodeNetworkId): Promise<NetworkInventory> {
|
||||
base.peers = nodes.filter((n) => n.role === 'peer').length
|
||||
if (verR.status === 'fulfilled') base.version = verR.value?.version
|
||||
if (hgtR.status === 'fulfilled') base.height = parseHeight(hgtR.value?.height)
|
||||
// Chains are best-effort: a network can report validators/peers yet not answer
|
||||
// getBlockchains — then the chains list is honestly empty (no fabricated chains).
|
||||
if (chainR.status === 'fulfilled') base.chains = normalizeChains(chainR.value?.blockchains)
|
||||
return base
|
||||
} catch (e) {
|
||||
base.error = e instanceof Error ? e.message : String(e)
|
||||
@@ -124,8 +139,9 @@ async function probe(net: NodeNetworkId): Promise<NetworkInventory> {
|
||||
}
|
||||
|
||||
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
|
||||
// ONE endpoint — the inventory. No arbitrary RPC pass-through.
|
||||
if (path.join('/') !== 'v1/inventory') {
|
||||
// ONE endpoint — the inventory. No arbitrary RPC pass-through. This handler lives at
|
||||
// `app/v1/nodes/[...path]`, so the catch-all captures the sub-path after `/v1/nodes/`.
|
||||
if (path.join('/') !== 'inventory') {
|
||||
return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Per-user proxy to the Hanzo Base control plane (base.hanzo.ai) — the embedded
|
||||
* Base module's ONE transport. The browser calls console2's OWN origin
|
||||
* (`/superbase/v1/...`) with just the session cookie; `forwardWithUserBearer`
|
||||
* (`/v1/superbase/...`) with just the session cookie; `forwardWithUserBearer`
|
||||
* resolves the user, mints a short-lived user-bound IAM token (shared per-user
|
||||
* cache), and forwards to base.hanzo.ai with that token. No token ever reaches the
|
||||
* browser, and the SAME @hanzo/superbase-dashboard screens render here and standalone.
|
||||
@@ -37,7 +37,11 @@ type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
function handle(req: NextRequest, ctx: Ctx) {
|
||||
return (async () => {
|
||||
const path = (await ctx.params).path.join('/')
|
||||
// This handler lives under `app/v1/superbase/[...path]`, so the catch-all captures
|
||||
// ONLY the sub-path after `/v1/superbase/` (e.g. `collections/...`). Base serves its
|
||||
// data plane under `/v1/collections`, so re-root the upstream path at `v1/` — the same
|
||||
// path `allowBaseSurface` and `forwardWithUserBearer` see (`v1/collections/...`).
|
||||
const path = `v1/${(await ctx.params).path.join('/')}`
|
||||
return forwardWithUserBearer(req, {
|
||||
target: BASE_URL,
|
||||
path,
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Per-user proxy to the LIVE trading-bot state — the Trading module's ONE
|
||||
* live-data transport (the DEPLOYED FLEET is read separately via the `/v1`
|
||||
* PaaS proxy). The browser calls console2's OWN origin (`/v1/trading/*`) with just
|
||||
* the session cookie; this handler resolves the caller, resolves the BRAND from the
|
||||
* request host, and reads the allowlisted upstreams server-side, per network that
|
||||
* brand may see. No cluster host or RPC method ever reaches the browser, and the
|
||||
* browser can never choose either.
|
||||
*
|
||||
* Security (mirrors app/nodes/[...path]/route.ts):
|
||||
* - Session-gated: an unauthenticated caller gets 401.
|
||||
* - Org/brand-aware: the network set is scoped by `nodeNetworksForBrand(brand)`,
|
||||
* brand resolved from the host — cloud.lux.cloud sees only Lux networks.
|
||||
* - Least privilege: the ONLY paths are `metrics` and `orderbook`; the ONLY
|
||||
* upstreams are the maker's :2112 /metrics scrape and the DEX read endpoint —
|
||||
* this is not a general RPC/HTTP tunnel.
|
||||
*
|
||||
* Two upstreams, one concern each:
|
||||
* 1. METRICS — GETs the in-cluster maker's Prometheus `:2112/metrics` for a
|
||||
* network and parses it to `MakerStatus`. The maker Service host per network is
|
||||
* env-configurable; unset/unreachable → an honest `not-reporting` status.
|
||||
* 2. ORDERBOOK — reads the DEX CLOB `dex_get_orders?market=<poolHex>` for a market
|
||||
* on a network. The DEX read host is env-configurable; the private D-Chain is
|
||||
* not publicly exposed, so an unreachable venue → an honest `not-reporting` book
|
||||
* (never fabricated bids/asks).
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { brandFromHost } from '~/config'
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import { nodeNetworksForBrand, type NodeNetworkId } from '~/lib/products/brand-scope'
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
import {
|
||||
parseMakerMetrics,
|
||||
normalizeBook,
|
||||
type MakerStatus,
|
||||
type OrderBook,
|
||||
type RawBookOrder,
|
||||
} from '~/lib/api/trading'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
|
||||
/**
|
||||
* The maker's Prometheus metrics host per network — the ONLY metrics endpoint this
|
||||
* proxy will scrape. These point at the in-cluster maker Service (`maker-coherence`,
|
||||
* port 2112) per luxd network namespace. Each is overridable per-deploy; an
|
||||
* unset/unreachable host yields an honest `not-reporting` status — never fake rows.
|
||||
* (Cross-cluster reach depends on network policy; when unreachable the console shows
|
||||
* the honest not-reporting state, exactly like the Nodes surface.)
|
||||
*/
|
||||
const MAKER_METRICS_HOSTS: Partial<Record<NodeNetworkId, string>> = {
|
||||
'lux-mainnet': trim(process.env.MAKER_METRICS_MAINNET ?? 'http://maker-coherence.lux-mainnet.svc:2112'),
|
||||
'lux-testnet': trim(process.env.MAKER_METRICS_TESTNET ?? 'http://maker-coherence.lux-testnet.svc:2112'),
|
||||
'lux-devnet': trim(process.env.MAKER_METRICS_DEVNET ?? 'http://maker-coherence.lux-devnet.svc:2112'),
|
||||
}
|
||||
|
||||
/**
|
||||
* The DEX read host per network — the ONLY DEX endpoint this proxy will query for
|
||||
* the order book (`<host>/dex/dex_get_orders?market=<poolHex>`). The native D-Chain
|
||||
* CLOB is not publicly exposed, so these default to the in-cluster luxd router;
|
||||
* unreachable → an honest `not-reporting` book.
|
||||
*/
|
||||
const DEX_READ_HOSTS: Partial<Record<NodeNetworkId, string>> = {
|
||||
'lux-mainnet': trim(process.env.DEX_READ_MAINNET ?? 'http://luxd-0.luxd-headless.lux-mainnet.svc:9630/v1/bc/D'),
|
||||
'lux-testnet': trim(process.env.DEX_READ_TESTNET ?? 'http://luxd-0.luxd-headless.lux-testnet.svc:9640/v1/bc/D'),
|
||||
'lux-devnet': trim(process.env.DEX_READ_DEVNET ?? 'http://luxd-0.luxd-headless.lux-devnet.svc:9650/v1/bc/D'),
|
||||
}
|
||||
|
||||
/** Per-upstream probe timeout (ms). */
|
||||
const TIMEOUT_MS = Number(process.env.TRADING_TIMEOUT_MS ?? 8000)
|
||||
|
||||
/** Scrape ONE network's maker metrics → MakerStatus. Honest not-reporting on failure. */
|
||||
async function makerStatus(net: NodeNetworkId): Promise<MakerStatus> {
|
||||
const host = MAKER_METRICS_HOSTS[net]
|
||||
const base: MakerStatus = { status: 'not-reporting', symbols: [] }
|
||||
if (!host) {
|
||||
base.error = 'no metrics host configured for this network'
|
||||
return base
|
||||
}
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
`${host}/metrics`,
|
||||
{ headers: { accept: 'text/plain' }, cache: 'no-store' },
|
||||
{ timeoutMs: TIMEOUT_MS },
|
||||
)
|
||||
if (!res.ok) {
|
||||
base.error = `metrics ${res.status}`
|
||||
return base
|
||||
}
|
||||
const text = await res.text()
|
||||
const parsed = parseMakerMetrics(text)
|
||||
return { status: 'reporting', ...parsed }
|
||||
} catch (e) {
|
||||
base.error = e instanceof Error ? e.message : String(e)
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
/** A single allowlisted DEX read call. `method` is fixed here (dex_get_orders). */
|
||||
async function dexGetOrders(host: string, poolHex: string): Promise<RawBookOrder[]> {
|
||||
const url = `${host}/dex/dex_get_orders?market=${encodeURIComponent(poolHex)}`
|
||||
const res = await fetchWithTimeout(url, { headers: { accept: 'application/json' }, cache: 'no-store' }, { timeoutMs: TIMEOUT_MS })
|
||||
if (!res.ok) throw new Error(`dex_get_orders ${res.status}`)
|
||||
const json = (await res.json()) as { orders?: RawBookOrder[] }
|
||||
return Array.isArray(json?.orders) ? json.orders : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Read ONE market's order book. A `poolId` (32-byte hex) addresses the book
|
||||
* directly; a `symbol`/`base`/`quote` are echoed for display but the book is only
|
||||
* readable by poolId (the console does not compute keccak client- or server-side to
|
||||
* avoid an eth-crypto dep — the caller supplies the poolId, or the book is honestly
|
||||
* not-reporting). Honest not-reporting when the DEX is unreachable.
|
||||
*/
|
||||
async function orderbook(net: NodeNetworkId, params: URLSearchParams): Promise<OrderBook> {
|
||||
const symbol = params.get('symbol') ?? undefined
|
||||
const poolId = params.get('poolId') ?? undefined
|
||||
const base: OrderBook = { network: net, symbol, poolId, status: 'not-reporting', orders: [] }
|
||||
|
||||
const host = DEX_READ_HOSTS[net]
|
||||
if (!host) {
|
||||
base.error = 'no DEX read host configured for this network'
|
||||
return base
|
||||
}
|
||||
if (!poolId) {
|
||||
// No poolId → the book can't be addressed. Honest, never fabricated.
|
||||
base.error = 'order book is read by poolId; none supplied for this market'
|
||||
return base
|
||||
}
|
||||
try {
|
||||
const raw = await dexGetOrders(host, poolId)
|
||||
return { ...base, status: 'reporting', orders: normalizeBook(raw), error: undefined }
|
||||
} catch (e) {
|
||||
base.error = e instanceof Error ? e.message : String(e)
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the brand's networks, optionally narrowed to `?network=` (still gated). */
|
||||
function scopedNetworks(req: NextRequest): NodeNetworkId[] {
|
||||
const brand = brandFromHost(req.headers.get('host'))
|
||||
let networks = nodeNetworksForBrand(brand)
|
||||
const only = req.nextUrl.searchParams.get('network') as NodeNetworkId | null
|
||||
if (only) networks = networks.filter((n) => n === only)
|
||||
return networks
|
||||
}
|
||||
|
||||
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
|
||||
const route = path.join('/')
|
||||
|
||||
const user = await resolveUser(req)
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Sign in to view trading bots.' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (route === 'metrics') {
|
||||
const networks = scopedNetworks(req)
|
||||
// A single-network scope is the common case (per-bot status); return the first
|
||||
// (the network the caller asked for), or the brand's first if none specified.
|
||||
const net = networks[0]
|
||||
if (!net) return NextResponse.json({ status: 'not-reporting', symbols: [], error: 'no network in scope' })
|
||||
const status = await makerStatus(net)
|
||||
return NextResponse.json(status)
|
||||
}
|
||||
|
||||
if (route === 'orderbook') {
|
||||
const networks = scopedNetworks(req)
|
||||
const net = networks[0]
|
||||
if (!net) {
|
||||
return NextResponse.json({ network: null, status: 'not-reporting', orders: [], error: 'no network in scope' })
|
||||
}
|
||||
const book = await orderbook(net, req.nextUrl.searchParams)
|
||||
return NextResponse.json(book)
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Same-origin user-bearer proxy to Visor (vm.hanzo.ai) — the compute control plane
|
||||
* (regions / gpus / machines / instances). The browser calls this OWN-origin route
|
||||
* (`/vm/v1/...`) with just its session cookie; `forwardWithUserBearer` resolves the
|
||||
* (`/v1/vm/...`) with just its session cookie; `forwardWithUserBearer` resolves the
|
||||
* user, mints a short-lived user-bound IAM token, and forwards to visor with that
|
||||
* Bearer. Visor mints org + user from the JWT claims, so compute is org-scoped
|
||||
* server-side — a caller only ever sees their own org's machines. No token reaches
|
||||
@@ -34,7 +34,11 @@ type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
function handle(req: NextRequest, ctx: Ctx) {
|
||||
return (async () => {
|
||||
const path = (await ctx.params).path.join('/')
|
||||
// This handler lives under `app/v1/vm/[...path]`, so the catch-all captures ONLY the
|
||||
// sub-path after `/v1/vm/` (e.g. `regions`). Visor serves its compute surface under
|
||||
// `/v1/<x>`, so re-root the upstream path at `v1/` — the same path `allowVisorSurface`
|
||||
// and `forwardWithUserBearer` see (`v1/regions`).
|
||||
const path = `v1/${(await ctx.params).path.join('/')}`
|
||||
return forwardWithUserBearer(req, {
|
||||
target: VISOR_URL,
|
||||
path,
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* Waitlist join — the "Join waitlist" CTA on coming-soon products, server-side.
|
||||
*
|
||||
* Same-origin (`/waitlist`): the browser posts only its session cookie + the
|
||||
* waitlist slug and email; this handler forwards to the Hanzo Base waitlist
|
||||
* plugin (`POST /v1/waitlist/join`, the hanzoai/waitlist pattern — a per-product
|
||||
* SQLite-backed list). The backend URL is server-only env `WAITLIST_URL` (never
|
||||
* NEXT_PUBLIC_); when it is unset the route returns an honest 501 and the form
|
||||
* says the waitlist isn't open yet — no fake confirmation.
|
||||
*
|
||||
* The console UI is auth-gated, so we require a session (a signed-in user) to
|
||||
* keep this proxy from being an open relay to the waitlist backend, and forward
|
||||
* the client IP so the backend's per-IP rate limit still applies.
|
||||
*
|
||||
* POST /waitlist { waitlist, email } → { ok, rank, total, alreadyJoined, … }
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const WAITLIST_URL = (process.env.WAITLIST_URL ?? '').replace(/\/+$/, '')
|
||||
|
||||
/** Minimal email sanity check — the backend is authoritative (disposable, etc.). */
|
||||
const looksLikeEmail = (s: string): boolean => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s)
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return NextResponse.json({ error: 'Sign in to join the waitlist.' }, { status: 401 })
|
||||
|
||||
if (!WAITLIST_URL) {
|
||||
return NextResponse.json(
|
||||
{ error: 'The waitlist is not open on this deployment yet.' },
|
||||
{ status: 501 },
|
||||
)
|
||||
}
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as { waitlist?: string; email?: string }
|
||||
const waitlist = (body.waitlist ?? '').trim()
|
||||
// BIND the recorded email to the SESSION: prefer the authenticated account email so
|
||||
// a signed-in user can't enroll someone else (victim@othercorp) or forge "org X
|
||||
// wants ERP". The client-supplied email is only a fallback for an account with no
|
||||
// email on file. (RED review.)
|
||||
const email = ((user.email && user.email.trim()) || (body.email ?? '').trim()).trim()
|
||||
if (!waitlist) return NextResponse.json({ error: 'Missing waitlist.' }, { status: 400 })
|
||||
if (!looksLikeEmail(email)) return NextResponse.json({ error: 'Enter a valid email.' }, { status: 400 })
|
||||
|
||||
// Do NOT forward the client-controllable X-Forwarded-For — it's forgeable (a caller
|
||||
// could spoof a fresh source IP per request to defeat the backend's per-IP limit).
|
||||
// The backend sees this server's connection IP; per-user abuse is already bounded by
|
||||
// the session gate above. (RED review.)
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetchWithTimeout(`${WAITLIST_URL}/v1/waitlist/join`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ waitlist, email }),
|
||||
cache: 'no-store',
|
||||
})
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `Could not reach the waitlist: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
const text = await res.text()
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* e2e: admin.hanzo.ai AI Economics board (feat/ai-economics).
|
||||
*
|
||||
* TWO layers, mirroring provider-billing.spec:
|
||||
* (A) FIXTURE render — runs against a LOCAL server (BASE_URL=http://localhost:4000)
|
||||
* with the network mocked: `/auth/session` → a global admin so the admin shell
|
||||
* mounts, and the reads (`/v1/admin/usage/funding`, `/v1/admin/finance`,
|
||||
* `/v1/admin/providers/credit`, `/v1/evals/{datasets,runs,evaluators}`) → a
|
||||
* fixture where fable-5 is exactly 75% of requests and gross margin is 62%.
|
||||
* Proves: the page renders, the model-mix table shows the mocked rows WITH the
|
||||
* request-share %, the margin card shows the mocked grossMarginPct, and the
|
||||
* honest "no traffic is harvested" training-data card renders. Desktop + mobile.
|
||||
* (B) LIVE — the fail-closed gate proof (`/v1/admin/*` → >=401 unauthenticated)
|
||||
* against the same origin; needs no credentials, always runs.
|
||||
*
|
||||
* Run fixture: BASE_URL=http://localhost:4000 npx playwright test ai-economics
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
// A SuperAdmin via the isGlobalAdmin/isSuperAdmin CLAIM (what the `admin: true` module
|
||||
// gates on). owner is a normal org so OrgGate resolves locally instead of demanding a
|
||||
// pick from the (mocked-empty) org list.
|
||||
// owner === the reserved `admin` org IS the SuperAdmin signal the client gate reads
|
||||
// (`isSuperAdminOwner` / IAM `User.IsSuperAdmin` — the isGlobalAdmin/isSuperAdmin claim
|
||||
// fields are NOT read), so the `admin: true` module renders instead of the managed notice.
|
||||
const ACCOUNT = {
|
||||
owner: 'admin',
|
||||
name: 'z',
|
||||
type: 'normal-user',
|
||||
email: 'z@hanzo.ai',
|
||||
displayName: 'Z Admin',
|
||||
isGlobalAdmin: true,
|
||||
isSuperAdmin: true,
|
||||
isAdmin: true,
|
||||
signupApplication: 'hanzo-cloud',
|
||||
}
|
||||
|
||||
/** GET /v1/admin/usage/funding — the model mix: fable-5 = 750/1000 requests (75%),
|
||||
* gpt-5.6 = 200 (20%), ds4-flash = 30, ds4-pro = 20. One row per (provider,model,funding). */
|
||||
const FUNDING = [
|
||||
{ provider: 'do-ai', model: 'fable-5', funding: 'credit', tokens: 4_800_000, cost_cents: 18_200, requests: 600 },
|
||||
{ provider: 'do-ai', model: 'fable-5', funding: 'paid', tokens: 1_200_000, cost_cents: 6_100, requests: 150 },
|
||||
{ provider: 'openrouter', model: 'gpt-5.6', funding: 'paid', tokens: 900_000, cost_cents: 44_000, requests: 200 },
|
||||
{ provider: 'openrouter', model: 'ds4-flash', funding: 'paid', tokens: 120_000, cost_cents: 900, requests: 30 },
|
||||
{ provider: 'openrouter', model: 'ds4-pro', funding: 'paid', tokens: 80_000, cost_cents: 3_100, requests: 20 },
|
||||
]
|
||||
|
||||
/** GET /v1/admin/finance — the casibase-enveloped finance aggregate; grossMarginPct 62. */
|
||||
const FINANCE = {
|
||||
status: 'ok',
|
||||
msg: '',
|
||||
data: {
|
||||
cost: { configured: true, error: '', period: '2026-07', totalCents: 3_800_000, vendors: [], digitalocean: { configured: true, error: '', creditRemainingCents: 2_418_000, monthToDateSpendCents: 41_200, avgDailyBurnCents: 20_100, accountBalanceCents: -2_418_000, generatedAt: '', history: [] } },
|
||||
revenue: { configured: true, totalRevenueCents: 10_000_000, mrrCents: 820_000, creditsConsumedCents: 120_000 },
|
||||
derived: { grossMarginCents: 6_200_000, grossMarginPct: 62, runwayDays: 120, profitable: true },
|
||||
generatedAt: '2026-07-15T00:00:00Z',
|
||||
},
|
||||
}
|
||||
|
||||
/** GET /v1/admin/providers/credit — the DO grant + a paid-only provider. */
|
||||
const CREDIT = [
|
||||
{ provider: 'do-ai', grant_cents: 2_600_000, burn_cents: 41_200, remaining_cents: 2_418_000, runway_days: 58, has_credit: true, is_paid_only: false },
|
||||
{ provider: 'openrouter', grant_cents: 100_000, burn_cents: 21_000, remaining_cents: 62_500, runway_days: 3, has_credit: true, is_paid_only: false },
|
||||
]
|
||||
|
||||
/** GET /v1/evals/datasets — user-curated registry: 2 datasets, 150 items. */
|
||||
const DATASETS = { data: [
|
||||
{ name: 'router-quality', description: 'router routing quality', items: 120, createdAt: '2026-07-08T00:00:00Z' },
|
||||
{ name: 'safety-redteam', description: 'safety judgments', items: 30, createdAt: '2026-07-02T00:00:00Z' },
|
||||
] }
|
||||
|
||||
/** GET /v1/evals/runs — recent LLM-as-judge runs with an average score. */
|
||||
const RUNS = { data: [
|
||||
{ dataset: 'router-quality', runName: 'rq-2026-07-10', model: 'fable-5', judgeModel: 'claude-opus-4.6', items: 120, scored: 120, avgScore: 0.87, createdAt: '2026-07-10T00:00:00Z' },
|
||||
{ dataset: 'safety-redteam', runName: 'sr-2026-07-04', model: 'gpt-5.6', judgeModel: 'claude-opus-4.6', items: 30, scored: 30, avgScore: 0.93, createdAt: '2026-07-04T00:00:00Z' },
|
||||
] }
|
||||
|
||||
/** GET /v1/evals/evaluators. */
|
||||
const EVALUATORS = { data: [{ name: 'quality-judge', model: 'claude-opus-4.6', criteria: 'routing quality', scoreName: 'quality' }] }
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const path = url.pathname
|
||||
|
||||
if (path === '/auth/session') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ account: ACCOUNT, expiresIn: 3600 }) })
|
||||
}
|
||||
if (path.startsWith('/auth/')) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
|
||||
}
|
||||
// The economics reads. funding/credit are bare arrays (restGet + pluckList); finance
|
||||
// is the casibase envelope (originGet unwraps `data`); evals are `{data:[...]}`.
|
||||
const json = (body: unknown) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
if (path === '/v1/admin/usage/funding') return json(FUNDING)
|
||||
if (path === '/v1/admin/finance') return json(FINANCE)
|
||||
if (path === '/v1/admin/providers/credit') return json(CREDIT)
|
||||
if (path === '/v1/evals/datasets') return json(DATASETS)
|
||||
if (path === '/v1/evals/runs') return json(RUNS)
|
||||
if (path === '/v1/evals/evaluators') return json(EVALUATORS)
|
||||
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(path)) return route.continue()
|
||||
// Any other data call → an honest empty-ok envelope so the shell is quiet.
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }) })
|
||||
}
|
||||
|
||||
async function openBoard(page: Page) {
|
||||
await page.addInitScript((org) => {
|
||||
try {
|
||||
localStorage.setItem('hanzo.console.org', org)
|
||||
localStorage.setItem('hanzo.console.org.selected', '1') // ENTERED flag — OrgGate → scoped console
|
||||
localStorage.setItem('hz_onboarding_done:' + org, '1') // skip the first-run wizard
|
||||
localStorage.setItem('hz_admin_banner_dismissed', '1')
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}, ACCOUNT.owner)
|
||||
await page.route('**/*', mock)
|
||||
await page.goto(`${BASE_URL}/ai-economics`, { waitUntil: 'domcontentloaded' })
|
||||
const content = page.locator('[data-testid="product-content"]').first()
|
||||
await content.waitFor({ state: 'attached', timeout: 20_000 })
|
||||
await expect(content.getByTestId('ai-economics')).toBeVisible({ timeout: 20_000 })
|
||||
await page.waitForTimeout(700)
|
||||
}
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
// ─── (A) fixture render ───────────────────────────────────────────────────────
|
||||
test.describe('(A) fixture render — model mix (fable-5 75%) + 62% margin + honest training card', () => {
|
||||
test('renders the model mix, share %, margin, and the honest training-data card (desktop)', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await openBoard(page)
|
||||
|
||||
// Page rendered (not the operator gate).
|
||||
await expect(page.getByText('AI Economics').first()).toBeVisible()
|
||||
await expect(page.locator('text=/Operator access required|not authorized|access denied/i')).toHaveCount(0)
|
||||
|
||||
// (a) model mix — the mocked rows WITH request-share %.
|
||||
const modelMix = page.getByTestId('model-mix')
|
||||
await expect(modelMix.getByText('Model mix').first()).toBeVisible()
|
||||
await expect(modelMix.getByText('fable-5').first()).toBeVisible()
|
||||
await expect(modelMix.getByText('gpt-5.6').first()).toBeVisible()
|
||||
await expect(modelMix.getByText('75%').first()).toBeVisible() // fable-5 = 750/1000 requests
|
||||
await expect(modelMix.getByText('20%').first()).toBeVisible() // gpt-5.6 = 200/1000
|
||||
|
||||
// (b) profitability — the mocked grossMarginPct.
|
||||
const margin = page.getByTestId('margin-card')
|
||||
await expect(margin.getByText('+62% margin').first()).toBeVisible()
|
||||
|
||||
// (c) training data — the honest "no traffic harvested" collection card + real counts.
|
||||
const training = page.getByTestId('training-collection-card')
|
||||
await expect(training).toBeVisible()
|
||||
await expect(training.getByText(/No traffic is harvested for training/i)).toBeVisible()
|
||||
await expect(page.getByText('Eval datasets').first()).toBeVisible()
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'ai-economics-desktop.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('reflows with no horizontal body scroll at a narrow (mobile) viewport', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
|
||||
const page = await ctx.newPage()
|
||||
await openBoard(page)
|
||||
|
||||
await expect(page.getByTestId('model-mix').getByText('fable-5').first()).toBeVisible()
|
||||
const overflow = await page.evaluate(() => {
|
||||
const el = document.documentElement
|
||||
return { scrollWidth: el.scrollWidth, clientWidth: el.clientWidth }
|
||||
})
|
||||
expect(overflow.scrollWidth, 'no horizontal body scroll at 390px').toBeLessThanOrEqual(overflow.clientWidth + 1)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'ai-economics-mobile.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── (B) fail-closed gate — always runs, no credentials ───────────────────────
|
||||
test.describe('(B) admin gate fail-closed', () => {
|
||||
test('/v1/admin/{usage/funding,finance,providers/credit} → fail-closed unauthenticated', async ({ request }) => {
|
||||
for (const p of ['usage/funding', 'finance', 'providers/credit']) {
|
||||
const res = await request.get(`${BASE_URL}/v1/admin/${p}`)
|
||||
// A raw request (no page mocks, no session) NEVER gets data: the console's
|
||||
// getAdminGate is fail-closed. Post-deploy this is the 403 global-admin gate;
|
||||
// before a sibling route deploys it may 404 — both are "not open". Never 200.
|
||||
expect(res.status(), `${BASE_URL}/v1/admin/${p} must be fail-closed (>=401)`).toBeGreaterThanOrEqual(401)
|
||||
expect(res.status(), `${BASE_URL}/v1/admin/${p} must not 5xx`).toBeLessThan(500)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -46,7 +46,7 @@ async function billing(page: Page, path: string): Promise<{ status: number; ids:
|
||||
const body = await res.json()
|
||||
const rows = Array.isArray(body)
|
||||
? body
|
||||
: (body?.subscriptions ?? body?.paymentMethods ?? body?.payment_methods ?? body?.data ?? [])
|
||||
: (body?.subscriptions ?? body?.paymentMethods ?? body?.payment_methods ?? body?.invoices ?? body?.data ?? [])
|
||||
ids = (Array.isArray(rows) ? rows : [])
|
||||
.map((r: { id?: unknown }) => (typeof r?.id === 'string' ? r.id : ''))
|
||||
.filter(Boolean)
|
||||
@@ -72,7 +72,10 @@ test.describe('billing is isolated per tenant through the proxy', () => {
|
||||
await signIn(pageA, A.email, A.password)
|
||||
await signIn(pageB, B.email, B.password)
|
||||
|
||||
for (const path of ['subscriptions', 'payment-methods']) {
|
||||
// `invoices` is included because its row ids drive the per-invoice PDF URL
|
||||
// (`/billing/v1/invoices/:id/pdf`) — proving the invoice list is tenant-isolated
|
||||
// proves a user can only ever build a PDF URL for their OWN org's invoices.
|
||||
for (const path of ['subscriptions', 'payment-methods', 'invoices']) {
|
||||
const a = await billing(pageA, path)
|
||||
const b = await billing(pageB, path)
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* e2e blank audit — mocked-network render proof for EVERY in-console product route.
|
||||
*
|
||||
* Runs against a LOCAL `next dev` (BASE_URL=http://localhost:4000) with the whole
|
||||
* network mocked, so it needs NO real backend and NO password:
|
||||
* - `/auth/session` → a global-admin account (sees every product), so the AuthGate
|
||||
* and OrgGate pass and the full console shell mounts.
|
||||
* - every data endpoint (`/v1`, `/v1`, `/ai`, `/billing`, `/commerce`,
|
||||
* `/telemetry`, `/vm`, `/superbase`, `/admin`, cross-origin platform) → a chosen
|
||||
* failure mode (AUDIT_MODE): `notrouted` (404, the "backend not wired on this
|
||||
* deployment" reality), `down` (502), or `empty` (200 empty payload).
|
||||
*
|
||||
* For each route it navigates `/<id>`, waits for the shell's `product-content`
|
||||
* region, and classifies what rendered there:
|
||||
* - `blank` → the content region mounted but is EMPTY (the bug we hunt),
|
||||
* - `content` → real data OR an honest error/empty card (the goal),
|
||||
* - `notfound` → Next 404 (an unrouted/external id — expected for a few),
|
||||
* - `no-shell` → the shell itself failed to mount (worse than blank).
|
||||
*
|
||||
* It writes e2e/blank-report.json and fails iff any route is `blank`/`no-shell`.
|
||||
*
|
||||
* Run: AUDIT_MODE=notrouted BASE_URL=http://localhost:4000 npx playwright test blank-audit
|
||||
*/
|
||||
import { test, expect, type Route } from '@playwright/test'
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
const MODE = (process.env.AUDIT_MODE ?? 'notrouted') as 'notrouted' | 'down' | 'empty'
|
||||
const ROLE = (process.env.AUDIT_ROLE ?? 'admin') as 'admin' | 'customer'
|
||||
|
||||
const CANONICAL_IDS: string[] = JSON.parse(readFileSync(join(process.cwd(), 'e2e', 'route-ids.json'), 'utf8'))
|
||||
/**
|
||||
* The HUMAN slugs the console nav / docs / bookmarks / the CTO's e2e list use that
|
||||
* are NOT registry ids — they must resolve via SLUG_ALIASES to a real module (never
|
||||
* a 404 blank). Auditing them here proves the alias map end-to-end against the real app.
|
||||
*/
|
||||
const ALIAS_SLUGS = ['traces', 'deploy', 'plans-pricing', 'wallets', 'model-catalog', 'fine-tuning', 'web-search', 'mlpipelines', 'kubeflow']
|
||||
const IDS: string[] = [...CANONICAL_IDS, ...ALIAS_SLUGS]
|
||||
|
||||
/** A global-admin (sees every surface) or a tenant customer (Dave/maxpower shape). */
|
||||
const ACCOUNT =
|
||||
ROLE === 'admin'
|
||||
? { owner: 'admin', name: 'z', type: 'normal-user', email: 'z@hanzo.ai', displayName: 'Z Admin', isGlobalAdmin: true, isAdmin: true, signupApplication: 'hanzo-cloud' }
|
||||
: { owner: 'maxpower', name: 'dave', type: 'normal-user', email: 'dave@maxpower.com', displayName: 'Dave', isGlobalAdmin: false, isAdmin: true, signupApplication: 'hanzo-cloud' }
|
||||
|
||||
/** casibase envelope + REST payloads for the three failure modes. */
|
||||
function payloadFor(mode: typeof MODE): { status: number; body: string } {
|
||||
if (mode === 'down') return { status: 502, body: 'Bad Gateway' }
|
||||
if (mode === 'notrouted') return { status: 404, body: JSON.stringify({ status: 'error', msg: 'not found', data: null }) }
|
||||
// empty-ok: a well-formed empty envelope; REST readers also tolerate [] / {}.
|
||||
return { status: 200, body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }) }
|
||||
}
|
||||
|
||||
/** Path prefixes that are DATA calls (mock them); everything else is a Next asset/page. */
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
// NEVER mock a top-level page navigation (the app HTML). A product route id can
|
||||
// collide with an API head (e.g. `/integrations`, `/billing`), so keying off the
|
||||
// path alone would serve the mock JSON AS the page. Only data calls (xhr/fetch)
|
||||
// are mocked; documents/assets always load the real app.
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const path = url.pathname
|
||||
|
||||
// Session → authed account (always ok, regardless of MODE).
|
||||
if (path === '/auth/session') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ account: ACCOUNT, expiresIn: 3600 }) })
|
||||
}
|
||||
if (path.startsWith('/auth/')) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
|
||||
}
|
||||
|
||||
// Same-origin Next asset or the app HTML → let it through.
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
const isApi = API_RE.test(path)
|
||||
if (sameOrigin && !isApi) return route.continue()
|
||||
|
||||
// Cross-origin (platform.hanzo.ai, api.hanzo.ai, cloud.hanzo.ai, …) OR a
|
||||
// same-origin data path → the chosen failure mode.
|
||||
const { status, body } = payloadFor(MODE)
|
||||
const contentType = body.startsWith('{') || body.startsWith('[') ? 'application/json' : 'text/plain'
|
||||
return route.fulfill({ status, contentType, body })
|
||||
}
|
||||
|
||||
type Outcome = 'content' | 'blank' | 'notfound' | 'no-shell'
|
||||
const results: Record<string, { outcome: Outcome; chars: number; sample: string }> = {}
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.describe(`blank audit [mode=${MODE} role=${ROLE}]`, () => {
|
||||
let page: import('@playwright/test').Page
|
||||
let ctx: import('@playwright/test').BrowserContext
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
page = await ctx.newPage()
|
||||
// Seed the active org to the account's own org so OrgGate doesn't hard-pin +
|
||||
// reload a customer (currentOrg !== owner) mid-audit, and dismiss the admin
|
||||
// banner so the shell is stable. Runs before every navigation (survives reloads).
|
||||
await page.addInitScript((org) => {
|
||||
try {
|
||||
localStorage.setItem('hanzo.console.org', org)
|
||||
localStorage.setItem('hz_admin_banner_dismissed', '1')
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}, ACCOUNT.owner)
|
||||
await page.route('**/*', mock)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
writeFileSync(join(process.cwd(), 'e2e', 'blank-report.json'), JSON.stringify({ mode: MODE, role: ROLE, results }, null, 2))
|
||||
const blanks = Object.entries(results).filter(([, r]) => r.outcome === 'blank' || r.outcome === 'no-shell')
|
||||
const nf = Object.entries(results).filter(([, r]) => r.outcome === 'notfound').map(([id]) => id)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`\n=== blank audit [${MODE}/${ROLE}] ===\nroutes: ${Object.keys(results).length} blank/no-shell: ${blanks.length} notfound: ${nf.length}`)
|
||||
if (blanks.length) console.log('BLANK:', blanks.map(([id, r]) => `${id}(${r.outcome})`).join(', '))
|
||||
if (nf.length) console.log('NOTFOUND:', nf.join(', '))
|
||||
await ctx?.close()
|
||||
})
|
||||
|
||||
for (const id of IDS) {
|
||||
test(`/${id}`, async () => {
|
||||
await page.goto(`${BASE_URL}/${id}`, { waitUntil: 'domcontentloaded' })
|
||||
// Let the client mount + the module's first data attempt settle.
|
||||
const content = page.locator('[data-testid="product-content"]').first()
|
||||
const appeared = await content.waitFor({ state: 'attached', timeout: 15_000 }).then(() => true).catch(() => false)
|
||||
|
||||
let outcome: Outcome
|
||||
let chars = 0
|
||||
let sample = ''
|
||||
if (!appeared) {
|
||||
// No shell content region — either a Next 404 (notfound) or a shell failure.
|
||||
const is404 = await page.locator('text=/404|not be found|This page could not/i').count().then((c) => c > 0).catch(() => false)
|
||||
outcome = is404 ? 'notfound' : 'no-shell'
|
||||
} else {
|
||||
// Give async data one more settle beat, then read the region's text.
|
||||
await page.waitForTimeout(1200)
|
||||
const txt = (await content.innerText().catch(() => '')) || ''
|
||||
chars = txt.trim().length
|
||||
sample = txt.trim().slice(0, 80).replace(/\s+/g, ' ')
|
||||
outcome = chars > 0 ? 'content' : 'blank'
|
||||
}
|
||||
results[id] = { outcome, chars, sample }
|
||||
// Record only — the final `no blank routes` test asserts over ALL results, so
|
||||
// one blank never fail-fasts the serial group and hides the rest.
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`${outcome === 'content' ? '✓' : outcome === 'notfound' ? '·' : '✗'} /${id} [${outcome}] ${chars}c ${sample}`)
|
||||
})
|
||||
}
|
||||
|
||||
test('no route renders blank', async () => {
|
||||
const bad = Object.entries(results).filter(([, r]) => r.outcome === 'blank' || r.outcome === 'no-shell')
|
||||
expect(bad.map(([id, r]) => `${id}(${r.outcome})`), 'every product route must render real data or an honest state').toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* e2e: Budgets & limits page — mocked-network render + RESPONSIVE proof.
|
||||
*
|
||||
* Runs against a LOCAL server (BASE_URL=http://localhost:4000) with the whole network
|
||||
* mocked (same pattern as blank-audit): `/auth/session` → a global admin so the shell
|
||||
* mounts, `/billing/v1/spend-alerts` → real-shaped budget rows (org default + project
|
||||
* warn + service over + unlimited/rate-limit-only), everything else → an empty-ok
|
||||
* envelope.
|
||||
*
|
||||
* It proves the extended Budgets page renders real content at a desktop AND a NARROW
|
||||
* (mobile) viewport, that the body never scrolls horizontally on mobile (the CTO
|
||||
* requirement), and opens the inline edit form. Screenshots at each width.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test budgets-responsive
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
const ACCOUNT = {
|
||||
owner: 'hanzo',
|
||||
name: 'z',
|
||||
type: 'normal-user',
|
||||
email: 'z@hanzo.ai',
|
||||
displayName: 'Z Admin',
|
||||
// Super admin via the CLAIM (owner is a normal org, not the reserved `admin`).
|
||||
// `isSuperAdmin` is canonical; `isGlobalAdmin` stays for legacy-claim coverage.
|
||||
isSuperAdmin: true,
|
||||
isGlobalAdmin: true,
|
||||
isAdmin: true,
|
||||
signupApplication: 'hanzo-cloud',
|
||||
}
|
||||
|
||||
/** Real-shaped `/v1/billing/spend-alerts` rows — one per verdict/scope (threshold = cents). */
|
||||
const BUDGETS = [
|
||||
{ id: 'b1', title: 'Org monthly cap', threshold: 500000, currency: 'usd', project: '', service: '', enforce: true, softPct: 80, rateLimitRpm: 0, periodSpentCents: 312000, over: false, warn: false },
|
||||
{ id: 'b2', title: 'Inference budget', threshold: 200000, currency: 'usd', project: 'acme-prod', service: 'inference', enforce: false, softPct: 75, rateLimitRpm: 600, periodSpentCents: 186000, over: false, warn: true },
|
||||
{ id: 'b3', title: 'Embeddings cap', threshold: 50000, currency: 'usd', project: '', service: 'embeddings', enforce: true, softPct: 80, rateLimitRpm: 300, periodSpentCents: 51500, over: true, warn: true },
|
||||
{ id: 'b4', title: 'Sandbox throttle', threshold: 0, currency: 'usd', project: 'sandbox', service: '', enforce: false, softPct: 0, rateLimitRpm: 120, periodSpentCents: 8300, over: false, warn: false },
|
||||
]
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const path = url.pathname
|
||||
|
||||
if (path === '/auth/session') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ account: ACCOUNT, expiresIn: 3600 }) })
|
||||
}
|
||||
if (path.startsWith('/auth/')) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
|
||||
}
|
||||
// The page under test — the real spend-alerts contract.
|
||||
if (path === '/billing/v1/spend-alerts') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(BUDGETS) })
|
||||
}
|
||||
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(path)) return route.continue()
|
||||
// Any other data call → an honest empty-ok envelope so the shell is quiet.
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }) })
|
||||
}
|
||||
|
||||
async function openBudgets(page: Page) {
|
||||
await page.addInitScript((org) => {
|
||||
try {
|
||||
localStorage.setItem('hanzo.console.org', org)
|
||||
localStorage.setItem('hz_admin_banner_dismissed', '1')
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}, ACCOUNT.owner)
|
||||
await page.route('**/*', mock)
|
||||
await page.goto(`${BASE_URL}/billing/budgets`, { waitUntil: 'domcontentloaded' })
|
||||
const content = page.locator('[data-testid="product-content"]').first()
|
||||
await content.waitFor({ state: 'attached', timeout: 20_000 })
|
||||
await expect(page.locator('text=Budgets & limits').first()).toBeVisible({ timeout: 20_000 })
|
||||
await page.waitForTimeout(800)
|
||||
}
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
test('renders the budgets & limits page at a desktop viewport', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await openBudgets(page)
|
||||
|
||||
// The four budgets + verdicts + scope labels + enforcement, from the real contract.
|
||||
await expect(page.locator('text=Organization default').first()).toBeVisible()
|
||||
await expect(page.locator('text=acme-prod · inference').first()).toBeVisible()
|
||||
await expect(page.locator('text=Over cap').first()).toBeVisible()
|
||||
await expect(page.locator('text=Unlimited').first()).toBeVisible()
|
||||
await expect(page.locator('text=Hard cap').first()).toBeVisible()
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'budgets-desktop.png'), fullPage: true })
|
||||
|
||||
// Prove the inline edit form opens with the new controls (scope selector +
|
||||
// Enforce toggle + rate limit). `exact: true` — a substring match would hit the
|
||||
// "Cr-EDIT-s" tab (which contains "edit"); we want the card's Edit button.
|
||||
await page.getByRole('button', { name: 'Edit', exact: true }).first().click()
|
||||
await expect(page.getByRole('button', { name: 'Save budget' }).first()).toBeVisible()
|
||||
await expect(page.locator('text=Enforce (hard cap)').first()).toBeVisible()
|
||||
await expect(page.locator('text=Rate limit').first()).toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, 'budgets-edit.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('reflows with no horizontal body scroll at a narrow (mobile) viewport', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
|
||||
const page = await ctx.newPage()
|
||||
await openBudgets(page)
|
||||
|
||||
await expect(page.locator('text=Organization default').first()).toBeVisible()
|
||||
|
||||
// The CTO requirement: the body must not scroll horizontally on mobile.
|
||||
const overflow = await page.evaluate(() => {
|
||||
const el = document.documentElement
|
||||
return { scrollWidth: el.scrollWidth, clientWidth: el.clientWidth }
|
||||
})
|
||||
expect(overflow.scrollWidth, 'no horizontal body scroll at 390px').toBeLessThanOrEqual(overflow.clientWidth + 1)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'budgets-mobile.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* e2e: entitlement-gated sidebar — mocked-network render proof.
|
||||
*
|
||||
* Runs against a LOCAL server (BASE_URL=http://localhost:4000) with the whole
|
||||
* network mocked (same pattern as budgets-responsive): `/auth/session` → a CUSTOMER
|
||||
* (non-admin, non-super-admin) account, and `/v1/orgs/<org>/entitlements` →
|
||||
* `{ enabled: ['agents'] }`. Everything else → an empty-ok envelope.
|
||||
*
|
||||
* It proves the out-of-box gate: a customer's sidebar shows ONLY the products the
|
||||
* org has enabled (always-on essentials + Agents), HIDES a non-entitled product
|
||||
* (GPUs), and offers the "Add product" flow — whose panel lists the non-entitled
|
||||
* products with an Enable action.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test entitlement-sidebar
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
const ORG = 'maxpower'
|
||||
|
||||
// A CUSTOMER — NOT a super admin (no isSuperAdmin/isGlobalAdmin, owner ≠ admin), so
|
||||
// the entitlement gate is in force (a super admin would bypass it).
|
||||
const ACCOUNT = {
|
||||
owner: ORG,
|
||||
name: 'dave',
|
||||
type: 'normal-user',
|
||||
email: 'dave@maxpower.com',
|
||||
displayName: 'Dave',
|
||||
isAdmin: true, // admin of their OWN org — still a customer, not a platform admin
|
||||
signupApplication: 'hanzo-cloud',
|
||||
}
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const path = url.pathname
|
||||
|
||||
if (path === '/auth/session') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ account: ACCOUNT, expiresIn: 3600 }) })
|
||||
}
|
||||
if (path.startsWith('/auth/')) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
|
||||
}
|
||||
// The gate under test — the org has ONLY Agents enabled beyond the essentials.
|
||||
if (path === `/v1/orgs/${ORG}/entitlements`) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ enabled: ['agents'] }) })
|
||||
}
|
||||
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(path)) return route.continue()
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }) })
|
||||
}
|
||||
|
||||
async function openShell(page: Page) {
|
||||
await page.addInitScript((org) => {
|
||||
try {
|
||||
// Enter the org (skip the picker) so the dashboard shell mounts.
|
||||
localStorage.setItem('hanzo.console.org', org)
|
||||
localStorage.setItem('hanzo.console.org.selected', '1')
|
||||
localStorage.setItem('hz_admin_banner_dismissed', '1')
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}, ORG)
|
||||
await page.route('**/*', mock)
|
||||
await page.goto(`${BASE_URL}/agents`, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForTimeout(1200)
|
||||
}
|
||||
|
||||
test('gated sidebar shows only enabled products + Add product', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await openShell(page)
|
||||
|
||||
const nav = page.locator('nav, [role="navigation"]').first()
|
||||
|
||||
// Enabled product IS in the nav.
|
||||
await expect(page.getByText('Agents', { exact: true }).first()).toBeVisible({ timeout: 20_000 })
|
||||
// The "Add product" affordance is offered.
|
||||
await expect(page.getByRole('button', { name: 'Add product' }).first()).toBeVisible()
|
||||
// A non-entitled product is HIDDEN from the sidebar nav.
|
||||
await expect(nav.getByText('GPUs', { exact: true })).toHaveCount(0)
|
||||
|
||||
// The Add-product flow lists the non-entitled products with an Enable action.
|
||||
await page.getByRole('button', { name: 'Add product' }).first().click()
|
||||
await expect(page.getByText('Enable products to add them', { exact: false }).first()).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.getByRole('button', { name: /Enable GPUs/ }).first()).toBeVisible()
|
||||
|
||||
await ctx.close()
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* e2e screenshot proof — the GPUs page shows BOTH "Connect GPU" (BYO) and "Deploy GPU"
|
||||
* (cloud) actions, and a BYO machine renders with a BYO badge next to a cloud one.
|
||||
*
|
||||
* Fully mocked network (no backend, no password), same harness as blank-audit:
|
||||
* - /auth/session → a tenant customer (so CustomerGpus renders, not AdminGpus).
|
||||
* - GET .../v1/machines → one BYO GB10 (provider=byo) + one cloud H100 (provider=doks).
|
||||
* - GET .../v1/gpus → a small live catalog so the page reads real.
|
||||
* - every other data path → an honest empty envelope.
|
||||
*
|
||||
* Writes two PNGs to e2e/shots/. Run:
|
||||
* BASE_URL=http://localhost:4000 npx playwright test gpus-connect
|
||||
*/
|
||||
import { test, type Route } from '@playwright/test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
const SHOTS = join(process.cwd(), 'e2e', 'shots')
|
||||
|
||||
const ACCOUNT = {
|
||||
owner: 'maxpower',
|
||||
name: 'dave',
|
||||
type: 'normal-user',
|
||||
email: 'dave@maxpower.com',
|
||||
displayName: 'Dave',
|
||||
isGlobalAdmin: false,
|
||||
isAdmin: true,
|
||||
signupApplication: 'hanzo-cloud',
|
||||
}
|
||||
|
||||
const ok = (data: unknown) => JSON.stringify({ status: 'ok', msg: '', data })
|
||||
|
||||
// Two GPU machines: a BYO GB10 that dialed in via `hanzo gpu connect`, and a
|
||||
// Hanzo-Cloud-provisioned H100. isGpuMachine keeps both (gpu set / gpu-* slug).
|
||||
const MACHINES = [
|
||||
{ id: 'gb10-studio', name: 'gb10-studio', type: 'byo-gpu', provider: 'byo', gpu: 'NVIDIA GB10', region: 'on-prem', status: 'online' },
|
||||
{ id: 'gpu-h100-sfo', name: 'gpu-h100-sfo', type: 'gpu-h100x1-80gb', provider: 'doks', gpu: 'H100', region: 'sfo3', status: 'running', costHourlyUsd: 2.49 },
|
||||
]
|
||||
|
||||
const CATALOG = [
|
||||
{ slug: 'gpu-h100x1-80gb', model: 'H100', gpuCount: 1, vramGb: 80, vcpus: 20, memGb: 240, priceHourly: 2.49, priceMonthly: 1818 },
|
||||
{ slug: 'gpu-a100x1-40gb', model: 'A100', gpuCount: 1, vramGb: 40, vcpus: 12, memGb: 120, priceHourly: 1.59, priceMonthly: 1161 },
|
||||
{ slug: 'gpu-l40sx1-48gb', model: 'L40S', gpuCount: 1, vramGb: 48, vcpus: 8, memGb: 64, priceHourly: 1.14, priceMonthly: 832 },
|
||||
]
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const path = url.pathname
|
||||
|
||||
if (path === '/auth/session') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ account: ACCOUNT, expiresIn: 3600 }) })
|
||||
}
|
||||
if (path.startsWith('/auth/')) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
|
||||
}
|
||||
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(path)) return route.continue()
|
||||
|
||||
// Data paths — match by suffix so it works regardless of /vm vs /cloud proxy prefix.
|
||||
if (path.endsWith('/v1/machines')) return route.fulfill({ status: 200, contentType: 'application/json', body: ok(MACHINES) })
|
||||
if (path.endsWith('/v1/gpus')) return route.fulfill({ status: 200, contentType: 'application/json', body: ok(CATALOG) })
|
||||
// Everything else (regions, sizes, clusters, billing, …) → honest empty.
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: ok([]) })
|
||||
}
|
||||
|
||||
test('GPUs page: Connect vs Deploy + BYO/Cloud badges', async ({ browser }) => {
|
||||
mkdirSync(SHOTS, { recursive: true })
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 })
|
||||
const page = await ctx.newPage()
|
||||
await page.addInitScript((org) => {
|
||||
try {
|
||||
localStorage.setItem('hanzo.console.org', org)
|
||||
localStorage.setItem('hz_admin_banner_dismissed', '1')
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}, ACCOUNT.owner)
|
||||
await page.route('**/*', mock)
|
||||
|
||||
await page.goto(`${BASE_URL}/gpus`, { waitUntil: 'domcontentloaded' })
|
||||
await page.locator('[data-testid="product-content"]').first().waitFor({ state: 'attached', timeout: 20_000 })
|
||||
// Wait for both header actions to be present, then the machine rows to render.
|
||||
await page.getByRole('button', { name: 'Connect GPU' }).first().waitFor({ timeout: 20_000 })
|
||||
await page.locator('text=NVIDIA GB10').first().waitFor({ timeout: 20_000 })
|
||||
await page.waitForTimeout(600)
|
||||
await page.screenshot({ path: join(SHOTS, 'gpus-connect-deploy.png'), fullPage: true })
|
||||
|
||||
// Open the Connect drawer and capture it.
|
||||
await page.getByRole('button', { name: 'Connect GPU' }).first().click()
|
||||
await page.locator('text=hanzo gpu connect').first().waitFor({ timeout: 10_000 })
|
||||
await page.waitForTimeout(500)
|
||||
await page.screenshot({ path: join(SHOTS, 'gpus-connect-drawer.png'), fullPage: true })
|
||||
|
||||
await ctx.close()
|
||||
})
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* PROOF: Insights (o11y observability) is live, IAM-gated, and renders on
|
||||
* admin.hanzo.ai for the SuperAdmin — wired to the VERSION-LESS `/v1/o11y/<resource>`
|
||||
* surface (cloud embedded o11y v1.5.4).
|
||||
*
|
||||
* Two layers, so the spec is ALWAYS runnable and honest:
|
||||
*
|
||||
* A. UNAUTHENTICATED gate proof (always runs, no creds). Proves the version-less
|
||||
* surface is LIVE and IAM-gated against the real backend:
|
||||
* - GET /v1/o11y/health → 200 {"service":"o11y","status":"ok"}
|
||||
* - POST /v1/o11y/services → 403 "no validated principal" (gated)
|
||||
* - POST /v1/o11y/query_range → 403 "no validated principal" (gated)
|
||||
* - GET /v1/o11y/rules → 403 "no validated principal" (gated)
|
||||
* i.e. anonymous is refused (403), so a logged-in bearer is REQUIRED — which is
|
||||
* exactly why the console routes o11y through the `/v1` user-bearer BFF.
|
||||
* (The deprecated `/v1/o11y/v1/rules` alias also still resolves — 403, not 404.)
|
||||
*
|
||||
* B. AUTHENTICATED render proof (runs when a SuperAdmin password is provided). Signs
|
||||
* in, establishes the shared `.hanzo.ai` session, enters admin.hanzo.ai (or falls
|
||||
* back to console.hanzo.ai — the SAME image — when the edge guard refuses), and:
|
||||
* - fetches `/v1/o11y/health` + reads through the bearer proxy: a logged-in
|
||||
* session PASSES the IAM gate (NOT 403); health is 200.
|
||||
* - navigates to Insights (Service Map · Logs · Traces · Fleet Observability) and
|
||||
* asserts each RENDERS — real o11y data when the runtime returns rows, else the
|
||||
* honest RuntimeNotice — never a crash. Screenshots each.
|
||||
*
|
||||
* Run:
|
||||
* # unauthenticated gate proof (works today, no creds):
|
||||
* BASE_URL=https://console.hanzo.ai npx playwright test insights-o11y --reporter=line
|
||||
* # full authenticated render proof (needs the SuperAdmin password):
|
||||
* HANZO_EMAIL='z@hanzo.ai' HANZO_PASSWORD='…' npx playwright test insights-o11y --reporter=line
|
||||
*
|
||||
* The SuperAdmin creds are the reserved-`admin`-org superuser (admin.hanzo.ai login).
|
||||
* If z@hanzo.ai resolves to the brand `hanzo` org (a per-org admin, not the platform
|
||||
* SuperAdmin), the per-org Insights modules STILL render for the hanzo org (o11y only
|
||||
* needs a validated principal, not the admin org); the cross-org Fleet Observability
|
||||
* board is the one surface that additionally requires `owner==admin`.
|
||||
*/
|
||||
import { test, expect, type Page, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const EMAIL = process.env.HANZO_EMAIL ?? 'z@hanzo.ai'
|
||||
const PASSWORD = process.env.HANZO_PASSWORD ?? ''
|
||||
const CONSOLE = process.env.CONSOLE_URL ?? 'https://console.hanzo.ai'
|
||||
const ADMIN = process.env.ADMIN_URL ?? 'https://admin.hanzo.ai'
|
||||
/** The real cloud backend the console's `/v1` bearer BFF forwards to (for the direct gate proof). */
|
||||
const CLOUD_API = process.env.CLOUD_API_ORIGIN ?? 'https://api.hanzo.ai'
|
||||
const SHOTS = process.env.SHOT_DIR ?? 'e2e-shots'
|
||||
|
||||
// ── o11y-shape payloads (mirrors src/lib/api/apm.ts — inlined so the spec has no
|
||||
// 'use client'/React import from the app source) ────────────────────────────────
|
||||
const nowMs = Date.now()
|
||||
const win = { startNs: String((nowMs - 3_600_000) * 1e6), endNs: String(nowMs * 1e6), startMs: nowMs - 3_600_000, endMs: nowMs }
|
||||
const servicesBody = { start: win.startNs, end: win.endNs, tags: [] as unknown[] }
|
||||
const queryRangeBody = {
|
||||
start: win.startMs,
|
||||
end: win.endMs,
|
||||
step: 60,
|
||||
compositeQuery: {
|
||||
queryType: 'builder',
|
||||
panelType: 'list',
|
||||
builderQueries: {
|
||||
A: {
|
||||
queryName: 'A',
|
||||
dataSource: 'logs',
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: {},
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: 60,
|
||||
filters: { items: [], op: 'AND' },
|
||||
groupBy: [],
|
||||
having: [],
|
||||
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
|
||||
limit: null,
|
||||
offset: 0,
|
||||
pageSize: 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/** Sign in via the console app sign-in form (email/password → session cookie). */
|
||||
async function signIn(page: Page, base: string) {
|
||||
await page.goto(`${base}/signin`)
|
||||
await page.waitForSelector('input[placeholder="Email"]', { timeout: 25_000 })
|
||||
await page.fill('input[placeholder="Email"]', EMAIL)
|
||||
await page.fill('input[placeholder="Password"]', PASSWORD)
|
||||
await page.click('button:has-text("Sign in")')
|
||||
await page.waitForFunction(() => !location.pathname.startsWith('/signin'), { timeout: 30_000 }).catch(() => {})
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
}
|
||||
|
||||
/** Body text of an APIResponse, best-effort. */
|
||||
async function body(res: { text(): Promise<string> }): Promise<string> {
|
||||
return (await res.text().catch(() => '')).slice(0, 200)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
// A. Unauthenticated gate proof — ALWAYS runs (no credentials required).
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
test.describe('Insights o11y — version-less surface is LIVE + IAM-gated (unauthenticated)', () => {
|
||||
test('GET /v1/o11y/health is 200; the reads are 403 "no validated principal"', async ({ request }: { request: APIRequestContext }) => {
|
||||
// Liveness — the version-less health endpoint the reboot ships (public).
|
||||
const health = await request.get(`${CLOUD_API}/v1/o11y/health`)
|
||||
expect(health.status(), 'version-less /v1/o11y/health must be live').toBe(200)
|
||||
const healthBody = await body(health)
|
||||
expect(healthBody, 'health should report the o11y service ok').toMatch(/o11y|ok|status|healthy/i)
|
||||
console.log(`✓ GET /v1/o11y/health → 200 :: ${healthBody}`)
|
||||
|
||||
// Every VERSION-LESS read is IAM-gated: anonymous → 403 "no validated principal".
|
||||
// This is the proof that a logged-in bearer is REQUIRED (attached by the /v1 bearer BFF).
|
||||
const gated: { name: string; res: Awaited<ReturnType<APIRequestContext['get']>> }[] = [
|
||||
{ name: 'services', res: await request.post(`${CLOUD_API}/v1/o11y/services`, { data: servicesBody }) },
|
||||
{ name: 'query_range', res: await request.post(`${CLOUD_API}/v1/o11y/query_range`, { data: queryRangeBody }) },
|
||||
{ name: 'rules', res: await request.get(`${CLOUD_API}/v1/o11y/rules`) },
|
||||
]
|
||||
for (const g of gated) {
|
||||
expect(g.res.status(), `version-less /v1/o11y/${g.name} must be IAM-gated (403) for an anonymous caller`).toBe(403)
|
||||
expect(await body(g.res)).toMatch(/no validated principal|principal|unauthor/i)
|
||||
console.log(`✓ /v1/o11y/${g.name} → 403 (IAM-gated, anonymous refused)`)
|
||||
}
|
||||
|
||||
// The deprecated nested-version alias still RESOLVES (gated, not a 404) — canonical
|
||||
// is version-less, but the old form remains addressable during migration.
|
||||
const alias = await request.get(`${CLOUD_API}/v1/o11y/v1/rules`)
|
||||
expect(alias.status(), 'deprecated /v1/o11y/v1/rules alias should resolve (403), not 404').toBe(403)
|
||||
console.log('✓ deprecated /v1/o11y/v1/rules alias resolves (403, not 404) — version-less is canonical')
|
||||
})
|
||||
})
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
// B. Authenticated render proof — runs when a SuperAdmin password is provided.
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
test.describe('Insights renders on admin.hanzo.ai for the SuperAdmin (authenticated)', () => {
|
||||
test.skip(!PASSWORD, 'HANZO_PASSWORD (SuperAdmin) not set — authenticated render proof staged')
|
||||
|
||||
/** Sign in on console (sets the `.hanzo.ai` session) and pick the admin surface if it admits. */
|
||||
async function enter(page: Page): Promise<string> {
|
||||
await signIn(page, CONSOLE)
|
||||
// admin.hanzo.ai carries an edge forward-auth guard (`admin-guard@file`, org=admin).
|
||||
// The shared `.hanzo.ai` session set above may admit the SuperAdmin; if the guard
|
||||
// still refuses, fall back to console.hanzo.ai — the SAME image + o11y wiring.
|
||||
const probe = await page.request.get(`${ADMIN}/`)
|
||||
if (probe.status() === 200) {
|
||||
console.log('✓ admin.hanzo.ai admitted the session — proving on the admin surface')
|
||||
return ADMIN
|
||||
}
|
||||
console.log(`ℹ admin.hanzo.ai edge-guard → ${probe.status()} for the shared session; proving on console.hanzo.ai (same image + o11y wiring)`)
|
||||
return CONSOLE
|
||||
}
|
||||
|
||||
test('o11y reads pass the IAM gate through the /v1 bearer BFF (NOT 403 when signed in)', async ({ page }) => {
|
||||
const surface = await enter(page)
|
||||
|
||||
// Health through the bearer proxy — 200 JSON (NOT the SPA shell / 403).
|
||||
const health = await page.request.get(`${surface}/v1/o11y/health`)
|
||||
expect(health.status(), 'authenticated /v1/o11y/health must be 200').toBe(200)
|
||||
const hb = await body(health)
|
||||
expect(hb, 'health must be JSON from o11y, not the SPA shell').toMatch(/o11y|ok|status|healthy/i)
|
||||
expect(hb, 'health must not be the HTML app shell').not.toMatch(/<!DOCTYPE html>|<html/i)
|
||||
console.log(`✓ authenticated /v1/o11y/health → 200 :: ${hb}`)
|
||||
|
||||
// The gated reads: a logged-in session's minted bearer PASSES the IAM gate. The
|
||||
// runtime may answer 200 (rows or honest-empty) or 503 (initializing) — but NEVER
|
||||
// 403 "no validated principal" (which the anonymous caller got in proof A).
|
||||
const reads: { name: string; res: Awaited<ReturnType<typeof page.request.post>> }[] = [
|
||||
{ name: 'services', res: await page.request.post(`${surface}/v1/o11y/services`, { data: servicesBody }) },
|
||||
{ name: 'query_range', res: await page.request.post(`${surface}/v1/o11y/query_range`, { data: queryRangeBody }) },
|
||||
{ name: 'rules', res: await page.request.get(`${surface}/v1/o11y/rules`) },
|
||||
]
|
||||
for (const r of reads) {
|
||||
expect(r.res.status(), `/v1/o11y/${r.name} must PASS the IAM gate (not 403) for a signed-in session`).not.toBe(403)
|
||||
console.log(`✓ authenticated /v1/o11y/${r.name} → ${r.res.status()} (bearer passed the gate)`)
|
||||
}
|
||||
})
|
||||
|
||||
test('Insights modules render (Service Map · Logs · Traces · Fleet Observability)', async ({ page }) => {
|
||||
const surface = await enter(page)
|
||||
|
||||
// Each Observe module must MOUNT and render either real o11y data or the honest
|
||||
// RuntimeNotice/empty state — and never a crash / error boundary / blank.
|
||||
const modules: { id: string; label: string; expect: RegExp }[] = [
|
||||
{ id: 'service-map', label: 'Service Map', expect: /Service Map|Rate|Errors|Duration|p99|dependency|Observability|no telemetry|not enabled|initializing/i },
|
||||
{ id: 'logs', label: 'Logs', expect: /Logs|Application logs|Request activity|Severity|Message|no application logs|Observability|initializing/i },
|
||||
{ id: 'o11y', label: 'Traces', expect: /Traces|Trace|Latency|Tokens|Cost|Observability|No traces|initializing|not enabled/i },
|
||||
{ id: 'fleet-o11y', label: 'Fleet Observability', expect: /Fleet Observability|Requests|Tokens|Latency|Top organizations|operator access|not authorized/i },
|
||||
]
|
||||
for (const m of modules) {
|
||||
await page.goto(`${surface}/${m.id}`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page, `${m.label} bounced to sign-in`).not.toHaveURL(/\/signin/, { timeout: 15_000 })
|
||||
// No React error boundary / hard crash.
|
||||
await expect(page.locator('text=/something went wrong|application error|Unexpected token|this page could not be found/i'),
|
||||
`${m.label} crashed`).toHaveCount(0)
|
||||
// The module rendered its own surface (real data OR an honest state).
|
||||
await expect(page.getByText(m.expect).first(), `${m.label} did not render`).toBeVisible({ timeout: 30_000 })
|
||||
await page.screenshot({ path: `${SHOTS}/insights-${m.id}.png`, fullPage: true })
|
||||
console.log(`✓ ${m.label} (/${m.id}) rendered — screenshot e2e-shots/insights-${m.id}.png`)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* e2e: Interactive Training (Fine-tuning → Interactive tab) — mocked-network render proof.
|
||||
*
|
||||
* Runs against a LOCAL server (BASE_URL=http://localhost:4000) with the whole network
|
||||
* mocked (same pattern as budgets-responsive / entitlement-sidebar): `/auth/session` → a
|
||||
* super-admin account so the shell mounts and the entitlement gate is bypassed, the engine
|
||||
* training plane (`/v1/training/clients` + `/clients/<id>`) → real-shaped fixtures,
|
||||
* everything else → an empty-ok envelope.
|
||||
*
|
||||
* It proves the ENGINE plane surface: the Interactive tab renders, the New-client form
|
||||
* validates an empty base_model (Create disabled until a model is typed), a mocked client
|
||||
* row reports status `ready`, and selecting it renders the loss-curve chart region.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test interactive-training
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
const ORG = 'hanzo'
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
const ACCOUNT = {
|
||||
owner: ORG,
|
||||
name: 'z',
|
||||
type: 'normal-user',
|
||||
email: 'z@hanzo.ai',
|
||||
displayName: 'Z Admin',
|
||||
isSuperAdmin: true,
|
||||
isGlobalAdmin: true,
|
||||
isAdmin: true,
|
||||
signupApplication: 'hanzo-cloud',
|
||||
}
|
||||
|
||||
const LORA = { rank: 16, alpha: 32, target_modules: ['q_proj', 'k_proj', 'v_proj', 'o_proj', 'gate_proj', 'up_proj', 'down_proj'] }
|
||||
const CLIENT = {
|
||||
id: 'client_demo',
|
||||
base_model: 'HuggingFaceTB/SmolLM2-135M',
|
||||
status: 'ready',
|
||||
lora_config: LORA,
|
||||
trainable_params: 442368,
|
||||
forward_backward_calls: 3,
|
||||
optim_steps: 2,
|
||||
last_loss: 1.234,
|
||||
}
|
||||
const DETAIL = { ...CLIENT, loss_history: [2.4, 2.0, 1.7, 1.5, 1.234] }
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const path = url.pathname
|
||||
|
||||
if (path === '/auth/session') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ account: ACCOUNT, expiresIn: 3600 }) })
|
||||
}
|
||||
if (path.startsWith('/auth/')) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
|
||||
}
|
||||
// The engine training plane under test — the clean `/v1/training/*` the browser calls
|
||||
// (next.config dispatches it to the `/ai` bearer proxy server-side; the mock short-circuits).
|
||||
if (path === '/v1/training/clients') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ clients: [CLIENT] }) })
|
||||
}
|
||||
if (path === `/v1/training/clients/${CLIENT.id}`) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(DETAIL) })
|
||||
}
|
||||
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(path)) return route.continue()
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }) })
|
||||
}
|
||||
|
||||
async function openInteractive(page: Page) {
|
||||
await page.addInitScript((org) => {
|
||||
try {
|
||||
localStorage.setItem('hanzo.console.org', org)
|
||||
localStorage.setItem('hanzo.console.org.selected', '1')
|
||||
localStorage.setItem('hz_admin_banner_dismissed', '1')
|
||||
// Skip the first-run onboarding wizard so the console surface mounts directly.
|
||||
localStorage.setItem(`hz_onboarding_done:${org}`, '1')
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}, ORG)
|
||||
await page.route('**/*', mock)
|
||||
await page.goto(`${BASE_URL}/finetuning/interactive`, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForTimeout(1500)
|
||||
}
|
||||
|
||||
test('Interactive tab renders the engine plane, validates create, shows a ready client + loss chart', async ({ browser }) => {
|
||||
mkdirSync(SHOTS, { recursive: true })
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
|
||||
const page = await ctx.newPage()
|
||||
await openInteractive(page)
|
||||
|
||||
// No hard crash / bounce to sign-in.
|
||||
await expect(page).not.toHaveURL(/\/signin/)
|
||||
await expect(page.locator('text=/Application error|Unhandled Runtime Error/i')).toHaveCount(0)
|
||||
|
||||
// 1. The Interactive tab surface rendered (its unique sub-header copy).
|
||||
await expect(page.getByText(/Create a live LoRA client/i)).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// 2. A mocked client row reports status `ready`.
|
||||
await expect(page.getByText('client_demo').first()).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByText('ready', { exact: true }).first()).toBeVisible()
|
||||
|
||||
// 3. The New-client form validates an empty base_model: Create is disabled until a
|
||||
// model is typed.
|
||||
await page.getByRole('button', { name: 'New client' }).first().click()
|
||||
const baseInput = page.getByPlaceholder('HuggingFaceTB/SmolLM2-135M')
|
||||
await expect(baseInput).toBeVisible({ timeout: 10_000 })
|
||||
const create = page.getByRole('button', { name: 'Create client' })
|
||||
await expect(create).toBeDisabled()
|
||||
await baseInput.fill('HuggingFaceTB/SmolLM2-135M')
|
||||
await expect(create).toBeEnabled()
|
||||
await page.screenshot({ path: join(SHOTS, 'interactive-training-clients.png'), fullPage: true })
|
||||
|
||||
// 4. Selecting the client renders its loss-curve chart region (real loss_history).
|
||||
await page.getByText('client_demo').first().click()
|
||||
await expect(page.getByText('Loss curve')).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByText(/steps · last/)).toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, 'interactive-training-detail.png'), fullPage: true })
|
||||
|
||||
await ctx.close()
|
||||
})
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* e2e: visual-polish + responsive + a11y regression guards (v8.4.112).
|
||||
*
|
||||
* Locks the fixes from the state-of-the-art QA pass so they can't silently
|
||||
* regress:
|
||||
* - the body is a hard no-horizontal-scroll surface (overflow-x guard),
|
||||
* - a global :focus-visible keyboard ring exists,
|
||||
* - the overview loads REAL data (KPI numbers, not skeletons),
|
||||
* - the per-product quick-links band navigates to the right destination,
|
||||
* - the GPU Launch drawer shows the prepay/card gate (never credit-fundable),
|
||||
* - the model catalog renders DISTINCT per-family brand icons,
|
||||
* - the sidebar collapses to a hamburger drawer on mobile,
|
||||
* - top-bar tap targets are ≥44px on a touch (coarse) pointer.
|
||||
*
|
||||
* The PUBLIC block runs with no credentials (it exercises /signin + the shipped
|
||||
* CSS floor) so it always runs in CI. The AUTHENTICATED block gates on
|
||||
* HANZO_PASSWORD (the repo convention) — it needs the Dave/maxpower-class session.
|
||||
*
|
||||
* Credentials (env, never in repo):
|
||||
* HANZO_EMAIL default z@hanzo.ai
|
||||
* HANZO_PASSWORD required for the authenticated block (skips when unset)
|
||||
* BASE_URL default https://console.hanzo.ai
|
||||
*
|
||||
* Run: pnpm e2e polish-qa.spec.ts
|
||||
* HANZO_PASSWORD=xxx pnpm e2e polish-qa.spec.ts
|
||||
*/
|
||||
import { test, expect, devices, type Page } from '@playwright/test'
|
||||
|
||||
const EMAIL = process.env.HANZO_EMAIL ?? 'z@hanzo.ai'
|
||||
const PASSWORD = process.env.HANZO_PASSWORD ?? ''
|
||||
const BASE_URL = process.env.BASE_URL ?? 'https://console.hanzo.ai'
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function signIn(page: Page) {
|
||||
await page.goto(`${BASE_URL}/signin`)
|
||||
await page.waitForSelector('input[placeholder="Email"]', { timeout: 20_000 })
|
||||
await page.fill('input[placeholder="Email"]', EMAIL)
|
||||
await page.fill('input[placeholder="Password"]', PASSWORD)
|
||||
await page.click('button:has-text("Sign in")')
|
||||
const base = new URL(BASE_URL).origin
|
||||
await page.waitForURL((url) => url.origin === base && url.pathname === '/', { timeout: 30_000 })
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
}
|
||||
|
||||
/** Widest element beyond the viewport right edge (a real horizontal-overflow culprit
|
||||
* in NORMAL flow — excludes off-screen transform:translateX drawers, which clip). */
|
||||
async function horizontalOverflow(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const de = document.documentElement
|
||||
return { scrollW: de.scrollWidth, clientW: de.clientWidth, overflow: de.scrollWidth > de.clientWidth + 1 }
|
||||
})
|
||||
}
|
||||
|
||||
// ── PUBLIC — always runs (no credentials) ────────────────────────────────────
|
||||
|
||||
test.describe('console polish — public (CSS floor + responsive)', () => {
|
||||
for (const [name, width, height] of [
|
||||
['mobile', 390, 844],
|
||||
['tablet', 768, 1024],
|
||||
['desktop', 1440, 900],
|
||||
] as const) {
|
||||
test(`no horizontal body scroll on /signin — ${name} ${width}×${height}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height })
|
||||
await page.goto(`${BASE_URL}/signin`)
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
const { overflow, scrollW, clientW } = await horizontalOverflow(page)
|
||||
expect(overflow, `document scrolls sideways (${scrollW} > ${clientW})`).toBe(false)
|
||||
})
|
||||
}
|
||||
|
||||
test('body carries the overflow-x guard (never a sideways-scrolling document)', async ({ page }) => {
|
||||
await page.goto(`${BASE_URL}/signin`)
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
const overflowX = await page.evaluate(() => getComputedStyle(document.body).overflowX)
|
||||
// `clip` (preferred) or `hidden` — either bans a horizontal scroll container.
|
||||
expect(['clip', 'hidden']).toContain(overflowX)
|
||||
})
|
||||
|
||||
test('a global :focus-visible keyboard ring is defined', async ({ page }) => {
|
||||
await page.goto(`${BASE_URL}/signin`)
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
// The rule is compiled into a same-origin stylesheet — scan for it (proves the
|
||||
// a11y floor shipped, independent of any element's own focus style).
|
||||
const hasRule = await page.evaluate(() => {
|
||||
for (const sheet of Array.from(document.styleSheets)) {
|
||||
let rules: CSSRuleList
|
||||
try {
|
||||
rules = sheet.cssRules
|
||||
} catch {
|
||||
continue // cross-origin sheet — skip
|
||||
}
|
||||
for (const rule of Array.from(rules)) {
|
||||
const t = (rule as CSSStyleRule).selectorText
|
||||
if (t && t.includes(':focus-visible') && (rule as CSSStyleRule).style?.outlineStyle) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
expect(hasRule, ':focus-visible outline rule not found in any stylesheet').toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ── AUTHENTICATED — the console shell (gates on HANZO_PASSWORD) ───────────────
|
||||
|
||||
test.describe('console polish — authenticated shell', () => {
|
||||
test.skip(!PASSWORD, 'HANZO_PASSWORD not set — skipping authenticated polish checks')
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test('overview loads REAL data (KPI numbers, not skeletons)', async ({ page }) => {
|
||||
await signIn(page)
|
||||
// The living overview renders count-up KPI tiles with real figures.
|
||||
await expect(page.locator('text=/Inference tokens|Spend|Requests|Active models/i').first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
// At least one KPI shows a concrete numeric value (k/M/$/%, not just "—").
|
||||
const body = (await page.locator('body').innerText()) || ''
|
||||
expect(/\$\s?\d|[\d.]+\s?[kKmM]\b|\d+%/.test(body), 'no real KPI figure on the overview').toBe(true)
|
||||
})
|
||||
|
||||
test('per-product quick-links band navigates to the scoped destination', async ({ page }) => {
|
||||
await signIn(page)
|
||||
await page.goto(`${BASE_URL}/models`, { waitUntil: 'domcontentloaded' })
|
||||
// The band shows BILLING / USAGE / METRICS with → links.
|
||||
const cost = page.locator('text=/Cost reports/i').first()
|
||||
await expect(cost).toBeVisible({ timeout: 20_000 })
|
||||
await cost.click()
|
||||
// Lands on a billing/cost surface (never a 404 / access-required).
|
||||
await expect(page).toHaveURL(/billing|cost/i, { timeout: 15_000 })
|
||||
await expect(page.locator('text=/404|could not be found|Access required/i')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('GPU Launch drawer shows the prepay/CARD gate (credits never fund GPUs)', async ({ page }) => {
|
||||
await signIn(page)
|
||||
await page.goto(`${BASE_URL}/gpus`, { waitUntil: 'domcontentloaded' })
|
||||
await page.locator('button:has-text("Launch GPU")').first().click()
|
||||
// The drawer's gate copy is the exact prepay/card contract.
|
||||
await expect(page.locator('text=/Prepay only/i').first()).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.locator("text=/Granted credits can.?t be used for GPUs/i").first()).toBeVisible()
|
||||
await expect(page.locator('text=/Add a payment card/i').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Machines launch is CREDIT-funded (distinct from the GPU card gate)', async ({ page }) => {
|
||||
await signIn(page)
|
||||
await page.goto(`${BASE_URL}/machines`, { waitUntil: 'domcontentloaded' })
|
||||
// CPU machines fund from the Hanzo credit balance — no card required.
|
||||
await expect(page.locator('text=/Hanzo credit|charged to credits|no card required/i').first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
})
|
||||
|
||||
test('model catalog renders DISTINCT per-family brand icons', async ({ page }) => {
|
||||
await signIn(page)
|
||||
await page.goto(`${BASE_URL}/models`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('text=/Search models across every family/i')).toBeVisible({ timeout: 20_000 })
|
||||
// Each family header icon carries its own brand background colour (Zen light,
|
||||
// Qwen #615CED, Meta #0866FF, DeepSeek #4D6BFE, Mistral #FA520F, Google #1A73E8,
|
||||
// OpenAI black). Collect the distinct colours behind the family marks.
|
||||
const distinct = await page.evaluate(() => {
|
||||
const colours = new Set<string>()
|
||||
document.querySelectorAll('[style*="background"]').forEach((el) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
if (r.width >= 24 && r.width <= 56 && Math.abs(r.width - r.height) <= 8) {
|
||||
const bg = getComputedStyle(el as HTMLElement).backgroundColor
|
||||
if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') colours.add(bg)
|
||||
}
|
||||
})
|
||||
return colours.size
|
||||
})
|
||||
// At least 3 distinct brand colours ⇒ icons are NOT one generic circle.
|
||||
expect(distinct, 'family icons are not visibly distinct').toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
test('sidebar collapses to a hamburger drawer on mobile', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ ...devices['iPhone 13'] })
|
||||
const page = await ctx.newPage()
|
||||
try {
|
||||
await signIn(page)
|
||||
await page.goto(`${BASE_URL}/models`, { waitUntil: 'domcontentloaded' })
|
||||
// Persistent sidebar (the "Filter products…" search) is hidden below lg.
|
||||
const sidebarFilter = page.locator('input[placeholder="Filter products…"]')
|
||||
await expect(sidebarFilter).toBeHidden({ timeout: 15_000 }).catch(() => {})
|
||||
// The hamburger opens the SAME nav as a drawer.
|
||||
await page.locator('button[aria-label="Open navigation"]').click()
|
||||
await expect(page.locator('text=/Overview/i').first()).toBeVisible({ timeout: 10_000 })
|
||||
// No horizontal body scroll on mobile.
|
||||
const { overflow } = await horizontalOverflow(page)
|
||||
expect(overflow, 'mobile document scrolls sideways').toBe(false)
|
||||
} finally {
|
||||
await ctx.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('top-bar tap targets are ≥44px on a touch pointer', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ ...devices['iPhone 13'] })
|
||||
const page = await ctx.newPage()
|
||||
try {
|
||||
await signIn(page)
|
||||
await page.goto(`${BASE_URL}/models`, { waitUntil: 'domcontentloaded' })
|
||||
// Wait for the shell top bar to render.
|
||||
await page.locator('button[aria-label="Open navigation"]').waitFor({ state: 'visible', timeout: 15_000 })
|
||||
const small = await page.evaluate(() => {
|
||||
const bad: { label: string; w: number; h: number }[] = []
|
||||
document.querySelectorAll('.hz-topbar button').forEach((el) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
if (r.width > 0 && (r.width < 44 || r.height < 44)) {
|
||||
bad.push({ label: el.getAttribute('aria-label') || '(icon)', w: Math.round(r.width), h: Math.round(r.height) })
|
||||
}
|
||||
})
|
||||
return bad
|
||||
})
|
||||
expect(small, `top-bar controls under 44px: ${JSON.stringify(small)}`).toEqual([])
|
||||
} finally {
|
||||
await ctx.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
+40
-73
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* LIVE probe of the o11y (SigNoz) backend through the console's own /cloud bearer
|
||||
* proxy. Logs in as z@hanzo.ai and, from the AUTHENTICATED page context, fetches
|
||||
* each candidate endpoint exactly as the new Observe modules will — same-origin
|
||||
* `<origin>/v1/o11y/*` (rewritten to `/cloud/v1/o11y/*`, cloud rewrites to the o11y
|
||||
* runtime's `/api/*`). Prints the HTTP status + a body snippet per endpoint so we
|
||||
* know what returns real data vs 404 (which must be flagged) before we build.
|
||||
* LIVE probe of the o11y (O11y) backend through the console's own /v1 bearer BFF.
|
||||
* Logs in as z@hanzo.ai and, from the AUTHENTICATED page context, fetches each
|
||||
* candidate endpoint exactly as the Observe modules now do — same-origin
|
||||
* `<origin>/v1/o11y/<resource>` (the VERSION-LESS canonical surface; the `/v1`
|
||||
* catch-all mints the caller's IAM bearer). Prints the HTTP status + a body
|
||||
* snippet per endpoint so we know what returns real data vs 404/403 before we build.
|
||||
*
|
||||
* Not a pass/fail test — a discovery harness. Run:
|
||||
* BASE_URL=https://console.hanzo.ai HANZO_PASSWORD='…' npx playwright test probe-o11y --reporter=line
|
||||
@@ -33,7 +33,7 @@ async function signIn(page: Page) {
|
||||
await page.waitForTimeout(2500)
|
||||
}
|
||||
|
||||
/** now() epoch — SigNoz wants ns for services/errors, ms for infra. */
|
||||
/** now() epoch — O11y wants ns for services/errors, ms for infra. */
|
||||
const nowMs = Date.now()
|
||||
const endNs = String(nowMs * 1_000_000)
|
||||
const startNs = String((nowMs - 60 * 60 * 1000) * 1_000_000) // 1h window
|
||||
@@ -43,78 +43,45 @@ const startMs = nowMs - 60 * 60 * 1000
|
||||
type Probe = { name: string; path: string; method: 'GET' | 'POST'; body?: unknown }
|
||||
|
||||
const PROBES: Probe[] = [
|
||||
// ── Dashboards (SigNoz) ──
|
||||
{ name: 'dashboards.list', path: 'o11y/v1/dashboards', method: 'GET' },
|
||||
{ name: 'dashboards.v2', path: 'o11y/v2/dashboards', method: 'GET' },
|
||||
// ── Health (sanity: proves the runtime is reachable) ──
|
||||
{ name: 'health', path: 'o11y/health', method: 'GET' },
|
||||
{ name: 'version', path: 'o11y/version', method: 'GET' },
|
||||
// ── Dashboards ──
|
||||
{ name: 'dashboards.list', path: 'o11y/dashboards', method: 'GET' },
|
||||
// ── Service map / APM ──
|
||||
{ name: 'services.list', path: 'o11y/v1/services/list', method: 'GET' },
|
||||
{ name: 'services.list', path: 'o11y/services/list', method: 'GET' },
|
||||
{ name: 'services', path: 'o11y/services', method: 'POST', body: { start: startNs, end: endNs, tags: [] } },
|
||||
{ name: 'dependency_graph', path: 'o11y/dependency_graph', method: 'POST', body: { start: startNs, end: endNs, tags: [] } },
|
||||
{ name: 'service.top_operations', path: 'o11y/service/top_operations', method: 'POST', body: { start: startNs, end: endNs, service: '' } },
|
||||
// ── Logs / traces (the one true read — composite query_range) ──
|
||||
{
|
||||
name: 'services',
|
||||
path: 'o11y/v1/services',
|
||||
name: 'query_range',
|
||||
path: 'o11y/query_range',
|
||||
method: 'POST',
|
||||
body: { start: startNs, end: endNs, tags: [] },
|
||||
},
|
||||
{
|
||||
name: 'dependency_graph',
|
||||
path: 'o11y/v1/dependency_graph',
|
||||
method: 'POST',
|
||||
body: { start: startNs, end: endNs, tags: [] },
|
||||
},
|
||||
{
|
||||
name: 'service.top_operations',
|
||||
path: 'o11y/v1/service/top_operations',
|
||||
method: 'POST',
|
||||
body: { start: startNs, end: endNs, service: '' },
|
||||
body: {
|
||||
start: startMs,
|
||||
end: endMs,
|
||||
step: 60,
|
||||
compositeQuery: {
|
||||
queryType: 'builder',
|
||||
panelType: 'list',
|
||||
builderQueries: {
|
||||
A: { queryName: 'A', dataSource: 'logs', aggregateOperator: 'noop', aggregateAttribute: {}, expression: 'A', disabled: false, stepInterval: 60, filters: { items: [], op: 'AND' }, groupBy: [], having: [], orderBy: [{ columnName: 'timestamp', order: 'desc' }], limit: null, offset: 0, pageSize: 50 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// ── Infra ──
|
||||
{
|
||||
name: 'hosts.list',
|
||||
path: 'o11y/v1/hosts/list',
|
||||
method: 'POST',
|
||||
body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } },
|
||||
},
|
||||
{
|
||||
name: 'pods.list',
|
||||
path: 'o11y/v1/pods/list',
|
||||
method: 'POST',
|
||||
body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } },
|
||||
},
|
||||
{
|
||||
name: 'nodes.list',
|
||||
path: 'o11y/v1/nodes/list',
|
||||
method: 'POST',
|
||||
body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } },
|
||||
},
|
||||
{
|
||||
name: 'namespaces.list',
|
||||
path: 'o11y/v1/namespaces/list',
|
||||
method: 'POST',
|
||||
body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } },
|
||||
},
|
||||
{
|
||||
name: 'clusters.list',
|
||||
path: 'o11y/v1/clusters/list',
|
||||
method: 'POST',
|
||||
body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } },
|
||||
},
|
||||
{ name: 'hosts.list', path: 'o11y/hosts/list', method: 'POST', body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } } },
|
||||
{ name: 'pods.list', path: 'o11y/pods/list', method: 'POST', body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } } },
|
||||
{ name: 'nodes.list', path: 'o11y/nodes/list', method: 'POST', body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } } },
|
||||
{ name: 'namespaces.list', path: 'o11y/namespaces/list', method: 'POST', body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } } },
|
||||
{ name: 'clusters.list', path: 'o11y/clusters/list', method: 'POST', body: { start: startMs, end: endMs, filters: { op: 'AND', items: [] } } },
|
||||
// ── Exceptions ──
|
||||
{
|
||||
name: 'listErrors',
|
||||
path: 'o11y/v1/listErrors',
|
||||
method: 'POST',
|
||||
body: { start: startNs, end: endNs, limit: 50, order: 'descending', orderParam: 'exceptionCount' },
|
||||
},
|
||||
{
|
||||
name: 'countErrors',
|
||||
path: 'o11y/v1/countErrors',
|
||||
method: 'POST',
|
||||
body: { start: startNs, end: endNs },
|
||||
},
|
||||
// ── Health (sanity: proves the runtime is reachable) ──
|
||||
{ name: 'health', path: 'o11y/v1/health', method: 'GET' },
|
||||
{ name: 'version', path: 'o11y/v1/version', method: 'GET' },
|
||||
// ── Alerts (known-good baseline — AlertsModule already uses this) ──
|
||||
{ name: 'rules', path: 'o11y/v1/rules', method: 'GET' },
|
||||
{ name: 'listErrors', path: 'o11y/listErrors', method: 'POST', body: { start: startNs, end: endNs, limit: 50, order: 'descending', orderParam: 'exceptionCount' } },
|
||||
{ name: 'countErrors', path: 'o11y/countErrors', method: 'POST', body: { start: startNs, end: endNs } },
|
||||
// ── Alerts ──
|
||||
{ name: 'rules', path: 'o11y/rules', method: 'GET' },
|
||||
]
|
||||
|
||||
test('probe o11y endpoints (live, authenticated)', async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* e2e: admin.hanzo.ai Provider Billing board (feat/admin-provider-billing).
|
||||
*
|
||||
* TWO layers, mirroring insights-o11y.spec:
|
||||
* (A) FIXTURE render — runs against a LOCAL server (BASE_URL=http://localhost:4000)
|
||||
* with the network mocked (budgets-responsive pattern): `/auth/session` → a
|
||||
* global admin so the admin shell mounts, and the two contract endpoints
|
||||
* (`/v1/admin/providers/credit`, `/v1/admin/usage/funding`) → the DIGITALOCEAN
|
||||
* $26k credit + glm-5.2 funding-split fixture. Proves the credit card + the
|
||||
* credit-vs-paid split RENDER, at desktop AND mobile (no horizontal scroll),
|
||||
* with screenshots. This is the develop-against-the-contract proof.
|
||||
* (B) LIVE — the fail-closed gate proof (`/v1/admin/providers/credit` +
|
||||
* `/v1/admin/usage/funding` → 403 unauthenticated) ALWAYS runs; the
|
||||
* authenticated render against REAL DO data is STAGED behind HANZO_PASSWORD
|
||||
* (the reserved-admin SuperAdmin secret) + a deployed image.
|
||||
*
|
||||
* Run fixture: BASE_URL=http://localhost:4000 npx playwright test provider-billing
|
||||
* Run live: HANZO_PASSWORD=… CONSOLE_URL=https://admin.hanzo.ai npx playwright test provider-billing
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
// A SuperAdmin via the isGlobalAdmin/isSuperAdmin CLAIM (what the admin:true module
|
||||
// gates on — `useIsSuperAdmin`). owner is a normal org so the OrgGate resolves the
|
||||
// current org locally instead of demanding a pick from the (mocked-empty) org list;
|
||||
// the real reserved-`admin`-org SuperAdmin is exercised by the LIVE (B) test.
|
||||
const ACCOUNT = {
|
||||
owner: 'admin',
|
||||
name: 'z',
|
||||
type: 'normal-user',
|
||||
email: 'z@hanzo.ai',
|
||||
displayName: 'Z Admin',
|
||||
isGlobalAdmin: true,
|
||||
isSuperAdmin: true,
|
||||
isAdmin: true,
|
||||
signupApplication: 'hanzo-cloud',
|
||||
}
|
||||
|
||||
/** GET /v1/admin/providers/credit — the DO $26k grant (the acceptance headline),
|
||||
* plus a paid-only + a second credit provider to exercise every badge. */
|
||||
const CREDIT = [
|
||||
{ provider: 'do-ai', grant_cents: 2_600_000, burn_cents: 41_200, remaining_cents: 2_418_000, runway_days: 58, has_credit: true, is_paid_only: false },
|
||||
{ provider: 'openrouter', grant_cents: 100_000, burn_cents: 21_000, remaining_cents: 62_500, runway_days: 3, has_credit: true, is_paid_only: false },
|
||||
{ provider: 'openai-direct', grant_cents: 0, burn_cents: 8_500, remaining_cents: 0, runway_days: null, has_credit: false, is_paid_only: true },
|
||||
]
|
||||
|
||||
/** GET /v1/admin/usage/funding — glm-5.2 on DO drawn from OUR CREDIT (the live
|
||||
* usage), plus paid / paid-only / BYO rows so the split shows all four classes. */
|
||||
const FUNDING = [
|
||||
{ provider: 'do-ai', model: 'glm-5.2', funding: 'credit', tokens: 1_284_000, cost_cents: 18_200, requests: 3_120 },
|
||||
{ provider: 'openrouter', model: 'gpt-5', funding: 'paid', tokens: 92_000, cost_cents: 44_000, requests: 210 },
|
||||
{ provider: 'openai-direct', model: 'gpt-5-mini', funding: 'paid_only', tokens: 30_000, cost_cents: 9_000, requests: 140 },
|
||||
{ provider: 'anthropic', model: 'claude-opus-4.6', funding: 'byo', tokens: 50_000, cost_cents: 0, requests: 80 },
|
||||
]
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const path = url.pathname
|
||||
|
||||
if (path === '/auth/session') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ account: ACCOUNT, expiresIn: 3600 }) })
|
||||
}
|
||||
if (path.startsWith('/auth/')) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
|
||||
}
|
||||
// The two contract endpoints — bare arrays, exactly as documented.
|
||||
if (path === '/v1/admin/providers/credit') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(CREDIT) })
|
||||
}
|
||||
if (path === '/v1/admin/usage/funding') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(FUNDING) })
|
||||
}
|
||||
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(path)) return route.continue()
|
||||
// Any other data call → an honest empty-ok envelope so the shell is quiet.
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }) })
|
||||
}
|
||||
|
||||
async function openBoard(page: Page) {
|
||||
await page.addInitScript((org) => {
|
||||
try {
|
||||
localStorage.setItem('hanzo.console.org', org)
|
||||
localStorage.setItem('hanzo.console.org.selected', '1') // ENTERED flag — OrgGate → scoped console
|
||||
localStorage.setItem('hz_onboarding_done:' + org, '1') // skip the first-run wizard (auto-skipped on admin.* + embed)
|
||||
localStorage.setItem('hz_admin_banner_dismissed', '1')
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}, ACCOUNT.owner)
|
||||
await page.route('**/*', mock)
|
||||
await page.goto(`${BASE_URL}/provider-billing`, { waitUntil: 'domcontentloaded' })
|
||||
const content = page.locator('[data-testid="product-content"]').first()
|
||||
await content.waitFor({ state: 'attached', timeout: 20_000 })
|
||||
// Scope the readiness wait to the product CONTENT (the "Provider Billing" nav
|
||||
// label is hidden behind the off-canvas sidebar on a narrow viewport).
|
||||
await expect(content.getByText('Provider credit').first()).toBeVisible({ timeout: 20_000 })
|
||||
await page.waitForTimeout(900)
|
||||
}
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
// ─── (A) fixture render ───────────────────────────────────────────────────────
|
||||
test.describe('(A) fixture render — DO $26k credit + glm-5.2 funding split', () => {
|
||||
test('renders the credit card + funding split at a desktop viewport', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await openBoard(page)
|
||||
|
||||
// Section 1: the DO credit card — the $26k grant + remaining + runway + badge.
|
||||
await expect(page.locator('text=Provider credit').first()).toBeVisible()
|
||||
await expect(page.locator('text=do-ai').first()).toBeVisible()
|
||||
await expect(page.locator('text=$26,000.00').first()).toBeVisible() // the $26k DO grant
|
||||
await expect(page.locator('text=$24,180.00').first()).toBeVisible() // remaining
|
||||
await expect(page.locator('text=58 days').first()).toBeVisible() // runway_days
|
||||
await expect(page.locator('text=Has credit').first()).toBeVisible()
|
||||
await expect(page.locator('text=Paid-only').first()).toBeVisible() // openai-direct badge
|
||||
|
||||
// Section 2: the credit-vs-paid split — all four funding classes + the glm-5.2 row.
|
||||
await expect(page.locator('text=Credit vs paid usage').first()).toBeVisible()
|
||||
await expect(page.locator('text=Our credit').first()).toBeVisible()
|
||||
await expect(page.locator('text=glm-5.2').first()).toBeVisible()
|
||||
await expect(page.locator('text=BYO key').first()).toBeVisible()
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'provider-billing-desktop.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('reflows with no horizontal body scroll at a narrow (mobile) viewport', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
|
||||
const page = await ctx.newPage()
|
||||
await openBoard(page)
|
||||
|
||||
await expect(page.locator('text=do-ai').first()).toBeVisible()
|
||||
const overflow = await page.evaluate(() => {
|
||||
const el = document.documentElement
|
||||
return { scrollWidth: el.scrollWidth, clientWidth: el.clientWidth }
|
||||
})
|
||||
expect(overflow.scrollWidth, 'no horizontal body scroll at 390px').toBeLessThanOrEqual(overflow.clientWidth + 1)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'provider-billing-mobile.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── (B) live — fail-closed gate always runs; real-DO render staged ───────────
|
||||
const CONSOLE = process.env.CONSOLE_URL ?? 'https://console.hanzo.ai'
|
||||
const PASSWORD = process.env.HANZO_PASSWORD ?? ''
|
||||
const EMAIL = process.env.HANZO_EMAIL ?? 'z@hanzo.ai'
|
||||
|
||||
test.describe('(B) LIVE — admin gate + real DO data', () => {
|
||||
test('fail-closed: /v1/admin/{providers/credit,usage/funding} → 403 unauthenticated', async ({ request }) => {
|
||||
for (const p of ['providers/credit', 'usage/funding']) {
|
||||
const res = await request.get(`${CONSOLE}/v1/admin/${p}`)
|
||||
// Fail-closed: an unauthenticated caller NEVER gets data. Post-deploy this is
|
||||
// the 403 global-admin gate (like every other /v1/admin/* head); before the
|
||||
// sibling endpoint deploys the route may 404 — both are "not open". Never 200.
|
||||
expect(res.status(), `${CONSOLE}/v1/admin/${p} must be fail-closed (>=401)`).toBeGreaterThanOrEqual(401)
|
||||
expect(res.status(), `${CONSOLE}/v1/admin/${p} must not 5xx`).toBeLessThan(500)
|
||||
}
|
||||
})
|
||||
|
||||
test('authenticated: the board renders REAL DO credit + funding for the SuperAdmin', async ({ page }) => {
|
||||
test.skip(!PASSWORD, 'HANZO_PASSWORD (reserved-admin SuperAdmin secret) not set — staged for the live deploy')
|
||||
await page.goto(`${CONSOLE}/signin`)
|
||||
await page.waitForSelector('input[placeholder="Email"]', { timeout: 25_000 })
|
||||
await page.fill('input[placeholder="Email"]', EMAIL)
|
||||
await page.fill('input[placeholder="Password"]', PASSWORD)
|
||||
await page.click('button:has-text("Sign in")')
|
||||
await page.waitForFunction(() => !location.pathname.startsWith('/signin'), { timeout: 30_000 })
|
||||
|
||||
await page.goto(`${CONSOLE}/provider-billing`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page).not.toHaveURL(/\/signin/, { timeout: 15_000 })
|
||||
// The board (not the operator-access gate) rendered for the SuperAdmin.
|
||||
await expect(page.locator('text=/Provider credit|Credit vs paid/i').first()).toBeVisible({ timeout: 30_000 })
|
||||
await expect(page.locator('text=/Operator access required/i')).toHaveCount(0)
|
||||
// Real DO data: the $26k grant / a do-ai card / glm-5.2 usage.
|
||||
await expect(page.locator('text=/do-ai|digitalocean/i').first()).toBeVisible({ timeout: 30_000 })
|
||||
await page.screenshot({ path: join(SHOTS, 'provider-billing-live-do.png'), fullPage: true })
|
||||
console.log('✓ (B) Provider Billing rendered REAL DO credit + funding for the SuperAdmin')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
[
|
||||
"overview",
|
||||
"overlord",
|
||||
"business",
|
||||
"finance",
|
||||
"fleet-customers",
|
||||
"fleet-revenue",
|
||||
"retention",
|
||||
"enablement",
|
||||
"beta-features",
|
||||
"bots",
|
||||
"vms",
|
||||
"cluster-fleet",
|
||||
"function-fleet",
|
||||
"models",
|
||||
"providers",
|
||||
"provider-admin",
|
||||
"agents",
|
||||
"inference",
|
||||
"finetuning",
|
||||
"ml-pipelines",
|
||||
"embeddings",
|
||||
"evals",
|
||||
"gpus",
|
||||
"machines",
|
||||
"containers",
|
||||
"functions",
|
||||
"edge",
|
||||
"applications",
|
||||
"app-platform",
|
||||
"vector",
|
||||
"sql",
|
||||
"kv",
|
||||
"s3",
|
||||
"datastore",
|
||||
"base",
|
||||
"records",
|
||||
"docdb",
|
||||
"gateway",
|
||||
"nodes",
|
||||
"vpc",
|
||||
"dns",
|
||||
"cdn",
|
||||
"load-balancer",
|
||||
"service-mesh",
|
||||
"iam",
|
||||
"authz",
|
||||
"kms",
|
||||
"hsm",
|
||||
"secrets",
|
||||
"mpc",
|
||||
"audit",
|
||||
"zero-trust",
|
||||
"cli",
|
||||
"sdks",
|
||||
"api",
|
||||
"integrations",
|
||||
"playground",
|
||||
"ide",
|
||||
"desktop",
|
||||
"projects",
|
||||
"tracker",
|
||||
"tenants",
|
||||
"apps",
|
||||
"environments",
|
||||
"builds",
|
||||
"registry",
|
||||
"releases",
|
||||
"pipelines",
|
||||
"clusters",
|
||||
"kubernetes",
|
||||
"logs",
|
||||
"metrics",
|
||||
"o11y",
|
||||
"service-map",
|
||||
"ai-metrics",
|
||||
"open-edition",
|
||||
"analytics",
|
||||
"dashboards",
|
||||
"alerts",
|
||||
"billing",
|
||||
"status",
|
||||
"plans",
|
||||
"trading",
|
||||
"markets",
|
||||
"settlement",
|
||||
"wallet",
|
||||
"referrals",
|
||||
"tokens",
|
||||
"networks",
|
||||
"indexer",
|
||||
"oracles",
|
||||
"attestations",
|
||||
"chat",
|
||||
"bot",
|
||||
"crm",
|
||||
"cms",
|
||||
"erp",
|
||||
"helpdesk",
|
||||
"accessibility",
|
||||
"marketplace",
|
||||
"search",
|
||||
"websearch",
|
||||
"crawl",
|
||||
"studio",
|
||||
"templates",
|
||||
"console",
|
||||
"products",
|
||||
"orders",
|
||||
"customers",
|
||||
"inventory",
|
||||
"promotions",
|
||||
"storefront",
|
||||
"api-keys",
|
||||
"settings",
|
||||
"prompts",
|
||||
"datasets",
|
||||
"experiments",
|
||||
"sessions",
|
||||
"scores",
|
||||
"score-configs",
|
||||
"annotation-queues",
|
||||
"observations",
|
||||
"users",
|
||||
"memory",
|
||||
"tasks",
|
||||
"team",
|
||||
"profile"
|
||||
]
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* e2e regression — a DIRECT load of /signin renders the sign-in form.
|
||||
*
|
||||
* ROOT CAUSE this guards: the deploy (the go:embed'd static console in hanzoai/cloud)
|
||||
* serves the SPA shell (the `/` route's index.html) for EVERY path — verified live:
|
||||
* GET / and GET /signin return byte-identical HTML. So a direct /signin load mounts the
|
||||
* dashboard tree (AuthGate), NOT the /signin route. Before the fix, AuthGate saw no
|
||||
* account and called `router.replace('/signin')`, a NO-OP at /signin, and spun on the
|
||||
* loader forever (inputs=0, buttons=0). Reaching /signin as a REDIRECT target (from
|
||||
* `/`, `/projects`, …) worked because the URL changed. This asserts the direct entry
|
||||
* now resolves to the form. The fix: AuthGate + the /signin route both render the ONE
|
||||
* `<SignIn/>` component, so /signin resolves to the form without depending on a nav.
|
||||
*
|
||||
* Runs LOGGED OUT (a fresh context): the live get-account is anonymous → not signed in.
|
||||
* Works against the live console (BASE_URL default) OR a local SPA-fallback server
|
||||
* (BASE_URL=http://localhost:4173 serving out/ with index.html as the catch-all).
|
||||
*/
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'https://console.hanzo.ai'
|
||||
|
||||
test.describe('direct /signin renders the sign-in form (SPA-fallback regression)', () => {
|
||||
test('a hard load of /signin shows inputs + buttons, not an infinite spinner', async ({ browser }) => {
|
||||
// Fresh, cookie-less context → a logged-out visitor (anonymous get-account).
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
|
||||
await page.goto(`${BASE_URL}/signin`, { waitUntil: 'domcontentloaded' })
|
||||
|
||||
// The credential form: email + password inputs and at least one button. A few
|
||||
// seconds is ample — a spinner that never resolves is the bug.
|
||||
const emailInput = page.getByPlaceholder('Email')
|
||||
await expect(emailInput).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByPlaceholder('Password')).toBeVisible()
|
||||
expect(await page.locator('input').count()).toBeGreaterThanOrEqual(2)
|
||||
expect(await page.locator('button').count()).toBeGreaterThanOrEqual(1)
|
||||
|
||||
await page.screenshot({ path: 'e2e-shots/signin-direct.png' })
|
||||
await ctx.close()
|
||||
})
|
||||
})
|
||||
+8
-8
@@ -1,20 +1,20 @@
|
||||
import { defaultConfig } from '@hanzogui/config/v5'
|
||||
import { createGui } from '@hanzo/gui'
|
||||
|
||||
// Canonical Hanzo UI face: Basel Grotesk (self-hosted via app/globals.css @font-face),
|
||||
// paired with Geist Mono for code/data. Override the @hanzo/gui (Tamagui) v5 default
|
||||
// Canonical Hanzo UI face: Geist Sans (loaded via the CDN @import in app/globals.css,
|
||||
// parallel to Geist Mono for code/data). Override the @hanzo/gui (Tamagui) v5 default
|
||||
// system-font family on the body + heading fonts so every <Text>/<Paragraph>/<H*>
|
||||
// renders Basel — one place, whole product (DRY). Size/line-height/weight scales are
|
||||
// inherited from the default config; only the family swaps.
|
||||
const BASEL =
|
||||
"'Basel', -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
|
||||
// renders Geist — one place, whole product (DRY). Size/line-height/weight scales are
|
||||
// inherited from the default config; only the family swaps. Falls back to system-ui
|
||||
// if the Geist face is unavailable, so the UI degrades gracefully.
|
||||
const GEIST = "'Geist', system-ui, -apple-system, sans-serif"
|
||||
|
||||
export const config = createGui({
|
||||
...defaultConfig,
|
||||
fonts: {
|
||||
...defaultConfig.fonts,
|
||||
body: { ...defaultConfig.fonts.body, family: BASEL },
|
||||
heading: { ...defaultConfig.fonts.heading, family: BASEL },
|
||||
body: { ...defaultConfig.fonts.body, family: GEIST },
|
||||
heading: { ...defaultConfig.fonts.heading, family: GEIST },
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
+84
-35
@@ -2,6 +2,8 @@ import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
import { resolveBuildId, readGitSha } from './src/config/build-id.mjs'
|
||||
|
||||
/**
|
||||
* Hanzo Cloud Console — Next.js config.
|
||||
*
|
||||
@@ -37,51 +39,67 @@ function guiPackages() {
|
||||
}
|
||||
// @hanzo/dash and @hanzo/data ship their screens/components as ESM/TSX
|
||||
// source (no compiled dist), so the shared Base UI is transpiled here the same
|
||||
// way Gui is.
|
||||
return ['@hanzo/gui', '@hanzo/iam-js-sdk', '@hanzo/dash', '@hanzo/data', 'react-native-web', ...scoped]
|
||||
// way Gui is. @hanzo/usage ships its <UsagePanel> (`/react`) as TSX source for the
|
||||
// same reason (its headless core `.`/`/node` are compiled dist and pass through).
|
||||
return ['@hanzo/gui', '@hanzo/iam-js-sdk', '@hanzo/dash', '@hanzo/data', '@hanzo/canvas', '@hanzo/finance-ui', '@hanzo/usage', 'react-native-web', ...scoped]
|
||||
}
|
||||
|
||||
/**
|
||||
* Same-origin `/v1/*` for the AI product surface — ZERO client-visible prefix.
|
||||
* Same-origin `/v1/*` — ZERO client-visible prefix (the CTO contract: "no prefix
|
||||
* before /v1/ in any API call"). The browser ALWAYS calls its OWN origin at a clean
|
||||
* `/v1/<head>/...`, never `/cloud/...`, `/ai/...` or `/api/...`.
|
||||
*
|
||||
* The CTO contract is "no prefix before /v1/ in any API call": the browser calls
|
||||
* its OWN origin at a clean `/v1/<head>/...`, never `/cloud/...` or `/ai/...`.
|
||||
* These rewrites map exactly the AI-surface heads to the console's already-hardened
|
||||
* server-side bearer proxies (`app/cloud`, `app/ai`) — so the URL the client builds
|
||||
* is `/v1/prompts` while the request still terminates at OUR Next origin, which
|
||||
* mints a short-lived user bearer and forwards it (the raw session cookie NEVER
|
||||
* reaches cloud-api, so cloud-api carries no cookie-CSRF surface). This gives the
|
||||
* one-endpoint-form goal WITHOUT weakening the bearer trust boundary.
|
||||
* The DEFAULT terminus for a `/v1/<head>` call is the console's `app/v1/[...path]`
|
||||
* catch-all bearer proxy (→ cloud-api `/v1/*`): it mints a short-lived user bearer
|
||||
* from the session cookie and forwards it, so the raw cookie NEVER reaches cloud-api
|
||||
* (no cookie-CSRF surface) and the org is server-authoritative. That handler needs
|
||||
* NO rewrite — a clean `/v1/agents` falls straight through to it.
|
||||
*
|
||||
* Scope is deliberately the CLOSED head list the AI clients use (prompts/agents/
|
||||
* evals via /cloud, models/chat/embeddings/rerank via /ai) — a blanket `/v1/:path*`
|
||||
* would shadow paths meant for other backends. Each destination handler still
|
||||
* enforces its own least-privilege allow-list (`proxy-allow.ts`), so a rewrite can
|
||||
* never widen what the proxy admits. `beforeFiles` so these win over any route.
|
||||
* These `beforeFiles` rewrites exist ONLY to DISPATCH the heads whose backend is NOT
|
||||
* cloud-api to their own hardened same-origin proxy, while keeping the client URL a
|
||||
* clean `/v1/...`:
|
||||
* - AI gateway heads (models/chat/embeddings/rerank/… + pricing/plans) → `/ai`.
|
||||
* - Admin AGGREGATE reads/writes (`/v1/admin/{overview,usage,…}`, the cross-tenant
|
||||
* god view) → the GLOBAL-ADMIN-GATED `/admin/aggregate` proxy, which runs
|
||||
* `getAdminGate` (fail-closed 403) BEFORE forwarding (RED H1). `admin/iam` +
|
||||
* `admin/kms` are deliberately NOT rewritten — they keep their own gated proxies,
|
||||
* reached by the client's explicit `/admin/*` origin path.
|
||||
* - Visor compute CATALOG (regions/sizes, and `gpu-sizes` → visor `gpus`) → `/v1/vm`.
|
||||
*
|
||||
* The admin AGGREGATE reads (`/v1/admin/{overview,usage,orgs,audit,products}` — the
|
||||
* cross-tenant business/platform board) map to the GLOBAL-ADMIN-GATED proxy
|
||||
* (`app/admin/aggregate`), which runs `getAdminGate` (fail-closed 403) BEFORE
|
||||
* forwarding. This is the console-side server gate for the all-orgs god view (RED
|
||||
* H1) — NOT the ungated `/cloud` proxy. `admin/iam` + `admin/kms` are deliberately
|
||||
* NOT rewritten here: they keep their own gated proxies with their own tenant
|
||||
* scoping, and are reached by the client's explicit `/admin/*` origin path.
|
||||
* (Per-tenant billing + commerce store DATA are NOT dispatched here — they are
|
||||
* FILESYSTEM routes `app/v1/{billing,commerce}/[...path]`, more specific than the
|
||||
* `/v1/[...path]` cloud BFF, so a clean `/v1/billing/*` · `/v1/commerce/*` resolves
|
||||
* straight to them with no rewrite.)
|
||||
*
|
||||
* `beforeFiles` so a dispatched head wins over the `/v1` catch-all; the scope is the
|
||||
* CLOSED head list each non-cloud client uses (a blanket `/v1/:path*` would shadow the
|
||||
* cloud surface). Each destination handler STILL enforces its own least-privilege
|
||||
* allow-list (`proxy-allow.ts`), so a rewrite can never widen what a proxy admits.
|
||||
*/
|
||||
const CLOUD_V1_HEADS = ['prompts', 'agents', 'evals', 'analytics', 'templates', 'projects', 'crm', 'ml', 'vpcs', 'load-balancers', 'networks', 'mesh', 'edge', 'indexers', 'oracles', 'authz', 'o11y', 'websearch']
|
||||
const AI_V1_HEADS = ['models', 'chat', 'embeddings', 'rerank', 'audio']
|
||||
// `pricing` (the rich model+provider CATALOG at `/v1/pricing/models`) and `plans` (the
|
||||
// subscription tiers/entitlements) are AI-gateway-served like models/chat and are in the
|
||||
// `/ai` proxy ALLOWED set (app/ai/[...path]), so they route to `/ai` too.
|
||||
// `ai` is the AI Login Manager connections head (`/v1/ai/connections[/*]`) — routed to the
|
||||
// `/ai` bearer proxy like the rest; it is NOT a cloud-api head (never shadows a cloud surface).
|
||||
// `training` is the interactive (Tinker-style) engine head (`/v1/training/clients[/*]`) — the
|
||||
// live LoRA client plane, allow-listed in the `/ai` proxy, likewise never a cloud-api head.
|
||||
// `router` is the routing-observability head (`/v1/router/stats`); `get-/update-training-contribution`
|
||||
// are the org's opt-in flag — all AI-gateway-served (hanzoai/ai) like the router policy, so they route
|
||||
// to the `/ai` bearer proxy and are in its ALLOWED set (app/ai/[...path]).
|
||||
const AI_V1_HEADS = ['models', 'chat', 'embeddings', 'rerank', 'audio', 'images', 'videos', 'pricing', 'plans', 'ai', 'training', 'get-router-policy', 'update-router-policy', 'router', 'get-training-contribution', 'update-training-contribution']
|
||||
// The admin aggregate heads rewritten to the GLOBAL-ADMIN-GATED proxy. `providers`
|
||||
// is the AI-provider control board — its GET (the list) AND its POST mutations
|
||||
// (`providers/toggle`, `providers/primary`) both match the `/:path*` rewrite below,
|
||||
// which is method-agnostic (Next matches on the URL), so POST is covered without a
|
||||
// second entry. Keep this in sync with `admin-aggregate.ts` ADMIN_AGGREGATE_HEADS.
|
||||
const ADMIN_V1_HEADS = ['overview', 'usage', 'orgs', 'audit', 'products', 'finance', 'compute', 'providers']
|
||||
const ADMIN_V1_HEADS = ['overview', 'usage', 'orgs', 'audit', 'products', 'finance', 'compute', 'o11y', 'providers', 'customers', 'revenue', 'analytics', 'enablement', 'grants', 'referrals', 'affiliates', 'authors', 'treasury', 'services']
|
||||
/**
|
||||
* DEV-ONLY: proxy the client's direct-cloud `/v1/{iam,o11y}/*` calls (get-account,
|
||||
* annotation-queues/users) to a real cloud backend so `npm run dev` renders the
|
||||
* authenticated shell locally. Enabled ONLY when `DEV_CLOUD_ORIGIN` is set (never in
|
||||
* the built image), so production is unchanged — there the console host's edge routes
|
||||
* `/v1` to cloud-api. The request cookie is forwarded by the rewrite, so the local
|
||||
* dev session resolves against the real cloud.
|
||||
* `/v1` to the console, whose `/v1` catch-all forwards to cloud-api. The request cookie
|
||||
* is forwarded by the rewrite, so the local dev session resolves against the real cloud.
|
||||
*/
|
||||
const DEV_CLOUD_ORIGIN = process.env.DEV_CLOUD_ORIGIN?.replace(/\/+$/, '')
|
||||
const devCloudRewrites = () =>
|
||||
@@ -92,14 +110,37 @@ const devCloudRewrites = () =>
|
||||
]
|
||||
: []
|
||||
|
||||
// Public compute CATALOG (regions / CPU sizes) → the same-origin visor proxy
|
||||
// (`app/v1/vm/[...path]`). The GPU-accelerator catalog is the DISTINCT head `/v1/gpu-sizes`
|
||||
// so it never collides with the cloud-api GPU INVENTORY at `/v1/gpus` (served by `/v1`).
|
||||
const VM_V1_HEADS = ['regions', 'sizes']
|
||||
|
||||
const aiSurfaceRewrites = () => ({
|
||||
beforeFiles: [
|
||||
...CLOUD_V1_HEADS.map((h) => ({ source: `/v1/${h}`, destination: `/cloud/v1/${h}` })),
|
||||
...CLOUD_V1_HEADS.map((h) => ({ source: `/v1/${h}/:path*`, destination: `/cloud/v1/${h}/:path*` })),
|
||||
...AI_V1_HEADS.map((h) => ({ source: `/v1/${h}`, destination: `/ai/v1/${h}` })),
|
||||
...AI_V1_HEADS.map((h) => ({ source: `/v1/${h}/:path*`, destination: `/ai/v1/${h}/:path*` })),
|
||||
// Cloud-api heads (prompts/agents/automations/functions/framework/s3/vector/…) are
|
||||
// NOT rewritten: a clean `/v1/<head>` falls through to the `app/v1/[...path]` bearer
|
||||
// proxy → cloud-api `/v1/*`. Only the NON-cloud backends are dispatched below.
|
||||
// Client builds a clean `/v1/<aihead>`; dispatch to the `/ai` bearer proxy WITHOUT a
|
||||
// nested version in the target — `app/ai/[...path]` re-roots the upstream at `v1/`
|
||||
// (`isAllowedAiPath`/the gateway see `v1/<aihead>`), so no nested version leaks anywhere.
|
||||
...AI_V1_HEADS.map((h) => ({ source: `/v1/${h}`, destination: `/ai/${h}` })),
|
||||
...AI_V1_HEADS.map((h) => ({ source: `/v1/${h}/:path*`, destination: `/ai/${h}/:path*` })),
|
||||
// The SaaS-operations god-view is served by COMMERCE (the money SOT), NOT the
|
||||
// cloud aggregate: route /v1/admin/saas to its OWN global-admin-gated commerce
|
||||
// proxy (`app/admin/saas`). Placed before the aggregate map so it wins; `saas` is
|
||||
// deliberately NOT in ADMIN_V1_HEADS (that list forwards to cloud /v1/admin/*).
|
||||
{ source: `/v1/admin/saas`, destination: `/admin/saas` },
|
||||
...ADMIN_V1_HEADS.map((h) => ({ source: `/v1/admin/${h}`, destination: `/admin/aggregate/${h}` })),
|
||||
...ADMIN_V1_HEADS.map((h) => ({ source: `/v1/admin/${h}/:path*`, destination: `/admin/aggregate/${h}/:path*` })),
|
||||
// Public compute catalog → the visor `app/v1/vm/[...path]` proxy (a bare `/v1/regions`
|
||||
// dispatches to the /v1-first vm handler; the visor client also builds `/v1/vm/*` directly).
|
||||
...VM_V1_HEADS.map((h) => ({ source: `/v1/${h}`, destination: `/v1/vm/${h}` })),
|
||||
...VM_V1_HEADS.map((h) => ({ source: `/v1/${h}/:path*`, destination: `/v1/vm/${h}/:path*` })),
|
||||
{ source: `/v1/gpu-sizes`, destination: `/v1/vm/gpus` },
|
||||
// Per-tenant billing DATA + commerce store DATA need NO rewrite: they are FILESYSTEM
|
||||
// routes `app/v1/billing/[...path]` (service token) and `app/v1/commerce/[...path]`
|
||||
// (user bearer), each MORE SPECIFIC than the `app/v1/[...path]` cloud BFF, so a clean
|
||||
// `/v1/billing/*` · `/v1/commerce/*` resolves straight to them (the /v1-first law).
|
||||
...devCloudRewrites(),
|
||||
],
|
||||
})
|
||||
@@ -116,8 +157,8 @@ const aiSurfaceRewrites = () => ({
|
||||
* - NO `rewrites` — a static export cannot run rewrites, and it does not need
|
||||
* them: the clean `/v1/<head>` calls the SPA already builds now terminate
|
||||
* DIRECTLY at the embedded cloud's mounted subsystems (prompts/agents/evals/…,
|
||||
* models/chat/embeddings/…, admin/*), which is exactly what the rewrites used
|
||||
* to forward to via the Next BFF. The BFF proxy routes (app/cloud, app/ai,
|
||||
* models/chat/embeddings/…, admin/*), which is exactly what the rewrites/`app/v1`
|
||||
* proxy forward to via the Next BFF. The BFF proxy routes (app/v1, app/ai,
|
||||
* app/commerce, …) are the server, and in one-binary the cloud binary IS the
|
||||
* server — so they are simply absent from the export (see below).
|
||||
* - `images.unoptimized` — the export has no Image Optimization server.
|
||||
@@ -125,7 +166,7 @@ const aiSurfaceRewrites = () => ({
|
||||
* PRECONDITION for a clean `output:'export'`: the app/ tree must contain NO dynamic
|
||||
* server route handlers (a static export has no server runtime to run them).
|
||||
* Those handlers are the BFF proxies + the two standalone routes; the latter
|
||||
* (keys/onboard) are ported to cloud `/v1/console/*`, and the proxies collapse to
|
||||
* (keys/onboard) are ported to cloud `/v1/iam/{keys,onboard}`, and the proxies collapse to
|
||||
* the cloud `/v1/*` the SPA calls directly. The embed build therefore runs against
|
||||
* a tree with every app route handler removed (the build:embed script prunes the
|
||||
* "route" files into a scratch stash so the server build on `main` is untouched).
|
||||
@@ -138,7 +179,15 @@ const EMBED = process.env.CONSOLE_EMBED === '1'
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
// Deterministic per-commit build id (see src/config/build-id.mjs): pins ONE id
|
||||
// across every replica of a release so a rolling deploy never serves one build's
|
||||
// HTML against another build's /_next/static/<BUILD_ID>/ path — the chunk-404 the
|
||||
// live audit hit. SOURCE_COMMIT (CI build-arg) -> git HEAD -> package version.
|
||||
generateBuildId: () => resolveBuildId({ env: process.env, gitSha: readGitSha(__dirname), version: pkgVersion }),
|
||||
env: { NEXT_PUBLIC_APP_VERSION: pkgVersion },
|
||||
// @hanzo/usage is transpiled (see guiPackages) so its source <UsagePanel> (`/react`)
|
||||
// compiles in the client bundle; its headless `.` entry (used by the /ai-accounts
|
||||
// server routes) carries no node built-ins, so it needs no server-external treatment.
|
||||
transpilePackages: guiPackages(),
|
||||
...(EMBED
|
||||
? { output: 'export', images: { unoptimized: true } }
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
/Users/a/work/hanzo/hanzoai/console2/node_modules
|
||||
Generated
+926
-3
File diff suppressed because it is too large
Load Diff
+15
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/console",
|
||||
"version": "8.4.58",
|
||||
"version": "8.4.136",
|
||||
"private": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"author": "Hanzo AI <dev@hanzo.ai>",
|
||||
@@ -16,22 +16,36 @@
|
||||
"e2e:headed": "playwright test --headed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/capture": "^0.1.0",
|
||||
"@hanzo/canvas": "^0.1.0",
|
||||
"@hanzo/dash": "0.3.0",
|
||||
"@hanzo/data": "^1.2.0",
|
||||
"@hanzo/finance-ui": "0.1.1",
|
||||
"@hanzo/gui": "7.3.0",
|
||||
"@hanzo/iam-js-sdk": "0.19.1",
|
||||
"@hanzo/logo": "^1.0.7",
|
||||
"@hanzo/usage": "^0.1.6",
|
||||
"@hanzogui/config": "7.3.0",
|
||||
"@hanzogui/core": "7.3.0",
|
||||
"@hanzogui/lucide-icons-2": "7.3.0",
|
||||
"@hanzogui/next-theme": "7.3.0",
|
||||
"@lexical/html": "0.46.0",
|
||||
"@lexical/link": "0.46.0",
|
||||
"@lexical/list": "0.46.0",
|
||||
"@lexical/react": "0.46.0",
|
||||
"@lexical/rich-text": "0.46.0",
|
||||
"@lexical/selection": "0.46.0",
|
||||
"@lexical/utils": "0.46.0",
|
||||
"@luxfi/logo": "^1.0.1",
|
||||
"@xyflow/react": "12.11.1",
|
||||
"@zap-proto/web": "1.0.0",
|
||||
"@zap-proto/zap": "1.6.0",
|
||||
"@zooai/logo": "^1.0.2",
|
||||
"axe-core": "4.12.1",
|
||||
"ethers": "6.17.0",
|
||||
"lexical": "0.46.0",
|
||||
"next": "15.5.19",
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"react-native-web": "0.21.2",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"$comment": "SEED DATA for the platform `package` table — the white-label package catalog. This file is the SOURCE for seeding the platform DB (`~/work/hanzo/platform`), NOT a console runtime import. The console reads packages at runtime via `GET /v1/packages` (the platform serves these rows); adding a package = inserting a row here + reseeding, never a console code edit + redeploy. See `src/lib/api/packages.ts` and the follow-up spec in LLM.md. Each row matches the `Package` shape (`src/components/products/tenants/packages.ts`).",
|
||||
"packages": [
|
||||
{
|
||||
"id": "console-admin",
|
||||
"name": "Console / Admin",
|
||||
"description": "A white-label admin console for the tenant — their own branded control plane over Hanzo Cloud.",
|
||||
"services": ["console-admin", "iam"],
|
||||
"brandTemplate": { "customBrand": true, "hostPattern": "console.{slug}.hanzo.app" },
|
||||
"iamTemplate": { "ownIssuer": false, "appPattern": "{slug}-console" },
|
||||
"domainPattern": "console.{slug}.hanzo.app",
|
||||
"plan": "starter",
|
||||
"sovereign": false
|
||||
},
|
||||
{
|
||||
"id": "paas",
|
||||
"name": "PaaS",
|
||||
"description": "The deploy platform — projects, environments, apps, and pipelines on a dedicated cluster.",
|
||||
"services": ["paas", "iam", "kms"],
|
||||
"brandTemplate": { "customBrand": false, "hostPattern": "{slug}.hanzo.app" },
|
||||
"iamTemplate": { "ownIssuer": false, "appPattern": "{slug}-paas" },
|
||||
"domainPattern": "{slug}.hanzo.app",
|
||||
"plan": "growth",
|
||||
"sovereign": false
|
||||
},
|
||||
{
|
||||
"id": "dex",
|
||||
"name": "DEX",
|
||||
"description": "A decentralized exchange surface, branded and domain-bound for the tenant.",
|
||||
"services": ["dex", "iam", "kms"],
|
||||
"brandTemplate": { "customBrand": true, "hostPattern": "dex.{slug}.exchange" },
|
||||
"iamTemplate": { "ownIssuer": false, "appPattern": "{slug}-dex" },
|
||||
"domainPattern": "dex.{slug}.exchange",
|
||||
"plan": "growth",
|
||||
"sovereign": false
|
||||
},
|
||||
{
|
||||
"id": "bank",
|
||||
"name": "Bank",
|
||||
"description": "The regulated bank / tokenization stack with per-tenant custody and KMS.",
|
||||
"services": ["bank", "iam", "kms"],
|
||||
"brandTemplate": { "customBrand": true, "hostPattern": "bank.{slug}.com" },
|
||||
"iamTemplate": { "ownIssuer": true, "appPattern": "{slug}-bank" },
|
||||
"domainPattern": "bank.{slug}.com",
|
||||
"plan": "enterprise",
|
||||
"sovereign": false
|
||||
},
|
||||
{
|
||||
"id": "ats",
|
||||
"name": "ATS",
|
||||
"description": "An alternative trading system surface with its own IAM scope and secrets.",
|
||||
"services": ["ats", "iam", "kms"],
|
||||
"brandTemplate": { "customBrand": true, "hostPattern": "ats.{slug}.com" },
|
||||
"iamTemplate": { "ownIssuer": true, "appPattern": "{slug}-ats" },
|
||||
"domainPattern": "ats.{slug}.com",
|
||||
"plan": "enterprise",
|
||||
"sovereign": false
|
||||
},
|
||||
{
|
||||
"id": "bd",
|
||||
"name": "Broker-Dealer",
|
||||
"description": "A broker-dealer surface, provisioned with the tenant brand + identity scope.",
|
||||
"services": ["bd", "iam", "kms"],
|
||||
"brandTemplate": { "customBrand": true, "hostPattern": "bd.{slug}.com" },
|
||||
"iamTemplate": { "ownIssuer": true, "appPattern": "{slug}-bd" },
|
||||
"domainPattern": "bd.{slug}.com",
|
||||
"plan": "enterprise",
|
||||
"sovereign": false
|
||||
},
|
||||
{
|
||||
"id": "ta",
|
||||
"name": "Transfer Agent",
|
||||
"description": "A transfer-agent surface for the tenant, branded and identity-scoped.",
|
||||
"services": ["ta", "iam", "kms"],
|
||||
"brandTemplate": { "customBrand": true, "hostPattern": "ta.{slug}.com" },
|
||||
"iamTemplate": { "ownIssuer": true, "appPattern": "{slug}-ta" },
|
||||
"domainPattern": "ta.{slug}.com",
|
||||
"plan": "enterprise",
|
||||
"sovereign": false
|
||||
},
|
||||
{
|
||||
"id": "sovereign-l1",
|
||||
"name": "Sovereign L1",
|
||||
"description": "The full regulated-fintech stack on a sovereign L1 — ATS + BD + TA + the tenant's own chain.",
|
||||
"services": ["ats", "bd", "ta", "chain", "iam", "kms", "console-admin"],
|
||||
"brandTemplate": { "customBrand": true, "hostPattern": "{slug}.network" },
|
||||
"iamTemplate": { "ownIssuer": true, "appPattern": "{slug}-console" },
|
||||
"domainPattern": "{slug}.network",
|
||||
"plan": "enterprise",
|
||||
"sovereign": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
* 1) NO server route handlers. A static export has no runtime to run an
|
||||
* app/route.ts. This repo's route handlers are (a) BFF reverse-proxies that in
|
||||
* one-binary collapse to the cloud `/v1/*` the SPA calls directly, and (b) the
|
||||
* two standalone routes (keys/onboard) now ported to cloud `/v1/console/*`.
|
||||
* two standalone routes (keys/onboard) now ported to cloud `/v1/iam/{keys,onboard}`.
|
||||
* Either way they must be absent from the export → we STASH them.
|
||||
*
|
||||
* 2) Every dynamic page segment needs `generateStaticParams()`. The console's two
|
||||
@@ -193,7 +193,11 @@ try {
|
||||
execFileSync('npx', ['next', 'build'], {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, CONSOLE_EMBED: '1' },
|
||||
// CONSOLE_EMBED gates the server-side build transforms; NEXT_PUBLIC_CONSOLE_EMBED
|
||||
// is inlined into the CLIENT bundle so runtime code (lib/embed.ts → IS_EMBED) can
|
||||
// skip the BFF-only session probes (/auth/refresh|session, /billing welcome) that
|
||||
// don't exist in this static, server-less deployment.
|
||||
env: { ...process.env, CONSOLE_EMBED: '1', NEXT_PUBLIC_CONSOLE_EMBED: '1' },
|
||||
})
|
||||
|
||||
const out = join(root, 'out')
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Add product — the out-of-box "assemble your own backend" flow. Lists every
|
||||
* product the org has NOT yet enabled (brand-scoped, non-admin, minus the always-on
|
||||
* essentials), grouped by category, each with an "Enable" action that POSTs the
|
||||
* entitlement patch and drops the product into the org's sidebar. Rendered in the
|
||||
* shared DetailPane (opened from the sidebar's "Add product" row).
|
||||
*
|
||||
* Honest by construction: it derives from the ONE catalog + the live entitlement
|
||||
* set, shows the real enable state (Enabling…/Enabled), surfaces an error inline,
|
||||
* and shows an honest empty state when nothing is left to add.
|
||||
*/
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Button, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Check, Plus } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { addableCatalogByCategory, type CatalogEntry } from '~/lib/products/registry'
|
||||
import { useEntitlements } from '~/lib/entitlements-context'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { useProductColors } from '~/lib/products/pins'
|
||||
import { asColor } from '~/components/ui/color'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
|
||||
function AddRow({ entry, onEnable }: { entry: CatalogEntry; onEnable: () => Promise<void> }) {
|
||||
const { colorOf } = useProductColors()
|
||||
const [state, setState] = useState<'idle' | 'busy' | 'done' | 'error'>('idle')
|
||||
const Icon = entry.icon
|
||||
const enable = async () => {
|
||||
setState('busy')
|
||||
try {
|
||||
await onEnable()
|
||||
setState('done')
|
||||
} catch {
|
||||
setState('error')
|
||||
}
|
||||
}
|
||||
return (
|
||||
<XStack items="center" gap="$3" py="$2" px="$2" rounded="$3" hoverStyle={{ bg: '$color2' }}>
|
||||
<YStack width={30} height={30} rounded="$3" bg="$color3" items="center" justify="center">
|
||||
<Icon size={16} color={asColor(colorOf(entry.id))} />
|
||||
</YStack>
|
||||
<YStack flex={1} minW={0}>
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
|
||||
{entry.label}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color10" numberOfLines={1}>
|
||||
{entry.description}
|
||||
</Text>
|
||||
{state === 'error' ? (
|
||||
<Text fontSize="$1" color="$red10">
|
||||
Could not enable — try again.
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
{state === 'done' ? (
|
||||
<XStack items="center" gap="$1.5" px="$2">
|
||||
<Check size={14} color="$green10" />
|
||||
<Text fontSize="$2" color="$green10" fontWeight="600">
|
||||
Enabled
|
||||
</Text>
|
||||
</XStack>
|
||||
) : (
|
||||
<Button
|
||||
size="$2"
|
||||
icon={<Plus size={14} />}
|
||||
disabled={state === 'busy'}
|
||||
onPress={enable}
|
||||
aria-label={`Enable ${entry.label}`}
|
||||
>
|
||||
{state === 'busy' ? 'Enabling…' : 'Enable'}
|
||||
</Button>
|
||||
)}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
export function AddProductPanel() {
|
||||
const showAdmin = useIsSuperAdmin()
|
||||
const { enabled, addProducts } = useEntitlements()
|
||||
const groups = useMemo(() => addableCatalogByCategory(showAdmin, enabled), [showAdmin, enabled])
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<YStack p="$4">
|
||||
<EmptyState
|
||||
icon={Check}
|
||||
title="Everything's enabled"
|
||||
description="Your organization has enabled every available product. New products appear here as they launch."
|
||||
/>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack p="$3" gap="$4">
|
||||
<Text fontSize="$2" color="$color10">
|
||||
Enable products to add them to your organization's console. You only pay for what you use.
|
||||
</Text>
|
||||
{groups.map((group) => (
|
||||
<YStack key={group.category} gap="$1">
|
||||
<Text fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase" px="$2">
|
||||
{group.category}
|
||||
</Text>
|
||||
{group.entries.map((entry) => (
|
||||
<AddRow key={entry.id} entry={entry} onEnable={() => addProducts([entry.id])} />
|
||||
))}
|
||||
</YStack>
|
||||
))}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Bridges the console session + App-Router navigation into the shared analytics
|
||||
* client (`@hanzo/capture`). Rendered once, inside both `SessionProvider` and
|
||||
* `AnalyticsProvider` (see `Provider.tsx`), it renders nothing.
|
||||
*
|
||||
* - `usePageview` emits a pageview on every path change (the provider fires the
|
||||
* FIRST pageview itself, so this only covers subsequent client navigations).
|
||||
* - `identify` binds the person to the STABLE `owner/name` actor id — the same id
|
||||
* the API client already uses (`setCurrentActor`), never the email — once the
|
||||
* session resolves. The org tenant is stamped server-side from the session, so
|
||||
* we send the user id only. Anonymous placeholder sessions are skipped.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useAnalytics, usePageview } from '@hanzo/capture/react'
|
||||
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
|
||||
export function AnalyticsBridge() {
|
||||
const analytics = useAnalytics()
|
||||
const { account } = useSession()
|
||||
usePageview(usePathname())
|
||||
|
||||
const identified = useRef('')
|
||||
useEffect(() => {
|
||||
if (!account?.owner || !account?.name || account.type === 'anonymous-user') return
|
||||
const personId = `${account.owner}/${account.name}`
|
||||
if (identified.current === personId) return
|
||||
identified.current = personId
|
||||
analytics.identify(personId)
|
||||
}, [account, analytics])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -17,16 +17,19 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { Dialog, Input, ScrollView, Text, VisuallyHidden, XStack, YStack } from '@hanzo/gui'
|
||||
import { Lock, Search } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { visibleCatalogByCategory, type CatalogEntry } from '~/lib/products/registry'
|
||||
import { findEntry, visibleCatalogByCategory, type CatalogEntry } from '~/lib/products/registry'
|
||||
import { orderEntries } from '~/lib/products/order'
|
||||
import { searchCatalog } from '~/lib/products/search'
|
||||
import { useProductColors } from '~/lib/products/pins'
|
||||
import { asColor } from '~/components/ui/color'
|
||||
import { openProduct } from '~/lib/products/open'
|
||||
import { useIsGlobalAdmin } from '~/lib/auth/admin'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { useEntitlements } from '~/lib/entitlements-context'
|
||||
import { filterEntitled } from '~/lib/entitlements'
|
||||
|
||||
type LauncherApi = { isOpen: boolean; open: () => void; close: () => void }
|
||||
|
||||
@@ -38,7 +41,7 @@ export function useAppLauncher(): LauncherApi {
|
||||
return ctx
|
||||
}
|
||||
|
||||
function Tile({ entry, color, onPress }: { entry: CatalogEntry; color: string; onPress: () => void }) {
|
||||
function Tile({ entry, color, active, onPress }: { entry: CatalogEntry; color: string; active?: boolean; onPress: () => void }) {
|
||||
const Icon = entry.icon
|
||||
return (
|
||||
<YStack
|
||||
@@ -51,6 +54,9 @@ function Tile({ entry, color, onPress }: { entry: CatalogEntry; color: string; o
|
||||
items="center"
|
||||
justify="center"
|
||||
rounded="$6"
|
||||
bg={active ? '$color3' : 'transparent'}
|
||||
borderWidth={1}
|
||||
borderColor={active ? '$color6' : 'transparent'}
|
||||
hoverStyle={{ bg: '$color3' }}
|
||||
>
|
||||
<XStack
|
||||
@@ -72,27 +78,40 @@ function Tile({ entry, color, onPress }: { entry: CatalogEntry; color: string; o
|
||||
<Text fontSize="$2" fontWeight="600" color="$color12" numberOfLines={1}>
|
||||
{entry.label}
|
||||
</Text>
|
||||
{entry.status === 'soon' ? (
|
||||
<YStack px="$1.5" py={1} rounded="$10" bg="$color4" position="absolute" b="$2">
|
||||
<Text fontSize={8} fontWeight="800" letterSpacing={0.5} color="$color11">
|
||||
SOON
|
||||
</Text>
|
||||
</YStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
function LauncherDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
|
||||
const router = useRouter()
|
||||
const showAdmin = useIsGlobalAdmin()
|
||||
const pathname = usePathname() ?? ''
|
||||
const showAdmin = useIsSuperAdmin()
|
||||
const { enabled } = useEntitlements()
|
||||
const { colorOf } = useProductColors()
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const groups = useMemo(() => visibleCatalogByCategory(showAdmin), [showAdmin])
|
||||
// The active product (from the current route) — pinned first + emphasized in the
|
||||
// browse grid, per directive #58 §2.2.
|
||||
const activeId = useMemo(() => {
|
||||
const seg = pathname.split('/').filter(Boolean)[0]
|
||||
return seg ? (findEntry(seg)?.id ?? null) : null
|
||||
}, [pathname])
|
||||
|
||||
// Browse (no query): each category's apps are CONTINUOUS ALPHABETICAL with the
|
||||
// selected app pinned first — the SAME `orderEntries` rule the sidebar uses (DRY).
|
||||
const groups = useMemo(
|
||||
() =>
|
||||
visibleCatalogByCategory(showAdmin, enabled).map((g) => ({
|
||||
category: g.category,
|
||||
entries: orderEntries(g.entries, activeId),
|
||||
})),
|
||||
[showAdmin, enabled, activeId],
|
||||
)
|
||||
// While filtering, keep the relevance ranking (a search is not alphabetical). Gate
|
||||
// the results by admin AND entitlement (the launcher mirrors the sidebar's scope).
|
||||
const filtered = useMemo(
|
||||
() => (query.trim() ? searchCatalog(query).filter((e) => showAdmin || !e.admin) : null),
|
||||
[query, showAdmin],
|
||||
() => (query.trim() ? filterEntitled(searchCatalog(query).filter((e) => showAdmin || !e.admin), enabled, showAdmin) : null),
|
||||
[query, showAdmin, enabled],
|
||||
)
|
||||
|
||||
const activate = useCallback(
|
||||
@@ -106,11 +125,11 @@ function LauncherDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (
|
||||
return (
|
||||
<Dialog modal open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay key="launcher-overlay" bg="rgba(0,0,0,0.6)" />
|
||||
<Dialog.Overlay key="launcher-overlay" className="hz-scrim-in" bg="rgba(0,0,0,0.6)" />
|
||||
<Dialog.Content
|
||||
key="launcher-content"
|
||||
className="hz-paper hz-pop-in"
|
||||
bordered
|
||||
elevate
|
||||
width="92vw"
|
||||
height="88vh"
|
||||
maxW={1180}
|
||||
@@ -160,7 +179,7 @@ function LauncherDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (
|
||||
) : (
|
||||
<XStack flexWrap="wrap" gap="$2">
|
||||
{filtered.map((entry) => (
|
||||
<Tile key={entry.id} entry={entry} color={colorOf(entry.id)} onPress={() => activate(entry)} />
|
||||
<Tile key={entry.id} entry={entry} color={colorOf(entry.id)} active={entry.id === activeId} onPress={() => activate(entry)} />
|
||||
))}
|
||||
</XStack>
|
||||
)
|
||||
@@ -172,7 +191,7 @@ function LauncherDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (
|
||||
</Text>
|
||||
<XStack flexWrap="wrap" gap="$2">
|
||||
{group.entries.map((entry) => (
|
||||
<Tile key={entry.id} entry={entry} color={colorOf(entry.id)} onPress={() => activate(entry)} />
|
||||
<Tile key={entry.id} entry={entry} color={colorOf(entry.id)} active={entry.id === activeId} onPress={() => activate(entry)} />
|
||||
))}
|
||||
</XStack>
|
||||
</YStack>
|
||||
|
||||
@@ -3,25 +3,45 @@
|
||||
/**
|
||||
* Auth gate — renders children only for a signed-in account.
|
||||
*
|
||||
* While the session loads, shows a spinner. With no account, redirects to
|
||||
* While the session loads, shows a spinner. With no account it sends the visitor to
|
||||
* `/signin`. Used to wrap the authenticated dashboard.
|
||||
*
|
||||
* SPA-FALLBACK CAVEAT (the reason for the `/signin` branch below): the deploy serves
|
||||
* the SPA shell (the `/` route's index.html) for EVERY path, so a DIRECT load of
|
||||
* `/signin` mounts THIS gate (the `/` route tree), not the `/signin` route. A visitor
|
||||
* with no account would then have `router.replace('/signin')` be a no-op (the URL is
|
||||
* already `/signin`) and be trapped on the spinner forever. So at `/signin` we render
|
||||
* the sign-in experience inline instead of redirecting — `<SignIn/>` is the SAME
|
||||
* component the `/signin` route renders (and it bounces an already-signed-in visitor
|
||||
* back to `/`). Everywhere else, an unauthenticated visitor is redirected to `/signin`.
|
||||
*/
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import { Loader } from '~/components/ui/Loader'
|
||||
import { SignIn } from '~/components/SignIn'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
const { account, loading } = useSession()
|
||||
const router = useRouter()
|
||||
// The REAL browser path, resolved after mount (window is absent during SSR/prerender)
|
||||
// — unambiguous under the SPA fallback where the served HTML is the `/` route's shell.
|
||||
const [atSignin, setAtSignin] = useState<boolean | null>(null)
|
||||
useEffect(() => {
|
||||
setAtSignin(window.location.pathname === '/signin')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !account) router.replace('/signin')
|
||||
}, [loading, account, router])
|
||||
// Redirect an unauthenticated visitor to /signin — but never when already there
|
||||
// (a same-URL replace is a no-op; we render the form inline at /signin instead).
|
||||
if (!loading && !account && atSignin === false) router.replace('/signin')
|
||||
}, [loading, account, atSignin, router])
|
||||
|
||||
if (loading || !account) {
|
||||
return <Loader />
|
||||
}
|
||||
// At /signin, the ONE sign-in experience owns the surface (form on a tenant host,
|
||||
// silent SSO on an admin host, redirect-to-/ when already signed in).
|
||||
if (atSignin) return <SignIn />
|
||||
|
||||
if (atSignin === null || loading || !account) return <Loader />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { branding } from '~/config'
|
||||
|
||||
/**
|
||||
* BrandTitle — keep the browser-tab document.title white-labeled to the request
|
||||
* host's brand, on the client.
|
||||
*
|
||||
* The console ships into the unified `hanzoai/cloud` binary as a Next.js STATIC
|
||||
* EXPORT (go:embed): `generateMetadata` runs at BUILD time with the default host,
|
||||
* so the exported <title> is baked to "Hanzo Cloud Console" for every host. The
|
||||
* cloud serving layer rewrites that <title> per Host on the first paint, but Next
|
||||
* re-applies the baked metadata title on hydration (and on client navigations) —
|
||||
* reverting a Lux/Zoo tab back to "Hanzo Cloud Console", a white-label violation.
|
||||
*
|
||||
* This client net resolves the brand from window.location (via `branding.name`,
|
||||
* the same source the visible shell uses) and enforces the correct title,
|
||||
* defeating the baked-metadata re-application. On the dynamic standalone app the
|
||||
* SSR title is already host-correct, so this only ever re-affirms it — a no-op.
|
||||
*/
|
||||
export function BrandTitle() {
|
||||
useEffect(() => {
|
||||
const desired = branding.name // "<Brand> Cloud Console", from window.location
|
||||
const apply = () => {
|
||||
if (document.title !== desired) document.title = desired
|
||||
}
|
||||
apply()
|
||||
// Next re-applies the baked metadata title after hydration; re-affirm the
|
||||
// brand title whenever the document head mutates. Writing document.title only
|
||||
// when it has drifted keeps the observer from looping on its own change.
|
||||
const observer = new MutationObserver(apply)
|
||||
observer.observe(document.head, { childList: true, subtree: true, characterData: true })
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,51 +1,74 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* ChunkGuard — recover gracefully from a stale-deploy chunk error.
|
||||
* ChunkGuard — window-level net that recovers from a stale-deploy chunk 404.
|
||||
*
|
||||
* After a console deploy, an open tab still references the previous build's
|
||||
* hashed chunks. Those chunk URLs no longer exist, so the request falls through
|
||||
* to the app shell (HTML), and the browser throws `ChunkLoadError` /
|
||||
* "Unexpected token '<'" trying to parse HTML as JS — an unrecoverable blank
|
||||
* screen. This catches that exact failure and does ONE full reload, which pulls
|
||||
* the fresh HTML + current chunks. A sessionStorage flag prevents reload loops
|
||||
* if the failure is genuine (not a stale deploy); it clears on the next load.
|
||||
* On a rolling deploy an open tab (or a fresh deep-link that lands on the other
|
||||
* replica) requests a hashed chunk that no longer exists on the replica it hits.
|
||||
* The 404 falls through to the app-shell HTML, so the browser throws
|
||||
* `ChunkLoadError` / "Unexpected token '<'" trying to parse HTML as JS — a blank,
|
||||
* unrecoverable screen. This catches that at the WINDOW level and does one full
|
||||
* reload, which pulls the fresh HTML + current chunks.
|
||||
*
|
||||
* Two catch surfaces, because a chunk 404 surfaces two ways:
|
||||
* - CAPTURE-phase `error` on the failing `<script>`/`<link>` element — a resource
|
||||
* load error does NOT bubble, so only a capture listener sees it. This fires on
|
||||
* the RAW 404 during the initial deep-link load, before webpack's loader even
|
||||
* rejects — the earliest, most reliable signal for "refresh a sub-route 404s a
|
||||
* chunk".
|
||||
* - `unhandledrejection` / bubbled `error` carrying a `ChunkLoadError` message —
|
||||
* webpack's dynamic-import path.
|
||||
*
|
||||
* The React error boundaries (`global-error`, the dashboard segment,
|
||||
* `ProductErrorBoundary`) catch the same class at RENDER time; this complements
|
||||
* them for the async/resource paths a render boundary never sees. Every recovery
|
||||
* site — this net and all three boundaries — shares ONE loop-breaker
|
||||
* (`shouldReloadForChunk` bounded by `CHUNK_RELOAD_AT_KEY`), so a persistent skew
|
||||
* reloads at most once per window and never spins. Chunk detection is shared too
|
||||
* (`isChunkLoadError`) — one definition of "this is a chunk skew" for the whole app.
|
||||
*/
|
||||
import { useEffect } from 'react'
|
||||
|
||||
const FLAG = 'hz_chunk_reloaded'
|
||||
const PATTERN = /ChunkLoadError|Loading chunk [\d]+ failed|Loading CSS chunk|Importing a module script failed|Unexpected token '<'/i
|
||||
import { isChunkLoadError, shouldReloadForChunk, CHUNK_RELOAD_AT_KEY } from '~/components/errors/boundary-logic'
|
||||
|
||||
/** True when a failed resource load targets a Next build asset (script/link). */
|
||||
function isNextAssetError(target: EventTarget | null): boolean {
|
||||
if (!target || typeof target !== 'object') return false
|
||||
const el = target as Partial<HTMLScriptElement & HTMLLinkElement>
|
||||
const url = el.src || el.href
|
||||
return typeof url === 'string' && url.includes('/_next/static/')
|
||||
}
|
||||
|
||||
export function ChunkGuard() {
|
||||
useEffect(() => {
|
||||
// A clean load means any prior stale-chunk reload worked — reset the guard.
|
||||
try {
|
||||
sessionStorage.removeItem(FLAG)
|
||||
} catch {
|
||||
/* sessionStorage may be unavailable (private mode) — best-effort only */
|
||||
}
|
||||
|
||||
const recover = (message: string) => {
|
||||
if (!PATTERN.test(message)) return
|
||||
// Reload at most once per window, coordinated with the render boundaries so a
|
||||
// skew that trips several detectors at once reloads ONCE (the timestamp ages
|
||||
// out, so a genuine later skew can still recover) — never a reload loop.
|
||||
const recover = () => {
|
||||
try {
|
||||
if (sessionStorage.getItem(FLAG)) return // already tried once — let the error surface
|
||||
sessionStorage.setItem(FLAG, '1')
|
||||
const raw = window.sessionStorage.getItem(CHUNK_RELOAD_AT_KEY)
|
||||
const last = raw ? Number(raw) : null
|
||||
if (!shouldReloadForChunk(Date.now(), last)) return
|
||||
window.sessionStorage.setItem(CHUNK_RELOAD_AT_KEY, String(Date.now()))
|
||||
window.location.reload()
|
||||
} catch {
|
||||
/* ignore */
|
||||
/* sessionStorage blocked (private mode) — let a render boundary show its card */
|
||||
}
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const onError = (e: ErrorEvent) => recover(e?.message ?? String(e?.error ?? ''))
|
||||
const onError = (e: ErrorEvent) => {
|
||||
if (isNextAssetError(e.target) || isChunkLoadError(e.error ?? e.message)) recover()
|
||||
}
|
||||
const onRejection = (e: PromiseRejectionEvent) => {
|
||||
const r = e?.reason
|
||||
recover(typeof r === 'string' ? r : (r?.message ?? ''))
|
||||
if (isChunkLoadError(e.reason)) recover()
|
||||
}
|
||||
|
||||
window.addEventListener('error', onError)
|
||||
// `capture: true` so the non-bubbling resource-load error on a 404'd chunk
|
||||
// element reaches us.
|
||||
window.addEventListener('error', onError, true)
|
||||
window.addEventListener('unhandledrejection', onRejection)
|
||||
return () => {
|
||||
window.removeEventListener('error', onError)
|
||||
window.removeEventListener('error', onError, true)
|
||||
window.removeEventListener('unhandledrejection', onRejection)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -74,7 +74,8 @@ import { ProductIcon } from '~/components/ui/ProductIcon'
|
||||
import { openProduct } from '~/lib/products/open'
|
||||
import { currentOrg, switchOrg } from '~/lib/org-scope'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { useIsGlobalAdmin } from '~/lib/auth/admin'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { useEntitlements } from '~/lib/entitlements-context'
|
||||
import { useAppLauncher } from '~/components/AppLauncher'
|
||||
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
|
||||
|
||||
@@ -298,7 +299,8 @@ function PaletteDialog({
|
||||
const router = useRouter()
|
||||
const launcher = useAppLauncher()
|
||||
const { signOut } = useSession()
|
||||
const showAdmin = useIsGlobalAdmin()
|
||||
const showAdmin = useIsSuperAdmin()
|
||||
const { enabled } = useEntitlements()
|
||||
const { colorOf } = useProductColors()
|
||||
const { current, resolvedTheme, set: setTheme } = useThemeSetting()
|
||||
const isDark = (resolvedTheme ?? current ?? 'dark') !== 'light'
|
||||
@@ -360,8 +362,8 @@ function PaletteDialog({
|
||||
// Every jump target — products AND deep sub-pages ("queues" → Tasks › Queues) —
|
||||
// gated so a customer never sees an admin-only surface.
|
||||
const destResults = useMemo(
|
||||
() => (mode === 'catalog' ? searchDestinations(query, showAdmin).slice(0, 50) : []),
|
||||
[mode, query, showAdmin],
|
||||
() => (mode === 'catalog' ? searchDestinations(query, showAdmin, enabled).slice(0, 50) : []),
|
||||
[mode, query, showAdmin, enabled],
|
||||
)
|
||||
|
||||
const matchedActions = useMemo(
|
||||
@@ -485,13 +487,13 @@ function PaletteDialog({
|
||||
return (
|
||||
<Dialog modal open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay key="palette-overlay" bg="rgba(0,0,0,0.5)" />
|
||||
<Dialog.Overlay key="palette-overlay" className="hz-scrim-in" bg="rgba(0,0,0,0.5)" />
|
||||
{/* Full-screen on mobile (fills the viewport, reachable from the mobile
|
||||
menu); a floating 640 box at lg+. */}
|
||||
menu); a floating 640 box at lg+ on Material paper (real depth). */}
|
||||
<Dialog.Content
|
||||
key="palette-content"
|
||||
className="hz-paper hz-pop-in"
|
||||
bordered
|
||||
elevate
|
||||
width="100vw"
|
||||
height="100dvh"
|
||||
maxW="100vw"
|
||||
@@ -696,7 +698,9 @@ export function CommandSearchBox() {
|
||||
<Text flex={1} fontSize="$3" color="$color10" numberOfLines={1}>
|
||||
Search or jump to…
|
||||
</Text>
|
||||
<XStack items="center" gap="$1" opacity={0.6}>
|
||||
{/* ⌘K hint — hidden below lg: there is no keyboard shortcut on a phone, and
|
||||
the chip stole width from the placeholder (which truncated to “S…”). */}
|
||||
<XStack display="none" $lg={{ display: 'flex' }} items="center" gap="$1" opacity={0.6}>
|
||||
<Command size={12} />
|
||||
<Text fontSize="$2" color="$color10">
|
||||
K
|
||||
|
||||
+439
-317
File diff suppressed because it is too large
Load Diff
@@ -17,11 +17,12 @@
|
||||
* instance floats over all children — lightweight, no per-page wiring.
|
||||
*/
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { Button, Dialog, Text, VisuallyHidden, XStack, YStack } from '@hanzo/gui'
|
||||
import { MessageCircle, Sparkles, X } from '@hanzogui/lucide-icons-2'
|
||||
import { Sparkles, X } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { ChatConversation } from '~/components/products/chat/ChatConversation'
|
||||
import { BrandMark } from '~/components/ui/BrandLogo'
|
||||
|
||||
type FloatingChatApi = { isOpen: boolean; open: () => void; close: () => void; toggle: () => void }
|
||||
|
||||
@@ -49,11 +50,11 @@ function ChatSheet({
|
||||
return (
|
||||
<Dialog modal open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay key="chat-overlay" bg="rgba(0,0,0,0.5)" $lg={{ bg: 'transparent' }} />
|
||||
<Dialog.Overlay key="chat-overlay" className="hz-scrim-in" bg="rgba(0,0,0,0.5)" $lg={{ bg: 'transparent' }} />
|
||||
<Dialog.Content
|
||||
key="chat-content"
|
||||
className="hz-paper hz-pop-in"
|
||||
bordered
|
||||
elevate
|
||||
position="absolute"
|
||||
bg="$color1"
|
||||
overflow="hidden"
|
||||
@@ -119,6 +120,16 @@ function ChatSheet({
|
||||
|
||||
export function FloatingChatProvider({ children }: { children: ReactNode }) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname() ?? ''
|
||||
// The bubble is redundant — and OVERLAPS the composer's send control — on the
|
||||
// pages that ARE a full chat/composer surface. Suppress it there (the assistant
|
||||
// is still openable programmatically via `useFloatingChat`); every other page
|
||||
// keeps the one-tap bubble.
|
||||
const onChatSurface =
|
||||
pathname === '/chat' ||
|
||||
pathname.startsWith('/chat/') ||
|
||||
pathname === '/playground' ||
|
||||
pathname.startsWith('/playground/')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const open = useCallback(() => setIsOpen(true), [])
|
||||
const close = useCallback(() => setIsOpen(false), [])
|
||||
@@ -136,20 +147,21 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
|
||||
{/* The bubble — fixed bottom-right over every page (rendered last in this
|
||||
provider so DOM order keeps it above normal-flow content; the chat sheet
|
||||
portals above it). Hidden while open so the sheet's own close control is
|
||||
the single dismiss affordance. */}
|
||||
{!isOpen ? (
|
||||
the single dismiss affordance. Hidden on the chat/playground surfaces,
|
||||
where it would overlap the page's own composer. */}
|
||||
{!isOpen && !onChatSurface ? (
|
||||
<YStack position="fixed" b={24} r={24}>
|
||||
{/* The support/AI bubble = the brand 'H' mark (white-labeled per brand),
|
||||
on Material paper elevation with a gentle hover lift. */}
|
||||
<Button
|
||||
circular
|
||||
size="$6"
|
||||
bg="$color5"
|
||||
className="hz-lift hz-elevation-3"
|
||||
hoverStyle={{ bg: '$color6' }}
|
||||
pressStyle={{ bg: '$color7' }}
|
||||
icon={<MessageCircle size={24} />}
|
||||
icon={<BrandMark size={22} />}
|
||||
onPress={open}
|
||||
shadowColor="rgba(0,0,0,0.35)"
|
||||
shadowRadius={16}
|
||||
shadowOffset={{ width: 0, height: 4 }}
|
||||
aria-label="Open AI assistant"
|
||||
/>
|
||||
</YStack>
|
||||
|
||||
+48
-61
@@ -1,32 +1,30 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Org gate — the console operates inside the user's organization.
|
||||
* Org gate — the two-level org model's router. EVERY login lands org-LESS.
|
||||
*
|
||||
* Behaviours:
|
||||
* 1. isAdmin on a non-admin host → show a dismissible amber banner linking to
|
||||
* admin.hanzo.ai (IAM/KMS ops), but render the full console. Admins use the
|
||||
* console for all normal cloud work (models, API keys, AI, etc.).
|
||||
* 2. Any non-admin user in any org → render console normally.
|
||||
* 3. No org yet → first-run org onboarding.
|
||||
* Route:
|
||||
* 0. No org at all → first-run org onboarding (create/join).
|
||||
* 1. Has org(s) but none ENTERED yet → the {@link OrgPicker} (the "Home" org
|
||||
* list). A one-org user still sees a one-card list and clicks in; a global
|
||||
* admin sees every org (masquerade). We never auto-enter.
|
||||
* 2. An org is entered → the scoped console (children). A global admin on a
|
||||
* non-admin host also gets a dismissible banner linking to admin.hanzo.ai.
|
||||
*
|
||||
* Switching orgs at runtime is the OrgSwitcher's job; this gate only covers
|
||||
* the "no org" degenerate case and the admin hint.
|
||||
*
|
||||
* Last org: restored from localStorage on sign-in so the scope remembers where
|
||||
* the user left off.
|
||||
* Entering/leaving an org is `org-scope`'s job (`enterOrg`/`leaveOrg`, which
|
||||
* reload); in-shell quick-switching is the OrgSwitcher's. This gate only decides
|
||||
* which of the three surfaces to show, read fresh on mount to avoid a flash.
|
||||
*/
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { Button, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Button, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
|
||||
import { config } from '~/config'
|
||||
import { getBrand } from '~/lib/branding/brands'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { isGlobalAdminAccount } from '~/lib/auth/admin'
|
||||
import { currentOrg, setCurrentOrg } from '~/lib/org-scope'
|
||||
import { isSuperAdminAccount } from '~/lib/auth/admin'
|
||||
import { hasSelectedOrg } from '~/lib/org-scope'
|
||||
import { OrgOnboarding } from '~/components/OrgOnboarding'
|
||||
import { OrgPicker } from '~/components/OrgPicker'
|
||||
|
||||
const LS_LAST_ORG = 'hz_last_org'
|
||||
const LS_BANNER_DISMISSED = 'hz_admin_banner_dismissed'
|
||||
|
||||
function onAdminHost(): boolean {
|
||||
@@ -75,48 +73,21 @@ export function OrgGate({ children }: { children: ReactNode }) {
|
||||
const { account } = useSession()
|
||||
const owner = account?.owner ?? ''
|
||||
// GLOBAL (cross-tenant) admin — the only one who may use admin.hanzo.ai. The
|
||||
// decision (membership in the reserved `admin` org, or an explicit isGlobalAdmin
|
||||
// claim) lives in ONE place — `isGlobalAdminAccount` — shared with the nav gate.
|
||||
// decision (membership in the reserved `admin` org, or an explicit isSuperAdmin
|
||||
// claim) lives in ONE place — `isSuperAdminAccount` — shared with the nav gate.
|
||||
// A tenant org owner (e.g. Dave/maxpower) has owner!=='admin' → never global.
|
||||
const isGlobalAdmin = isGlobalAdminAccount(account)
|
||||
const isSuperAdmin = isSuperAdminAccount(account)
|
||||
const [bannerDismissed, setBannerDismissed] = useState(true) // start hidden to avoid flash
|
||||
// Which surface to show — resolved on mount (localStorage is client-only) so the
|
||||
// picker vs. scoped-console decision never flashes the wrong one during hydration.
|
||||
const [selected, setSelected] = useState<boolean | null>(null)
|
||||
|
||||
// Restore banner dismissed state and last org on mount
|
||||
// Restore banner dismissed state + read the org selection on mount.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
const dismissed = localStorage.getItem(LS_BANNER_DISMISSED) === '1'
|
||||
setBannerDismissed(dismissed)
|
||||
}, [])
|
||||
|
||||
// Seed org scope on sign-in. A non-global admin can ONLY act in their OWN org —
|
||||
// the server pins them there and every cross-tenant call 403s — so they are
|
||||
// ALWAYS scoped to `owner`, ignoring a stale/switched org left in localStorage
|
||||
// (e.g. a leftover `adnexus` from a prior global-admin switch, which would make
|
||||
// the whole console 403 against a tenant they can't read). Only a global admin
|
||||
// restores a previously-switched org.
|
||||
useEffect(() => {
|
||||
if (!owner || typeof window === 'undefined') return
|
||||
if (!isGlobalAdmin) {
|
||||
// Hard-pin to own org. If a stale cross-tenant scope was active, reset it and
|
||||
// reload so every module refetches under the correct X-Org-Id. The reload is
|
||||
// guarded on `currentOrg() !== owner`, so once the scope is right it never
|
||||
// fires again — no loop.
|
||||
localStorage.removeItem(LS_LAST_ORG)
|
||||
if (currentOrg() !== owner) {
|
||||
setCurrentOrg(owner)
|
||||
window.location.reload()
|
||||
}
|
||||
return
|
||||
}
|
||||
const lastOrg = localStorage.getItem(LS_LAST_ORG)
|
||||
if (currentOrg() === config.iamOrgName) {
|
||||
const target = (lastOrg && lastOrg !== config.iamOrgName) ? lastOrg : owner
|
||||
if (target !== config.iamOrgName) setCurrentOrg(target)
|
||||
}
|
||||
// Persist current org whenever it updates
|
||||
const cur = currentOrg()
|
||||
if (cur && cur !== config.iamOrgName) localStorage.setItem(LS_LAST_ORG, cur)
|
||||
}, [owner, isGlobalAdmin])
|
||||
setBannerDismissed(localStorage.getItem(LS_BANNER_DISMISSED) === '1')
|
||||
setSelected(hasSelectedOrg())
|
||||
}, [owner])
|
||||
|
||||
const dismissBanner = () => {
|
||||
setBannerDismissed(true)
|
||||
@@ -129,11 +100,11 @@ export function OrgGate({ children }: { children: ReactNode }) {
|
||||
// server /admin/* proxies also fail-closed, this is the matching UI gate).
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
if (onAdminHost() && owner && !isGlobalAdmin) {
|
||||
if (onAdminHost() && owner && !isSuperAdmin) {
|
||||
const consoleHost = window.location.hostname.replace(/^admin\./, 'console.')
|
||||
window.location.replace(`https://${consoleHost}${window.location.pathname}${window.location.search}`)
|
||||
}
|
||||
}, [owner, isGlobalAdmin])
|
||||
}, [owner, isSuperAdmin])
|
||||
|
||||
// No org yet → first-run onboarding
|
||||
if (!owner) {
|
||||
@@ -142,14 +113,30 @@ export function OrgGate({ children }: { children: ReactNode }) {
|
||||
|
||||
// On the admin host but not a global admin → render nothing while the redirect
|
||||
// above fires, so the admin console never flashes for an unauthorized user.
|
||||
if (onAdminHost() && !isGlobalAdmin) {
|
||||
if (onAdminHost() && !isSuperAdmin) {
|
||||
return null
|
||||
}
|
||||
|
||||
// GLOBAL admin on a non-admin host: show dismissible banner, render console
|
||||
// normally. Org-level admins (org owners) never see it — admin.hanzo.ai is
|
||||
// cross-tenant ops they cannot use.
|
||||
const showBanner = isGlobalAdmin && !onAdminHost() && !bannerDismissed
|
||||
// Selection not read yet (pre-hydration) → a brief neutral loader, so the picker
|
||||
// and the scoped console never flash in the wrong order.
|
||||
if (selected === null) {
|
||||
return (
|
||||
<YStack flex={1} minH="100vh" items="center" justify="center">
|
||||
<Spinner size="large" color="$color11" />
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
// Has org(s) but none entered → the org-less picker (the "Home" list). Every
|
||||
// login lands here; a one-org user clicks their single card in.
|
||||
if (!selected) {
|
||||
return <OrgPicker />
|
||||
}
|
||||
|
||||
// An org is entered → the scoped console. A GLOBAL admin on a non-admin host also
|
||||
// gets the dismissible admin banner; org owners never see it (admin.hanzo.ai is
|
||||
// cross-tenant ops they cannot use).
|
||||
const showBanner = isSuperAdmin && !onAdminHost() && !bannerDismissed
|
||||
|
||||
return (
|
||||
<YStack flex={1}>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Building2, ArrowRight, Sparkles } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { slugifyOrg, validateOrgName } from '~/lib/server/onboarding'
|
||||
import { v1Url } from '~/lib/api/client'
|
||||
import { FadeIn } from '~/components/ui/FadeIn'
|
||||
|
||||
type Phase = 'form' | 'done'
|
||||
@@ -41,7 +42,7 @@ export function OrgOnboarding() {
|
||||
setBusy(which)
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch('/onboard', {
|
||||
res = await fetch(v1Url('iam/onboard'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Org picker — the org-LESS landing every login sees first.
|
||||
*
|
||||
* The two-level model's top level: a full-page list of the organizations the
|
||||
* caller can act in. A one-org user sees a single card and clicks in (we never
|
||||
* auto-enter); a global admin (z@hanzo.ai) sees EVERY live org and can enter any
|
||||
* of them (masquerade). Clicking a card {@link enterOrg} scopes the whole console
|
||||
* to that org (X-Org-Id) and drops into it; the sidebar "Home" affordance
|
||||
* ({@link leaveOrg}) returns here and de-scopes.
|
||||
*
|
||||
* Data source mirrors {@link OrgSwitcher}: a global admin lists all orgs via the
|
||||
* gated `/admin/iam` proxy; a tenant (who 403s that list) sees just their own org,
|
||||
* synthesized from the session — so the picker is always honest and never fabricates
|
||||
* an org. All decisions (sort, filter, paginate, card view-model) live in the pure
|
||||
* `org-picker/logic.ts`; this file is a thin render of it with honest loading /
|
||||
* empty / error states.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Building2, LayoutGrid, Plus, Search } from '@hanzogui/lucide-icons-2'
|
||||
import { Button, Card, Input, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
|
||||
import { getBrand } from '~/lib/branding/brands'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { enterOrg } from '~/lib/org-scope'
|
||||
import { IamAdminApi, type Organization } from '~/lib/api'
|
||||
import { BrandMark } from '~/components/ui/BrandLogo'
|
||||
import { EmptyState } from '~/components/ui/EmptyState'
|
||||
import { FadeIn } from '~/components/ui/FadeIn'
|
||||
import { OrgOnboarding } from '~/components/OrgOnboarding'
|
||||
import { PAGE_SIZE, pickerView, type OrgCard, type PickerContext } from '~/components/org-picker/logic'
|
||||
|
||||
const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
|
||||
|
||||
/** One org card — logo or monogram, label, honest role + quick facts. */
|
||||
function OrgCardView({ card, onEnter }: { card: OrgCard; onEnter: () => void }) {
|
||||
return (
|
||||
<Card
|
||||
onPress={onEnter}
|
||||
cursor="pointer"
|
||||
role="button"
|
||||
aria-label={`Open ${card.title}`}
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
bg="$color1"
|
||||
p="$4"
|
||||
gap="$3"
|
||||
width={280}
|
||||
minW={240}
|
||||
hoverStyle={{ bg: '$color2', borderColor: '$color8' }}
|
||||
pressStyle={{ opacity: 0.85 }}
|
||||
>
|
||||
<XStack items="center" gap="$3">
|
||||
{card.logo ? (
|
||||
// Arbitrary external org logo — raw <img> (next/image would need a
|
||||
// per-tenant remote allowlist).
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={card.logo}
|
||||
alt=""
|
||||
style={{ height: 40, width: 40, objectFit: 'contain', display: 'block', borderRadius: 8 }}
|
||||
/>
|
||||
) : (
|
||||
<YStack width={40} height={40} rounded="$4" bg="$color4" items="center" justify="center">
|
||||
<Text fontSize="$4" fontWeight="800" color="$color12">
|
||||
{card.initials}
|
||||
</Text>
|
||||
</YStack>
|
||||
)}
|
||||
<YStack flex={1} minW={0}>
|
||||
<Text fontSize="$5" fontWeight="700" color="$color12" numberOfLines={1}>
|
||||
{card.title}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color10" numberOfLines={1}>
|
||||
{card.name}
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
|
||||
<XStack items="center" gap="$2" flexWrap="wrap">
|
||||
<XStack items="center" gap="$1.5" bg="$color3" rounded="$10" px="$2.5" py="$1">
|
||||
<Building2 size={12} opacity={0.7} />
|
||||
<Text fontSize="$1" color="$color11" fontWeight="600">
|
||||
{card.role}
|
||||
</Text>
|
||||
</XStack>
|
||||
{card.facts.map((f) => (
|
||||
<Text key={f.label} fontSize="$1" color="$color10" numberOfLines={1}>
|
||||
{f.label}: <Text color="$color11">{f.value}</Text>
|
||||
</Text>
|
||||
))}
|
||||
</XStack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function Center({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<YStack flex={1} minH="100vh" items="center" justify="center" p="$4" gap="$4">
|
||||
{children}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
export function OrgPicker() {
|
||||
const { account } = useSession()
|
||||
const isSuperAdmin = useIsSuperAdmin()
|
||||
const owner = account?.owner ?? ''
|
||||
|
||||
const [orgs, setOrgs] = useState<Organization[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
// The caller's OWN org, synthesized from the session — the single org that IS a
|
||||
// tenant's identity, and the honest fallback if the cross-tenant list can't load.
|
||||
const ownOrgOnly = useMemo<Organization[]>(
|
||||
() =>
|
||||
owner
|
||||
? [{ owner: 'admin', name: owner, displayName: account?.displayName?.trim() || titleCase(owner) } as Organization]
|
||||
: [],
|
||||
[owner, account?.displayName],
|
||||
)
|
||||
|
||||
// Load the caller's visible orgs. A global admin gets the full cross-tenant list
|
||||
// (paged large, then this component client-paginates); a tenant 403s that list, so
|
||||
// it sees just its own org — honest, never fabricated.
|
||||
useEffect(() => {
|
||||
if (!owner) return
|
||||
let live = true
|
||||
if (!isSuperAdmin) {
|
||||
setOrgs(ownOrgOnly)
|
||||
return
|
||||
}
|
||||
setOrgs(null)
|
||||
setError(null)
|
||||
IamAdminApi.organizations({ pageSize: 1000 })
|
||||
.then((p) => {
|
||||
if (live) setOrgs(p.rows ?? [])
|
||||
})
|
||||
.catch(() => {
|
||||
// The cross-tenant list is unavailable — surface it honestly, but still fall
|
||||
// back to the caller's own org so the picker is never a dead screen.
|
||||
if (live) {
|
||||
setError('Could not load all organizations — showing your own.')
|
||||
setOrgs(ownOrgOnly)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [owner, isSuperAdmin, ownOrgOnly])
|
||||
|
||||
const ctx: PickerContext = useMemo(
|
||||
() => ({ ownOrg: owner, isSuperAdmin, callerIsAdmin: Boolean(account?.isAdmin) }),
|
||||
[owner, isSuperAdmin, account?.isAdmin],
|
||||
)
|
||||
|
||||
const view = useMemo(
|
||||
() => (orgs ? pickerView(orgs, query, page, ctx) : null),
|
||||
[orgs, query, page, ctx],
|
||||
)
|
||||
|
||||
const brandName = getBrand().brandName.replace(' Cloud', '')
|
||||
|
||||
// "Create organization" → the sanctioned onboarding flow (its own success re-auth),
|
||||
// with a way back to the list.
|
||||
if (creating) {
|
||||
return (
|
||||
<YStack flex={1} minH="100vh">
|
||||
<XStack p="$3">
|
||||
<Button size="$2" chromeless icon={<LayoutGrid size={14} />} onPress={() => setCreating(false)}>
|
||||
All organizations
|
||||
</Button>
|
||||
</XStack>
|
||||
<OrgOnboarding />
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
// Loading — the first org fetch hasn't settled.
|
||||
if (!view) {
|
||||
return (
|
||||
<Center>
|
||||
<Spinner size="large" color="$color11" />
|
||||
<Text fontSize="$3" color="$color10">
|
||||
Loading your organizations…
|
||||
</Text>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack flex={1} minH="100vh" items="center" px="$4" py="$8" $md={{ py: '$10' }}>
|
||||
<YStack width="100%" maxW={1040} gap="$6">
|
||||
{/* Hero — brand mark + what this is. */}
|
||||
<FadeIn style={{ width: '100%' }}>
|
||||
<YStack gap="$3" items="center">
|
||||
<XStack items="center" gap="$2.5">
|
||||
<BrandMark size={30} />
|
||||
<Text fontSize="$8" fontWeight="800" color="$color12" letterSpacing={-0.6}>
|
||||
{brandName} Console
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$5" fontWeight="700" color="$color12" text="center">
|
||||
Choose an organization
|
||||
</Text>
|
||||
<Text fontSize="$3" color="$color11" maxW={520} text="center">
|
||||
{isSuperAdmin
|
||||
? 'Pick an organization to manage. As a platform admin you can enter any of them.'
|
||||
: 'Select your organization to open its console.'}
|
||||
</Text>
|
||||
</YStack>
|
||||
</FadeIn>
|
||||
|
||||
{/* Search + create — only show search once there's enough to filter. */}
|
||||
<XStack items="center" gap="$2" flexWrap="wrap" justify="center">
|
||||
{orgs && orgs.length > 6 ? (
|
||||
<XStack
|
||||
items="center"
|
||||
gap="$2"
|
||||
px="$3"
|
||||
height={40}
|
||||
rounded="$4"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
bg="$color2"
|
||||
flex={1}
|
||||
minW={240}
|
||||
maxW={420}
|
||||
>
|
||||
<Search size={15} opacity={0.6} />
|
||||
<Input
|
||||
flex={1}
|
||||
unstyled
|
||||
value={query}
|
||||
onChangeText={(v) => {
|
||||
setQuery(v)
|
||||
setPage(1)
|
||||
}}
|
||||
placeholder="Filter organizations…"
|
||||
fontSize="$3"
|
||||
color="$color12"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</XStack>
|
||||
) : null}
|
||||
<Button
|
||||
size="$3"
|
||||
icon={<Plus size={16} />}
|
||||
onPress={() => setCreating(true)}
|
||||
aria-label="Create organization"
|
||||
>
|
||||
Create organization
|
||||
</Button>
|
||||
</XStack>
|
||||
|
||||
{/* Error — the fetch itself failed (we still degrade to own-org above, so this
|
||||
is rare; kept honest rather than silent). */}
|
||||
{error ? (
|
||||
<Text fontSize="$2" color="$red10" text="center">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{/* Grid, empty, or no-match — all honest. */}
|
||||
{view.total === 0 && query.trim() ? (
|
||||
<Text fontSize="$3" color="$color10" text="center">
|
||||
No organizations match “{query.trim()}”.
|
||||
</Text>
|
||||
) : view.total === 0 ? (
|
||||
<EmptyState
|
||||
icon={Building2}
|
||||
title="No organizations yet"
|
||||
description="Create your first organization to start using the console."
|
||||
primary={{ label: 'Create organization', icon: <Plus size={15} />, onPress: () => setCreating(true) }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<XStack flexWrap="wrap" gap="$3" justify="center">
|
||||
{view.cards.map((card) => (
|
||||
<OrgCardView key={card.key} card={card} onEnter={() => enterOrg(card.name)} />
|
||||
))}
|
||||
</XStack>
|
||||
|
||||
{view.hasMore ? (
|
||||
<XStack justify="center">
|
||||
<Button size="$3" chromeless onPress={() => setPage((p) => p + 1)}>
|
||||
Show {Math.min(view.remaining, PAGE_SIZE)} more
|
||||
{view.remaining > PAGE_SIZE ? ` of ${view.remaining}` : ''}
|
||||
</Button>
|
||||
</XStack>
|
||||
) : null}
|
||||
|
||||
<Text fontSize="$1" color="$color9" text="center">
|
||||
{view.shown} of {view.total} {view.total === 1 ? 'organization' : 'organizations'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</YStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
+355
-104
@@ -1,78 +1,287 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Org switcher — shows the organization the console is scoped to, lets the user
|
||||
* switch between the orgs they can see, and CREATE a new one.
|
||||
* Org switcher — a Vercel-style, LAZY-LOADING team/org switcher that shows the org
|
||||
* the console is scoped to, lets the user switch between the orgs they can see, and
|
||||
* CREATE a new one.
|
||||
*
|
||||
* The org LIST comes from IAM (`get-organizations`, via the gated `/admin/iam`
|
||||
* proxy): a global admin sees every org; a tenant whose account can't list gets
|
||||
* an empty result and simply sees their current org. Either way the trigger is
|
||||
* ALWAYS interactive (so "Create organization" is reachable even with one org).
|
||||
* Selecting one re-scopes the console IN PLACE — `switchOrg` persists the choice
|
||||
* and reloads so every module refetches under the new `X-Org-Id`.
|
||||
* proxy): a super admin sees every org, a regular user sees only their own. For a
|
||||
* super admin the list is TRULY lazy — `useOrgList` fetches ONE page at a time
|
||||
* (`orgQuery` / `ORG_PAGE_SIZE`), pushes the search term to the server, and loads
|
||||
* more on demand (infinite-scroll + "Load more") so it scales to thousands of orgs
|
||||
* without loading them all up front. A regular user (who 403s the cross-tenant list)
|
||||
* simply sees their current org, synthesized from the scope — never fabricated.
|
||||
*
|
||||
* Create posts to the same-origin `/onboard` route: a zero-org user is created +
|
||||
* joined as admin; a user who already has an org gets an ADDITIONAL org (created
|
||||
* without moving them) and is scope-switched into it. We never fabricate orgs.
|
||||
* Selecting an org re-scopes the console IN PLACE — `switchOrg` persists the choice
|
||||
* and reloads so every module refetches under the new `X-Org-Id` (the super-admin
|
||||
* masquerade / god-view is unchanged). Create posts to the same-origin `/onboard`
|
||||
* route (zero-org user → created + joined as admin; existing user → an ADDITIONAL
|
||||
* org they're scope-switched into). Keyboard: ↑/↓ move, ↵ select, Esc close.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Button, Input, Popover, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Building2, Check, ChevronsUpDown, Plus, Search } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { currentOrg, switchOrg, filterOrgs } from '~/lib/org-scope'
|
||||
import { currentOrg, switchOrg } from '~/lib/org-scope'
|
||||
import {
|
||||
mergeOrgs,
|
||||
orgQuery,
|
||||
orgRows,
|
||||
pageIsFull,
|
||||
rowFor,
|
||||
type OrgRow,
|
||||
type OrgTier,
|
||||
} from '~/lib/org-list'
|
||||
import { IamAdminApi, type Organization } from '~/lib/api'
|
||||
import { useIsGlobalAdmin } from '~/lib/auth/admin'
|
||||
import { v1Url } from '~/lib/api/client'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
|
||||
const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
|
||||
|
||||
// ── Lazy-load hook ────────────────────────────────────────────────────────────
|
||||
|
||||
type OrgListState = {
|
||||
/** Accumulated raw orgs (server page order), appended across pages. */
|
||||
rows: Organization[]
|
||||
/** First page in flight (blank list). */
|
||||
loading: boolean
|
||||
/** A subsequent page in flight (list already shown). */
|
||||
loadingMore: boolean
|
||||
error: string | null
|
||||
/** Whether another page is likely available (last page was full). */
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
const EMPTY: OrgListState = { rows: [], loading: false, loadingMore: false, error: null, hasMore: false }
|
||||
|
||||
/**
|
||||
* Fetch the cross-tenant org list lazily: page 0 on open, more pages on demand,
|
||||
* and re-query (debounced) when the search term changes. Only runs when `enabled`
|
||||
* (a super admin) — a regular user never fires the admin-gated list.
|
||||
*/
|
||||
function useOrgList(enabled: boolean) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [debounced, setDebounced] = useState('')
|
||||
const [state, setState] = useState<OrgListState>(EMPTY)
|
||||
const pageRef = useRef(0)
|
||||
const reqRef = useRef(0) // race token — a newer request supersedes older ones
|
||||
const stateRef = useRef(state)
|
||||
useEffect(() => {
|
||||
stateRef.current = state
|
||||
}, [state])
|
||||
|
||||
// Debounce the raw query so search-as-you-type re-hits the server at most ~4×/s.
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebounced(query.trim()), 250)
|
||||
return () => clearTimeout(id)
|
||||
}, [query])
|
||||
|
||||
const fetchPage = useCallback(async (page: number, q: string, append: boolean) => {
|
||||
const token = ++reqRef.current
|
||||
setState((s) => ({ ...s, loading: !append, loadingMore: append, error: null }))
|
||||
try {
|
||||
const res = await IamAdminApi.organizations(orgQuery(page, q))
|
||||
if (token !== reqRef.current) return // superseded by a newer query/page
|
||||
const incoming = res.rows ?? []
|
||||
pageRef.current = page
|
||||
setState((s) => ({
|
||||
rows: append ? mergeOrgs(s.rows, incoming) : incoming,
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
error: null,
|
||||
hasMore: pageIsFull(incoming.length),
|
||||
}))
|
||||
} catch (e) {
|
||||
if (token !== reqRef.current) return
|
||||
// The cross-tenant list isn't available to this account — honest empty, never
|
||||
// a fabricated list. (A regular user 403s here; the hook is disabled for them.)
|
||||
setState((s) => ({
|
||||
rows: append ? s.rows : [],
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
error: append ? (e instanceof Error ? e.message : 'Could not load more.') : null,
|
||||
hasMore: false,
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
|
||||
// (Re)load page 0 when enabled toggles on or the debounced query changes.
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
reqRef.current++ // cancel any in-flight page
|
||||
pageRef.current = 0
|
||||
setState(EMPTY)
|
||||
return
|
||||
}
|
||||
pageRef.current = 0
|
||||
void fetchPage(0, debounced, false)
|
||||
}, [enabled, debounced, fetchPage])
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
const s = stateRef.current
|
||||
if (s.loading || s.loadingMore || !s.hasMore) return
|
||||
void fetchPage(pageRef.current + 1, debounced, true)
|
||||
}, [debounced, fetchPage])
|
||||
|
||||
return { ...state, query, setQuery, loadMore }
|
||||
}
|
||||
|
||||
// ── Presentational bits ───────────────────────────────────────────────────────
|
||||
|
||||
/** Monochrome plan/tier pill — rendered only when the org carries a real tier. */
|
||||
function TierBadge({ tier }: { tier: OrgTier }) {
|
||||
return (
|
||||
<XStack items="center" bg="$color3" rounded="$10" px="$2" py="$0.5" borderWidth={1} borderColor="$borderColor">
|
||||
<Text fontSize="$1" color="$color11" fontWeight="600">
|
||||
{tier.label}
|
||||
</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** An org's avatar — its logo when set, else a monogram tile. */
|
||||
function OrgAvatar({ row, size = 22 }: { row: OrgRow; size?: number }) {
|
||||
if (row.logo) {
|
||||
// Arbitrary external org logo — raw <img> (next/image would need a per-tenant
|
||||
// remote allowlist).
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return <img src={row.logo} alt="" style={{ height: size, width: size, objectFit: 'contain', display: 'block', borderRadius: 6 }} />
|
||||
}
|
||||
return (
|
||||
<YStack width={size} height={size} rounded="$3" bg="$color4" items="center" justify="center">
|
||||
<Text fontSize="$1" fontWeight="800" color="$color12">
|
||||
{row.initials}
|
||||
</Text>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** One selectable org row. */
|
||||
function OrgRowView({
|
||||
row,
|
||||
current,
|
||||
active,
|
||||
onPress,
|
||||
}: {
|
||||
row: OrgRow
|
||||
current: boolean
|
||||
active: boolean
|
||||
onPress: () => void
|
||||
}) {
|
||||
return (
|
||||
<XStack
|
||||
id={active ? 'org-active' : undefined}
|
||||
onPress={onPress}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
gap="$2.5"
|
||||
px="$2"
|
||||
py="$2"
|
||||
rounded="$3"
|
||||
bg={active ? '$color5' : current ? '$color4' : 'transparent'}
|
||||
hoverStyle={{ bg: '$color5' }}
|
||||
>
|
||||
<OrgAvatar row={row} />
|
||||
<Text flex={1} fontSize="$2" color="$color12" numberOfLines={1}>
|
||||
{row.title}
|
||||
</Text>
|
||||
{row.tier ? <TierBadge tier={row.tier} /> : null}
|
||||
{current ? <Check size={15} /> : null}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Switcher ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function OrgSwitcher() {
|
||||
const currentId = currentOrg()
|
||||
const isGlobalAdmin = useIsGlobalAdmin()
|
||||
const [orgs, setOrgs] = useState<Organization[]>([])
|
||||
const isSuperAdmin = useIsSuperAdmin()
|
||||
const list = useOrgList(isSuperAdmin)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [sel, setSel] = useState(0)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const searchRef = useRef<{ focus?: () => void } | null>(null)
|
||||
|
||||
// Rows to render. Super admin → the (lazy, server-paged) cross-tenant list,
|
||||
// client-filtered over name+displayName. Regular user → only their own org,
|
||||
// synthesized from the active scope (unchanged behavior; never fabricated).
|
||||
const rows: OrgRow[] = useMemo(() => {
|
||||
if (isSuperAdmin) return orgRows(list.rows, list.query)
|
||||
return [rowFor({ owner: 'admin', name: currentId, displayName: titleCase(currentId) } as Organization)]
|
||||
}, [isSuperAdmin, list.rows, list.query, currentId])
|
||||
|
||||
// The current org's human label for the trigger — real displayName once loaded.
|
||||
const currentName = useMemo(() => {
|
||||
const hit = list.rows.find((o) => o.name === currentId)
|
||||
return hit?.displayName || titleCase(currentId)
|
||||
}, [list.rows, currentId])
|
||||
|
||||
// Selectable items: the org rows, then the "Create" footer as the last item.
|
||||
const itemCount = rows.length + 1
|
||||
const createIndex = rows.length
|
||||
|
||||
// Reset the highlight to the top whenever the visible set changes.
|
||||
useEffect(() => {
|
||||
// The cross-tenant org list (`/admin/iam/get-organizations?owner=admin`) is
|
||||
// server-gated to global admins; a tenant user only ever 403s it. Don't fire it
|
||||
// for them — they simply see their current org (synthesized below) and can still
|
||||
// "Create organization". Global admins get the full switchable list.
|
||||
if (!isGlobalAdmin) {
|
||||
setOrgs([])
|
||||
return
|
||||
setSel(0)
|
||||
}, [list.query, rows.length])
|
||||
|
||||
// Focus the search box when the popover opens (super admin only).
|
||||
useEffect(() => {
|
||||
if (open && !creating && isSuperAdmin) {
|
||||
const id = setTimeout(() => searchRef.current?.focus?.(), 0)
|
||||
return () => clearTimeout(id)
|
||||
}
|
||||
let live = true
|
||||
IamAdminApi.organizations()
|
||||
.then((p) => {
|
||||
if (live) setOrgs(p.rows ?? [])
|
||||
})
|
||||
.catch(() => {
|
||||
// IAM org listing not available to this account (tenant) — current-org only.
|
||||
if (live) setOrgs([])
|
||||
})
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [isGlobalAdmin])
|
||||
}, [open, creating, isSuperAdmin])
|
||||
|
||||
// Always include the current org so the switcher is meaningful even when the
|
||||
// (admin-gated) list is empty for a tenant.
|
||||
const allOrgs = useMemo(() => {
|
||||
if (orgs.some((o) => o.name === currentId)) return orgs
|
||||
return [{ owner: 'admin', name: currentId, displayName: titleCase(currentId) } as Organization, ...orgs]
|
||||
}, [orgs, currentId])
|
||||
|
||||
const currentName = allOrgs.find((o) => o.name === currentId)?.displayName || titleCase(currentId)
|
||||
const filtered = useMemo(() => filterOrgs(allOrgs, query), [allOrgs, query])
|
||||
|
||||
const select = (org: Organization) => {
|
||||
const select = useCallback((org: string) => {
|
||||
setOpen(false)
|
||||
switchOrg(org.name)
|
||||
}
|
||||
switchOrg(org) // persists + reloads so every module re-scopes under the new X-Org-Id
|
||||
}, [])
|
||||
|
||||
const activate = useCallback(
|
||||
(index: number) => {
|
||||
if (index === createIndex) {
|
||||
setCreating(true)
|
||||
setErr(null)
|
||||
return
|
||||
}
|
||||
const row = rows[index]
|
||||
if (row) select(row.name)
|
||||
},
|
||||
[createIndex, rows, select],
|
||||
)
|
||||
|
||||
// Keyboard while open (and not in the create form): ↑/↓ move, ↵ select, Esc close.
|
||||
useEffect(() => {
|
||||
if (!open || creating) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false)
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSel((s) => Math.min(s + 1, itemCount - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSel((s) => Math.max(s - 1, 0))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
activate(sel)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [open, creating, itemCount, sel, activate])
|
||||
|
||||
// Keep the highlighted row in view as ↑/↓ moves past the fold.
|
||||
useEffect(() => {
|
||||
if (!open || typeof document === 'undefined') return
|
||||
document.getElementById('org-active')?.scrollIntoView({ block: 'nearest' })
|
||||
}, [sel, open])
|
||||
|
||||
const create = async () => {
|
||||
const name = newName.trim()
|
||||
@@ -80,8 +289,9 @@ export function OrgSwitcher() {
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
const res = await fetch('/onboard', {
|
||||
const res = await fetch(v1Url('iam/onboard'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
@@ -95,6 +305,9 @@ export function OrgSwitcher() {
|
||||
}
|
||||
}
|
||||
|
||||
const showSearch = isSuperAdmin
|
||||
const isEmpty = !list.loading && rows.length === 0
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen} placement="bottom-end">
|
||||
<Popover.Trigger asChild>
|
||||
@@ -102,7 +315,7 @@ export function OrgSwitcher() {
|
||||
{currentName}
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content bordered elevate p="$2" width={300} bg="$color2" borderColor="$borderColor">
|
||||
<Popover.Content bordered elevate p="$2" width={320} bg="$color2" borderColor="$borderColor">
|
||||
{creating ? (
|
||||
<YStack gap="$2">
|
||||
<Text fontSize="$2" color="$color12" fontWeight="700">
|
||||
@@ -145,70 +358,108 @@ export function OrgSwitcher() {
|
||||
</YStack>
|
||||
) : (
|
||||
<YStack gap="$1">
|
||||
<XStack items="center" gap="$2" px="$2" py="$1" rounded="$3" borderWidth={1} borderColor="$borderColor">
|
||||
<Search size={13} opacity={0.6} />
|
||||
<Input
|
||||
flex={1}
|
||||
size="$2"
|
||||
borderWidth={0}
|
||||
bg="transparent"
|
||||
placeholder="Filter organizations…"
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</XStack>
|
||||
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
|
||||
Organizations · {allOrgs.length}
|
||||
</Text>
|
||||
<YStack gap="$0.5" maxH={300} overflow="scroll">
|
||||
{filtered.length === 0 ? (
|
||||
<Text px="$2" py="$2" fontSize="$2" color="$color10">
|
||||
No organizations match “{query}”.
|
||||
</Text>
|
||||
) : (
|
||||
filtered.map((org) => {
|
||||
const isCurrent = org.name === currentId
|
||||
return (
|
||||
<XStack
|
||||
key={`${org.owner}/${org.name}`}
|
||||
onPress={() => select(org)}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
gap="$2"
|
||||
px="$2"
|
||||
py="$2"
|
||||
rounded="$3"
|
||||
bg={isCurrent ? '$color4' : 'transparent'}
|
||||
hoverStyle={{ bg: '$color5' }}
|
||||
>
|
||||
<Building2 size={14} opacity={0.7} />
|
||||
<Text flex={1} fontSize="$2" color="$color12" numberOfLines={1}>
|
||||
{org.displayName || titleCase(org.name)}
|
||||
</Text>
|
||||
{isCurrent ? <Check size={14} /> : null}
|
||||
</XStack>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</YStack>
|
||||
<XStack
|
||||
onPress={() => {
|
||||
setCreating(true)
|
||||
setErr(null)
|
||||
{showSearch ? (
|
||||
<XStack items="center" gap="$2" px="$2" py="$1" rounded="$3" borderWidth={1} borderColor="$borderColor">
|
||||
<Search size={13} opacity={0.6} />
|
||||
<Input
|
||||
ref={searchRef as never}
|
||||
flex={1}
|
||||
size="$2"
|
||||
borderWidth={0}
|
||||
bg="transparent"
|
||||
placeholder="Find organization…"
|
||||
value={list.query}
|
||||
onChangeText={list.setQuery}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</XStack>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{ maxHeight: 320, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget
|
||||
if (el.scrollHeight - el.scrollTop - el.clientHeight < 48) list.loadMore()
|
||||
}}
|
||||
>
|
||||
{list.loading ? (
|
||||
<XStack items="center" gap="$2" px="$2" py="$3">
|
||||
<Spinner size="small" color="$color11" />
|
||||
<Text fontSize="$2" color="$color10">
|
||||
Loading organizations…
|
||||
</Text>
|
||||
</XStack>
|
||||
) : isEmpty ? (
|
||||
list.query.trim() ? (
|
||||
<Text px="$2" py="$2" fontSize="$2" color="$color10">
|
||||
No organizations match “{list.query.trim()}”.
|
||||
</Text>
|
||||
) : (
|
||||
<YStack px="$2" py="$3" gap="$1">
|
||||
<Text fontSize="$2" color="$color11" fontWeight="600">
|
||||
No organizations yet
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color10">
|
||||
Teams you create and join appear here for quick context switching.
|
||||
</Text>
|
||||
</YStack>
|
||||
)
|
||||
) : (
|
||||
rows.map((row, i) => (
|
||||
<OrgRowView
|
||||
key={row.key}
|
||||
row={row}
|
||||
current={row.name === currentId}
|
||||
active={i === sel}
|
||||
onPress={() => activate(i)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
{list.loadingMore ? (
|
||||
<XStack items="center" gap="$2" px="$2" py="$2">
|
||||
<Spinner size="small" color="$color11" />
|
||||
<Text fontSize="$1" color="$color10">
|
||||
Loading more…
|
||||
</Text>
|
||||
</XStack>
|
||||
) : list.hasMore ? (
|
||||
<XStack
|
||||
onPress={() => list.loadMore()}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
justify="center"
|
||||
px="$2"
|
||||
py="$1.5"
|
||||
rounded="$3"
|
||||
hoverStyle={{ bg: '$color4' }}
|
||||
>
|
||||
<Text fontSize="$1" color="$color11" fontWeight="600">
|
||||
Load more
|
||||
</Text>
|
||||
</XStack>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<XStack
|
||||
id={sel === createIndex ? 'org-active' : undefined}
|
||||
onPress={() => activate(createIndex)}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
gap="$2"
|
||||
gap="$2.5"
|
||||
px="$2"
|
||||
py="$2"
|
||||
mt="$1"
|
||||
rounded="$3"
|
||||
borderTopWidth={1}
|
||||
borderColor="$borderColor"
|
||||
bg={sel === createIndex ? '$color5' : 'transparent'}
|
||||
hoverStyle={{ bg: '$color5' }}
|
||||
>
|
||||
<Plus size={14} />
|
||||
<YStack width={22} height={22} rounded="$3" bg="$color3" items="center" justify="center">
|
||||
<Plus size={14} />
|
||||
</YStack>
|
||||
<Text fontSize="$2" color="$color12">
|
||||
Create organization
|
||||
</Text>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* The ONE product-route renderer. Resolves a slug against the registry and renders
|
||||
* the module (or an honest sub-page stub / admin notice / interstitial), inside the
|
||||
* product error boundary. Used by BOTH the `app/(dashboard)/[...slug]` catch-all
|
||||
* (slug from the route params, on a real Next server) AND the dashboard home
|
||||
* (`app/(dashboard)/page.tsx`, slug from `usePathname()` — see the note there),
|
||||
* so the one-binary STATIC embed resolves deep links client-side from the live URL
|
||||
* even though cloud serves the single root `index.html` for every path. One
|
||||
* definition, both entry points — no duplicated routing.
|
||||
*/
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
import { resolveView, isAdminRoute } from '~/lib/products/match'
|
||||
import { findEntry } from '~/lib/products/registry'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { ProductSubpageStub } from '~/components/products/ProductSubpageStub'
|
||||
import { ProductSubpageModule } from '~/components/products/subpage/ProductSubpageModule'
|
||||
import { ProductInterstitial } from '~/components/products/ProductInterstitial'
|
||||
import { AdminManagedNotice } from '~/components/products/AdminManagedNotice'
|
||||
import { ProductErrorBoundary } from '~/components/errors/ProductErrorBoundary'
|
||||
|
||||
export function ProductRoute({ slug }: { slug: string[] }) {
|
||||
const showAdmin = useIsSuperAdmin()
|
||||
const view = resolveView(slug)
|
||||
|
||||
if (view.kind === 'notfound') notFound()
|
||||
|
||||
if (view.kind === 'external') return <ProductInterstitial id={view.entry.id} />
|
||||
|
||||
if (!showAdmin && isAdminRoute(slug)) {
|
||||
const entry = findEntry(slug[0])
|
||||
if (entry && entry.kind === 'module') {
|
||||
const seg = slug[1]
|
||||
const subpage = seg ? (entry.subpages ?? []).find((s) => s.slug === seg && s.admin) : undefined
|
||||
return <AdminManagedNotice entry={entry} subpage={subpage} />
|
||||
}
|
||||
}
|
||||
|
||||
if (view.kind === 'stub') return <ProductSubpageStub entry={view.entry} subpage={view.subpage} />
|
||||
|
||||
if (view.kind === 'subpage')
|
||||
return (
|
||||
<ProductErrorBoundary resetKey={slug.join('/')}>
|
||||
<ProductSubpageModule entry={view.entry} subpage={view.subpage} />
|
||||
</ProductErrorBoundary>
|
||||
)
|
||||
|
||||
const Component = view.matched.route.component
|
||||
return (
|
||||
<ProductErrorBoundary resetKey={slug.join('/')}>
|
||||
<Component params={view.matched.params} />
|
||||
</ProductErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProductRoute
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Inbound cross-surface deep-link. When the console is opened from another surface
|
||||
* (hanzo.app / hanzo.chat) with `?project=<iamProjectId>`, select that project as the
|
||||
* active scope AND land on its Platform hub detail — the mirror of the outbound
|
||||
* `?project=` links the hub emits. One shared key, both directions.
|
||||
*
|
||||
* `useSearchParams` needs a Suspense boundary (Next 15); this renders nothing.
|
||||
*/
|
||||
import { Suspense, useEffect, useRef } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
|
||||
import { useScope } from '~/lib/scope-context'
|
||||
import { PROJECT_PARAM } from '~/lib/products/cross-surface'
|
||||
|
||||
function ProjectDeepLinkInner() {
|
||||
const sp = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { selectProject } = useScope()
|
||||
const handled = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const id = (sp?.get(PROJECT_PARAM) || '').trim()
|
||||
if (!id || handled.current === id) return
|
||||
handled.current = id
|
||||
selectProject(id) // scope every module to it
|
||||
router.replace(`/platform/${encodeURIComponent(id)}`) // open its hub; drops ?project=
|
||||
}, [sp, selectProject, router])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function ProjectDeepLink() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ProjectDeepLinkInner />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -7,11 +7,15 @@
|
||||
import { useMemo, type ReactNode } from 'react'
|
||||
import { GuiProvider } from '@hanzo/gui'
|
||||
import { NextThemeProvider, useRootTheme } from '@hanzogui/next-theme'
|
||||
import { registerDefaultFields } from '@hanzo/data'
|
||||
import { registerDefaultFields, registerField } from '@hanzo/data'
|
||||
import { AnalyticsProvider } from '@hanzo/capture/react'
|
||||
|
||||
import config from '../../gui.config'
|
||||
import { SessionProvider } from '~/lib/auth/session'
|
||||
import { EntitlementsProvider } from '~/lib/entitlements-context'
|
||||
import { AnalyticsBridge } from './Analytics'
|
||||
import { OrgAccentProvider } from './OrgAccentProvider'
|
||||
import { RichTextDisplay, RichTextInput } from './fields/RichTextField'
|
||||
|
||||
// @hanzo/data populates its field-INPUT registry via an import SIDE EFFECT, but the
|
||||
// package ships `"sideEffects": false`, so production tree-shaking (we consume it via
|
||||
@@ -23,6 +27,14 @@ import { OrgAccentProvider } from './OrgAccentProvider'
|
||||
// ONE place, DRY — fixes every editable @hanzo/data surface, current and future.
|
||||
registerDefaultFields()
|
||||
|
||||
// Upgrade the `richText` field from @hanzo/data's plain-textarea fallback to the
|
||||
// native Lexical WYSIWYG (bold/italic/headings/lists/links/quote + read-mode HTML).
|
||||
// `registerField` OVERRIDES the default in place — one dispatch point, so every
|
||||
// `richText` field (a CMS Article/Page body, any DocType/collection field typed
|
||||
// RichText) renders the real editor. Lexical is a console concern, not a data-layer
|
||||
// one, so it's registered here rather than forking @hanzo/data.
|
||||
registerField('richText', { Display: RichTextDisplay, Input: RichTextInput })
|
||||
|
||||
function Themed({ children }: { children: ReactNode }) {
|
||||
// `dark` fallback so the server render and the client's initial state agree
|
||||
// (the html element ships `class="t_dark"`), avoiding a hydration mismatch.
|
||||
@@ -45,7 +57,18 @@ export function Provider({ children }: { children: ReactNode }) {
|
||||
() => (
|
||||
<SessionProvider>
|
||||
<OrgAccentProvider />
|
||||
{children}
|
||||
{/* Entitlements live inside the session (they read the signed-in account +
|
||||
active org scope) so the sidebar/launcher/palette gate from ONE fetch. */}
|
||||
<EntitlementsProvider>
|
||||
{/* Analytics lives INSIDE the session so `identify` binds the signed-in
|
||||
actor; cookie/same-origin mode (the tenant is stamped server-side), so
|
||||
only the product is configured. `AnalyticsBridge` wires pageviews +
|
||||
identity. */}
|
||||
<AnalyticsProvider config={{ product: 'console' }}>
|
||||
<AnalyticsBridge />
|
||||
{children}
|
||||
</AnalyticsProvider>
|
||||
</EntitlementsProvider>
|
||||
</SessionProvider>
|
||||
),
|
||||
[children],
|
||||
|
||||
@@ -1,32 +1,43 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Scope switcher — the project + environment pickers that scope every module.
|
||||
* Scope switcher — the project + network pickers that scope every module.
|
||||
*
|
||||
* Two chips next to the org switcher: the active PROJECT (or "All projects" for
|
||||
* org-level scope) and the active ENVIRONMENT (mainnet/testnet/devnet + custom).
|
||||
* Both write through `useScope`, which updates the module-level scope the API
|
||||
* client reads — so changing either re-scopes every product at once. A "New
|
||||
* project" affordance routes to the Projects module; we never fabricate a project.
|
||||
* org-level scope) and the active NETWORK. The network picker offers the stock
|
||||
* tiers (Mainnet/Testnet/Devnet — the live Hanzo networks), a Local option for a
|
||||
* self-hosted cloud binary, and any custom networks the user adds (their own
|
||||
* networkID / EVM chainID / RPC / API). Selecting a network writes through
|
||||
* `useScope`, which updates the module-level scope the API client reads — so it
|
||||
* re-scopes every module (via `X-Environment`) AND retargets chain/RPC/API at once.
|
||||
* A "New project" affordance routes to the Projects module; we never fabricate one.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button, Popover, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Check, ChevronsUpDown, FolderGit2, Layers, Plus } from '@hanzogui/lucide-icons-2'
|
||||
import { Check, ChevronsUpDown, FolderGit2, Layers, Plus, Trash } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { useScope } from '~/lib/scope-context'
|
||||
import { STOCK_ENVIRONMENTS } from '~/lib/scope'
|
||||
import { isStockNetwork, parseCustomNetwork, type Network } from '~/lib/network'
|
||||
import { FieldText } from '~/components/ui/Field'
|
||||
|
||||
/** A small colored dot keyed to the environment's network tier. */
|
||||
type DotColor = '$green10' | '$yellow10' | '$blue10' | '$purple10'
|
||||
const ENV_DOT: Record<string, DotColor> = {
|
||||
/** A small colored dot keyed to the network tier. */
|
||||
type DotColor = '$green10' | '$yellow10' | '$blue10' | '$purple10' | '$orange10'
|
||||
const NET_DOT: Record<string, DotColor> = {
|
||||
mainnet: '$green10',
|
||||
testnet: '$yellow10',
|
||||
devnet: '$blue10',
|
||||
local: '$purple10',
|
||||
}
|
||||
const envDot = (env: string): DotColor => ENV_DOT[env] ?? '$purple10'
|
||||
const netDot = (n: Network): DotColor => (n.custom ? '$orange10' : NET_DOT[n.id] ?? '$purple10')
|
||||
const envDot = (env: string): DotColor => NET_DOT[env] ?? '$orange10'
|
||||
const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
|
||||
|
||||
/** The (networkID, evmChainID) subtitle — collapsed when they're equal (sovereign L1). */
|
||||
const idSub = (n: Network): string =>
|
||||
n.networkID === n.evmChainID ? `Chain ${n.evmChainID}` : `Net ${n.networkID} · Chain ${n.evmChainID}`
|
||||
|
||||
function ProjectPicker() {
|
||||
const router = useRouter()
|
||||
const { scope, projects, loadingProjects, selectProject } = useScope()
|
||||
@@ -80,45 +91,175 @@ function ProjectPicker() {
|
||||
)
|
||||
}
|
||||
|
||||
function EnvironmentPicker() {
|
||||
const { scope, environments, selectEnvironment } = useScope()
|
||||
const EMPTY_FORM = { label: '', networkID: '', evmChainID: '', rpcEndpoint: '', apiEndpoint: '' }
|
||||
|
||||
function AddNetworkForm({
|
||||
taken,
|
||||
onAdd,
|
||||
onCancel,
|
||||
}: {
|
||||
taken: ReadonlySet<string>
|
||||
onAdd: (net: Network) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [form, setForm] = useState(EMPTY_FORM)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const set = (k: keyof typeof form) => (v: string) => setForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
const submit = () => {
|
||||
const r = parseCustomNetwork(form, taken)
|
||||
if ('error' in r) {
|
||||
setError(r.error)
|
||||
return
|
||||
}
|
||||
onAdd(r.network)
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack gap="$2" px="$2" py="$1.5">
|
||||
<FieldText value={form.label} onChange={set('label')} placeholder="Name (e.g. Home)" />
|
||||
<FieldText value={form.networkID} onChange={set('networkID')} placeholder="Network ID (e.g. 70000)" />
|
||||
<FieldText value={form.evmChainID} onChange={set('evmChainID')} placeholder="EVM chain ID (defaults to Network ID)" />
|
||||
<FieldText value={form.rpcEndpoint} onChange={set('rpcEndpoint')} placeholder="RPC URL (https://…)" />
|
||||
<FieldText value={form.apiEndpoint} onChange={set('apiEndpoint')} placeholder="API URL (blank = same-origin)" />
|
||||
{error ? (
|
||||
<Text fontSize="$1" color="$red10">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
<XStack gap="$2" justify="flex-end">
|
||||
<Button size="$2" chromeless onPress={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="$2" icon={<Plus size={14} />} onPress={submit}>
|
||||
Add
|
||||
</Button>
|
||||
</XStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
function NetworkPicker() {
|
||||
const { scope, networks, environments, activeNetwork, selectEnvironment, addCustomNetwork, removeCustomNetwork } =
|
||||
useScope()
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
const takenCustomIds = useMemo(
|
||||
() => new Set(networks.filter((n) => n.custom).map((n) => n.id)),
|
||||
[networks],
|
||||
)
|
||||
|
||||
// Project-custom environments (a project's own env names) that are NOT networks —
|
||||
// surfaced so selecting one still re-scopes X-Environment (default network endpoints).
|
||||
const networkIds = useMemo(() => new Set(networks.map((n) => n.id)), [networks])
|
||||
const extraEnvs = useMemo(
|
||||
() => environments.filter((e) => !networkIds.has(e) && !(STOCK_ENVIRONMENTS as readonly string[]).includes(e)),
|
||||
[environments, networkIds],
|
||||
)
|
||||
|
||||
const add = (net: Network) => {
|
||||
addCustomNetwork(net)
|
||||
setAdding(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover placement="bottom-end">
|
||||
<Popover.Trigger asChild>
|
||||
<Button size="$2" chromeless icon={<Layers size={14} />} iconAfter={<ChevronsUpDown size={13} />}>
|
||||
<XStack items="center" gap="$2">
|
||||
<YStack width={8} height={8} rounded={999} bg={envDot(scope.environment)} />
|
||||
<YStack width={8} height={8} rounded={999} bg={netDot(activeNetwork)} />
|
||||
<Text fontSize="$2" color="$color12">
|
||||
{scope.environment}
|
||||
{activeNetwork.label}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content bordered elevate p="$2" width={220} bg="$color2" borderColor="$borderColor">
|
||||
<Popover.Content bordered elevate p="$2" width={300} bg="$color2" borderColor="$borderColor">
|
||||
<YStack gap="$0.5">
|
||||
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
|
||||
Environment
|
||||
Network
|
||||
</Text>
|
||||
{environments.map((env) => {
|
||||
const stock = (STOCK_ENVIRONMENTS as readonly string[]).includes(env)
|
||||
return (
|
||||
<Row
|
||||
key={env}
|
||||
label={titleCase(env)}
|
||||
sub={stock ? undefined : 'Custom'}
|
||||
dot={envDot(env)}
|
||||
active={scope.environment === env}
|
||||
onPress={() => selectEnvironment(env)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{networks.map((n) => (
|
||||
<NetworkRow
|
||||
key={n.id}
|
||||
net={n}
|
||||
active={scope.environment === n.id}
|
||||
onPress={() => selectEnvironment(n.id)}
|
||||
onRemove={n.custom ? () => removeCustomNetwork(n.id) : undefined}
|
||||
/>
|
||||
))}
|
||||
|
||||
{extraEnvs.length ? (
|
||||
<>
|
||||
<XStack height={1} bg="$borderColor" my="$1" />
|
||||
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
|
||||
Project environments
|
||||
</Text>
|
||||
{extraEnvs.map((env) => (
|
||||
<Row
|
||||
key={env}
|
||||
label={titleCase(env)}
|
||||
sub="Custom"
|
||||
dot={envDot(env)}
|
||||
active={scope.environment === env}
|
||||
onPress={() => selectEnvironment(env)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<XStack height={1} bg="$borderColor" my="$1" />
|
||||
{adding ? (
|
||||
<AddNetworkForm taken={takenCustomIds} onAdd={add} onCancel={() => setAdding(false)} />
|
||||
) : (
|
||||
<Row label="Add custom network" icon={<Plus size={14} />} onPress={() => setAdding(true)} />
|
||||
)}
|
||||
</YStack>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
/** A network row: pressable identity (dot + label + chain sub + check) with an optional remove. */
|
||||
function NetworkRow({
|
||||
net,
|
||||
active,
|
||||
onPress,
|
||||
onRemove,
|
||||
}: {
|
||||
net: Network
|
||||
active?: boolean
|
||||
onPress: () => void
|
||||
onRemove?: () => void
|
||||
}) {
|
||||
return (
|
||||
<XStack items="center" gap="$1" rounded="$3" hoverStyle={{ bg: '$color4' }}>
|
||||
<XStack flex={1} onPress={onPress} cursor="pointer" items="center" gap="$2" px="$2" py="$2">
|
||||
<YStack width={8} height={8} rounded={999} bg={netDot(net)} />
|
||||
<YStack flex={1}>
|
||||
<Text fontSize="$2" color="$color12" numberOfLines={1}>
|
||||
{net.label}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color10" numberOfLines={1}>
|
||||
{idSub(net)}
|
||||
</Text>
|
||||
</YStack>
|
||||
{active ? <Check size={14} /> : null}
|
||||
</XStack>
|
||||
{onRemove ? (
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless
|
||||
icon={<Trash size={13} />}
|
||||
onPress={onRemove}
|
||||
aria-label={`Remove ${net.label}`}
|
||||
/>
|
||||
) : null}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** One selectable row in a picker popover. */
|
||||
function Row({
|
||||
label,
|
||||
@@ -162,12 +303,12 @@ function Row({
|
||||
)
|
||||
}
|
||||
|
||||
/** Project + environment pickers as a unit (topbar). */
|
||||
/** Project + network pickers as a unit (topbar). */
|
||||
export function ScopeSwitcher() {
|
||||
return (
|
||||
<XStack items="center" gap="$1">
|
||||
<ProjectPicker />
|
||||
<EnvironmentPicker />
|
||||
<NetworkPicker />
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Top-left brand logomark — the ONE brand glyph in the console chrome, matching
|
||||
* the unified Hanzo app-shell (hanzo.app + hanzo.chat): the real geometric mark
|
||||
* ALONE (no wordmark, no product name, no letter-H text), white-labeled by host.
|
||||
*
|
||||
* The mark is the host-derived `BrandMark` (Hanzo H / Lux / Zoo / Pars per host,
|
||||
* `currentColor` so it inherits the calm chrome foreground and adapts to the
|
||||
* theme) — never a hardcoded Hanzo asset. On a lux/zoo/pars host it renders THAT
|
||||
* brand's mark. Left-click → product home (`/`); RIGHT-CLICK → a small brand
|
||||
* context menu (Settings · Brand · Docs · About), the same affordance the shared
|
||||
* shell's HanzoMark exposes.
|
||||
*
|
||||
* The interactive surface is a plain `<div>` (the console's own escape hatch, as
|
||||
* used for `<div onScroll>` in OrgSwitcher) so the native `contextmenu` event is
|
||||
* guaranteed to fire and the mark still inherits the chrome color via
|
||||
* `currentColor`. The menu reuses the console's one menu surface (a `$color2`
|
||||
* paper sheet with the shared `hz-paper hz-menu-in` styling) — no new menu
|
||||
* system; it is a cursor-anchored overlay (a right-click has no trigger rect to
|
||||
* anchor a Popover to).
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { BookOpen, Globe, Info, SlidersHorizontal } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { config } from '~/config'
|
||||
import { getBrand } from '~/lib/branding/brands'
|
||||
import { BrandMark } from '~/components/ui/BrandLogo'
|
||||
|
||||
type MenuItem = {
|
||||
icon: typeof SlidersHorizontal
|
||||
label: string
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
/** The cursor-anchored brand menu — clamped inside the viewport, closes on
|
||||
* outside-click / Escape. One paper sheet, shared chrome styling. */
|
||||
function BrandMenu({ x, y, items, onClose }: { x: number; y: number; items: MenuItem[]; onClose: () => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = (e: globalThis.MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose()
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
// Clamp so the sheet never spills past the viewport edge.
|
||||
const left = typeof window !== 'undefined' ? Math.min(x, window.innerWidth - 200) : x
|
||||
const top = typeof window !== 'undefined' ? Math.min(y, window.innerHeight - 200) : y
|
||||
|
||||
return (
|
||||
<div ref={ref} role="menu" style={{ position: 'fixed', left, top, zIndex: 9999 }}>
|
||||
<YStack
|
||||
className="hz-paper hz-menu-in"
|
||||
minW={184}
|
||||
p="$1.5"
|
||||
gap="$0.5"
|
||||
rounded="$4"
|
||||
bg="$color2"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<XStack
|
||||
key={item.label}
|
||||
role="menuitem"
|
||||
onPress={() => {
|
||||
onClose()
|
||||
item.onSelect()
|
||||
}}
|
||||
cursor="pointer"
|
||||
items="center"
|
||||
gap="$2.5"
|
||||
px="$2"
|
||||
py="$2"
|
||||
rounded="$3"
|
||||
hoverStyle={{ bg: '$color4' }}
|
||||
>
|
||||
<item.icon size={15} />
|
||||
<Text fontSize="$2" color="$color12">
|
||||
{item.label}
|
||||
</Text>
|
||||
</XStack>
|
||||
))}
|
||||
</YStack>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The top-left brand mark. `collapsed` centers it in the icon rail. Left-click →
|
||||
* home; right-click → the brand context menu.
|
||||
*/
|
||||
export function SidebarBrand({ collapsed, onNavigate }: { collapsed: boolean; onNavigate?: () => void }) {
|
||||
const router = useRouter()
|
||||
const brand = getBrand()
|
||||
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null)
|
||||
|
||||
const go = useCallback(
|
||||
(path: string) => {
|
||||
router.push(path)
|
||||
onNavigate?.()
|
||||
},
|
||||
[router, onNavigate],
|
||||
)
|
||||
|
||||
const openExternal = useCallback((url: string) => {
|
||||
if (typeof window !== 'undefined') window.open(url, '_blank', 'noopener')
|
||||
}, [])
|
||||
|
||||
const onContextMenu = useCallback((e: ReactMouseEvent) => {
|
||||
e.preventDefault()
|
||||
setMenu({ x: e.clientX, y: e.clientY })
|
||||
}, [])
|
||||
|
||||
// Settings + Docs are REAL, always-resolving targets (the `/settings` product +
|
||||
// the brand's docs site); Brand + About open the brand's own marketing site
|
||||
// (white-labeled — a lux/zoo host opens ITS site, never hanzo.ai). No dead links.
|
||||
const items: MenuItem[] = [
|
||||
{ icon: SlidersHorizontal, label: 'Settings', onSelect: () => go('/settings') },
|
||||
{ icon: Globe, label: 'Brand', onSelect: () => openExternal(`${brand.websiteUrl}/brand`) },
|
||||
{ icon: BookOpen, label: 'Docs', onSelect: () => openExternal(config.docsUrl) },
|
||||
{ icon: Info, label: 'About', onSelect: () => openExternal(brand.websiteUrl) },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onClick={() => go('/')}
|
||||
onContextMenu={onContextMenu}
|
||||
role="link"
|
||||
aria-label={`${brand.brandName} — home (right-click for brand menu)`}
|
||||
title={brand.brandName}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
height: 40,
|
||||
paddingLeft: collapsed ? 0 : 4,
|
||||
cursor: 'pointer',
|
||||
color: 'var(--color12)',
|
||||
}}
|
||||
>
|
||||
<BrandMark size={24} />
|
||||
</div>
|
||||
{menu ? <BrandMenu x={menu.x} y={menu.y} items={items} onClose={() => setMenu(null)} /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,52 +1,26 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Always-visible identity + wallet — pinned to the bottom of the sidebar so a
|
||||
* customer's account, balance, top-up, and sign-out are one glance / one click
|
||||
* away on every page.
|
||||
*
|
||||
* Identity (avatar + display name) comes from the signed-in IAM account. Three
|
||||
* destinations, one way each:
|
||||
* - The user row → the **Profile** page (`/profile`): account, security, keys.
|
||||
* - The balance row → the in-console **Billing** module (`/billing`): balance,
|
||||
* usage, invoices — the org's own data, scoped to the active org.
|
||||
* - **Top up** → the brand billing portal (billing.hanzo.ai) — payment is never
|
||||
* rebuilt here. **Sign out** sits directly beneath it.
|
||||
* Wallet footer — pinned to the bottom of the sidebar so the org's balance, top-up,
|
||||
* and sign-out are one glance / one click away on every page. The signed-in ACCOUNT
|
||||
* identity now lives in the sidebar's TOP switcher (SidebarIdentity → account menu),
|
||||
* so this footer no longer repeats the name/avatar — it is purely the wallet:
|
||||
* - The balance row → the in-console **Billing** module (`/billing`): balance, usage,
|
||||
* invoices — the org's own data, scoped to the active org.
|
||||
* - **Top up** → the in-console card top-up (`/billing/credits`). **Sign out** beneath.
|
||||
*
|
||||
* The balance comes from the per-tenant `/billing/*` server proxy, scoped to the
|
||||
* caller's OWN org — the exact credit the gateway debits.
|
||||
*/
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Avatar, Button, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Button, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { ChevronRight, LogOut, Wallet } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { useCloudBalance, spendableCents } from '~/lib/billing/live-balance'
|
||||
import { useCloudBalance, spendableCents, balanceSplitLabel } from '~/lib/billing/live-balance'
|
||||
|
||||
const fmtUsd = (cents: number): string => `$${(cents / 100).toFixed(2)}`
|
||||
|
||||
/** Up-to-two-letter initials from a display name / handle (avatar fallback). */
|
||||
function initials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean)
|
||||
if (parts.length === 0) return '?'
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
||||
}
|
||||
|
||||
/** The account avatar — IAM photo when present, initials circle otherwise. */
|
||||
function IdentityAvatar({ name, avatar, size }: { name: string; avatar?: string; size: number }) {
|
||||
return (
|
||||
<Avatar circular size={size}>
|
||||
{avatar ? <Avatar.Image accessibilityLabel={name} src={avatar} /> : null}
|
||||
<Avatar.Fallback bg="$color5" items="center" justify="center">
|
||||
<Text fontSize={size <= 28 ? '$1' : '$2'} fontWeight="800" color="$color12">
|
||||
{initials(name)}
|
||||
</Text>
|
||||
</Avatar.Fallback>
|
||||
</Avatar>
|
||||
)
|
||||
}
|
||||
|
||||
export function SidebarWallet({ collapsed }: { collapsed: boolean }) {
|
||||
const { account, signOut } = useSession()
|
||||
const router = useRouter()
|
||||
@@ -56,25 +30,21 @@ export function SidebarWallet({ collapsed }: { collapsed: boolean }) {
|
||||
// top-up — so spend/credit changes reflect here without a reload.
|
||||
const { balance } = useCloudBalance()
|
||||
const cents = spendableCents(balance)
|
||||
// The distinct trial + prepaid split ("$5.00 trial + $X.XX credits"), when commerce
|
||||
// reports the buckets — else null and we show only the combined total.
|
||||
const splitText = balanceSplitLabel(balance)
|
||||
|
||||
if (!owner) return null
|
||||
|
||||
const name = account?.displayName || account?.name || 'Account'
|
||||
const avatar = typeof account?.avatar === 'string' ? account.avatar : undefined
|
||||
const balanceText = cents === null ? '—' : fmtUsd(cents)
|
||||
const openProfile = () => router.push('/profile')
|
||||
const openCost = () => router.push('/billing')
|
||||
// In-console card top-up (Square) — the /billing/credits page. Replaces the old
|
||||
// window.open to the external billing.hanzo.ai portal.
|
||||
// In-console card top-up (Square) — the /billing/credits page.
|
||||
const openTopUp = () => router.push('/billing/credits')
|
||||
|
||||
if (collapsed) {
|
||||
// Three stacked affordances: identity (→ Profile), top-up, sign out.
|
||||
// Two stacked affordances: wallet/top-up + sign out (the identity is the top avatar).
|
||||
return (
|
||||
<YStack items="center" gap="$2">
|
||||
<YStack onPress={openProfile} cursor="pointer" hoverStyle={{ opacity: 0.85 }} aria-label={`${name} — open Profile`}>
|
||||
<IdentityAvatar name={name} avatar={avatar} size={32} />
|
||||
</YStack>
|
||||
<Button size="$2" chromeless onPress={openTopUp} icon={<Wallet size={18} />} aria-label={`Wallet ${balanceText} — top up`} />
|
||||
<Button size="$2" chromeless onPress={() => void signOut()} icon={<LogOut size={18} />} aria-label="Sign out" />
|
||||
</YStack>
|
||||
@@ -83,32 +53,24 @@ export function SidebarWallet({ collapsed }: { collapsed: boolean }) {
|
||||
|
||||
return (
|
||||
<YStack gap="$2" px="$2.5" py="$2.5" rounded="$3" bg="$color2" borderWidth={1} borderColor="$borderColor">
|
||||
{/* Identity → Profile */}
|
||||
<XStack items="center" gap="$2.5" onPress={openProfile} cursor="pointer" hoverStyle={{ opacity: 0.85 }} aria-label={`${name} — open Profile`}>
|
||||
<IdentityAvatar name={name} avatar={avatar} size={36} />
|
||||
<YStack flex={1} minW={0}>
|
||||
<Text fontSize="$3" fontWeight="700" color="$color12" numberOfLines={1}>{name}</Text>
|
||||
<Text fontSize="$1" color="$color10" numberOfLines={1}>{account?.email || 'View profile'}</Text>
|
||||
</YStack>
|
||||
<ChevronRight size={16} opacity={0.5} />
|
||||
</XStack>
|
||||
|
||||
{/* Balance → Cost */}
|
||||
<XStack
|
||||
items="center"
|
||||
justify="space-between"
|
||||
{/* Balance → Cost (with the trial + prepaid split when reported) */}
|
||||
<YStack
|
||||
onPress={openCost}
|
||||
cursor="pointer"
|
||||
hoverStyle={{ opacity: 0.85 }}
|
||||
px="$1"
|
||||
aria-label={`Balance ${balanceText} — open Cost`}
|
||||
gap="$0.5"
|
||||
aria-label={`Balance ${balanceText}${splitText ? ` (${splitText})` : ''} — open Cost`}
|
||||
>
|
||||
<XStack items="center" gap="$1.5">
|
||||
<Wallet size={13} opacity={0.7} />
|
||||
<Text fontSize="$2" color="$color11">{balanceText}</Text>
|
||||
<XStack items="center" justify="space-between">
|
||||
<XStack items="center" gap="$2">
|
||||
<Wallet size={15} opacity={0.75} />
|
||||
<Text fontSize="$3" fontWeight="700" color="$color12">{balanceText}</Text>
|
||||
</XStack>
|
||||
<ChevronRight size={14} opacity={0.4} />
|
||||
</XStack>
|
||||
<ChevronRight size={14} opacity={0.4} />
|
||||
</XStack>
|
||||
{splitText ? <Text fontSize="$1" color="$color10" numberOfLines={1}>{splitText}</Text> : null}
|
||||
</YStack>
|
||||
|
||||
<Button size="$2" onPress={openTopUp}>Top up</Button>
|
||||
<Button size="$2" chromeless icon={<LogOut size={15} />} onPress={() => void signOut()} justify="center">
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Sign-in experience — the ONE way the console presents sign-in, wherever the user
|
||||
* lands on it.
|
||||
*
|
||||
* TENANT host (console.<brand>): the credential form (email/password + social), which
|
||||
* resolves the user's org by email.
|
||||
*
|
||||
* ADMIN host (admin.<brand>): NO second manual form. The admin guard has already
|
||||
* authenticated the operator at the brand IAM (a live SSO session), so the console
|
||||
* silently (re)authorizes the PUBLIC `admin-console` client against that session and
|
||||
* returns via `/auth/callback` — ONE login reaches the operator cockpit (the whole
|
||||
* point of the admin.hanzo.ai cutover). See `startAdminSignin`.
|
||||
*
|
||||
* Rendered by BOTH the `/signin` route (`app/signin/page.tsx`) AND `AuthGate`: the
|
||||
* deploy serves the SPA shell for every path, so a direct load of `/signin` mounts the
|
||||
* dashboard tree (AuthGate), not the `/signin` route — AuthGate defers to this
|
||||
* component so `/signin` resolves to the form without depending on a navigation.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import { SignInForm } from '~/components/SignInForm'
|
||||
import { Loader } from '~/components/ui/Loader'
|
||||
import { isAdminHost } from '~/config'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { startAdminSignin } from '~/lib/auth/iam-login'
|
||||
|
||||
export function SignIn() {
|
||||
const { account, loading } = useSession()
|
||||
const router = useRouter()
|
||||
// Host-derived, so resolved AFTER mount (window is absent during SSR). Null until
|
||||
// then keeps SSR and hydration in agreement AND never flashes the manual form on an
|
||||
// admin host before the silent-SSO redirect fires.
|
||||
const [admin, setAdmin] = useState<boolean | null>(null)
|
||||
const started = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
setAdmin(isAdminHost(window.location.hostname))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || admin === null) return
|
||||
// Already authenticated → the cockpit. Never (re)trigger SSO for a live session.
|
||||
if (account) {
|
||||
router.replace('/')
|
||||
return
|
||||
}
|
||||
// Tenant host keeps the manual form; only an admin host auto-initiates.
|
||||
if (!admin || started.current) return
|
||||
// Loop guard: never re-initiate while an OAuth response is being processed (the
|
||||
// code/error rides `/auth/callback`, not here — belt and suspenders).
|
||||
const q = new URLSearchParams(window.location.search)
|
||||
if (q.has('code') || q.has('error')) return
|
||||
started.current = true
|
||||
void startAdminSignin()
|
||||
}, [loading, account, admin, router])
|
||||
|
||||
// Admin host (or still resolving): never the manual form — a loader covers the
|
||||
// one-tick silent-SSO redirect. Tenant host: the standard credential form.
|
||||
if (admin === null || admin) return <Loader label="Signing in…" />
|
||||
return <SignInForm />
|
||||
}
|
||||
+105
-19
@@ -21,21 +21,57 @@
|
||||
* cross-site fetch can't carry the MFA challenge) — we hand off with a redirect
|
||||
* rather than fake an inline step.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Anchor, Button, Card, Input, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Github } from '@hanzogui/lucide-icons-2'
|
||||
import { Apple, Github, Gitlab, Wallet } from '@hanzogui/lucide-icons-2'
|
||||
import { useAnalytics } from '@hanzo/capture/react'
|
||||
import { EVENTS } from '@hanzo/capture'
|
||||
|
||||
import { branding } from '~/config'
|
||||
import { HanzoMark } from '~/components/ui/Loader'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { Turnstile, turnstileConfigured } from '~/components/ui/Turnstile'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { getSigninUrl } from '~/lib/auth/iam'
|
||||
import { fetchSignInProviders, type SignInProvider } from '~/lib/auth/providers'
|
||||
import { loginState, loginWithPassword } from '~/lib/auth/iam-login'
|
||||
import { signUp } from '~/lib/auth/signup'
|
||||
|
||||
type Mode = 'signin' | 'signup'
|
||||
|
||||
/** Button presentation per IAM provider TYPE — an unknown type renders nothing
|
||||
* (honest: never a button the flow can't finish). Web3 reads "Connect Wallet"
|
||||
* to match the multi-chain SIWx hand-off. */
|
||||
function socialSpec(type: string): { label: string; icon: React.ReactElement } | null {
|
||||
switch (type) {
|
||||
case 'GitHub':
|
||||
return { label: 'Continue with GitHub', icon: <Github size={18} /> }
|
||||
case 'Google':
|
||||
return { label: 'Continue with Google', icon: <GoogleMark /> }
|
||||
case 'GitLab':
|
||||
return { label: 'Continue with GitLab', icon: <Gitlab size={18} /> }
|
||||
case 'Apple':
|
||||
return { label: 'Continue with Apple', icon: <Apple size={18} /> }
|
||||
case 'Web3Onboard':
|
||||
case 'Web3':
|
||||
return { label: 'Connect Wallet', icon: <Wallet size={18} /> }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** A `?ref=<code>` on the landing URL — passed to signup to credit the referrer's
|
||||
* waitlist position. Read at submit so a link opened straight on /signin still works. */
|
||||
function refFromUrl(): string {
|
||||
if (typeof window === 'undefined') return ''
|
||||
try {
|
||||
return (new URLSearchParams(window.location.search).get('ref') ?? '').trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** Monochrome Google "G" — filled with the current text color so it tracks the
|
||||
* console's black/white chrome. */
|
||||
function GoogleMark({ size = 18 }: { size?: number }) {
|
||||
@@ -75,6 +111,19 @@ function CardShell({ subtitle, children }: { subtitle: string; children: React.R
|
||||
|
||||
export function SignInForm() {
|
||||
const { completeSignIn, signInWith, establishConsoleSession } = useSession()
|
||||
// Live sign-in providers from IAM — the app's real list is the ONE source of
|
||||
// truth (no hardcoded set that could drift). Empty until it lands / if it fails.
|
||||
const [providers, setProviders] = useState<SignInProvider[]>([])
|
||||
useEffect(() => {
|
||||
let on = true
|
||||
void fetchSignInProviders().then((p) => {
|
||||
if (on) setProviders(p)
|
||||
})
|
||||
return () => {
|
||||
on = false
|
||||
}
|
||||
}, [])
|
||||
const analytics = useAnalytics()
|
||||
const router = useRouter()
|
||||
const [mode, setMode] = useState<Mode>('signin')
|
||||
const [email, setEmail] = useState('')
|
||||
@@ -82,18 +131,28 @@ export function SignInForm() {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [mfa, setMfa] = useState(false)
|
||||
// Turnstile token for the signup path (only required when Turnstile is provisioned).
|
||||
const [captcha, setCaptcha] = useState('')
|
||||
|
||||
// Open the signup funnel when the create-account view is shown (a mode toggle here,
|
||||
// not a route), so the funnel starts from one event on every host.
|
||||
useEffect(() => {
|
||||
if (mode === 'signup') analytics.capture(EVENTS.SIGNUP_VIEWED)
|
||||
}, [mode, analytics])
|
||||
|
||||
// The ONE credential login path — shared by sign-in and by post-signup auto-login.
|
||||
async function logInWithCredentials(): Promise<void> {
|
||||
const res = await loginWithPassword(email.trim(), password)
|
||||
if (res.kind === 'code') {
|
||||
await completeSignIn(res.code, loginState())
|
||||
// Upgrade to the console's OWN durable, silently-refreshed session (server-side
|
||||
// password grant, gated by the casibase session we just established). Best-effort:
|
||||
// it never throws, and a failure leaves the user signed in on the casibase session
|
||||
// — so login is never blocked by this enhancement. MFA accounts don't reach here
|
||||
// (they hand off to the hosted flow), so this never bypasses MFA.
|
||||
await establishConsoleSession(email.trim(), password)
|
||||
await completeSignIn(res.code, loginState(), res.verifier)
|
||||
// `res.verifier` is present ONLY on an admin host, where completeSignIn already
|
||||
// established the console's OWN durable session (the BFF PKCE redemption). A tenant
|
||||
// login upgrades its casibase session to the durable console session here (server-
|
||||
// side password grant, gated by that session). Best-effort: it never throws, and a
|
||||
// failure leaves the user signed in on the casibase session — so login is never
|
||||
// blocked. MFA accounts don't reach here (they hand off to the hosted flow), so
|
||||
// this never bypasses MFA.
|
||||
if (!res.verifier) await establishConsoleSession(email.trim(), password)
|
||||
router.replace('/')
|
||||
} else if (res.kind === 'mfa') {
|
||||
setMfa(true)
|
||||
@@ -117,10 +176,17 @@ export function SignInForm() {
|
||||
|
||||
async function submitSignUp() {
|
||||
if (busy || !email || !password) return
|
||||
// When Turnstile is provisioned, a solved challenge is required before we spend a
|
||||
// signup attempt (the server enforces this too — this is just early UX feedback).
|
||||
if (turnstileConfigured() && !captcha) {
|
||||
setError('Please complete the verification challenge.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
analytics.capture(EVENTS.SIGNUP_SUBMITTED)
|
||||
try {
|
||||
const r = await signUp(email.trim(), password)
|
||||
const r = await signUp(email.trim(), password, { turnstileToken: captcha, ref: refFromUrl() })
|
||||
if (r.kind === 'exists') {
|
||||
setError('An account with this email already exists — sign in below.')
|
||||
setMode('signin')
|
||||
@@ -132,8 +198,9 @@ export function SignInForm() {
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
// Account + org created — sign in with the same credentials so the new admin
|
||||
// lands straight in their workspace (fresh accounts have no MFA to satisfy).
|
||||
// Account + org created (self-serve signup has no separate email-verify step) —
|
||||
// the funnel completes here, then auto-login lands the new admin in their workspace.
|
||||
analytics.capture(EVENTS.SIGNUP_COMPLETED)
|
||||
await logInWithCredentials()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not create your account.')
|
||||
@@ -177,13 +244,21 @@ export function SignInForm() {
|
||||
return (
|
||||
<CardShell subtitle={signup ? `Create your ${branding.name} account.` : 'Sign in to manage your cloud.'}>
|
||||
<YStack gap="$2.5">
|
||||
<Button size="$4" icon={<Github size={18} />} onPress={() => signInWith('provider-github')}>
|
||||
Continue with GitHub
|
||||
</Button>
|
||||
<Button size="$4" icon={<GoogleMark />} onPress={() => signInWith('provider-google')}>
|
||||
Continue with Google
|
||||
</Button>
|
||||
|
||||
{/* Social buttons render from the app's LIVE IAM provider list (one
|
||||
source of truth — a hardcoded button IAM can't honor strands the
|
||||
user on the hanzo.id login page). Wallet sign-in hands off to the
|
||||
hanzo.id login where the native multi-chain SIWx flow runs
|
||||
(connecting a wallet needs a user gesture); it returns an
|
||||
authorization code to our /auth/callback like the others. */}
|
||||
{providers.map((p) => {
|
||||
const spec = socialSpec(p.type)
|
||||
if (!spec) return null
|
||||
return (
|
||||
<Button key={p.name} size="$4" icon={spec.icon} onPress={() => signInWith(p.name)}>
|
||||
{spec.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
<XStack items="center" gap="$3" my="$1">
|
||||
<YStack flex={1} height={1} bg="$borderColor" />
|
||||
<Text fontSize="$2" color="$color10">
|
||||
@@ -216,6 +291,10 @@ export function SignInForm() {
|
||||
onSubmitEditing={submit}
|
||||
/>
|
||||
|
||||
{/* Bot wall — only rendered/required in signup mode when Turnstile is
|
||||
provisioned (NEXT_PUBLIC_TURNSTILE_SITE_KEY). Otherwise renders nothing. */}
|
||||
{signup ? <Turnstile onToken={setCaptcha} /> : null}
|
||||
|
||||
{error ? (
|
||||
<Text fontSize="$2" color="$red10" role="alert">
|
||||
{error}
|
||||
@@ -231,6 +310,13 @@ export function SignInForm() {
|
||||
{busy ? (signup ? 'Creating account…' : 'Signing in…') : signup ? 'Create account' : 'Sign in'}
|
||||
</PrimaryButton>
|
||||
|
||||
{signup ? (
|
||||
<Text fontSize="$1" color="$color9" text="center">
|
||||
Sign-up is open. Product access rolls out by waitlist position — you'll get
|
||||
your spot right after you create your account.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{signup ? (
|
||||
<Text fontSize="$2" color="$color10" text="center">
|
||||
Already have an account?{' '}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* WaitlistGate — WAITLISTED PRODUCT ACCESS.
|
||||
*
|
||||
* Signup is open (anyone has an account); reaching the PRODUCT is gated on a
|
||||
* waitlist position. This wraps the authenticated shell: while the access check
|
||||
* loads it shows a spinner, grants the product when the user is at the front (or the
|
||||
* gate is off / open), and otherwise renders the waitlist panel — position + the two
|
||||
* ways to MOVE UP:
|
||||
* 1. Run a hanzod node (contribute compute → jump the line / instant access).
|
||||
* 2. Invite friends (each referred signup raises your position).
|
||||
*
|
||||
* The access verdict comes from the server (`/auth/waitlist` → the waitlist plugin),
|
||||
* the ONE source shared by console, chat and app. FAIL-OPEN by construction (the
|
||||
* hook returns hasAccess on any error), so a blip never traps a signed-in user.
|
||||
*/
|
||||
import { useCallback, useState, type ReactNode } from 'react'
|
||||
import { Anchor, Button, Card, Input, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { ArrowUp, Check, Copy, RefreshCw, Share2, Terminal, Users } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { branding, isAdminHost } from '~/config'
|
||||
import { HanzoMark, Loader } from '~/components/ui/Loader'
|
||||
import { PrimaryButton } from '~/components/ui/PrimaryButton'
|
||||
import { isSuperAdminAccount } from '~/lib/auth/admin'
|
||||
import { useSession } from '~/lib/auth/session'
|
||||
import { useWaitlist, type WaitlistStatus } from '~/lib/auth/waitlist'
|
||||
|
||||
/** The node one-liner + docs — env-overridable, honest defaults to hanzo.network. */
|
||||
const NODE_CMD = process.env.NEXT_PUBLIC_HANZOD_INSTALL ?? 'curl -fsSL https://hanzo.network/up | sh'
|
||||
const NODE_URL = process.env.NEXT_PUBLIC_HANZOD_URL ?? 'https://hanzo.network'
|
||||
|
||||
function useCopy(): { copied: boolean; copy: (text: string) => void } {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copy = useCallback((text: string) => {
|
||||
try {
|
||||
void navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
/* clipboard unavailable — the value is still selectable in the field */
|
||||
}
|
||||
}, [])
|
||||
return { copied, copy }
|
||||
}
|
||||
|
||||
function nf(n: number): string {
|
||||
return new Intl.NumberFormat().format(Math.max(0, Math.floor(n)))
|
||||
}
|
||||
|
||||
/** The referral link a user shares — their refCode on the brand origin. */
|
||||
function shareLink(refCode: string): string {
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : ''
|
||||
return refCode ? `${origin}/?ref=${refCode}` : origin
|
||||
}
|
||||
|
||||
function CopyField({ value, label }: { value: string; label: string }) {
|
||||
const { copied, copy } = useCopy()
|
||||
return (
|
||||
<XStack gap="$2" items="center" flexWrap="wrap">
|
||||
{/* Read-only but selectable (readOnly DOM attr, RNW passthrough — same pattern
|
||||
the sign-in form uses for `type`); a no-op onChangeText keeps it controlled. */}
|
||||
<Input flex={1} minW={220} size="$3" value={value} onChangeText={() => {}} {...{ readOnly: true }} />
|
||||
<Button
|
||||
size="$3"
|
||||
theme={copied ? 'green' : undefined}
|
||||
icon={copied ? <Check size={15} /> : <Copy size={15} />}
|
||||
onPress={() => copy(value)}
|
||||
aria-label={label}
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
function MoveUpCard({
|
||||
icon,
|
||||
title,
|
||||
desc,
|
||||
children,
|
||||
}: {
|
||||
icon: ReactNode
|
||||
title: string
|
||||
desc: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor" bg="$color2">
|
||||
<XStack gap="$2.5" items="center">
|
||||
{icon}
|
||||
<YStack flex={1}>
|
||||
<Text fontSize="$5" fontWeight="700" color="$color12">
|
||||
{title}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color10">
|
||||
{desc}
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
{children}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function WaitlistPanel({ status, onRefresh }: { status: WaitlistStatus | null; onRefresh: () => void }) {
|
||||
const { signOut } = useSession()
|
||||
const rank = status?.rank ?? 0
|
||||
const total = status?.total ?? 0
|
||||
const aheadOf = status?.aheadOf ?? 0
|
||||
const referralCount = status?.referralCount ?? 0
|
||||
const refCode = status?.refCode ?? ''
|
||||
const link = shareLink(refCode)
|
||||
const tweet = `https://twitter.com/intent/tweet?text=${encodeURIComponent(
|
||||
`I'm on the ${branding.name} waitlist — join me and jump the line:`,
|
||||
)}&url=${encodeURIComponent(link)}`
|
||||
const linkedin = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(link)}`
|
||||
|
||||
return (
|
||||
<YStack flex={1} minH="100vh" items="center" justify="center" p="$4">
|
||||
<YStack width={560} maxW="100%" gap="$4">
|
||||
<YStack items="center" gap="$3">
|
||||
<HanzoMark size={40} />
|
||||
<YStack items="center" gap="$1">
|
||||
<Text fontSize="$8" fontWeight="800" color="$color12">
|
||||
You're on the list
|
||||
</Text>
|
||||
<Text fontSize="$3" color="$color11" text="center">
|
||||
{branding.name} access is rolling out by waitlist position. Move up by
|
||||
contributing compute or inviting friends.
|
||||
</Text>
|
||||
</YStack>
|
||||
</YStack>
|
||||
|
||||
{rank > 0 ? (
|
||||
<Card p="$4" borderWidth={1} borderColor="$borderColor" bg="$color1">
|
||||
<XStack items="center" justify="space-between" flexWrap="wrap" gap="$3">
|
||||
<YStack>
|
||||
<Text fontSize="$2" color="$color10">
|
||||
Your position
|
||||
</Text>
|
||||
<Text fontSize="$10" fontWeight="900" color="$color12">
|
||||
#{nf(rank)}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color10">
|
||||
of {nf(total)} · {nf(aheadOf)} behind you
|
||||
</Text>
|
||||
</YStack>
|
||||
<Button size="$3" icon={<RefreshCw size={15} />} onPress={onRefresh}>
|
||||
Refresh
|
||||
</Button>
|
||||
</XStack>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<MoveUpCard
|
||||
icon={<Terminal size={22} />}
|
||||
title="Jump the line — run a node"
|
||||
desc="Boot a cloud fragment. Your hanzod node contributes compute to hanzo.network and earns you instant access."
|
||||
>
|
||||
<CopyField value={NODE_CMD} label="Copy the node install command" />
|
||||
<Anchor href={NODE_URL} target="_blank" rel="noreferrer" fontSize="$2" color="$color11">
|
||||
Learn about running a node →
|
||||
</Anchor>
|
||||
</MoveUpCard>
|
||||
|
||||
<MoveUpCard
|
||||
icon={<Users size={22} />}
|
||||
title="Invite friends"
|
||||
desc={`Each friend who joins with your link moves you up. ${nf(referralCount)} referral${referralCount === 1 ? '' : 's'} so far.`}
|
||||
>
|
||||
<CopyField value={link} label="Copy your referral link" />
|
||||
<XStack gap="$2" flexWrap="wrap">
|
||||
<Anchor href={tweet} target="_blank" rel="noreferrer">
|
||||
<Button size="$3" icon={<Share2 size={15} />}>
|
||||
Share on X
|
||||
</Button>
|
||||
</Anchor>
|
||||
<Anchor href={linkedin} target="_blank" rel="noreferrer">
|
||||
<Button size="$3" icon={<ArrowUp size={15} />}>
|
||||
Share on LinkedIn
|
||||
</Button>
|
||||
</Anchor>
|
||||
</XStack>
|
||||
</MoveUpCard>
|
||||
|
||||
<XStack justify="center" gap="$3" mt="$2">
|
||||
<PrimaryButton size="$3" icon={<RefreshCw size={15} />} onPress={onRefresh}>
|
||||
Check my access
|
||||
</PrimaryButton>
|
||||
<Button size="$3" chromeless onPress={() => void signOut()}>
|
||||
Sign out
|
||||
</Button>
|
||||
</XStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
export function WaitlistGate({ children }: { children: ReactNode }) {
|
||||
const { account } = useSession()
|
||||
// The admin operator surface (admin.<brand>) and any super (platform) admin are
|
||||
// NEVER behind the consumer product waitlist — the operator cockpit is a distinct
|
||||
// concern from consumer product rollout. Mirrors the server authority
|
||||
// (`waitlistAccess` short-circuits an admin host); gating here too avoids even a
|
||||
// panel flash and covers a super admin on any host. Real access to admin.<brand>
|
||||
// stays enforced by admin-guard + the cloud global-admin gate.
|
||||
const operator =
|
||||
(typeof window !== 'undefined' && isAdminHost(window.location.hostname)) ||
|
||||
isSuperAdminAccount(account)
|
||||
const { view, loading, reload } = useWaitlist(!operator)
|
||||
if (loading || !view) return <Loader />
|
||||
if (view.hasAccess) return <>{children}</>
|
||||
return <WaitlistPanel status={view.status} onRefresh={reload} />
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
* library, a prompt-body fetch, and the create effect) — the builder owns the form,
|
||||
* the LIVE dropdowns, validation, and the honest states, but knows NOTHING about
|
||||
* any host's API client. That is what lets console2, chat, app, bot, and team all
|
||||
* import this exact component over the SAME backend (`POST /cloud/v1/agents`, org
|
||||
* import this exact component over the SAME backend (`POST /v1/agents`, org
|
||||
* resolved server-side from the caller's bearer) instead of each rebuilding a form.
|
||||
*
|
||||
* Dynamic by construction:
|
||||
|
||||
@@ -111,7 +111,7 @@ export function pruneConfig(c: AgentConfig): Partial<AgentConfig> | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* The clean create body for `POST /cloud/v1/agents`: name is trimmed (required);
|
||||
* The clean create body for `POST /v1/agents`: name is trimmed (required);
|
||||
* every other field is trimmed and OMITTED when empty, and tools are normalized —
|
||||
* so the backend never stores a blank model/description/prompt or a `[]` tools
|
||||
* key it didn't need. PURE.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* loaders (`AgentBuilderLoaders`) — the model catalog, the saved-prompt library,
|
||||
* a prompt body, and the create effect — so a surface wires it to whatever client
|
||||
* it already has, as long as that client speaks the ONE agent backend
|
||||
* (`POST /cloud/v1/agents`, org resolved server-side from the caller's bearer).
|
||||
* (`POST /v1/agents`, org resolved server-side from the caller's bearer).
|
||||
* That is what makes the builder truly shareable (extractable to
|
||||
* `@hanzo/agent-builder`) rather than a console2 one-off.
|
||||
*/
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user