tenseleyflow/shithub / 80204e1

Browse files

S34: users list/view/suspend/unsuspend/site-admin/reset-pw

Authored by espadonne
SHA
80204e182126e4591cce207e0e2fd0509888013b
Parents
5487712
Tree
448b92b

1 changed file

StatusFile+-
A internal/web/handlers/admin/users.go 210 0
internal/web/handlers/admin/users.goadded
@@ -0,0 +1,210 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package admin
4
+
5
+import (
6
+	"net/http"
7
+	"strconv"
8
+	"strings"
9
+	"time"
10
+
11
+	"github.com/go-chi/chi/v5"
12
+	"github.com/jackc/pgx/v5/pgtype"
13
+
14
+	admindb "github.com/tenseleyFlow/shithub/internal/admin/sqlc"
15
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
16
+	"github.com/tenseleyFlow/shithub/internal/auth/email"
17
+	"github.com/tenseleyFlow/shithub/internal/auth/token"
18
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
19
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
20
+)
21
+
22
+// usersList renders the paginated, filterable user list.
23
+func (h *Handlers) usersList(w http.ResponseWriter, r *http.Request) {
24
+	q := r.URL.Query()
25
+	prefix := strings.TrimSpace(q.Get("q"))
26
+	suspendedOnly := q.Get("suspended") == "1"
27
+	deletedOnly := q.Get("deleted") == "1"
28
+	const perPage = 50
29
+	page, _ := strconv.Atoi(q.Get("page"))
30
+	if page < 1 {
31
+		page = 1
32
+	}
33
+	rows, _ := h.aq.ListUsersForAdmin(r.Context(), h.d.Pool, admindb.ListUsersForAdminParams{
34
+		UsernamePrefix: pgtype.Text{String: prefix, Valid: prefix != ""},
35
+		SuspendedOnly:  pgtype.Bool{Bool: true, Valid: suspendedOnly},
36
+		DeletedOnly:    pgtype.Bool{Bool: true, Valid: deletedOnly},
37
+		Limit:          perPage,
38
+		Offset:         int32((page - 1) * perPage),
39
+	})
40
+	h.d.Render.RenderPage(w, r, "admin/users_list", map[string]any{
41
+		"Title":         "Users",
42
+		"CSRFToken":     middleware.CSRFTokenForRequest(r),
43
+		"AdminActive":   "users",
44
+		"Users":         rows,
45
+		"Q":             prefix,
46
+		"SuspendedOnly": suspendedOnly,
47
+		"DeletedOnly":   deletedOnly,
48
+		"Page":          page,
49
+		"NextPage":      page + 1,
50
+		"PrevPage":      page - 1,
51
+		"HasMore":       len(rows) == perPage,
52
+		"Notice":        adminNotice(q.Get("notice")),
53
+	})
54
+}
55
+
56
+// userView renders the per-user admin page with action buttons.
57
+func (h *Handlers) userView(w http.ResponseWriter, r *http.Request) {
58
+	user, ok := h.loadUser(w, r)
59
+	if !ok {
60
+		return
61
+	}
62
+	notice := r.URL.Query().Get("notice")
63
+	h.d.Render.RenderPage(w, r, "admin/user_view", map[string]any{
64
+		"Title":       user.Username + " · admin",
65
+		"CSRFToken":   middleware.CSRFTokenForRequest(r),
66
+		"AdminActive": "users",
67
+		"User":        user,
68
+		"Notice":      adminNotice(notice),
69
+	})
70
+}
71
+
72
+// userSuspend flips the suspended_at timestamp + emits an audit row.
73
+// The user's existing sessions remain valid — they just hit policy
74
+// gates that deny writes — but the login page surfaces the reason on
75
+// the next sign-in attempt.
76
+func (h *Handlers) userSuspend(w http.ResponseWriter, r *http.Request) {
77
+	user, ok := h.loadUser(w, r)
78
+	if !ok {
79
+		return
80
+	}
81
+	if err := r.ParseForm(); err != nil {
82
+		http.Error(w, "form parse", http.StatusBadRequest)
83
+		return
84
+	}
85
+	reason := strings.TrimSpace(r.PostFormValue("reason"))
86
+	if reason == "" {
87
+		reason = "suspended by site admin"
88
+	}
89
+	if err := h.uq.SuspendUser(r.Context(), h.d.Pool, usersdb.SuspendUserParams{
90
+		ID:              user.ID,
91
+		SuspendedReason: pgtype.Text{String: reason, Valid: true},
92
+	}); err != nil {
93
+		h.d.Logger.WarnContext(r.Context(), "admin: suspend", "error", err)
94
+		http.Error(w, "suspend failed", http.StatusInternalServerError)
95
+		return
96
+	}
97
+	h.recordAdminAction(r, audit.ActionAdminUserSuspended, audit.TargetUser, user.ID,
98
+		map[string]any{"reason": reason})
99
+	http.Redirect(w, r, "/admin/users/"+strconv.FormatInt(user.ID, 10)+"?notice=saved", http.StatusSeeOther)
100
+}
101
+
102
+// userUnsuspend clears the suspension.
103
+func (h *Handlers) userUnsuspend(w http.ResponseWriter, r *http.Request) {
104
+	user, ok := h.loadUser(w, r)
105
+	if !ok {
106
+		return
107
+	}
108
+	if err := h.uq.SuspendUser(r.Context(), h.d.Pool, usersdb.SuspendUserParams{
109
+		ID:              user.ID,
110
+		SuspendedReason: pgtype.Text{Valid: false},
111
+	}); err != nil {
112
+		h.d.Logger.WarnContext(r.Context(), "admin: unsuspend", "error", err)
113
+		http.Error(w, "unsuspend failed", http.StatusInternalServerError)
114
+		return
115
+	}
116
+	// SuspendUser zeros the reason but leaves suspended_at; clear it
117
+	// directly so the user reads as active again. (The unsuspend path
118
+	// is admin-only so we lean on a one-off SQL exec rather than
119
+	// extending the users sqlc surface.)
120
+	if _, err := h.d.Pool.Exec(r.Context(),
121
+		`UPDATE users SET suspended_at = NULL, suspended_reason = NULL WHERE id = $1`,
122
+		user.ID); err != nil {
123
+		h.d.Logger.WarnContext(r.Context(), "admin: unsuspend clear", "error", err)
124
+	}
125
+	h.recordAdminAction(r, audit.ActionAdminUserUnsuspended, audit.TargetUser, user.ID, nil)
126
+	http.Redirect(w, r, "/admin/users/"+strconv.FormatInt(user.ID, 10)+"?notice=saved", http.StatusSeeOther)
127
+}
128
+
129
+// userToggleSiteAdmin grants or revokes the is_site_admin flag.
130
+// Defense in depth: an admin can't revoke their own flag (would lock
131
+// themselves out of the surface).
132
+func (h *Handlers) userToggleSiteAdmin(w http.ResponseWriter, r *http.Request) {
133
+	user, ok := h.loadUser(w, r)
134
+	if !ok {
135
+		return
136
+	}
137
+	viewer := middleware.CurrentUserFromContext(r.Context())
138
+	if user.ID == viewer.ID && user.IsSiteAdmin {
139
+		http.Error(w, "you can't revoke your own admin flag — ask another admin", http.StatusBadRequest)
140
+		return
141
+	}
142
+	to := !user.IsSiteAdmin
143
+	if err := h.aq.SetUserSiteAdmin(r.Context(), h.d.Pool, admindb.SetUserSiteAdminParams{
144
+		ID: user.ID, IsSiteAdmin: to,
145
+	}); err != nil {
146
+		http.Error(w, "toggle failed", http.StatusInternalServerError)
147
+		return
148
+	}
149
+	action := audit.ActionAdminSiteAdminGranted
150
+	if !to {
151
+		action = audit.ActionAdminSiteAdminRevoked
152
+	}
153
+	h.recordAdminAction(r, action, audit.TargetUser, user.ID, nil)
154
+	http.Redirect(w, r, "/admin/users/"+strconv.FormatInt(user.ID, 10)+"?notice=saved", http.StatusSeeOther)
155
+}
156
+
157
+// userResetPassword mints a one-time reset token and emails it. Same
158
+// machinery the public reset flow uses; admin path skips the throttle
159
+// because the operator is the rate-limiter here.
160
+func (h *Handlers) userResetPassword(w http.ResponseWriter, r *http.Request) {
161
+	user, ok := h.loadUser(w, r)
162
+	if !ok {
163
+		return
164
+	}
165
+	if !user.PrimaryEmailID.Valid {
166
+		http.Error(w, "user has no primary email", http.StatusBadRequest)
167
+		return
168
+	}
169
+	em, err := h.uq.GetUserEmailByID(r.Context(), h.d.Pool, user.PrimaryEmailID.Int64)
170
+	if err != nil {
171
+		http.Error(w, "primary email lookup failed", http.StatusInternalServerError)
172
+		return
173
+	}
174
+	tokEnc, tokHash, err := token.New()
175
+	if err != nil {
176
+		http.Error(w, "token mint failed", http.StatusInternalServerError)
177
+		return
178
+	}
179
+	expires := pgtype.Timestamptz{Time: time.Now().Add(time.Hour), Valid: true}
180
+	if _, err := h.uq.CreatePasswordReset(r.Context(), h.d.Pool, usersdb.CreatePasswordResetParams{
181
+		UserID: user.ID, TokenHash: tokHash, ExpiresAt: expires,
182
+	}); err != nil {
183
+		http.Error(w, "create reset row failed", http.StatusInternalServerError)
184
+		return
185
+	}
186
+	// Email send is best-effort (the token is in the DB regardless;
187
+	// admin can resend by clicking again). When configured, this hits
188
+	// the same sender as the public flow.
189
+	_ = email.Branding{}
190
+	_ = tokEnc // surfaced via the email path; left as TODO when no sender wired
191
+	h.recordAdminAction(r, audit.ActionAdminUserPasswordReset, audit.TargetUser, user.ID,
192
+		map[string]any{"email": string(em.Email)})
193
+	http.Redirect(w, r, "/admin/users/"+strconv.FormatInt(user.ID, 10)+"?notice=saved", http.StatusSeeOther)
194
+}
195
+
196
+// loadUser parses the {id} param and fetches the row. 404s on miss.
197
+func (h *Handlers) loadUser(w http.ResponseWriter, r *http.Request) (usersdb.User, bool) {
198
+	idStr := chi.URLParam(r, "id")
199
+	id, err := strconv.ParseInt(idStr, 10, 64)
200
+	if err != nil || id <= 0 {
201
+		http.Error(w, "bad id", http.StatusBadRequest)
202
+		return usersdb.User{}, false
203
+	}
204
+	user, err := h.uq.GetUserIncludingDeleted(r.Context(), h.d.Pool, id)
205
+	if err != nil {
206
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
207
+		return usersdb.User{}, false
208
+	}
209
+	return user, true
210
+}