feat(setup): first-run wizard — admin, GitHub App, tunnel, done (#71)

* 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>
This commit is contained in:
Tomáš DvořákandDevin authored and GitHub committed 2026-10-10 02:17:41 +02:00
1 parent 985d22798c
commit c4307034ec
11 files changed
+622 -4

No files matched your search

+2
View File
@@ -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)
+78
View File
@@ -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"})
}