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

* feat(branding): white-label — public /branding, admin write, shell theming

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomáš DvořákandDevin authored and GitHub committed 2026-10-09 10:43:12 +02:00
1 parent 50e39fe140
commit 00e69b3733
16 files changed
+1184 -28

No files matched your search

+51
View File
@@ -246,6 +246,57 @@ func handleAdminSetUserAdmin(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "User updated"})
}
// POST /admin/users/:id/impersonate — mints a 15-minute bearer token acting
// as the target user. Admin-only; admin accounts cannot be impersonated
// (would blur audit attribution). Every use is audit-logged at mint time;
// token carries `impersonated_by` for downstream attribution.
func handleAdminImpersonateUser(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
targetID := strings.TrimSpace(c.Param("id"))
if _, err := uuid.Parse(targetID); err != nil {
respondError(c, http.StatusBadRequest, "INVALID_ID", "invalid user id")
return
}
var email, name string
var isAdmin bool
err := db.QueryRowContext(c.Request.Context(),
`SELECT email, COALESCE(name, ''), is_admin FROM users WHERE id = $1`, targetID).
Scan(&email, &name, &isAdmin)
if err != nil {
respondError(c, http.StatusNotFound, "NOT_FOUND", "user not found")
return
}
if isAdmin {
respondError(c, http.StatusForbidden, "FORBIDDEN", "admin accounts cannot be impersonated")
return
}
actorID := optionalUserID(c)
if actorID == targetID {
respondError(c, http.StatusBadRequest, "VALIDATION", "cannot impersonate yourself")
return
}
expiresAt := time.Now().UTC().Add(15 * time.Minute)
jwtSecret := c.MustGet("jwt_secret").(string)
token, err := generateImpersonationJWT(targetID, email, actorID, jwtSecret, expiresAt)
if err != nil {
respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to generate token")
return
}
LogAuditWithRequest(c, "user", targetID, "impersonate", map[string]interface{}{
"actor_id": actorID,
"target_email": email,
"expires_at": expiresAt.Format(time.RFC3339),
})
c.JSON(http.StatusOK, gin.H{
"token": token,
"expires_at": expiresAt.Format(time.RFC3339),
"user": gin.H{"id": targetID, "email": email, "name": name},
})
}
func handleAdminSetProjectApproval(c *gin.Context) {
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)