From c4307034ec2e1afe2c2538efd935b159ba1e87cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Dvo=C5=99=C3=A1k?= <150935816+Dvorinka@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:17:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(setup):=20first-run=20wizard=20=E2=80=94?= =?UTF-8?q?=20admin,=20GitHub=20App,=20tunnel,=20done=20(#71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(setup): first-run wizard — admin, GitHub App, tunnel, done - GET /api/v1/setup/status (public): has_users, needs_setup, has_github_app, has_tunnel. app_settings.setup_completed flag; installs with existing projects auto-complete so upgrades never see the wizard. - POST /api/v1/setup/complete records the flag. - /setup page: progress rail + four steps — create admin (links into the bootstrap sign-up with redirect back), GitHub App manifest flow, optional Cloudflare tunnel token, finish. Every optional step is skippable; nothing blocks. - PlatformShell redirects signed-in users to /setup while needs_setup. - The GitHub App callback honors a sessionStorage return marker set by the wizard so provisioning resumes at /setup instead of /settings. - OpenAPI documents both endpoints. Verified live: status reports auto-completed on this install, complete persists the flag, response shape matches the client. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(setup): wizard step-state coverage Five cases across the state machine: fresh-install account prompt, sign-in resume, git-provider step, tunnel step, and finish. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ROADMAP2.md | 8 +- app/backend/internal/api/routes.go | 2 + app/backend/internal/api/setup.go | 78 +++++ app/frontend/src/app/App.tsx | 2 + app/frontend/src/app/layout/PlatformShell.tsx | 14 +- app/frontend/src/features/secondary/pages.tsx | 6 +- .../features/setup/SetupWizardPage.test.tsx | 92 ++++++ .../src/features/setup/SetupWizardPage.tsx | 279 ++++++++++++++++++ app/frontend/src/generated/api-types.ts | 84 ++++++ app/frontend/src/lib/api-client.ts | 17 ++ docs/api/openapi.yaml | 44 +++ 11 files changed, 622 insertions(+), 4 deletions(-) create mode 100644 app/backend/internal/api/setup.go create mode 100644 app/frontend/src/features/setup/SetupWizardPage.test.tsx create mode 100644 app/frontend/src/features/setup/SetupWizardPage.tsx diff --git a/ROADMAP2.md b/ROADMAP2.md index 645d02a..c81e6b8 100644 --- a/ROADMAP2.md +++ b/ROADMAP2.md @@ -537,8 +537,12 @@ and wired refs, one click or one CLI call. - [ ] **sqlc port** — ~140 raw `database/sql` sites → `sqlc/queries/`. - [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/ webhook table tests, Playwright e2e golden path. -- [ ] **First-run wizard** — admin → GitHub App (manifest flow from - Phase G makes this self-serve) → Tunnel → done. +- [x] **First-run wizard** — public `GET /setup/status` + authed + `POST /setup/complete`; `/setup` page steps admin account → GitHub App + manifest flow (callback returns to the wizard via a session marker) → + optional Cloudflare tunnel token → done. Shell redirects signed-in + users to `/setup` while `needs_setup`; installs with existing projects + auto-complete so nobody gets nagged retroactively. - [ ] **Self-upgrade loop verification**, docs freshness, v1.0.0 release. ## 11. Deliberately not imported from dflow diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index dab0f46..35b5ecf 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -286,6 +286,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg public.POST("/auth/register", handleRegister) public.GET("/maintenance", handleMaintenancePage) public.GET("/branding", handleGetBranding) + public.GET("/setup/status", handleSetupStatus) // Invite accept flow — validates the link, then registers // even while public signup is closed. @@ -355,6 +356,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg { authed.GET("/user/profile", handleGetProfile) authed.PUT("/user/profile", handleUpdateProfile) + authed.POST("/setup/complete", handleSetupComplete) // Personal access tokens for CLI/MCP/agent auth. authed.GET("/user/tokens", handleListUserTokens) diff --git a/app/backend/internal/api/setup.go b/app/backend/internal/api/setup.go new file mode 100644 index 0000000..e259397 --- /dev/null +++ b/app/backend/internal/api/setup.go @@ -0,0 +1,78 @@ +package api + +// First-run setup wizard state. GET /setup/status is public so the web +// shell can redirect fresh installs before a session exists; the flag +// lives in app_settings so it survives restarts. + +import ( + "net/http" + "strings" + + "containr/internal/database" + + "github.com/gin-gonic/gin" +) + +const settingSetupCompleted = "setup_completed" + +// setupComplete resolves the flag. Installs that predate the wizard +// (users + projects already exist) auto-complete — nobody who has been +// running Containr for months should get a welcome screen. +func setupComplete(db *database.DB, hasUsers bool) bool { + if strings.EqualFold(settingValue(db, settingSetupCompleted, "", ""), "true") { + return true + } + if !hasUsers { + return false + } + var projects int + if err := db.QueryRow(`SELECT COUNT(*) FROM projects`).Scan(&projects); err == nil && projects > 0 { + return true + } + return false +} + +// GET /api/v1/setup/status — public: fresh installs hit it before any +// session exists. +func handleSetupStatus(c *gin.Context) { + db, ok := c.MustGet("db").(*database.DB) + if !ok { + respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Database connection not available") + return + } + count, err := countLocalUsers(db) + if err != nil { + respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to inspect setup state") + return + } + hasUsers := count > 0 + completed := setupComplete(db, hasUsers) + tunnelToken, _ := resolvedTunnelToken(db) + c.JSON(http.StatusOK, gin.H{ + "has_users": hasUsers, + "setup_completed": completed, + "needs_setup": !completed, + "has_github_app": githubAppConfigured(db), + "has_tunnel": tunnelToken != "", + "signup_enabled": signupEnabled(db), + }) +} + +// POST /api/v1/setup/complete — marks the wizard done. Open to any +// authenticated user: the first account is the owner, and the flag only +// suppresses a banner. +func handleSetupComplete(c *gin.Context) { + if _, ok := requireAuthenticatedUserID(c); !ok { + return + } + db, ok := c.MustGet("db").(*database.DB) + if !ok { + respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Database connection not available") + return + } + if err := setSetting(db, settingSetupCompleted, "true", false); err != nil { + respondError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to save setup state") + return + } + c.JSON(http.StatusOK, gin.H{"message": "Setup completed"}) +} diff --git a/app/frontend/src/app/App.tsx b/app/frontend/src/app/App.tsx index b2fff03..7930e2e 100644 --- a/app/frontend/src/app/App.tsx +++ b/app/frontend/src/app/App.tsx @@ -28,6 +28,7 @@ import { } from '@/features/secondary/pages'; import { NodeDetailPage } from '@/features/nodes/NodeDetailPage'; import { AcceptInvitePage, SignInPage, SignUpPage } from '@/features/auth/pages'; +import { SetupWizardPage } from '@/features/setup/SetupWizardPage'; import { useAuthSession } from '@/lib/use-auth-session'; import { useDemoMode } from '@/lib/demo-mode'; import { getCurrentUserProfile } from '@/lib/api-client'; @@ -138,6 +139,7 @@ export default function App() { } /> } /> } /> + } /> {/* Public browsing — approved projects, templates, docs. */} {/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */} diff --git a/app/frontend/src/app/layout/PlatformShell.tsx b/app/frontend/src/app/layout/PlatformShell.tsx index c76fa28..35e0a62 100644 --- a/app/frontend/src/app/layout/PlatformShell.tsx +++ b/app/frontend/src/app/layout/PlatformShell.tsx @@ -1,4 +1,4 @@ -import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; +import { NavLink, Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { useEffect, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; @@ -36,6 +36,7 @@ import { getCurrentUserProfile, getUpgradeStatus, pingServer, + getSetupStatus, listNotifications, markAllNotificationsRead, markNotificationRead, @@ -185,6 +186,13 @@ export function PlatformShell() { enabled: !isDemoMode && signedIn, refetchInterval: 30_000, }); + // Fresh installs get routed through the setup wizard until the flag lands. + const setupQuery = useQuery({ + queryKey: ['setup-status'], + queryFn: getSetupStatus, + enabled: !isDemoMode && signedIn, + staleTime: 60_000, + }); const markReadMutation = useMutation({ mutationFn: (id: string) => markNotificationRead(id), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['shell-notifications'] }), @@ -247,6 +255,10 @@ export function PlatformShell() { ); + if (!isDemoMode && signedIn && setupQuery.data?.needs_setup) { + return ; + } + return (
diff --git a/app/frontend/src/features/secondary/pages.tsx b/app/frontend/src/features/secondary/pages.tsx index 8a68467..1bbab92 100644 --- a/app/frontend/src/features/secondary/pages.tsx +++ b/app/frontend/src/features/secondary/pages.tsx @@ -1253,7 +1253,11 @@ export function GitHubAppCallbackPage() { mutationFn: (appCode: string) => convertGitHubAppCode(appCode), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['github-app-status'] }); - navigate('/settings', { replace: true }); + // The setup wizard stashes a return marker before submitting the + // manifest form — resume there instead of the settings page. + const inSetup = sessionStorage.getItem('containr.setup_return') === '1'; + sessionStorage.removeItem('containr.setup_return'); + navigate(inSetup ? '/setup' : '/settings', { replace: true }); }, }); const firedRef = useRef(false); diff --git a/app/frontend/src/features/setup/SetupWizardPage.test.tsx b/app/frontend/src/features/setup/SetupWizardPage.test.tsx new file mode 100644 index 0000000..2291e20 --- /dev/null +++ b/app/frontend/src/features/setup/SetupWizardPage.test.tsx @@ -0,0 +1,92 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import { SetupWizardPage } from './SetupWizardPage'; +import type { SetupStatus } from '@/lib/api-client'; + +const mockStatus = vi.fn<() => Promise>(); +const mockSession = vi.fn(); + +vi.mock('@/lib/api-client', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + getSetupStatus: () => mockStatus(), + completeSetup: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock('@/lib/use-auth-session', () => ({ + useAuthSession: () => mockSession(), +})); + +function renderWizard() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + + , + ); +} + +const base: SetupStatus = { + has_users: false, + setup_completed: false, + needs_setup: true, + has_github_app: false, + has_tunnel: false, + signup_enabled: false, +}; + +describe('SetupWizardPage', () => { + beforeEach(() => { + mockSession.mockReturnValue({ data: null }); + }); + + it('prompts for the admin account on a fresh install', async () => { + mockStatus.mockResolvedValue({ ...base }); + renderWizard(); + expect(await screen.findByText(/welcome to containr/i)).toBeInTheDocument(); + expect(screen.getByText(/create admin account/i)).toBeInTheDocument(); + }); + + it('offers sign-in when users exist but nobody is signed in', async () => { + mockStatus.mockResolvedValue({ ...base, has_users: true }); + renderWizard(); + expect(await screen.findByText(/sign in to continue setup/i)).toBeInTheDocument(); + }); + + it('shows the git provider step for a signed-in admin without GitHub App', async () => { + mockStatus.mockResolvedValue({ ...base, has_users: true }); + mockSession.mockReturnValue({ data: { user: { id: 'u1' } } }); + renderWizard(); + expect(await screen.findByText(/connect a git provider/i)).toBeInTheDocument(); + expect(screen.getByText(/set up github app/i)).toBeInTheDocument(); + }); + + it('shows the tunnel step when git is already wired', async () => { + mockStatus.mockResolvedValue({ ...base, has_users: true, has_github_app: true }); + mockSession.mockReturnValue({ data: { user: { id: 'u1' } } }); + renderWizard(); + expect( + await screen.findByRole('heading', { name: /public access/i }), + ).toBeInTheDocument(); + }); + + it('lands on the finish step when everything is configured', async () => { + mockStatus.mockResolvedValue({ + ...base, + has_users: true, + has_github_app: true, + has_tunnel: true, + }); + mockSession.mockReturnValue({ data: { user: { id: 'u1' } } }); + renderWizard(); + expect(await screen.findByText(/enter containr/i)).toBeInTheDocument(); + }); +}); diff --git a/app/frontend/src/features/setup/SetupWizardPage.tsx b/app/frontend/src/features/setup/SetupWizardPage.tsx new file mode 100644 index 0000000..6f506c5 --- /dev/null +++ b/app/frontend/src/features/setup/SetupWizardPage.tsx @@ -0,0 +1,279 @@ +import { useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Link, Navigate, useNavigate } from 'react-router-dom'; +import { Check, ChevronRight, Github, Loader2, Rocket, ShieldCheck } from 'lucide-react'; +import { useAuthSession } from '@/lib/use-auth-session'; +import { + completeSetup, + createGitHubAppManifest, + getSetupStatus, + updatePlatformSettings, +} from '@/lib/api-client'; + +// Marker the GitHub App callback reads so the wizard flow resumes here +// instead of the settings page. +const SETUP_RETURN_KEY = 'containr.setup_return'; + +type Step = 'account' | 'github' | 'tunnel' | 'done'; + +export function SetupWizardPage() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const sessionQuery = useAuthSession(); + const statusQuery = useQuery({ queryKey: ['setup-status'], queryFn: getSetupStatus }); + const [override, setOverride] = useState(null); + + const status = statusQuery.data; + const signedIn = Boolean(sessionQuery.data); + + const step: Step = useMemo(() => { + if (override) return override; + if (!status) return 'account'; + if (!status.has_users || !signedIn) return 'account'; + if (!status.has_github_app) return 'github'; + if (!status.has_tunnel) return 'tunnel'; + return 'done'; + }, [override, status, signedIn]); + + const completeMutation = useMutation({ + mutationFn: completeSetup, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['setup-status'] }); + navigate('/projects', { replace: true }); + }, + }); + + if (statusQuery.isLoading) { + return ( +
+ +
+ ); + } + + // Wizard is optional once the instance is already configured. + if (status && !status.needs_setup && !override) { + return ; + } + + const steps: { key: Step; label: string }[] = [ + { key: 'account', label: 'Admin account' }, + { key: 'github', label: 'Git provider' }, + { key: 'tunnel', label: 'Public access' }, + { key: 'done', label: 'Done' }, + ]; + const activeIdx = steps.findIndex((s) => s.key === step); + + return ( +
+
+
+ {steps.map((s, i) => ( +
+
+ {i < activeIdx ? : i + 1} +
+ + {s.label} + + {i < steps.length - 1 ?
: null} +
+ ))} +
+ + {step === 'account' ? ( +
+

Welcome to Containr

+

+ First boot detected. Create the admin account — registration closes to the public + right after, so this account owns the instance. +

+ {status?.has_users && !signedIn ? ( +
+

+ An account already exists — sign in to continue setup. +

+ + Sign in + +
+ ) : !status?.has_users ? ( + + Create admin account + + ) : null} +
+ ) : null} + + {step === 'github' ? setOverride('tunnel')} /> : null} + {step === 'tunnel' ? setOverride('done')} /> : null} + + {step === 'done' ? ( +
+ +

All set

+

+ Deploy your first service from a git repo, an image, or a template — the dashboard is + ready. +

+ {completeMutation.isError ? ( +

+ {completeMutation.error instanceof Error + ? completeMutation.error.message + : 'Failed to save setup state'} +

+ ) : null} + +
+ ) : null} +
+
+ ); +} + +function GitHubStep({ onNext }: { onNext: () => void }) { + const queryClient = useQueryClient(); + const provisionMutation = useMutation({ + mutationFn: async () => { + const { url, manifest } = await createGitHubAppManifest({ + base_url: window.location.origin, + }); + sessionStorage.setItem(SETUP_RETURN_KEY, '1'); + const form = document.createElement('form'); + form.method = 'POST'; + form.action = url; + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = 'manifest'; + input.value = manifest; + form.appendChild(input); + document.body.appendChild(form); + form.submit(); + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['setup-status'] }), + }); + + return ( +
+
+ +

Connect a git provider

+
+

+ Provision the instance GitHub App — repo push events then trigger deployments and preview + environments automatically. Self-hosted GitLab/Gitea can be added later in Settings. +

+ {provisionMutation.isError ? ( +

+ {provisionMutation.error instanceof Error + ? provisionMutation.error.message + : 'Could not start GitHub App provisioning'} +

+ ) : null} +
+ + +
+
+ ); +} + +function TunnelStep({ onNext }: { onNext: () => void }) { + const queryClient = useQueryClient(); + const [token, setToken] = useState(''); + const saveMutation = useMutation({ + mutationFn: () => + updatePlatformSettings({ cloudflareTunnelToken: token.trim() || undefined }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['setup-status'] }); + onNext(); + }, + }); + + return ( +
+

Public access (optional)

+

+ Paste a Cloudflare Tunnel token to expose this instance on the internet. Without one, + Containr stays reachable on this network — you can add it later in Settings. +

+ setToken(e.target.value)} + placeholder="eyJhIjo… tunnel token" + className="mt-4 w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-colors" + /> + {saveMutation.isError ? ( +

+ {saveMutation.error instanceof Error ? saveMutation.error.message : 'Failed to save token'} +

+ ) : null} +
+ + +
+
+ ); +} diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index 50e3d0f..4e6d672 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -5633,6 +5633,90 @@ export interface paths { patch?: never; trace?: never; }; + "/setup/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * First-run setup status + * @description Public. Drives the setup wizard — needs_setup is true until setup_completed is recorded. Instances with users and projects that predate the wizard report completed automatically. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Setup state */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + has_users?: boolean; + setup_completed?: boolean; + needs_setup?: boolean; + /** @description GitHub App credentials configured (manifest flow or env) */ + has_github_app?: boolean; + /** @description Cloudflare tunnel token configured */ + has_tunnel?: boolean; + signup_enabled?: boolean; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/setup/complete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Mark the setup wizard complete */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Setup completed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/settings": { parameters: { query?: never; diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index 8cda35b..22d19dd 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -499,6 +499,23 @@ export async function pingServer(): Promise { } } +export type SetupStatus = { + has_users: boolean; + setup_completed: boolean; + needs_setup: boolean; + has_github_app: boolean; + has_tunnel: boolean; + signup_enabled: boolean; +}; + +export async function getSetupStatus(): Promise { + return requestJson('/setup/status'); +} + +export async function completeSetup(): Promise { + await requestJson('/setup/complete', { method: 'POST', body: JSON.stringify({}) }); +} + export function getAgentPublicBaseUrl(): string { return `${API_ROOT}/api/agents`; } diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index af0bdfc..058419e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -3705,6 +3705,50 @@ paths: type: boolean description: True when the owner re-opened public registration in Settings + /setup/status: + get: + tags: + - Authentication + summary: First-run setup status + description: Public. Drives the setup wizard — needs_setup is true until setup_completed is recorded. Instances with users and projects that predate the wizard report completed automatically. + responses: + '200': + description: Setup state + content: + application/json: + schema: + type: object + properties: + has_users: + type: boolean + setup_completed: + type: boolean + needs_setup: + type: boolean + has_github_app: + type: boolean + description: GitHub App credentials configured (manifest flow or env) + has_tunnel: + type: boolean + description: Cloudflare tunnel token configured + signup_enabled: + type: boolean + + /setup/complete: + post: + tags: + - Authentication + summary: Mark the setup wizard complete + security: + - bearerAuth: [] + responses: + '200': + description: Setup completed + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + /settings: get: tags: