Compare commits

..
3 Commits
Author SHA1 Message Date
Tomáš DvořákandDevin 00e69b3733 feat(git): GitHub App manifest self-provisioning + app-level webhook (#44)
* feat(branding): white-label — public /branding, admin write, shell theming

app_settings branding.* keys (product name, logo/favicon URLs, accent
color, docs/support links). Public GET /branding so login and shell
theme pre-auth; admin writes via PUT /settings.

useBranding() applies document.title, accent CSS var and favicon;
BrandWordmark replaces hardcoded wordmarks; admin Platform settings
gain a Branding form. MCP containr_branding_get + settings_update.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(invites): team invite links — admin create/revoke, public accept

Admins mint single-use invite links (sha256-hashed token at rest,
optional email binding, configurable TTL). Public endpoints let the
accept page validate the link and register — working even while
public signup is disabled.

- migration + sqlc queries/model for user_invites
- POST /admin/invites (returns one-time token+url), GET list, DELETE revoke
- GET /auth/invites/:token public validation, POST /auth/accept-invite
- CLI containr invites list|create|revoke; MCP invites tools
- /auth/accept-invite page (expired/used/invalid states, bound-email lock)
- Admin page Team invites section: create, copy link, revoke
- OpenAPI + generated types + CLI guide

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(admin): impersonate user — 15-min token, audit-logged

POST /admin/users/:id/impersonate mints a short-lived bearer token
(impersonated_by claim) for support/debugging. Admin accounts cannot
be impersonated; every mint is audit-logged with actor + target.

- CLI: containr admin impersonate <user-id> (works via CONTAINR_TOKEN)
- MCP: containr_admin_impersonate
- Admin UI: per-user Impersonate action → token modal with usage hint
- OpenAPI + generated types

Verified live: token resolves to target, admin target 403, audit row.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(git): GitHub App manifest self-provisioning + app-level webhook

Admins create the instance GitHub App in-browser instead of hand-
setting GITHUB_APP_* env vars:

- POST /admin/git/github-app/manifest builds the manifest + URL;
  the frontend auto-submits it to github.com
- /git/github-app/callback page exchanges the code via
  POST /admin/git/github-app/convert; credentials persist in
  app_settings (precedence over env)
- GET /admin/git/github-app reports configured/source
- POST /api/git/github-app/webhook — single app-level push receiver,
  HMAC-verified, dispatches via extracted dispatchPushToServices
- install-url + installation-token paths resolve settings→env
- CLI: containr admin github-app status|manifest|convert; MCP tools
- Git providers UI shows "Set up GitHub App" for admins when
  unconfigured

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>
2026-10-09 10:43:12 +02:00
Tomáš DvořákandDevin 50e39fe140 feat(invites): team invite links — admin create/revoke, public accept (#42)
* feat(branding): white-label — public /branding, admin write, shell theming

app_settings branding.* keys (product name, logo/favicon URLs, accent
color, docs/support links). Public GET /branding so login and shell
theme pre-auth; admin writes via PUT /settings.

useBranding() applies document.title, accent CSS var and favicon;
BrandWordmark replaces hardcoded wordmarks; admin Platform settings
gain a Branding form. MCP containr_branding_get + settings_update.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(invites): team invite links — admin create/revoke, public accept

Admins mint single-use invite links (sha256-hashed token at rest,
optional email binding, configurable TTL). Public endpoints let the
accept page validate the link and register — working even while
public signup is disabled.

- migration + sqlc queries/model for user_invites
- POST /admin/invites (returns one-time token+url), GET list, DELETE revoke
- GET /auth/invites/:token public validation, POST /auth/accept-invite
- CLI containr invites list|create|revoke; MCP invites tools
- /auth/accept-invite page (expired/used/invalid states, bound-email lock)
- Admin page Team invites section: create, copy link, revoke
- OpenAPI + generated types + CLI guide

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>
2026-10-09 10:35:12 +02:00
Tomáš DvořákandDevin b47ced0135 feat(branding): white-label — public /branding, admin write, shell theming (#41)
app_settings branding.* keys (product name, logo/favicon URLs, accent
color, docs/support links). Public GET /branding so login and shell
theme pre-auth; admin writes via PUT /settings.

useBranding() applies document.title, accent CSS var and favicon;
BrandWordmark replaces hardcoded wordmarks; admin Platform settings
gain a Branding form. MCP containr_branding_get + settings_update.

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 10:22:58 +02:00
33 changed files with 2786 additions and 40 deletions

No files matched your search

+25 -10
View File
@@ -289,10 +289,12 @@ and wired refs, one click or one CLI call.
users; admin CRUD at `/admin/banners`. `BannerBar` renders above the
header (per-banner dismiss via localStorage); admin console manages
them; `containr banners` CLI + `auth status` surfaces active ones.
- [ ] **White-label** — `app_settings.branding`: product name, logos,
favicon, accent override, docs/support links. `GET /api/v1/branding`
public + cached. **Containr/Vertice remains default;** this is for
self-hosters who rebrand their instance.
- [x] **White-label** — `app_settings` `branding.*` keys: product name,
logo/favicon URL, accent color, docs/support links. Public
`GET /api/v1/branding` (defaults = Containr); admin writes via
`PUT /settings`. `useBranding()` applies title/accent/favicon;
`BrandWordmark` replaces hardcoded marks; admin Platform section
edits all fields. MCP `containr_branding_get` + `settings_update`.
- [x] **Operations page** — `GET /operations` aggregates active
deployments, in-memory deploy-queue depth (`Queue.Snapshot()`),
24h failures, cron runs and backups scoped to the caller's projects.
@@ -302,12 +304,25 @@ and wired refs, one click or one CLI call.
- [ ] **Interactive terminal** — upgrade exec to WS PTY (`xterm.js`);
`nodes/:id/terminal` via agent (dflow's wetty without the extra
container).
- [ ] **Team invites** — `POST /invites` → signed link → join flow;
replaces manual-only user creation.
- [ ] **Impersonate user** — admin support tool, audit-logged.
- [ ] **GitHub App manifest flow** — `POST /git/github-app/manifest`
returns manifest + redirect; instance self-provisions its App
(dflow pattern — removes the env-setup barrier).
- [x] **Team invites** — `POST /admin/invites` returns a one-time
link (sha256-hashed token at rest, optional email binding, TTL);
public `GET /auth/invites/:token` validates and
`POST /auth/accept-invite` registers — works while public signup is
closed. `/auth/accept-invite` page, admin manage/revoke UI,
`containr invites` CLI + MCP tools.
- [x] **Impersonate user** — `POST /admin/users/:id/impersonate`
mints a 15-minute bearer token (`impersonated_by` claim),
audit-logged; admin accounts are not impersonatable. CLI
`admin impersonate`, MCP tool, admin UI token modal. Remaining:
in-browser session swap needs a session bridge.
- [x] **GitHub App manifest flow** — `POST /admin/git/github-app/manifest`
returns manifest + submission URL; the callback page exchanges
`code` via `POST /admin/git/github-app/convert` and credentials
persist in `app_settings` (overriding `GITHUB_APP_*` env). New
public `POST /api/git/github-app/webhook` receives App-level push
events (HMAC-verified) and enqueues matching services. Settings→env
fallback wired into install-URL + installation-token paths.
`containr admin github-app` CLI + MCP + OpenAPI.
- [ ] **fork-sync trigger** — accept repo-sync events on webhook
receiver.
- [ ] **Dynamic filter framework** — port dflow's `filter.utils`
+51
View File
@@ -246,6 +246,57 @@ func handleAdminSetUserAdmin(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "User updated"})
}
// POST /admin/users/:id/impersonate — mints a 15-minute bearer token acting
// as the target user. Admin-only; admin accounts cannot be impersonated
// (would blur audit attribution). Every use is audit-logged at mint time;
// token carries `impersonated_by` for downstream attribution.
func handleAdminImpersonateUser(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
targetID := strings.TrimSpace(c.Param("id"))
if _, err := uuid.Parse(targetID); err != nil {
respondError(c, http.StatusBadRequest, "INVALID_ID", "invalid user id")
return
}
var email, name string
var isAdmin bool
err := db.QueryRowContext(c.Request.Context(),
`SELECT email, COALESCE(name, ''), is_admin FROM users WHERE id = $1`, targetID).
Scan(&email, &name, &isAdmin)
if err != nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "user not found")
return
}
if isAdmin {
respondError(c, http.StatusForbidden, "FORBIDDEN", "admin accounts cannot be impersonated")
return
}
actorID := optionalUserID(c)
if actorID == targetID {
respondError(c, http.StatusBadRequest, "VALIDATION", "cannot impersonate yourself")
return
}
expiresAt := time.Now().UTC().Add(15 * time.Minute)
jwtSecret := c.MustGet("jwt_secret").(string)
token, err := generateImpersonationJWT(targetID, email, actorID, jwtSecret, expiresAt)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to generate token")
return
}
LogAuditWithRequest(c, "user", targetID, "impersonate", map[string]interface{}{
"actor_id": actorID,
"target_email": email,
"expires_at": expiresAt.Format(time.RFC3339),
})
c.JSON(http.StatusOK, gin.H{
"token": token,
"expires_at": expiresAt.Format(time.RFC3339),
"user": gin.H{"id": targetID, "email": email, "name": name},
})
}
func handleAdminSetProjectApproval(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
+13
View File
@@ -546,6 +546,19 @@ func generateJWT(userID, email, secret string) (string, error) {
return token.SignedString([]byte(secret))
}
// generateImpersonationJWT mints a short-lived token acting as `userID`,
// tagged with the admin who minted it so downstream callers can attribute
// actions taken while impersonating.
func generateImpersonationJWT(userID, email, actorID, secret string, expiresAt time.Time) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": userID,
"email": email,
"impersonated_by": actorID,
"exp": expiresAt.Unix(),
})
return token.SignedString([]byte(secret))
}
// ValidateJWT validates a JWT token and returns the claims
func ValidateJWT(tokenString, secret string) (jwt.MapClaims, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
+5 -3
View File
@@ -133,7 +133,9 @@ func handleGetGitProviders(c *gin.Context) {
}
func handleGetGitHubAppInstallURL(c *gin.Context) {
slug := strings.TrimSpace(os.Getenv("GITHUB_APP_SLUG"))
db, _ := c.Get("db")
d, _ := db.(*database.DB)
slug := strings.TrimSpace(settingValue(d, settingGitHubAppSlug, "GITHUB_APP_SLUG", ""))
if slug == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "GITHUB_APP_SLUG is not configured"})
return
@@ -1073,8 +1075,8 @@ func fetchBitbucketRepositories(providerAPIURL, accessToken string) ([]remoteGit
}
func getGitHubAppInstallationToken(baseURL string, installationID int64) (string, error) {
appID := strings.TrimSpace(os.Getenv("GITHUB_APP_ID"))
privateKeyPEM := strings.TrimSpace(os.Getenv("GITHUB_APP_PRIVATE_KEY"))
appID := platformSetting(settingGitHubAppID, "GITHUB_APP_ID")
privateKeyPEM := platformSetting(settingGitHubAppPrivateKey, "GITHUB_APP_PRIVATE_KEY")
if appID == "" || privateKeyPEM == "" {
return "", fmt.Errorf("GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY must be configured")
}
+23 -13
View File
@@ -113,6 +113,26 @@ func handleGitWebhookPush(c *gin.Context) {
return
}
engineValue, _ := c.Get("deployment_engine")
engine, _ := engineValue.(*deployment.DeploymentEngine)
enqueued, err := dispatchPushToServices(c, db, engine, cloneURL, fullName, branch, commit, repoUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to match services"})
return
}
c.JSON(http.StatusAccepted, gin.H{
"received": true,
"branch": branch,
"deployments": enqueued,
})
}
// dispatchPushToServices enqueues a deployment for every service tracking
// the pushed repo+branch. Shared by the per-repo webhook receiver and the
// GitHub App webhook endpoint.
func dispatchPushToServices(c *gin.Context, db *database.DB, engine *deployment.DeploymentEngine, cloneURL, fullName, branch, commit, repoUserID string) (int, error) {
rows, err := db.Query(`
SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
@@ -121,8 +141,7 @@ func handleGitWebhookPush(c *gin.Context) {
WHERE s.git_branch = $1 AND (s.git_repo = $2 OR s.git_repo = $3)`,
branch, cloneURL, fullName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to match services"})
return
return 0, err
}
defer rows.Close()
@@ -134,15 +153,11 @@ func handleGitWebhookPush(c *gin.Context) {
&s.Environment, &s.GitRepo, &s.GitBranch, &s.BuildPath, &s.CPU, &s.Memory,
&s.CreatedAt, &s.UpdatedAt,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"})
return
return 0, err
}
services = append(services, s)
}
engineValue, _ := c.Get("deployment_engine")
engine, _ := engineValue.(*deployment.DeploymentEngine)
enqueued := 0
for _, service := range services {
now := time.Now()
@@ -199,12 +214,7 @@ func handleGitWebhookPush(c *gin.Context) {
}
enqueued++
}
c.JSON(http.StatusAccepted, gin.H{
"received": true,
"branch": branch,
"deployments": enqueued,
})
return enqueued, nil
}
// verifyGitWebhookSignature checks the push signature per provider:
+310
View File
@@ -0,0 +1,310 @@
package api
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"containr/internal/database"
"containr/internal/deployment"
"github.com/gin-gonic/gin"
)
// GitHub App self-provisioning (manifest flow): an admin POSTs the manifest
// to github.com/settings/apps/new, GitHub redirects back with `code`, and the
// code is exchanged at /app-manifests/{code}/conversions for real credentials
// stored in app_settings. Values stored there take precedence over the
// GITHUB_APP_* env vars, so the flow removes the manual env-setup barrier.
const (
settingGitHubAppID = "github_app.id"
settingGitHubAppSlug = "github_app.slug"
settingGitHubAppName = "github_app.name"
settingGitHubAppClientID = "github_app.client_id"
settingGitHubAppClientSecret = "github_app.client_secret"
settingGitHubAppPrivateKey = "github_app.private_key"
settingGitHubAppWebhookSecret = "github_app.webhook_secret"
)
// platformSetting resolves a setting row first, then env, then empty.
func platformSetting(key, envKey string) string {
return strings.TrimSpace(settingValue(GetAuditDB(), key, envKey, ""))
}
func githubAppConfigured(db *database.DB) bool {
return strings.TrimSpace(settingValue(db, settingGitHubAppID, "GITHUB_APP_ID", "")) != "" &&
strings.TrimSpace(settingValue(db, settingGitHubAppPrivateKey, "GITHUB_APP_PRIVATE_KEY", "")) != ""
}
// POST /admin/git/github-app/manifest — builds the GitHub App manifest the
// browser POSTs to github.com. Response carries the manifest as a JSON string
// (the form submits it verbatim) plus the destination URL.
func handleAdminGitHubAppManifest(c *gin.Context) {
var req struct {
BaseURL string `json:"base_url" binding:"required"`
Name string `json:"name"`
Organization string `json:"organization"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "INVALID_BODY", "base_url is required")
return
}
base := strings.TrimSuffix(strings.TrimSpace(req.BaseURL), "/")
if !strings.HasPrefix(base, "https://") &&
!strings.HasPrefix(base, "http://localhost") &&
!strings.HasPrefix(base, "http://127.0.0.1") {
respondError(c, http.StatusBadRequest, "VALIDATION", "base_url must be an https URL (localhost http allowed)")
return
}
stateBuf := make([]byte, 16)
if _, err := rand.Read(stateBuf); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to generate state")
return
}
state := hex.EncodeToString(stateBuf)
name := strings.TrimSpace(req.Name)
if name == "" {
db, _ := c.Get("db")
if d, ok := db.(*database.DB); ok {
name = strings.TrimSpace(settingValue(d, settingBrandProductName, "BRAND_NAME", ""))
}
if name == "" {
name = "Containr"
}
name = fmt.Sprintf("%s-%s", name, time.Now().UTC().Format("2006-01-02"))
}
callbackURL := base + "/git/github-app/callback"
manifest, err := json.Marshal(map[string]interface{}{
"name": name,
"url": base,
"hook_attributes": map[string]interface{}{
"url": base + "/api/git/github-app/webhook",
"active": true,
},
"redirect_url": callbackURL,
"callback_urls": []string{callbackURL},
"public": false,
"default_permissions": map[string]string{
"contents": "read",
"metadata": "read",
"pull_requests": "write",
},
"default_events": []string{"push", "pull_request"},
})
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to build manifest")
return
}
org := strings.TrimSpace(req.Organization)
createURL := fmt.Sprintf("https://github.com/settings/apps/new?state=%s", state)
if org != "" {
createURL = fmt.Sprintf("https://github.com/organizations/%s/settings/apps/new?state=%s", org, state)
}
c.JSON(http.StatusOK, gin.H{
"url": createURL,
"manifest": string(manifest),
"state": state,
})
}
// POST /admin/git/github-app/convert — exchanges the `code` GitHub appends to
// the manifest redirect for the real app credentials, then stores them.
func handleAdminGitHubAppConvert(c *gin.Context) {
var req struct {
Code string `json:"code" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "INVALID_BODY", "code is required")
return
}
apiBase := strings.TrimSuffix(strings.TrimSpace(os.Getenv("GITHUB_APP_BASE_URL")), "/")
if apiBase == "" {
apiBase = "https://api.github.com"
}
request, err := http.NewRequest(http.MethodPost,
fmt.Sprintf("%s/app-manifests/%s/conversions", apiBase, strings.TrimSpace(req.Code)), nil)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to build conversion request")
return
}
request.Header.Set("Accept", "application/vnd.github+json")
request.Header.Set("X-GitHub-Api-Version", "2022-11-28")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(request)
if err != nil {
respondError(c, http.StatusBadGateway, "UPSTREAM", "failed to reach GitHub")
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
respondError(c, http.StatusBadGateway, "UPSTREAM",
fmt.Sprintf("GitHub conversion failed (%d)", resp.StatusCode))
return
}
var app struct {
ID int64 `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
PEM string `json:"pem"`
WebhookSecret string `json:"webhook_secret"`
}
if err := json.Unmarshal(body, &app); err != nil || app.ID == 0 {
respondError(c, http.StatusBadGateway, "UPSTREAM", "invalid conversion response")
return
}
db := c.MustGet("db").(*database.DB)
for _, kv := range []struct {
key string
value string
secret bool
}{
{settingGitHubAppID, fmt.Sprintf("%d", app.ID), false},
{settingGitHubAppSlug, app.Slug, false},
{settingGitHubAppName, app.Name, false},
{settingGitHubAppClientID, app.ClientID, false},
{settingGitHubAppClientSecret, app.ClientSecret, true},
{settingGitHubAppPrivateKey, app.PEM, true},
{settingGitHubAppWebhookSecret, app.WebhookSecret, true},
} {
if err := setSetting(db, kv.key, kv.value, kv.secret); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to store app credentials")
return
}
}
LogAuditWithRequest(c, "github_app", fmt.Sprintf("%d", app.ID), "provision",
map[string]interface{}{"name": app.Name, "slug": app.Slug})
c.JSON(http.StatusOK, gin.H{
"app_id": app.ID,
"slug": app.Slug,
"name": app.Name,
})
}
// GET /admin/git/github-app — provisioning status for the settings UI.
func handleAdminGetGitHubApp(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
var stored string
source := "none"
if err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, settingGitHubAppID).Scan(&stored); err == nil && strings.TrimSpace(stored) != "" {
source = "settings"
} else if strings.TrimSpace(os.Getenv("GITHUB_APP_ID")) != "" {
source = "env"
}
c.JSON(http.StatusOK, gin.H{
"configured": githubAppConfigured(db),
"source": source,
"app_id": settingValue(db, settingGitHubAppID, "GITHUB_APP_ID", ""),
"slug": settingValue(db, settingGitHubAppSlug, "GITHUB_APP_SLUG", ""),
"name": settingValue(db, settingGitHubAppName, "", ""),
})
}
// POST /api/git/github-app/webhook — the single App-level webhook all
// installed repos deliver to. Verified against the provisioned webhook
// secret, then dispatched to matching services.
func handleGitHubAppWebhook(c *gin.Context) {
dbValue, exists := c.Get("db")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
return
}
db := dbValue.(*database.DB)
secret := strings.TrimSpace(settingValue(db, settingGitHubAppWebhookSecret, "GITHUB_APP_WEBHOOK_SECRET", ""))
if secret == "" {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "GitHub App webhook not configured"})
return
}
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 4<<20))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read payload"})
return
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := mac.Sum(nil)
sig := strings.TrimPrefix(c.GetHeader("X-Hub-Signature-256"), "sha256=")
decoded, err := hex.DecodeString(sig)
if err != nil || len(decoded) == 0 || subtle.ConstantTimeCompare(decoded, expected) != 1 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid signature"})
return
}
event := c.GetHeader("X-GitHub-Event")
if event != "push" {
c.JSON(http.StatusAccepted, gin.H{"received": true, "ignored": "event " + event})
return
}
var payload struct {
Ref string `json:"ref"`
After string `json:"after"`
Repository struct {
FullName string `json:"full_name"`
CloneURL string `json:"clone_url"`
} `json:"repository"`
HeadCommit struct {
ID string `json:"id"`
} `json:"head_commit"`
}
if err := json.Unmarshal(body, &payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
if !strings.HasPrefix(payload.Ref, "refs/heads/") {
c.JSON(http.StatusAccepted, gin.H{"received": true, "ignored": "not a branch push"})
return
}
branch := strings.TrimPrefix(payload.Ref, "refs/heads/")
commit := payload.HeadCommit.ID
if commit == "" {
commit = payload.After
}
cloneURL := payload.Repository.CloneURL
fullName := payload.Repository.FullName
if cloneURL == "" {
cloneURL = fmt.Sprintf("https://github.com/%s.git", fullName)
}
// Services tracking an App-connected repo store either the clone URL or
// owner/name — match both like the per-repo receiver does.
engineValue, _ := c.Get("deployment_engine")
engine, _ := engineValue.(*deployment.DeploymentEngine)
enqueued, err := dispatchPushToServices(c, db, engine, cloneURL, fullName, branch, commit, "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to match services"})
return
}
c.JSON(http.StatusAccepted, gin.H{
"received": true,
"branch": branch,
"deployments": enqueued,
})
}
+245
View File
@@ -0,0 +1,245 @@
package api
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"net/http"
"strings"
"time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
// Team invites: an admin creates a single-use link; the invitee registers
// through it even while public signup is closed. Tokens are random 32-byte
// values shared once — only their sha256 hash is stored.
type inviteJSON struct {
ID string `json:"id"`
Email *string `json:"email,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
UsedAt *time.Time `json:"used_at,omitempty"`
CreatedAt *time.Time `json:"created_at,omitempty"`
}
func toInviteJSON(i sqlcdb.UserInvite) inviteJSON {
out := inviteJSON{ID: i.ID.String(), ExpiresAt: i.ExpiresAt}
if i.Email.Valid && i.Email.String != "" {
out.Email = &i.Email.String
}
if i.UsedAt.Valid {
out.UsedAt = &i.UsedAt.Time
}
if i.CreatedAt.Valid {
out.CreatedAt = &i.CreatedAt.Time
}
return out
}
func inviteTokenHash(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func generateInviteToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
// POST /admin/invites — {email?, expires_in_hours?} → {invite, token, url}
func handleAdminCreateInvite(c *gin.Context) {
var req struct {
Email string `json:"email"`
ExpiresInHours *int `json:"expires_in_hours"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "INVALID_BODY", "invalid request body")
return
}
ttl := 24 * 7
if req.ExpiresInHours != nil {
if *req.ExpiresInHours < 1 || *req.ExpiresInHours > 24*90 {
respondError(c, http.StatusBadRequest, "VALIDATION", "expires_in_hours must be 1–2160")
return
}
ttl = *req.ExpiresInHours
}
token, err := generateInviteToken()
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to generate token")
return
}
userID, _ := requireAuthenticatedUserUUID(c)
params := sqlcdb.CreateUserInviteParams{
TokenHash: inviteTokenHash(token),
CreatedBy: uuid.NullUUID{UUID: userID, Valid: true},
ExpiresAt: time.Now().UTC().Add(time.Duration(ttl) * time.Hour),
}
if email := strings.ToLower(strings.TrimSpace(req.Email)); email != "" {
params.Email = sql.NullString{String: email, Valid: true}
}
db := c.MustGet("db").(*database.DB)
invite, err := sqlcdb.New(db.DB).CreateUserInvite(c.Request.Context(), params)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to create invite")
return
}
c.JSON(http.StatusCreated, gin.H{
"invite": toInviteJSON(invite),
"token": token,
"url": "/auth/accept-invite?token=" + token,
})
}
// GET /admin/invites
func handleAdminListInvites(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
invites, err := sqlcdb.New(db.DB).ListUserInvites(c.Request.Context())
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to list invites")
return
}
out := make([]inviteJSON, 0, len(invites))
for _, i := range invites {
out = append(out, toInviteJSON(i))
}
c.JSON(http.StatusOK, gin.H{"invites": out})
}
// DELETE /admin/invites/:id — revoke an unused invite.
func handleAdminDeleteInvite(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
respondError(c, http.StatusBadRequest, "INVALID_ID", "invalid invite id")
return
}
db := c.MustGet("db").(*database.DB)
if err := sqlcdb.New(db.DB).DeleteUserInvite(c.Request.Context(), id); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to delete invite")
return
}
c.Status(http.StatusNoContent)
}
// GET /auth/invites/:token — public. Lets the accept page validate the link
// and pre-fill a bound email before the user commits to registering.
func handleGetInvite(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
invite, err := sqlcdb.New(db.DB).GetUserInviteByTokenHash(c.Request.Context(), inviteTokenHash(c.Param("token")))
if err != nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "invite not found")
return
}
expired := time.Now().After(invite.ExpiresAt)
var email *string
if invite.Email.Valid && invite.Email.String != "" {
email = &invite.Email.String
}
c.JSON(http.StatusOK, gin.H{
"valid": !invite.UsedAt.Valid && !expired,
"used": invite.UsedAt.Valid,
"expired": expired,
"email": email,
"expires_at": invite.ExpiresAt,
})
}
// POST /auth/accept-invite — {token, name, email, password}
func handleAcceptInvite(c *gin.Context) {
var req struct {
Token string `json:"token" binding:"required"`
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
respondError(c, http.StatusBadRequest, "INVALID_BODY", "token, name, email and password are required")
return
}
if len(req.Password) < 8 {
respondError(c, http.StatusBadRequest, "VALIDATION", "password must be at least 8 characters")
return
}
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
db := c.MustGet("db").(*database.DB)
q := sqlcdb.New(db.DB)
invite, err := q.GetUserInviteByTokenHash(c.Request.Context(), inviteTokenHash(req.Token))
if err != nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "invite not found")
return
}
if invite.UsedAt.Valid {
respondError(c, http.StatusGone, "INVITE_USED", "invite already used")
return
}
if time.Now().After(invite.ExpiresAt) {
respondError(c, http.StatusGone, "INVITE_EXPIRED", "invite expired")
return
}
if invite.Email.Valid && invite.Email.String != "" && invite.Email.String != req.Email {
respondError(c, http.StatusForbidden, "INVITE_EMAIL_MISMATCH", "invite is bound to a different email")
return
}
var existing int
if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE email = $1`, req.Email).Scan(&existing); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "database error")
return
}
if existing > 0 {
respondError(c, http.StatusConflict, "CONFLICT", "user already exists")
return
}
if err := createBetterAuthUser(c, strings.TrimSpace(req.Name), req.Email, req.Password); err != nil && !errors.Is(err, errBetterAuthUserExists) {
respondError(c, http.StatusInternalServerError, "INTERNAL", err.Error())
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to hash password")
return
}
var user User
err = db.QueryRow(`
INSERT INTO users (email, password_hash, name)
VALUES ($1, $2, $3)
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
`, req.Email, string(hashedPassword), strings.TrimSpace(req.Name)).
Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
if err != nil {
respondError(c, http.StatusConflict, "CONFLICT", "user already exists")
return
}
userUUID, _ := uuid.Parse(user.ID)
if err := q.MarkUserInviteUsed(c.Request.Context(), sqlcdb.MarkUserInviteUsedParams{
ID: invite.ID,
UsedBy: uuid.NullUUID{UUID: userUUID, Valid: userUUID != uuid.Nil},
}); err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to consume invite")
return
}
jwtSecret := c.MustGet("jwt_secret").(string)
token, err := generateJWT(user.ID, user.Email, jwtSecret)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to generate token")
return
}
c.JSON(http.StatusCreated, AuthResponse{Token: token, User: user})
}
+21
View File
@@ -249,6 +249,10 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// replaces session auth. Providers POST to /api/git/webhooks/:id.
router.POST("/api/git/webhooks/:id", handleGitWebhookPush)
// GitHub App-level webhook: installed repos deliver to this single
// endpoint, verified against the provisioned webhook secret.
router.POST("/api/git/github-app/webhook", handleGitHubAppWebhook)
v1 := router.Group("/api/v1")
{
// Public routes (no authentication required)
@@ -258,6 +262,12 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
public.POST("/auth/login", handleLogin)
public.POST("/auth/register", handleRegister)
public.GET("/maintenance", handleMaintenancePage)
public.GET("/branding", handleGetBranding)
// Invite accept flow — validates the link, then registers
// even while public signup is closed.
public.GET("/auth/invites/:token", handleGetInvite)
public.POST("/auth/accept-invite", handleAcceptInvite)
// Wake callback for sleep-mode placeholders. Unauthenticated but
// non-destructive: it only reconciles the service back to running.
@@ -471,6 +481,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
admin.GET("/admin/overview", handleAdminOverview)
admin.GET("/admin/users", handleAdminListUsers)
admin.PATCH("/admin/users/:id", handleAdminSetUserAdmin)
admin.POST("/admin/users/:id/impersonate", handleAdminImpersonateUser)
admin.PATCH("/admin/projects/:id", handleAdminSetProjectApproval)
admin.POST("/users", handleCreateUser)
@@ -494,6 +505,16 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
admin.POST("/admin/banners", handleAdminCreateBanner)
admin.PATCH("/admin/banners/:id", handleAdminUpdateBanner)
admin.DELETE("/admin/banners/:id", handleAdminDeleteBanner)
// GitHub App self-provisioning (manifest flow)
admin.GET("/admin/git/github-app", handleAdminGetGitHubApp)
admin.POST("/admin/git/github-app/manifest", handleAdminGitHubAppManifest)
admin.POST("/admin/git/github-app/convert", handleAdminGitHubAppConvert)
// Team invites — single-use registration links
admin.GET("/admin/invites", handleAdminListInvites)
admin.POST("/admin/invites", handleAdminCreateInvite)
admin.DELETE("/admin/invites/:id", handleAdminDeleteInvite)
admin.GET("/audit-logs", handleGetAuditLogs)
admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
+78
View File
@@ -23,10 +23,49 @@ const (
settingSignupEnabled = "signup_enabled"
settingCloudflareTunnelToken = "cloudflare_tunnel_token"
// White-label branding keys — unset fields fall back to Containr
// defaults. Self-hosters rebrand their instance via these.
settingBrandProductName = "branding.product_name"
settingBrandLogoURL = "branding.logo_url"
settingBrandFaviconURL = "branding.favicon_url"
settingBrandAccent = "branding.accent_color"
settingBrandDocsURL = "branding.docs_url"
settingBrandSupportURL = "branding.support_url"
cloudflaredContainerName = "containr-cloudflared"
cloudflaredImage = "cloudflare/cloudflared:latest"
)
// brandingConfig is the public white-label payload. Fields are always
// populated — defaults keep the instance Containr-branded.
type brandingConfig struct {
ProductName string `json:"product_name"`
LogoURL string `json:"logo_url"`
FaviconURL string `json:"favicon_url"`
AccentColor string `json:"accent_color"`
DocsURL string `json:"docs_url"`
SupportURL string `json:"support_url"`
}
func resolveBranding(db *database.DB) brandingConfig {
return brandingConfig{
ProductName: settingValue(db, settingBrandProductName, "BRAND_NAME", "Containr"),
LogoURL: settingValue(db, settingBrandLogoURL, "BRAND_LOGO_URL", ""),
FaviconURL: settingValue(db, settingBrandFaviconURL, "BRAND_FAVICON_URL", ""),
AccentColor: settingValue(db, settingBrandAccent, "BRAND_ACCENT_COLOR", ""),
DocsURL: settingValue(db, settingBrandDocsURL, "BRAND_DOCS_URL", ""),
SupportURL: settingValue(db, settingBrandSupportURL, "BRAND_SUPPORT_URL", ""),
}
}
// GET /branding — public so the login page and shell can brand themselves
// before a session exists.
func handleGetBranding(c *gin.Context) {
db, _ := c.Get("db")
database, _ := db.(*database.DB)
c.JSON(http.StatusOK, resolveBranding(database))
}
// 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 {
@@ -110,6 +149,7 @@ func handleGetSettings(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"signup_enabled": signupEnabled(db),
"cloudflare_tunnel": cloudflareTunnelState(c.Request.Context(), db, client),
"branding": resolveBranding(db),
})
}
@@ -122,6 +162,14 @@ func handleUpdateSettings(c *gin.Context) {
var req struct {
SignupEnabled *bool `json:"signup_enabled"`
CloudflareTunnelToken *string `json:"cloudflare_tunnel_token"`
Branding *struct {
ProductName *string `json:"product_name"`
LogoURL *string `json:"logo_url"`
FaviconURL *string `json:"favicon_url"`
AccentColor *string `json:"accent_color"`
DocsURL *string `json:"docs_url"`
SupportURL *string `json:"support_url"`
} `json:"branding"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -158,6 +206,36 @@ func handleUpdateSettings(c *gin.Context) {
}
}
if req.Branding != nil {
fields := []struct {
key string
value *string
}{
{settingBrandProductName, req.Branding.ProductName},
{settingBrandLogoURL, req.Branding.LogoURL},
{settingBrandFaviconURL, req.Branding.FaviconURL},
{settingBrandAccent, req.Branding.AccentColor},
{settingBrandDocsURL, req.Branding.DocsURL},
{settingBrandSupportURL, req.Branding.SupportURL},
}
for _, f := range fields {
if f.value == nil {
continue
}
v := strings.TrimSpace(*f.value)
var err error
if v == "" {
err = deleteSetting(db, f.key)
} else {
err = setSetting(db, f.key, v, false)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save branding"})
return
}
}
}
handleGetSettings(c)
}
+85 -1
View File
@@ -56,6 +56,84 @@ var adminUsersCmd = &cobra.Command{
},
}
var adminImpersonateCmd = &cobra.Command{
Use: "impersonate <user-id>",
Short: "Mint a 15-minute token acting as a user (audit-logged)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/admin/users/"+args[0]+"/impersonate", map[string]interface{}{})
if err != nil {
return err
}
PrintRaw(data)
fmt.Fprintln(cmd.ErrOrStderr(), "\nUse it with: CONTAINR_TOKEN=<token> containr <command>")
return nil
},
}
var adminGitHubAppCmd = &cobra.Command{
Use: "github-app",
Short: "GitHub App self-provisioning",
}
var adminGitHubAppStatusCmd = &cobra.Command{
Use: "status",
Short: "Show GitHub App provisioning status",
RunE: simpleGet("/admin/git/github-app"),
}
var adminGitHubAppManifestCmd = &cobra.Command{
Use: "manifest",
Short: "Build the manifest to create the instance GitHub App",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
req := map[string]interface{}{"base_url": ghAppBaseURL}
if ghAppOrg != "" {
req["organization"] = ghAppOrg
}
if ghAppName != "" {
req["name"] = ghAppName
}
data, err := c.Do("POST", "/admin/git/github-app/manifest", req)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var adminGitHubAppConvertCmd = &cobra.Command{
Use: "convert <code>",
Short: "Exchange the manifest callback code for app credentials",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("POST", "/admin/git/github-app/convert", map[string]interface{}{"code": args[0]})
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var (
ghAppBaseURL string
ghAppOrg string
ghAppName string
)
var adminSettingsCmd = &cobra.Command{
Use: "settings",
Short: "Show platform settings",
@@ -353,7 +431,13 @@ func init() {
securityScanCmd.Flags().String("type", "configuration", "dependency|configuration|comprehensive")
securityScanCmd.Flags().String("service", "", "limit scan to one service")
AdminCmd.AddCommand(adminOverviewCmd, adminUsersCmd, adminSettingsCmd, adminAuditCmd)
adminGitHubAppManifestCmd.Flags().StringVar(&ghAppBaseURL, "base-url", "", "public base URL of this instance (required)")
adminGitHubAppManifestCmd.Flags().StringVar(&ghAppOrg, "org", "", "create the app under this organization")
adminGitHubAppManifestCmd.Flags().StringVar(&ghAppName, "name", "", "app name (defaults to product name + date)")
adminGitHubAppManifestCmd.MarkFlagRequired("base-url")
adminGitHubAppCmd.AddCommand(adminGitHubAppStatusCmd, adminGitHubAppManifestCmd, adminGitHubAppConvertCmd)
AdminCmd.AddCommand(adminOverviewCmd, adminUsersCmd, adminSettingsCmd, adminAuditCmd, adminImpersonateCmd, adminGitHubAppCmd)
GatewayCmd.AddCommand(gatewayServicesCmd, gatewayKeysCmd, gatewayAnalyticsCmd)
HACmd.AddCommand(haStatusCmd, haAlertsCmd, haHealthCmd, haPoliciesCmd, haEnableCmd, haDisableCmd, haFailoverCmd)
ScalingCmd.AddCommand(scalingStatusCmd, scalingPoliciesCmd, scalingServicesCmd, scalingScaleCmd)
@@ -0,0 +1,92 @@
package commands
import (
"github.com/spf13/cobra"
)
var inviteEmail string
var inviteTTL int
// InvitesCmd manages team invite links (admin).
var InvitesCmd = &cobra.Command{
Use: "invites",
Short: "Manage team invite links (admin)",
}
var inviteListCmd = &cobra.Command{
Use: "list",
Short: "List invites",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
data, err := c.Do("GET", "/admin/invites", nil)
if err != nil {
return err
}
items, err := unwrapList(data, "invites")
if err != nil {
return err
}
rows := make([][]string, 0, len(items))
for _, i := range items {
state := "open"
if _, used := i["used_at"]; used && i["used_at"] != nil {
state = "used"
}
rows = append(rows, []string{
str(i, "id"), str(i, "email"), str(i, "expires_at"), state,
})
}
printRows(data, []string{"ID", "EMAIL", "EXPIRES", "STATE"}, rows)
return nil
},
}
var inviteCreateCmd = &cobra.Command{
Use: "create",
Short: "Create an invite link (--email to bind, --hours for TTL)",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client()
if err != nil {
return err
}
req := map[string]interface{}{}
if cmd.Flags().Changed("email") {
req["email"] = inviteEmail
}
if cmd.Flags().Changed("hours") {
req["expires_in_hours"] = inviteTTL
}
data, err := c.Do("POST", "/admin/invites", req)
if err != nil {
return err
}
PrintRaw(data)
return nil
},
}
var inviteRevokeCmd = &cobra.Command{
Use: "revoke <id>",
Short: "Revoke an invite",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !Confirm("Revoke invite " + args[0] + "?") {
return &APIError{Message: "aborted (pass --yes to skip confirmation)", ExitCode: ExitError}
}
c, err := client()
if err != nil {
return err
}
_, err = c.Do("DELETE", "/admin/invites/"+args[0], nil)
return err
},
}
func init() {
inviteCreateCmd.Flags().StringVar(&inviteEmail, "email", "", "bind invite to this email")
inviteCreateCmd.Flags().IntVar(&inviteTTL, "hours", 168, "invite lifetime in hours (1-2160)")
InvitesCmd.AddCommand(inviteListCmd, inviteCreateCmd, inviteRevokeCmd)
}
+1
View File
@@ -90,6 +90,7 @@ func init() {
rootCmd.AddCommand(commands.WebhooksCmd)
rootCmd.AddCommand(commands.OperationsCmd)
rootCmd.AddCommand(commands.BannersCmd)
rootCmd.AddCommand(commands.InvitesCmd)
rootCmd.AddCommand(commands.UpCmd)
}
@@ -0,0 +1,134 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: invites.sql
package sqlcdb
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
)
const createUserInvite = `-- name: CreateUserInvite :one
INSERT INTO user_invites (token_hash, email, created_by, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING user_invites.id, user_invites.token_hash, user_invites.email, user_invites.created_by, user_invites.expires_at, user_invites.used_by, user_invites.used_at, user_invites.created_at
`
type CreateUserInviteParams struct {
TokenHash string `json:"token_hash"`
Email sql.NullString `json:"email"`
CreatedBy uuid.NullUUID `json:"created_by"`
ExpiresAt time.Time `json:"expires_at"`
}
func (q *Queries) CreateUserInvite(ctx context.Context, arg CreateUserInviteParams) (UserInvite, error) {
row := q.db.QueryRowContext(ctx, createUserInvite,
arg.TokenHash,
arg.Email,
arg.CreatedBy,
arg.ExpiresAt,
)
var i UserInvite
err := row.Scan(
&i.ID,
&i.TokenHash,
&i.Email,
&i.CreatedBy,
&i.ExpiresAt,
&i.UsedBy,
&i.UsedAt,
&i.CreatedAt,
)
return i, err
}
const deleteUserInvite = `-- name: DeleteUserInvite :exec
DELETE FROM user_invites
WHERE id = $1
`
func (q *Queries) DeleteUserInvite(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteUserInvite, id)
return err
}
const getUserInviteByTokenHash = `-- name: GetUserInviteByTokenHash :one
SELECT user_invites.id, user_invites.token_hash, user_invites.email, user_invites.created_by, user_invites.expires_at, user_invites.used_by, user_invites.used_at, user_invites.created_at
FROM user_invites
WHERE token_hash = $1
`
func (q *Queries) GetUserInviteByTokenHash(ctx context.Context, tokenHash string) (UserInvite, error) {
row := q.db.QueryRowContext(ctx, getUserInviteByTokenHash, tokenHash)
var i UserInvite
err := row.Scan(
&i.ID,
&i.TokenHash,
&i.Email,
&i.CreatedBy,
&i.ExpiresAt,
&i.UsedBy,
&i.UsedAt,
&i.CreatedAt,
)
return i, err
}
const listUserInvites = `-- name: ListUserInvites :many
SELECT user_invites.id, user_invites.token_hash, user_invites.email, user_invites.created_by, user_invites.expires_at, user_invites.used_by, user_invites.used_at, user_invites.created_at
FROM user_invites
ORDER BY created_at DESC
`
func (q *Queries) ListUserInvites(ctx context.Context) ([]UserInvite, error) {
rows, err := q.db.QueryContext(ctx, listUserInvites)
if err != nil {
return nil, err
}
defer rows.Close()
items := []UserInvite{}
for rows.Next() {
var i UserInvite
if err := rows.Scan(
&i.ID,
&i.TokenHash,
&i.Email,
&i.CreatedBy,
&i.ExpiresAt,
&i.UsedBy,
&i.UsedAt,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markUserInviteUsed = `-- name: MarkUserInviteUsed :exec
UPDATE user_invites
SET used_by = $2, used_at = now()
WHERE id = $1 AND used_at IS NULL
`
type MarkUserInviteUsedParams struct {
ID uuid.UUID `json:"id"`
UsedBy uuid.NullUUID `json:"used_by"`
}
func (q *Queries) MarkUserInviteUsed(ctx context.Context, arg MarkUserInviteUsedParams) error {
_, err := q.db.ExecContext(ctx, markUserInviteUsed, arg.ID, arg.UsedBy)
return err
}
@@ -301,6 +301,17 @@ type ServiceTemplate struct {
UpdatedAt sql.NullTime `json:"updated_at"`
}
type UserInvite struct {
ID uuid.UUID `json:"id"`
TokenHash string `json:"token_hash"`
Email sql.NullString `json:"email"`
CreatedBy uuid.NullUUID `json:"created_by"`
ExpiresAt time.Time `json:"expires_at"`
UsedBy uuid.NullUUID `json:"used_by"`
UsedAt sql.NullTime `json:"used_at"`
CreatedAt sql.NullTime `json:"created_at"`
}
type UserToken struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
@@ -31,6 +31,7 @@ type Querier interface {
CreateOutboundWebhook(ctx context.Context, arg CreateOutboundWebhookParams) error
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
CreateUserInvite(ctx context.Context, arg CreateUserInviteParams) (UserInvite, error)
CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error
CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error)
CreateWebhookDelivery(ctx context.Context, arg CreateWebhookDeliveryParams) error
@@ -42,6 +43,7 @@ type Querier interface {
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
DeleteUserInvite(ctx context.Context, id uuid.UUID) error
DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error)
GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error)
GetAgent(ctx context.Context, id string) (NodeAgent, error)
@@ -63,6 +65,7 @@ type Querier interface {
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error)
GetUserInviteByTokenHash(ctx context.Context, tokenHash string) (UserInvite, error)
GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error)
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
@@ -90,10 +93,12 @@ type Querier interface {
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error)
ListUserInvites(ctx context.Context) ([]UserInvite, error)
ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error)
ListWebhookDeliveriesByWebhook(ctx context.Context, webhookID uuid.UUID) ([]WebhookDelivery, error)
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
MarkUserInviteUsed(ctx context.Context, arg MarkUserInviteUsedParams) error
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error
+11
View File
@@ -155,6 +155,17 @@ func init() {
register(spec{"containr_banners_update", "Update an announcement banner (admin). Args: id plus fields to change.", "PATCH", "/admin/banners/{id}", []string{"id"}, []string{"title", "body", "level", "active", "dismissible", "starts_at", "ends_at"}, false})
register(spec{"containr_banners_delete", "Delete an announcement banner (admin). Args: id. Requires confirm=true.", "DELETE", "/admin/banners/{id}", []string{"id"}, nil, true})
register(spec{"containr_branding_get", "Get instance white-label branding (product name, logo, accent, links).", "GET", "/branding", nil, nil, false})
register(spec{"containr_settings_update", "Update platform settings (admin). Args: signup_enabled (bool), cloudflare_tunnel_token (string, empty clears), branding (object: product_name, logo_url, favicon_url, accent_color, docs_url, support_url — empty string resets a field).", "PUT", "/settings", nil, []string{"signup_enabled", "cloudflare_tunnel_token", "branding"}, false})
register(spec{"containr_invites_list", "List team invite links (admin).", "GET", "/admin/invites", nil, nil, false})
register(spec{"containr_invites_create", "Create a team invite link (admin). Args: email (optional bind), expires_in_hours (default 168). Returns the one-time token + accept URL.", "POST", "/admin/invites", nil, []string{"email", "expires_in_hours"}, false})
register(spec{"containr_invites_revoke", "Revoke a team invite (admin). Args: id. Requires confirm=true.", "DELETE", "/admin/invites/{id}", []string{"id"}, nil, true})
register(spec{"containr_admin_impersonate", "Mint a 15-minute bearer token acting as a user (admin, audit-logged). Args: id (user id).", "POST", "/admin/users/{id}/impersonate", []string{"id"}, nil, false})
register(spec{"containr_github_app_status", "Show GitHub App provisioning status (admin).", "GET", "/admin/git/github-app", nil, nil, false})
register(spec{"containr_github_app_manifest", "Build a GitHub App manifest for self-provisioning (admin). Args: base_url (required, https public URL), name (optional), organization (optional).", "POST", "/admin/git/github-app/manifest", nil, []string{"base_url", "name", "organization"}, false})
register(spec{"containr_github_app_convert", "Exchange the GitHub manifest callback code for app credentials (admin). Args: code.", "POST", "/admin/git/github-app/convert", nil, []string{"code"}, false})
// Nodes / agent tokens (admin)
register(spec{"containr_nodes_list", "List node agents (admin)", "GET", "/agents", nil, nil, false})
register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false})
@@ -0,0 +1,19 @@
-- +goose Up
-- Team invites: admin-generated single-use links that let a new user
-- register even while public signup is closed. Tokens are sha256-hashed
-- at rest like PATs.
CREATE TABLE IF NOT EXISTS user_invites (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
token_hash varchar(64) NOT NULL UNIQUE,
email varchar(255),
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
expires_at timestamptz NOT NULL,
used_by uuid REFERENCES users(id) ON DELETE SET NULL,
used_at timestamptz,
created_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_user_invites_expires ON user_invites(expires_at);
-- +goose Down
DROP TABLE IF EXISTS user_invites;
+23
View File
@@ -0,0 +1,23 @@
-- name: CreateUserInvite :one
INSERT INTO user_invites (token_hash, email, created_by, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING user_invites.*;
-- name: GetUserInviteByTokenHash :one
SELECT user_invites.*
FROM user_invites
WHERE token_hash = $1;
-- name: ListUserInvites :many
SELECT user_invites.*
FROM user_invites
ORDER BY created_at DESC;
-- name: MarkUserInviteUsed :exec
UPDATE user_invites
SET used_by = $2, used_at = now()
WHERE id = $1 AND used_at IS NULL;
-- name: DeleteUserInvite :exec
DELETE FROM user_invites
WHERE id = $1;
+11
View File
@@ -316,3 +316,14 @@ CREATE TABLE banners (
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE user_invites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token_hash VARCHAR(64) NOT NULL UNIQUE,
email VARCHAR(255),
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
used_by UUID REFERENCES users(id) ON DELETE SET NULL,
used_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
+6 -1
View File
@@ -13,6 +13,7 @@ import { WebhooksPage } from '@/features/webhooks/pages/WebhooksPage';
import { OperationsPage } from '@/features/operations/pages/OperationsPage';
import { AdminPage } from '@/features/admin/pages/AdminPage';
import { LandingPage } from '@/features/landing/LandingPage';
import { useBranding } from '@/lib/use-branding';
import {
DocsPage,
PeoplePage,
@@ -22,8 +23,9 @@ import {
HighAvailabilityPage,
SecurityPage,
ComponentShowcase,
GitHubAppCallbackPage,
} from '@/features/secondary/pages';
import { SignInPage, SignUpPage } from '@/features/auth/pages';
import { AcceptInvitePage, SignInPage, SignUpPage } from '@/features/auth/pages';
import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { getCurrentUserProfile } from '@/lib/api-client';
@@ -126,12 +128,14 @@ function NotFoundPage() {
}
export default function App() {
useBranding();
return (
<ErrorBoundary>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/auth/sign-in" element={<SignInPage />} />
<Route path="/auth/sign-up" element={<SignUpPage />} />
<Route path="/auth/accept-invite" element={<AcceptInvitePage />} />
{/* Public browsing — approved projects, templates, docs. */}
{/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */}
@@ -153,6 +157,7 @@ export default function App() {
<Route path="/builds" element={<BuildsPage />} />
<Route path="/people" element={<PeoplePage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/git/github-app/callback" element={<GitHubAppCallbackPage />} />
<Route path="/settings/audit-logs" element={<AuditLogsPage />} />
<Route path="/settings/webhooks" element={<WebhooksPage />} />
<Route path="/showcase" element={<ComponentShowcase />} />
@@ -41,7 +41,7 @@ import {
import { signOutAuthSession } from '@/lib/auth-client';
import { isDemoSearch } from '@/lib/demo-mode';
import { useAuthSession } from '@/lib/use-auth-session';
import { BannerBar, useToast } from '@/shared/components';
import { BannerBar, BrandWordmark, useToast } from '@/shared/components';
type NavItem = { label: string; href: string; icon: typeof FolderKanban; hint: string };
type NavSection = { title: string; items: NavItem[] };
@@ -398,7 +398,7 @@ export function PlatformShell() {
}}
>
<NavLink to={href('/projects')} className="mr-5 text-sm font-extrabold tracking-tight text-[var(--text-primary)]" style={{ fontFamily: 'Inter, sans-serif' }}>
contain<span style={{ color: 'var(--accent-primary)' }}>r</span>
<BrandWordmark />
</NavLink>
<div className="flex items-center gap-5">
@@ -4,22 +4,29 @@ import { Link } from 'react-router-dom';
import {
CheckCircle2,
Clock,
Copy,
FolderKanban,
Megaphone,
Pencil,
ShieldCheck,
Trash2,
UserPlus,
Users,
XCircle,
} from 'lucide-react';
import {
createBanner,
createInvite,
deleteBanner,
deleteProject,
getAdminOverview,
impersonateUser,
type ImpersonationResult,
listAdminBanners,
listAdminUsers,
listInvites,
listProjects,
revokeInvite,
setProjectApproval,
setUserAdmin,
updateBanner,
@@ -57,9 +64,22 @@ export function AdminPage() {
const usersQuery = useQuery({ queryKey: ['admin-users'], queryFn: listAdminUsers, enabled: !isDemoMode });
const projectsQuery = useQuery({ queryKey: ['admin-projects'], queryFn: () => listProjects({ limit: 100 }), enabled: !isDemoMode });
const bannersQuery = useQuery({ queryKey: ['admin-banners'], queryFn: listAdminBanners, enabled: !isDemoMode });
const invitesQuery = useQuery({
queryKey: ['admin-invites'],
queryFn: listInvites,
enabled: !isDemoMode,
select: (list) =>
list.map((invite) => ({
...invite,
expired: !invite.used_at && new Date(invite.expires_at).getTime() < Date.now(),
})),
});
const [editingProject, setEditingProject] = useState<ProjectEntity | null>(null);
const [editForm, setEditForm] = useState({ name: '', description: '' });
const [bannerForm, setBannerForm] = useState({ title: '', body: '', level: 'info' as Banner['level'] });
const [inviteEmail, setInviteEmail] = useState('');
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [impersonation, setImpersonation] = useState<ImpersonationResult | null>(null);
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['admin-overview'] });
@@ -67,9 +87,35 @@ export function AdminPage() {
queryClient.invalidateQueries({ queryKey: ['admin-projects'] });
queryClient.invalidateQueries({ queryKey: ['admin-banners'] });
queryClient.invalidateQueries({ queryKey: ['active-banners'] });
queryClient.invalidateQueries({ queryKey: ['admin-invites'] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
};
const inviteCreateMutation = useMutation({
mutationFn: () => createInvite({ email: inviteEmail.trim() || undefined }),
onSuccess: (result) => {
setInviteLink(`${window.location.origin}${result.url}`);
setInviteEmail('');
invalidate();
},
onError: (error) => showToast('error', 'Invite failed', error instanceof Error ? error.message : undefined),
});
const inviteRevokeMutation = useMutation({
mutationFn: (id: string) => revokeInvite(id),
onSuccess: () => {
showToast('success', 'Invite revoked');
invalidate();
},
onError: (error) => showToast('error', 'Revoke failed', error instanceof Error ? error.message : undefined),
});
const impersonateMutation = useMutation({
mutationFn: (id: string) => impersonateUser(id),
onSuccess: (result) => setImpersonation(result),
onError: (error) => showToast('error', 'Impersonation failed', error instanceof Error ? error.message : undefined),
});
const bannerCreateMutation = useMutation({
mutationFn: () =>
createBanner({
@@ -167,6 +213,7 @@ export function AdminPage() {
const users = usersQuery.data ?? [];
const allProjects = projectsQuery.data ?? [];
const banners = bannersQuery.data ?? [];
const invites = invitesQuery.data ?? [];
return (
<div className="mx-auto max-w-6xl px-5 py-8">
@@ -378,6 +425,97 @@ export function AdminPage() {
</div>
</section>
<section className="mt-8">
<div className="mb-3 flex items-center gap-2">
<UserPlus size={15} className="text-[var(--accent-primary)]" />
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Team invites</h2>
</div>
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--border-subtle)] px-4 py-3">
<input
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="Bind to email (optional)"
type="email"
className="h-9 min-w-64 flex-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
/>
<button
type="button"
onClick={() => inviteCreateMutation.mutate()}
disabled={inviteCreateMutation.isPending}
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12px] font-semibold text-[var(--accent-on)] disabled:opacity-50"
>
{inviteCreateMutation.isPending ? 'Creating…' : 'Create invite link'}
</button>
</div>
{inviteLink ? (
<div className="flex items-center gap-2 border-b border-[var(--border-subtle)] bg-[var(--accent-primary-soft)] px-4 py-2.5">
<code className="min-w-0 flex-1 truncate text-[12px] text-[var(--accent-primary)]">{inviteLink}</code>
<button
type="button"
onClick={() => {
navigator.clipboard?.writeText(inviteLink);
showToast('success', 'Link copied');
}}
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-default)] px-2.5 py-1 text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
<Copy size={11} /> Copy
</button>
<button
type="button"
onClick={() => setInviteLink(null)}
className="text-[11px] text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]"
>
Dismiss
</button>
</div>
) : null}
{invites.map((invite) => {
const used = Boolean(invite.used_at);
const expired = !used && invite.expired;
return (
<div
key={invite.id}
className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0"
>
<div className="min-w-0 flex-1">
<p className="truncate text-[13.5px] font-medium text-[var(--text-primary)]">
{invite.email || 'Open invite'}
</p>
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">
expires {new Date(invite.expires_at).toLocaleString()}
</p>
</div>
<span
className={`rounded-full px-2.5 py-0.5 text-[10.5px] font-semibold ${
used
? 'bg-[var(--surface-muted)] text-[var(--text-tertiary)]'
: expired
? 'bg-[var(--error-soft)] text-[var(--error)]'
: 'bg-[var(--success-soft)] text-[var(--success)]'
}`}
>
{used ? 'used' : expired ? 'expired' : 'open'}
</span>
{!used ? (
<button
type="button"
onClick={() => inviteRevokeMutation.mutate(invite.id)}
disabled={inviteRevokeMutation.isPending}
className="rounded-[var(--radius-md)] border border-[var(--error)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] disabled:opacity-50"
>
Revoke
</button>
) : null}
</div>
);
})}
{invites.length === 0 ? (
<p className="px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">No invites yet.</p>
) : null}
</div>
</section>
<section className="mt-8 pb-10">
<div className="mb-3 flex items-center gap-2">
<Users size={15} className="text-[var(--accent-tertiary)]" />
@@ -391,6 +529,7 @@ export function AdminPage() {
isSelf={user.id === myId}
pending={adminMutation.isPending}
onToggle={(isAdmin) => adminMutation.mutate({ id: user.id, isAdmin })}
onImpersonate={user.is_admin || user.id === myId ? undefined : () => impersonateMutation.mutate(user.id)}
/>
))}
{users.length === 0 ? (
@@ -399,6 +538,52 @@ export function AdminPage() {
</div>
</section>
{impersonation ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="w-full max-w-lg overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
<div className="border-b border-[var(--border-subtle)] px-5 py-4">
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">
Impersonating {impersonation.user.email}
</h2>
<p className="text-xs text-[var(--text-tertiary)]">
Token valid until {new Date(impersonation.expires_at).toLocaleString()} — audit-logged.
</p>
</div>
<div className="space-y-3 px-5 py-4">
<div className="flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5">
<code className="min-w-0 flex-1 truncate text-[12px] text-[var(--text-primary)]">
{impersonation.token}
</code>
<button
type="button"
onClick={() => {
navigator.clipboard?.writeText(impersonation.token);
showToast('success', 'Token copied');
}}
className="inline-flex shrink-0 items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border-default)] px-2.5 py-1 text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
<Copy size={11} /> Copy
</button>
</div>
<p className="text-[12px] leading-relaxed text-[var(--text-tertiary)]">
Use it as a bearer token — e.g. <code className="text-[var(--text-secondary)]">CONTAINR_TOKEN=&lt;token&gt;
containr &lt;command&gt;</code> or <code className="text-[var(--text-secondary)]">Authorization: Bearer
&lt;token&gt;</code>. Requests run with that user's permissions for 15 minutes.
</p>
</div>
<div className="flex items-center justify-end gap-2 border-t border-[var(--border-subtle)] px-5 py-3.5">
<button
type="button"
onClick={() => setImpersonation(null)}
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-xs font-semibold text-[var(--accent-on)]"
>
Done
</button>
</div>
</div>
</div>
) : null}
{editingProject ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="w-full max-w-md overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--bg-base)] shadow-2xl">
@@ -454,11 +639,13 @@ function UserRow({
isSelf,
pending,
onToggle,
onImpersonate,
}: {
user: AdminUser;
isSelf: boolean;
pending: boolean;
onToggle: (isAdmin: boolean) => void;
onImpersonate?: () => void;
}) {
return (
<div className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0">
@@ -477,6 +664,15 @@ function UserRow({
<ShieldCheck size={11} /> admin
</span>
) : null}
{onImpersonate ? (
<button
type="button"
onClick={onImpersonate}
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
Impersonate
</button>
) : null}
<button
type="button"
onClick={() => onToggle(!user.is_admin)}
+134
View File
@@ -11,6 +11,7 @@ import {
signUpWithEmail,
startGitHubSignIn,
} from '@/lib/auth-client';
import { acceptInvite, getInvite } from '@/lib/api-client';
import { useAuthSession } from '@/lib/use-auth-session';
function sanitizeRedirect(raw: string | null): string {
@@ -258,6 +259,139 @@ export function SignInPage() {
);
}
export function AcceptInvitePage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [searchParams] = useSearchParams();
const token = searchParams.get('token') ?? '';
const sessionQuery = useAuthSession();
const inviteQuery = useQuery({
queryKey: ['invite', token],
queryFn: () => getInvite(token),
enabled: token.length > 0,
retry: false,
});
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
if (sessionQuery.data) {
return <Navigate to="/projects" replace />;
}
const boundEmail = inviteQuery.data?.email ?? undefined;
const effectiveEmail = boundEmail ?? email;
const inviteValid = inviteQuery.data?.valid === true;
const submit = async (event: FormEvent) => {
event.preventDefault();
setError(null);
setIsSubmitting(true);
try {
await acceptInvite({ token, name: name.trim(), email: effectiveEmail.trim(), password });
// Establish the cookie session — the accept endpoint creates the
// account, sign-in creates the browser session.
await signInWithEmail(effectiveEmail.trim(), password);
await queryClient.invalidateQueries({ queryKey: ['auth-session'] });
navigate('/projects', { replace: true });
} catch (exception) {
setError(exception instanceof Error ? exception.message : 'Failed to accept invite');
} finally {
setIsSubmitting(false);
}
};
if (!token) {
return (
<AuthLayout>
<AuthCard title="Invalid invite" subtitle="This link is missing its invite token.">
<AuthErrorNotice message="Ask your administrator for a fresh invite link." />
</AuthCard>
</AuthLayout>
);
}
return (
<AuthLayout>
<AuthCard
title="Join the team"
subtitle={boundEmail ? `Invited as ${boundEmail}` : 'You have been invited — create your account.'}
>
{error ? <AuthErrorNotice message={error} /> : null}
{inviteQuery.isLoading ? <AuthInfoNotice message="Validating invite…" /> : null}
{inviteQuery.isError ? <AuthErrorNotice message="This invite link is not valid." /> : null}
{inviteQuery.data && !inviteValid ? (
<AuthErrorNotice message={inviteQuery.data.used ? 'This invite was already used.' : 'This invite has expired.'} />
) : null}
{inviteValid ? (
<form className="space-y-3" onSubmit={submit}>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
Name
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
className="mt-2 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5 text-sm text-[var(--text-primary)] outline-none transition-colors focus:border-[var(--accent-primary)]"
placeholder="Your name"
/>
</label>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
Email
<input
type="email"
value={boundEmail ?? email}
onChange={(e) => setEmail(e.target.value)}
disabled={Boolean(boundEmail)}
required
className="mt-2 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5 text-sm text-[var(--text-primary)] outline-none transition-colors focus:border-[var(--accent-primary)] disabled:opacity-60"
placeholder="[email protected]"
/>
</label>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
minLength={8}
required
className="mt-2 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5 text-sm text-[var(--text-primary)] outline-none transition-colors focus:border-[var(--accent-primary)]"
placeholder="At least 8 characters"
/>
</label>
<button
type="submit"
disabled={isSubmitting}
className="mt-1 inline-flex h-10 w-full items-center justify-center gap-2 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] shadow-lg transition-all disabled:cursor-not-allowed disabled:opacity-60"
style={{ background: 'var(--accent-primary)' }}
>
{isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <User2 size={15} />}
Accept invite
</button>
</form>
) : null}
<div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]">
<span>Already have an account?</span>
<Link to="/auth/sign-in" className="inline-flex items-center gap-1 text-[var(--accent-primary)] hover:underline">
Sign in <ArrowRight size={12} />
</Link>
</div>
</AuthCard>
</AuthLayout>
);
}
export function SignUpPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -15,6 +15,8 @@ import { listProjects, listTemplates } from '@/lib/api-client';
import { DocsBrowser } from '@/features/docs/DocsBrowser';
import { useAuthSession } from '@/lib/use-auth-session';
import { useDemoMode } from '@/lib/demo-mode';
import { useBranding } from '@/lib/use-branding';
import { BrandWordmark } from '@/shared/components';
import { demoProjects, demoTemplates } from '@/lib/demo-data';
const features = [
@@ -28,6 +30,7 @@ const features = [
export function LandingPage() {
const isDemoMode = useDemoMode();
const { logoUrl, productName } = useBranding();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = !isDemoMode && Boolean(sessionQuery.data);
const demo = isDemoMode ? '?demo=1' : '';
@@ -45,9 +48,9 @@ export function LandingPage() {
<header className="sticky top-0 z-40 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/80 backdrop-blur-xl">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-5">
<Link to="/" className="flex items-center gap-2.5">
<img src="/containr.svg" alt="Containr" className="h-7 w-7 rounded-lg" />
<img src={logoUrl || '/containr.svg'} alt={productName} className="h-7 w-7 rounded-lg object-contain" />
<span className="text-[15px] font-extrabold tracking-tight text-[var(--text-primary)]">
contain<span style={{ color: 'var(--accent-primary)' }}>r</span>
<BrandWordmark />
</span>
</Link>
<nav className="flex items-center gap-1 text-[13px]">
+174 -7
View File
@@ -44,10 +44,15 @@ import {
listServiceVariables,
updateServiceVariables,
getGitHubAppInstallUrl,
getGitHubAppStatus,
createGitHubAppManifest,
convertGitHubAppCode,
connectGitHubApp,
updateCurrentUserProfile,
getPlatformSettings,
updatePlatformSettings,
type Branding,
type BrandingInput,
getHAStatus,
setHAEnabled,
triggerFailover,
@@ -948,6 +953,35 @@ function GitProvidersSection() {
}
};
// Admins can self-provision the instance's GitHub App via the manifest
// flow when GITHUB_APP_* env vars were never configured.
const profileQuery = useQuery({ queryKey: ['user-profile'], queryFn: getCurrentUserProfile, retry: false });
const isAdmin = Boolean(profileQuery.data?.isAdmin);
const ghAppQuery = useQuery({
queryKey: ['github-app-status'],
queryFn: getGitHubAppStatus,
enabled: isAdmin,
retry: false,
});
const provisionMutation = useMutation({
mutationFn: async () => {
const { url, manifest } = await createGitHubAppManifest({ base_url: window.location.origin });
// GitHub requires a form POST carrying the manifest JSON.
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();
},
});
const ghAppStatus = ghAppQuery.data;
const showProvision = isAdmin && ghAppStatus && !ghAppStatus.configured;
const [form, setForm] = useState({
name: 'github' as (typeof gitProviderTypes)[number]['value'],
displayName: '',
@@ -994,13 +1028,25 @@ function GitProvidersSection() {
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => void installApp()}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
>
<GitBranch size={14} />
Install GitHub App
</button>
{showProvision ? (
<button
onClick={() => provisionMutation.mutate()}
disabled={provisionMutation.isPending}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg transition-all disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
>
<GitBranch size={14} />
{provisionMutation.isPending ? 'Opening GitHub…' : 'Set up GitHub App'}
</button>
) : (
<button
onClick={() => void installApp()}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
>
<GitBranch size={14} />
Install GitHub App
</button>
)}
<button
onClick={() => setFormOpen((open) => !open)}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg transition-all"
@@ -1017,6 +1063,11 @@ function GitProvidersSection() {
{installUrlError}
</div>
)}
{provisionMutation.isError && (
<div className="mb-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
{provisionMutation.error instanceof Error ? provisionMutation.error.message : 'Failed to start GitHub App setup'}
</div>
)}
{connectAppMutation.isPending && (
<div className="mb-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--surface-muted)] text-sm text-[var(--text-secondary)]">
Completing GitHub App installation…
@@ -1149,6 +1200,66 @@ function GitProvidersSection() {
);
}
// GitHub redirects here after the admin completes the manifest form —
// /git/github-app/callback?code=…&state=…
export function GitHubAppCallbackPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const code = searchParams.get('code');
const convertMutation = useMutation({
mutationFn: (appCode: string) => convertGitHubAppCode(appCode),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['github-app-status'] });
navigate('/settings', { replace: true });
},
});
const firedRef = useRef(false);
useEffect(() => {
if (code && !firedRef.current) {
firedRef.current = true;
convertMutation.mutate(code);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- fire once per code
}, [code]);
return (
<div className="mx-auto max-w-md px-5 py-16 text-center">
{!code ? (
<>
<AlertCircle size={28} className="mx-auto text-[var(--error)]" />
<h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Missing code</h1>
<p className="mt-2 text-sm text-[var(--text-tertiary)]">
GitHub did not return an app code. Start the setup again from Git providers.
</p>
</>
) : convertMutation.isError ? (
<>
<AlertCircle size={28} className="mx-auto text-[var(--error)]" />
<h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Setup failed</h1>
<p className="mt-2 text-sm text-[var(--text-tertiary)]">
{convertMutation.error instanceof Error ? convertMutation.error.message : 'Could not provision the GitHub App'}
</p>
<button
type="button"
onClick={() => navigate('/settings', { replace: true })}
className="mt-6 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-sm font-semibold text-[var(--accent-on)]"
>
Back to settings
</button>
</>
) : (
<>
<Loader2 size={28} className="mx-auto animate-spin text-[var(--accent-primary)]" />
<h1 className="mt-4 text-lg font-semibold text-[var(--text-primary)]">Setting up GitHub App</h1>
<p className="mt-2 text-sm text-[var(--text-tertiary)]">Exchanging the app credentials…</p>
</>
)}
</div>
);
}
function PlatformSettingsSection() {
const queryClient = useQueryClient();
const settingsQuery = useQuery({
@@ -1158,6 +1269,7 @@ function PlatformSettingsSection() {
const [signupDraft, setSignupDraft] = useState<boolean | null>(null);
const [tokenDraft, setTokenDraft] = useState('');
const [brandDraft, setBrandDraft] = useState<BrandingInput>({});
const [feedback, setFeedback] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -1168,8 +1280,10 @@ function PlatformSettingsSection() {
mutationFn: updatePlatformSettings,
onSuccess: (settings) => {
queryClient.setQueryData(['platform-settings'], settings);
queryClient.invalidateQueries({ queryKey: ['branding'] });
setSignupDraft(null);
setTokenDraft('');
setBrandDraft({});
setFeedback('Settings saved.');
setError(null);
},
@@ -1279,6 +1393,59 @@ function PlatformSettingsSection() {
</a>
</div>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Branding</span>
<span className="text-xs text-[var(--text-tertiary)]">leave blank to keep Containr defaults</span>
</div>
<p className="text-xs text-[var(--text-secondary)] mb-3">
White-label this instance — product name, logo, accent color and links apply
to the shell, login page and document title.
</p>
<div className="grid grid-cols-2 gap-2 max-sm:grid-cols-1">
{(
[
['productName', 'Product name'],
['logoUrl', 'Logo URL'],
['faviconUrl', 'Favicon URL'],
['accentColor', 'Accent color (#hex)'],
['docsUrl', 'Docs URL'],
['supportUrl', 'Support URL'],
] as const
).map(([field, label]) => (
<label key={field} className="block">
<span className="mb-1 block text-[11px] text-[var(--text-tertiary)]">{label}</span>
<input
value={brandDraft[field] ?? settingsQuery.data?.branding[field] ?? ''}
onChange={(e) => setBrandDraft((d) => ({ ...d, [field]: e.target.value }))}
className="h-9 w-full px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] text-sm focus:border-[var(--accent-primary)] transition-colors"
/>
</label>
))}
</div>
<div className="flex gap-2 mt-3">
<button
onClick={() => {
const b = { ...(settingsQuery.data?.branding ?? {}), ...brandDraft } as Branding;
saveMutation.mutate({ branding: b });
}}
disabled={saveMutation.isPending || Object.keys(brandDraft).length === 0}
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style={{ background: 'var(--accent-primary)' }}
>
Save branding
</button>
{Object.keys(brandDraft).length > 0 ? (
<button
onClick={() => setBrandDraft({})}
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-secondary)]"
>
Reset draft
</button>
) : null}
</div>
</div>
{feedback ? <p className="text-xs text-[var(--success)]">{feedback}</p> : null}
{error ? <p className="text-xs text-[var(--error)]">{error}</p> : null}
</div>
+546
View File
@@ -2717,6 +2717,63 @@ export interface paths {
};
trace?: never;
};
"/admin/users/{id}/impersonate": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Impersonate a user
* @description Mint a 15-minute bearer token acting as the target user. Admin-only; admin accounts cannot be impersonated and every mint is audit-logged. The token carries an `impersonated_by` claim.
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Impersonation token */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
token?: string;
/** Format: date-time */
expires_at?: string;
user?: {
id?: string;
email?: string;
name?: string;
};
};
};
};
/** @description Target is an admin account */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/projects/{id}": {
parameters: {
query?: never;
@@ -4015,6 +4072,258 @@ export interface paths {
};
trace?: never;
};
"/auth/invites/{token}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Validate an invite token (public) */
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Invite state */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
valid?: boolean;
used?: boolean;
expired?: boolean;
email?: string | null;
/** Format: date-time */
expires_at?: string;
};
};
};
/** @description Invite not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/auth/accept-invite": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Register via an invite link (public) */
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
token: string;
name: string;
email: string;
password: string;
};
};
};
responses: {
/** @description Created session */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["LoginResponse"];
};
};
/** @description Invite used or expired */
410: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/invites": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List invites (admin) */
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description All invites */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
invites?: Record<string, never>[];
};
};
};
};
};
put?: never;
/** Create an invite link (admin) */
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: {
content: {
"application/json": {
/** @description Optionally bind the invite to this email */
email?: string;
/** @default 168 */
expires_in_hours?: number;
};
};
};
responses: {
/** @description Created invite with one-time token */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
invite?: Record<string, never>;
/** @description Shown once */
token?: string;
url?: string;
};
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/invites/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Revoke an invite (admin) */
delete: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Revoked */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/branding": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* White-label branding (public)
* @description Instance branding payload — product name, logo/favicon URLs,
* accent color, docs/support links. Unauthenticated so the login
* page and shell can brand themselves before a session exists.
* Unset fields return Containr defaults.
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Branding configuration */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Branding"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/audit-logs": {
parameters: {
query?: never;
@@ -4980,6 +5289,15 @@ export interface paths {
signup_enabled?: boolean;
/** @description Cloudflare Tunnel token. Empty string clears the stored token and removes the cloudflared container. */
cloudflare_tunnel_token?: string;
/** @description White-label fields. Empty string clears a field back to the Containr default. */
branding?: {
product_name?: string;
logo_url?: string;
favicon_url?: string;
accent_color?: string;
docs_url?: string;
support_url?: string;
};
};
};
};
@@ -5400,6 +5718,162 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/git/github-app": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* GitHub App provisioning status
* @description Shows whether the instance GitHub App is configured, and whether credentials come from app_settings or environment variables.
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Provisioning status */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
configured?: boolean;
/** @enum {string} */
source?: "settings" | "env" | "none";
app_id?: string;
slug?: string;
name?: string;
};
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/git/github-app/manifest": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Build GitHub App manifest
* @description Returns a GitHub App manifest for self-provisioning. The client POSTs `manifest` verbatim as a form field to `url`; GitHub then redirects to the instance's /git/github-app/callback with a code.
*/
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @description Public https base URL of this instance */
base_url: string;
name?: string;
/** @description Create the app under this GitHub organization */
organization?: string;
};
};
};
responses: {
/** @description Manifest + submission URL */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
url?: string;
/** @description JSON string — POST as form field `manifest` */
manifest?: string;
state?: string;
};
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/git/github-app/convert": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Exchange manifest callback code
* @description Exchanges the `code` from the manifest redirect for the app credentials and stores them in app_settings (overriding env vars).
*/
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
code: string;
};
};
};
responses: {
/** @description App provisioned */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
app_id?: number;
slug?: string;
name?: string;
};
};
};
/** @description GitHub conversion failed */
502: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/git/providers/{providerId}": {
parameters: {
query?: never;
@@ -8125,6 +8599,68 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/git/github-app/webhook": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* GitHub App webhook receiver
* @description Single app-level webhook all installed repositories deliver to (configured via the manifest's hook_attributes). Verified with X-Hub-Signature-256 against the provisioned webhook secret. Push events enqueue deployments for services tracking the repo+branch.
*/
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": Record<string, never>;
};
};
responses: {
/** @description Event accepted */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
received?: boolean;
branch?: string;
deployments?: number;
ignored?: string;
};
};
};
/** @description Invalid signature */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description GitHub App webhook secret not configured */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/agents/{id}/commands/{commandId}/result": {
parameters: {
query?: never;
@@ -9615,6 +10151,16 @@ export interface components {
/** @description cloudflared container state - running/exited/missing, or unavailable when Docker is unreachable */
container?: string;
};
branding?: components["schemas"]["Branding"];
};
/** @description White-label instance branding. Unset fields fall back to Containr defaults. */
Branding: {
product_name?: string;
logo_url?: string;
favicon_url?: string;
accent_color?: string;
docs_url?: string;
support_url?: string;
};
User: {
/** @description User ID */
+1 -1
View File
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
"path": "guides/CLI.md",
"section": "guides",
"title": "Containr CLI",
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nwebhooks list|create|update|delete|deliveries|test\n outbound signed webhooks — create <name> <url>\n --event 'service.*' (repeatable, '*' = all);\n deliveries are HMAC-signed (X-Containr-Signature)\noperations live ops view: active deploys, queue depth,\n 24h failures, cron runs, backups\nup Line truncated
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nwebhooks list|create|update|delete|deliveries|test\n outbound signed webhooks — create <name> <url>\n --event 'service.*' (repeatable, '*' = all);\n deliveries are HMAC-signed (X-Containr-Signature)\noperations live ops view: active deploys, queue depth,\n 24h failures, cron runs, backups\nbanners list|creatLine truncated
},
{
"path": "guides/CLOUDFLARE_SETUP.md",
+153
View File
@@ -24,6 +24,24 @@ export type UserProfile = {
updatedAt?: string;
};
export type Branding = {
productName: string;
logoUrl: string;
faviconUrl: string;
accentColor: string;
docsUrl: string;
supportUrl: string;
};
export type BrandingInput = Partial<{
productName: string;
logoUrl: string;
faviconUrl: string;
accentColor: string;
docsUrl: string;
supportUrl: string;
}>;
export type PlatformSettings = {
signupEnabled: boolean;
cloudflareTunnel: {
@@ -31,6 +49,7 @@ export type PlatformSettings = {
source: 'app' | 'env' | 'none';
container: string;
};
branding: Branding;
};
export type ServiceEntity = {
@@ -542,6 +561,37 @@ function normalizeUserProfile(profile: RawUserProfile): UserProfile | null {
};
}
function normalizeBranding(raw: {
product_name?: string;
logo_url?: string;
favicon_url?: string;
accent_color?: string;
docs_url?: string;
support_url?: string;
} | undefined | null): Branding {
return {
productName: raw?.product_name ?? 'Containr',
logoUrl: raw?.logo_url ?? '',
faviconUrl: raw?.favicon_url ?? '',
accentColor: raw?.accent_color ?? '',
docsUrl: raw?.docs_url ?? '',
supportUrl: raw?.support_url ?? '',
};
}
// GET /branding is public — used before login to theme the shell.
export async function getBranding(): Promise<Branding> {
const raw = await requestJson<{
product_name?: string;
logo_url?: string;
favicon_url?: string;
accent_color?: string;
docs_url?: string;
support_url?: string;
}>('/branding');
return normalizeBranding(raw);
}
function normalizePlatformSettings(raw: components['schemas']['PlatformSettings']): PlatformSettings {
return {
signupEnabled: raw.signup_enabled ?? false,
@@ -550,6 +600,7 @@ function normalizePlatformSettings(raw: components['schemas']['PlatformSettings'
source: raw.cloudflare_tunnel?.source ?? 'none',
container: raw.cloudflare_tunnel?.container ?? 'missing',
},
branding: normalizeBranding(raw.branding),
};
}
@@ -561,6 +612,7 @@ export async function getPlatformSettings(): Promise<PlatformSettings> {
export async function updatePlatformSettings(input: {
signupEnabled?: boolean;
cloudflareTunnelToken?: string;
branding?: BrandingInput;
}): Promise<PlatformSettings> {
const body: Record<string, unknown> = {};
if (input.signupEnabled !== undefined) {
@@ -569,6 +621,16 @@ export async function updatePlatformSettings(input: {
if (input.cloudflareTunnelToken !== undefined) {
body.cloudflare_tunnel_token = input.cloudflareTunnelToken;
}
if (input.branding !== undefined) {
const b: Record<string, string> = {};
if (input.branding.productName !== undefined) b.product_name = input.branding.productName;
if (input.branding.logoUrl !== undefined) b.logo_url = input.branding.logoUrl;
if (input.branding.faviconUrl !== undefined) b.favicon_url = input.branding.faviconUrl;
if (input.branding.accentColor !== undefined) b.accent_color = input.branding.accentColor;
if (input.branding.docsUrl !== undefined) b.docs_url = input.branding.docsUrl;
if (input.branding.supportUrl !== undefined) b.support_url = input.branding.supportUrl;
body.branding = b;
}
const raw = await requestJson<components['schemas']['PlatformSettings']>('/settings', {
method: 'PUT',
body: JSON.stringify(body),
@@ -1910,6 +1972,55 @@ export async function deleteBanner(id: string): Promise<void> {
await requestJson(`/admin/banners/${id}`, { method: 'DELETE' });
}
export type Invite = {
id: string;
email?: string;
expires_at: string;
used_at?: string;
created_at?: string;
};
export type InviteState = {
valid: boolean;
used: boolean;
expired: boolean;
email?: string | null;
expires_at: string;
};
export async function getInvite(token: string): Promise<InviteState> {
return requestJson<InviteState>(`/auth/invites/${token}`);
}
export async function acceptInvite(input: {
token: string;
name: string;
email: string;
password: string;
}): Promise<void> {
await requestJson('/auth/accept-invite', { method: 'POST', body: JSON.stringify(input) });
}
export async function listInvites(): Promise<Invite[]> {
const payload = await requestJson<{ invites?: Invite[] }>('/admin/invites');
return payload.invites ?? [];
}
export async function createInvite(input: { email?: string; expiresInHours?: number }): Promise<{
invite: Invite;
token: string;
url: string;
}> {
const body: Record<string, unknown> = {};
if (input.email) body.email = input.email;
if (input.expiresInHours) body.expires_in_hours = input.expiresInHours;
return requestJson('/admin/invites', { method: 'POST', body: JSON.stringify(body) });
}
export async function revokeInvite(id: string): Promise<void> {
await requestJson(`/admin/invites/${id}`, { method: 'DELETE' });
}
export function serviceStatusClass(status: ServiceStatus): string {
switch (status) {
case 'running':
@@ -2147,6 +2258,36 @@ export async function getGitHubAppInstallUrl(): Promise<string> {
return payload.install_url;
}
export type GitHubAppStatus = {
configured: boolean;
source: 'env' | 'settings' | 'none';
app_id?: string;
slug?: string;
name?: string;
};
export async function getGitHubAppStatus(): Promise<GitHubAppStatus> {
return requestJson<GitHubAppStatus>('/admin/git/github-app');
}
export async function createGitHubAppManifest(input: {
base_url: string;
name?: string;
organization?: string;
}): Promise<{ url: string; manifest: string; state: string }> {
return requestJson('/admin/git/github-app/manifest', {
method: 'POST',
body: JSON.stringify(input),
});
}
export async function convertGitHubAppCode(code: string): Promise<{ app_id: number; slug: string; name: string }> {
return requestJson('/admin/git/github-app/convert', {
method: 'POST',
body: JSON.stringify({ code }),
});
}
export async function connectGitHubApp(installationId: number, displayName?: string): Promise<GitProviderEntity> {
const payload = await requestJson<{ provider?: GitProviderEntity }>('/git/github-app/connect', {
method: 'POST',
@@ -2511,6 +2652,18 @@ export async function listAdminUsers(): Promise<AdminUser[]> {
return payload.users ?? [];
}
export type ImpersonationResult = {
token: string;
expires_at: string;
user: { id: string; email: string; name: string };
};
export async function impersonateUser(userId: string): Promise<ImpersonationResult> {
return requestJson<ImpersonationResult>(`/admin/users/${encodeURIComponent(userId)}/impersonate`, {
method: 'POST',
});
}
export async function setUserAdmin(userId: string, isAdmin: boolean): Promise<void> {
await requestJson(`/admin/users/${encodeURIComponent(userId)}`, {
method: 'PATCH',
+54
View File
@@ -0,0 +1,54 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getBranding, type Branding } from '@/lib/api-client';
import { useDemoMode } from '@/lib/demo-mode';
const DEFAULT_TITLE = 'containr';
/**
* Loads the instance's white-label branding (public endpoint) and applies
* it: document title, favicon, and the accent CSS variable. Defaults keep
* the app Containr-branded.
*/
export function useBranding(): Branding {
const isDemoMode = useDemoMode();
const brandingQuery = useQuery({
queryKey: ['branding'],
queryFn: getBranding,
staleTime: 5 * 60_000,
enabled: !isDemoMode,
});
const branding = brandingQuery.data ?? {
productName: 'Containr',
logoUrl: '',
faviconUrl: '',
accentColor: '',
docsUrl: '',
supportUrl: '',
};
useEffect(() => {
const name = branding.productName;
document.title = name && name !== 'Containr' ? name : DEFAULT_TITLE;
const root = document.documentElement;
if (branding.accentColor && /^#[0-9a-fA-F]{3,8}$/.test(branding.accentColor)) {
root.style.setProperty('--accent-primary', branding.accentColor);
} else {
root.style.removeProperty('--accent-primary');
}
if (branding.faviconUrl) {
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
if (!link) {
link = document.createElement('link');
link.rel = 'icon';
document.head.appendChild(link);
}
link.href = branding.faviconUrl;
}
}, [branding.productName, branding.accentColor, branding.faviconUrl]);
return branding;
}
@@ -0,0 +1,28 @@
import { useBranding } from '@/lib/use-branding';
/**
* Instance wordmark — renders the configured logo when set, otherwise the
* product name with the signature accent on the last character
* ("contain[r]"). Falls back to Containr branding.
*/
export function BrandWordmark({ className = '' }: { className?: string }) {
const branding = useBranding();
if (branding.logoUrl) {
return (
<img
src={branding.logoUrl}
alt={branding.productName}
className={`h-6 w-auto object-contain ${className}`}
/>
);
}
const name = branding.productName;
return (
<span className={className}>
{name.slice(0, -1)}
<span style={{ color: 'var(--accent-primary)' }}>{name.slice(-1)}</span>
</span>
);
}
@@ -10,3 +10,4 @@ export * from './CommandPalette';
export * from './StatusBadge';
export { useToast } from '../hooks/use-toast';
export type { ToastType, ToastContextValue } from '../hooks/use-toast';
export * from './BrandWordmark';
+318
View File
@@ -1791,6 +1791,45 @@ paths:
message:
type: string
/admin/users/{id}/impersonate:
post:
tags:
- Admin
summary: Impersonate a user
description: >-
Mint a 15-minute bearer token acting as the target user. Admin-only;
admin accounts cannot be impersonated and every mint is audit-logged.
The token carries an `impersonated_by` claim.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Impersonation token
content:
application/json:
schema:
type: object
properties:
token:
type: string
expires_at:
type: string
format: date-time
user:
type: object
properties:
id: {type: string}
email: {type: string}
name: {type: string}
'403':
description: Target is an admin account
/admin/projects/{id}:
patch:
tags:
@@ -2702,6 +2741,130 @@ paths:
'204':
description: Deleted
/auth/invites/{token}:
get:
tags:
- Invites
summary: Validate an invite token (public)
responses:
'200':
description: Invite state
content:
application/json:
schema:
type: object
properties:
valid: {type: boolean}
used: {type: boolean}
expired: {type: boolean}
email: {type: string, nullable: true}
expires_at: {type: string, format: date-time}
'404':
description: Invite not found
/auth/accept-invite:
post:
tags:
- Invites
summary: Register via an invite link (public)
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [token, name, email, password]
properties:
token: {type: string}
name: {type: string}
email: {type: string}
password: {type: string, minLength: 8}
responses:
'201':
description: Created session
content:
application/json:
schema:
$ref: '#/components/schemas/LoginResponse'
'410':
description: Invite used or expired
/admin/invites:
get:
tags:
- Invites
summary: List invites (admin)
security:
- bearerAuth: []
responses:
'200':
description: All invites
content:
application/json:
schema:
type: object
properties:
invites:
type: array
items:
type: object
post:
tags:
- Invites
summary: Create an invite link (admin)
security:
- bearerAuth: []
requestBody:
content:
application/json:
schema:
type: object
properties:
email: {type: string, description: Optionally bind the invite to this email}
expires_in_hours: {type: integer, default: 168, minimum: 1, maximum: 2160}
responses:
'201':
description: Created invite with one-time token
content:
application/json:
schema:
type: object
properties:
invite: {type: object}
token: {type: string, description: Shown once}
url: {type: string}
/admin/invites/{id}:
delete:
tags:
- Invites
summary: Revoke an invite (admin)
security:
- bearerAuth: []
parameters:
- {name: id, in: path, required: true, schema: {type: string}}
responses:
'204':
description: Revoked
/branding:
get:
tags:
- Branding
summary: White-label branding (public)
description: |
Instance branding payload — product name, logo/favicon URLs,
accent color, docs/support links. Unauthenticated so the login
page and shell can brand themselves before a session exists.
Unset fields return Containr defaults.
responses:
'200':
description: Branding configuration
content:
application/json:
schema:
$ref: '#/components/schemas/Branding'
# Audit Logs
/audit-logs:
get:
@@ -3325,6 +3488,16 @@ paths:
cloudflare_tunnel_token:
type: string
description: Cloudflare Tunnel token. Empty string clears the stored token and removes the cloudflared container.
branding:
type: object
description: White-label fields. Empty string clears a field back to the Containr default.
properties:
product_name: {type: string}
logo_url: {type: string}
favicon_url: {type: string}
accent_color: {type: string}
docs_url: {type: string}
support_url: {type: string}
responses:
'200':
description: Updated platform settings
@@ -3596,6 +3769,93 @@ paths:
schema:
$ref: '#/components/schemas/Error'
/admin/git/github-app:
get:
tags:
- Admin
summary: GitHub App provisioning status
description: Shows whether the instance GitHub App is configured, and whether credentials come from app_settings or environment variables.
security:
- bearerAuth: []
responses:
'200':
description: Provisioning status
content:
application/json:
schema:
type: object
properties:
configured: {type: boolean}
source: {type: string, enum: [settings, env, none]}
app_id: {type: string}
slug: {type: string}
name: {type: string}
/admin/git/github-app/manifest:
post:
tags:
- Admin
summary: Build GitHub App manifest
description: >-
Returns a GitHub App manifest for self-provisioning. The client POSTs
`manifest` verbatim as a form field to `url`; GitHub then redirects to
the instance's /git/github-app/callback with a code.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [base_url]
properties:
base_url: {type: string, description: Public https base URL of this instance}
name: {type: string}
organization: {type: string, description: Create the app under this GitHub organization}
responses:
'200':
description: Manifest + submission URL
content:
application/json:
schema:
type: object
properties:
url: {type: string}
manifest: {type: string, description: JSON string — POST as form field `manifest`}
state: {type: string}
/admin/git/github-app/convert:
post:
tags:
- Admin
summary: Exchange manifest callback code
description: Exchanges the `code` from the manifest redirect for the app credentials and stores them in app_settings (overriding env vars).
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [code]
properties:
code: {type: string}
responses:
'200':
description: App provisioned
content:
application/json:
schema:
type: object
properties:
app_id: {type: integer}
slug: {type: string}
name: {type: string}
'502':
description: GitHub conversion failed
/git/providers/{providerId}:
delete:
tags:
@@ -5191,6 +5451,45 @@ paths:
schema:
$ref: '#/components/schemas/Error'
/api/git/github-app/webhook:
servers:
- url: http://localhost:8080
post:
tags:
- Git
summary: GitHub App webhook receiver
description: >-
Single app-level webhook all installed repositories deliver to
(configured via the manifest's hook_attributes). Verified with
X-Hub-Signature-256 against the provisioned webhook secret. Push
events enqueue deployments for services tracking the repo+branch.
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
'202':
description: Event accepted
content:
application/json:
schema:
type: object
properties:
received:
type: boolean
branch:
type: string
deployments:
type: integer
ignored:
type: string
'401':
description: Invalid signature
'503':
description: GitHub App webhook secret not configured
/api/agents/{id}/commands/{commandId}/result:
servers:
- url: http://localhost:8080
@@ -6090,6 +6389,25 @@ components:
container:
type: string
description: cloudflared container state - running/exited/missing, or unavailable when Docker is unreachable
branding:
$ref: '#/components/schemas/Branding'
Branding:
type: object
description: White-label instance branding. Unset fields fall back to Containr defaults.
properties:
product_name:
type: string
logo_url:
type: string
favicon_url:
type: string
accent_color:
type: string
docs_url:
type: string
support_url:
type: string
User:
type: object
+5
View File
@@ -86,6 +86,11 @@ operations live ops view: active deploys, queue depth,
24h failures, cron runs, backups
banners list|create|update|delete announcement banners (admin);
active banners print on `auth status`
invites list|create|revoke team invite links (admin); create
prints the one-time acceptance URL
admin impersonate <id> mint a 15-min token acting as a user
admin github-app status|manifest|convert
self-provision the instance GitHub App
up deploy the current directory
```