Monorepo for Tangled tangled.org

appview/settings: add account management UI for tngl.sh users #1151

merged opened by oyster.cafe targeting master from appview-acc-mgmt-ui

tngl.sh users should be able to:

  • change handle
  • deactivate account
  • delete account
  • change password
Labels

None yet.

assignee

None yet.

Participants 2
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3mgu4bwsml322
+58 -167
Interdiff #3 โ†’ #4
appview/config/config.go

This file has not been changed.

appview/oauth/handler.go

This file has not been changed.

appview/oauth/oauth.go

This file has not been changed.

appview/pages/htmx.go

This file has not been changed.

appview/pages/pages.go

This file has not been changed.

appview/pages/templates/user/settings/fragments/dangerDeleteToken.html

This file has not been changed.

appview/pages/templates/user/settings/fragments/dangerPasswordSuccess.html

This file has not been changed.

appview/pages/templates/user/settings/fragments/dangerPasswordToken.html

This file has not been changed.

appview/pages/templates/user/settings/profile.html

This file has not been changed.

+54 -159
appview/settings/danger.go
··· 1 package settings 2 3 import ( 4 - "bytes" 5 "context" 6 - "encoding/json" 7 "errors" 8 - "fmt" 9 - "io" 10 "net/http" 11 "strings" 12 "time" ··· 15 "github.com/bluesky-social/indigo/xrpc" 16 ) 17 18 - var pdsClient = &http.Client{Timeout: 15 * time.Second} 19 - 20 - type createSessionResponse struct { 21 - AccessJwt string `json:"accessJwt"` 22 - Did string `json:"did"` 23 - Email string `json:"email"` 24 } 25 26 - func (s *Settings) verifyPdsPassword(did, password string) (*createSessionResponse, error) { 27 - body := map[string]string{ 28 - "identifier": did, 29 - "password": password, 30 - } 31 - 32 - jsonData, err := json.Marshal(body) 33 - if err != nil { 34 - return nil, err 35 - } 36 - 37 - url := fmt.Sprintf("%s/xrpc/com.atproto.server.createSession", s.Config.Pds.Host) 38 - req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) 39 - if err != nil { 40 - return nil, err 41 - } 42 - req.Header.Set("Content-Type", "application/json") 43 - 44 - resp, err := pdsClient.Do(req) 45 - if err != nil { 46 - return nil, err 47 - } 48 - defer resp.Body.Close() 49 - 50 - if resp.StatusCode != http.StatusOK { 51 - respBody, _ := io.ReadAll(resp.Body) 52 - var errResp struct { 53 - Error string `json:"error"` 54 - Message string `json:"message"` 55 - } 56 - if json.Unmarshal(respBody, &errResp) == nil && errResp.Message != "" { 57 - return nil, fmt.Errorf("%s", errResp.Message) 58 - } 59 - return nil, fmt.Errorf("authentication failed (status %d)", resp.StatusCode) 60 - } 61 - 62 - var session createSessionResponse 63 - if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { 64 - return nil, fmt.Errorf("failed to decode session response: %w", err) 65 } 66 - 67 - return &session, nil 68 } 69 70 - func (s *Settings) pdsPost(endpoint string, body any, bearerToken string) (*http.Response, error) { 71 - var bodyReader io.Reader 72 - if body != nil { 73 - jsonData, err := json.Marshal(body) 74 - if err != nil { 75 - return nil, err 76 - } 77 - bodyReader = bytes.NewBuffer(jsonData) 78 - } 79 - 80 - url := fmt.Sprintf("%s/xrpc/%s", s.Config.Pds.Host, endpoint) 81 - req, err := http.NewRequest("POST", url, bodyReader) 82 if err != nil { 83 return nil, err 84 } 85 86 - if body != nil { 87 - req.Header.Set("Content-Type", "application/json") 88 - } 89 - if bearerToken != "" { 90 - req.Header.Set("Authorization", "Bearer "+bearerToken) 91 } 92 93 - return pdsClient.Do(req) 94 } 95 96 - func (s *Settings) revokePdsSession(accessJwt string) { 97 - resp, err := s.pdsPost("com.atproto.server.deleteSession", nil, accessJwt) 98 - if err != nil { 99 s.Logger.Warn("failed to revoke session", "err", err) 100 - return 101 } 102 - resp.Body.Close() 103 } 104 105 func (s *Settings) requestPasswordReset(w http.ResponseWriter, r *http.Request) { 106 user := s.OAuth.GetMultiAccountUser(r) 107 - if !s.isTnglShUser(user.Pds()) { 108 s.Pages.Notice(w, "password-error", "Only available for tngl.sh accounts.") 109 return 110 } ··· 123 } 124 125 if session.Email == "" { 126 - s.revokePdsSession(session.AccessJwt) 127 s.Logger.Error("requesting password reset: no email on account", "did", did) 128 s.Pages.Notice(w, "password-error", "No email associated with your account.") 129 return 130 } 131 132 - s.revokePdsSession(session.AccessJwt) 133 134 - resp, err := s.pdsPost("com.atproto.server.requestPasswordReset", map[string]string{ 135 - "email": session.Email, 136 - }, "") 137 if err != nil { 138 s.Logger.Error("requesting password reset", "err", err) 139 s.Pages.Notice(w, "password-error", "Failed to request password reset. Try again later.") 140 return 141 } 142 - defer resp.Body.Close() 143 - 144 - if resp.StatusCode != http.StatusOK { 145 - s.Logger.Error("requesting password reset", "status", resp.StatusCode) 146 - s.Pages.Notice(w, "password-error", "Failed to request password reset. Try again later.") 147 - return 148 - } 149 150 s.Pages.DangerPasswordTokenStep(w) 151 } 152 153 func (s *Settings) resetPassword(w http.ResponseWriter, r *http.Request) { 154 user := s.OAuth.GetMultiAccountUser(r) 155 - if !s.isTnglShUser(user.Pds()) { 156 s.Pages.Notice(w, "password-error", "Only available for tngl.sh accounts.") 157 return 158 } ··· 171 return 172 } 173 174 - resp, err := s.pdsPost("com.atproto.server.resetPassword", map[string]string{ 175 - "token": token, 176 - "password": newPassword, 177 - }, "") 178 if err != nil { 179 s.Logger.Error("resetting password", "err", err) 180 - s.Pages.Notice(w, "password-error", "Failed to reset password. Try again later.") 181 - return 182 - } 183 - defer resp.Body.Close() 184 - 185 - if resp.StatusCode != http.StatusOK { 186 - respBody, _ := io.ReadAll(resp.Body) 187 - var errResp struct { 188 - Message string `json:"message"` 189 - } 190 - if json.Unmarshal(respBody, &errResp) == nil && errResp.Message != "" { 191 - s.Pages.Notice(w, "password-error", errResp.Message) 192 - return 193 - } 194 - s.Logger.Error("resetting password", "status", resp.StatusCode) 195 s.Pages.Notice(w, "password-error", "Failed to reset password. The token may have expired.") 196 return 197 } ··· 201 202 func (s *Settings) deactivateAccount(w http.ResponseWriter, r *http.Request) { 203 user := s.OAuth.GetMultiAccountUser(r) 204 - if !s.isTnglShUser(user.Pds()) { 205 s.Pages.Notice(w, "deactivate-error", "Only available for tngl.sh accounts.") 206 return 207 } ··· 220 return 221 } 222 223 - resp, err := s.pdsPost("com.atproto.server.deactivateAccount", map[string]any{}, session.AccessJwt) 224 - s.revokePdsSession(session.AccessJwt) 225 if err != nil { 226 s.Logger.Error("deactivating account", "err", err) 227 s.Pages.Notice(w, "deactivate-error", "Failed to deactivate account. Try again later.") 228 return 229 } 230 - defer resp.Body.Close() 231 - 232 - if resp.StatusCode != http.StatusOK { 233 - s.Logger.Error("deactivating account", "status", resp.StatusCode) 234 - s.Pages.Notice(w, "deactivate-error", "Failed to deactivate account. Try again later.") 235 - return 236 - } 237 238 if err := s.OAuth.DeleteSession(w, r); err != nil { 239 s.Logger.Error("clearing session after deactivation", "did", did, "err", err) ··· 246 247 func (s *Settings) requestAccountDelete(w http.ResponseWriter, r *http.Request) { 248 user := s.OAuth.GetMultiAccountUser(r) 249 - if !s.isTnglShUser(user.Pds()) { 250 s.Pages.Notice(w, "delete-error", "Only available for tngl.sh accounts.") 251 return 252 } ··· 265 return 266 } 267 268 - resp, err := s.pdsPost("com.atproto.server.requestAccountDelete", nil, session.AccessJwt) 269 - s.revokePdsSession(session.AccessJwt) 270 if err != nil { 271 s.Logger.Error("requesting account deletion", "err", err) 272 s.Pages.Notice(w, "delete-error", "Failed to request account deletion. Try again later.") 273 return 274 } 275 - defer resp.Body.Close() 276 - 277 - if resp.StatusCode != http.StatusOK { 278 - respBody, _ := io.ReadAll(resp.Body) 279 - s.Logger.Error("requesting account deletion", "status", resp.StatusCode, "body", string(respBody)) 280 - s.Pages.Notice(w, "delete-error", "Failed to request account deletion. Try again later.") 281 - return 282 - } 283 284 s.Pages.DangerDeleteTokenStep(w) 285 } 286 287 func (s *Settings) deleteAccount(w http.ResponseWriter, r *http.Request) { 288 user := s.OAuth.GetMultiAccountUser(r) 289 - if !s.isTnglShUser(user.Pds()) { 290 s.Pages.Notice(w, "delete-error", "Only available for tngl.sh accounts.") 291 return 292 } ··· 306 return 307 } 308 309 - resp, err := s.pdsPost("com.atproto.server.deleteAccount", map[string]string{ 310 - "did": did, 311 - "password": password, 312 - "token": token, 313 - }, "") 314 if err != nil { 315 s.Logger.Error("deleting account", "err", err) 316 s.Pages.Notice(w, "delete-error", "Failed to delete account. Try again later.") 317 return 318 } 319 - defer resp.Body.Close() 320 - 321 - if resp.StatusCode != http.StatusOK { 322 - respBody, _ := io.ReadAll(resp.Body) 323 - var errResp struct { 324 - Message string `json:"message"` 325 - } 326 - if json.Unmarshal(respBody, &errResp) == nil && errResp.Message != "" { 327 - s.Pages.Notice(w, "delete-error", errResp.Message) 328 - return 329 - } 330 - s.Logger.Error("deleting account", "status", resp.StatusCode) 331 - s.Pages.Notice(w, "delete-error", "Failed to delete account. No dice!") 332 - return 333 - } 334 335 if err := s.OAuth.DeleteSession(w, r); err != nil { 336 s.Logger.Error("clearing session after account deletion", "did", did, "err", err) ··· 361 362 func (s *Settings) reactivateAccount(w http.ResponseWriter, r *http.Request) { 363 user := s.OAuth.GetMultiAccountUser(r) 364 - if !s.isTnglShUser(user.Pds()) { 365 s.Pages.Notice(w, "reactivate-error", "Only available for tngl.sh accounts.") 366 return 367 } ··· 380 return 381 } 382 383 - resp, err := s.pdsPost("com.atproto.server.activateAccount", nil, session.AccessJwt) 384 - s.revokePdsSession(session.AccessJwt) 385 if err != nil { 386 s.Logger.Error("reactivating account", "err", err) 387 s.Pages.Notice(w, "reactivate-error", "Failed to reactivate account. Try again later.") 388 return 389 } 390 - defer resp.Body.Close() 391 - 392 - if resp.StatusCode != http.StatusOK { 393 - s.Logger.Error("reactivating account", "status", resp.StatusCode) 394 - s.Pages.Notice(w, "reactivate-error", "Failed to reactivate account. Try again later.") 395 - return 396 - } 397 398 s.Pages.HxRefresh(w) 399 }
··· 1 package settings 2 3 import ( 4 "context" 5 "errors" 6 "net/http" 7 "strings" 8 "time" ··· 11 "github.com/bluesky-social/indigo/xrpc" 12 ) 13 14 + type pdsSession struct { 15 + Client *xrpc.Client 16 + Did string 17 + Email string 18 + AccessJwt string 19 } 20 21 + func (s *Settings) pdsClient() *xrpc.Client { 22 + return &xrpc.Client{ 23 + Host: s.Config.Pds.Host, 24 + Client: &http.Client{Timeout: 15 * time.Second}, 25 } 26 } 27 28 + func (s *Settings) verifyPdsPassword(did, password string) (*pdsSession, error) { 29 + client := s.pdsClient() 30 + resp, err := comatproto.ServerCreateSession(context.Background(), client, &comatproto.ServerCreateSession_Input{ 31 + Identifier: did, 32 + Password: password, 33 + }) 34 if err != nil { 35 return nil, err 36 } 37 38 + client.Auth = &xrpc.AuthInfo{AccessJwt: resp.AccessJwt} 39 + 40 + var email string 41 + if resp.Email != nil { 42 + email = *resp.Email 43 } 44 45 + return &pdsSession{ 46 + Client: client, 47 + Did: resp.Did, 48 + Email: email, 49 + AccessJwt: resp.AccessJwt, 50 + }, nil 51 } 52 53 + func (s *Settings) revokePdsSession(session *pdsSession) { 54 + if err := comatproto.ServerDeleteSession(context.Background(), session.Client); err != nil { 55 s.Logger.Warn("failed to revoke session", "err", err) 56 } 57 } 58 59 func (s *Settings) requestPasswordReset(w http.ResponseWriter, r *http.Request) { 60 user := s.OAuth.GetMultiAccountUser(r) 61 + if !s.Config.Pds.IsTnglShUser(user.Pds()) { 62 s.Pages.Notice(w, "password-error", "Only available for tngl.sh accounts.") 63 return 64 } ··· 77 } 78 79 if session.Email == "" { 80 + s.revokePdsSession(session) 81 s.Logger.Error("requesting password reset: no email on account", "did", did) 82 s.Pages.Notice(w, "password-error", "No email associated with your account.") 83 return 84 } 85 86 + s.revokePdsSession(session) 87 88 + err = comatproto.ServerRequestPasswordReset(context.Background(), s.pdsClient(), &comatproto.ServerRequestPasswordReset_Input{ 89 + Email: session.Email, 90 + }) 91 if err != nil { 92 s.Logger.Error("requesting password reset", "err", err) 93 s.Pages.Notice(w, "password-error", "Failed to request password reset. Try again later.") 94 return 95 } 96 97 s.Pages.DangerPasswordTokenStep(w) 98 } 99 100 func (s *Settings) resetPassword(w http.ResponseWriter, r *http.Request) { 101 user := s.OAuth.GetMultiAccountUser(r) 102 + if !s.Config.Pds.IsTnglShUser(user.Pds()) { 103 s.Pages.Notice(w, "password-error", "Only available for tngl.sh accounts.") 104 return 105 } ··· 118 return 119 } 120 121 + err := comatproto.ServerResetPassword(context.Background(), s.pdsClient(), &comatproto.ServerResetPassword_Input{ 122 + Token: token, 123 + Password: newPassword, 124 + }) 125 if err != nil { 126 s.Logger.Error("resetting password", "err", err) 127 s.Pages.Notice(w, "password-error", "Failed to reset password. The token may have expired.") 128 return 129 } ··· 133 134 func (s *Settings) deactivateAccount(w http.ResponseWriter, r *http.Request) { 135 user := s.OAuth.GetMultiAccountUser(r) 136 + if !s.Config.Pds.IsTnglShUser(user.Pds()) { 137 s.Pages.Notice(w, "deactivate-error", "Only available for tngl.sh accounts.") 138 return 139 } ··· 152 return 153 } 154 155 + err = comatproto.ServerDeactivateAccount(context.Background(), session.Client, &comatproto.ServerDeactivateAccount_Input{}) 156 + s.revokePdsSession(session) 157 if err != nil { 158 s.Logger.Error("deactivating account", "err", err) 159 s.Pages.Notice(w, "deactivate-error", "Failed to deactivate account. Try again later.") 160 return 161 } 162 163 if err := s.OAuth.DeleteSession(w, r); err != nil { 164 s.Logger.Error("clearing session after deactivation", "did", did, "err", err) ··· 171 172 func (s *Settings) requestAccountDelete(w http.ResponseWriter, r *http.Request) { 173 user := s.OAuth.GetMultiAccountUser(r) 174 + if !s.Config.Pds.IsTnglShUser(user.Pds()) { 175 s.Pages.Notice(w, "delete-error", "Only available for tngl.sh accounts.") 176 return 177 } ··· 190 return 191 } 192 193 + err = comatproto.ServerRequestAccountDelete(context.Background(), session.Client) 194 + s.revokePdsSession(session) 195 if err != nil { 196 s.Logger.Error("requesting account deletion", "err", err) 197 s.Pages.Notice(w, "delete-error", "Failed to request account deletion. Try again later.") 198 return 199 } 200 201 s.Pages.DangerDeleteTokenStep(w) 202 } 203 204 func (s *Settings) deleteAccount(w http.ResponseWriter, r *http.Request) { 205 user := s.OAuth.GetMultiAccountUser(r) 206 + if !s.Config.Pds.IsTnglShUser(user.Pds()) { 207 s.Pages.Notice(w, "delete-error", "Only available for tngl.sh accounts.") 208 return 209 } ··· 223 return 224 } 225 226 + err := comatproto.ServerDeleteAccount(context.Background(), s.pdsClient(), &comatproto.ServerDeleteAccount_Input{ 227 + Did: did, 228 + Password: password, 229 + Token: token, 230 + }) 231 if err != nil { 232 s.Logger.Error("deleting account", "err", err) 233 s.Pages.Notice(w, "delete-error", "Failed to delete account. Try again later.") 234 return 235 } 236 237 if err := s.OAuth.DeleteSession(w, r); err != nil { 238 s.Logger.Error("clearing session after account deletion", "did", did, "err", err) ··· 263 264 func (s *Settings) reactivateAccount(w http.ResponseWriter, r *http.Request) { 265 user := s.OAuth.GetMultiAccountUser(r) 266 + if !s.Config.Pds.IsTnglShUser(user.Pds()) { 267 s.Pages.Notice(w, "reactivate-error", "Only available for tngl.sh accounts.") 268 return 269 } ··· 282 return 283 } 284 285 + err = comatproto.ServerActivateAccount(context.Background(), session.Client) 286 + s.revokePdsSession(session) 287 if err != nil { 288 s.Logger.Error("reactivating account", "err", err) 289 s.Pages.Notice(w, "reactivate-error", "Failed to reactivate account. Try again later.") 290 return 291 } 292 293 s.Pages.HxRefresh(w) 294 }
+4 -8
appview/settings/settings.go
··· 251 log.Printf("failed to get users punchcard preferences: %s", err) 252 } 253 254 - isDeactivated := s.isTnglShUser(user.Pds()) && s.isAccountDeactivated(r.Context(), user.Did(), user.Pds()) 255 256 s.Pages.UserProfileSettings(w, pages.UserProfileSettingsParams{ 257 LoggedInUser: user, 258 PunchcardPreference: punchcardPreferences, 259 - IsTnglSh: s.isTnglShUser(user.Pds()), 260 IsDeactivated: isDeactivated, 261 PdsDomain: s.pdsDomain(), 262 HandleOpen: r.URL.Query().Get("handle") == "1", ··· 715 } 716 } 717 718 - func (s *Settings) isTnglShUser(pdsHost string) bool { 719 - return s.Config.Pds.IsTnglShUser(pdsHost) 720 - } 721 - 722 func (s *Settings) pdsDomain() string { 723 parsed, err := url.Parse(s.Config.Pds.Host) 724 if err != nil { ··· 729 730 func (s *Settings) elevateForHandle(w http.ResponseWriter, r *http.Request) { 731 user := s.OAuth.GetMultiAccountUser(r) 732 - if !s.isTnglShUser(user.Pds()) { 733 http.Redirect(w, r, "/settings/profile", http.StatusSeeOther) 734 return 735 } ··· 757 758 func (s *Settings) updateHandle(w http.ResponseWriter, r *http.Request) { 759 user := s.OAuth.GetMultiAccountUser(r) 760 - if !s.isTnglShUser(user.Pds()) { 761 s.Pages.Notice(w, "handle-error", "Handle changes are only available for tngl.sh accounts.") 762 return 763 }
··· 251 log.Printf("failed to get users punchcard preferences: %s", err) 252 } 253 254 + isDeactivated := s.Config.Pds.IsTnglShUser(user.Pds()) && s.isAccountDeactivated(r.Context(), user.Did(), user.Pds()) 255 256 s.Pages.UserProfileSettings(w, pages.UserProfileSettingsParams{ 257 LoggedInUser: user, 258 PunchcardPreference: punchcardPreferences, 259 + IsTnglSh: s.Config.Pds.IsTnglShUser(user.Pds()), 260 IsDeactivated: isDeactivated, 261 PdsDomain: s.pdsDomain(), 262 HandleOpen: r.URL.Query().Get("handle") == "1", ··· 715 } 716 } 717 718 func (s *Settings) pdsDomain() string { 719 parsed, err := url.Parse(s.Config.Pds.Host) 720 if err != nil { ··· 725 726 func (s *Settings) elevateForHandle(w http.ResponseWriter, r *http.Request) { 727 user := s.OAuth.GetMultiAccountUser(r) 728 + if !s.Config.Pds.IsTnglShUser(user.Pds()) { 729 http.Redirect(w, r, "/settings/profile", http.StatusSeeOther) 730 return 731 } ··· 753 754 func (s *Settings) updateHandle(w http.ResponseWriter, r *http.Request) { 755 user := s.OAuth.GetMultiAccountUser(r) 756 + if !s.Config.Pds.IsTnglShUser(user.Pds()) { 757 s.Pages.Notice(w, "handle-error", "Handle changes are only available for tngl.sh accounts.") 758 return 759 }
appview/state/login.go

This file has not been changed.

History

7 rounds 4 comments
sign up or login to add to the discussion
1 commit
expand
appview/settings: add account management UI for tngl.sh users
3/3 success
expand
expand 0 comments
pull request successfully merged
1 commit
expand
appview/settings: add account management UI for tngl.sh users
3/3 success
expand
expand 0 comments
1 commit
expand
appview/settings: add account management UI for tngl.sh users
3/3 success
expand
expand 1 comment
  • this can go in pages.go
1 commit
expand
appview/settings: add account management UI for tngl.sh users
3/3 success
expand
expand 0 comments
1 commit
expand
appview/settings: add account management UI for tngl.sh users
2/3 failed, 1/3 success
expand
expand 3 comments
  • here could you explain what an elevated auth flow is
  • here why not a template here? does this need to be in htmx.go?
  • here do we need this IsTnglSh bool?
  • here are there any indigo bits to achieve this?
  • here we have defined isTnglShUser once here, and another time here

do we need both?

1 commit
expand
appview/settings: add account management UI for tngl.sh users
3/3 failed
expand
expand 0 comments
1 commit
expand
appview/settings: add account management UI for tngl.sh users
3/3 success
expand
expand 0 comments