From 50e39fe1402cb9670b0d28e39d64c39368b74e3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Dvo=C5=99=C3=A1k?= <150935816+Dvorinka@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:35:12 +0200 Subject: [PATCH] =?UTF-8?q?feat(invites):=20team=20invite=20links=20?= =?UTF-8?q?=E2=80=94=20admin=20create/revoke,=20public=20accept=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(branding): white-label — public /branding, admin write, shell theming app_settings branding.* keys (product name, logo/favicon URLs, accent color, docs/support links). Public GET /branding so login and shell theme pre-auth; admin writes via PUT /settings. useBranding() applies document.title, accent CSS var and favicon; BrandWordmark replaces hardcoded wordmarks; admin Platform settings gain a Branding form. MCP containr_branding_get + settings_update. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(invites): team invite links — admin create/revoke, public accept Admins mint single-use invite links (sha256-hashed token at rest, optional email binding, configurable TTL). Public endpoints let the accept page validate the link and register — working even while public signup is disabled. - migration + sqlc queries/model for user_invites - POST /admin/invites (returns one-time token+url), GET list, DELETE revoke - GET /auth/invites/:token public validation, POST /auth/accept-invite - CLI containr invites list|create|revoke; MCP invites tools - /auth/accept-invite page (expired/used/invalid states, bound-email lock) - Admin page Team invites section: create, copy link, revoke - OpenAPI + generated types + CLI guide Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ROADMAP2.md | 8 +- app/backend/internal/api/invites.go | 245 ++++++++++++++++++ app/backend/internal/api/routes.go | 10 + app/backend/internal/cli/commands/invites.go | 92 +++++++ app/backend/internal/cli/root.go | 1 + .../internal/database/sqlcdb/invites.sql.go | 134 ++++++++++ .../internal/database/sqlcdb/models.go | 11 + .../internal/database/sqlcdb/querier.go | 5 + app/backend/internal/mcpserver/server.go | 4 + .../20261105000000_user_invites.sql | 19 ++ app/backend/sqlc/queries/invites.sql | 23 ++ app/backend/sqlc/schema.sql | 11 + app/frontend/src/app/App.tsx | 3 +- .../src/features/admin/pages/AdminPage.tsx | 129 +++++++++ app/frontend/src/features/auth/pages.tsx | 134 ++++++++++ app/frontend/src/generated/api-types.ts | 210 +++++++++++++++ app/frontend/src/generated/docs-snapshot.ts | 2 +- app/frontend/src/lib/api-client.ts | 49 ++++ docs/api/openapi.yaml | 106 ++++++++ docs/guides/CLI.md | 2 + 20 files changed, 1194 insertions(+), 4 deletions(-) create mode 100644 app/backend/internal/api/invites.go create mode 100644 app/backend/internal/cli/commands/invites.go create mode 100644 app/backend/internal/database/sqlcdb/invites.sql.go create mode 100644 app/backend/migrations_goose/20261105000000_user_invites.sql create mode 100644 app/backend/sqlc/queries/invites.sql diff --git a/ROADMAP2.md b/ROADMAP2.md index db89982..00ffd1a 100644 --- a/ROADMAP2.md +++ b/ROADMAP2.md @@ -304,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 diff --git a/app/backend/internal/api/invites.go b/app/backend/internal/api/invites.go new file mode 100644 index 0000000..1741983 --- /dev/null +++ b/app/backend/internal/api/invites.go @@ -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}) +} diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index 197f300..1931641 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -260,6 +260,11 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg 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. public.Any("/internal/wake/:id", handleServiceWake) @@ -495,6 +500,11 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg admin.POST("/admin/banners", handleAdminCreateBanner) admin.PATCH("/admin/banners/:id", handleAdminUpdateBanner) admin.DELETE("/admin/banners/:id", handleAdminDeleteBanner) + + // 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) diff --git a/app/backend/internal/cli/commands/invites.go b/app/backend/internal/cli/commands/invites.go new file mode 100644 index 0000000..029d0d2 --- /dev/null +++ b/app/backend/internal/cli/commands/invites.go @@ -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 ", + 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) +} diff --git a/app/backend/internal/cli/root.go b/app/backend/internal/cli/root.go index fbe54c6..ea2ab3f 100644 --- a/app/backend/internal/cli/root.go +++ b/app/backend/internal/cli/root.go @@ -90,6 +90,7 @@ func init() { rootCmd.AddCommand(commands.WebhooksCmd) rootCmd.AddCommand(commands.OperationsCmd) rootCmd.AddCommand(commands.BannersCmd) + rootCmd.AddCommand(commands.InvitesCmd) rootCmd.AddCommand(commands.UpCmd) } diff --git a/app/backend/internal/database/sqlcdb/invites.sql.go b/app/backend/internal/database/sqlcdb/invites.sql.go new file mode 100644 index 0000000..3ab5905 --- /dev/null +++ b/app/backend/internal/database/sqlcdb/invites.sql.go @@ -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 +} diff --git a/app/backend/internal/database/sqlcdb/models.go b/app/backend/internal/database/sqlcdb/models.go index a32f383..d3c0c36 100644 --- a/app/backend/internal/database/sqlcdb/models.go +++ b/app/backend/internal/database/sqlcdb/models.go @@ -301,6 +301,17 @@ type ServiceTemplate struct { UpdatedAt sql.NullTime `json:"updated_at"` } +type UserInvite struct { + ID uuid.UUID `json:"id"` + TokenHash string `json:"token_hash"` + Email sql.NullString `json:"email"` + CreatedBy uuid.NullUUID `json:"created_by"` + ExpiresAt time.Time `json:"expires_at"` + UsedBy uuid.NullUUID `json:"used_by"` + UsedAt sql.NullTime `json:"used_at"` + CreatedAt sql.NullTime `json:"created_at"` +} + type UserToken struct { ID uuid.UUID `json:"id"` UserID uuid.UUID `json:"user_id"` diff --git a/app/backend/internal/database/sqlcdb/querier.go b/app/backend/internal/database/sqlcdb/querier.go index f67fb4c..c8784ed 100644 --- a/app/backend/internal/database/sqlcdb/querier.go +++ b/app/backend/internal/database/sqlcdb/querier.go @@ -31,6 +31,7 @@ type Querier interface { CreateOutboundWebhook(ctx context.Context, arg CreateOutboundWebhookParams) error CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error + CreateUserInvite(ctx context.Context, arg CreateUserInviteParams) (UserInvite, error) CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error) CreateWebhookDelivery(ctx context.Context, arg CreateWebhookDeliveryParams) error @@ -42,6 +43,7 @@ type Querier interface { DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error) + DeleteUserInvite(ctx context.Context, id uuid.UUID) error DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error) GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error) GetAgent(ctx context.Context, id string) (NodeAgent, error) @@ -63,6 +65,7 @@ type Querier interface { GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error) GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error) + GetUserInviteByTokenHash(ctx context.Context, tokenHash string) (UserInvite, error) GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error) InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error @@ -90,10 +93,12 @@ type Querier interface { ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error) ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error) + ListUserInvites(ctx context.Context) ([]UserInvite, error) ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error) ListWebhookDeliveriesByWebhook(ctx context.Context, webhookID uuid.UUID) ([]WebhookDelivery, error) MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error + MarkUserInviteUsed(ctx context.Context, arg MarkUserInviteUsedParams) error RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error) RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error) SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go index a0575a7..637f84d 100644 --- a/app/backend/internal/mcpserver/server.go +++ b/app/backend/internal/mcpserver/server.go @@ -158,6 +158,10 @@ func init() { 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}) diff --git a/app/backend/migrations_goose/20261105000000_user_invites.sql b/app/backend/migrations_goose/20261105000000_user_invites.sql new file mode 100644 index 0000000..170f91d --- /dev/null +++ b/app/backend/migrations_goose/20261105000000_user_invites.sql @@ -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; diff --git a/app/backend/sqlc/queries/invites.sql b/app/backend/sqlc/queries/invites.sql new file mode 100644 index 0000000..42fabd1 --- /dev/null +++ b/app/backend/sqlc/queries/invites.sql @@ -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; diff --git a/app/backend/sqlc/schema.sql b/app/backend/sqlc/schema.sql index 8c9671b..5f3d386 100644 --- a/app/backend/sqlc/schema.sql +++ b/app/backend/sqlc/schema.sql @@ -316,3 +316,14 @@ CREATE TABLE banners ( created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); + +CREATE TABLE user_invites ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + token_hash VARCHAR(64) NOT NULL UNIQUE, + email VARCHAR(255), + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + used_by UUID REFERENCES users(id) ON DELETE SET NULL, + used_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); diff --git a/app/frontend/src/app/App.tsx b/app/frontend/src/app/App.tsx index 48bef9f..e605971 100644 --- a/app/frontend/src/app/App.tsx +++ b/app/frontend/src/app/App.tsx @@ -24,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'; @@ -134,6 +134,7 @@ export default function App() { } /> } /> } /> + } /> {/* Public browsing — approved projects, templates, docs. */} {/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */} diff --git a/app/frontend/src/features/admin/pages/AdminPage.tsx b/app/frontend/src/features/admin/pages/AdminPage.tsx index 0459d9a..d6e1c4e 100644 --- a/app/frontend/src/features/admin/pages/AdminPage.tsx +++ b/app/frontend/src/features/admin/pages/AdminPage.tsx @@ -4,22 +4,27 @@ 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, @@ -57,9 +62,21 @@ export function AdminPage() { const usersQuery = useQuery({ queryKey: ['admin-users'], queryFn: listAdminUsers, enabled: !isDemoMode }); const projectsQuery = useQuery({ queryKey: ['admin-projects'], queryFn: () => listProjects({ limit: 100 }), enabled: !isDemoMode }); const bannersQuery = useQuery({ queryKey: ['admin-banners'], queryFn: listAdminBanners, enabled: !isDemoMode }); + const invitesQuery = useQuery({ + queryKey: ['admin-invites'], + queryFn: listInvites, + enabled: !isDemoMode, + select: (list) => + list.map((invite) => ({ + ...invite, + expired: !invite.used_at && new Date(invite.expires_at).getTime() < Date.now(), + })), + }); const [editingProject, setEditingProject] = useState(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(null); const invalidate = () => { queryClient.invalidateQueries({ queryKey: ['admin-overview'] }); @@ -67,9 +84,29 @@ export function AdminPage() { queryClient.invalidateQueries({ queryKey: ['admin-projects'] }); queryClient.invalidateQueries({ queryKey: ['admin-banners'] }); queryClient.invalidateQueries({ queryKey: ['active-banners'] }); + queryClient.invalidateQueries({ queryKey: ['admin-invites'] }); queryClient.invalidateQueries({ queryKey: ['projects'] }); }; + const inviteCreateMutation = useMutation({ + mutationFn: () => createInvite({ email: inviteEmail.trim() || undefined }), + onSuccess: (result) => { + setInviteLink(`${window.location.origin}${result.url}`); + setInviteEmail(''); + invalidate(); + }, + onError: (error) => showToast('error', 'Invite failed', error instanceof Error ? error.message : undefined), + }); + + const inviteRevokeMutation = useMutation({ + mutationFn: (id: string) => revokeInvite(id), + onSuccess: () => { + showToast('success', 'Invite revoked'); + invalidate(); + }, + onError: (error) => showToast('error', 'Revoke failed', error instanceof Error ? error.message : undefined), + }); + const bannerCreateMutation = useMutation({ mutationFn: () => createBanner({ @@ -167,6 +204,7 @@ export function AdminPage() { const users = usersQuery.data ?? []; const allProjects = projectsQuery.data ?? []; const banners = bannersQuery.data ?? []; + const invites = invitesQuery.data ?? []; return (
@@ -378,6 +416,97 @@ export function AdminPage() {
+
+
+ +

Team invites

+
+
+
+ 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)]" + /> + +
+ {inviteLink ? ( +
+ {inviteLink} + + +
+ ) : null} + {invites.map((invite) => { + const used = Boolean(invite.used_at); + const expired = !used && invite.expired; + return ( +
+
+

+ {invite.email || 'Open invite'} +

+

+ expires {new Date(invite.expires_at).toLocaleString()} +

+
+ + {used ? 'used' : expired ? 'expired' : 'open'} + + {!used ? ( + + ) : null} +
+ ); + })} + {invites.length === 0 ? ( +

No invites yet.

+ ) : null} +
+
+
diff --git a/app/frontend/src/features/auth/pages.tsx b/app/frontend/src/features/auth/pages.tsx index d7b2ff5..c3d6e54 100644 --- a/app/frontend/src/features/auth/pages.tsx +++ b/app/frontend/src/features/auth/pages.tsx @@ -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(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + if (sessionQuery.data) { + return ; + } + + 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 ( + + + + + + ); + } + + return ( + + + {error ? : null} + {inviteQuery.isLoading ? : null} + {inviteQuery.isError ? : null} + {inviteQuery.data && !inviteValid ? ( + + ) : null} + + {inviteValid ? ( +
+ + + + + + + +
+ ) : null} + +
+ Already have an account? + + Sign in + +
+
+
+ ); +} + export function SignUpPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index 5eb0ac7..1e8bc0d 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -4015,6 +4015,216 @@ export interface paths { }; trace?: never; }; + "/auth/invites/{token}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Validate an invite token (public) */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Invite state */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + valid?: boolean; + used?: boolean; + expired?: boolean; + email?: string | null; + /** Format: date-time */ + expires_at?: string; + }; + }; + }; + /** @description Invite not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/accept-invite": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Register via an invite link (public) */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + token: string; + name: string; + email: string; + password: string; + }; + }; + }; + responses: { + /** @description Created session */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LoginResponse"]; + }; + }; + /** @description Invite used or expired */ + 410: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/invites": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List invites (admin) */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description All invites */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + invites?: Record[]; + }; + }; + }; + }; + }; + 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; + /** @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; diff --git a/app/frontend/src/generated/docs-snapshot.ts b/app/frontend/src/generated/docs-snapshot.ts index 7769fe9..1d221ad 100644 --- a/app/frontend/src/generated/docs-snapshot.ts +++ b/app/frontend/src/generated/docs-snapshot.ts @@ -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 trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs [--follow] runtime logs (SSE follow)\nexec -- 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 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 --public/--public=false toggles it (container recreates, data persists)\ndatabases register 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 --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 converts a compose file into\n a v2 graph config; deploy-graph \n deploys an ad-hoc service graph\nimport compose \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 \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 deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project \ncontainr up --project --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles..project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy ` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete ` removes an unused volume (admin scope).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add app.example.com --default\ncontainr services domains check # DNS preflight per domain\ncontainr services domains remove \n```\n\nDNS check compares each domain to the expected target — set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update --maintenance on\ncontainr services update --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `/api/v1/maintenance`\n— set `app_settings.base_url` or `BASE_URL`; without it the service is\ntaken offline with empty responses. Basic-auth credentials are stored\nbcrypt-hashed (htpasswd) and gate every request.\n\n## Build strategy and resources\n\nSource-built services pick a builder per service:\n\n```bash\ncontainr services update --builder railpack # auto|railpack|nixpacks|dockerfile|static\ncontainr services update --builder static --static-cmd \"npm run build\" --static-dir dist\n```\n\n`auto` detects from repo contents (Dockerfile → railpack → nixpacks).\n`static` builds assets in a node stage and serves them from nginx —\n`--static-cmd` runs inside the build stage (default\n`npm ci && npm run build`), `--static-dir` is copied into the nginx image\n(default `dist`, exposed on port 80).\n\nResource reservations complement the hard `cpu`/`memory` limits:\n\n```bash\ncontainr services update --cpu-reserve 0.25 --memory-reserve 128Mi\n```\n\n`cpu`/`memory` are hard caps (NanoCPUs/Memory); `cpu_reserve`/\n`memory_reserve` are soft hints (CPU shares, MemoryReservation). Empty\nstring clears. `app_settings.default_cpu`/`default_memory` change the\ninstance-level defaults used at service creation;\n`app_settings.capacity_policy=block` fails deployments whose requested\nmemory exceeds node capacity (default `warn` logs only).\n\n## Sleep mode (scale-to-zero)\n\nServices can scale to zero after an idle window and resume on traffic:\n\n```bash\ncontainr services update --sleep --sleep-idle 15 # enable, 15 min idle timeout\ncontainr services update --sleep=false # disable\ncontainr services sleep # sleep now\ncontainr services wake # resume now\ncontainr services env-check # flag empty vars + unresolved ${{refs}}\n```\n\nIdleness is measured as flat container network I/O over the configured\nwindow (swept every 30s). Sleeping removes the workload containers and —\nwhen the service has domains — installs a busybox placeholder that holds\nthe Traefik route at higher priority, serves a reloading \"waking\" page,\nand pings `GET /api/v1/internal/wake/:id` to reconcile the service back.\nThe API must be reachable from containers for the callback (containerized\ndeploys work via the shared network; bare-metal needs `HOST=0.0.0.0`).\n\n## Clone, move, registries\n\n`services clone` duplicates config, volumes, domains, access gates,\nbuilder settings, and variables — ciphertext secret values are copied\nverbatim (no decrypt/re-encrypt). The clone starts stopped:\n\n```bash\ncontainr services clone --name staging-api --project --environment preview\ncontainr services move # name conflicts → 409\n```\n\nNote: cloned domains are verbatim copies — the same hostname attached to\ntwo services makes Traefik routing ambiguous. Re-domain or undeploy one.\n\n`registries` stores private-image pull credentials, owner-scoped and\nencrypted at rest. Matched by image host on every image-sourced deploy:\n\n```bash\ncontainr registries add ghcr ghcr.io --username --password \ncontainr registries add hub docker.io --username --password # Docker Hub\ncontainr registries update --username # empty --password keeps stored\ncontainr registries remove \n```\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio — every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdio MCP host):\n\n```json\n{\n \"mcpServers\": {\n \"containr\": {\n \"command\": \"containr-mcp\",\n \"env\": {\n \"CONTAINR_API_URL\": \"https://containr.example.com\",\n \"CONTAINR_TOKEN\": \"cnp_...\"\n }\n }\n }\n}\n```\n\nScope the token deliberately: `read` is enough for inspection agents,\n`write` for deploy automation, `admin` only for platform operations.\n\nDeployments are serialized per service: a second `deploy` on a busy\nservice lands in `queued` state and runs when the active one finishes.\n`deployments cancel ` drops a queued deployment or aborts a running\none (builds/reconciles stop via context cancellation).\n" + "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 trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs [--follow] runtime logs (SSE follow)\nexec -- 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 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 --public/--public=false toggles it (container recreates, data persists)\ndatabases register 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 --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 converts a compose file into\n a v2 graph config; deploy-graph \n deploys an ad-hoc service graph\nimport compose \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 \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|create|update|delete announcement banners (admin);\n active banners print on `auth status`\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project \ncontainr up --project --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles..project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy ` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete ` removes an unused volume (admin scope).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add app.example.com --default\ncontainr services domains check # DNS preflight per domain\ncontainr services domains remove \n```\n\nDNS check compares each domain to the expected target — set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update --maintenance on\ncontainr services update --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `/api/v1/maintenance`\n— set `app_settings.base_url` or `BASE_URL`; without it the service is\ntaken offline with empty responses. Basic-auth credentials are stored\nbcrypt-hashed (htpasswd) and gate every request.\n\n## Build strategy and resources\n\nSource-built services pick a builder per service:\n\n```bash\ncontainr services update --builder railpack # auto|railpack|nixpacks|dockerfile|static\ncontainr services update --builder static --static-cmd \"npm run build\" --static-dir dist\n```\n\n`auto` detects from repo contents (Dockerfile → railpack → nixpacks).\n`static` builds assets in a node stage and serves them from nginx —\n`--static-cmd` runs inside the build stage (default\n`npm ci && npm run build`), `--static-dir` is copied into the nginx image\n(default `dist`, exposed on port 80).\n\nResource reservations complement the hard `cpu`/`memory` limits:\n\n```bash\ncontainr services update --cpu-reserve 0.25 --memory-reserve 128Mi\n```\n\n`cpu`/`memory` are hard caps (NanoCPUs/Memory); `cpu_reserve`/\n`memory_reserve` are soft hints (CPU shares, MemoryReservation). Empty\nstring clears. `app_settings.default_cpu`/`default_memory` change the\ninstance-level defaults used at service creation;\n`app_settings.capacity_policy=block` fails deployments whose requested\nmemory exceeds node capacity (default `warn` logs only).\n\n## Sleep mode (scale-to-zero)\n\nServices can scale to zero after an idle window and resume on traffic:\n\n```bash\ncontainr services update --sleep --sleep-idle 15 # enable, 15 min idle timeout\ncontainr services update --sleep=false # disable\ncontainr services sleep # sleep now\ncontainr services wake # resume now\ncontainr services env-check # flag empty vars + unresolved ${{refs}}\n```\n\nIdleness is measured as flat container network I/O over the configured\nwindow (swept every 30s). Sleeping removes the workload containers and —\nwhen the service has domains — installs a busybox placeholder that holds\nthe Traefik route at higher priority, serves a reloading \"waking\" page,\nand pings `GET /api/v1/internal/wake/:id` to reconcile the service back.\nThe API must be reachable from containers for the callback (containerized\ndeploys work via the shared network; bare-metal needs `HOST=0.0.0.0`).\n\n## Clone, move, registries\n\n`services clone` duplicates config, volumes, domains, access gates,\nbuilder settings, and variables — ciphertext secret values are copied\nverbatim (no decrypt/re-encrypt). The clone starts stopped:\n\n```bash\ncontainr services clone --name staging-api --project --environment preview\ncontainr services move # name conflicts → 409\n```\n\nNote: cloned domains are verbatim copies — the same hostname attached to\ntwo services makes Traefik routing ambiguous. Re-domain or undeploy one.\n\n`registries` stores private-image pull credentials, owner-scoped and\nencrypted at rest. Matched by image host on every image-sourced deploy:\n\n```bash\ncontainr registries add ghcr ghcr.io --username --password \ncontainr registries add hub docker.io --username --password # Docker Hub\ncontainr registries update --username # empty --password keeps stored\ncontainr registries remove \n```\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio — every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdio MCP host):\n\n```json\n{\n \"mcpServers\": {\n \"containr\": {\n \"command\": \"containr-mcp\",\n \"env\": {\n \"CONTAINR_API_URL\": \"https://containr.example.com\",\n \"CONTAINR_TOKEN\": \"cnp_...\"\n }\n }\n }\n}\n```\n\nScope the token deliberately: `read` is enough for inspection agents,\n`write` for deploy automation, `admin` only for platform operations.\n\nDeployments are serialized per service: a second `deploy` on a busy\nservice lands in `queued` state and runs when the active one finishes.\n`deployments cancel ` drops a queued deployment or aborts a running\none (builds/reconciles stop via context cancellation).\n" }, { "path": "guides/CLOUDFLARE_SETUP.md", diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index 24e1647..55da426 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -1972,6 +1972,55 @@ export async function deleteBanner(id: string): Promise { 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 { + return requestJson(`/auth/invites/${token}`); +} + +export async function acceptInvite(input: { + token: string; + name: string; + email: string; + password: string; +}): Promise { + await requestJson('/auth/accept-invite', { method: 'POST', body: JSON.stringify(input) }); +} + +export async function listInvites(): Promise { + 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 = {}; + 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 { + await requestJson(`/admin/invites/${id}`, { method: 'DELETE' }); +} + export function serviceStatusClass(status: ServiceStatus): string { switch (status) { case 'running': diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index be3bea1..3e153e3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2702,6 +2702,112 @@ paths: '204': description: Deleted + /auth/invites/{token}: + get: + tags: + - Invites + summary: Validate an invite token (public) + responses: + '200': + description: Invite state + content: + application/json: + schema: + type: object + properties: + valid: {type: boolean} + used: {type: boolean} + expired: {type: boolean} + email: {type: string, nullable: true} + expires_at: {type: string, format: date-time} + '404': + description: Invite not found + + /auth/accept-invite: + post: + tags: + - Invites + summary: Register via an invite link (public) + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token, name, email, password] + properties: + token: {type: string} + name: {type: string} + email: {type: string} + password: {type: string, minLength: 8} + responses: + '201': + description: Created session + content: + application/json: + schema: + $ref: '#/components/schemas/LoginResponse' + '410': + description: Invite used or expired + + /admin/invites: + get: + tags: + - Invites + summary: List invites (admin) + security: + - bearerAuth: [] + responses: + '200': + description: All invites + content: + application/json: + schema: + type: object + properties: + invites: + type: array + items: + type: object + post: + tags: + - Invites + summary: Create an invite link (admin) + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + email: {type: string, description: Optionally bind the invite to this email} + expires_in_hours: {type: integer, default: 168, minimum: 1, maximum: 2160} + responses: + '201': + description: Created invite with one-time token + content: + application/json: + schema: + type: object + properties: + invite: {type: object} + token: {type: string, description: Shown once} + url: {type: string} + + /admin/invites/{id}: + delete: + tags: + - Invites + summary: Revoke an invite (admin) + security: + - bearerAuth: [] + parameters: + - {name: id, in: path, required: true, schema: {type: string}} + responses: + '204': + description: Revoked + /branding: get: tags: diff --git a/docs/guides/CLI.md b/docs/guides/CLI.md index 8a9b5ee..ab4d1ee 100644 --- a/docs/guides/CLI.md +++ b/docs/guides/CLI.md @@ -86,6 +86,8 @@ 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 ```