mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00e69b3733 | ||
|
|
50e39fe140 | ||
|
|
b47ced0135 | ||
|
|
9408464f12 |
No files matched your search
+30
-12
@@ -284,12 +284,17 @@ and wired refs, one click or one CLI call.
|
||||
CLI + MCP + `/settings/webhooks` page with delivery log + test ping.
|
||||
- [ ] **Activity feed** — enrich `audit_logs` with `severity category
|
||||
icon label metadata`; per-project timeline + global feed; TTL job.
|
||||
- [ ] **Banners** — `banners(title, body, level, scope, active)`; top
|
||||
bar in UI + surfaced to CLI on `auth status`.
|
||||
- [ ] **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] **Banners** — `banners` table with title/body/level/active/
|
||||
dismissible/schedule window. `GET /banners/active` for signed-in
|
||||
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.
|
||||
- [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.
|
||||
@@ -299,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`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Instance-wide announcement banners. Admins manage them under
|
||||
// /admin/banners; every signed-in user reads the active set at
|
||||
// /banners/active so the UI (and CLI) can surface announcements.
|
||||
|
||||
var bannerLevels = map[string]bool{"info": true, "success": true, "warning": true, "error": true}
|
||||
|
||||
// bannerJSON flattens sqlc nullable wrappers into plain API values.
|
||||
type bannerJSON struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Level string `json:"level"`
|
||||
Active bool `json:"active"`
|
||||
Dismissible bool `json:"dismissible"`
|
||||
StartsAt *time.Time `json:"starts_at,omitempty"`
|
||||
EndsAt *time.Time `json:"ends_at,omitempty"`
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
func toBannerJSON(b sqlcdb.Banner) bannerJSON {
|
||||
out := bannerJSON{
|
||||
ID: b.ID.String(),
|
||||
Title: b.Title,
|
||||
Body: b.Body,
|
||||
Level: b.Level,
|
||||
Active: b.Active,
|
||||
Dismissible: b.Dismissible,
|
||||
}
|
||||
if b.StartsAt.Valid {
|
||||
out.StartsAt = &b.StartsAt.Time
|
||||
}
|
||||
if b.EndsAt.Valid {
|
||||
out.EndsAt = &b.EndsAt.Time
|
||||
}
|
||||
if b.CreatedAt.Valid {
|
||||
out.CreatedAt = &b.CreatedAt.Time
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toBannerList(banners []sqlcdb.Banner) []bannerJSON {
|
||||
out := make([]bannerJSON, 0, len(banners))
|
||||
for _, b := range banners {
|
||||
out = append(out, toBannerJSON(b))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type bannerRequest struct {
|
||||
Title *string `json:"title"`
|
||||
Body *string `json:"body"`
|
||||
Level *string `json:"level"`
|
||||
Active *bool `json:"active"`
|
||||
Dismissible *bool `json:"dismissible"`
|
||||
StartsAt *time.Time `json:"starts_at"`
|
||||
EndsAt *time.Time `json:"ends_at"`
|
||||
}
|
||||
|
||||
func bannerQueries(c *gin.Context) *sqlcdb.Queries {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
return sqlcdb.New(db.DB)
|
||||
}
|
||||
|
||||
func parseBannerID(c *gin.Context) (uuid.UUID, bool) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
respondError(c, http.StatusBadRequest, "INVALID_ID", "invalid banner id")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func validBannerLevel(level string) bool { return bannerLevels[level] }
|
||||
|
||||
func handleListActiveBanners(c *gin.Context) {
|
||||
banners, err := bannerQueries(c).ListActiveBanners(c.Request.Context())
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to list banners")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"banners": toBannerList(banners)})
|
||||
}
|
||||
|
||||
func handleAdminListBanners(c *gin.Context) {
|
||||
banners, err := bannerQueries(c).ListBanners(c.Request.Context())
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to list banners")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"banners": toBannerList(banners)})
|
||||
}
|
||||
|
||||
func handleAdminCreateBanner(c *gin.Context) {
|
||||
var req bannerRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "INVALID_BODY", "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Title == nil || *req.Title == "" {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", "title is required")
|
||||
return
|
||||
}
|
||||
level := "info"
|
||||
if req.Level != nil {
|
||||
if !validBannerLevel(*req.Level) {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", "level must be info, success, warning or error")
|
||||
return
|
||||
}
|
||||
level = *req.Level
|
||||
}
|
||||
userID, _ := requireAuthenticatedUserUUID(c)
|
||||
params := sqlcdb.CreateBannerParams{
|
||||
Title: *req.Title,
|
||||
Level: level,
|
||||
Active: true,
|
||||
Dismissible: true,
|
||||
CreatedBy: uuid.NullUUID{UUID: userID, Valid: true},
|
||||
}
|
||||
if req.Body != nil {
|
||||
params.Body = *req.Body
|
||||
}
|
||||
if req.Active != nil {
|
||||
params.Active = *req.Active
|
||||
}
|
||||
if req.Dismissible != nil {
|
||||
params.Dismissible = *req.Dismissible
|
||||
}
|
||||
if req.StartsAt != nil {
|
||||
params.StartsAt = sql.NullTime{Time: *req.StartsAt, Valid: true}
|
||||
}
|
||||
if req.EndsAt != nil {
|
||||
params.EndsAt = sql.NullTime{Time: *req.EndsAt, Valid: true}
|
||||
}
|
||||
banner, err := bannerQueries(c).CreateBanner(c.Request.Context(), params)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to create banner")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, toBannerJSON(banner))
|
||||
}
|
||||
|
||||
func handleAdminUpdateBanner(c *gin.Context) {
|
||||
id, ok := parseBannerID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q := bannerQueries(c)
|
||||
banner, err := q.GetBannerByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusNotFound, "NOT_FOUND", "banner not found")
|
||||
return
|
||||
}
|
||||
var req bannerRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "INVALID_BODY", "invalid request body")
|
||||
return
|
||||
}
|
||||
params := sqlcdb.UpdateBannerParams{
|
||||
ID: id,
|
||||
Title: banner.Title,
|
||||
Body: banner.Body,
|
||||
Level: banner.Level,
|
||||
Active: banner.Active,
|
||||
Dismissible: banner.Dismissible,
|
||||
StartsAt: banner.StartsAt,
|
||||
EndsAt: banner.EndsAt,
|
||||
}
|
||||
if req.Title != nil {
|
||||
params.Title = *req.Title
|
||||
}
|
||||
if req.Body != nil {
|
||||
params.Body = *req.Body
|
||||
}
|
||||
if req.Level != nil {
|
||||
if !validBannerLevel(*req.Level) {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", "level must be info, success, warning or error")
|
||||
return
|
||||
}
|
||||
params.Level = *req.Level
|
||||
}
|
||||
if req.Active != nil {
|
||||
params.Active = *req.Active
|
||||
}
|
||||
if req.Dismissible != nil {
|
||||
params.Dismissible = *req.Dismissible
|
||||
}
|
||||
if req.StartsAt != nil {
|
||||
params.StartsAt = sql.NullTime{Time: *req.StartsAt, Valid: true}
|
||||
}
|
||||
if req.EndsAt != nil {
|
||||
params.EndsAt = sql.NullTime{Time: *req.EndsAt, Valid: true}
|
||||
}
|
||||
updated, err := q.UpdateBanner(c.Request.Context(), params)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to update banner")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toBannerJSON(updated))
|
||||
}
|
||||
|
||||
func handleAdminDeleteBanner(c *gin.Context) {
|
||||
id, ok := parseBannerID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := bannerQueries(c).DeleteBanner(c.Request.Context(), id); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to delete banner")
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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.
|
||||
@@ -336,6 +346,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
// Cross-cutting ops view — active/queued/failed work
|
||||
authed.GET("/operations", handleGetOperations)
|
||||
|
||||
// Instance announcements shown to all signed-in users
|
||||
authed.GET("/banners/active", handleListActiveBanners)
|
||||
|
||||
// Outbound webhooks — signed event delivery to user HTTP endpoints
|
||||
authed.GET("/webhooks", handleListOutboundWebhooks)
|
||||
authed.POST("/webhooks", handleCreateOutboundWebhook)
|
||||
@@ -468,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)
|
||||
@@ -485,6 +499,22 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
|
||||
admin.GET("/admin/volumes", handleAdminListVolumes)
|
||||
admin.DELETE("/admin/volumes/:name", handleAdminDeleteVolume)
|
||||
|
||||
// Announcement banners
|
||||
admin.GET("/admin/banners", handleAdminListBanners)
|
||||
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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -147,5 +147,21 @@ func runStatus(cmd *cobra.Command, args []string) error {
|
||||
if p.IsAdmin {
|
||||
fmt.Println("Role: platform admin")
|
||||
}
|
||||
|
||||
var bannersResp struct {
|
||||
Banners []struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Level string `json:"level"`
|
||||
} `json:"banners"`
|
||||
}
|
||||
if err := client.DoJSON("GET", "/banners/active", nil, &bannersResp); err == nil {
|
||||
for _, b := range bannersResp.Banners {
|
||||
fmt.Printf("[%s] %s\n", b.Level, b.Title)
|
||||
if b.Body != "" {
|
||||
fmt.Printf(" %s\n", b.Body)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
bannerTitle string
|
||||
bannerLevel string
|
||||
bannerBody string
|
||||
bannerActive bool
|
||||
)
|
||||
|
||||
// BannersCmd manages instance-wide announcement banners (admin).
|
||||
var BannersCmd = &cobra.Command{
|
||||
Use: "banners",
|
||||
Short: "Manage announcement banners (admin)",
|
||||
}
|
||||
|
||||
var bannerListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all banners",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/admin/banners", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := unwrapList(data, "banners")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, b := range items {
|
||||
rows = append(rows, []string{
|
||||
str(b, "id"), str(b, "title"), str(b, "level"), str(b, "active"),
|
||||
})
|
||||
}
|
||||
printRows(data, []string{"ID", "TITLE", "LEVEL", "ACTIVE"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var bannerCreateCmd = &cobra.Command{
|
||||
Use: "create <title>",
|
||||
Short: "Create a banner (--body --level info|success|warning|error)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req := map[string]interface{}{"title": args[0]}
|
||||
if cmd.Flags().Changed("body") {
|
||||
req["body"] = bannerBody
|
||||
}
|
||||
if cmd.Flags().Changed("level") {
|
||||
req["level"] = bannerLevel
|
||||
}
|
||||
data, err := c.Do("POST", "/admin/banners", req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var bannerUpdateCmd = &cobra.Command{
|
||||
Use: "update <id>",
|
||||
Short: "Update a banner (--title --body --level --active/--inactive)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req := map[string]interface{}{}
|
||||
if cmd.Flags().Changed("title") {
|
||||
req["title"] = bannerTitle
|
||||
}
|
||||
if cmd.Flags().Changed("body") {
|
||||
req["body"] = bannerBody
|
||||
}
|
||||
if cmd.Flags().Changed("level") {
|
||||
req["level"] = bannerLevel
|
||||
}
|
||||
if cmd.Flags().Changed("active") {
|
||||
req["active"] = bannerActive
|
||||
}
|
||||
if len(req) == 0 {
|
||||
return fmt.Errorf("nothing to update — pass --title/--body/--level/--active")
|
||||
}
|
||||
data, err := c.Do("PATCH", "/admin/banners/"+args[0], req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var bannerDeleteCmd = &cobra.Command{
|
||||
Use: "delete <id>",
|
||||
Short: "Delete a banner",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !Confirm("Delete banner " + 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/banners/"+args[0], nil)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
bannerCreateCmd.Flags().StringVar(&bannerBody, "body", "", "banner body text")
|
||||
bannerCreateCmd.Flags().StringVar(&bannerLevel, "level", "info", "info|success|warning|error")
|
||||
bannerUpdateCmd.Flags().StringVar(&bannerTitle, "title", "", "new title")
|
||||
bannerUpdateCmd.Flags().StringVar(&bannerBody, "body", "", "new body")
|
||||
bannerUpdateCmd.Flags().StringVar(&bannerLevel, "level", "", "info|success|warning|error")
|
||||
bannerUpdateCmd.Flags().BoolVar(&bannerActive, "active", true, "set banner active/inactive")
|
||||
BannersCmd.AddCommand(bannerListCmd, bannerCreateCmd, bannerUpdateCmd, bannerDeleteCmd)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -89,6 +89,8 @@ func init() {
|
||||
rootCmd.AddCommand(commands.NotificationsCmd)
|
||||
rootCmd.AddCommand(commands.WebhooksCmd)
|
||||
rootCmd.AddCommand(commands.OperationsCmd)
|
||||
rootCmd.AddCommand(commands.BannersCmd)
|
||||
rootCmd.AddCommand(commands.InvitesCmd)
|
||||
rootCmd.AddCommand(commands.UpCmd)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: banners.sql
|
||||
|
||||
package sqlcdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const createBanner = `-- name: CreateBanner :one
|
||||
INSERT INTO banners (title, body, level, active, dismissible, created_by, starts_at, ends_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING banners.id, banners.title, banners.body, banners.level, banners.active, banners.dismissible, banners.created_by, banners.starts_at, banners.ends_at, banners.created_at, banners.updated_at
|
||||
`
|
||||
|
||||
type CreateBannerParams struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Level string `json:"level"`
|
||||
Active bool `json:"active"`
|
||||
Dismissible bool `json:"dismissible"`
|
||||
CreatedBy uuid.NullUUID `json:"created_by"`
|
||||
StartsAt sql.NullTime `json:"starts_at"`
|
||||
EndsAt sql.NullTime `json:"ends_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateBanner(ctx context.Context, arg CreateBannerParams) (Banner, error) {
|
||||
row := q.db.QueryRowContext(ctx, createBanner,
|
||||
arg.Title,
|
||||
arg.Body,
|
||||
arg.Level,
|
||||
arg.Active,
|
||||
arg.Dismissible,
|
||||
arg.CreatedBy,
|
||||
arg.StartsAt,
|
||||
arg.EndsAt,
|
||||
)
|
||||
var i Banner
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Title,
|
||||
&i.Body,
|
||||
&i.Level,
|
||||
&i.Active,
|
||||
&i.Dismissible,
|
||||
&i.CreatedBy,
|
||||
&i.StartsAt,
|
||||
&i.EndsAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteBanner = `-- name: DeleteBanner :exec
|
||||
DELETE FROM banners
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteBanner(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteBanner, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const getBannerByID = `-- name: GetBannerByID :one
|
||||
SELECT banners.id, banners.title, banners.body, banners.level, banners.active, banners.dismissible, banners.created_by, banners.starts_at, banners.ends_at, banners.created_at, banners.updated_at
|
||||
FROM banners
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetBannerByID(ctx context.Context, id uuid.UUID) (Banner, error) {
|
||||
row := q.db.QueryRowContext(ctx, getBannerByID, id)
|
||||
var i Banner
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Title,
|
||||
&i.Body,
|
||||
&i.Level,
|
||||
&i.Active,
|
||||
&i.Dismissible,
|
||||
&i.CreatedBy,
|
||||
&i.StartsAt,
|
||||
&i.EndsAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listActiveBanners = `-- name: ListActiveBanners :many
|
||||
SELECT banners.id, banners.title, banners.body, banners.level, banners.active, banners.dismissible, banners.created_by, banners.starts_at, banners.ends_at, banners.created_at, banners.updated_at
|
||||
FROM banners
|
||||
WHERE active
|
||||
AND (starts_at IS NULL OR starts_at <= now())
|
||||
AND (ends_at IS NULL OR ends_at > now())
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListActiveBanners(ctx context.Context) ([]Banner, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listActiveBanners)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Banner{}
|
||||
for rows.Next() {
|
||||
var i Banner
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Title,
|
||||
&i.Body,
|
||||
&i.Level,
|
||||
&i.Active,
|
||||
&i.Dismissible,
|
||||
&i.CreatedBy,
|
||||
&i.StartsAt,
|
||||
&i.EndsAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); 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 listBanners = `-- name: ListBanners :many
|
||||
SELECT banners.id, banners.title, banners.body, banners.level, banners.active, banners.dismissible, banners.created_by, banners.starts_at, banners.ends_at, banners.created_at, banners.updated_at
|
||||
FROM banners
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListBanners(ctx context.Context) ([]Banner, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listBanners)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Banner{}
|
||||
for rows.Next() {
|
||||
var i Banner
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Title,
|
||||
&i.Body,
|
||||
&i.Level,
|
||||
&i.Active,
|
||||
&i.Dismissible,
|
||||
&i.CreatedBy,
|
||||
&i.StartsAt,
|
||||
&i.EndsAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); 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 updateBanner = `-- name: UpdateBanner :one
|
||||
UPDATE banners
|
||||
SET title = $2,
|
||||
body = $3,
|
||||
level = $4,
|
||||
active = $5,
|
||||
dismissible = $6,
|
||||
starts_at = $7,
|
||||
ends_at = $8,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING banners.id, banners.title, banners.body, banners.level, banners.active, banners.dismissible, banners.created_by, banners.starts_at, banners.ends_at, banners.created_at, banners.updated_at
|
||||
`
|
||||
|
||||
type UpdateBannerParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Level string `json:"level"`
|
||||
Active bool `json:"active"`
|
||||
Dismissible bool `json:"dismissible"`
|
||||
StartsAt sql.NullTime `json:"starts_at"`
|
||||
EndsAt sql.NullTime `json:"ends_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateBanner(ctx context.Context, arg UpdateBannerParams) (Banner, error) {
|
||||
row := q.db.QueryRowContext(ctx, updateBanner,
|
||||
arg.ID,
|
||||
arg.Title,
|
||||
arg.Body,
|
||||
arg.Level,
|
||||
arg.Active,
|
||||
arg.Dismissible,
|
||||
arg.StartsAt,
|
||||
arg.EndsAt,
|
||||
)
|
||||
var i Banner
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Title,
|
||||
&i.Body,
|
||||
&i.Level,
|
||||
&i.Active,
|
||||
&i.Dismissible,
|
||||
&i.CreatedBy,
|
||||
&i.StartsAt,
|
||||
&i.EndsAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -64,6 +64,20 @@ type BackupTarget struct {
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Banner struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Level string `json:"level"`
|
||||
Active bool `json:"active"`
|
||||
Dismissible bool `json:"dismissible"`
|
||||
CreatedBy uuid.NullUUID `json:"created_by"`
|
||||
StartsAt sql.NullTime `json:"starts_at"`
|
||||
EndsAt sql.NullTime `json:"ends_at"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ContainerInstance struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -287,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"`
|
||||
|
||||
@@ -21,6 +21,7 @@ type Querier interface {
|
||||
CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeAgent, error)
|
||||
CreateAgentAuthToken(ctx context.Context, arg CreateAgentAuthTokenParams) (AgentAuthToken, error)
|
||||
CreateBackupTarget(ctx context.Context, arg CreateBackupTargetParams) error
|
||||
CreateBanner(ctx context.Context, arg CreateBannerParams) (Banner, error)
|
||||
CreateCommand(ctx context.Context, arg CreateCommandParams) (AgentCommand, error)
|
||||
CreateContainer(ctx context.Context, arg CreateContainerParams) (ContainerInstance, error)
|
||||
CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBackupParams) error
|
||||
@@ -30,22 +31,26 @@ 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
|
||||
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
|
||||
DeleteAgent(ctx context.Context, id string) error
|
||||
DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error
|
||||
DeleteBanner(ctx context.Context, id uuid.UUID) error
|
||||
DeleteDatabaseServiceByID(ctx context.Context, id string) error
|
||||
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)
|
||||
GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error)
|
||||
GetBackupTargetByID(ctx context.Context, id string) (BackupTarget, error)
|
||||
GetBackupTargetByIDAndUser(ctx context.Context, arg GetBackupTargetByIDAndUserParams) (BackupTarget, error)
|
||||
GetBannerByID(ctx context.Context, id uuid.UUID) (Banner, error)
|
||||
GetCommandForAgent(ctx context.Context, arg GetCommandForAgentParams) (AgentCommand, error)
|
||||
GetContainer(ctx context.Context, id string) (ContainerInstance, error)
|
||||
GetContainerForAgent(ctx context.Context, arg GetContainerForAgentParams) (ContainerInstance, error)
|
||||
@@ -60,14 +65,17 @@ 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
|
||||
ListActiveBanners(ctx context.Context) ([]Banner, error)
|
||||
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
|
||||
ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error)
|
||||
ListAgents(ctx context.Context) ([]NodeAgent, error)
|
||||
ListAllDatabaseServices(ctx context.Context) ([]DatabaseService, error)
|
||||
ListBackupTargetsByUser(ctx context.Context, userID string) ([]BackupTarget, error)
|
||||
ListBanners(ctx context.Context) ([]Banner, error)
|
||||
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
|
||||
ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error)
|
||||
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error)
|
||||
@@ -85,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
|
||||
@@ -106,6 +116,7 @@ type Querier interface {
|
||||
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
|
||||
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
|
||||
UpdateBackupTargetByIDAndUser(ctx context.Context, arg UpdateBackupTargetByIDAndUserParams) error
|
||||
UpdateBanner(ctx context.Context, arg UpdateBannerParams) (Banner, error)
|
||||
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
|
||||
UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error
|
||||
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
|
||||
|
||||
@@ -149,6 +149,23 @@ func init() {
|
||||
|
||||
register(spec{"containr_operations_get", "Cross-cutting operations view: active deployments, deploy queue depth, recent failures, cron runs, backups.", "GET", "/operations", nil, nil, false})
|
||||
|
||||
register(spec{"containr_banners_active", "List active announcement banners shown to users.", "GET", "/banners/active", nil, nil, false})
|
||||
register(spec{"containr_banners_list", "List all announcement banners (admin).", "GET", "/admin/banners", nil, nil, false})
|
||||
register(spec{"containr_banners_create", "Create an announcement banner (admin). Args: title, body, level (info|success|warning|error), active, dismissible, starts_at, ends_at.", "POST", "/admin/banners", nil, []string{"title", "body", "level", "active", "dismissible", "starts_at", "ends_at"}, false})
|
||||
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,21 @@
|
||||
-- +goose Up
|
||||
-- Instance-wide announcement banners. Admins create them; active ones are
|
||||
-- shown to every signed-in user in the web UI and surfaced in the CLI.
|
||||
CREATE TABLE IF NOT EXISTS banners (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title varchar(255) NOT NULL,
|
||||
body text NOT NULL DEFAULT '',
|
||||
level varchar(20) NOT NULL DEFAULT 'info',
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
dismissible boolean NOT NULL DEFAULT true,
|
||||
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
starts_at timestamptz,
|
||||
ends_at timestamptz,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_banners_active ON banners(active);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS banners;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,39 @@
|
||||
-- name: ListBanners :many
|
||||
SELECT banners.*
|
||||
FROM banners
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: ListActiveBanners :many
|
||||
SELECT banners.*
|
||||
FROM banners
|
||||
WHERE active
|
||||
AND (starts_at IS NULL OR starts_at <= now())
|
||||
AND (ends_at IS NULL OR ends_at > now())
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: GetBannerByID :one
|
||||
SELECT banners.*
|
||||
FROM banners
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: CreateBanner :one
|
||||
INSERT INTO banners (title, body, level, active, dismissible, created_by, starts_at, ends_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING banners.*;
|
||||
|
||||
-- name: UpdateBanner :one
|
||||
UPDATE banners
|
||||
SET title = $2,
|
||||
body = $3,
|
||||
level = $4,
|
||||
active = $5,
|
||||
dismissible = $6,
|
||||
starts_at = $7,
|
||||
ends_at = $8,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING banners.*;
|
||||
|
||||
-- name: DeleteBanner :exec
|
||||
DELETE FROM banners
|
||||
WHERE id = $1;
|
||||
@@ -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;
|
||||
@@ -302,3 +302,28 @@ CREATE TABLE webhook_deliveries (
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
delivered_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE TABLE banners (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
level VARCHAR(20) NOT NULL DEFAULT 'info',
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
dismissible BOOLEAN NOT NULL DEFAULT true,
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
starts_at TIMESTAMP WITH TIME ZONE,
|
||||
ends_at TIMESTAMP WITH TIME ZONE,
|
||||
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()
|
||||
);
|
||||
@@ -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 { 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[] };
|
||||
@@ -387,6 +387,7 @@ export function PlatformShell() {
|
||||
</aside>
|
||||
|
||||
<div className="relative z-10 flex min-h-screen min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<BannerBar />
|
||||
<header
|
||||
className="hidden h-[46px] shrink-0 items-center border-b px-5 md:flex"
|
||||
style={{
|
||||
@@ -397,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,35 @@ 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,
|
||||
updateProject,
|
||||
type AdminUser,
|
||||
type Banner,
|
||||
type ProjectEntity,
|
||||
} from '@/lib/api-client';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
@@ -50,16 +63,89 @@ export function AdminPage() {
|
||||
const overviewQuery = useQuery({ queryKey: ['admin-overview'], queryFn: getAdminOverview, enabled: !isDemoMode });
|
||||
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'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||
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({
|
||||
title: bannerForm.title.trim(),
|
||||
body: bannerForm.body.trim(),
|
||||
level: bannerForm.level,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
showToast('success', 'Banner published');
|
||||
setBannerForm({ title: '', body: '', level: 'info' });
|
||||
invalidate();
|
||||
},
|
||||
onError: (error) => showToast('error', 'Create failed', error instanceof Error ? error.message : undefined),
|
||||
});
|
||||
|
||||
const bannerToggleMutation = useMutation({
|
||||
mutationFn: ({ id, active }: { id: string; active: boolean }) => updateBanner(id, { active }),
|
||||
onSuccess: () => invalidate(),
|
||||
onError: (error) => showToast('error', 'Update failed', error instanceof Error ? error.message : undefined),
|
||||
});
|
||||
|
||||
const bannerDeleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteBanner(id),
|
||||
onSuccess: () => {
|
||||
showToast('success', 'Banner deleted');
|
||||
invalidate();
|
||||
},
|
||||
onError: (error) => showToast('error', 'Delete failed', error instanceof Error ? error.message : undefined),
|
||||
});
|
||||
|
||||
const approvalMutation = useMutation({
|
||||
mutationFn: ({ id, approved }: { id: string; approved: boolean }) => setProjectApproval(id, approved),
|
||||
onSuccess: (_, vars) => {
|
||||
@@ -126,6 +212,8 @@ export function AdminPage() {
|
||||
const pending = overview?.pending_projects ?? [];
|
||||
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">
|
||||
@@ -258,6 +346,176 @@ export function AdminPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-8">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Megaphone size={15} className="text-[var(--accent-primary)]" />
|
||||
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Announcements</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={bannerForm.title}
|
||||
onChange={(e) => setBannerForm((f) => ({ ...f, title: e.target.value }))}
|
||||
placeholder="Title"
|
||||
className="h-9 min-w-48 flex-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
<input
|
||||
value={bannerForm.body}
|
||||
onChange={(e) => setBannerForm((f) => ({ ...f, body: e.target.value }))}
|
||||
placeholder="Body (optional)"
|
||||
className="h-9 min-w-48 flex-[2] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
<select
|
||||
value={bannerForm.level}
|
||||
onChange={(e) => setBannerForm((f) => ({ ...f, level: e.target.value as Banner['level'] }))}
|
||||
className="h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 text-sm"
|
||||
>
|
||||
<option value="info">info</option>
|
||||
<option value="success">success</option>
|
||||
<option value="warning">warning</option>
|
||||
<option value="error">error</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => bannerCreateMutation.mutate()}
|
||||
disabled={bannerCreateMutation.isPending || bannerForm.title.trim().length === 0}
|
||||
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12px] font-semibold text-[var(--accent-on)] disabled:opacity-50"
|
||||
>
|
||||
{bannerCreateMutation.isPending ? 'Publishing…' : 'Publish'}
|
||||
</button>
|
||||
</div>
|
||||
{banners.map((banner) => (
|
||||
<div
|
||||
key={banner.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)]">{banner.title}</p>
|
||||
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">
|
||||
{banner.level}
|
||||
{banner.body ? ` · ${banner.body}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => bannerToggleMutation.mutate({ id: banner.id, active: !banner.active })}
|
||||
disabled={bannerToggleMutation.isPending}
|
||||
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)] disabled:opacity-50"
|
||||
>
|
||||
{banner.active ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete banner "${banner.title}"?`)) {
|
||||
bannerDeleteMutation.mutate(banner.id);
|
||||
}
|
||||
}}
|
||||
disabled={bannerDeleteMutation.isPending}
|
||||
className="inline-flex items-center gap-1 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"
|
||||
title="Delete banner"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{banners.length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">No announcements.</p>
|
||||
) : null}
|
||||
</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)]" />
|
||||
@@ -271,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 ? (
|
||||
@@ -279,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=<token>
|
||||
containr <command></code> or <code className="text-[var(--text-secondary)]">Authorization: Bearer
|
||||
<token></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">
|
||||
@@ -334,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">
|
||||
@@ -357,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)}
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -3809,6 +3866,464 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/banners/active": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Active announcement banners
|
||||
* @description Banners currently active (enabled and within their optional schedule window).
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Active banners */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
banners?: {
|
||||
id?: string;
|
||||
title?: string;
|
||||
body?: string;
|
||||
/** @enum {string} */
|
||||
level?: "info" | "success" | "warning" | "error";
|
||||
active?: boolean;
|
||||
dismissible?: boolean;
|
||||
/** Format: date-time */
|
||||
starts_at?: string | null;
|
||||
/** Format: date-time */
|
||||
ends_at?: string | null;
|
||||
/** Format: date-time */
|
||||
created_at?: string;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/banners": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List all banners (admin) */
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description All banners */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
banners?: Record<string, never>[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
/** Create a banner (admin) */
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
title: string;
|
||||
body?: string;
|
||||
/**
|
||||
* @default info
|
||||
* @enum {string}
|
||||
*/
|
||||
level?: "info" | "success" | "warning" | "error";
|
||||
/** @default true */
|
||||
active?: boolean;
|
||||
/** @default true */
|
||||
dismissible?: boolean;
|
||||
/** Format: date-time */
|
||||
starts_at?: string;
|
||||
/** Format: date-time */
|
||||
ends_at?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Created banner */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": Record<string, never>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/banners/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
/** Delete a banner (admin) */
|
||||
delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Deleted */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** Update a banner (admin) */
|
||||
patch: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
title?: string;
|
||||
body?: string;
|
||||
/** @enum {string} */
|
||||
level?: "info" | "success" | "warning" | "error";
|
||||
active?: boolean;
|
||||
dismissible?: boolean;
|
||||
/** Format: date-time */
|
||||
starts_at?: string;
|
||||
/** Format: date-time */
|
||||
ends_at?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Updated banner */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": Record<string, never>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
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;
|
||||
@@ -4774,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;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -5194,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;
|
||||
@@ -7919,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;
|
||||
@@ -9409,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 */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
@@ -1866,6 +1928,99 @@ export async function getOperations(): Promise<OperationsView> {
|
||||
return requestJson<OperationsView>('/operations');
|
||||
}
|
||||
|
||||
export type Banner = {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
level: 'info' | 'success' | 'warning' | 'error';
|
||||
active: boolean;
|
||||
dismissible: boolean;
|
||||
starts_at?: string | null;
|
||||
ends_at?: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type BannerInput = {
|
||||
title?: string;
|
||||
body?: string;
|
||||
level?: Banner['level'];
|
||||
active?: boolean;
|
||||
dismissible?: boolean;
|
||||
starts_at?: string;
|
||||
ends_at?: string;
|
||||
};
|
||||
|
||||
export async function listActiveBanners(): Promise<Banner[]> {
|
||||
const payload = await requestJson<{ banners?: Banner[] }>('/banners/active');
|
||||
return payload.banners ?? [];
|
||||
}
|
||||
|
||||
export async function listAdminBanners(): Promise<Banner[]> {
|
||||
const payload = await requestJson<{ banners?: Banner[] }>('/admin/banners');
|
||||
return payload.banners ?? [];
|
||||
}
|
||||
|
||||
export async function createBanner(input: BannerInput): Promise<Banner> {
|
||||
return requestJson<Banner>('/admin/banners', { method: 'POST', body: JSON.stringify(input) });
|
||||
}
|
||||
|
||||
export async function updateBanner(id: string, input: BannerInput): Promise<Banner> {
|
||||
return requestJson<Banner>(`/admin/banners/${id}`, { method: 'PATCH', body: JSON.stringify(input) });
|
||||
}
|
||||
|
||||
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':
|
||||
@@ -2103,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',
|
||||
@@ -2467,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',
|
||||
|
||||
@@ -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,85 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AlertTriangle, CheckCircle2, Info, Megaphone, X, XCircle } from 'lucide-react';
|
||||
import { listActiveBanners, type Banner } from '@/lib/api-client';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
|
||||
const DISMISSED_KEY = 'containr.dismissed-banners';
|
||||
|
||||
function dismissedIds(): Set<string> {
|
||||
try {
|
||||
return new Set(JSON.parse(localStorage.getItem(DISMISSED_KEY) ?? '[]'));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
const levelStyle: Record<Banner['level'], { bg: string; fg: string; icon: typeof Info }> = {
|
||||
info: { bg: 'var(--accent-primary-soft)', fg: 'var(--accent-primary)', icon: Info },
|
||||
success: { bg: 'var(--success-soft)', fg: 'var(--success)', icon: CheckCircle2 },
|
||||
warning: { bg: 'var(--warning-soft)', fg: 'var(--warning)', icon: AlertTriangle },
|
||||
error: { bg: 'var(--error-soft)', fg: 'var(--error)', icon: XCircle },
|
||||
};
|
||||
|
||||
export function BannerBar() {
|
||||
const isDemoMode = useDemoMode();
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(dismissedIds);
|
||||
|
||||
const bannersQuery = useQuery({
|
||||
queryKey: ['active-banners'],
|
||||
queryFn: listActiveBanners,
|
||||
enabled: !isDemoMode,
|
||||
refetchInterval: 60_000,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const banners = (bannersQuery.data ?? []).filter((b) => !dismissed.has(b.id));
|
||||
if (banners.length === 0) return null;
|
||||
|
||||
const dismiss = (id: string) => {
|
||||
setDismissed((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(id);
|
||||
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...next]));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="shrink-0">
|
||||
{banners.map((banner) => {
|
||||
const style = levelStyle[banner.level] ?? levelStyle.info;
|
||||
const Icon = banner.level === 'info' ? Megaphone : style.icon;
|
||||
return (
|
||||
<div
|
||||
key={banner.id}
|
||||
className="flex items-center gap-3 border-b border-[var(--border-subtle)] px-5 py-2"
|
||||
style={{ background: style.bg }}
|
||||
role="status"
|
||||
>
|
||||
<Icon size={14} className="shrink-0" style={{ color: style.fg }} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-[12.5px] font-semibold" style={{ color: style.fg }}>
|
||||
{banner.title}
|
||||
</span>
|
||||
{banner.body ? (
|
||||
<span className="ml-2 text-[12px] text-[var(--text-secondary)]">{banner.body}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{banner.dismissible ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(banner.id)}
|
||||
className="shrink-0 rounded p-0.5 hover:bg-black/10"
|
||||
style={{ color: style.fg }}
|
||||
aria-label="Dismiss announcement"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './BannerBar';
|
||||
export * from './DemoGate';
|
||||
export * from './ErrorBoundary';
|
||||
export * from './LoadingSpinner';
|
||||
@@ -9,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';
|
||||
@@ -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:
|
||||
@@ -2581,6 +2620,251 @@ paths:
|
||||
items:
|
||||
type: object
|
||||
|
||||
/banners/active:
|
||||
get:
|
||||
tags:
|
||||
- Banners
|
||||
summary: Active announcement banners
|
||||
description: Banners currently active (enabled and within their optional schedule window).
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Active banners
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
banners:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id: {type: string}
|
||||
title: {type: string}
|
||||
body: {type: string}
|
||||
level: {type: string, enum: [info, success, warning, error]}
|
||||
active: {type: boolean}
|
||||
dismissible: {type: boolean}
|
||||
starts_at: {type: string, format: date-time, nullable: true}
|
||||
ends_at: {type: string, format: date-time, nullable: true}
|
||||
created_at: {type: string, format: date-time}
|
||||
|
||||
/admin/banners:
|
||||
get:
|
||||
tags:
|
||||
- Banners
|
||||
summary: List all banners (admin)
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: All banners
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
banners:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
post:
|
||||
tags:
|
||||
- Banners
|
||||
summary: Create a banner (admin)
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [title]
|
||||
properties:
|
||||
title: {type: string}
|
||||
body: {type: string}
|
||||
level: {type: string, enum: [info, success, warning, error], default: info}
|
||||
active: {type: boolean, default: true}
|
||||
dismissible: {type: boolean, default: true}
|
||||
starts_at: {type: string, format: date-time}
|
||||
ends_at: {type: string, format: date-time}
|
||||
responses:
|
||||
'201':
|
||||
description: Created banner
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/admin/banners/{id}:
|
||||
patch:
|
||||
tags:
|
||||
- Banners
|
||||
summary: Update a banner (admin)
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- {name: id, in: path, required: true, schema: {type: string}}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
title: {type: string}
|
||||
body: {type: string}
|
||||
level: {type: string, enum: [info, success, warning, error]}
|
||||
active: {type: boolean}
|
||||
dismissible: {type: boolean}
|
||||
starts_at: {type: string, format: date-time}
|
||||
ends_at: {type: string, format: date-time}
|
||||
responses:
|
||||
'200':
|
||||
description: Updated banner
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
delete:
|
||||
tags:
|
||||
- Banners
|
||||
summary: Delete a banner (admin)
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- {name: id, in: path, required: true, schema: {type: string}}
|
||||
responses:
|
||||
'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:
|
||||
@@ -3204,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
|
||||
@@ -3475,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:
|
||||
@@ -5070,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
|
||||
@@ -5969,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
|
||||
|
||||
@@ -84,6 +84,13 @@ webhooks list|create|update|delete|deliveries|test
|
||||
deliveries are HMAC-signed (X-Containr-Signature)
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
Reference in new issue
Block a user