Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions .github/workflows/deploy-demo.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
name: Deploy Demo to Vercel

# Re-deploys apps/ui to https://nqr-microvm-demo.vercel.app with
# NEXT_PUBLIC_DEMO_MODE=true so the public mocked demo stays in sync with
# every tagged release.
#
# Triggers:
# - any pushed semver tag (vX.Y.Z) — release-time refresh
# - manual run via the Actions UI — ad-hoc refresh
#
# Required repository secret:
# VERCEL_TOKEN — a token created in https://vercel.com/account/tokens scoped
# to the kleopasevans-projects team.
#
# The Vercel org + project IDs aren't secret (they're already in
# apps/ui/.vercel/project.json on the linked dev machine and shown in the
# Vercel dashboard URL), so we keep them inline for clarity.

on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
target:
description: 'Deployment target'
required: true
default: 'production'
type: choice
options:
- production
- preview

permissions:
contents: read

env:
VERCEL_ORG_ID: team_alFtlU7eg7MXyxZQq2Wj0B7z
VERCEL_PROJECT_ID: prj_k7rhdRvqYOhZBQrnKuyXHhnmZamO
TARGET: ${{ github.event.inputs.target || 'production' }}

jobs:
deploy:
name: Deploy apps/ui (demo)
runs-on: ubuntu-24.04
timeout-minutes: 20
defaults:
run:
working-directory: apps/ui

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: pnpm
cache-dependency-path: apps/ui/pnpm-lock.yaml

- name: Install Vercel CLI
run: npm install --global vercel@latest

- name: Pull Vercel environment
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
run: |
if [ "$TARGET" = "production" ]; then
vercel pull --yes --environment=production --token=$VERCEL_TOKEN
else
vercel pull --yes --environment=preview --token=$VERCEL_TOKEN
fi

- name: Build with Vercel
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
run: |
if [ "$TARGET" = "production" ]; then
vercel build --prod --token=$VERCEL_TOKEN
else
vercel build --token=$VERCEL_TOKEN
fi

- name: Deploy prebuilt artifact
id: deploy
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
run: |
if [ "$TARGET" = "production" ]; then
URL=$(vercel deploy --prebuilt --prod --token=$VERCEL_TOKEN)
else
URL=$(vercel deploy --prebuilt --token=$VERCEL_TOKEN)
fi
echo "url=$URL" >> "$GITHUB_OUTPUT"
echo "Deployed to: $URL"

- name: Summary
run: |
echo "## ✅ Demo deployed" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "- Target: \`$TARGET\`" >> "$GITHUB_STEP_SUMMARY"
echo "- URL: ${{ steps.deploy.outputs.url }}" >> "$GITHUB_STEP_SUMMARY"
echo "- Stable alias: https://nqr-microvm-demo.vercel.app" >> "$GITHUB_STEP_SUMMARY"
10 changes: 10 additions & 0 deletions apps/ui/.vercelignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Things Vercel doesn't need from the apps/ui directory
node_modules
.next
.turbo
out
dist
*.log

# Heavy assets we don't ship to demo
content/api/overrides
12 changes: 11 additions & 1 deletion apps/ui/app/(dashboard)/templates/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"use client"

import { useEffect } from "react"
export const dynamic = "force-dynamic"

import { Suspense, useEffect } from "react"
import { useSearchParams, useRouter } from "next/navigation"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
Expand Down Expand Up @@ -94,6 +96,14 @@ const TemplateFlowDiagram = () => (
)

export default function TemplatesPage() {
return (
<Suspense fallback={null}>
<TemplatesPageInner />
</Suspense>
)
}

function TemplatesPageInner() {
const searchParams = useSearchParams()
const router = useRouter()
const { data: templates = [], isLoading, error } = useTemplates()
Expand Down
25 changes: 24 additions & 1 deletion apps/ui/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ import { authApi } from "@/lib/api/auth"
import { ssoApi, type SsoProvider } from "@/lib/api/sso"
import { useAuthStore } from "@/lib/auth/store"
import { parseFacadeError } from "@/lib/api"
import { Eye, EyeOff, LogIn } from "lucide-react"
import { Eye, EyeOff, LogIn, Sparkles } from "lucide-react"
import { toast } from "sonner"
import { apiClient } from "@/lib/api/http"
import { DEMO_MODE } from "@/lib/demo/flag"

export default function LandingPage() {
const router = useRouter()
Expand Down Expand Up @@ -150,6 +151,28 @@ export default function LandingPage() {
<CardTitle className="text-md font-medium mx-auto text-muted-foreground">Welcome back! Please enter your credentials to continue.</CardTitle>
</CardHeader>
<CardContent className="px-0">
{DEMO_MODE && (
<div className="mb-4 flex items-start gap-2.5 rounded-lg border border-orange-500/30 bg-orange-500/10 px-3 py-2.5 text-sm">
<Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-orange-600" />
<div className="leading-snug">
<p className="font-medium text-orange-700 dark:text-orange-300">Demo mode</p>
<p className="text-muted-foreground">
Sign in with <span className="font-mono font-semibold text-foreground">admin</span> /{" "}
<span className="font-mono font-semibold text-foreground">admin</span>. All data is simulated.
</p>
<button
type="button"
onClick={() => {
setUsername("admin")
setPassword("admin")
}}
className="mt-1 text-xs font-medium text-orange-600 hover:underline"
>
Fill credentials →
</button>
</div>
</div>
)}
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
Expand Down
30 changes: 23 additions & 7 deletions apps/ui/app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ import { AuthProvider, useAuthStore, getAuthToken } from "@/lib/auth/store"
import { authApi, setAuthTokenGetter } from "@/lib/api/auth"
import { ThemeProvider } from "@/components/theme-provider"
import { ThemeSyncProvider } from "@/components/theme-sync-provider"
import { DemoBootstrap } from "@/components/demo/demo-bootstrap"
import { DEMO_MODE } from "@/lib/demo/flag"
import { installDemoMode } from "@/lib/demo/install"

// Install demo-mode interceptors at module-eval time so they're active before
// any React effect fires (notably AuthInitializer's getCurrentUser call).
if (DEMO_MODE && typeof window !== "undefined") {
installDemoMode()
}

function AuthInitializer({ children }: { children: React.ReactNode }) {
const { token, user, setUser, clearAuth } = useAuthStore()
Expand All @@ -22,6 +31,7 @@ function AuthInitializer({ children }: { children: React.ReactNode }) {
// Validate token and fetch user on mount if token exists
// Only validate if we have token but no user data
useEffect(() => {
if (DEMO_MODE) return
if (token && !user) {
authApi.getCurrentUser()
.then((fetchedUser) => {
Expand Down Expand Up @@ -83,13 +93,19 @@ export function Providers({ children }: { children: React.ReactNode }) {
<AuthProvider>
<AuthInitializer>
<ThemeSyncProvider>
<LicenseGuard>
<EulaGuard>
<AuthGuard>
{children}
</AuthGuard>
</EulaGuard>
</LicenseGuard>
{DEMO_MODE ? (
<AuthGuard>
<DemoBootstrap>{children}</DemoBootstrap>
</AuthGuard>
) : (
<LicenseGuard>
<EulaGuard>
<AuthGuard>
<DemoBootstrap>{children}</DemoBootstrap>
</AuthGuard>
</EulaGuard>
</LicenseGuard>
)}
</ThemeSyncProvider>
</AuthInitializer>
<ReactQueryDevtools initialIsOpen={false} />
Expand Down
12 changes: 11 additions & 1 deletion apps/ui/app/sso/callback/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
"use client"

import { useEffect, useRef, useState } from "react"
export const dynamic = "force-dynamic"

import { Suspense, useEffect, useRef, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { useAuthStore } from "@/lib/auth/store"

export default function SsoCallbackPage() {
return (
<Suspense fallback={null}>
<SsoCallbackPageInner />
</Suspense>
)
}

function SsoCallbackPageInner() {
const router = useRouter()
const searchParams = useSearchParams()
const { setAuth } = useAuthStore()
Expand Down
65 changes: 65 additions & 0 deletions apps/ui/components/demo/demo-bootstrap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"use client"

// DemoBootstrap mounts at the top of the providers tree when NEXT_PUBLIC_DEMO_MODE
// is enabled. Responsibilities:
// 1. Install the request/WebSocket interceptors before any data hooks fire.
// 2. Show a small floating banner so visitors know the data is synthetic.
//
// Auth: real login flow stays — the user must type admin/admin to enter. The
// mock /auth/login endpoint validates those credentials and rejects anything
// else, so visitors get the same experience as the real product.

import { useEffect } from "react"
import { DEMO_MODE } from "@/lib/demo/flag"
import { installDemoMode } from "@/lib/demo/install"
import { Sparkles, RotateCcw } from "lucide-react"
import { resetState } from "@/lib/demo/state"

export function DemoBootstrap({ children }: { children: React.ReactNode }) {
if (!DEMO_MODE) return <>{children}</>
return <DemoBootstrapImpl>{children}</DemoBootstrapImpl>
}

function DemoBootstrapImpl({ children }: { children: React.ReactNode }) {
useEffect(() => {
installDemoMode()
}, [])

return (
<>
{children}
<DemoBanner />
</>
)
}

function DemoBanner() {
const onReset = () => {
resetState()
if (typeof window !== "undefined") {
// Clear the auth state too so visitors are kicked back to the login
// page after a reset — matches the "fresh demo" expectation.
try {
localStorage.removeItem("auth-storage")
} catch {
/* ignore */
}
window.location.href = "/"
}
}
return (
<div className="fixed bottom-4 right-4 z-[9999] flex items-center gap-2 rounded-full border border-orange-500/30 bg-orange-500/95 px-4 py-2 text-xs font-medium text-white shadow-lg backdrop-blur-sm">
<Sparkles className="h-3.5 w-3.5" />
<span>Demo mode — data is simulated</span>
<button
type="button"
onClick={onReset}
className="ml-2 inline-flex items-center gap-1 rounded-full bg-white/15 px-2 py-0.5 text-[11px] font-medium hover:bg-white/25"
title="Reset demo state and sign out"
>
<RotateCcw className="h-3 w-3" />
Reset
</button>
</div>
)
}
11 changes: 11 additions & 0 deletions apps/ui/lib/demo/flag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Demo-mode flag — true when the deployment is the public, mocked-data demo.
// Read at build/runtime via NEXT_PUBLIC_DEMO_MODE.
//
// Why a dedicated module: we want a single source of truth that's safe to import
// from anywhere (server components, client components, lib code) without pulling
// React or browser-only state.

export const DEMO_MODE: boolean = (() => {
const v = process.env.NEXT_PUBLIC_DEMO_MODE
return v === "1" || v === "true" || v === "yes"
})()
Loading
Loading