mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-29 00:01:22 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50e39fe140 | ||
|
|
b47ced0135 | ||
|
|
9408464f12 |
No files matched your search
+17
-8
@@ -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,8 +304,12 @@ 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.
|
||||
- [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.
|
||||
- [ ] **Impersonate user** — admin support tool, audit-logged.
|
||||
- [ ] **GitHub App manifest flow** — `POST /git/github-app/manifest`
|
||||
returns manifest + redirect; instance self-provisions its App
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -258,6 +258,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 +342,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)
|
||||
@@ -485,6 +494,17 @@ 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)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,19 @@ 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})
|
||||
|
||||
// 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,
|
||||
@@ -23,7 +24,7 @@ import {
|
||||
SecurityPage,
|
||||
ComponentShowcase,
|
||||
} 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 +127,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 */}
|
||||
|
||||
@@ -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,33 @@ 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,
|
||||
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 +61,82 @@ 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 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 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 +203,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 +337,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)]" />
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -48,6 +48,8 @@ import {
|
||||
updateCurrentUserProfile,
|
||||
getPlatformSettings,
|
||||
updatePlatformSettings,
|
||||
type Branding,
|
||||
type BrandingInput,
|
||||
getHAStatus,
|
||||
setHAEnabled,
|
||||
triggerFailover,
|
||||
@@ -1158,6 +1160,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 +1171,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 +1284,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>
|
||||
|
||||
@@ -3809,6 +3809,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 +5232,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;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -9409,6 +9876,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':
|
||||
|
||||
@@ -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';
|
||||
@@ -2581,6 +2581,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 +3449,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
|
||||
@@ -5969,6 +6224,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,10 @@ 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
|
||||
up deploy the current directory
|
||||
```
|
||||
|
||||
|
||||
Reference in new issue
Block a user