From c16b32f56af253e7d9320d7dd3d770ecd1cfeb7b Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Mon, 21 Sep 2026 18:05:22 +0200 Subject: [PATCH] feat: owner accounts and in-app platform settings - First registered account becomes admin (users.is_admin, backfilled to earliest account); better-auth mirror path does the same - Public registration closes after bootstrap; admin can reopen it via GET/PUT /api/v1/settings (admin-gated) or SIGNUP_ENABLED env - In-app settings override env vars; secrets stored in app_settings, token values never returned by the API - Saving a Cloudflare Tunnel token pulls and runs a managed cloudflared sidecar on the backend's networks; clearing removes it; startup restores it from saved settings - group_add DOCKER_GID grants the non-root backend user Docker socket access; install.sh probes the in-container socket gid - Settings page gains an owner-only Platform section (registration toggle, tunnel token, link to Cloudflare dashboard); sign-up page stays reachable while registration is open - OpenAPI spec + generated types updated; manual user creation now requires admin Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .env.example | 13 +- README.md | 3 +- app/backend/docker-compose.yml | 4 + app/backend/internal/api/auth.go | 47 +-- app/backend/internal/api/auth_proxy.go | 4 +- app/backend/internal/api/routes.go | 6 + app/backend/internal/api/settings.go | 294 ++++++++++++++++++ app/backend/internal/middleware/middleware.go | 13 +- .../20260922000000_app_settings.sql | 24 ++ app/frontend/src/features/auth/pages.tsx | 10 +- app/frontend/src/features/secondary/pages.tsx | 145 +++++++++ app/frontend/src/generated/api-types.ts | 117 ++++++- app/frontend/src/lib/api-client.ts | 45 +++ app/frontend/src/lib/auth-client.ts | 2 + docker-compose.yml | 7 + docs/api/openapi.yaml | 88 +++++- infra/docker-compose.yml | 4 + install.sh | 10 + 18 files changed, 802 insertions(+), 34 deletions(-) create mode 100644 app/backend/internal/api/settings.go create mode 100644 app/backend/migrations_goose/20260922000000_app_settings.sql diff --git a/.env.example b/.env.example index b8f6a81..ab5fbe7 100644 --- a/.env.example +++ b/.env.example @@ -120,10 +120,21 @@ TRAEFIK_AUTH=admin:$$apr1$$b8mh8c8v$$KkR8hQZQZQZQZQZQZQZQZ/ # Development convenience: true in local dev, false in production TRAEFIK_API_INSECURE=true -# Optional: Cloudflare Tunnel (alternative to domain) +# Optional: Cloudflare Tunnel (alternative to domain). Can also be set in-app +# under Settings > Platform (in-app value overrides this). # Get token from: https://dash.cloudflare.com/argotunnel CLOUDFLARED_TOKEN=your_cloudflare_tunnel_token_here +# Optional: reopen public registration after the owner account exists. +# Manageable in-app under Settings > Platform (in-app value overrides this). +# SIGNUP_ENABLED=false + +# Docker socket gid AS SEEN INSIDE containers - lets the backend manage +# containers (deployments, cloudflared sidecar). install.sh detects it with a +# throwaway container. Modern Docker maps the socket to root: use 0. +# Older daemons expose the host docker gid: stat -c %g /var/run/docker.sock +DOCKER_GID=0 + # Optional: Custom Docker Registry # DOCKER_REGISTRY=your-registry.com # DOCKER_USERNAME=your_username diff --git a/README.md b/README.md index 3e013fe..2d9a601 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,8 @@ Installs into `./containr`, generates secrets, pulls the published `:latest` images, and starts the stack — UI at `http://localhost:3000`, API at `http://localhost:8082`. Non-interactive; re-runs never overwrite `.env`. On first visit you create the owner account (email/password); registration -then closes automatically. +then closes automatically. The owner can reopen it and configure a Cloudflare +Tunnel token under **Settings → Platform** — in-app values override env vars. Override with env vars: diff --git a/app/backend/docker-compose.yml b/app/backend/docker-compose.yml index a6aa6b1..22b96c6 100644 --- a/app/backend/docker-compose.yml +++ b/app/backend/docker-compose.yml @@ -38,6 +38,10 @@ services: - ../../.env.prod ports: - "8080:8080" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + group_add: + - "${DOCKER_GID:-0}" depends_on: postgres: condition: service_healthy diff --git a/app/backend/internal/api/auth.go b/app/backend/internal/api/auth.go index 06d4be6..813b21f 100644 --- a/app/backend/internal/api/auth.go +++ b/app/backend/internal/api/auth.go @@ -46,6 +46,7 @@ type User struct { Email string `json:"email"` Name string `json:"name"` AvatarURL string `json:"avatar_url,omitempty"` + IsAdmin bool `json:"is_admin"` CreatedAt string `json:"created_at"` } @@ -63,10 +64,10 @@ func handleLogin(c *gin.Context) { var user User var hashedPassword string err := db.QueryRow(` - SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), created_at - FROM users + SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), is_admin, created_at + FROM users WHERE email = $1 - `, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.CreatedAt) + `, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt) if err == sql.ErrNoRows { authUser, ok := verifyBetterAuthCredentials(c, req.Email, req.Password) @@ -128,10 +129,11 @@ func handleAuthBootstrap(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "has_users": count > 0, - "user_count": count, - "mode": mode, - "providers": providers, + "has_users": count > 0, + "user_count": count, + "mode": mode, + "providers": providers, + "signup_enabled": signupEnabled(db), }) } @@ -282,24 +284,26 @@ func handleRegister(c *gin.Context) { db := c.MustGet("db").(*database.DB) jwtSecret := c.MustGet("jwt_secret").(string) - count, err := countLocalUsers(db) + total, err := countLocalUsers(db) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) return } - if count > 0 { + // Closed once the first account exists unless the owner reopens registration. + if total > 0 && !signupEnabled(db) { c.JSON(http.StatusForbidden, gin.H{"error": "Public registration is disabled after bootstrap"}) return } - err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", strings.ToLower(strings.TrimSpace(req.Email))).Scan(&count) + var existing int + err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", strings.ToLower(strings.TrimSpace(req.Email))).Scan(&existing) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) return } - if count > 0 { + if existing > 0 { c.JSON(http.StatusConflict, gin.H{"error": "User already exists"}) return } @@ -311,13 +315,13 @@ func handleRegister(c *gin.Context) { return } - // Create user + // First account owns the platform. var user User err = db.QueryRow(` - INSERT INTO users (email, password_hash, name) - VALUES ($1, $2, $3) + INSERT INTO users (email, password_hash, name, is_admin) + VALUES ($1, $2, $3, $4) RETURNING id, email, name, COALESCE(avatar_url, ''), created_at - `, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt) + `, req.Email, string(hashedPassword), req.Name, total == 0).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"}) @@ -338,13 +342,16 @@ func handleRegister(c *gin.Context) { } func handleCreateUser(c *gin.Context) { + db, ok := requireAdmin(c) + if !ok { + return + } + var req ManualUserCreateRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - - db := c.MustGet("db").(*database.DB) req.Email = strings.ToLower(strings.TrimSpace(req.Email)) req.Name = strings.TrimSpace(req.Name) if req.Email == "" || req.Name == "" { @@ -477,10 +484,10 @@ func handleGetProfile(c *gin.Context) { var user User err := db.QueryRow(` - SELECT id, email, name, COALESCE(avatar_url, ''), created_at - FROM users + SELECT id, email, name, COALESCE(avatar_url, ''), is_admin, created_at + FROM users WHERE id = $1 - `, userID).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt) + `, userID).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) diff --git a/app/backend/internal/api/auth_proxy.go b/app/backend/internal/api/auth_proxy.go index ddced07..43da378 100644 --- a/app/backend/internal/api/auth_proxy.go +++ b/app/backend/internal/api/auth_proxy.go @@ -67,7 +67,9 @@ func shouldBlockPublicBetterAuthSignup(c *gin.Context, cfg *config.Config, db *d return true } - return count > 0 + // Registration is closed once the first account exists unless the owner + // reopens it in Settings (signup_enabled). + return count > 0 && !signupEnabled(db) } func parseAuthProxyTarget(rawTarget string) (*url.URL, error) { diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index 0fdc7f4..5a036f2 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -35,6 +35,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg dockerClient = client buildManager = build.NewBuildManager("/tmp/containr-builds", dockerClient) deploymentEngine = deployment.NewDeploymentEngine(buildManager, dockerClient) + // Restore managed sidecars (e.g. cloudflared) from saved settings. + syncManagedContainers(dockerClient, db) } // Initialize build handler @@ -184,6 +186,10 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg protected.PUT("/user/profile", handleUpdateProfile) protected.POST("/users", handleCreateUser) + // Platform settings (admin-gated inside handlers) + protected.GET("/settings", handleGetSettings) + protected.PUT("/settings", handleUpdateSettings) + // Project routes protected.GET("/projects", handleGetProjects) protected.POST("/projects", handleCreateProject) diff --git a/app/backend/internal/api/settings.go b/app/backend/internal/api/settings.go new file mode 100644 index 0000000..ead2fc8 --- /dev/null +++ b/app/backend/internal/api/settings.go @@ -0,0 +1,294 @@ +package api + +import ( + "context" + "database/sql" + "io" + "log" + "net/http" + "os" + "strings" + "time" + + "containr/internal/database" + "containr/internal/docker" + + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types/registry" + "github.com/gin-gonic/gin" +) + +// Platform setting keys. Values stored in app_settings take precedence over +// environment variables; env remains the fallback for optional overrides. +const ( + settingSignupEnabled = "signup_enabled" + settingCloudflareTunnelToken = "cloudflare_tunnel_token" + + cloudflaredContainerName = "containr-cloudflared" + cloudflaredImage = "cloudflare/cloudflared:latest" +) + +// settingValue resolves a setting: app_settings row first, env fallback, +// then the caller's default. +func settingValue(db *database.DB, key, envKey, fallback string) string { + if db != nil && db.DB != nil { + var value string + err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, key).Scan(&value) + if err == nil { + return value + } + if err != sql.ErrNoRows { + log.Printf("Failed to read setting %s: %v", key, err) + } + } + if envKey != "" { + if value := strings.TrimSpace(os.Getenv(envKey)); value != "" { + return value + } + } + return fallback +} + +func setSetting(db *database.DB, key, value string, secret bool) error { + _, err := db.Exec(` + INSERT INTO app_settings (key, value, is_secret, updated_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (key) DO UPDATE + SET value = EXCLUDED.value, is_secret = EXCLUDED.is_secret, updated_at = NOW() + `, key, value, secret) + return err +} + +func deleteSetting(db *database.DB, key string) error { + _, err := db.Exec(`DELETE FROM app_settings WHERE key = $1`, key) + return err +} + +// signupEnabled controls whether public registration stays open after the +// first account exists. Defaults to closed; the platform owner can reopen it +// in Settings or via SIGNUP_ENABLED=true. +func signupEnabled(db *database.DB) bool { + return strings.EqualFold(settingValue(db, settingSignupEnabled, "SIGNUP_ENABLED", "false"), "true") +} + +func isAdminUser(db *database.DB, userID string) bool { + var isAdmin bool + if err := db.QueryRow(`SELECT is_admin FROM users WHERE id = $1`, userID).Scan(&isAdmin); err != nil { + return false + } + return isAdmin +} + +func requireAdmin(c *gin.Context) (*database.DB, bool) { + userID, ok := requireAuthenticatedUserID(c) + if !ok { + return nil, false + } + db, _ := c.Get("db") + database, _ := db.(*database.DB) + if database == nil || database.DB == nil || !isAdminUser(database, userID) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"}) + return nil, false + } + return database, true +} + +type cloudflareTunnelStatus struct { + TokenSet bool `json:"token_set"` + Source string `json:"source"` // "app" | "env" | "none" + Container string `json:"container"` +} + +func handleGetSettings(c *gin.Context) { + db, ok := requireAdmin(c) + if !ok { + return + } + + dockerClient, _ := c.Get("docker_client") + client, _ := dockerClient.(*docker.Client) + + c.JSON(http.StatusOK, gin.H{ + "signup_enabled": signupEnabled(db), + "cloudflare_tunnel": cloudflareTunnelState(c.Request.Context(), db, client), + }) +} + +func handleUpdateSettings(c *gin.Context) { + db, ok := requireAdmin(c) + if !ok { + return + } + + var req struct { + SignupEnabled *bool `json:"signup_enabled"` + CloudflareTunnelToken *string `json:"cloudflare_tunnel_token"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if req.SignupEnabled != nil { + value := "false" + if *req.SignupEnabled { + value = "true" + } + if err := setSetting(db, settingSignupEnabled, value, false); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update signup setting"}) + return + } + } + + if req.CloudflareTunnelToken != nil { + token := strings.TrimSpace(*req.CloudflareTunnelToken) + if token == "" { + if err := deleteSetting(db, settingCloudflareTunnelToken); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear tunnel token"}) + return + } + } else if err := setSetting(db, settingCloudflareTunnelToken, token, true); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save tunnel token"}) + return + } + + if dockerClient, exists := c.Get("docker_client"); exists { + if client, ok := dockerClient.(*docker.Client); ok && client != nil { + go applyCloudflaredTunnel(client, token) + } + } + } + + handleGetSettings(c) +} + +// resolvedTunnelToken prefers the in-app value and falls back to the env. +func resolvedTunnelToken(db *database.DB) (string, string) { + if db != nil && db.DB != nil { + var value string + if err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, settingCloudflareTunnelToken).Scan(&value); err == nil && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value), "app" + } + } + if value := strings.TrimSpace(os.Getenv("CLOUDFLARED_TOKEN")); value != "" && value != "your_cloudflare_tunnel_token_here" { + return value, "env" + } + return "", "none" +} + +func cloudflareTunnelState(ctx context.Context, db *database.DB, client *docker.Client) cloudflareTunnelStatus { + token, source := resolvedTunnelToken(db) + state := cloudflareTunnelStatus{TokenSet: token != "", Source: source, Container: "missing"} + + if client == nil { + state.Container = "unavailable" + return state + } + + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + container, err := findCloudflaredContainer(ctx, client) + if err != nil || container == "" { + return state + } + if inspect, err := client.GetContainer(ctx, container); err == nil && inspect.State != nil { + state.Container = inspect.State.Status + } + return state +} + +// syncManagedContainers reconciles containers the platform manages from +// settings - currently only the optional Cloudflare Tunnel sidecar. +func syncManagedContainers(client *docker.Client, db *database.DB) { + token, _ := resolvedTunnelToken(db) + if token == "" { + return + } + go applyCloudflaredTunnel(client, token) +} + +func findCloudflaredContainer(ctx context.Context, client *docker.Client) (string, error) { + containers, err := client.ListContainers(ctx, true) + if err != nil { + return "", err + } + for _, item := range containers { + for _, name := range item.Names { + if strings.TrimPrefix(name, "/") == cloudflaredContainerName { + return item.ID, nil + } + } + } + return "", nil +} + +// applyCloudflaredTunnel ensures a cloudflared container mirrors the stored +// token: created/recreated when set, removed when cleared. It joins the same +// networks as the backend so tunnel targets can use compose service names. +func applyCloudflaredTunnel(client *docker.Client, token string) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + if existing, err := findCloudflaredContainer(ctx, client); err == nil && existing != "" { + if err := client.RemoveContainer(ctx, existing, true); err != nil { + log.Printf("Failed to remove existing cloudflared container: %v", err) + return + } + } + + if token == "" { + return + } + + if reader, err := client.PullImage(ctx, cloudflaredImage, registry.AuthConfig{}); err == nil { + _, _ = io.Copy(io.Discard, reader) + _ = reader.Close() + } else { + log.Printf("Failed to pull %s: %v", cloudflaredImage, err) + } + + networks, err := backendNetworks(ctx, client) + if err != nil { + log.Printf("Failed to resolve backend networks for cloudflared: %v", err) + return + } + + id, err := client.CreateContainer(ctx, docker.ContainerConfig{ + Name: cloudflaredContainerName, + Image: cloudflaredImage, + Cmd: []string{"tunnel", "--no-autoupdate", "run", "--token", token}, + RestartPolicy: "unless-stopped", + Labels: map[string]string{"containr.managed": "cloudflared"}, + Networks: networks, + }) + if err != nil { + log.Printf("Failed to create cloudflared container: %v", err) + return + } + if err := client.StartContainer(ctx, id); err != nil { + log.Printf("Failed to start cloudflared container: %v", err) + } +} + +// backendNetworks returns the networks this backend container sits on, so the +// managed cloudflared container can reach the frontend/backend by name. +func backendNetworks(ctx context.Context, client *docker.Client) (map[string]*network.EndpointSettings, error) { + hostname, err := os.Hostname() + if err != nil { + return nil, err + } + self, err := client.GetContainer(ctx, hostname) + if err != nil { + return nil, err + } + + networks := map[string]*network.EndpointSettings{} + for name := range self.NetworkSettings.Networks { + networks[name] = &network.EndpointSettings{} + } + if len(networks) == 0 { + return nil, sql.ErrNoRows + } + return networks, nil +} diff --git a/app/backend/internal/middleware/middleware.go b/app/backend/internal/middleware/middleware.go index 45ccbc3..012daa8 100644 --- a/app/backend/internal/middleware/middleware.go +++ b/app/backend/internal/middleware/middleware.go @@ -248,11 +248,18 @@ func ensureLocalUserRecord(c *gin.Context, user betterAuthSessionUser) (string, return "", hashErr } + // The first mirrored account owns the platform. + var userCount int + if err := db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil { + return "", err + } + isFirstUser := userCount == 0 + if err := db.QueryRow(` - INSERT INTO users (email, password_hash, name, avatar_url) - VALUES ($1, $2, $3, NULLIF($4, '')) + INSERT INTO users (email, password_hash, name, avatar_url, is_admin) + VALUES ($1, $2, $3, NULLIF($4, ''), $5) RETURNING id - `, email, string(hashedPassword), name, avatarURL).Scan(&localUserID); err != nil { + `, email, string(hashedPassword), name, avatarURL, isFirstUser).Scan(&localUserID); err != nil { return "", err } diff --git a/app/backend/migrations_goose/20260922000000_app_settings.sql b/app/backend/migrations_goose/20260922000000_app_settings.sql new file mode 100644 index 0000000..d8afe3b --- /dev/null +++ b/app/backend/migrations_goose/20260922000000_app_settings.sql @@ -0,0 +1,24 @@ +-- +goose Up +-- In-app platform settings. Values here override environment variables; +-- secrets (e.g. Cloudflare tunnel token) are flagged and masked in API reads. +CREATE TABLE IF NOT EXISTS public.app_settings ( + key character varying(255) PRIMARY KEY, + value text NOT NULL DEFAULT '', + is_secret boolean NOT NULL DEFAULT false, + updated_at timestamp with time zone NOT NULL DEFAULT now() +); + +ALTER TABLE public.users ADD COLUMN IF NOT EXISTS is_admin boolean NOT NULL DEFAULT false; + +-- The first registered user owns the platform. Backfill existing installs: +-- the earliest account becomes admin when no admin is set. +UPDATE public.users +SET is_admin = true +WHERE id = ( + SELECT id FROM public.users ORDER BY created_at ASC, id ASC LIMIT 1 +) + AND NOT EXISTS (SELECT 1 FROM public.users WHERE is_admin); + +-- +goose Down +ALTER TABLE public.users DROP COLUMN IF EXISTS is_admin; +DROP TABLE IF EXISTS public.app_settings; diff --git a/app/frontend/src/features/auth/pages.tsx b/app/frontend/src/features/auth/pages.tsx index 1476200..d7b2ff5 100644 --- a/app/frontend/src/features/auth/pages.tsx +++ b/app/frontend/src/features/auth/pages.tsx @@ -279,7 +279,8 @@ export function SignUpPage() { return ; } - if (bootstrapQuery.data?.mode === 'login') { + // Registration stays available when the owner reopened it in Settings. + if (bootstrapQuery.data?.mode === 'login' && !bootstrapQuery.data.signupEnabled) { return ; } @@ -302,7 +303,12 @@ export function SignUpPage() { return ( - + {error ? : null} {bootstrapQuery.isLoading ? : null} {bootstrapQuery.isError ? : null} diff --git a/app/frontend/src/features/secondary/pages.tsx b/app/frontend/src/features/secondary/pages.tsx index 6578109..5755907 100644 --- a/app/frontend/src/features/secondary/pages.tsx +++ b/app/frontend/src/features/secondary/pages.tsx @@ -29,6 +29,8 @@ import { getGitHubAppInstallUrl, connectGitHubApp, updateCurrentUserProfile, + getPlatformSettings, + updatePlatformSettings, getHAStatus, setHAEnabled, triggerFailover, @@ -92,6 +94,8 @@ import { ShieldCheck, Zap, HeartPulse, + Cloud, + ExternalLink, } from 'lucide-react'; function SecondaryPageHeader({ title, description }: { title: string; description: string }) { @@ -1077,6 +1081,144 @@ function GitProvidersSection() { ); } +function PlatformSettingsSection() { + const queryClient = useQueryClient(); + const settingsQuery = useQuery({ + queryKey: ['platform-settings'], + queryFn: getPlatformSettings, + }); + + const [signupDraft, setSignupDraft] = useState(null); + const [tokenDraft, setTokenDraft] = useState(''); + const [feedback, setFeedback] = useState(null); + const [error, setError] = useState(null); + + const signupEnabled = signupDraft ?? settingsQuery.data?.signupEnabled ?? false; + const tunnel = settingsQuery.data?.cloudflareTunnel; + + const saveMutation = useMutation({ + mutationFn: updatePlatformSettings, + onSuccess: (settings) => { + queryClient.setQueryData(['platform-settings'], settings); + setSignupDraft(null); + setTokenDraft(''); + setFeedback('Settings saved.'); + setError(null); + }, + onError: (err) => { + setError(err instanceof Error ? err.message : 'Failed to save settings'); + setFeedback(null); + }, + }); + + const saveSignup = (enabled: boolean) => { + setSignupDraft(enabled); + saveMutation.mutate({ signupEnabled: enabled }); + }; + + const saveToken = () => { + if (!tokenDraft.trim()) return; + saveMutation.mutate({ cloudflareTunnelToken: tokenDraft.trim() }); + }; + + const clearToken = () => { + saveMutation.mutate({ cloudflareTunnelToken: '' }); + }; + + return ( +
+
+
+ +
+
+

Platform

+

Owner only - values set here override environment variables

+
+
+ + {settingsQuery.isLoading ? ( +
+ +
+ ) : ( +
+ + +
+
+ Cloudflare Tunnel + + {tunnel?.tokenSet + ? `token saved (${tunnel.source}) - ${tunnel.container}` + : tunnel?.container === 'unavailable' + ? 'Docker socket unavailable' + : 'not configured'} + +
+

+ Expose this instance through a Cloudflare Tunnel. Create a tunnel in the dashboard, + copy the token, paste it here - the cloudflared sidecar is managed for you. +

+
+ setTokenDraft(e.target.value)} + className="flex-1 h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] text-sm focus:border-[var(--accent-primary)] transition-colors" + placeholder={tunnel?.tokenSet ? 'Token saved - paste a new one to rotate' : 'eyJhIjo... tunnel token'} + /> + + {tunnel?.tokenSet && tunnel.source === 'app' ? ( + + ) : null} +
+ + Open Cloudflare Zero Trust dashboard - Networks → Tunnels → Create + + +
+ + {feedback ?

{feedback}

: null} + {error ?

{error}

: null} +
+ )} +
+ ); +} + export function SettingsPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -1238,6 +1380,9 @@ export function SettingsPage() { )} + {/* Platform Settings - owner only */} + {profileQuery.data?.isAdmin ? : null} + {/* Git Providers Section */} diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index 459683f..be00369 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -2153,7 +2153,7 @@ export interface paths { }; /** * Bootstrap status - * @description Reports whether the instance already has a registered user. Drives the first-run setup gate. + * @description Reports whether the instance already has a registered user, which auth providers are configured, and whether public registration is open. Drives the first-run setup gate. */ get: { parameters: { @@ -2171,9 +2171,18 @@ export interface paths { }; content: { "application/json": { - /** @description True when no user exists yet */ - needs_setup?: boolean; + /** @description True when at least one account exists */ + has_users?: boolean; user_count?: number; + /** + * @description register when the instance has no users, login otherwise + * @enum {string} + */ + mode?: "register" | "login"; + /** @description OAuth providers with real credentials configured (e.g. github, google). Empty on self-hosted installs. */ + providers?: string[]; + /** @description True when the owner re-opened public registration in Settings */ + signup_enabled?: boolean; }; }; }; @@ -2187,6 +2196,91 @@ export interface paths { patch?: never; trace?: never; }; + "/settings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Read platform settings + * @description Admin only. Returns in-app settings that override environment variables. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Current platform settings */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PlatformSettings"]; + }; + }; + /** @description Admin access required */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + /** + * Update platform settings + * @description Admin only. Provided keys are applied immediately; the Cloudflare tunnel token starts/stops the managed cloudflared sidecar. + */ + put: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Allow new public account registration after bootstrap */ + signup_enabled?: boolean; + /** @description Cloudflare Tunnel token. Empty string clears the stored token and removes the cloudflared container. */ + cloudflare_tunnel_token?: string; + }; + }; + }; + responses: { + /** @description Updated platform settings */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PlatformSettings"]; + }; + }; + /** @description Admin access required */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/users": { parameters: { query?: never; @@ -6687,6 +6781,21 @@ export interface components { /** @description User display name */ name: string; }; + PlatformSettings: { + /** @description Whether public account registration is open after bootstrap */ + signup_enabled?: boolean; + cloudflare_tunnel?: { + /** @description Whether a tunnel token is configured (value is never returned) */ + token_set?: boolean; + /** + * @description Where the active token comes from - in-app setting, env fallback, or unset + * @enum {string} + */ + source?: "app" | "env" | "none"; + /** @description cloudflared container state - running/exited/missing, or unavailable when Docker is unreachable */ + container?: string; + }; + }; User: { /** @description User ID */ id?: string; @@ -6699,6 +6808,8 @@ export interface components { name?: string; /** @description Optional avatar image URL */ avatar_url?: string; + /** @description True for the platform owner (first registered account) */ + is_admin?: boolean; /** * Format: date-time * @description Account creation timestamp diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index e56d796..f432de1 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -18,10 +18,20 @@ export type UserProfile = { email: string; name: string; avatarUrl?: string; + isAdmin: boolean; createdAt?: string; updatedAt?: string; }; +export type PlatformSettings = { + signupEnabled: boolean; + cloudflareTunnel: { + tokenSet: boolean; + source: 'app' | 'env' | 'none'; + container: string; + }; +}; + export type ServiceEntity = { id: string; projectId: string; @@ -442,11 +452,46 @@ function normalizeUserProfile(profile: RawUserProfile): UserProfile | null { email: profile.email, name: profile.name, avatarUrl: profile.avatar_url ?? undefined, + isAdmin: profile.is_admin ?? false, createdAt: profile.created_at ?? undefined, updatedAt: profile.updated_at ?? undefined, }; } +function normalizePlatformSettings(raw: components['schemas']['PlatformSettings']): PlatformSettings { + return { + signupEnabled: raw.signup_enabled ?? false, + cloudflareTunnel: { + tokenSet: raw.cloudflare_tunnel?.token_set ?? false, + source: raw.cloudflare_tunnel?.source ?? 'none', + container: raw.cloudflare_tunnel?.container ?? 'missing', + }, + }; +} + +export async function getPlatformSettings(): Promise { + const raw = await requestJson('/settings'); + return normalizePlatformSettings(raw); +} + +export async function updatePlatformSettings(input: { + signupEnabled?: boolean; + cloudflareTunnelToken?: string; +}): Promise { + const body: Record = {}; + if (input.signupEnabled !== undefined) { + body.signup_enabled = input.signupEnabled; + } + if (input.cloudflareTunnelToken !== undefined) { + body.cloudflare_tunnel_token = input.cloudflareTunnelToken; + } + const raw = await requestJson('/settings', { + method: 'PUT', + body: JSON.stringify(body), + }); + return normalizePlatformSettings(raw); +} + function normalizeService(service: RawService): ServiceEntity | null { if (!service.id || !service.name || !service.project_id) { return null; diff --git a/app/frontend/src/lib/auth-client.ts b/app/frontend/src/lib/auth-client.ts index b5c977a..7f11565 100644 --- a/app/frontend/src/lib/auth-client.ts +++ b/app/frontend/src/lib/auth-client.ts @@ -21,6 +21,7 @@ export type AuthBootstrap = { userCount: number; mode: 'register' | 'login'; providers: string[]; + signupEnabled: boolean; }; export class AuthError extends Error { @@ -67,6 +68,7 @@ export async function getAuthBootstrap(): Promise { userCount: typeof payload?.user_count === 'number' ? payload.user_count : 0, mode: payload?.mode === 'register' ? 'register' : 'login', providers, + signupEnabled: Boolean(payload?.signup_enabled), }; } diff --git a/docker-compose.yml b/docker-compose.yml index 9257661..31f0641 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,6 +84,13 @@ services: BITBUCKET_API_URL: ${BITBUCKET_API_URL:-https://api.bitbucket.org/2.0} BITBUCKET_BASE_URL: ${BITBUCKET_BASE_URL:-https://bitbucket.org} GITEA_BASE_URL: ${GITEA_BASE_URL:-https://gitea.example.com} + volumes: + # Required for service deployments and managed sidecars (e.g. cloudflared) + - /var/run/docker.sock:/var/run/docker.sock + # Grants the non-root backend user access to the Docker socket. + # install.sh writes the host's docker gid into DOCKER_GID. + group_add: + - "${DOCKER_GID:-0}" depends_on: postgres: condition: service_healthy diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 7f398ac..ed33f01 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1432,7 +1432,7 @@ paths: tags: - Authentication summary: Bootstrap status - description: Reports whether the instance already has a registered user. Drives the first-run setup gate. + description: Reports whether the instance already has a registered user, which auth providers are configured, and whether public registration is open. Drives the first-run setup gate. responses: '200': description: Bootstrap state @@ -1441,11 +1441,70 @@ paths: schema: type: object properties: - needs_setup: + has_users: type: boolean - description: True when no user exists yet + description: True when at least one account exists user_count: type: integer + mode: + type: string + enum: [register, login] + description: register when the instance has no users, login otherwise + providers: + type: array + items: + type: string + description: OAuth providers with real credentials configured (e.g. github, google). Empty on self-hosted installs. + signup_enabled: + type: boolean + description: True when the owner re-opened public registration in Settings + + /settings: + get: + tags: + - Settings + summary: Read platform settings + description: Admin only. Returns in-app settings that override environment variables. + security: + - bearerAuth: [] + responses: + '200': + description: Current platform settings + content: + application/json: + schema: + $ref: '#/components/schemas/PlatformSettings' + '403': + description: Admin access required + put: + tags: + - Settings + summary: Update platform settings + description: Admin only. Provided keys are applied immediately; the Cloudflare tunnel token starts/stops the managed cloudflared sidecar. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + signup_enabled: + type: boolean + description: Allow new public account registration after bootstrap + cloudflare_tunnel_token: + type: string + description: Cloudflare Tunnel token. Empty string clears the stored token and removes the cloudflared container. + responses: + '200': + description: Updated platform settings + content: + application/json: + schema: + $ref: '#/components/schemas/PlatformSettings' + '403': + description: Admin access required # User Management /users: @@ -4111,6 +4170,26 @@ components: type: string description: User display name + PlatformSettings: + type: object + properties: + signup_enabled: + type: boolean + description: Whether public account registration is open after bootstrap + cloudflare_tunnel: + type: object + properties: + token_set: + type: boolean + description: Whether a tunnel token is configured (value is never returned) + source: + type: string + enum: [app, env, none] + description: Where the active token comes from - in-app setting, env fallback, or unset + container: + type: string + description: cloudflared container state - running/exited/missing, or unavailable when Docker is unreachable + User: type: object properties: @@ -4127,6 +4206,9 @@ components: avatar_url: type: string description: Optional avatar image URL + is_admin: + type: boolean + description: True for the platform owner (first registered account) created_at: type: string format: date-time diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 7b71422..6f2ac62 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -134,6 +134,10 @@ services: - BITBUCKET_API_URL=${BITBUCKET_API_URL:-https://api.bitbucket.org/2.0} - BITBUCKET_BASE_URL=${BITBUCKET_BASE_URL:-https://bitbucket.org} - GITEA_BASE_URL=${GITEA_BASE_URL:-https://gitea.example.com} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + group_add: + - "${DOCKER_GID:-0}" networks: - containr-network depends_on: diff --git a/install.sh b/install.sh index 0f8b7a2..a20d945 100755 --- a/install.sh +++ b/install.sh @@ -82,6 +82,16 @@ else sed -i.bak "s|your_secure_postgres_password|$PG_PASS|g" .env sed -i.bak "s|your_secure_redis_password|$REDIS_PASS|g" .env + # Docker socket gid as seen INSIDE containers (modern Docker maps it to + # root). Probe a throwaway container; fall back to the host gid, then 0. + if [[ -z "${DOCKER_GID:-}" ]]; then + DOCKER_GID="$(docker run --rm -v /var/run/docker.sock:/var/run/docker.sock alpine:3 stat -c %g /var/run/docker.sock 2>/dev/null \ + || stat -c %g /var/run/docker.sock 2>/dev/null \ + || stat -f %g /var/run/docker.sock 2>/dev/null \ + || echo 0)" + fi + setvar DOCKER_GID "$DOCKER_GID" + # Persist any overrides passed via the environment. for v in HTTP_PORT API_PORT POSTGRES_PORT REDIS_PORT CONTAINR_VERSION; do [[ -n "${!v:-}" ]] && setvar "$v" "${!v}"