mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50e39fe140 |
No files matched your search
+6
-2
@@ -304,8 +304,12 @@ and wired refs, one click or one CLI call.
|
|||||||
- [ ] **Interactive terminal** — upgrade exec to WS PTY (`xterm.js`);
|
- [ ] **Interactive terminal** — upgrade exec to WS PTY (`xterm.js`);
|
||||||
`nodes/:id/terminal` via agent (dflow's wetty without the extra
|
`nodes/:id/terminal` via agent (dflow's wetty without the extra
|
||||||
container).
|
container).
|
||||||
- [ ] **Team invites** — `POST /invites` → signed link → join flow;
|
- [x] **Team invites** — `POST /admin/invites` returns a one-time
|
||||||
replaces manual-only user creation.
|
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.
|
- [ ] **Impersonate user** — admin support tool, audit-logged.
|
||||||
- [ ] **GitHub App manifest flow** — `POST /git/github-app/manifest`
|
- [ ] **GitHub App manifest flow** — `POST /git/github-app/manifest`
|
||||||
returns manifest + redirect; instance self-provisions its App
|
returns manifest + redirect; instance self-provisions its App
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -260,6 +260,11 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
public.GET("/maintenance", handleMaintenancePage)
|
public.GET("/maintenance", handleMaintenancePage)
|
||||||
public.GET("/branding", handleGetBranding)
|
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
|
// Wake callback for sleep-mode placeholders. Unauthenticated but
|
||||||
// non-destructive: it only reconciles the service back to running.
|
// non-destructive: it only reconciles the service back to running.
|
||||||
public.Any("/internal/wake/:id", handleServiceWake)
|
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.POST("/admin/banners", handleAdminCreateBanner)
|
||||||
admin.PATCH("/admin/banners/:id", handleAdminUpdateBanner)
|
admin.PATCH("/admin/banners/:id", handleAdminUpdateBanner)
|
||||||
admin.DELETE("/admin/banners/:id", handleAdminDeleteBanner)
|
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", handleGetAuditLogs)
|
||||||
admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
|
admin.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -90,6 +90,7 @@ func init() {
|
|||||||
rootCmd.AddCommand(commands.WebhooksCmd)
|
rootCmd.AddCommand(commands.WebhooksCmd)
|
||||||
rootCmd.AddCommand(commands.OperationsCmd)
|
rootCmd.AddCommand(commands.OperationsCmd)
|
||||||
rootCmd.AddCommand(commands.BannersCmd)
|
rootCmd.AddCommand(commands.BannersCmd)
|
||||||
|
rootCmd.AddCommand(commands.InvitesCmd)
|
||||||
rootCmd.AddCommand(commands.UpCmd)
|
rootCmd.AddCommand(commands.UpCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: invites.sql
|
||||||
|
|
||||||
|
package sqlcdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createUserInvite = `-- name: CreateUserInvite :one
|
||||||
|
INSERT INTO user_invites (token_hash, email, created_by, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING user_invites.id, user_invites.token_hash, user_invites.email, user_invites.created_by, user_invites.expires_at, user_invites.used_by, user_invites.used_at, user_invites.created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateUserInviteParams struct {
|
||||||
|
TokenHash string `json:"token_hash"`
|
||||||
|
Email sql.NullString `json:"email"`
|
||||||
|
CreatedBy uuid.NullUUID `json:"created_by"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateUserInvite(ctx context.Context, arg CreateUserInviteParams) (UserInvite, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, createUserInvite,
|
||||||
|
arg.TokenHash,
|
||||||
|
arg.Email,
|
||||||
|
arg.CreatedBy,
|
||||||
|
arg.ExpiresAt,
|
||||||
|
)
|
||||||
|
var i UserInvite
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.TokenHash,
|
||||||
|
&i.Email,
|
||||||
|
&i.CreatedBy,
|
||||||
|
&i.ExpiresAt,
|
||||||
|
&i.UsedBy,
|
||||||
|
&i.UsedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteUserInvite = `-- name: DeleteUserInvite :exec
|
||||||
|
DELETE FROM user_invites
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteUserInvite(ctx context.Context, id uuid.UUID) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, deleteUserInvite, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUserInviteByTokenHash = `-- name: GetUserInviteByTokenHash :one
|
||||||
|
SELECT user_invites.id, user_invites.token_hash, user_invites.email, user_invites.created_by, user_invites.expires_at, user_invites.used_by, user_invites.used_at, user_invites.created_at
|
||||||
|
FROM user_invites
|
||||||
|
WHERE token_hash = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetUserInviteByTokenHash(ctx context.Context, tokenHash string) (UserInvite, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getUserInviteByTokenHash, tokenHash)
|
||||||
|
var i UserInvite
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.TokenHash,
|
||||||
|
&i.Email,
|
||||||
|
&i.CreatedBy,
|
||||||
|
&i.ExpiresAt,
|
||||||
|
&i.UsedBy,
|
||||||
|
&i.UsedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listUserInvites = `-- name: ListUserInvites :many
|
||||||
|
SELECT user_invites.id, user_invites.token_hash, user_invites.email, user_invites.created_by, user_invites.expires_at, user_invites.used_by, user_invites.used_at, user_invites.created_at
|
||||||
|
FROM user_invites
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListUserInvites(ctx context.Context) ([]UserInvite, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listUserInvites)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []UserInvite{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i UserInvite
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.TokenHash,
|
||||||
|
&i.Email,
|
||||||
|
&i.CreatedBy,
|
||||||
|
&i.ExpiresAt,
|
||||||
|
&i.UsedBy,
|
||||||
|
&i.UsedAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const markUserInviteUsed = `-- name: MarkUserInviteUsed :exec
|
||||||
|
UPDATE user_invites
|
||||||
|
SET used_by = $2, used_at = now()
|
||||||
|
WHERE id = $1 AND used_at IS NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
type MarkUserInviteUsedParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
UsedBy uuid.NullUUID `json:"used_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) MarkUserInviteUsed(ctx context.Context, arg MarkUserInviteUsedParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, markUserInviteUsed, arg.ID, arg.UsedBy)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -301,6 +301,17 @@ type ServiceTemplate struct {
|
|||||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
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 {
|
type UserToken struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
UserID uuid.UUID `json:"user_id"`
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type Querier interface {
|
|||||||
CreateOutboundWebhook(ctx context.Context, arg CreateOutboundWebhookParams) error
|
CreateOutboundWebhook(ctx context.Context, arg CreateOutboundWebhookParams) error
|
||||||
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
|
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
|
||||||
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
|
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
|
||||||
|
CreateUserInvite(ctx context.Context, arg CreateUserInviteParams) (UserInvite, error)
|
||||||
CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error
|
CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error
|
||||||
CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error)
|
CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error)
|
||||||
CreateWebhookDelivery(ctx context.Context, arg CreateWebhookDeliveryParams) error
|
CreateWebhookDelivery(ctx context.Context, arg CreateWebhookDeliveryParams) error
|
||||||
@@ -42,6 +43,7 @@ type Querier interface {
|
|||||||
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
|
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
|
||||||
DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error
|
DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error
|
||||||
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, 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)
|
DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error)
|
||||||
GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error)
|
GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error)
|
||||||
GetAgent(ctx context.Context, id string) (NodeAgent, 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)
|
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
|
||||||
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
|
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
|
||||||
GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, 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)
|
GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error)
|
||||||
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
|
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
|
||||||
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
|
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
|
||||||
@@ -90,10 +93,12 @@ type Querier interface {
|
|||||||
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
|
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
|
||||||
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
|
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
|
||||||
ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]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)
|
ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error)
|
||||||
ListWebhookDeliveriesByWebhook(ctx context.Context, webhookID uuid.UUID) ([]WebhookDelivery, error)
|
ListWebhookDeliveriesByWebhook(ctx context.Context, webhookID uuid.UUID) ([]WebhookDelivery, error)
|
||||||
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
|
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
|
||||||
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
|
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
|
||||||
|
MarkUserInviteUsed(ctx context.Context, arg MarkUserInviteUsedParams) error
|
||||||
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
|
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
|
||||||
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
|
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
|
||||||
SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error
|
SetDatabaseBackupRemoteKeyByID(ctx context.Context, arg SetDatabaseBackupRemoteKeyByIDParams) error
|
||||||
|
|||||||
@@ -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_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_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)
|
// Nodes / agent tokens (admin)
|
||||||
register(spec{"containr_nodes_list", "List node agents (admin)", "GET", "/agents", nil, nil, false})
|
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})
|
register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false})
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Team invites: admin-generated single-use links that let a new user
|
||||||
|
-- register even while public signup is closed. Tokens are sha256-hashed
|
||||||
|
-- at rest like PATs.
|
||||||
|
CREATE TABLE IF NOT EXISTS user_invites (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
token_hash varchar(64) NOT NULL UNIQUE,
|
||||||
|
email varchar(255),
|
||||||
|
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
used_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
used_at timestamptz,
|
||||||
|
created_at timestamptz DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_invites_expires ON user_invites(expires_at);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS user_invites;
|
||||||
@@ -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;
|
||||||
@@ -316,3 +316,14 @@ CREATE TABLE banners (
|
|||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
updated_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()
|
||||||
|
);
|
||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
SecurityPage,
|
SecurityPage,
|
||||||
ComponentShowcase,
|
ComponentShowcase,
|
||||||
} from '@/features/secondary/pages';
|
} 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 { useAuthSession } from '@/lib/use-auth-session';
|
||||||
import { useDemoMode } from '@/lib/demo-mode';
|
import { useDemoMode } from '@/lib/demo-mode';
|
||||||
import { getCurrentUserProfile } from '@/lib/api-client';
|
import { getCurrentUserProfile } from '@/lib/api-client';
|
||||||
@@ -134,6 +134,7 @@ export default function App() {
|
|||||||
<Route path="/" element={<LandingPage />} />
|
<Route path="/" element={<LandingPage />} />
|
||||||
<Route path="/auth/sign-in" element={<SignInPage />} />
|
<Route path="/auth/sign-in" element={<SignInPage />} />
|
||||||
<Route path="/auth/sign-up" element={<SignUpPage />} />
|
<Route path="/auth/sign-up" element={<SignUpPage />} />
|
||||||
|
<Route path="/auth/accept-invite" element={<AcceptInvitePage />} />
|
||||||
|
|
||||||
{/* Public browsing — approved projects, templates, docs. */}
|
{/* Public browsing — approved projects, templates, docs. */}
|
||||||
{/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */}
|
{/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */}
|
||||||
|
|||||||
@@ -4,22 +4,27 @@ import { Link } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
|
Copy,
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
Megaphone,
|
Megaphone,
|
||||||
Pencil,
|
Pencil,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
UserPlus,
|
||||||
Users,
|
Users,
|
||||||
XCircle,
|
XCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
createBanner,
|
createBanner,
|
||||||
|
createInvite,
|
||||||
deleteBanner,
|
deleteBanner,
|
||||||
deleteProject,
|
deleteProject,
|
||||||
getAdminOverview,
|
getAdminOverview,
|
||||||
listAdminBanners,
|
listAdminBanners,
|
||||||
listAdminUsers,
|
listAdminUsers,
|
||||||
|
listInvites,
|
||||||
listProjects,
|
listProjects,
|
||||||
|
revokeInvite,
|
||||||
setProjectApproval,
|
setProjectApproval,
|
||||||
setUserAdmin,
|
setUserAdmin,
|
||||||
updateBanner,
|
updateBanner,
|
||||||
@@ -57,9 +62,21 @@ export function AdminPage() {
|
|||||||
const usersQuery = useQuery({ queryKey: ['admin-users'], queryFn: listAdminUsers, 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 projectsQuery = useQuery({ queryKey: ['admin-projects'], queryFn: () => listProjects({ limit: 100 }), enabled: !isDemoMode });
|
||||||
const bannersQuery = useQuery({ queryKey: ['admin-banners'], queryFn: listAdminBanners, 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 [editingProject, setEditingProject] = useState<ProjectEntity | null>(null);
|
||||||
const [editForm, setEditForm] = useState({ name: '', description: '' });
|
const [editForm, setEditForm] = useState({ name: '', description: '' });
|
||||||
const [bannerForm, setBannerForm] = useState({ title: '', body: '', level: 'info' as Banner['level'] });
|
const [bannerForm, setBannerForm] = useState({ title: '', body: '', level: 'info' as Banner['level'] });
|
||||||
|
const [inviteEmail, setInviteEmail] = useState('');
|
||||||
|
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||||
|
|
||||||
const invalidate = () => {
|
const invalidate = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-overview'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-overview'] });
|
||||||
@@ -67,9 +84,29 @@ export function AdminPage() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['admin-projects'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-projects'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-banners'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-banners'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['active-banners'] });
|
queryClient.invalidateQueries({ queryKey: ['active-banners'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-invites'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
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({
|
const bannerCreateMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
createBanner({
|
createBanner({
|
||||||
@@ -167,6 +204,7 @@ export function AdminPage() {
|
|||||||
const users = usersQuery.data ?? [];
|
const users = usersQuery.data ?? [];
|
||||||
const allProjects = projectsQuery.data ?? [];
|
const allProjects = projectsQuery.data ?? [];
|
||||||
const banners = bannersQuery.data ?? [];
|
const banners = bannersQuery.data ?? [];
|
||||||
|
const invites = invitesQuery.data ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-6xl px-5 py-8">
|
<div className="mx-auto max-w-6xl px-5 py-8">
|
||||||
@@ -378,6 +416,97 @@ export function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section className="mt-8 pb-10">
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<Users size={15} className="text-[var(--accent-tertiary)]" />
|
<Users size={15} className="text-[var(--accent-tertiary)]" />
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
signUpWithEmail,
|
signUpWithEmail,
|
||||||
startGitHubSignIn,
|
startGitHubSignIn,
|
||||||
} from '@/lib/auth-client';
|
} from '@/lib/auth-client';
|
||||||
|
import { acceptInvite, getInvite } from '@/lib/api-client';
|
||||||
import { useAuthSession } from '@/lib/use-auth-session';
|
import { useAuthSession } from '@/lib/use-auth-session';
|
||||||
|
|
||||||
function sanitizeRedirect(raw: string | null): string {
|
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() {
|
export function SignUpPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|||||||
@@ -4015,6 +4015,216 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
trace?: 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": {
|
"/branding": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
|
|||||||
"path": "guides/CLI.md",
|
"path": "guides/CLI.md",
|
||||||
"section": "guides",
|
"section": "guides",
|
||||||
"title": "Containr CLI",
|
"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",
|
"path": "guides/CLOUDFLARE_SETUP.md",
|
||||||
|
|||||||
@@ -1972,6 +1972,55 @@ export async function deleteBanner(id: string): Promise<void> {
|
|||||||
await requestJson(`/admin/banners/${id}`, { method: 'DELETE' });
|
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 {
|
export function serviceStatusClass(status: ServiceStatus): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'running':
|
case 'running':
|
||||||
|
|||||||
@@ -2702,6 +2702,112 @@ paths:
|
|||||||
'204':
|
'204':
|
||||||
description: Deleted
|
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:
|
/branding:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ operations live ops view: active deploys, queue depth,
|
|||||||
24h failures, cron runs, backups
|
24h failures, cron runs, backups
|
||||||
banners list|create|update|delete announcement banners (admin);
|
banners list|create|update|delete announcement banners (admin);
|
||||||
active banners print on `auth status`
|
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
|
up deploy the current directory
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user