1package web
2
3import (
4 "errors"
5 "log/slog"
6 "net/http"
7 "regexp"
8 "slices"
9 "strings"
10
11 "tug/internal/transport/web/text"
12)
13
14const accountBadges = 20
15
16var reSessionID = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`)
17
18var accountPasswordProblems = map[AccountProblem]text.Key{
19 AccountPasswordWrong: text.AccountProblemWrong,
20 AccountPasswordShort: text.AccountProblemShort,
21 AccountPasswordLong: text.AccountProblemLong,
22 AccountBusy: text.AccountProblemBusy,
23 AccountPasswordChanged: text.AccountProblemStale,
24}
25
26type portal struct {
27 kit *Kit
28 b AccountBackend
29 e SessionEnder
30}
31
32type accountOverviewData struct {
33 Username Ident
34 DisplayName string
35 Groups []Ident
36 More int
37 Admin string
38 Google bool
39 GoogleLinked bool
40 GoogleSince string
41 Portdistrict bool
42 Primary bool
43 SSHKeys sshKeyList
44}
45
46type accountProfileData struct {
47 DisplayName string
48 Message text.Key
49}
50
51type accountPasswordData struct {
52 Message text.Key
53}
54
55type accountSessionRow struct {
56 ID Ident
57 Host Ident
58 Opened string
59 Ends string
60 Current bool
61 Browser bool
62 Device string
63 Agent string
64 Address string
65 Client Ident
66 LastSeen string
67}
68
69type accountSessionsData struct {
70 Rows []accountSessionRow
71 Pager adminPager
72 Others bool
73}
74
75func (k *Kit) newAccount(b AccountBackend) *portal {
76 if b == nil {
77 panic("web: the account pages need a backend")
78 }
79 return &portal{kit: k, b: b}
80}
81
82func (k *Kit) AccountOverview(b AccountBackend) http.Handler {
83 return http.HandlerFunc(k.newAccount(b).overview)
84}
85
86func (k *Kit) AccountRoutes(b AccountBackend, e SessionEnder) []Route {
87 if e == nil {
88 panic("web: the account pages need a session ender")
89 }
90 a := k.newAccount(b)
91 a.e = e
92 return append([]Route{
93 {Method: http.MethodGet, Path: "/profile", Handler: http.HandlerFunc(a.profile)},
94 {Method: http.MethodPost, Path: "/profile", Handler: http.HandlerFunc(a.saveProfile)},
95 {Method: http.MethodGet, Path: "/password", Handler: http.HandlerFunc(a.password)},
96 {Method: http.MethodPost, Path: "/password", Handler: http.HandlerFunc(a.changePassword)},
97 {Method: http.MethodGet, Path: "/sessions", Handler: http.HandlerFunc(a.sessions)},
98 {Method: http.MethodPost, Path: "/sessions/revoke", Handler: http.HandlerFunc(a.revoke)},
99 {Method: http.MethodGet, Path: "/sessions/revoke-all", Handler: http.HandlerFunc(a.revokeAllShow)},
100 {Method: http.MethodPost, Path: "/sessions/revoke-all", Handler: http.HandlerFunc(a.revokeAll)},
101 {Method: http.MethodGet, Path: BrowserLogoutPath, Access: SignedIn, Handler: http.HandlerFunc(a.browserShow)},
102 {Method: http.MethodPost, Path: BrowserLogoutPath, Access: SignedIn, Handler: http.HandlerFunc(a.browserSignOut)},
103 }, a.sshKeyRoutes()...)
104}
105
106func (a *portal) render(w http.ResponseWriter, r *http.Request, status int, name string, data any) {
107 p := a.kit.formPage(w, r, data)
108 p.Host = Ident(a.kit.server.DomainAccount)
109 a.kit.render(w, r, status, name, p)
110}
111
112func (a *portal) failed(w http.ResponseWriter, r *http.Request, step string, err error) {
113 if errors.Is(err, ErrNoSession) && r.Method != http.MethodGet {
114 a.kit.SeeOther(w, r, "/")
115 return
116 }
117 if errors.Is(err, ErrAccountNotFound) {
118 a.kit.ErrorPage(w, r, http.StatusNotFound)
119 return
120 }
121 slog.Error("account page failed", "step", step, "err", err)
122 a.kit.renderError(w, r, http.StatusInternalServerError, text.ErrorInternalTitle, text.ErrorInternal)
123}
124
125func (a *portal) done(w http.ResponseWriter, r *http.Request, key text.Key, path string) {
126 if err := a.kit.SetFlash(w, key, ""); err != nil {
127 slog.Error("account flash not set", "err", err)
128 }
129 a.kit.SeeOther(w, r, path)
130}
131
132func (a *portal) overview(w http.ResponseWriter, r *http.Request) {
133 p, ok := a.kit.principal(w, r)
134 if !ok {
135 return
136 }
137 prof, err := a.b.Profile(r.Context(), p)
138 if err != nil {
139 a.failed(w, r, "profile", err)
140 return
141 }
142 if prof.AdminURL != "" {
143 if err := a.kit.origins.allows(prof.AdminURL); err != nil {
144 a.failed(w, r, "admin link", err)
145 return
146 }
147 }
148 groups := primaryFirst(prof.Groups, prof.Primary)
149 shown := groups[:min(len(groups), accountBadges)]
150 d := accountOverviewData{
151 Username: p.Username,
152 DisplayName: prof.DisplayName,
153 More: max(prof.GroupCount, len(groups)) - len(shown),
154 Admin: prof.AdminURL,
155 Google: prof.Google,
156 Portdistrict: prof.Portdistrict,
157 Primary: prof.Primary != "" && len(shown) > 0 && shown[0] == prof.Primary,
158 SSHKeys: newSSHKeyList(prof.SSHKeys, "/ssh-keys/", SSHKeyNewPath),
159 }
160 for _, g := range shown {
161 d.Groups = append(d.Groups, Ident(g))
162 }
163 if prof.Google && prof.GoogleLink != nil {
164 d.GoogleLinked, d.GoogleSince = true, stamp(prof.GoogleLink.LinkedAt)
165 }
166 a.render(w, r, http.StatusOK, "account-overview", d)
167}
168
169func (a *portal) password(w http.ResponseWriter, r *http.Request) {
170 if _, ok := a.kit.principal(w, r); ok {
171 a.render(w, r, http.StatusOK, "account-password", accountPasswordData{})
172 }
173}
174
175func (a *portal) changePassword(w http.ResponseWriter, r *http.Request) {
176 p, ok := a.kit.principal(w, r)
177 if !ok {
178 return
179 }
180 current, next := r.PostFormValue("current"), r.PostFormValue("password")
181 var msg text.Key
182 switch {
183 case current == "":
184 msg = text.AccountProblemEmpty
185 case next != r.PostFormValue("confirm"):
186 msg = text.AccountProblemMismatch
187 }
188 if msg != "" {
189 a.render(w, r, http.StatusOK, "account-password", accountPasswordData{Message: msg})
190 return
191 }
192 err := a.b.ChangePassword(r.Context(), p, current, next)
193 var prob AccountProblem
194 if errors.As(err, &prob) {
195 if key, ok := accountPasswordProblems[prob]; ok {
196 status := http.StatusOK
197 if prob == AccountBusy {
198 status = http.StatusServiceUnavailable
199 }
200 a.render(w, r, status, "account-password", accountPasswordData{Message: key})
201 return
202 }
203 }
204 if err != nil {
205 a.failed(w, r, "change password", err)
206 return
207 }
208 a.done(w, r, text.FlashAccountPasswordChanged, "/password")
209}
210
211func (a *portal) sessions(w http.ResponseWriter, r *http.Request) {
212 p, ok := a.kit.principal(w, r)
213 if !ok {
214 return
215 }
216 page := pageNumber(r)
217 list, err := a.b.Sessions(r.Context(), p, page)
218 if err != nil {
219 a.failed(w, r, "sessions", err)
220 return
221 }
222 rows := list.Sessions[:min(len(list.Sessions), AccountSessionsPerPage)]
223 d := accountSessionsData{Pager: newPager("/sessions", "", page, AccountSessionsPerPage, len(rows), list.Total), Others: list.Total > 1}
224 for _, s := range rows {
225 row := accountSessionRow{ID: Ident(s.ID), Host: Ident(s.Host), Opened: stamp(s.Created), Ends: stamp(s.Expires), Current: s.ThisSession, Browser: s.ThisBrowser, Device: deviceLabel(s.UserAgent), Agent: clipAgent(s.UserAgent), Address: s.Address}
226 if !s.LastSeen.IsZero() {
227 row.LastSeen = stamp(s.LastSeen)
228 }
229 if s.ClientID != "" && !strings.HasPrefix(s.ClientID, "_") {
230 row.Client = Ident(s.ClientID)
231 }
232 d.Rows = append(d.Rows, row)
233 }
234 a.render(w, r, http.StatusOK, "account-sessions", d)
235}
236
237func (a *portal) revoke(w http.ResponseWriter, r *http.Request) {
238 p, ok := a.kit.principal(w, r)
239 if !ok {
240 return
241 }
242 id := r.PostFormValue("id")
243 if !reSessionID.MatchString(id) || id == p.SessionID {
244 a.kit.ErrorPage(w, r, http.StatusBadRequest)
245 return
246 }
247 if err := a.b.RevokeSession(r.Context(), p, id); err != nil {
248 a.failed(w, r, "revoke session", err)
249 return
250 }
251 a.done(w, r, text.FlashAccountRevoked, "/sessions")
252}
253
254func (a *portal) revokeAllShow(w http.ResponseWriter, r *http.Request) {
255 p, ok := a.kit.principal(w, r)
256 if !ok {
257 return
258 }
259 a.kit.RenderConfirm(w, r, Confirm{Title: text.AccountRevokeAllTitle, Body: text.AccountRevokeAllBody, Button: text.AccountSessionsRevokeAll, Subject: p.Username, Action: "/sessions/revoke-all", Cancel: "/sessions"})
260}
261
262func (a *portal) revokeAll(w http.ResponseWriter, r *http.Request) {
263 p, ok := a.kit.principal(w, r)
264 if !ok {
265 return
266 }
267 if _, err := a.b.RevokeOtherSessions(r.Context(), p); err != nil {
268 a.failed(w, r, "revoke other sessions", err)
269 return
270 }
271 a.done(w, r, text.FlashAccountRevokedOthers, "/sessions")
272}
273
274func (a *portal) browserShow(w http.ResponseWriter, r *http.Request) {
275 p, ok := a.kit.principal(w, r)
276 if !ok {
277 return
278 }
279 a.kit.RenderConfirm(w, r, Confirm{Title: text.AccountBrowserTitle, Body: text.AccountBrowserBody, Button: text.AccountBrowserSignOut, Subject: p.Username, Action: BrowserLogoutPath, Cancel: "/", Neutral: true})
280}
281
282func (a *portal) browserSignOut(w http.ResponseWriter, r *http.Request) {
283 p, ok := a.kit.principal(w, r)
284 if !ok {
285 return
286 }
287 ended, err := a.b.SignOutBrowser(r.Context(), p)
288 if err != nil {
289 a.failed(w, r, "sign out browser", err)
290 return
291 }
292 if err := a.e.EndSession(w, r, p); err != nil && !errors.Is(err, ErrNoSession) {
293 a.failed(w, r, "end session", err)
294 return
295 }
296 a.kit.RotateCSRF(w)
297 flash := text.FlashAccountBrowserOut
298 if ended <= 1 {
299 flash = text.FlashAccountBrowserOutHost
300 }
301 a.done(w, r, flash, SignedOutPath)
302}
303
304func (a *portal) profile(w http.ResponseWriter, r *http.Request) {
305 p, ok := a.kit.principal(w, r)
306 if !ok {
307 return
308 }
309 prof, err := a.b.Profile(r.Context(), p)
310 if err != nil {
311 a.failed(w, r, "profile", err)
312 return
313 }
314 a.render(w, r, http.StatusOK, "account-profile", accountProfileData{DisplayName: prof.DisplayName})
315}
316
317func (a *portal) saveProfile(w http.ResponseWriter, r *http.Request) {
318 p, ok := a.kit.principal(w, r)
319 if !ok {
320 return
321 }
322 name := r.PostFormValue("display_name")
323 err := a.b.UpdateDisplayName(r.Context(), p, name)
324 var prob AccountProblem
325 if errors.As(err, &prob) && prob == AccountDisplayNameInvalid {
326 a.render(w, r, http.StatusOK, "account-profile", accountProfileData{DisplayName: echoed(name, echoDisplayNameBytes), Message: text.AdminProblemDisplayName})
327 return
328 }
329 if err != nil {
330 a.failed(w, r, "update display name", err)
331 return
332 }
333 a.done(w, r, text.FlashAccountProfileSaved, "/")
334}
335
336func primaryFirst(groups []string, primary string) []string {
337 i := slices.Index(groups, primary)
338 if primary == "" || i <= 0 {
339 return groups
340 }
341 out := make([]string, 0, len(groups))
342 out = append(out, primary)
343 out = append(out, groups[:i]...)
344 return append(out, groups[i+1:]...)
345}