Go · 8227 bytes Raw Blame History
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 // SR2 M2: was inline SQL with the comment "lean on a one-off SQL
117 // exec rather than extending the users sqlc surface." That comment
118 // stopped being true the moment we needed to test it; usersdb has
119 // the UnsuspendUser query now.
120 if err := h.uq.UnsuspendUser(r.Context(), h.d.Pool, user.ID); err != nil {
121 h.d.Logger.WarnContext(r.Context(), "admin: unsuspend clear", "error", err)
122 }
123 h.recordAdminAction(r, audit.ActionAdminUserUnsuspended, audit.TargetUser, user.ID, nil)
124 http.Redirect(w, r, "/admin/users/"+strconv.FormatInt(user.ID, 10)+"?notice=saved", http.StatusSeeOther)
125 }
126
127 // userToggleSiteAdmin grants or revokes the is_site_admin flag.
128 // Defense in depth: an admin can't revoke their own flag (would lock
129 // themselves out of the surface).
130 func (h *Handlers) userToggleSiteAdmin(w http.ResponseWriter, r *http.Request) {
131 user, ok := h.loadUser(w, r)
132 if !ok {
133 return
134 }
135 viewer := middleware.CurrentUserFromContext(r.Context())
136 if user.ID == viewer.ID && user.IsSiteAdmin {
137 http.Error(w, "you can't revoke your own admin flag — ask another admin", http.StatusBadRequest)
138 return
139 }
140 to := !user.IsSiteAdmin
141 if err := h.aq.SetUserSiteAdmin(r.Context(), h.d.Pool, admindb.SetUserSiteAdminParams{
142 ID: user.ID, IsSiteAdmin: to,
143 }); err != nil {
144 http.Error(w, "toggle failed", http.StatusInternalServerError)
145 return
146 }
147 action := audit.ActionAdminSiteAdminGranted
148 if !to {
149 action = audit.ActionAdminSiteAdminRevoked
150 }
151 h.recordAdminAction(r, action, audit.TargetUser, user.ID, nil)
152 http.Redirect(w, r, "/admin/users/"+strconv.FormatInt(user.ID, 10)+"?notice=saved", http.StatusSeeOther)
153 }
154
155 // userResetPassword mints a one-time reset token and emails it. Same
156 // machinery the public reset flow uses; admin path skips the throttle
157 // because the operator is the rate-limiter here.
158 func (h *Handlers) userResetPassword(w http.ResponseWriter, r *http.Request) {
159 user, ok := h.loadUser(w, r)
160 if !ok {
161 return
162 }
163 if !user.PrimaryEmailID.Valid {
164 http.Error(w, "user has no primary email", http.StatusBadRequest)
165 return
166 }
167 em, err := h.uq.GetUserEmailByID(r.Context(), h.d.Pool, user.PrimaryEmailID.Int64)
168 if err != nil {
169 http.Error(w, "primary email lookup failed", http.StatusInternalServerError)
170 return
171 }
172 tokEnc, tokHash, err := token.New()
173 if err != nil {
174 http.Error(w, "token mint failed", http.StatusInternalServerError)
175 return
176 }
177 expires := pgtype.Timestamptz{Time: time.Now().Add(time.Hour), Valid: true}
178 if _, err := h.uq.CreatePasswordReset(r.Context(), h.d.Pool, usersdb.CreatePasswordResetParams{
179 UserID: user.ID, TokenHash: tokHash, ExpiresAt: expires,
180 }); err != nil {
181 http.Error(w, "create reset row failed", http.StatusInternalServerError)
182 return
183 }
184
185 // Send the message (best-effort: the token row is committed; the
186 // admin can resend by clicking again). The audit row records
187 // whether the send succeeded, so a stuck mailbox shows up in
188 // /admin/audit instead of being invisible (SR2 C3 fix).
189 emailSent := false
190 emailErr := ""
191 if h.d.Email != nil {
192 msg, err := email.ResetMessage(h.d.Branding, string(em.Email), tokEnc)
193 if err != nil {
194 emailErr = err.Error()
195 h.d.Logger.WarnContext(r.Context(), "admin reset: build message", "error", err)
196 } else if err := h.d.Email.Send(r.Context(), msg); err != nil {
197 emailErr = err.Error()
198 h.d.Logger.WarnContext(r.Context(), "admin reset: send", "error", err)
199 } else {
200 emailSent = true
201 }
202 } else {
203 emailErr = "no sender wired"
204 }
205 auditMeta := map[string]any{"email": string(em.Email), "email_sent": emailSent}
206 if emailErr != "" {
207 auditMeta["email_error"] = emailErr
208 }
209 h.recordAdminAction(r, audit.ActionAdminUserPasswordReset, audit.TargetUser, user.ID, auditMeta)
210 http.Redirect(w, r, "/admin/users/"+strconv.FormatInt(user.ID, 10)+"?notice=saved", http.StatusSeeOther)
211 }
212
213 // loadUser parses the {id} param and fetches the row. 404s on miss.
214 func (h *Handlers) loadUser(w http.ResponseWriter, r *http.Request) (usersdb.User, bool) {
215 idStr := chi.URLParam(r, "id")
216 id, err := strconv.ParseInt(idStr, 10, 64)
217 if err != nil || id <= 0 {
218 http.Error(w, "bad id", http.StatusBadRequest)
219 return usersdb.User{}, false
220 }
221 user, err := h.uq.GetUserIncludingDeleted(r.Context(), h.d.Pool, id)
222 if err != nil {
223 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
224 return usersdb.User{}, false
225 }
226 return user, true
227 }
228