state: add account switch/remove endpoints and login flow

Changed files
+145 -7
appview
+83
appview/state/accounts.go
··· 1 + package state 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/go-chi/chi/v5" 7 + ) 8 + 9 + func (s *State) SwitchAccount(w http.ResponseWriter, r *http.Request) { 10 + l := s.logger.With("handler", "SwitchAccount") 11 + 12 + if err := r.ParseForm(); err != nil { 13 + l.Error("failed to parse form", "err", err) 14 + http.Error(w, "invalid request", http.StatusBadRequest) 15 + return 16 + } 17 + 18 + did := r.FormValue("did") 19 + if did == "" { 20 + http.Error(w, "missing did", http.StatusBadRequest) 21 + return 22 + } 23 + 24 + if err := s.oauth.SwitchAccount(w, r, did); err != nil { 25 + l.Error("failed to switch account", "err", err) 26 + s.pages.HxRedirect(w, "/login?error=session") 27 + return 28 + } 29 + 30 + l.Info("switched account", "did", did) 31 + s.pages.HxRedirect(w, "/") 32 + } 33 + 34 + func (s *State) RemoveAccount(w http.ResponseWriter, r *http.Request) { 35 + l := s.logger.With("handler", "RemoveAccount") 36 + 37 + did := chi.URLParam(r, "did") 38 + if did == "" { 39 + http.Error(w, "missing did", http.StatusBadRequest) 40 + return 41 + } 42 + 43 + currentUser := s.oauth.GetMultiAccountUser(r) 44 + isCurrentAccount := currentUser != nil && currentUser.Active.Did == did 45 + 46 + var remainingAccounts []string 47 + if currentUser != nil { 48 + for _, acc := range currentUser.Accounts { 49 + if acc.Did != did { 50 + remainingAccounts = append(remainingAccounts, acc.Did) 51 + } 52 + } 53 + } 54 + 55 + if err := s.oauth.RemoveAccount(w, r, did); err != nil { 56 + l.Error("failed to remove account", "err", err) 57 + http.Error(w, "failed to remove account", http.StatusInternalServerError) 58 + return 59 + } 60 + 61 + l.Info("removed account", "did", did) 62 + 63 + if isCurrentAccount { 64 + if len(remainingAccounts) > 0 { 65 + nextDid := remainingAccounts[0] 66 + if err := s.oauth.SwitchAccount(w, r, nextDid); err != nil { 67 + l.Error("failed to switch to next account", "err", err) 68 + s.pages.HxRedirect(w, "/login") 69 + return 70 + } 71 + s.pages.HxRefresh(w) 72 + return 73 + } 74 + 75 + if err := s.oauth.DeleteSession(w, r); err != nil { 76 + l.Error("failed to delete session", "err", err) 77 + } 78 + s.pages.HxRedirect(w, "/login") 79 + return 80 + } 81 + 82 + s.pages.HxRefresh(w) 83 + }
+57 -7
appview/state/login.go
··· 5 5 "net/http" 6 6 "strings" 7 7 8 + "tangled.org/core/appview/oauth" 8 9 "tangled.org/core/appview/pages" 9 10 ) 10 11 ··· 15 16 case http.MethodGet: 16 17 returnURL := r.URL.Query().Get("return_url") 17 18 errorCode := r.URL.Query().Get("error") 19 + addAccount := r.URL.Query().Get("mode") == "add_account" 20 + 21 + user := s.oauth.GetMultiAccountUser(r) 22 + if user == nil { 23 + registry := s.oauth.GetAccounts(r) 24 + if len(registry.Accounts) > 0 { 25 + user = &oauth.MultiAccountUser{ 26 + Active: nil, 27 + Accounts: registry.Accounts, 28 + } 29 + } 30 + } 18 31 s.pages.Login(w, pages.LoginParams{ 19 - ReturnUrl: returnURL, 20 - ErrorCode: errorCode, 32 + ReturnUrl: returnURL, 33 + ErrorCode: errorCode, 34 + AddAccount: addAccount, 35 + LoggedInUser: user, 21 36 }) 22 37 case http.MethodPost: 23 38 handle := r.FormValue("handle") 39 + returnURL := r.FormValue("return_url") 40 + addAccount := r.FormValue("add_account") == "true" 24 41 25 42 // when users copy their handle from bsky.app, it tends to have these characters around it: 26 43 // ··· 44 61 return 45 62 } 46 63 64 + if err := s.oauth.SetAuthReturn(w, r, returnURL, addAccount); err != nil { 65 + l.Error("failed to set auth return", "err", err) 66 + } 67 + 47 68 redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle) 48 69 if err != nil { 49 70 l.Error("failed to start auth", "err", err) ··· 58 79 func (s *State) Logout(w http.ResponseWriter, r *http.Request) { 59 80 l := s.logger.With("handler", "Logout") 60 81 61 - err := s.oauth.DeleteSession(w, r) 62 - if err != nil { 63 - l.Error("failed to logout", "err", err) 64 - } else { 65 - l.Info("logged out successfully") 82 + currentUser := s.oauth.GetMultiAccountUser(r) 83 + if currentUser == nil || currentUser.Active == nil { 84 + s.pages.HxRedirect(w, "/login") 85 + return 66 86 } 67 87 88 + currentDid := currentUser.Active.Did 89 + 90 + var remainingAccounts []string 91 + for _, acc := range currentUser.Accounts { 92 + if acc.Did != currentDid { 93 + remainingAccounts = append(remainingAccounts, acc.Did) 94 + } 95 + } 96 + 97 + if err := s.oauth.RemoveAccount(w, r, currentDid); err != nil { 98 + l.Error("failed to remove account from registry", "err", err) 99 + } 100 + 101 + if err := s.oauth.DeleteSession(w, r); err != nil { 102 + l.Error("failed to delete session", "err", err) 103 + } 104 + 105 + if len(remainingAccounts) > 0 { 106 + nextDid := remainingAccounts[0] 107 + if err := s.oauth.SwitchAccount(w, r, nextDid); err != nil { 108 + l.Error("failed to switch to next account", "err", err) 109 + s.pages.HxRedirect(w, "/login") 110 + return 111 + } 112 + l.Info("switched to next account after logout", "did", nextDid) 113 + s.pages.HxRefresh(w) 114 + return 115 + } 116 + 117 + l.Info("logged out last account") 68 118 s.pages.HxRedirect(w, "/login") 69 119 }
+5
appview/state/router.go
··· 132 132 r.Post("/login", s.Login) 133 133 r.Post("/logout", s.Logout) 134 134 135 + r.With(middleware.AuthMiddleware(s.oauth)).Route("/account", func(r chi.Router) { 136 + r.Post("/switch", s.SwitchAccount) 137 + r.Delete("/{did}", s.RemoveAccount) 138 + }) 139 + 135 140 r.Route("/repo", func(r chi.Router) { 136 141 r.Route("/new", func(r chi.Router) { 137 142 r.Use(middleware.AuthMiddleware(s.oauth))