diff --git a/features/api/auth.go b/features/api/auth.go index 4f0cf59..4d78686 100644 --- a/features/api/auth.go +++ b/features/api/auth.go @@ -3,12 +3,26 @@ package api import ( "log/slog" "net/http" + "net/url" "strings" + "sync" "atmoquest/internal/connection" + "atmoquest/internal/oauthclient" "atmoquest/internal/users" ) +// upgradeState tracks a mobile API scope upgrade in flight so the callback +// can clean up the old OAuth session after the new one is established. +type upgradeState struct { + OldSid string +} + +var ( + upgradeMu sync.RWMutex + pendingUpgrade = make(map[string]upgradeState) // OAuth state → old session info +) + // sessionResponse is the JSON shape for GET /api/v1/auth/session. type sessionResponse struct { DID string `json:"did"` @@ -55,9 +69,9 @@ func (h *Handlers) OAuthLogin(w http.ResponseWriter, r *http.Request) { return } - redirectURL, err := h.Auth.OAuth.StartAuthFlow(r.Context(), identifier) + redirectURL, err := h.Auth.StartMinimalAuthFlow(r.Context(), identifier) if err != nil { - slog.Warn("api: oauth start", + slog.Warn("api: oauth login", "identifier", identifier, "err", err, ) @@ -70,11 +84,84 @@ func (h *Handlers) OAuthLogin(w http.ResponseWriter, r *http.Request) { }) } +// OAuthUpgrade starts a scope upgrade flow for mobile clients. +// POST /api/v1/oauth/upgrade +// +// Response: {"authUrl": "https://...", "state": "..."} +func (h *Handlers) OAuthUpgrade(w http.ResponseWriter, r *http.Request) { + did, sess, ok := h.requireSession(w, r) + if !ok { + return + } + + if oauthclient.HasBskyProfileScope(sess) { + writeJSON(w, http.StatusOK, map[string]string{ + "status": "already_granted", + "message": "repo:app.bsky.actor.profile scope already granted", + }) + return + } + + pdsURL := sess.Data.HostURL + if pdsURL == "" { + writeError(w, http.StatusInternalServerError, "no PDS host URL available") + return + } + + authserverURL, err := h.Auth.OAuth.Resolver.ResolveAuthServerURL(r.Context(), pdsURL) + if err != nil { + slog.Error("api: oauth upgrade resolve URL", "did", did.String(), "err", err) + writeError(w, http.StatusInternalServerError, "couldn't resolve authorization server") + return + } + + authMeta, err := h.Auth.OAuth.Resolver.ResolveAuthServerMetadata(r.Context(), authserverURL) + if err != nil { + slog.Error("api: oauth upgrade resolve meta", "did", did.String(), "err", err) + writeError(w, http.StatusInternalServerError, "couldn't resolve authorization server metadata") + return + } + + info, err := h.Auth.OAuth.SendAuthRequest(r.Context(), authMeta, oauthclient.FullScopes, did.String()) + if err != nil { + slog.Error("api: oauth upgrade send", "did", did.String(), "err", err) + writeError(w, http.StatusInternalServerError, "couldn't start upgrade flow") + return + } + + info.AccountDID = &did + + if err := h.Auth.OAuth.Store.SaveAuthRequestInfo(r.Context(), *info); err != nil { + slog.Error("api: oauth upgrade save", "did", did.String(), "err", err) + writeError(w, http.StatusInternalServerError, "couldn't persist auth request") + return + } + + params := make(url.Values) + params.Set("client_id", h.Auth.OAuth.Config.ClientID) + params.Set("request_uri", info.RequestURI) + redirectURL := authMeta.AuthorizationEndpoint + "?" + params.Encode() + + upgradeMu.Lock() + pendingUpgrade[info.State] = upgradeState{OldSid: sess.Data.SessionID} + upgradeMu.Unlock() + + slog.Info("api: oauth upgrade started", "did", did.String()) + writeJSON(w, http.StatusOK, map[string]string{ + "authUrl": redirectURL, + }) +} + // OAuthCallback completes the OAuth flow for mobile clients. // GET /api/v1/oauth/callback?code=...&state=... // +// When called as part of a scope upgrade (state matches pendingUpgrade), +// the old OAuth session is cleaned up and a new bearer token is issued. +// // Response: {"token": "...", "did": "...", "handle": "..."} func (h *Handlers) OAuthCallback(w http.ResponseWriter, r *http.Request) { + state := r.URL.Query().Get("state") + sessData, err := h.Auth.OAuth.ProcessCallback(r.Context(), r.URL.Query()) if err != nil { slog.Warn("api: oauth callback", "err", err) @@ -82,25 +169,41 @@ func (h *Handlers) OAuthCallback(w http.ResponseWriter, r *http.Request) { return } - // Issue a Bearer token for the mobile app. - token := h.Tokens.Issue(sessData.AccountDID.String(), sessData.SessionID) + // Check if this is a scope upgrade — clean up old session. + upgradeMu.Lock() + old, isUpgrade := pendingUpgrade[state] + if isUpgrade { + delete(pendingUpgrade, state) + } + upgradeMu.Unlock() - // Best-effort: record login in users table. - go h.Auth.RecordUserLogin(sessData) - - // Best-effort: drain pending connections. - if h.ConnQueue != nil { - sess, err := h.Auth.OAuth.ResumeSession(r.Context(), sessData.AccountDID, sessData.SessionID) - if err == nil { - res, err := connection.Drain(r.Context(), h.ConnQueue, sess, sess.Data.HostURL, slog.Default(), nil) - if err != nil { - slog.Warn("api: connect drain", "did", sessData.AccountDID.String(), "err", err) - } else if res.Written > 0 || res.Skipped > 0 { - slog.Info("api: connect drain", "did", sessData.AccountDID.String(), "written", res.Written, "skipped", res.Skipped) + if isUpgrade && old.OldSid != "" { + if err := h.Auth.OAuth.Store.DeleteSession(r.Context(), sessData.AccountDID, old.OldSid); err != nil { + slog.Warn("api: oauth upgrade cleanup", "did", sessData.AccountDID.String(), "err", err) + } else { + slog.Info("api: oauth upgrade complete", "did", sessData.AccountDID.String()) + } + } else { + // Best-effort: record login in users table (skip for upgrades). + go h.Auth.RecordUserLogin(sessData) + + // Best-effort: drain pending connections. + if h.ConnQueue != nil { + sess, err := h.Auth.OAuth.ResumeSession(r.Context(), sessData.AccountDID, sessData.SessionID) + if err == nil { + res, err := connection.Drain(r.Context(), h.ConnQueue, sess, sess.Data.HostURL, slog.Default(), nil) + if err != nil { + slog.Warn("api: connect drain", "did", sessData.AccountDID.String(), "err", err) + } else if res.Written > 0 || res.Skipped > 0 { + slog.Info("api: connect drain", "did", sessData.AccountDID.String(), "written", res.Written, "skipped", res.Skipped) + } } } } + // Issue a Bearer token for the mobile app (always, regardless of upgrade). + token := h.Tokens.Issue(sessData.AccountDID.String(), sessData.SessionID) + handle := h.resolveHandle(r, sessData.AccountDID.String()) writeJSON(w, http.StatusOK, map[string]string{ diff --git a/features/api/routes.go b/features/api/routes.go index 2f27e79..f546587 100644 --- a/features/api/routes.go +++ b/features/api/routes.go @@ -24,6 +24,7 @@ func SetupRoutes(router chi.Router, conn *sql.DB, authH *auth.Handlers, connQueu // Auth r.Get("/auth/session", h.GetSession) r.Post("/oauth/login", h.OAuthLogin) + r.Post("/oauth/upgrade", h.OAuthUpgrade) r.Get("/oauth/callback", h.OAuthCallback) r.Post("/oauth/logout", h.Logout) diff --git a/features/auth/handlers.go b/features/auth/handlers.go index 12e7c1f..f781e23 100644 --- a/features/auth/handlers.go +++ b/features/auth/handlers.go @@ -8,6 +8,7 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" "io" "log/slog" "net/http" @@ -21,6 +22,7 @@ import ( "atmoquest/features/auth/pages" "atmoquest/internal/connection" + "atmoquest/internal/oauthclient" "atmoquest/internal/profile" "atmoquest/internal/session" "atmoquest/internal/users" @@ -97,8 +99,9 @@ func (h *Handlers) SigninATProto(w http.ResponseWriter, r *http.Request) { } } -// OAuthLogin starts the OAuth flow: takes a handle / DID / PDS URL, calls -// indigo's StartAuthFlow, redirects to the AS. +// OAuthLogin starts the OAuth flow with the minimal scope set. Takes a +// handle / DID / PDS URL, resolves identity, sends PAR with DefaultScopes, +// and redirects to the AS. func (h *Handlers) OAuthLogin(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "invalid form", http.StatusBadRequest) @@ -112,17 +115,13 @@ func (h *Handlers) OAuthLogin(w http.ResponseWriter, r *http.Request) { return } - redirectURL, err := h.OAuth.StartAuthFlow(r.Context(), identifier) + redirectURL, err := h.StartMinimalAuthFlow(r.Context(), identifier) if err != nil { - // Indigo already emits a slog.Warn with the full AS response body - // from parseAuthErrorReason — check the line above this one in the - // log for the AS's error_description. We log identifier + client_id - // + callback_url here so the two log entries are easy to correlate. - slog.Warn("oauth start failed", + slog.Warn("oauth login failed", "identifier", identifier, "client_id", h.OAuth.Config.ClientID, "callback_url", h.OAuth.Config.CallbackURL, - "scopes", h.OAuth.Config.Scopes, + "scopes", oauthclient.DefaultScopes, "err", err, ) w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -130,7 +129,132 @@ func (h *Handlers) OAuthLogin(w http.ResponseWriter, r *http.Request) { _ = pages.SigninATProto("couldn't start sign-in: "+sanitizeAuthError(err)).Render(r.Context(), w) return } - slog.Info("oauth start ok", "identifier", identifier, "redirect", redirectURL) + slog.Info("oauth login ok", "identifier", identifier, "redirect", redirectURL) + http.Redirect(w, r, redirectURL, http.StatusFound) +} + +// StartMinimalAuthFlow replicates indigo's StartAuthFlow logic but sends +// only the DefaultScopes (without repo:app.bsky.actor.profile). The client +// config's FullScopes is still used for client metadata so the AS knows the +// client may request the broader set on upgrade. +func (h *Handlers) StartMinimalAuthFlow(ctx context.Context, identifier string) (string, error) { + var authserverURL string + var accountDID syntax.DID + + if strings.HasPrefix(identifier, "https://") { + authserverURL = identifier + identifier = "" + } else { + atid, err := syntax.ParseAtIdentifier(identifier) + if err != nil { + return "", fmt.Errorf("not a valid account identifier (%s): %w", identifier, err) + } + ident, err := h.OAuth.Dir.Lookup(ctx, atid) + if err != nil { + return "", fmt.Errorf("failed to resolve username (%s): %w", identifier, err) + } + accountDID = ident.DID + host := ident.PDSEndpoint() + if host == "" { + return "", fmt.Errorf("identity does not link to an atproto host (PDS)") + } + authserverURL, err = h.OAuth.Resolver.ResolveAuthServerURL(ctx, host) + if err != nil { + return "", fmt.Errorf("resolving auth server: %w", err) + } + } + + authserverMeta, err := h.OAuth.Resolver.ResolveAuthServerMetadata(ctx, authserverURL) + if err != nil { + return "", fmt.Errorf("fetching auth server metadata: %w", err) + } + + info, err := h.OAuth.SendAuthRequest(ctx, authserverMeta, oauthclient.DefaultScopes, identifier) + if err != nil { + return "", fmt.Errorf("auth request failed: %w", err) + } + + if accountDID != "" { + info.AccountDID = &accountDID + } + + if err := h.OAuth.Store.SaveAuthRequestInfo(ctx, *info); err != nil { + return "", fmt.Errorf("persist auth request: %w", err) + } + + params := url.Values{} + params.Set("client_id", h.OAuth.Config.ClientID) + params.Set("request_uri", info.RequestURI) + return authserverMeta.AuthorizationEndpoint + "?" + params.Encode(), nil +} + +// OAuthUpgrade starts an OAuth flow with the full scope set so the user +// can grant repo:app.bsky.actor.profile access to edit display name/avatar. +// The existing session remains valid until the callback completes. +func (h *Handlers) OAuthUpgrade(w http.ResponseWriter, r *http.Request) { + did, sess, err := h.ResumeSession(r) + if err != nil { + http.Redirect(w, r, "/signin", http.StatusFound) + return + } + + if oauthclient.HasBskyProfileScope(sess) { + http.Redirect(w, r, "/profile/edit", http.StatusFound) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "upgrade_pending", + Value: sess.Data.SessionID, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: r.TLS != nil, + MaxAge: 600, + }) + + pdsURL := sess.Data.HostURL + if pdsURL == "" { + slog.Error("oauth upgrade: no host URL", "did", did.String()) + http.Error(w, "no PDS host URL available", http.StatusInternalServerError) + return + } + + authserverURL, err := h.OAuth.Resolver.ResolveAuthServerURL(r.Context(), pdsURL) + if err != nil { + slog.Error("oauth upgrade: resolve auth server URL", "did", did.String(), "pds", pdsURL, "err", err) + http.Error(w, "couldn't resolve authorization server", http.StatusInternalServerError) + return + } + + authMeta, err := h.OAuth.Resolver.ResolveAuthServerMetadata(r.Context(), authserverURL) + if err != nil { + slog.Error("oauth upgrade: resolve auth server metadata", "did", did.String(), "err", err) + http.Error(w, "couldn't resolve authorization server metadata", http.StatusInternalServerError) + return + } + + info, err := h.OAuth.SendAuthRequest(r.Context(), authMeta, oauthclient.FullScopes, did.String()) + if err != nil { + slog.Error("oauth upgrade: send auth request", "did", did.String(), "err", err) + http.Error(w, "couldn't start upgrade flow", http.StatusInternalServerError) + return + } + + info.AccountDID = &did + + if err := h.OAuth.Store.SaveAuthRequestInfo(r.Context(), *info); err != nil { + slog.Error("oauth upgrade: save auth request info", "did", did.String(), "err", err) + http.Error(w, "couldn't persist auth request", http.StatusInternalServerError) + return + } + + params := url.Values{} + params.Set("client_id", h.OAuth.Config.ClientID) + params.Set("request_uri", info.RequestURI) + redirectURL := authMeta.AuthorizationEndpoint + "?" + params.Encode() + + slog.Info("oauth upgrade started", "did", did.String()) http.Redirect(w, r, redirectURL, http.StatusFound) } @@ -157,6 +281,24 @@ func (h *Handlers) OAuthCallback(w http.ResponseWriter, r *http.Request) { return } + // Check for upgrade flow — clean up old session and redirect to profile + // editor so the user can immediately use the newly-granted scope. + if upgradeCookie, err := r.Cookie("upgrade_pending"); err == nil && upgradeCookie.Value != "" { + oldSid := upgradeCookie.Value + http.SetCookie(w, &http.Cookie{ + Name: "upgrade_pending", + Value: "", + Path: "/", + MaxAge: -1, + }) + slog.Info("oauth upgrade complete", "did", sessData.AccountDID.String()) + if err := h.OAuth.Store.DeleteSession(r.Context(), sessData.AccountDID, oldSid); err != nil { + slog.Warn("oauth upgrade: delete old session", "did", sessData.AccountDID.String(), "old_sid", oldSid, "err", err) + } + http.Redirect(w, r, "/profile/edit", http.StatusFound) + return + } + // Best-effort: touch the users table so the admin UI knows about this DID. // Enrichment (handle resolution, Bluesky profile fetch) lands when the // profile + admin features are ported. diff --git a/features/auth/pages/signin_templ.go b/features/auth/pages/signin_templ.go index 49bdf33..ad76401 100644 --- a/features/auth/pages/signin_templ.go +++ b/features/auth/pages/signin_templ.go @@ -5,12 +5,13 @@ package pages //lint:file-ignore SA4006 This context is only used if a nested component is present. +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + import ( - "atmoquest/features/common/layouts" "net/url" - "github.com/a-h/templ" - templruntime "github.com/a-h/templ/runtime" + "atmoquest/features/common/layouts" ) // Signin renders the chooser: existing Atmosphere account vs create a new one. @@ -61,26 +62,26 @@ func Signin(next string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "atmo.quest:~$ auth --pick
step 1 of 1 · pick a path

sign in

atmo.quest is built on ATProto. Your records live in your repo, not ours. You'll sign in with the same identity you use anywhere on the open social web.

option a
sign in with your Atmosphere account
already have a handle like you.bsky.social or any ATProto PDS? Go this way.
▸ continue ↵
atmo.quest:~$ auth --pick

sign in

atmo.quest is built on ATProto. Your records live in your repo, not ours. You'll sign in with the same identity you use anywhere on the open social web.

option a
Sign in with your Atmosphere (Bluesky) account
already have a handle like you.bsky.social or any ATProto PDS? Go this way.
▸ continue ↵
option b
Continue without an Atmosphere account
use atmo.quest locally — link an Atmosphere account later in Settings to claim your data.
▸ continue
atmo.quest
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" class=\"auth-card\">
option b
Continue without an Atmosphere (Bluesky) account
use atmo.quest locally — link an Atmosphere account later in Settings to claim your data.
▸ continue
atmo.quest
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = layouts.Base("sign in", "Sign in with your ATProto account.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = layouts.Base("sign in", "Sign in with your Atmosphere (Bluesky) account.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/features/auth/routes.go b/features/auth/routes.go index 9e69279..49feeb9 100644 --- a/features/auth/routes.go +++ b/features/auth/routes.go @@ -13,6 +13,7 @@ import ( // - GET /signin — chooser page // - GET /signin/atproto — handle entry form // - POST /oauth/login — start auth flow (PAR → AS redirect) +// - POST /oauth/upgrade — re-auth with full scopes (display name/avatar) // - GET /oauth/callback — finish auth flow, set cookie, redirect // - POST /oauth/logout — revoke + clear cookie + redirect home // - GET /oauth/client-metadata.json — public client metadata doc @@ -25,6 +26,7 @@ func SetupRoutes(router chi.Router, h *Handlers) { router.Get("/signin", h.Signin) router.Get("/signin/atproto", h.SigninATProto) router.Post("/oauth/login", h.OAuthLogin) + router.Post("/oauth/upgrade", h.OAuthUpgrade) router.Get("/oauth/callback", h.OAuthCallback) router.Post("/oauth/logout", h.OAuthLogout) router.Get("/oauth/client-metadata.json", h.OAuthClientMetadata) diff --git a/features/index/pages/index_templ.go b/features/index/pages/index_templ.go index 9b46893..3ec2198 100644 --- a/features/index/pages/index_templ.go +++ b/features/index/pages/index_templ.go @@ -5,11 +5,11 @@ package pages //lint:file-ignore SA4006 This context is only used if a nested component is present. +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + import ( "atmoquest/features/common/layouts" - - "github.com/a-h/templ" - templruntime "github.com/a-h/templ/runtime" ) // IndexView passes session-aware values into the landing page. When a user diff --git a/features/profile/pages/profile_edit.templ b/features/profile/pages/profile_edit.templ index 9b9d003..b024037 100644 --- a/features/profile/pages/profile_edit.templ +++ b/features/profile/pages/profile_edit.templ @@ -88,19 +88,12 @@ templ ProfileEdit(v ProfileEditView) {
- if v.MissingScope { - - } -
if !v.IsLocal {

These fields are shared with your bluesky profile.

} +
if v.AvatarURL != "" { { @@ -109,9 +102,7 @@ templ ProfileEdit(v ProfileEditView) { }
if v.AvatarDisabled { - if v.MissingScope { -

sign out and sign back in to change your avatar

- } else { + if !v.MissingScope {

Link your ATmosphere account to upload a profile picture

} } else { @@ -135,7 +126,7 @@ templ ProfileEdit(v ProfileEditView) {
- +
- if v.DisplayNameDisabled { -
sign out and sign back in to edit your display name
- }
if v.IsLocal { @@ -179,6 +167,12 @@ templ ProfileEdit(v ProfileEditView) { } + if v.MissingScope && !v.IsLocal { + + }
if v.Error != "" { diff --git a/features/profile/pages/profile_edit_templ.go b/features/profile/pages/profile_edit_templ.go index 2a9f9de..8c6d7bd 100644 --- a/features/profile/pages/profile_edit_templ.go +++ b/features/profile/pages/profile_edit_templ.go @@ -136,397 +136,378 @@ func ProfileEdit(v ProfileEditView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "atmo.quest:~$ profile --edit
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if v.MissingScope { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
info: To edit your display name and avatar, you need to re-authenticate with updated permissions. sign out and sign back in
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "atmo.quest:~$ profile --edit
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !v.IsLocal { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "

These fields are shared with your bluesky profile.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

These fields are shared with your bluesky profile.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if v.AvatarURL != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\"")") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" loading=\"lazy\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
?
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
?
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if v.AvatarDisabled { - if v.MissingScope { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "

sign out and sign back in to change your avatar

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "

Link your ATmosphere account to upload a profile picture

") + if !v.MissingScope { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

Link your ATmosphere account to upload a profile picture

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if v.AvatarURL != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" maxlength=\"64\"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if v.DisplayNameDisabled { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
sign out and sign back in to edit your display name
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " disabled") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " placeholder=\"your name\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if v.IsLocal { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" maxlength=\"320\" placeholder=\"you@example.com\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if v.IsLocal { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "> follow up after my event if I have not created an ATmosphere account ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if v.MissingScope && !v.IsLocal { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
Grant atmo.quest permission to change these values:
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if v.Error != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
error: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
error: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(v.Error) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 186, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 180, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if v.BlueskyBio != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "leave blank to fall back to your bluesky bio:
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "leave blank to fall back to your bluesky bio:
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(v.BlueskyBio) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 205, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 199, Col: 42} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "tell people what you're about. up to 256 characters.") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "tell people what you're about. up to 256 characters.") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
where you spend your weekdays. up to 120 characters.
where you spend your weekdays. up to 120 characters.
city, region, or vibe. up to 120 characters.
city, region, or vibe. up to 120 characters.
how people should reach out after a connect. up to 200 characters.
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" maxlength=\"200\" placeholder=\"DM on bsky · email me · signal: …\">
how people should reach out after a connect. up to 200 characters.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, t := range v.Interests { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(t) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 271, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 265, Col: 42} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" data-tag-remove>×") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, s := range suggestedInterests { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "
pick from suggestions or add your own. up to 30, 64 characters each.
status
status
shown as pills above your bio. toggle off to hide.
links (up to 5) ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "> ● looking for work
shown as pills above your bio. toggle off to hide.
links (up to 5) ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -536,7 +517,7 @@ func ProfileEdit(v ProfileEditView) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "
pinned links shown on your profile. label + URL. http(s) only.
cancel
atmo.quest
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "
pinned links shown on your profile. label + URL. http(s) only.
cancel
atmo.quest
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -576,98 +557,98 @@ func profileLinkRow(idx int, l ProfileLink) templ.Component { templ_7745c5c3_Var20 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "