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
16 changes: 16 additions & 0 deletions .mise-tasks/zero/hosts.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail

# MISE description='Ensure setup.localhost is in /etc/hosts for enterprise onboarding dev.'
# MISE hide=true

ENTRY="127.0.0.1 setup.localhost"

if grep -q 'setup\.localhost' /etc/hosts 2>/dev/null; then
echo "✅ setup.localhost already in /etc/hosts"
exit 0
fi

echo "Adding setup.localhost to /etc/hosts (requires sudo)"
sudo sh -c "echo '$ENTRY' >> /etc/hosts"
echo "✅ Added setup.localhost to /etc/hosts"
1 change: 1 addition & 0 deletions .mise-tasks/zero/tls.sh
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ cert_names() {
"$server_host" \
"$assistant_host" \
"localhost" \
"setup.localhost" \
"127.0.0.1" \
"::1" \
"gram.local" \
Expand Down
5 changes: 5 additions & 0 deletions client/dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import { usePageTitle } from "./hooks/use-page-title";
import CliCallback from "./pages/cli/CliCallback";
import SlackRegister from "./pages/slackapp/SlackRegister";
import { AppRoute, useRoutes, useOrgRoutes } from "./routes";
import SetupPage from "./pages/setup/Setup";
import { isSetupDomain } from "./lib/utils";

export default function App() {
const [theme, setTheme] = useState<"light" | "dark">("light");
Expand Down Expand Up @@ -246,6 +248,8 @@ const RouteProvider = () => {
<Route index element={<SlackRegister />} />
</Route>
<Route path="/" element={<LoginCheck />}>
{/* On the setup subdomain, render the wizard at "/" so the org slug stays hidden */}
{isSetupDomain() && <Route index element={<SetupPage />} />}
<Route path=":orgSlug/projects/:projectSlug">
{routesWithSubroutes(outsideStructureRoutes)}
</Route>
Expand All @@ -257,6 +261,7 @@ const RouteProvider = () => {
/>
{routesWithSubroutes(authenticatedRoutes)}
</Route>
<Route path=":orgSlug/setup" element={<SetupPage />} />
<Route path=":orgSlug" element={<OrgLayout />}>
{orgHomeRoute?.component && (
<Route index element={<orgHomeRoute.component />} />
Expand Down
6 changes: 6 additions & 0 deletions client/dashboard/src/components/app-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { useIsAdmin, useOrganization, useSession } from "@/contexts/Auth.tsx";
import { useSdkClient } from "@/contexts/Sdk.tsx";
import { useRBAC } from "@/hooks/useRBAC";
import { useObservabilityMcpConfig } from "@/hooks/useObservabilityMcpConfig";
import { isSetupDomain } from "@/lib/utils";
import { Icon, Modal, ModalProvider } from "@speakeasy-api/moonshine";
import { ShieldAlert } from "lucide-react";
import { useCallback, useMemo } from "react";
import { Navigate, Outlet, useLocation } from "react-router";
import Login from "@/pages/login/Login";
import { AppSidebar } from "./app-sidebar.tsx";
import { BrandGradientLine } from "./brand-gradient-line.tsx";
import { InsightsProvider } from "./insights-sidebar.tsx";
Expand All @@ -19,6 +21,10 @@ export const LoginCheck = () => {
const location = useLocation();

if (session.session === "") {
// On setup domain, render login inline — no redirect, no URL change
if (isSetupDomain()) {
return <Login />;
}
const redirectTo = encodeURIComponent(location.pathname + location.search);
return <Navigate to={`/login?redirect=${redirectTo}`} />;
}
Expand Down
21 changes: 20 additions & 1 deletion client/dashboard/src/contexts/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
useSearchParams,
} from "react-router";
import { orgRoutePaths } from "@/routes";
import { isSetupDomain } from "@/lib/utils";
import { useSlugs } from "./Sdk";
import {
useCaptureUserAuthorizationEvent,
Expand Down Expand Up @@ -85,7 +86,9 @@ const AuthHandler = ({ children }: { children: React.ReactNode }) => {
// skeleton flash for logged-out users before the redirect to /login fires.
if (
location.pathname === "/" ||
UNAUTHENTICATED_PATHS.some((p) => location.pathname.startsWith(p))
UNAUTHENTICATED_PATHS.some((p) => location.pathname.startsWith(p)) ||
location.pathname.endsWith("/setup") ||
isSetupDomain()
) {
return null;
}
Expand Down Expand Up @@ -148,6 +151,22 @@ const AuthHandler = ({ children }: { children: React.ReactNode }) => {

// Handle initial navigation
const redirectParam = searchParams.get("redirect");

// On the setup subdomain, always funnel authenticated users to the setup wizard.
// The setup domain renders the wizard at "/" so the org slug stays out of the URL.
// Early-return after the redirect so we never hit the slug-based navigation below
// (which would redirect to /:orgSlug and cause a loop).
if (isSetupDomain() && session.organization) {
if (location.pathname !== "/") {
return <Navigate to="/" replace />;
}
return (
<SessionContext.Provider value={session}>
{children}
</SessionContext.Provider>
);
}

if (redirectParam) {
return <Navigate to={redirectParam} replace />;
} else if (isSlugExempt) {
Expand Down
21 changes: 20 additions & 1 deletion client/dashboard/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ export function cn(...inputs: ClassValue[]) {
// Use everywhere except the playground — MCP configs, callback URL
// displays, anything operator-facing.
export function getServerURL(): string {
// On the setup subdomain, route API calls through the current origin
// (Vite proxy in dev, same-origin in prod) so that session cookies —
// which are scoped to the setup host — are included by the browser.
if (isSetupDomain()) {
return window.location.origin;
}
return __GRAM_SERVER_URL__ ?? window.location.origin;
}

Expand All @@ -23,7 +29,10 @@ export function getPlaygroundMcpBaseURL(): string {
}

export function buildLoginRedirectURL(redirectTo: string | null): string {
let href = `${getServerURL()}/rpc/auth.login`;
// On the setup subdomain, route auth through the current origin so the
// session cookie is scoped to setup.* (Vite proxies /rpc to the server).
const base = isSetupDomain() ? window.location.origin : getServerURL();
let href = `${base}/rpc/auth.login`;
if (redirectTo) href += `?redirect=${encodeURIComponent(redirectTo)}`;
return href;
}
Expand All @@ -42,6 +51,16 @@ export function assert(condition: unknown, message: string): asserts condition {
}
}

/**
* Returns true when the dashboard is served from the setup subdomain
* (setup.getgram.ai in prod, setup.dev.getgram.ai in dev,
* setup.localhost in local dev, setup-pr-*.dev.getgram.ai in preview deploys).
*/
export function isSetupDomain(): boolean {
const hostname = window.location.hostname;
return hostname.startsWith("setup.") || hostname.startsWith("setup-");
}

export function getCustomDomainCNAME(): string {
try {
const url = new URL(getServerURL());
Expand Down
18 changes: 14 additions & 4 deletions client/dashboard/src/pages/login/Login.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useSession } from "@/contexts/Auth";
import { useRoutes } from "@/routes";
import { buildLoginRedirectURL } from "@/lib/utils";
import { buildLoginRedirectURL, isSetupDomain } from "@/lib/utils";
import { JourneyDemo } from "./components/journey-demo";
import { LoginSection } from "./components/login-section";
import { useSearchParams, useNavigate } from "react-router";
Expand All @@ -14,9 +14,14 @@ export default function Login() {

const explicitRedirect = searchParams.get("redirect");
const disposition = searchParams.get("disposition");
const redirectTo =
explicitRedirect ??
(disposition ? `/?disposition=${encodeURIComponent(disposition)}` : null);
// On setup domain, redirect back to the setup origin after auth so the
// server doesn't send us to the default GRAM_SITE_URL (app domain).
const redirectTo = isSetupDomain()
? window.location.origin
: (explicitRedirect ??
(disposition
? `/?disposition=${encodeURIComponent(disposition)}`
: null));
useEffect(() => {
if (session.session !== "") {
// Disposition signups must reach the server-side Callback so
Expand All @@ -26,6 +31,11 @@ export default function Login() {
window.location.href = buildLoginRedirectURL(redirectTo);
return;
}
// On setup domain, always navigate to the setup wizard after auth
if (isSetupDomain()) {
navigate("/", { replace: true });
return;
}
if (redirectTo) {
navigate(redirectTo, { replace: true });
} else {
Expand Down
5 changes: 5 additions & 0 deletions client/dashboard/src/pages/setup/Setup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { EnterpriseSetupWizard } from "./components/onboarding-wizard";

export default function SetupPage() {
return <EnterpriseSetupWizard />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export function OnboardingFooter() {
return (
<footer className="border-border bg-card flex items-center justify-between border-t px-8 py-4">
<div className="flex items-center gap-6">
<a
href="#"
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
>
Help Center
</a>
<a
href="#"
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
>
Status
</a>
<a
href="#"
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
>
Docs
</a>
<a
href="#"
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
>
Contact Support
</a>
</div>
<span className="text-muted-foreground text-sm">Speakeasy 2026</span>
</footer>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { RotateCcw, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Logo } from "@speakeasy-api/moonshine";

interface OnboardingHeaderProps {
onRestart?: () => void;
onLeave?: () => void;
}

export function OnboardingHeader({
onRestart,
onLeave,
}: OnboardingHeaderProps) {
return (
<header className="border-border bg-background w-full border-b">
<div className="mx-auto flex w-full max-w-5xl items-center justify-between py-4">
<div className="flex items-center gap-3">
<Logo variant="wordmark" className="w-32" />
<div className="bg-border h-4 w-px" />
<span className="text-foreground text-sm font-medium">
Set up workspace
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={onRestart}
className="text-muted-foreground hover:text-foreground gap-1.5"
>
<RotateCcw className="h-4 w-4" />
Restart
</Button>
<Button
variant="ghost"
size="sm"
onClick={onLeave}
className="text-muted-foreground hover:text-foreground gap-1.5"
>
<X className="h-4 w-4" />
Leave
</Button>
</div>
</div>
</header>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";

export interface Step {
id: string;
title: string;
description: string;
}

interface OnboardingStepperProps {
steps: Step[];
currentStep: number;
onStepClick?: (index: number) => void;
}

export function OnboardingStepper({
steps,
currentStep,
onStepClick,
}: OnboardingStepperProps) {
return (
<nav className="flex flex-col" aria-label="Progress">
{steps.map((step, index) => {
const isCompleted = index < currentStep;
const isCurrent = index === currentStep;
const isUpcoming = index > currentStep;
const isLast = index === steps.length - 1;

return (
<div key={step.id} className="relative flex gap-4">
{/* Vertical line connector - runs through all steps except last */}
{!isLast && (
<div
className="absolute top-[28px] left-[13px] h-[calc(100%-28px)] w-px bg-[#e0e0e0]"
aria-hidden="true"
/>
)}

{/* Step indicator */}
<div className="relative z-10 flex-shrink-0">
{isCurrent ? (
/* Active step: dark filled rounded rectangle */
<div className="flex h-[28px] w-[28px] items-center justify-center rounded-[8px] bg-[#1a1a1a] text-sm font-semibold text-white">
{index + 1}
</div>
) : isCompleted ? (
/* Completed step: dark circle with checkmark */
<button
onClick={() => onStepClick?.(index)}
className="flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded-full bg-[#1a1a1a] text-white transition-colors hover:bg-[#333]"
>
<Check className="h-3.5 w-3.5" strokeWidth={2.5} />
</button>
) : (
/* Upcoming step: light outlined circle with white fill to cover track */
<div className="flex h-[28px] w-[28px] items-center justify-center rounded-full border border-[#d9d9d9] bg-[#f7f7f5] text-sm font-normal text-[#b5b5b5]">
{index + 1}
</div>
)}
</div>

{/* Step content */}
<div className="min-w-0 pt-1 pb-8">
<h3
className={cn(
"text-sm leading-tight font-semibold",
isCurrent && "text-[#0f0f0f]",
isCompleted && "text-[#0f0f0f]",
isUpcoming && "text-[#a3a3a3]",
)}
>
{step.title}
</h3>
<p
className={cn(
"mt-0.5 text-sm leading-snug",
isCurrent && "text-[#737373]",
isCompleted && "text-[#737373]",
isUpcoming && "text-[#c4c4c4]",
)}
>
{step.description}
</p>
</div>
</div>
);
})}
</nav>
);
}
Loading
Loading