An event companion and onboarding experience for the ATmosphere (alpha) atmo.quest
6

Configure Feed

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

Stagger grants so bsky permissions are a later request

Will Garrison (Jun 3, 2026, 12:40 AM -0700) 159fb065 e3376d59

+466 -166
+118 -15
features/api/auth.go
··· 3 3 import ( 4 4 "log/slog" 5 5 "net/http" 6 + "net/url" 6 7 "strings" 8 + "sync" 7 9 8 10 "atmoquest/internal/connection" 11 + "atmoquest/internal/oauthclient" 9 12 "atmoquest/internal/users" 13 + ) 14 + 15 + // upgradeState tracks a mobile API scope upgrade in flight so the callback 16 + // can clean up the old OAuth session after the new one is established. 17 + type upgradeState struct { 18 + OldSid string 19 + } 20 + 21 + var ( 22 + upgradeMu sync.RWMutex 23 + pendingUpgrade = make(map[string]upgradeState) // OAuth state → old session info 10 24 ) 11 25 12 26 // sessionResponse is the JSON shape for GET /api/v1/auth/session. ··· 55 69 return 56 70 } 57 71 58 - redirectURL, err := h.Auth.OAuth.StartAuthFlow(r.Context(), identifier) 72 + redirectURL, err := h.Auth.StartMinimalAuthFlow(r.Context(), identifier) 59 73 if err != nil { 60 - slog.Warn("api: oauth start", 74 + slog.Warn("api: oauth login", 61 75 "identifier", identifier, 62 76 "err", err, 63 77 ) ··· 70 84 }) 71 85 } 72 86 87 + // OAuthUpgrade starts a scope upgrade flow for mobile clients. 88 + // POST /api/v1/oauth/upgrade 89 + // 90 + // Response: {"authUrl": "https://...", "state": "..."} 91 + func (h *Handlers) OAuthUpgrade(w http.ResponseWriter, r *http.Request) { 92 + did, sess, ok := h.requireSession(w, r) 93 + if !ok { 94 + return 95 + } 96 + 97 + if oauthclient.HasBskyProfileScope(sess) { 98 + writeJSON(w, http.StatusOK, map[string]string{ 99 + "status": "already_granted", 100 + "message": "repo:app.bsky.actor.profile scope already granted", 101 + }) 102 + return 103 + } 104 + 105 + pdsURL := sess.Data.HostURL 106 + if pdsURL == "" { 107 + writeError(w, http.StatusInternalServerError, "no PDS host URL available") 108 + return 109 + } 110 + 111 + authserverURL, err := h.Auth.OAuth.Resolver.ResolveAuthServerURL(r.Context(), pdsURL) 112 + if err != nil { 113 + slog.Error("api: oauth upgrade resolve URL", "did", did.String(), "err", err) 114 + writeError(w, http.StatusInternalServerError, "couldn't resolve authorization server") 115 + return 116 + } 117 + 118 + authMeta, err := h.Auth.OAuth.Resolver.ResolveAuthServerMetadata(r.Context(), authserverURL) 119 + if err != nil { 120 + slog.Error("api: oauth upgrade resolve meta", "did", did.String(), "err", err) 121 + writeError(w, http.StatusInternalServerError, "couldn't resolve authorization server metadata") 122 + return 123 + } 124 + 125 + info, err := h.Auth.OAuth.SendAuthRequest(r.Context(), authMeta, oauthclient.FullScopes, did.String()) 126 + if err != nil { 127 + slog.Error("api: oauth upgrade send", "did", did.String(), "err", err) 128 + writeError(w, http.StatusInternalServerError, "couldn't start upgrade flow") 129 + return 130 + } 131 + 132 + info.AccountDID = &did 133 + 134 + if err := h.Auth.OAuth.Store.SaveAuthRequestInfo(r.Context(), *info); err != nil { 135 + slog.Error("api: oauth upgrade save", "did", did.String(), "err", err) 136 + writeError(w, http.StatusInternalServerError, "couldn't persist auth request") 137 + return 138 + } 139 + 140 + params := make(url.Values) 141 + params.Set("client_id", h.Auth.OAuth.Config.ClientID) 142 + params.Set("request_uri", info.RequestURI) 143 + redirectURL := authMeta.AuthorizationEndpoint + "?" + params.Encode() 144 + 145 + upgradeMu.Lock() 146 + pendingUpgrade[info.State] = upgradeState{OldSid: sess.Data.SessionID} 147 + upgradeMu.Unlock() 148 + 149 + slog.Info("api: oauth upgrade started", "did", did.String()) 150 + writeJSON(w, http.StatusOK, map[string]string{ 151 + "authUrl": redirectURL, 152 + }) 153 + } 154 + 73 155 // OAuthCallback completes the OAuth flow for mobile clients. 74 156 // GET /api/v1/oauth/callback?code=...&state=... 75 157 // 158 + // When called as part of a scope upgrade (state matches pendingUpgrade), 159 + // the old OAuth session is cleaned up and a new bearer token is issued. 160 + // 76 161 // Response: {"token": "...", "did": "...", "handle": "..."} 77 162 func (h *Handlers) OAuthCallback(w http.ResponseWriter, r *http.Request) { 163 + state := r.URL.Query().Get("state") 164 + 78 165 sessData, err := h.Auth.OAuth.ProcessCallback(r.Context(), r.URL.Query()) 79 166 if err != nil { 80 167 slog.Warn("api: oauth callback", "err", err) ··· 82 169 return 83 170 } 84 171 85 - // Issue a Bearer token for the mobile app. 86 - token := h.Tokens.Issue(sessData.AccountDID.String(), sessData.SessionID) 172 + // Check if this is a scope upgrade — clean up old session. 173 + upgradeMu.Lock() 174 + old, isUpgrade := pendingUpgrade[state] 175 + if isUpgrade { 176 + delete(pendingUpgrade, state) 177 + } 178 + upgradeMu.Unlock() 87 179 88 - // Best-effort: record login in users table. 89 - go h.Auth.RecordUserLogin(sessData) 180 + if isUpgrade && old.OldSid != "" { 181 + if err := h.Auth.OAuth.Store.DeleteSession(r.Context(), sessData.AccountDID, old.OldSid); err != nil { 182 + slog.Warn("api: oauth upgrade cleanup", "did", sessData.AccountDID.String(), "err", err) 183 + } else { 184 + slog.Info("api: oauth upgrade complete", "did", sessData.AccountDID.String()) 185 + } 186 + } else { 187 + // Best-effort: record login in users table (skip for upgrades). 188 + go h.Auth.RecordUserLogin(sessData) 90 189 91 - // Best-effort: drain pending connections. 92 - if h.ConnQueue != nil { 93 - sess, err := h.Auth.OAuth.ResumeSession(r.Context(), sessData.AccountDID, sessData.SessionID) 94 - if err == nil { 95 - res, err := connection.Drain(r.Context(), h.ConnQueue, sess, sess.Data.HostURL, slog.Default(), nil) 96 - if err != nil { 97 - slog.Warn("api: connect drain", "did", sessData.AccountDID.String(), "err", err) 98 - } else if res.Written > 0 || res.Skipped > 0 { 99 - slog.Info("api: connect drain", "did", sessData.AccountDID.String(), "written", res.Written, "skipped", res.Skipped) 190 + // Best-effort: drain pending connections. 191 + if h.ConnQueue != nil { 192 + sess, err := h.Auth.OAuth.ResumeSession(r.Context(), sessData.AccountDID, sessData.SessionID) 193 + if err == nil { 194 + res, err := connection.Drain(r.Context(), h.ConnQueue, sess, sess.Data.HostURL, slog.Default(), nil) 195 + if err != nil { 196 + slog.Warn("api: connect drain", "did", sessData.AccountDID.String(), "err", err) 197 + } else if res.Written > 0 || res.Skipped > 0 { 198 + slog.Info("api: connect drain", "did", sessData.AccountDID.String(), "written", res.Written, "skipped", res.Skipped) 199 + } 100 200 } 101 201 } 102 202 } 203 + 204 + // Issue a Bearer token for the mobile app (always, regardless of upgrade). 205 + token := h.Tokens.Issue(sessData.AccountDID.String(), sessData.SessionID) 103 206 104 207 handle := h.resolveHandle(r, sessData.AccountDID.String()) 105 208
+1
features/api/routes.go
··· 24 24 // Auth 25 25 r.Get("/auth/session", h.GetSession) 26 26 r.Post("/oauth/login", h.OAuthLogin) 27 + r.Post("/oauth/upgrade", h.OAuthUpgrade) 27 28 r.Get("/oauth/callback", h.OAuthCallback) 28 29 r.Post("/oauth/logout", h.Logout) 29 30
+152 -10
features/auth/handlers.go
··· 8 8 "database/sql" 9 9 "encoding/json" 10 10 "errors" 11 + "fmt" 11 12 "io" 12 13 "log/slog" 13 14 "net/http" ··· 21 22 22 23 "atmoquest/features/auth/pages" 23 24 "atmoquest/internal/connection" 25 + "atmoquest/internal/oauthclient" 24 26 "atmoquest/internal/profile" 25 27 "atmoquest/internal/session" 26 28 "atmoquest/internal/users" ··· 97 99 } 98 100 } 99 101 100 - // OAuthLogin starts the OAuth flow: takes a handle / DID / PDS URL, calls 101 - // indigo's StartAuthFlow, redirects to the AS. 102 + // OAuthLogin starts the OAuth flow with the minimal scope set. Takes a 103 + // handle / DID / PDS URL, resolves identity, sends PAR with DefaultScopes, 104 + // and redirects to the AS. 102 105 func (h *Handlers) OAuthLogin(w http.ResponseWriter, r *http.Request) { 103 106 if err := r.ParseForm(); err != nil { 104 107 http.Error(w, "invalid form", http.StatusBadRequest) ··· 112 115 return 113 116 } 114 117 115 - redirectURL, err := h.OAuth.StartAuthFlow(r.Context(), identifier) 118 + redirectURL, err := h.StartMinimalAuthFlow(r.Context(), identifier) 116 119 if err != nil { 117 - // Indigo already emits a slog.Warn with the full AS response body 118 - // from parseAuthErrorReason — check the line above this one in the 119 - // log for the AS's error_description. We log identifier + client_id 120 - // + callback_url here so the two log entries are easy to correlate. 121 - slog.Warn("oauth start failed", 120 + slog.Warn("oauth login failed", 122 121 "identifier", identifier, 123 122 "client_id", h.OAuth.Config.ClientID, 124 123 "callback_url", h.OAuth.Config.CallbackURL, 125 - "scopes", h.OAuth.Config.Scopes, 124 + "scopes", oauthclient.DefaultScopes, 126 125 "err", err, 127 126 ) 128 127 w.Header().Set("Content-Type", "text/html; charset=utf-8") ··· 130 129 _ = pages.SigninATProto("couldn't start sign-in: "+sanitizeAuthError(err)).Render(r.Context(), w) 131 130 return 132 131 } 133 - slog.Info("oauth start ok", "identifier", identifier, "redirect", redirectURL) 132 + slog.Info("oauth login ok", "identifier", identifier, "redirect", redirectURL) 133 + http.Redirect(w, r, redirectURL, http.StatusFound) 134 + } 135 + 136 + // StartMinimalAuthFlow replicates indigo's StartAuthFlow logic but sends 137 + // only the DefaultScopes (without repo:app.bsky.actor.profile). The client 138 + // config's FullScopes is still used for client metadata so the AS knows the 139 + // client may request the broader set on upgrade. 140 + func (h *Handlers) StartMinimalAuthFlow(ctx context.Context, identifier string) (string, error) { 141 + var authserverURL string 142 + var accountDID syntax.DID 143 + 144 + if strings.HasPrefix(identifier, "https://") { 145 + authserverURL = identifier 146 + identifier = "" 147 + } else { 148 + atid, err := syntax.ParseAtIdentifier(identifier) 149 + if err != nil { 150 + return "", fmt.Errorf("not a valid account identifier (%s): %w", identifier, err) 151 + } 152 + ident, err := h.OAuth.Dir.Lookup(ctx, atid) 153 + if err != nil { 154 + return "", fmt.Errorf("failed to resolve username (%s): %w", identifier, err) 155 + } 156 + accountDID = ident.DID 157 + host := ident.PDSEndpoint() 158 + if host == "" { 159 + return "", fmt.Errorf("identity does not link to an atproto host (PDS)") 160 + } 161 + authserverURL, err = h.OAuth.Resolver.ResolveAuthServerURL(ctx, host) 162 + if err != nil { 163 + return "", fmt.Errorf("resolving auth server: %w", err) 164 + } 165 + } 166 + 167 + authserverMeta, err := h.OAuth.Resolver.ResolveAuthServerMetadata(ctx, authserverURL) 168 + if err != nil { 169 + return "", fmt.Errorf("fetching auth server metadata: %w", err) 170 + } 171 + 172 + info, err := h.OAuth.SendAuthRequest(ctx, authserverMeta, oauthclient.DefaultScopes, identifier) 173 + if err != nil { 174 + return "", fmt.Errorf("auth request failed: %w", err) 175 + } 176 + 177 + if accountDID != "" { 178 + info.AccountDID = &accountDID 179 + } 180 + 181 + if err := h.OAuth.Store.SaveAuthRequestInfo(ctx, *info); err != nil { 182 + return "", fmt.Errorf("persist auth request: %w", err) 183 + } 184 + 185 + params := url.Values{} 186 + params.Set("client_id", h.OAuth.Config.ClientID) 187 + params.Set("request_uri", info.RequestURI) 188 + return authserverMeta.AuthorizationEndpoint + "?" + params.Encode(), nil 189 + } 190 + 191 + // OAuthUpgrade starts an OAuth flow with the full scope set so the user 192 + // can grant repo:app.bsky.actor.profile access to edit display name/avatar. 193 + // The existing session remains valid until the callback completes. 194 + func (h *Handlers) OAuthUpgrade(w http.ResponseWriter, r *http.Request) { 195 + did, sess, err := h.ResumeSession(r) 196 + if err != nil { 197 + http.Redirect(w, r, "/signin", http.StatusFound) 198 + return 199 + } 200 + 201 + if oauthclient.HasBskyProfileScope(sess) { 202 + http.Redirect(w, r, "/profile/edit", http.StatusFound) 203 + return 204 + } 205 + 206 + http.SetCookie(w, &http.Cookie{ 207 + Name: "upgrade_pending", 208 + Value: sess.Data.SessionID, 209 + Path: "/", 210 + HttpOnly: true, 211 + SameSite: http.SameSiteLaxMode, 212 + Secure: r.TLS != nil, 213 + MaxAge: 600, 214 + }) 215 + 216 + pdsURL := sess.Data.HostURL 217 + if pdsURL == "" { 218 + slog.Error("oauth upgrade: no host URL", "did", did.String()) 219 + http.Error(w, "no PDS host URL available", http.StatusInternalServerError) 220 + return 221 + } 222 + 223 + authserverURL, err := h.OAuth.Resolver.ResolveAuthServerURL(r.Context(), pdsURL) 224 + if err != nil { 225 + slog.Error("oauth upgrade: resolve auth server URL", "did", did.String(), "pds", pdsURL, "err", err) 226 + http.Error(w, "couldn't resolve authorization server", http.StatusInternalServerError) 227 + return 228 + } 229 + 230 + authMeta, err := h.OAuth.Resolver.ResolveAuthServerMetadata(r.Context(), authserverURL) 231 + if err != nil { 232 + slog.Error("oauth upgrade: resolve auth server metadata", "did", did.String(), "err", err) 233 + http.Error(w, "couldn't resolve authorization server metadata", http.StatusInternalServerError) 234 + return 235 + } 236 + 237 + info, err := h.OAuth.SendAuthRequest(r.Context(), authMeta, oauthclient.FullScopes, did.String()) 238 + if err != nil { 239 + slog.Error("oauth upgrade: send auth request", "did", did.String(), "err", err) 240 + http.Error(w, "couldn't start upgrade flow", http.StatusInternalServerError) 241 + return 242 + } 243 + 244 + info.AccountDID = &did 245 + 246 + if err := h.OAuth.Store.SaveAuthRequestInfo(r.Context(), *info); err != nil { 247 + slog.Error("oauth upgrade: save auth request info", "did", did.String(), "err", err) 248 + http.Error(w, "couldn't persist auth request", http.StatusInternalServerError) 249 + return 250 + } 251 + 252 + params := url.Values{} 253 + params.Set("client_id", h.OAuth.Config.ClientID) 254 + params.Set("request_uri", info.RequestURI) 255 + redirectURL := authMeta.AuthorizationEndpoint + "?" + params.Encode() 256 + 257 + slog.Info("oauth upgrade started", "did", did.String()) 134 258 http.Redirect(w, r, redirectURL, http.StatusFound) 135 259 } 136 260 ··· 154 278 if err := h.Sessions.Set(w, r, sessData.AccountDID.String(), sessData.SessionID); err != nil { 155 279 slog.Error("set session cookie", "err", err) 156 280 http.Error(w, "internal error", http.StatusInternalServerError) 281 + return 282 + } 283 + 284 + // Check for upgrade flow — clean up old session and redirect to profile 285 + // editor so the user can immediately use the newly-granted scope. 286 + if upgradeCookie, err := r.Cookie("upgrade_pending"); err == nil && upgradeCookie.Value != "" { 287 + oldSid := upgradeCookie.Value 288 + http.SetCookie(w, &http.Cookie{ 289 + Name: "upgrade_pending", 290 + Value: "", 291 + Path: "/", 292 + MaxAge: -1, 293 + }) 294 + slog.Info("oauth upgrade complete", "did", sessData.AccountDID.String()) 295 + if err := h.OAuth.Store.DeleteSession(r.Context(), sessData.AccountDID, oldSid); err != nil { 296 + slog.Warn("oauth upgrade: delete old session", "did", sessData.AccountDID.String(), "old_sid", oldSid, "err", err) 297 + } 298 + http.Redirect(w, r, "/profile/edit", http.StatusFound) 157 299 return 158 300 } 159 301
+8 -7
features/auth/pages/signin_templ.go
··· 5 5 6 6 //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 7 8 + import "github.com/a-h/templ" 9 + import templruntime "github.com/a-h/templ/runtime" 10 + 8 11 import ( 9 - "atmoquest/features/common/layouts" 10 12 "net/url" 11 13 12 - "github.com/a-h/templ" 13 - templruntime "github.com/a-h/templ/runtime" 14 + "atmoquest/features/common/layouts" 14 15 ) 15 16 16 17 // Signin renders the chooser: existing Atmosphere account vs create a new one. ··· 61 62 if templ_7745c5c3_Err != nil { 62 63 return templ_7745c5c3_Err 63 64 } 64 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</span><span class=\"path\">atmo.quest</span><span class=\"sep\">:~$</span> <span class=\"cmd\">auth --pick</span></div><div class=\"hero-block\"><span class=\"hero-tag\">step 1 of 1 · pick a path</span><h1>sign <span class=\"quest\">in</span></h1><p class=\"lede\">atmo.quest is built on <span class=\"kw\">ATProto</span>. Your records live in <span class=\"kw2\">your repo</span>, not ours. You'll sign in with the same identity you use anywhere on the open social web.</p></div><div class=\"auth-choices\"><a href=\"/signin/atproto\" class=\"auth-card\"><div class=\"auth-card-tag\">option a</div><div class=\"auth-card-title\">sign in with your <span class=\"kw2\">Atmosphere</span> account</div><div class=\"auth-card-desc\">already have a handle like <code>you.bsky.social</code> or any ATProto PDS? Go this way.</div><div class=\"auth-card-cta\">▸ continue <span class=\"shortcut\">↵</span></div></a> <a href=\"") 65 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</span><span class=\"path\">atmo.quest</span><span class=\"sep\">:~$</span> <span class=\"cmd\">auth --pick</span></div><div class=\"hero-block\"><h1>sign <span class=\"quest\">in</span></h1><p class=\"lede\">atmo.quest is built on <span class=\"kw\">ATProto</span>. Your records live in <span class=\"kw2\">your repo</span>, not ours. You'll sign in with the same identity you use anywhere on the open social web.</p></div><div class=\"auth-choices\"><a href=\"/signin/atproto\" class=\"auth-card\"><div class=\"auth-card-tag\">option a</div><div class=\"auth-card-title\">Sign in with your <span class=\"kw2\">Atmosphere (Bluesky)</span> account</div><div class=\"auth-card-desc\">already have a handle like <code>you.bsky.social</code> or any ATProto PDS? Go this way.</div><div class=\"auth-card-cta\">▸ continue <span class=\"shortcut\">↵</span></div></a> <a href=\"") 65 66 if templ_7745c5c3_Err != nil { 66 67 return templ_7745c5c3_Err 67 68 } 68 69 var templ_7745c5c3_Var4 templ.SafeURL 69 70 templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(continueLocalURL(next))) 70 71 if templ_7745c5c3_Err != nil { 71 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/auth/pages/signin.templ`, Line: 40, Col: 52} 72 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/auth/pages/signin.templ`, Line: 38, Col: 52} 72 73 } 73 74 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) 74 75 if templ_7745c5c3_Err != nil { 75 76 return templ_7745c5c3_Err 76 77 } 77 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" class=\"auth-card\"><div class=\"auth-card-tag\">option b</div><div class=\"auth-card-title\">Continue without an <span class=\"kw2\">Atmosphere</span> account</div><div class=\"auth-card-desc\">use atmo.quest locally — link an Atmosphere account later in Settings to claim your data.</div><div class=\"auth-card-cta\">▸ continue</div></a></div></div><div class=\"statusbar\"><a href=\"/\" class=\"statusbar-brand\">atmo.quest</a></div></div>") 78 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" class=\"auth-card\"><div class=\"auth-card-tag\">option b</div><div class=\"auth-card-title\">Continue without an <span class=\"kw2\">Atmosphere (Bluesky)</span> account</div><div class=\"auth-card-desc\">use atmo.quest locally — link an Atmosphere account later in Settings to claim your data.</div><div class=\"auth-card-cta\">▸ continue</div></a></div></div><div class=\"statusbar\"><a href=\"/\" class=\"statusbar-brand\">atmo.quest</a></div></div>") 78 79 if templ_7745c5c3_Err != nil { 79 80 return templ_7745c5c3_Err 80 81 } 81 82 return nil 82 83 }) 83 - templ_7745c5c3_Err = layouts.Base("sign in", "Sign in with your ATProto account.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) 84 + templ_7745c5c3_Err = layouts.Base("sign in", "Sign in with your Atmosphere (Bluesky) account.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) 84 85 if templ_7745c5c3_Err != nil { 85 86 return templ_7745c5c3_Err 86 87 }
+2
features/auth/routes.go
··· 13 13 // - GET /signin — chooser page 14 14 // - GET /signin/atproto — handle entry form 15 15 // - POST /oauth/login — start auth flow (PAR → AS redirect) 16 + // - POST /oauth/upgrade — re-auth with full scopes (display name/avatar) 16 17 // - GET /oauth/callback — finish auth flow, set cookie, redirect 17 18 // - POST /oauth/logout — revoke + clear cookie + redirect home 18 19 // - GET /oauth/client-metadata.json — public client metadata doc ··· 25 26 router.Get("/signin", h.Signin) 26 27 router.Get("/signin/atproto", h.SigninATProto) 27 28 router.Post("/oauth/login", h.OAuthLogin) 29 + router.Post("/oauth/upgrade", h.OAuthUpgrade) 28 30 router.Get("/oauth/callback", h.OAuthCallback) 29 31 router.Post("/oauth/logout", h.OAuthLogout) 30 32 router.Get("/oauth/client-metadata.json", h.OAuthClientMetadata)
+3 -3
features/index/pages/index_templ.go
··· 5 5 6 6 //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 7 8 + import "github.com/a-h/templ" 9 + import templruntime "github.com/a-h/templ/runtime" 10 + 8 11 import ( 9 12 "atmoquest/features/common/layouts" 10 - 11 - "github.com/a-h/templ" 12 - templruntime "github.com/a-h/templ/runtime" 13 13 ) 14 14 15 15 // IndexView passes session-aware values into the landing page. When a user
+9 -15
features/profile/pages/profile_edit.templ
··· 88 88 89 89 <form method="POST" action="/profile/edit" class="auth-form profile-edit-form" autocomplete="off" enctype="multipart/form-data"> 90 90 91 - if v.MissingScope { 92 - <div class="auth-error" role="alert"> 93 - <span class="auth-error-prefix">info:</span> 94 - To edit your display name and avatar, you need to re-authenticate with updated permissions. 95 - <a href="/oauth/logout" class="muted-link">sign out and sign back in</a> 96 - </div> 97 - } 98 - 99 91 <div class="profile-card"> 100 92 <div class="profile-avatar-edit"> 101 93 if !v.IsLocal { 102 94 <p class="field-hint" style="margin-bottom:6px">These fields are shared with your bluesky profile.</p> 103 95 } 96 + <label class="field-label">profile photo</label> 104 97 <div class="profile-avatar-edit-img"> 105 98 if v.AvatarURL != "" { 106 99 <img class="profile-avatar" src={ v.AvatarURL } alt={ avatarAlt(v.DisplayName) } loading="lazy"/> ··· 109 102 } 110 103 </div> 111 104 if v.AvatarDisabled { 112 - if v.MissingScope { 113 - <p class="field-hint">sign out and sign back in to change your avatar</p> 114 - } else { 105 + if !v.MissingScope { 115 106 <p class="field-hint">Link your ATmosphere account to upload a profile picture</p> 116 107 } 117 108 } else { ··· 135 126 136 127 <div class="profile-card-fields-row"> 137 128 <div class="field"> 138 - <label class="field-label" for="display_name">display name</label> 129 + <label class="field-label" for="display_name">name</label> 139 130 <div class="field-input-wrap"> 140 131 <input type="text" id="display_name" name="display_name" 141 132 class="field-input" value={ v.DisplayName } ··· 145 136 } 146 137 placeholder="your name" /> 147 138 </div> 148 - if v.DisplayNameDisabled { 149 - <div class="field-hint">sign out and sign back in to edit your display name</div> 150 - } 151 139 </div> 152 140 153 141 if v.IsLocal { ··· 179 167 </span> 180 168 </label> 181 169 } 170 + if v.MissingScope && !v.IsLocal { 171 + <div class="profile-card-footer"> 172 + <span>Grant atmo.quest permission to change these values:</span> 173 + <button type="submit" formaction="/oauth/upgrade" formmethod="POST" class="btn btn-small">▸ Grant access</button> 174 + </div> 175 + } 182 176 </div> 183 177 184 178 if v.Error != "" {
+87 -106
features/profile/pages/profile_edit_templ.go
··· 136 136 if templ_7745c5c3_Err != nil { 137 137 return templ_7745c5c3_Err 138 138 } 139 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span><span class=\"path\">atmo.quest</span><span class=\"sep\">:~$</span> <span class=\"cmd\">profile --edit</span></div><form method=\"POST\" action=\"/profile/edit\" class=\"auth-form profile-edit-form\" autocomplete=\"off\" enctype=\"multipart/form-data\">") 140 - if templ_7745c5c3_Err != nil { 141 - return templ_7745c5c3_Err 142 - } 143 - if v.MissingScope { 144 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"auth-error\" role=\"alert\"><span class=\"auth-error-prefix\">info:</span> To edit your display name and avatar, you need to re-authenticate with updated permissions. <a href=\"/oauth/logout\" class=\"muted-link\">sign out and sign back in</a></div>") 145 - if templ_7745c5c3_Err != nil { 146 - return templ_7745c5c3_Err 147 - } 148 - } 149 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"profile-card\"><div class=\"profile-avatar-edit\">") 139 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span><span class=\"path\">atmo.quest</span><span class=\"sep\">:~$</span> <span class=\"cmd\">profile --edit</span></div><form method=\"POST\" action=\"/profile/edit\" class=\"auth-form profile-edit-form\" autocomplete=\"off\" enctype=\"multipart/form-data\"><div class=\"profile-card\"><div class=\"profile-avatar-edit\">") 150 140 if templ_7745c5c3_Err != nil { 151 141 return templ_7745c5c3_Err 152 142 } 153 143 if !v.IsLocal { 154 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<p class=\"field-hint\" style=\"margin-bottom:6px\">These fields are shared with your bluesky profile.</p>") 144 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<p class=\"field-hint\" style=\"margin-bottom:6px\">These fields are shared with your bluesky profile.</p>") 155 145 if templ_7745c5c3_Err != nil { 156 146 return templ_7745c5c3_Err 157 147 } 158 148 } 159 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"profile-avatar-edit-img\">") 149 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<label class=\"field-label\">profile photo</label><div class=\"profile-avatar-edit-img\">") 160 150 if templ_7745c5c3_Err != nil { 161 151 return templ_7745c5c3_Err 162 152 } 163 153 if v.AvatarURL != "" { 164 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<img class=\"profile-avatar\" src=\"") 154 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<img class=\"profile-avatar\" src=\"") 165 155 if templ_7745c5c3_Err != nil { 166 156 return templ_7745c5c3_Err 167 157 } 168 158 var templ_7745c5c3_Var4 string 169 159 templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.AvatarURL) 170 160 if templ_7745c5c3_Err != nil { 171 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 106, Col: 53} 161 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 99, Col: 53} 172 162 } 173 163 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) 174 164 if templ_7745c5c3_Err != nil { 175 165 return templ_7745c5c3_Err 176 166 } 177 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" alt=\"") 167 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" alt=\"") 178 168 if templ_7745c5c3_Err != nil { 179 169 return templ_7745c5c3_Err 180 170 } 181 171 var templ_7745c5c3_Var5 string 182 172 templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(avatarAlt(v.DisplayName)) 183 173 if templ_7745c5c3_Err != nil { 184 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 106, Col: 86} 174 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 99, Col: 86} 185 175 } 186 176 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) 187 177 if templ_7745c5c3_Err != nil { 188 178 return templ_7745c5c3_Err 189 179 } 190 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" loading=\"lazy\">") 180 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" loading=\"lazy\">") 191 181 if templ_7745c5c3_Err != nil { 192 182 return templ_7745c5c3_Err 193 183 } 194 184 } else { 195 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"profile-avatar profile-avatar-empty\" aria-hidden=\"true\"><span>?</span></div>") 185 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"profile-avatar profile-avatar-empty\" aria-hidden=\"true\"><span>?</span></div>") 196 186 if templ_7745c5c3_Err != nil { 197 187 return templ_7745c5c3_Err 198 188 } 199 189 } 200 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div>") 190 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div>") 201 191 if templ_7745c5c3_Err != nil { 202 192 return templ_7745c5c3_Err 203 193 } 204 194 if v.AvatarDisabled { 205 - if v.MissingScope { 206 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<p class=\"field-hint\">sign out and sign back in to change your avatar</p>") 207 - if templ_7745c5c3_Err != nil { 208 - return templ_7745c5c3_Err 209 - } 210 - } else { 211 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<p class=\"field-hint\">Link your ATmosphere account to upload a profile picture</p>") 195 + if !v.MissingScope { 196 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<p class=\"field-hint\">Link your ATmosphere account to upload a profile picture</p>") 212 197 if templ_7745c5c3_Err != nil { 213 198 return templ_7745c5c3_Err 214 199 } 215 200 } 216 201 } else { 217 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<div class=\"profile-avatar-actions\" style=\"margin-bottom:6px\"><label class=\"btn btn-small\" for=\"avatar-input\">▸ change photo</label> ") 202 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"profile-avatar-actions\" style=\"margin-bottom:6px\"><label class=\"btn btn-small\" for=\"avatar-input\">▸ change photo</label> ") 218 203 if templ_7745c5c3_Err != nil { 219 204 return templ_7745c5c3_Err 220 205 } 221 206 if v.AvatarURL != "" { 222 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<button type=\"button\" class=\"btn btn-text-danger btn-small\" onclick=\"if(confirm('Remove your profile photo?')){document.getElementById('clear_avatar').value='1';this.style.opacity='0.5'}\">▸ remove photo</button> <input type=\"hidden\" id=\"clear_avatar\" name=\"clear_avatar\" value=\"\">") 207 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<button type=\"button\" class=\"btn btn-text-danger btn-small\" onclick=\"if(confirm('Remove your profile photo?')){document.getElementById('clear_avatar').value='1';this.style.opacity='0.5'}\">▸ remove photo</button> <input type=\"hidden\" id=\"clear_avatar\" name=\"clear_avatar\" value=\"\">") 223 208 if templ_7745c5c3_Err != nil { 224 209 return templ_7745c5c3_Err 225 210 } 226 211 } 227 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div><input type=\"file\" id=\"avatar-input\" name=\"avatar\" accept=\"image/jpeg,image/png,image/gif,image/webp\" class=\"avatar-input-hidden\">") 212 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div><input type=\"file\" id=\"avatar-input\" name=\"avatar\" accept=\"image/jpeg,image/png,image/gif,image/webp\" class=\"avatar-input-hidden\">") 228 213 if templ_7745c5c3_Err != nil { 229 214 return templ_7745c5c3_Err 230 215 } 231 216 } 232 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div><div class=\"profile-card-fields-row\"><div class=\"field\"><label class=\"field-label\" for=\"display_name\">display name</label><div class=\"field-input-wrap\"><input type=\"text\" id=\"display_name\" name=\"display_name\" class=\"field-input\" value=\"") 217 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div><div class=\"profile-card-fields-row\"><div class=\"field\"><label class=\"field-label\" for=\"display_name\">name</label><div class=\"field-input-wrap\"><input type=\"text\" id=\"display_name\" name=\"display_name\" class=\"field-input\" value=\"") 233 218 if templ_7745c5c3_Err != nil { 234 219 return templ_7745c5c3_Err 235 220 } 236 221 var templ_7745c5c3_Var6 string 237 222 templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.DisplayName) 238 223 if templ_7745c5c3_Err != nil { 239 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 141, Col: 53} 224 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 132, Col: 53} 240 225 } 241 226 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) 242 227 if templ_7745c5c3_Err != nil { 243 228 return templ_7745c5c3_Err 244 229 } 245 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" maxlength=\"64\"") 246 - if templ_7745c5c3_Err != nil { 247 - return templ_7745c5c3_Err 248 - } 249 - if v.DisplayNameDisabled { 250 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " disabled") 251 - if templ_7745c5c3_Err != nil { 252 - return templ_7745c5c3_Err 253 - } 254 - } 255 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " placeholder=\"your name\"></div>") 230 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" maxlength=\"64\"") 256 231 if templ_7745c5c3_Err != nil { 257 232 return templ_7745c5c3_Err 258 233 } 259 234 if v.DisplayNameDisabled { 260 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"field-hint\">sign out and sign back in to edit your display name</div>") 235 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " disabled") 261 236 if templ_7745c5c3_Err != nil { 262 237 return templ_7745c5c3_Err 263 238 } 264 239 } 265 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div>") 240 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " placeholder=\"your name\"></div></div>") 266 241 if templ_7745c5c3_Err != nil { 267 242 return templ_7745c5c3_Err 268 243 } 269 244 if v.IsLocal { 270 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<div class=\"field\"><label class=\"field-label\" for=\"email\">email</label><div class=\"field-input-wrap\"><input type=\"email\" id=\"email\" name=\"email\" class=\"field-input\" value=\"") 245 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<div class=\"field\"><label class=\"field-label\" for=\"email\">email</label><div class=\"field-input-wrap\"><input type=\"email\" id=\"email\" name=\"email\" class=\"field-input\" value=\"") 271 246 if templ_7745c5c3_Err != nil { 272 247 return templ_7745c5c3_Err 273 248 } 274 249 var templ_7745c5c3_Var7 string 275 250 templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.Email) 276 251 if templ_7745c5c3_Err != nil { 277 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 158, Col: 48} 252 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 146, Col: 48} 278 253 } 279 254 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) 280 255 if templ_7745c5c3_Err != nil { 281 256 return templ_7745c5c3_Err 282 257 } 283 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" maxlength=\"320\" placeholder=\"you@example.com\"></div></div>") 258 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" maxlength=\"320\" placeholder=\"you@example.com\"></div></div>") 284 259 if templ_7745c5c3_Err != nil { 285 260 return templ_7745c5c3_Err 286 261 } 287 262 } 288 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div>") 263 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</div>") 289 264 if templ_7745c5c3_Err != nil { 290 265 return templ_7745c5c3_Err 291 266 } 292 267 if v.IsLocal { 293 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<label class=\"continue-local-checkbox profile-editor-checkbox\"><input type=\"checkbox\" name=\"follow_up\" value=\"1\"") 268 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<label class=\"continue-local-checkbox profile-editor-checkbox\"><input type=\"checkbox\" name=\"follow_up\" value=\"1\"") 294 269 if templ_7745c5c3_Err != nil { 295 270 return templ_7745c5c3_Err 296 271 } 297 272 if v.FollowUp { 298 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " checked") 273 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " checked") 299 274 if templ_7745c5c3_Err != nil { 300 275 return templ_7745c5c3_Err 301 276 } 302 277 } 303 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "> <span class=\"checkbox-visual\" aria-hidden=\"true\"></span> <span class=\"checkbox-label\">follow up after my event if I have not created an <strong>ATmosphere</strong> account</span></label>") 278 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "> <span class=\"checkbox-visual\" aria-hidden=\"true\"></span> <span class=\"checkbox-label\">follow up after my event if I have not created an <strong>ATmosphere</strong> account</span></label> ") 304 279 if templ_7745c5c3_Err != nil { 305 280 return templ_7745c5c3_Err 306 281 } 307 282 } 308 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div>") 283 + if v.MissingScope && !v.IsLocal { 284 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<div class=\"profile-card-footer\"><span>Grant atmo.quest permission to change these values:</span> <button type=\"submit\" formaction=\"/oauth/upgrade\" formmethod=\"POST\" class=\"btn btn-small\">▸ Grant access</button></div>") 285 + if templ_7745c5c3_Err != nil { 286 + return templ_7745c5c3_Err 287 + } 288 + } 289 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div>") 309 290 if templ_7745c5c3_Err != nil { 310 291 return templ_7745c5c3_Err 311 292 } 312 293 if v.Error != "" { 313 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div class=\"auth-error\" role=\"alert\"><span class=\"auth-error-prefix\">error:</span> ") 294 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"auth-error\" role=\"alert\"><span class=\"auth-error-prefix\">error:</span> ") 314 295 if templ_7745c5c3_Err != nil { 315 296 return templ_7745c5c3_Err 316 297 } 317 298 var templ_7745c5c3_Var8 string 318 299 templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(v.Error) 319 300 if templ_7745c5c3_Err != nil { 320 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 186, Col: 61} 301 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 180, Col: 61} 321 302 } 322 303 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) 323 304 if templ_7745c5c3_Err != nil { 324 305 return templ_7745c5c3_Err 325 306 } 326 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</div>") 307 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</div>") 327 308 if templ_7745c5c3_Err != nil { 328 309 return templ_7745c5c3_Err 329 310 } 330 311 } 331 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<div class=\"field\"><label class=\"field-label\" for=\"bio\">bio (atmo.quest override)</label><div class=\"field-input-wrap\"><textarea id=\"bio\" name=\"bio\" class=\"field-input field-textarea\" rows=\"4\" maxlength=\"500\" placeholder=\"") 312 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<div class=\"field\"><label class=\"field-label\" for=\"bio\">bio (atmo.quest override)</label><div class=\"field-input-wrap\"><textarea id=\"bio\" name=\"bio\" class=\"field-input field-textarea\" rows=\"4\" maxlength=\"500\" placeholder=\"") 332 313 if templ_7745c5c3_Err != nil { 333 314 return templ_7745c5c3_Err 334 315 } 335 316 var templ_7745c5c3_Var9 string 336 317 templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(bioPlaceholder(v.BlueskyBio)) 337 318 if templ_7745c5c3_Err != nil { 338 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 198, Col: 50} 319 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 192, Col: 50} 339 320 } 340 321 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) 341 322 if templ_7745c5c3_Err != nil { 342 323 return templ_7745c5c3_Err 343 324 } 344 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\">") 325 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\">") 345 326 if templ_7745c5c3_Err != nil { 346 327 return templ_7745c5c3_Err 347 328 } 348 329 var templ_7745c5c3_Var10 string 349 330 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(v.Bio) 350 331 if templ_7745c5c3_Err != nil { 351 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 199, Col: 15} 332 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 193, Col: 15} 352 333 } 353 334 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) 354 335 if templ_7745c5c3_Err != nil { 355 336 return templ_7745c5c3_Err 356 337 } 357 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</textarea></div><div class=\"field-hint\">") 338 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</textarea></div><div class=\"field-hint\">") 358 339 if templ_7745c5c3_Err != nil { 359 340 return templ_7745c5c3_Err 360 341 } 361 342 if v.BlueskyBio != "" { 362 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "leave blank to fall back to your bluesky bio:<br><span class=\"muted\">") 343 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "leave blank to fall back to your bluesky bio:<br><span class=\"muted\">") 363 344 if templ_7745c5c3_Err != nil { 364 345 return templ_7745c5c3_Err 365 346 } 366 347 var templ_7745c5c3_Var11 string 367 348 templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(v.BlueskyBio) 368 349 if templ_7745c5c3_Err != nil { 369 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 205, Col: 42} 350 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 199, Col: 42} 370 351 } 371 352 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) 372 353 if templ_7745c5c3_Err != nil { 373 354 return templ_7745c5c3_Err 374 355 } 375 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</span>") 356 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</span>") 376 357 if templ_7745c5c3_Err != nil { 377 358 return templ_7745c5c3_Err 378 359 } 379 360 } else { 380 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "tell people what you're about. up to 256 characters.") 361 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "tell people what you're about. up to 256 characters.") 381 362 if templ_7745c5c3_Err != nil { 382 363 return templ_7745c5c3_Err 383 364 } 384 365 } 385 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</div></div><div class=\"field\"><label class=\"field-label\" for=\"works_at\">works at <span class=\"field-label-aux\">(optional)</span></label><div class=\"field-input-wrap\"><input type=\"text\" id=\"works_at\" name=\"works_at\" class=\"field-input\" value=\"") 366 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</div></div><div class=\"field\"><label class=\"field-label\" for=\"works_at\">works at <span class=\"field-label-aux\">(optional)</span></label><div class=\"field-input-wrap\"><input type=\"text\" id=\"works_at\" name=\"works_at\" class=\"field-input\" value=\"") 386 367 if templ_7745c5c3_Err != nil { 387 368 return templ_7745c5c3_Err 388 369 } 389 370 var templ_7745c5c3_Var12 string 390 371 templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.WorksAt) 391 372 if templ_7745c5c3_Err != nil { 392 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 220, Col: 25} 373 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 214, Col: 25} 393 374 } 394 375 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12) 395 376 if templ_7745c5c3_Err != nil { 396 377 return templ_7745c5c3_Err 397 378 } 398 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\" maxlength=\"120\" placeholder=\"acme co · freelance · between gigs…\"></div><div class=\"field-hint\">where you spend your weekdays. up to 120 characters.</div></div><div class=\"field\"><label class=\"field-label\" for=\"location\">based in <span class=\"field-label-aux\">(optional)</span></label><div class=\"field-input-wrap\"><input type=\"text\" id=\"location\" name=\"location\" class=\"field-input\" value=\"") 379 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\" maxlength=\"120\" placeholder=\"acme co · freelance · between gigs…\"></div><div class=\"field-hint\">where you spend your weekdays. up to 120 characters.</div></div><div class=\"field\"><label class=\"field-label\" for=\"location\">based in <span class=\"field-label-aux\">(optional)</span></label><div class=\"field-input-wrap\"><input type=\"text\" id=\"location\" name=\"location\" class=\"field-input\" value=\"") 399 380 if templ_7745c5c3_Err != nil { 400 381 return templ_7745c5c3_Err 401 382 } 402 383 var templ_7745c5c3_Var13 string 403 384 templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.Location) 404 385 if templ_7745c5c3_Err != nil { 405 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 236, Col: 26} 386 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 230, Col: 26} 406 387 } 407 388 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) 408 389 if templ_7745c5c3_Err != nil { 409 390 return templ_7745c5c3_Err 410 391 } 411 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" maxlength=\"120\" placeholder=\"portland, OR · remote · earth\"></div><div class=\"field-hint\">city, region, or vibe. up to 120 characters.</div></div><div class=\"field\"><label class=\"field-label\" for=\"contact_method\">preferred contact <span class=\"field-label-aux\">(optional)</span></label><div class=\"field-input-wrap\"><input type=\"text\" id=\"contact_method\" name=\"contact_method\" class=\"field-input\" value=\"") 392 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" maxlength=\"120\" placeholder=\"portland, OR · remote · earth\"></div><div class=\"field-hint\">city, region, or vibe. up to 120 characters.</div></div><div class=\"field\"><label class=\"field-label\" for=\"contact_method\">preferred contact <span class=\"field-label-aux\">(optional)</span></label><div class=\"field-input-wrap\"><input type=\"text\" id=\"contact_method\" name=\"contact_method\" class=\"field-input\" value=\"") 412 393 if templ_7745c5c3_Err != nil { 413 394 return templ_7745c5c3_Err 414 395 } 415 396 var templ_7745c5c3_Var14 string 416 397 templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.ContactMethod) 417 398 if templ_7745c5c3_Err != nil { 418 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 252, Col: 31} 399 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 246, Col: 31} 419 400 } 420 401 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) 421 402 if templ_7745c5c3_Err != nil { 422 403 return templ_7745c5c3_Err 423 404 } 424 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" maxlength=\"200\" placeholder=\"DM on bsky · email me · signal: …\"></div><div class=\"field-hint\">how people should reach out after a connect. up to 200 characters.</div></div><div class=\"field\"><label class=\"field-label\" for=\"interests-input\">interests</label><div class=\"tag-combobox field-input-wrap\" data-tag-combobox data-tag-max=\"30\" data-tag-maxlen=\"64\"><div class=\"tag-chips\" data-tag-chips>") 405 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" maxlength=\"200\" placeholder=\"DM on bsky · email me · signal: …\"></div><div class=\"field-hint\">how people should reach out after a connect. up to 200 characters.</div></div><div class=\"field\"><label class=\"field-label\" for=\"interests-input\">interests</label><div class=\"tag-combobox field-input-wrap\" data-tag-combobox data-tag-max=\"30\" data-tag-maxlen=\"64\"><div class=\"tag-chips\" data-tag-chips>") 425 406 if templ_7745c5c3_Err != nil { 426 407 return templ_7745c5c3_Err 427 408 } 428 409 for _, t := range v.Interests { 429 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<span class=\"tag-chip\" data-tag=\"") 410 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<span class=\"tag-chip\" data-tag=\"") 430 411 if templ_7745c5c3_Err != nil { 431 412 return templ_7745c5c3_Err 432 413 } 433 414 var templ_7745c5c3_Var15 string 434 415 templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(t) 435 416 if templ_7745c5c3_Err != nil { 436 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 270, Col: 44} 417 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 264, Col: 44} 437 418 } 438 419 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) 439 420 if templ_7745c5c3_Err != nil { 440 421 return templ_7745c5c3_Err 441 422 } 442 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\"><span class=\"tag-chip-label\">") 423 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\"><span class=\"tag-chip-label\">") 443 424 if templ_7745c5c3_Err != nil { 444 425 return templ_7745c5c3_Err 445 426 } 446 427 var templ_7745c5c3_Var16 string 447 428 templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(t) 448 429 if templ_7745c5c3_Err != nil { 449 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 271, Col: 42} 430 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 265, Col: 42} 450 431 } 451 432 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) 452 433 if templ_7745c5c3_Err != nil { 453 434 return templ_7745c5c3_Err 454 435 } 455 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</span> <button type=\"button\" class=\"tag-chip-remove\" aria-label=\"") 436 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</span> <button type=\"button\" class=\"tag-chip-remove\" aria-label=\"") 456 437 if templ_7745c5c3_Err != nil { 457 438 return templ_7745c5c3_Err 458 439 } 459 440 var templ_7745c5c3_Var17 string 460 441 templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("remove " + t) 461 442 if templ_7745c5c3_Err != nil { 462 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 275, Col: 37} 443 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 269, Col: 37} 463 444 } 464 445 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) 465 446 if templ_7745c5c3_Err != nil { 466 447 return templ_7745c5c3_Err 467 448 } 468 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" data-tag-remove>×</button></span>") 449 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" data-tag-remove>×</button></span>") 469 450 if templ_7745c5c3_Err != nil { 470 451 return templ_7745c5c3_Err 471 452 } 472 453 } 473 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</div><input type=\"text\" id=\"interests-input\" class=\"tag-input\" list=\"interest-suggestions\" autocomplete=\"off\" placeholder=\"type or pick — press enter to add\" data-tag-input><input type=\"hidden\" name=\"interests\" data-tag-hidden value=\"") 454 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</div><input type=\"text\" id=\"interests-input\" class=\"tag-input\" list=\"interest-suggestions\" autocomplete=\"off\" placeholder=\"type or pick — press enter to add\" data-tag-input><input type=\"hidden\" name=\"interests\" data-tag-hidden value=\"") 474 455 if templ_7745c5c3_Err != nil { 475 456 return templ_7745c5c3_Err 476 457 } 477 458 var templ_7745c5c3_Var18 string 478 459 templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(interestsAsText(v.Interests)) 479 460 if templ_7745c5c3_Err != nil { 480 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 293, Col: 97} 461 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 287, Col: 97} 481 462 } 482 463 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18) 483 464 if templ_7745c5c3_Err != nil { 484 465 return templ_7745c5c3_Err 485 466 } 486 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\"></div><datalist id=\"interest-suggestions\">") 467 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\"></div><datalist id=\"interest-suggestions\">") 487 468 if templ_7745c5c3_Err != nil { 488 469 return templ_7745c5c3_Err 489 470 } 490 471 for _, s := range suggestedInterests { 491 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<option value=\"") 472 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<option value=\"") 492 473 if templ_7745c5c3_Err != nil { 493 474 return templ_7745c5c3_Err 494 475 } 495 476 var templ_7745c5c3_Var19 string 496 477 templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(s) 497 478 if templ_7745c5c3_Err != nil { 498 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 297, Col: 25} 479 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 291, Col: 25} 499 480 } 500 481 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19) 501 482 if templ_7745c5c3_Err != nil { 502 483 return templ_7745c5c3_Err 503 484 } 504 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\"></option>") 485 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\"></option>") 505 486 if templ_7745c5c3_Err != nil { 506 487 return templ_7745c5c3_Err 507 488 } 508 489 } 509 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</datalist><div class=\"field-hint\">pick from suggestions or add your own. up to 30, 64 characters each.</div></div><fieldset class=\"field profile-status-field\"><legend class=\"field-label\">status</legend><div class=\"profile-status-toggles\"><label class=\"profile-status-toggle\"><input type=\"checkbox\" name=\"hiring\" value=\"1\"") 490 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</datalist><div class=\"field-hint\">pick from suggestions or add your own. up to 30, 64 characters each.</div></div><fieldset class=\"field profile-status-field\"><legend class=\"field-label\">status</legend><div class=\"profile-status-toggles\"><label class=\"profile-status-toggle\"><input type=\"checkbox\" name=\"hiring\" value=\"1\"") 510 491 if templ_7745c5c3_Err != nil { 511 492 return templ_7745c5c3_Err 512 493 } 513 494 if v.Hiring { 514 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " checked") 495 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " checked") 515 496 if templ_7745c5c3_Err != nil { 516 497 return templ_7745c5c3_Err 517 498 } 518 499 } 519 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "> <span class=\"profile-status-toggle-pill\"><span class=\"pill-status-dot\" aria-hidden=\"true\">●</span> i'm hiring</span></label> <label class=\"profile-status-toggle\"><input type=\"checkbox\" name=\"looking\" value=\"1\"") 500 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "> <span class=\"profile-status-toggle-pill\"><span class=\"pill-status-dot\" aria-hidden=\"true\">●</span> i'm hiring</span></label> <label class=\"profile-status-toggle\"><input type=\"checkbox\" name=\"looking\" value=\"1\"") 520 501 if templ_7745c5c3_Err != nil { 521 502 return templ_7745c5c3_Err 522 503 } 523 504 if v.Looking { 524 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " checked") 505 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " checked") 525 506 if templ_7745c5c3_Err != nil { 526 507 return templ_7745c5c3_Err 527 508 } 528 509 } 529 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "> <span class=\"profile-status-toggle-pill\"><span class=\"pill-status-dot\" aria-hidden=\"true\">●</span> looking for work</span></label></div><div class=\"field-hint\">shown as pills above your bio. toggle off to hide.</div></fieldset><fieldset class=\"field profile-links-field\"><legend class=\"field-label\">links (up to 5)</legend> ") 510 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "> <span class=\"profile-status-toggle-pill\"><span class=\"pill-status-dot\" aria-hidden=\"true\">●</span> looking for work</span></label></div><div class=\"field-hint\">shown as pills above your bio. toggle off to hide.</div></fieldset><fieldset class=\"field profile-links-field\"><legend class=\"field-label\">links (up to 5)</legend> ") 530 511 if templ_7745c5c3_Err != nil { 531 512 return templ_7745c5c3_Err 532 513 } ··· 536 517 return templ_7745c5c3_Err 537 518 } 538 519 } 539 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<div class=\"field-hint\">pinned links shown on your profile. label + URL. http(s) only.</div></fieldset><div class=\"ctas\"><button type=\"submit\" class=\"btn btn-primary\">▸ save</button> <a href=\"/profile\" class=\"btn btn-ghost\">cancel</a></div></form></div><div class=\"statusbar\"><a href=\"/\" class=\"statusbar-brand\">atmo.quest</a></div></div><script src=\"/static/js/interests.js\" defer></script> ") 520 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<div class=\"field-hint\">pinned links shown on your profile. label + URL. http(s) only.</div></fieldset><div class=\"ctas\"><button type=\"submit\" class=\"btn btn-primary\">▸ save</button> <a href=\"/profile\" class=\"btn btn-ghost\">cancel</a></div></form></div><div class=\"statusbar\"><a href=\"/\" class=\"statusbar-brand\">atmo.quest</a></div></div><script src=\"/static/js/interests.js\" defer></script> ") 540 521 if templ_7745c5c3_Err != nil { 541 522 return templ_7745c5c3_Err 542 523 } ··· 576 557 templ_7745c5c3_Var20 = templ.NopComponent 577 558 } 578 559 ctx = templ.ClearChildren(ctx) 579 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<div class=\"profile-link-row\"><div class=\"profile-link-num\" aria-hidden=\"true\">#") 560 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<div class=\"profile-link-row\"><div class=\"profile-link-num\" aria-hidden=\"true\">#") 580 561 if templ_7745c5c3_Err != nil { 581 562 return templ_7745c5c3_Err 582 563 } 583 564 var templ_7745c5c3_Var21 string 584 565 templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(idx + 1)) 585 566 if templ_7745c5c3_Err != nil { 586 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 370, Col: 75} 567 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 364, Col: 75} 587 568 } 588 569 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) 589 570 if templ_7745c5c3_Err != nil { 590 571 return templ_7745c5c3_Err 591 572 } 592 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</div><div class=\"profile-link-fields\"><div class=\"field-input-wrap profile-link-label-wrap\"><input type=\"text\" class=\"field-input\" name=\"") 573 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</div><div class=\"profile-link-fields\"><div class=\"field-input-wrap profile-link-label-wrap\"><input type=\"text\" class=\"field-input\" name=\"") 593 574 if templ_7745c5c3_Err != nil { 594 575 return templ_7745c5c3_Err 595 576 } 596 577 var templ_7745c5c3_Var22 string 597 578 templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("link_label_" + strconv.Itoa(idx)) 598 579 if templ_7745c5c3_Err != nil { 599 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 376, Col: 45} 580 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 370, Col: 45} 600 581 } 601 582 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22) 602 583 if templ_7745c5c3_Err != nil { 603 584 return templ_7745c5c3_Err 604 585 } 605 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\" value=\"") 586 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" value=\"") 606 587 if templ_7745c5c3_Err != nil { 607 588 return templ_7745c5c3_Err 608 589 } 609 590 var templ_7745c5c3_Var23 string 610 591 templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Label) 611 592 if templ_7745c5c3_Err != nil { 612 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 377, Col: 20} 593 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 371, Col: 20} 613 594 } 614 595 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23) 615 596 if templ_7745c5c3_Err != nil { 616 597 return templ_7745c5c3_Err 617 598 } 618 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" maxlength=\"60\" placeholder=\"my website\" aria-label=\"") 599 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" maxlength=\"60\" placeholder=\"my website\" aria-label=\"") 619 600 if templ_7745c5c3_Err != nil { 620 601 return templ_7745c5c3_Err 621 602 } 622 603 var templ_7745c5c3_Var24 string 623 604 templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue("link " + strconv.Itoa(idx+1) + " label") 624 605 if templ_7745c5c3_Err != nil { 625 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 380, Col: 58} 606 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 374, Col: 58} 626 607 } 627 608 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24) 628 609 if templ_7745c5c3_Err != nil { 629 610 return templ_7745c5c3_Err 630 611 } 631 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\"></div><div class=\"field-input-wrap profile-link-url-wrap\"><input type=\"url\" class=\"field-input\" name=\"") 612 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\"></div><div class=\"field-input-wrap profile-link-url-wrap\"><input type=\"url\" class=\"field-input\" name=\"") 632 613 if templ_7745c5c3_Err != nil { 633 614 return templ_7745c5c3_Err 634 615 } 635 616 var templ_7745c5c3_Var25 string 636 617 templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("link_url_" + strconv.Itoa(idx)) 637 618 if templ_7745c5c3_Err != nil { 638 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 387, Col: 43} 619 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 381, Col: 43} 639 620 } 640 621 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25) 641 622 if templ_7745c5c3_Err != nil { 642 623 return templ_7745c5c3_Err 643 624 } 644 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\" value=\"") 625 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\" value=\"") 645 626 if templ_7745c5c3_Err != nil { 646 627 return templ_7745c5c3_Err 647 628 } 648 629 var templ_7745c5c3_Var26 string 649 630 templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.URL) 650 631 if templ_7745c5c3_Err != nil { 651 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 388, Col: 18} 632 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 382, Col: 18} 652 633 } 653 634 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26) 654 635 if templ_7745c5c3_Err != nil { 655 636 return templ_7745c5c3_Err 656 637 } 657 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" maxlength=\"2048\" placeholder=\"https://example.com\" aria-label=\"") 638 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\" maxlength=\"2048\" placeholder=\"https://example.com\" aria-label=\"") 658 639 if templ_7745c5c3_Err != nil { 659 640 return templ_7745c5c3_Err 660 641 } 661 642 var templ_7745c5c3_Var27 string 662 643 templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue("link " + strconv.Itoa(idx+1) + " URL") 663 644 if templ_7745c5c3_Err != nil { 664 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 391, Col: 56} 645 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 385, Col: 56} 665 646 } 666 647 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27) 667 648 if templ_7745c5c3_Err != nil { 668 649 return templ_7745c5c3_Err 669 650 } 670 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\"></div></div></div>") 651 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\"></div></div></div>") 671 652 if templ_7745c5c3_Err != nil { 672 653 return templ_7745c5c3_Err 673 654 }
+30 -9
internal/oauthclient/oauthclient.go
··· 25 25 "atmoquest/config" 26 26 ) 27 27 28 - // DefaultScopes is the scope set requested for every login. 28 + // DefaultScopes is the minimal scope set requested on initial login. 29 29 // 30 30 // Design: principle of least privilege. We ask only for the `atproto` 31 31 // identity scope plus full CRUD on the records this app owns in the user's ··· 34 34 // that's the legacy blanket scope, and a quest-tracking app has no business 35 35 // holding it. 36 36 // 37 - // Reading public records (e.g. `app.bsky.actor.profile` for display name / 38 - // avatar) is unauthenticated — public records on a PDS need no scope. If we 39 - // ever need to call AppView XRPCs (e.g. `app.bsky.actor.getProfile` for a 40 - // hydrated profile), add the corresponding `rpc:<lxm>?aud=<appview-did>` 41 - // scope at that point. 37 + // Notably, `repo:app.bsky.actor.profile` is NOT in this list. Reading 38 + // public records (e.g. `app.bsky.actor.profile` for display name / avatar) 39 + // is unauthenticated — public records on a PDS need no scope. Writing the 40 + // Bluesky display name and avatar is gated behind an opt-in upgrade flow 41 + // (see FullScopes and POST /oauth/upgrade). 42 42 // 43 43 // Each `repo:<nsid>` here omits `action=`, which per spec grants 44 44 // create + update + delete on that collection. 45 45 var DefaultScopes = []string{ 46 46 "atproto", 47 - "repo:app.bsky.actor.profile", // write displayName + avatar 47 + "repo:quest.atmo.profile", 48 + "repo:quest.atmo.event", 49 + "repo:quest.atmo.checkin", 50 + "repo:quest.atmo.connection", 51 + "repo:quest.atmo.badge", 52 + } 53 + 54 + // FullScopes is the scope set used during the opt-in upgrade flow 55 + // (POST /oauth/upgrade). It includes everything in DefaultScopes plus 56 + // repo:app.bsky.actor.profile so the user can edit their Bluesky 57 + // display name and avatar from the profile editor. 58 + var FullScopes = []string{ 59 + "atproto", 60 + "repo:app.bsky.actor.profile", 48 61 "repo:quest.atmo.profile", 49 62 "repo:quest.atmo.event", 50 63 "repo:quest.atmo.checkin", ··· 55 68 // Build constructs an *oauth.ClientApp wired against the provided store. 56 69 // Returns the app plus the resolved client_id URL (for surfacing in logs / 57 70 // debug pages). 71 + // 72 + // The client config is initialised with FullScopes so the client metadata 73 + // (served at /oauth/client-metadata.json or embedded in the localhost 74 + // client_id) advertises every scope the app may ever request — including 75 + // repo:app.bsky.actor.profile, which is gated behind an opt-in upgrade 76 + // flow. The initial login flow (OAuthLogin) uses SendAuthRequest with 77 + // DefaultScopes instead of calling StartAuthFlow, so the actual PAR only 78 + // contains the minimal set. 58 79 func Build(cfg *config.Config, store oauth.ClientAuthStore) (*oauth.ClientApp, string, error) { 59 80 if cfg.IsLocalhost() { 60 81 callbackURL := cfg.PublicURL + "/oauth/callback" 61 - ocfg := oauth.NewLocalhostConfig(callbackURL, DefaultScopes) 82 + ocfg := oauth.NewLocalhostConfig(callbackURL, FullScopes) 62 83 app := oauth.NewClientApp(&ocfg, store) 63 84 return app, ocfg.ClientID, nil 64 85 } ··· 68 89 // atcrypto.ParsePrivateMultibase or generation + persistence. 69 90 clientID := cfg.PublicURL + "/oauth/client-metadata.json" 70 91 callbackURL := cfg.PublicURL + "/oauth/callback" 71 - ocfg := oauth.NewPublicConfig(clientID, callbackURL, DefaultScopes) 92 + ocfg := oauth.NewPublicConfig(clientID, callbackURL, FullScopes) 72 93 app := oauth.NewClientApp(&ocfg, store) 73 94 74 95 // Sanity check: NewPublicConfig should always set ClientID, but we depend
+32 -1
internal/oauthclient/oauthclient_test.go
··· 13 13 // instead of spinning up SQLite for what's a pure-construction test. 14 14 var nilStore = (*oauthstore.Store)(nil) 15 15 16 + func TestFullScopes(t *testing.T) { 17 + if len(FullScopes) < 2 { 18 + t.Fatalf("FullScopes should request at least atproto + a write scope; got %v", FullScopes) 19 + } 20 + 21 + set := make(map[string]bool, len(FullScopes)) 22 + for _, s := range FullScopes { 23 + set[s] = true 24 + } 25 + 26 + if !set["atproto"] { 27 + t.Errorf("FullScopes must include 'atproto'; got %v", FullScopes) 28 + } 29 + 30 + if !set["repo:app.bsky.actor.profile"] { 31 + t.Errorf("FullScopes must include 'repo:app.bsky.actor.profile'; got %v", FullScopes) 32 + } 33 + 34 + // FullScopes should be a superset of DefaultScopes. 35 + for _, s := range DefaultScopes { 36 + if !set[s] { 37 + t.Errorf("FullScopes missing scope from DefaultScopes: %q; got %v", s, FullScopes) 38 + } 39 + } 40 + } 41 + 16 42 func TestBuild_Localhost(t *testing.T) { 17 43 cfg := &config.Config{PublicURL: "http://localhost:3000"} 18 44 app, clientID, err := Build(cfg, nilStore) ··· 85 111 t.Errorf("DefaultScopes should not request transition:* scopes; got %v", DefaultScopes) 86 112 } 87 113 114 + // DefaultScopes should NOT include repo:app.bsky.actor.profile — 115 + // that scope is gated behind an opt-in upgrade flow (FullScopes). 116 + if set["repo:app.bsky.actor.profile"] { 117 + t.Errorf("DefaultScopes must NOT include 'repo:app.bsky.actor.profile' (moved to FullScopes); got %v", DefaultScopes) 118 + } 119 + 88 120 // Every app-owned lexicon under quest.atmo.* must have a corresponding 89 121 // repo: write scope, otherwise the feature that needs it will silently 90 122 // fail at write time. Update this list when adding a new lexicon. 91 123 wantRepoScopes := []string{ 92 - "repo:app.bsky.actor.profile", 93 124 "repo:quest.atmo.profile", 94 125 "repo:quest.atmo.event", 95 126 "repo:quest.atmo.checkin",
+24
web/resources/static/css/terminal.css
··· 585 585 text-align: left; 586 586 } 587 587 588 + /* Footer banner inside the profile card for the OAuth upgrade prompt. */ 589 + .profile-card-footer { 590 + margin-top: 18px; 591 + padding: 10px 14px; 592 + border: 1px solid var(--overlay); 593 + border-radius: 6px; 594 + background: rgba(127, 132, 156, 0.06); 595 + display: flex; 596 + align-items: center; 597 + justify-content: space-between; 598 + gap: 12px; 599 + width: 100%; 600 + box-sizing: border-box; 601 + } 602 + .profile-card-footer span { 603 + font-size: 12px; 604 + color: var(--muted); 605 + text-align: left; 606 + } 607 + .profile-card-footer .btn-small { 608 + flex-shrink: 0; 609 + } 610 + 588 611 /* "✓ connected with did:…" status banner shown after a successful confirm. */ 589 612 .connected-banner { 590 613 display: flex; ··· 3698 3721 flex-direction: column; 3699 3722 align-items: center; 3700 3723 gap: 10px; 3724 + margin-bottom: 10px; 3701 3725 } 3702 3726 .profile-avatar-actions { 3703 3727 display: flex;