1
0
forked from wrenn/wrenn
Files
wrenn-releases/internal/api/handlers_users.go
Rafeed M. Bhuiyan 52ad21c339 v0.1.3 (#36)
## What's new

Compliance, audit, and account lifecycle improvements — admin actions are now fully auditable, user data is properly anonymized on deletion, and OAuth signup flow gives users control over their profile.

### Audit

- Added audit logging for all admin actions (user activate/deactivate, team BYOC toggle, team delete, template delete, build create/cancel)
- Added admin audit page with infinite scroll and hierarchical filters
- Fixed audit log team assignment — admin/host actions now correctly land under PlatformTeamID
- Anonymize audit logs on user hard-delete (actor name, IDs, emails stripped)
- Deduplicated audit logger internals (665 → 374 lines, no behavior change)

### Authentication

- Separated GitHub OAuth login/signup flows — login no longer auto-creates accounts
- Added name confirmation dialog for new GitHub signups

### Account Lifecycle

- Email notification sent when account is permanently deleted after grace period
- Audit log anonymization tied to user purge (per-user transactional)

### UX

- Removed accent gradient bars from admin host dialogs (border + shadow only)
- Frontend renders deleted users as styled badge in audit log view

### Others

- Version bump
- Bug fixes

Reviewed-on: wrenn/wrenn#36
2026-04-21 10:11:49 +00:00

165 lines
4.6 KiB
Go

package api
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/pkg/audit"
"git.omukk.dev/wrenn/wrenn/pkg/auth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/pkg/service"
)
type usersHandler struct {
db *db.Queries
svc *service.UserService
audit *audit.AuditLogger
}
func newUsersHandler(db *db.Queries, svc *service.UserService, al *audit.AuditLogger) *usersHandler {
return &usersHandler{db: db, svc: svc, audit: al}
}
// Search handles GET /v1/users/search?email=<prefix>
// Returns up to 10 users whose email starts with the given prefix.
// The prefix must be at least 3 characters long and contain "@".
func (h *usersHandler) Search(w http.ResponseWriter, r *http.Request) {
auth.MustFromContext(r.Context()) // ensure authenticated
prefix := strings.TrimSpace(r.URL.Query().Get("email"))
if len(prefix) < 3 || !strings.Contains(prefix, "@") {
writeError(w, http.StatusBadRequest, "invalid_request", "email prefix must be at least 3 characters and contain '@'")
return
}
// Escape LIKE metacharacters to prevent pattern injection.
escaped := strings.NewReplacer("%", "\\%", "_", "\\_").Replace(prefix)
results, err := h.db.SearchUsersByEmailPrefix(r.Context(), pgtype.Text{String: escaped, Valid: true})
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "search failed")
return
}
type userResult struct {
UserID string `json:"user_id"`
Email string `json:"email"`
}
resp := make([]userResult, len(results))
for i, u := range results {
resp[i] = userResult{UserID: id.FormatUserID(u.ID), Email: u.Email}
}
writeJSON(w, http.StatusOK, resp)
}
// AdminListUsers handles GET /v1/admin/users?page=1
// Returns a paginated list of all users with team counts.
func (h *usersHandler) AdminListUsers(w http.ResponseWriter, r *http.Request) {
page := 1
if p := r.URL.Query().Get("page"); p != "" {
if _, err := fmt.Sscanf(p, "%d", &page); err != nil || page < 1 {
page = 1
}
}
const perPage = 100
offset := int32((page - 1) * perPage)
users, total, err := h.svc.AdminListUsers(r.Context(), perPage, offset)
if err != nil {
status, code, msg := serviceErrToHTTP(err)
writeError(w, status, code, msg)
return
}
type adminUserResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
IsAdmin bool `json:"is_admin"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
TeamsJoined int32 `json:"teams_joined"`
TeamsOwned int32 `json:"teams_owned"`
}
resp := make([]adminUserResponse, len(users))
for i, u := range users {
resp[i] = adminUserResponse{
ID: id.FormatUserID(u.ID),
Email: u.Email,
Name: u.Name,
IsAdmin: u.IsAdmin,
Status: u.Status,
CreatedAt: u.CreatedAt.Format(time.RFC3339),
TeamsJoined: u.TeamsJoined,
TeamsOwned: u.TeamsOwned,
}
}
totalPages := (total + perPage - 1) / perPage
writeJSON(w, http.StatusOK, map[string]any{
"users": resp,
"total": total,
"page": page,
"per_page": perPage,
"total_pages": totalPages,
})
}
// SetUserActive handles PUT /v1/admin/users/{id}/active
// Enables or disables a user account. Admins cannot deactivate themselves.
func (h *usersHandler) SetUserActive(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context())
userIDStr := chi.URLParam(r, "id")
userID, err := id.ParseUserID(userIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid user ID")
return
}
var req struct {
Active bool `json:"active"`
}
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
return
}
if ac.UserID == userID && !req.Active {
writeError(w, http.StatusBadRequest, "invalid_request", "cannot deactivate your own account")
return
}
newStatus := "active"
if !req.Active {
newStatus = "disabled"
}
// Look up user email for audit log before changing status.
user, err := h.db.GetUserByID(r.Context(), userID)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "user not found")
return
}
if err := h.svc.SetUserStatus(r.Context(), userID, newStatus); err != nil {
httpStatus, code, msg := serviceErrToHTTP(err)
writeError(w, httpStatus, code, msg)
return
}
if req.Active {
h.audit.LogUserActivate(r.Context(), ac, userID, user.Email)
} else {
h.audit.LogUserDeactivate(r.Context(), ac, userID, user.Email)
}
w.WriteHeader(http.StatusNoContent)
}