A fork of the Cocoon PDS but being made more distributed.
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: account switcher (#79)

* feat: account switcher

* fix(account-switch): merge redirect query params safely

Parse redirect targets and query_params with net/url, then merge into a single encoded query string to avoid malformed URLs when next already has a query or query_params starts with ?.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* perf(session): avoid duplicate account lookups

Reuse a single session-account fetch path for signin/account/oauth authorize flows by returning both the active repo and account list from one helper.

This removes repeated per-account queries on page render while preserving existing behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(auth): distinguish unauthenticated vs backend session errors

Introduce ErrSessionUnauthenticated and treat only that case as a signin redirect.

Return server errors for account/session lookup failures in account and oauth authorize/revoke flows so backend issues are not masked as re-login prompts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(pr-review): address remaining account/oath review issues

Populate authorize/account template render data for all paths, harden account switch against cross-site POSTs, and apply consistent account session cookie options on save.

Also fix pointer-to-range-variable in session account lookup.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(pr-review): resolve remaining template/session threads

Use explicit .Repo.Did in account switcher templates to avoid ambiguous embedded Did fields in RepoActor.

Reuse the already-loaded session in oauth authorize by adding a helper variant that accepts an existing session instead of re-fetching it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

author
Luna Seemann
co-author
Copilot
committer
GitHub
date (May 19, 2026, 12:39 PM -0700) commit 4bd740d8 parent 60d86b75
+460 -37
+168
server/account_sessions.go
··· 1 + package server 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "slices" 7 + "strings" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/gorilla/sessions" 11 + "github.com/haileyok/cocoon/models" 12 + "gorm.io/gorm" 13 + ) 14 + 15 + const ( 16 + sessionDidKey = "did" 17 + sessionDidsKey = "dids" 18 + ) 19 + 20 + func normalizeSessionDids(dids []string) []string { 21 + normalized := make([]string, 0, len(dids)) 22 + for _, did := range dids { 23 + if did == "" || slices.Contains(normalized, did) { 24 + continue 25 + } 26 + normalized = append(normalized, did) 27 + } 28 + return normalized 29 + } 30 + 31 + func getSessionDids(sess *sessions.Session) []string { 32 + if sess == nil { 33 + return nil 34 + } 35 + 36 + if val, ok := sess.Values[sessionDidsKey]; ok { 37 + switch dids := val.(type) { 38 + case []string: 39 + return normalizeSessionDids(dids) 40 + case []any: 41 + out := make([]string, 0, len(dids)) 42 + for _, did := range dids { 43 + if s, ok := did.(string); ok { 44 + out = append(out, s) 45 + } 46 + } 47 + return normalizeSessionDids(out) 48 + } 49 + } 50 + 51 + if did, ok := sess.Values[sessionDidKey].(string); ok && did != "" { 52 + return []string{did} 53 + } 54 + 55 + return nil 56 + } 57 + 58 + func setSessionDids(sess *sessions.Session, dids []string) { 59 + if sess == nil { 60 + return 61 + } 62 + 63 + normalized := normalizeSessionDids(dids) 64 + if len(normalized) == 0 { 65 + delete(sess.Values, sessionDidKey) 66 + delete(sess.Values, sessionDidsKey) 67 + return 68 + } 69 + 70 + sess.Values[sessionDidsKey] = normalized 71 + if activeDid, ok := sess.Values[sessionDidKey].(string); !ok || !slices.Contains(normalized, activeDid) { 72 + sess.Values[sessionDidKey] = normalized[0] 73 + } 74 + } 75 + 76 + func getActiveSessionDid(sess *sessions.Session) string { 77 + if sess == nil { 78 + return "" 79 + } 80 + 81 + dids := getSessionDids(sess) 82 + if len(dids) == 0 { 83 + return "" 84 + } 85 + 86 + if activeDid, ok := sess.Values[sessionDidKey].(string); ok && slices.Contains(dids, activeDid) { 87 + return activeDid 88 + } 89 + return dids[0] 90 + } 91 + 92 + func setActiveSessionDid(sess *sessions.Session, did string) bool { 93 + if sess == nil || did == "" { 94 + return false 95 + } 96 + 97 + dids := getSessionDids(sess) 98 + if !slices.Contains(dids, did) { 99 + dids = append(dids, did) 100 + } 101 + setSessionDids(sess, dids) 102 + 103 + current, _ := sess.Values[sessionDidKey].(string) 104 + if current == did { 105 + return false 106 + } 107 + sess.Values[sessionDidKey] = did 108 + return true 109 + } 110 + 111 + func removeSessionDid(sess *sessions.Session, did string) { 112 + if sess == nil || did == "" { 113 + return 114 + } 115 + 116 + next := make([]string, 0) 117 + for _, existingDid := range getSessionDids(sess) { 118 + if existingDid != did { 119 + next = append(next, existingDid) 120 + } 121 + } 122 + setSessionDids(sess, next) 123 + } 124 + 125 + func (s *Server) getSessionAccountActors(ctx context.Context, sess *sessions.Session) ([]models.RepoActor, bool, error) { 126 + changed := false 127 + validDids := make([]string, 0) 128 + var accounts []models.RepoActor 129 + for _, did := range getSessionDids(sess) { 130 + repo, err := s.getRepoActorByDid(ctx, did) 131 + if err != nil { 132 + if errors.Is(err, gorm.ErrRecordNotFound) { 133 + changed = true 134 + continue 135 + } 136 + return nil, changed, err 137 + } 138 + validDids = append(validDids, did) 139 + accounts = append(accounts, *repo) 140 + } 141 + 142 + if changed { 143 + setSessionDids(sess, validDids) 144 + } 145 + return accounts, changed, nil 146 + } 147 + 148 + func (s *Server) resolveLoginHintToDid(ctx context.Context, loginHint string) (string, error) { 149 + loginHint = strings.TrimSpace(loginHint) 150 + if loginHint == "" { 151 + return "", gorm.ErrRecordNotFound 152 + } 153 + 154 + if _, err := syntax.ParseDID(loginHint); err == nil { 155 + return loginHint, nil 156 + } 157 + 158 + normalizedHandle := strings.ToLower(loginHint) 159 + if _, err := syntax.ParseHandle(normalizedHandle); err == nil { 160 + actor, err := s.getActorByHandle(ctx, normalizedHandle) 161 + if err != nil { 162 + return "", err 163 + } 164 + return actor.Did, nil 165 + } 166 + 167 + return "", gorm.ErrRecordNotFound 168 + }
+16 -5
server/handle_account.go
··· 1 1 package server 2 2 3 3 import ( 4 + "errors" 4 5 "time" 5 6 7 + "github.com/haileyok/cocoon/internal/helpers" 6 8 "github.com/haileyok/cocoon/oauth" 7 9 "github.com/haileyok/cocoon/oauth/constants" 8 10 "github.com/haileyok/cocoon/oauth/provider" ··· 14 16 ctx := e.Request().Context() 15 17 logger := s.logger.With("name", "handleAuth") 16 18 17 - repo, sess, err := s.getSessionRepoOrErr(e) 19 + repo, sess, accounts, err := s.getSessionRepoAndAccountsOrErr(e) 18 20 if err != nil { 21 + if !errors.Is(err, ErrSessionUnauthenticated) { 22 + return helpers.ServerError(e, nil) 23 + } 19 24 return e.Redirect(303, "/account/signin") 20 25 } 21 26 ··· 27 32 sess.AddFlash("Unable to fetch sessions. See server logs for more details.", "error") 28 33 sess.Save(e.Request(), e.Response()) 29 34 return e.Render(200, "account.html", map[string]any{ 30 - "flashes": getFlashesFromSession(e, sess), 35 + "Repo": repo, 36 + "Tokens": []map[string]string{}, 37 + "flashes": getFlashesFromSession(e, sess), 38 + "Accounts": accounts, 39 + "ActiveDid": repo.Repo.Did, 31 40 }) 32 41 } 33 42 ··· 69 78 } 70 79 71 80 return e.Render(200, "account.html", map[string]any{ 72 - "Repo": repo, 73 - "Tokens": tokenInfo, 74 - "flashes": getFlashesFromSession(e, sess), 81 + "Repo": repo, 82 + "Tokens": tokenInfo, 83 + "flashes": getFlashesFromSession(e, sess), 84 + "Accounts": accounts, 85 + "ActiveDid": repo.Repo.Did, 75 86 }) 76 87 }
+5
server/handle_account_revoke.go
··· 1 1 package server 2 2 3 3 import ( 4 + "errors" 5 + 4 6 "github.com/haileyok/cocoon/internal/helpers" 5 7 "github.com/labstack/echo/v4" 6 8 ) ··· 21 23 22 24 repo, sess, err := s.getSessionRepoOrErr(e) 23 25 if err != nil { 26 + if !errors.Is(err, ErrSessionUnauthenticated) { 27 + return helpers.ServerError(e, nil) 28 + } 24 29 return e.Redirect(303, "/account/signin") 25 30 } 26 31
+54 -18
server/handle_account_signin.go
··· 1 1 package server 2 2 3 3 import ( 4 + "context" 4 5 "errors" 5 6 "fmt" 6 7 "strings" ··· 23 24 QueryParams string `form:"query_params"` 24 25 } 25 26 26 - func (s *Server) getSessionRepoOrErr(e echo.Context) (*models.RepoActor, *sessions.Session, error) { 27 + var ErrSessionUnauthenticated = errors.New("session is unauthenticated") 28 + 29 + func (s *Server) getSessionRepoAndAccountsOrErr(e echo.Context) (*models.RepoActor, *sessions.Session, []models.RepoActor, error) { 27 30 ctx := e.Request().Context() 28 - 29 31 sess, err := session.Get(s.config.SessionCookieKey, e) 30 32 if err != nil { 31 - return nil, nil, err 33 + return nil, nil, nil, err 32 34 } 33 35 34 - did, ok := sess.Values["did"].(string) 35 - if !ok { 36 - return nil, sess, errors.New("did was not set in session") 36 + return s.getSessionRepoAndAccountsFromSessionOrErr(e, ctx, sess) 37 + } 38 + 39 + func (s *Server) getSessionRepoAndAccountsFromSessionOrErr(e echo.Context, ctx context.Context, sess *sessions.Session) (*models.RepoActor, *sessions.Session, []models.RepoActor, error) { 40 + if sess == nil { 41 + return nil, nil, nil, errors.New("session is nil") 37 42 } 38 43 39 - repo, err := s.getRepoActorByDid(ctx, did) 44 + accounts, changed, err := s.getSessionAccountActors(ctx, sess) 40 45 if err != nil { 41 - return nil, sess, err 46 + return nil, sess, nil, err 47 + } 48 + if changed { 49 + applyAccountSessionOptions(sess, int(AccountSessionMaxAge.Seconds())) 50 + if err := sess.Save(e.Request(), e.Response()); err != nil { 51 + return nil, sess, nil, err 52 + } 42 53 } 43 54 44 - return repo, sess, nil 55 + did := getActiveSessionDid(sess) 56 + if did == "" { 57 + return nil, sess, accounts, fmt.Errorf("%w: did was not set in session", ErrSessionUnauthenticated) 58 + } 59 + 60 + for i := range accounts { 61 + if accounts[i].Repo.Did == did { 62 + return &accounts[i], sess, accounts, nil 63 + } 64 + } 65 + 66 + return nil, sess, accounts, fmt.Errorf("%w: did was not found in session accounts", ErrSessionUnauthenticated) 67 + } 68 + 69 + func (s *Server) getSessionRepoOrErr(e echo.Context) (*models.RepoActor, *sessions.Session, error) { 70 + repo, sess, _, err := s.getSessionRepoAndAccountsOrErr(e) 71 + return repo, sess, err 45 72 } 46 73 47 74 func getFlashesFromSession(e echo.Context, sess *sessions.Session) map[string]any { ··· 54 81 } 55 82 56 83 func (s *Server) handleAccountSigninGet(e echo.Context) error { 57 - _, sess, err := s.getSessionRepoOrErr(e) 58 - if err == nil { 84 + repo, sess, accounts, err := s.getSessionRepoAndAccountsOrErr(e) 85 + if err != nil && !errors.Is(err, ErrSessionUnauthenticated) { 86 + return helpers.ServerError(e, nil) 87 + } 88 + if err == nil && e.QueryString() == "" { 59 89 return e.Redirect(303, "/account") 60 90 } 61 91 92 + if sess == nil { 93 + return helpers.ServerError(e, nil) 94 + } 95 + 96 + activeDid := "" 97 + if repo != nil { 98 + activeDid = repo.Repo.Did 99 + } 100 + 62 101 return e.Render(200, "signin.html", map[string]any{ 63 102 "flashes": getFlashesFromSession(e, sess), 64 103 "QueryParams": e.QueryParams().Encode(), 104 + "Accounts": accounts, 105 + "ActiveDid": activeDid, 65 106 }) 66 107 } 67 108 ··· 161 202 } 162 203 } 163 204 164 - sess.Options = &sessions.Options{ 165 - Path: "/", 166 - MaxAge: int(AccountSessionMaxAge.Seconds()), 167 - HttpOnly: true, 168 - } 205 + applyAccountSessionOptions(sess, int(AccountSessionMaxAge.Seconds())) 169 206 170 - sess.Values = map[any]any{} 171 - sess.Values["did"] = repo.Repo.Did 207 + setActiveSessionDid(sess, repo.Repo.Did) 172 208 173 209 if err := sess.Save(e.Request(), e.Response()); err != nil { 174 210 return err
+9 -6
server/handle_account_signout.go
··· 1 1 package server 2 2 3 3 import ( 4 - "github.com/gorilla/sessions" 5 4 "github.com/labstack/echo-contrib/session" 6 5 "github.com/labstack/echo/v4" 7 6 ) ··· 12 11 return err 13 12 } 14 13 15 - sess.Options = &sessions.Options{ 16 - Path: "/", 17 - MaxAge: -1, 18 - HttpOnly: true, 14 + activeDid := getActiveSessionDid(sess) 15 + if activeDid != "" { 16 + removeSessionDid(sess, activeDid) 17 + } 18 + 19 + maxAge := int(AccountSessionMaxAge.Seconds()) 20 + if len(getSessionDids(sess)) == 0 { 21 + maxAge = -1 19 22 } 20 23 21 - sess.Values = map[any]any{} 24 + applyAccountSessionOptions(sess, maxAge) 22 25 23 26 if err := sess.Save(e.Request(), e.Response()); err != nil { 24 27 return err
+116
server/handle_account_switch.go
··· 1 + package server 2 + 3 + import ( 4 + "net/http" 5 + "net/url" 6 + "slices" 7 + "strings" 8 + 9 + "github.com/Azure/go-autorest/autorest/to" 10 + "github.com/haileyok/cocoon/internal/helpers" 11 + "github.com/labstack/echo-contrib/session" 12 + "github.com/labstack/echo/v4" 13 + ) 14 + 15 + type AccountSwitchRequest struct { 16 + Did string `form:"did"` 17 + QueryParams string `form:"query_params"` 18 + Next string `form:"next"` 19 + } 20 + 21 + func sanitizeLocalRedirectPath(next string) string { 22 + redirect := strings.TrimSpace(next) 23 + if redirect == "" { 24 + return "/account" 25 + } 26 + if !strings.HasPrefix(redirect, "/") || strings.HasPrefix(redirect, "//") { 27 + return "/account" 28 + } 29 + 30 + parsed, err := url.Parse(redirect) 31 + if err != nil || parsed.IsAbs() || parsed.Host != "" { 32 + return "/account" 33 + } 34 + 35 + return redirect 36 + } 37 + 38 + func mergeRedirectQuery(redirect string, queryParams string) (string, error) { 39 + parsedRedirect, err := url.Parse(redirect) 40 + if err != nil { 41 + return "", err 42 + } 43 + 44 + merged := parsedRedirect.Query() 45 + 46 + rawQueryParams := strings.TrimSpace(queryParams) 47 + if rawQueryParams != "" { 48 + rawQueryParams = strings.TrimPrefix(rawQueryParams, "?") 49 + additional, err := url.ParseQuery(rawQueryParams) 50 + if err != nil { 51 + return "", err 52 + } 53 + for key, values := range additional { 54 + for _, value := range values { 55 + merged.Add(key, value) 56 + } 57 + } 58 + } 59 + 60 + parsedRedirect.RawQuery = merged.Encode() 61 + return parsedRedirect.String(), nil 62 + } 63 + 64 + func isSameOriginRequest(e echo.Context) bool { 65 + host := e.Request().Host 66 + 67 + origin := strings.TrimSpace(e.Request().Header.Get("Origin")) 68 + if origin != "" { 69 + parsedOrigin, err := url.Parse(origin) 70 + return err == nil && parsedOrigin.Host == host 71 + } 72 + 73 + referer := strings.TrimSpace(e.Request().Header.Get("Referer")) 74 + if referer != "" { 75 + parsedReferer, err := url.Parse(referer) 76 + return err == nil && parsedReferer.Host == host 77 + } 78 + 79 + return false 80 + } 81 + 82 + func (s *Server) handleAccountSwitchPost(e echo.Context) error { 83 + if !isSameOriginRequest(e) { 84 + return e.JSON(http.StatusForbidden, map[string]string{"error": "Forbidden"}) 85 + } 86 + 87 + var req AccountSwitchRequest 88 + if err := e.Bind(&req); err != nil { 89 + return helpers.InputError(e, to.StringPtr("invalid switch account request")) 90 + } 91 + 92 + sess, err := session.Get(s.config.SessionCookieKey, e) 93 + if err != nil { 94 + return err 95 + } 96 + 97 + dids := getSessionDids(sess) 98 + if !slices.Contains(dids, req.Did) { 99 + return helpers.InputError(e, to.StringPtr("requested account is not logged in")) 100 + } 101 + 102 + setActiveSessionDid(sess, req.Did) 103 + applyAccountSessionOptions(sess, int(AccountSessionMaxAge.Seconds())) 104 + 105 + if err := sess.Save(e.Request(), e.Response()); err != nil { 106 + return err 107 + } 108 + 109 + redirect := sanitizeLocalRedirectPath(req.Next) 110 + redirect, err = mergeRedirectQuery(redirect, req.QueryParams) 111 + if err != nil { 112 + return helpers.InputError(e, to.StringPtr("invalid query params")) 113 + } 114 + 115 + return e.Redirect(303, redirect) 116 + }
+36 -5
server/handle_oauth_authorize.go
··· 1 1 package server 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 6 "net/url" 7 + "slices" 6 8 "strings" 7 9 "time" 8 10 ··· 11 13 "github.com/haileyok/cocoon/oauth" 12 14 "github.com/haileyok/cocoon/oauth/constants" 13 15 "github.com/haileyok/cocoon/oauth/provider" 16 + "github.com/labstack/echo-contrib/session" 14 17 "github.com/labstack/echo/v4" 15 18 ) 16 19 ··· 52 55 "AppName": "DEV MODE AUTHORIZATION PAGE", 53 56 "Handle": "paula.cocoon.social", 54 57 "RequestUri": "", 58 + "Accounts": []string{}, 59 + "ActiveDid": "", 55 60 }) 56 61 } 57 62 return helpers.InputError(e, to.StringPtr("no request uri and invalid parameters")) ··· 96 101 97 102 } 98 103 99 - repo, _, err := s.getSessionRepoOrErr(e) 100 - if err != nil { 101 - return e.Redirect(303, "/account/signin?"+e.QueryParams().Encode()) 102 - } 103 - 104 104 var req provider.OauthAuthorizationRequest 105 105 if err := s.db.Raw(ctx, "SELECT * FROM oauth_authorization_requests WHERE request_id = ?", nil, reqId).Scan(&req).Error; err != nil { 106 106 return helpers.ServerError(e, to.StringPtr(err.Error())) ··· 116 116 return helpers.ServerError(e, to.StringPtr(err.Error())) 117 117 } 118 118 119 + sess, err := session.Get(s.config.SessionCookieKey, e) 120 + if err != nil { 121 + return helpers.ServerError(e, to.StringPtr(err.Error())) 122 + } 123 + 124 + if req.Parameters.LoginHint != nil && *req.Parameters.LoginHint != "" { 125 + did, err := s.resolveLoginHintToDid(ctx, *req.Parameters.LoginHint) 126 + if err != nil || !slices.Contains(getSessionDids(sess), did) { 127 + return e.Redirect(303, "/account/signin?"+e.QueryParams().Encode()) 128 + } 129 + 130 + setActiveSessionDid(sess, did) 131 + applyAccountSessionOptions(sess, int(AccountSessionMaxAge.Seconds())) 132 + if err := sess.Save(e.Request(), e.Response()); err != nil { 133 + return helpers.ServerError(e, to.StringPtr(err.Error())) 134 + } 135 + } 136 + 137 + repo, _, accounts, err := s.getSessionRepoAndAccountsFromSessionOrErr(e, ctx, sess) 138 + if err != nil { 139 + if !errors.Is(err, ErrSessionUnauthenticated) { 140 + return helpers.ServerError(e, to.StringPtr(err.Error())) 141 + } 142 + return e.Redirect(303, "/account/signin?"+e.QueryParams().Encode()) 143 + } 144 + 119 145 scopes := strings.Split(req.Parameters.Scope, " ") 120 146 appName := client.Metadata.ClientName 121 147 ··· 125 151 "RequestUri": input.RequestUri, 126 152 "QueryParams": e.QueryParams().Encode(), 127 153 "Handle": repo.Actor.Handle, 154 + "Accounts": accounts, 155 + "ActiveDid": repo.Repo.Did, 128 156 } 129 157 130 158 return e.Render(200, "authorize.html", data) ··· 141 169 142 170 repo, _, err := s.getSessionRepoOrErr(e) 143 171 if err != nil { 172 + if !errors.Is(err, ErrSessionUnauthenticated) { 173 + return helpers.ServerError(e, to.StringPtr(err.Error())) 174 + } 144 175 return e.Redirect(303, "/account/signin") 145 176 } 146 177
+1
server/server.go
··· 523 523 // account 524 524 s.echo.GET("/account", s.handleAccount) 525 525 s.echo.POST("/account/revoke", s.handleAccountRevoke) 526 + s.echo.POST("/account/switch", s.handleAccountSwitchPost) 526 527 s.echo.GET("/account/signin", s.handleAccountSigninGet) 527 528 s.echo.POST("/account/signin", s.handleAccountSigninPost) 528 529 s.echo.GET("/account/signout", s.handleAccountSignout)
+16
server/session_options.go
··· 1 + package server 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/gorilla/sessions" 7 + ) 8 + 9 + func applyAccountSessionOptions(sess *sessions.Session, maxAge int) { 10 + sess.Options = &sessions.Options{ 11 + Path: "/", 12 + MaxAge: maxAge, 13 + HttpOnly: true, 14 + SameSite: http.SameSiteLaxMode, 15 + } 16 + }
+16 -1
server/templates/account.html
··· 12 12 <main class="container base-container authorize-container margin-top-xl"> 13 13 <h2>Welcome, {{ .Repo.Handle }}</h2> 14 14 <ul> 15 - <li><a href="/account/signout">Sign Out</a></li> 15 + <li><a href="/account/signout">Sign out this account</a></li> 16 + <li><a href="/account/signin">Sign in another account</a></li> 16 17 </ul> 18 + {{ if gt (len .Accounts) 1 }} 19 + <form action="/account/switch" method="post"> 20 + <input type="hidden" name="next" value="/account" /> 21 + <label for="did">Switch signed-in account</label> 22 + <select name="did" id="did"> 23 + {{ range .Accounts }} 24 + <option value="{{ .Repo.Did }}" {{ if eq .Repo.Did $.ActiveDid }}selected{{ end }}> 25 + {{ .Handle }} 26 + </option> 27 + {{ end }} 28 + </select> 29 + <button type="submit">Switch account</button> 30 + </form> 31 + {{ end }} 17 32 {{ if .flashes.successes }} 18 33 <div class="alert alert-success margin-bottom-xs"> 19 34 <p>{{ index .flashes.successes 0 }}</p>
+19 -1
server/templates/authorize.html
··· 15 15 <h2>Authorizing with {{ .AppName }}</h2> 16 16 <p> 17 17 You are signed in as <b>{{ .Handle }}</b>. 18 - <a href="/account/signout?{{ .QueryParams }}">Switch Account</a> 18 + <a href="/account/signout?{{ .QueryParams }}">Sign out this account</a> 19 + </p> 20 + {{ if gt (len .Accounts) 1 }} 21 + <form action="/account/switch" method="post"> 22 + <input type="hidden" name="query_params" value="{{ .QueryParams }}" /> 23 + <input type="hidden" name="next" value="/oauth/authorize" /> 24 + <label for="did">Switch to another signed-in account</label> 25 + <select name="did" id="did"> 26 + {{ range .Accounts }} 27 + <option value="{{ .Repo.Did }}" {{ if eq .Repo.Did $.ActiveDid }}selected{{ end }}> 28 + {{ .Handle }} 29 + </option> 30 + {{ end }} 31 + </select> 32 + <button type="submit">Switch account</button> 33 + </form> 34 + {{ end }} 35 + <p> 36 + Need a different account? <a href="/account/signin?{{ .QueryParams }}">Sign in another account</a>. 19 37 </p> 20 38 <p><b>{{ .AppName }}</b> is asking for you to grant it these scopes:</p> 21 39 <ul>
+3
server/templates/signin.html
··· 12 12 <main class="container base-container box-shadow-container login-container"> 13 13 <h2>Sign into your account</h2> 14 14 <p>Enter your handle and password below.</p> 15 + {{ if gt (len .Accounts) 0 }} 16 + <p>You currently have {{ len .Accounts }} signed-in account(s).</p> 17 + {{ end }} 15 18 {{ if .flashes.errors }} 16 19 <div class="alert alert-danger margin-bottom-xs"> 17 20 <p>{{ index .flashes.errors 0 }}</p>
+1 -1
test.go
··· 10 10 "strings" 11 11 12 12 "github.com/bluesky-social/indigo/api/atproto" 13 + atp "github.com/bluesky-social/indigo/atproto/repo" 13 14 "github.com/bluesky-social/indigo/atproto/syntax" 14 15 "github.com/bluesky-social/indigo/events" 15 16 "github.com/bluesky-social/indigo/events/schedulers/parallel" 16 - atp "github.com/bluesky-social/indigo/atproto/repo" 17 17 lexutil "github.com/bluesky-social/indigo/lex/util" 18 18 "github.com/bluesky-social/indigo/repomgr" 19 19 "github.com/gorilla/websocket"