A vibe coded tangled fork which supports pijul.

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

Signed-off-by: Lewis <lewis@tangled.org>

authored by

Lewis and committed by tangled.org 3417b984 7108735b

+874 -7
+5
appview/config/config.go
··· 4 4 "context" 5 5 "fmt" 6 6 "net/url" 7 + "strings" 7 8 "time" 8 9 9 10 "github.com/sethvargo/go-envconfig" ··· 86 87 type PdsConfig struct { 87 88 Host string `env:"HOST, default=https://tngl.sh"` 88 89 AdminSecret string `env:"ADMIN_SECRET"` 90 + } 91 + 92 + func (p *PdsConfig) IsTnglShUser(pdsHost string) bool { 93 + return strings.TrimRight(pdsHost, "/") == strings.TrimRight(p.Host, "/") 89 94 } 90 95 91 96 type R2Config struct {
+28
appview/oauth/handler.go
··· 15 15 comatproto "github.com/bluesky-social/indigo/api/atproto" 16 16 "github.com/bluesky-social/indigo/atproto/auth/oauth" 17 17 lexutil "github.com/bluesky-social/indigo/lex/util" 18 + xrpc "github.com/bluesky-social/indigo/xrpc" 18 19 "github.com/go-chi/chi/v5" 19 20 "github.com/posthog/posthog-go" 20 21 "tangled.org/core/api/tangled" ··· 40 41 doc.JWKSURI = &o.JwksUri 41 42 doc.ClientName = &o.ClientName 42 43 doc.ClientURI = &o.ClientUri 44 + doc.Scope = doc.Scope + " identity:handle" 43 45 44 46 w.Header().Set("Content-Type", "application/json") 45 47 if err := json.NewEncoder(w).Encode(doc); err != nil { ··· 109 111 redirectURL = authReturn.ReturnURL 110 112 } 111 113 114 + if o.isAccountDeactivated(sessData) { 115 + redirectURL = "/settings/profile" 116 + } 117 + 112 118 http.Redirect(w, r, redirectURL, http.StatusFound) 119 + } 120 + 121 + func (o *OAuth) isAccountDeactivated(sessData *oauth.ClientSessionData) bool { 122 + pdsClient := &xrpc.Client{ 123 + Host: sessData.HostURL, 124 + Client: &http.Client{Timeout: 5 * time.Second}, 125 + } 126 + 127 + _, err := comatproto.RepoDescribeRepo( 128 + context.Background(), 129 + pdsClient, 130 + sessData.AccountDID.String(), 131 + ) 132 + if err == nil { 133 + return false 134 + } 135 + 136 + var xrpcErr *xrpc.Error 137 + var xrpcBody *xrpc.XRPCError 138 + return errors.As(err, &xrpcErr) && 139 + errors.As(xrpcErr.Wrapped, &xrpcBody) && 140 + xrpcBody.ErrStr == "RepoDeactivated" 113 141 } 114 142 115 143 func (o *OAuth) addToDefaultSpindle(did string) {
+58
appview/oauth/oauth.go
··· 1 1 package oauth 2 2 3 3 import ( 4 + "context" 4 5 "errors" 5 6 "fmt" 6 7 "log/slog" 7 8 "net/http" 9 + "net/url" 8 10 "sync" 9 11 "time" 10 12 ··· 363 365 }, 364 366 }, nil 365 367 } 368 + 369 + func (o *OAuth) StartElevatedAuthFlow(ctx context.Context, w http.ResponseWriter, r *http.Request, did string, extraScopes []string, returnURL string) (string, error) { 370 + parsedDid, err := syntax.ParseDID(did) 371 + if err != nil { 372 + return "", fmt.Errorf("invalid DID: %w", err) 373 + } 374 + 375 + ident, err := o.ClientApp.Dir.Lookup(ctx, parsedDid.AtIdentifier()) 376 + if err != nil { 377 + return "", fmt.Errorf("failed to resolve DID (%s): %w", did, err) 378 + } 379 + 380 + host := ident.PDSEndpoint() 381 + if host == "" { 382 + return "", fmt.Errorf("identity does not link to an atproto host (PDS)") 383 + } 384 + 385 + authserverURL, err := o.ClientApp.Resolver.ResolveAuthServerURL(ctx, host) 386 + if err != nil { 387 + return "", fmt.Errorf("resolving auth server: %w", err) 388 + } 389 + 390 + authserverMeta, err := o.ClientApp.Resolver.ResolveAuthServerMetadata(ctx, authserverURL) 391 + if err != nil { 392 + return "", fmt.Errorf("fetching auth server metadata: %w", err) 393 + } 394 + 395 + scopes := make([]string, 0, len(TangledScopes)+len(extraScopes)) 396 + scopes = append(scopes, TangledScopes...) 397 + scopes = append(scopes, extraScopes...) 398 + 399 + loginHint := did 400 + if ident.Handle != "" && !ident.Handle.IsInvalidHandle() { 401 + loginHint = ident.Handle.String() 402 + } 403 + 404 + info, err := o.ClientApp.SendAuthRequest(ctx, authserverMeta, scopes, loginHint) 405 + if err != nil { 406 + return "", fmt.Errorf("auth request failed: %w", err) 407 + } 408 + 409 + info.AccountDID = &parsedDid 410 + o.ClientApp.Store.SaveAuthRequestInfo(ctx, *info) 411 + 412 + if err := o.SetAuthReturn(w, r, returnURL, false); err != nil { 413 + return "", fmt.Errorf("failed to set auth return: %w", err) 414 + } 415 + 416 + redirectURL := fmt.Sprintf("%s?client_id=%s&request_uri=%s", 417 + authserverMeta.AuthorizationEndpoint, 418 + url.QueryEscape(o.ClientApp.Config.ClientID), 419 + url.QueryEscape(info.RequestURI), 420 + ) 421 + 422 + return redirectURL, nil 423 + }
+12 -2
appview/pages/htmx.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 + "html" 5 6 "net/http" 6 7 ) 7 8 8 9 // Notice performs a hx-oob-swap to replace the content of an element with a message. 9 10 // Pass the id of the element and the message to display. 10 11 func (s *Pages) Notice(w http.ResponseWriter, id, msg string) { 11 - html := fmt.Sprintf(`<span id="%s" hx-swap-oob="innerHTML">%s</span>`, id, msg) 12 + escaped := html.EscapeString(msg) 13 + markup := fmt.Sprintf(`<span id="%s" hx-swap-oob="innerHTML">%s</span>`, id, escaped) 12 14 13 15 w.Header().Set("Content-Type", "text/html") 14 16 w.WriteHeader(http.StatusOK) 15 - w.Write([]byte(html)) 17 + w.Write([]byte(markup)) 18 + } 19 + 20 + func (s *Pages) NoticeHTML(w http.ResponseWriter, id string, trustedHTML string) { 21 + markup := fmt.Sprintf(`<span id="%s" hx-swap-oob="innerHTML">%s</span>`, id, trustedHTML) 22 + 23 + w.Header().Set("Content-Type", "text/html") 24 + w.WriteHeader(http.StatusOK) 25 + w.Write([]byte(markup)) 16 26 } 17 27 18 28 // HxRefresh is a client-side full refresh of the page.
+16
appview/pages/pages.go
··· 364 364 LoggedInUser *oauth.MultiAccountUser 365 365 Tab string 366 366 PunchcardPreference models.PunchcardPreference 367 + IsTnglSh bool 368 + IsDeactivated bool 369 + PdsDomain string 370 + HandleOpen bool 367 371 } 368 372 369 373 func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error { ··· 1571 1575 } 1572 1576 1573 1577 return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash 1578 + } 1579 + 1580 + func (p *Pages) DangerPasswordTokenStep(w io.Writer) error { 1581 + return p.executePlain("user/settings/fragments/dangerPasswordToken", w, nil) 1582 + } 1583 + 1584 + func (p *Pages) DangerPasswordSuccess(w io.Writer) error { 1585 + return p.executePlain("user/settings/fragments/dangerPasswordSuccess", w, nil) 1586 + } 1587 + 1588 + func (p *Pages) DangerDeleteTokenStep(w io.Writer) error { 1589 + return p.executePlain("user/settings/fragments/dangerDeleteToken", w, nil) 1574 1590 } 1575 1591 1576 1592 func (p *Pages) Error500(w io.Writer) error {
+26
appview/pages/templates/user/settings/fragments/dangerDeleteToken.html
··· 1 + {{ define "user/settings/fragments/dangerDeleteToken" }} 2 + <div id="delete-form-container" hx-swap-oob="innerHTML"> 3 + <label class="uppercase text-sm font-bold p-0 text-red-600 dark:text-red-400">Delete account</label> 4 + <p class="text-sm text-gray-500 dark:text-gray-400 pt-1">Check your email for an account deletion code.</p> 5 + <form hx-post="/settings/delete/confirm" hx-swap="none" hx-confirm="This will permanently delete your account. This cannot be undone. Continue?" hx-disabled-elt="find button[type='submit']" class="flex flex-col gap-3 pt-2"> 6 + <div class="flex flex-col"> 7 + <label for="delete-token">deletion code</label> 8 + <input type="text" id="delete-token" name="token" required autocomplete="off" placeholder="xxxx-xxxx" /> 9 + </div> 10 + <div class="flex flex-col"> 11 + <label for="delete-password-confirm">password</label> 12 + <input type="password" id="delete-password-confirm" name="password" required autocomplete="current-password" /> 13 + </div> 14 + <div class="flex flex-col"> 15 + <label for="delete-confirmation">confirmation</label> 16 + <input type="text" id="delete-confirmation" name="confirmation" required autocomplete="off" placeholder="delete my account" /> 17 + <span class="text-sm text-gray-500 mt-1">Type <strong>delete my account</strong> to confirm.</span> 18 + </div> 19 + <div class="flex gap-2 pt-2"> 20 + <button type="button" popovertarget="delete-modal" popovertargetaction="hide" class="btn w-1/2 flex items-center gap-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">cancel</button> 21 + <button type="submit" class="btn w-1/2 flex items-center justify-center gap-2 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">delete account</button> 22 + </div> 23 + <div id="delete-error" class="text-red-500 dark:text-red-400 text-sm empty:hidden"></div> 24 + </form> 25 + </div> 26 + {{ end }}
+6
appview/pages/templates/user/settings/fragments/dangerPasswordSuccess.html
··· 1 + {{ define "user/settings/fragments/dangerPasswordSuccess" }} 2 + <div id="password-form-container" hx-swap-oob="innerHTML"> 3 + <label class="uppercase text-sm font-bold p-0">Change password</label> 4 + <p class="text-green-500 dark:text-green-400 pt-2">Password changed.</p> 5 + </div> 6 + {{ end }}
+25
appview/pages/templates/user/settings/fragments/dangerPasswordToken.html
··· 1 + {{ define "user/settings/fragments/dangerPasswordToken" }} 2 + <div id="password-form-container" hx-swap-oob="innerHTML"> 3 + <label class="uppercase text-sm font-bold p-0">Change password</label> 4 + <p class="text-sm text-gray-500 dark:text-gray-400 pt-1">Check your email for a password reset code.</p> 5 + <form hx-post="/settings/password/reset" hx-swap="none" hx-disabled-elt="find button[type='submit']" class="flex flex-col gap-3 pt-2"> 6 + <div class="flex flex-col"> 7 + <label for="token">reset code</label> 8 + <input type="text" id="token" name="token" required autocomplete="off" placeholder="xxxx-xxxx" /> 9 + </div> 10 + <div class="flex flex-col"> 11 + <label for="new-password">new password</label> 12 + <input type="password" id="new-password" name="new_password" required autocomplete="new-password" /> 13 + </div> 14 + <div class="flex flex-col"> 15 + <label for="confirm-password">confirm new password</label> 16 + <input type="password" id="confirm-password" name="confirm_password" required autocomplete="new-password" /> 17 + </div> 18 + <div class="flex gap-2 pt-2"> 19 + <button type="button" popovertarget="change-password-modal" popovertargetaction="hide" class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">cancel</button> 20 + <button type="submit" class="btn w-1/2 flex items-center justify-center gap-2">set new password</button> 21 + </div> 22 + <div id="password-error" class="text-red-500 dark:text-red-400 text-sm empty:hidden"></div> 23 + </form> 24 + </div> 25 + {{ end }}
+242 -2
appview/pages/templates/user/settings/profile.html
··· 11 11 </div> 12 12 <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 13 {{ template "profile" . }} 14 + {{ if .IsTnglSh }} 15 + {{ template "accountActions" . }} 16 + {{ end }} 14 17 {{ template "punchcard" . }} 15 18 </div> 16 19 </section> ··· 26 29 <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 27 30 <div class="flex flex-col gap-1 p-4"> 28 31 <span class="text-sm text-gray-500 dark:text-gray-400">Handle</span> 29 - <span class="font-bold">{{ resolve .LoggedInUser.Did }}</span> 32 + <div class="flex items-center gap-2"> 33 + <span class="font-bold">{{ resolve .LoggedInUser.Did }}</span> 34 + {{ if .IsTnglSh }} 35 + {{ if .HandleOpen }} 36 + <button 37 + popovertarget="change-handle-modal" 38 + popovertargetaction="toggle" 39 + class="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 cursor-pointer">change</button> 40 + {{ else }} 41 + <a href="/settings/handle" class="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">change</a> 42 + {{ end }} 43 + {{ end }} 44 + </div> 30 45 </div> 31 46 <div class="flex flex-col gap-1 p-4"> 32 47 <span class="text-sm text-gray-500 dark:text-gray-400">Decentralized Identifier (DID)</span> ··· 37 52 <span class="font-bold">{{ .LoggedInUser.Pds }}</span> 38 53 </div> 39 54 </div> 55 + {{ if and .IsTnglSh .HandleOpen }} 56 + <div 57 + id="change-handle-modal" 58 + popover 59 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 60 + <div id="handle-subdomain" class="flex flex-col gap-3"> 61 + <label class="uppercase text-sm font-bold p-0">Change handle</label> 62 + <form hx-post="/settings/handle" hx-swap="none" class="flex flex-col gap-3"> 63 + <input type="hidden" name="type" value="subdomain"> 64 + <div class="flex items-stretch rounded border border-gray-200 dark:border-gray-600 overflow-hidden focus-within:ring-1 focus-within:ring-blue-500 dark:bg-gray-700"> 65 + <input type="text" name="handle" placeholder="username" class="flex-1 px-2 py-1.5 bg-transparent dark:text-white border-0 focus:outline-none focus:ring-0 min-w-0" required> 66 + <span class="px-2 py-1.5 bg-gray-100 dark:bg-gray-600 text-gray-500 dark:text-gray-300 select-none whitespace-nowrap border-l border-gray-200 dark:border-gray-600 content-center">.{{ .PdsDomain }}</span> 67 + </div> 68 + <div class="flex gap-2 pt-2"> 69 + <button type="button" popovertarget="change-handle-modal" popovertargetaction="hide" class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 70 + {{ i "x" "size-4" }} cancel 71 + </button> 72 + <button type="submit" class="btn w-1/2 flex items-center justify-center gap-2"> 73 + {{ i "check" "size-4" }} save 74 + </button> 75 + </div> 76 + </form> 77 + <a href="#" id="switch-to-custom" class="text-sm text-gray-400 underline hover:text-gray-600 dark:hover:text-gray-300">I have my own domain</a> 78 + </div> 79 + <div id="handle-custom" style="display: none;" class="flex flex-col gap-3"> 80 + <label class="uppercase text-sm font-bold p-0">Change handle</label> 81 + <form hx-post="/settings/handle" hx-swap="none" class="flex flex-col gap-3"> 82 + <input type="hidden" name="type" value="custom"> 83 + <input id="custom-domain-input" type="text" name="handle" placeholder="mycoolhandle.com" class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-2 py-1.5 border border-gray-200 dark:border-gray-600 rounded outline-none focus:ring-1 focus:ring-blue-500" required> 84 + <div class="bg-gray-50 dark:bg-gray-900 rounded p-3 text-gray-500 dark:text-gray-400 flex flex-col gap-2 text-xs"> 85 + <p>Set up one of the following on your domain:</p> 86 + <div> 87 + <p class="font-medium text-gray-700 dark:text-gray-300">DNS TXT record</p> 88 + <p>Add a TXT record for <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">_atproto.<span id="dns-domain">mycoolhandle.com</span></code></p> 89 + <code class="inline-block bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded mt-1.5 break-all">did={{ .LoggedInUser.Did }}</code> 90 + </div> 91 + <div> 92 + <p class="font-medium text-gray-700 dark:text-gray-300">HTTP well-known</p> 93 + <p>Serve your DID at:</p> 94 + <code class="inline-block bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded mt-1 break-all">https://<span id="wk-domain">mycoolhandle.com</span>/.well-known/atproto-did</code> 95 + <code class="inline-block bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded mt-1.5 break-all">{{ .LoggedInUser.Did }}</code> 96 + </div> 97 + </div> 98 + <div class="flex gap-2 pt-2"> 99 + <button type="button" popovertarget="change-handle-modal" popovertargetaction="hide" class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 100 + {{ i "x" "size-4" }} cancel 101 + </button> 102 + <button type="submit" class="btn w-1/2 flex items-center justify-center gap-2"> 103 + {{ i "check" "size-4" }} verify & save 104 + </button> 105 + </div> 106 + </form> 107 + <a href="#" id="switch-to-subdomain" class="text-sm text-gray-400 underline hover:text-gray-600 dark:hover:text-gray-300">use a {{ .PdsDomain }} subdomain instead</a> 108 + </div> 109 + <div id="handle-error" class="text-red-500 dark:text-red-400 text-sm empty:hidden"></div> 110 + <div id="handle-success" class="text-green-500 dark:text-green-400 text-sm empty:hidden"></div> 111 + </div> 112 + <script> 113 + document.getElementById('switch-to-custom').addEventListener('click', function(e) { 114 + e.preventDefault(); 115 + document.getElementById('handle-subdomain').style.display = 'none'; 116 + document.getElementById('handle-custom').style.display = ''; 117 + }); 118 + document.getElementById('switch-to-subdomain').addEventListener('click', function(e) { 119 + e.preventDefault(); 120 + document.getElementById('handle-custom').style.display = 'none'; 121 + document.getElementById('handle-subdomain').style.display = ''; 122 + }); 123 + document.getElementById('custom-domain-input').addEventListener('input', function(e) { 124 + var d = e.target.value.trim() || 'mycoolhandle.com'; 125 + document.getElementById('dns-domain').textContent = d; 126 + document.getElementById('wk-domain').textContent = d; 127 + }); 128 + document.getElementById('change-handle-modal').showPopover(); 129 + </script> 130 + {{ end }} 131 + </div> 132 + {{ end }} 133 + 134 + {{ define "accountActions" }} 135 + <div> 136 + <h2 class="text-sm uppercase font-bold">Account</h2> 137 + {{ if .IsDeactivated }} 138 + <div class="mt-2 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded text-sm text-amber-700 dark:text-amber-300"> 139 + Your account is deactivated. Your profile and repositories are currently inaccessible. Reactivate to restore access. 140 + </div> 141 + {{ end }} 142 + <div class="flex flex-wrap gap-2 pt-2"> 143 + <button 144 + popovertarget="change-password-modal" 145 + popovertargetaction="toggle" 146 + class="btn flex items-center gap-2 text-sm cursor-pointer"> 147 + {{ i "key" "size-4" }} 148 + change password 149 + </button> 150 + {{ if .IsDeactivated }} 151 + <button 152 + popovertarget="reactivate-modal" 153 + popovertargetaction="toggle" 154 + class="btn flex items-center gap-2 text-sm text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300 border-green-300 dark:border-green-600 cursor-pointer"> 155 + {{ i "play" "size-4" }} 156 + reactivate account 157 + </button> 158 + {{ else }} 159 + <button 160 + popovertarget="deactivate-modal" 161 + popovertargetaction="toggle" 162 + class="btn flex items-center gap-2 text-sm text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300 border-amber-300 dark:border-amber-600 cursor-pointer"> 163 + {{ i "pause" "size-4" }} 164 + deactivate account 165 + </button> 166 + {{ end }} 167 + <button 168 + popovertarget="delete-modal" 169 + popovertargetaction="toggle" 170 + class="btn flex items-center gap-2 text-sm text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 border-red-300 dark:border-red-600 cursor-pointer"> 171 + {{ i "trash-2" "size-4" }} 172 + delete account 173 + </button> 174 + </div> 175 + </div> 176 + 177 + <div 178 + id="change-password-modal" 179 + popover 180 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 181 + <div id="password-form-container" class="flex flex-col gap-3"> 182 + <label class="uppercase text-sm font-bold p-0">Change password</label> 183 + <form hx-post="/settings/password/request" hx-swap="none" hx-disabled-elt="find button[type='submit']" class="flex flex-col gap-3"> 184 + <div class="flex flex-col"> 185 + <label for="current-password">current password</label> 186 + <input type="password" id="current-password" name="current_password" required autocomplete="current-password" /> 187 + <span class="text-sm text-gray-500 mt-1">Confirm your identity to proceed.</span> 188 + </div> 189 + <div class="flex gap-2 pt-2"> 190 + <button type="button" popovertarget="change-password-modal" popovertargetaction="hide" class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 191 + {{ i "x" "size-4" }} cancel 192 + </button> 193 + <button type="submit" class="btn w-1/2 flex items-center justify-center gap-2"> 194 + {{ i "key" "size-4" }} send reset code 195 + </button> 196 + </div> 197 + </form> 198 + <div id="password-error" class="text-red-500 dark:text-red-400 text-sm empty:hidden"></div> 199 + </div> 200 + </div> 201 + 202 + {{ if .IsDeactivated }} 203 + <div 204 + id="reactivate-modal" 205 + popover 206 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-green-300 dark:border-green-600 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 207 + <div class="flex flex-col gap-3"> 208 + <label class="uppercase text-sm font-bold p-0 text-green-600 dark:text-green-400">Reactivate account</label> 209 + <p class="text-sm text-gray-500 dark:text-gray-400">This will restore your profile and repositories, making them accessible again.</p> 210 + <form hx-post="/settings/reactivate" hx-swap="none" hx-disabled-elt="find button[type='submit']" class="flex flex-col gap-3"> 211 + <div class="flex flex-col"> 212 + <label for="reactivate-password">password</label> 213 + <input type="password" id="reactivate-password" name="password" required autocomplete="current-password" /> 214 + <span class="text-sm text-gray-500 mt-1">Confirm your identity to proceed.</span> 215 + </div> 216 + <div class="flex gap-2 pt-2"> 217 + <button type="button" popovertarget="reactivate-modal" popovertargetaction="hide" class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 218 + {{ i "x" "size-4" }} cancel 219 + </button> 220 + <button type="submit" class="btn w-1/2 flex items-center justify-center gap-2 text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"> 221 + {{ i "play" "size-4" }} reactivate 222 + </button> 223 + </div> 224 + </form> 225 + <div id="reactivate-error" class="text-red-500 dark:text-red-400 text-sm empty:hidden"></div> 226 + </div> 227 + </div> 228 + {{ else }} 229 + <div 230 + id="deactivate-modal" 231 + popover 232 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-amber-300 dark:border-amber-600 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 233 + <div class="flex flex-col gap-3"> 234 + <label class="uppercase text-sm font-bold p-0 text-amber-600 dark:text-amber-400">Deactivate account</label> 235 + <p class="text-sm text-gray-500 dark:text-gray-400">Your profile and repositories will become inaccessible. You can reactivate by logging in again.</p> 236 + <form hx-post="/settings/deactivate" hx-swap="none" hx-confirm="Are you sure you want to deactivate your account?" hx-disabled-elt="find button[type='submit']" class="flex flex-col gap-3"> 237 + <div class="flex flex-col"> 238 + <label for="deactivate-password">password</label> 239 + <input type="password" id="deactivate-password" name="password" required autocomplete="current-password" /> 240 + <span class="text-sm text-gray-500 mt-1">Confirm your identity to proceed.</span> 241 + </div> 242 + <div class="flex gap-2 pt-2"> 243 + <button type="button" popovertarget="deactivate-modal" popovertargetaction="hide" class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 244 + {{ i "x" "size-4" }} cancel 245 + </button> 246 + <button type="submit" class="btn w-1/2 flex items-center justify-center gap-2 text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300"> 247 + {{ i "pause" "size-4" }} deactivate 248 + </button> 249 + </div> 250 + </form> 251 + <div id="deactivate-error" class="text-red-500 dark:text-red-400 text-sm empty:hidden"></div> 252 + </div> 253 + </div> 254 + {{ end }} 255 + 256 + <div 257 + id="delete-modal" 258 + popover 259 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-red-300 dark:border-red-600 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 260 + <div id="delete-form-container" class="flex flex-col gap-3"> 261 + <label class="uppercase text-sm font-bold p-0 text-red-600 dark:text-red-400">Delete account</label> 262 + <p class="text-sm text-gray-500 dark:text-gray-400">This permanently deletes your account and all associated data. This cannot be undone.</p> 263 + <form hx-post="/settings/delete/request" hx-swap="none" hx-disabled-elt="find button[type='submit']" class="flex flex-col gap-3"> 264 + <div class="flex flex-col"> 265 + <label for="delete-password">password</label> 266 + <input type="password" id="delete-password" name="password" required autocomplete="current-password" /> 267 + <span class="text-sm text-gray-500 mt-1">Confirm your identity to proceed.</span> 268 + </div> 269 + <div class="flex gap-2 pt-2"> 270 + <button type="button" popovertarget="delete-modal" popovertargetaction="hide" class="btn w-1/2 flex items-center gap-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"> 271 + {{ i "x" "size-4" }} cancel 272 + </button> 273 + <button type="submit" class="btn w-1/2 flex items-center justify-center gap-2 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 274 + {{ i "trash-2" "size-4" }} send deletion code 275 + </button> 276 + </div> 277 + </form> 278 + <div id="delete-error" class="text-red-500 dark:text-red-400 text-sm empty:hidden"></div> 279 + </div> 40 280 </div> 41 281 {{ end }} 42 282 ··· 46 286 <p class="text-gray-500 dark:text-gray-400 pb-2 "> 47 287 Configure punchcard visibility and preferences. 48 288 </p> 49 - <form hx-post="/profile/punchcard" hx-trigger="change" hx-swap="none" class="flex flex-col gap-2"> 289 + <form hx-post="/profile/punchcard" hx-trigger="change" hx-swap="none" class="flex flex-col gap-3"> 50 290 <div class="flex items-center gap-2"> 51 291 <input type="checkbox" id="hideMine" name="hideMine" value="on" {{ if eq true $.PunchcardPreference.HideMine }}checked{{ end }}> 52 292 <label for="hideMine" class="my-0 py-0 normal-case font-normal">Hide mine</label>
+294
appview/settings/danger.go
··· 1 + package settings 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "net/http" 7 + "strings" 8 + "time" 9 + 10 + comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/xrpc" 12 + ) 13 + 14 + type pdsSession struct { 15 + Client *xrpc.Client 16 + Did string 17 + Email string 18 + AccessJwt string 19 + } 20 + 21 + func (s *Settings) pdsClient() *xrpc.Client { 22 + return &xrpc.Client{ 23 + Host: s.Config.Pds.Host, 24 + Client: &http.Client{Timeout: 15 * time.Second}, 25 + } 26 + } 27 + 28 + func (s *Settings) verifyPdsPassword(did, password string) (*pdsSession, error) { 29 + client := s.pdsClient() 30 + resp, err := comatproto.ServerCreateSession(context.Background(), client, &comatproto.ServerCreateSession_Input{ 31 + Identifier: did, 32 + Password: password, 33 + }) 34 + if err != nil { 35 + return nil, err 36 + } 37 + 38 + client.Auth = &xrpc.AuthInfo{AccessJwt: resp.AccessJwt} 39 + 40 + var email string 41 + if resp.Email != nil { 42 + email = *resp.Email 43 + } 44 + 45 + return &pdsSession{ 46 + Client: client, 47 + Did: resp.Did, 48 + Email: email, 49 + AccessJwt: resp.AccessJwt, 50 + }, nil 51 + } 52 + 53 + func (s *Settings) revokePdsSession(session *pdsSession) { 54 + if err := comatproto.ServerDeleteSession(context.Background(), session.Client); err != nil { 55 + s.Logger.Warn("failed to revoke session", "err", err) 56 + } 57 + } 58 + 59 + func (s *Settings) requestPasswordReset(w http.ResponseWriter, r *http.Request) { 60 + user := s.OAuth.GetMultiAccountUser(r) 61 + if !s.Config.Pds.IsTnglShUser(user.Pds()) { 62 + s.Pages.Notice(w, "password-error", "Only available for tngl.sh accounts.") 63 + return 64 + } 65 + 66 + did := s.OAuth.GetDid(r) 67 + password := r.FormValue("current_password") 68 + if password == "" { 69 + s.Pages.Notice(w, "password-error", "Password is required.") 70 + return 71 + } 72 + 73 + session, err := s.verifyPdsPassword(did, password) 74 + if err != nil { 75 + s.Pages.Notice(w, "password-error", "Current password is incorrect.") 76 + return 77 + } 78 + 79 + if session.Email == "" { 80 + s.revokePdsSession(session) 81 + s.Logger.Error("requesting password reset: no email on account", "did", did) 82 + s.Pages.Notice(w, "password-error", "No email associated with your account.") 83 + return 84 + } 85 + 86 + s.revokePdsSession(session) 87 + 88 + err = comatproto.ServerRequestPasswordReset(context.Background(), s.pdsClient(), &comatproto.ServerRequestPasswordReset_Input{ 89 + Email: session.Email, 90 + }) 91 + if err != nil { 92 + s.Logger.Error("requesting password reset", "err", err) 93 + s.Pages.Notice(w, "password-error", "Failed to request password reset. Try again later.") 94 + return 95 + } 96 + 97 + s.Pages.DangerPasswordTokenStep(w) 98 + } 99 + 100 + func (s *Settings) resetPassword(w http.ResponseWriter, r *http.Request) { 101 + user := s.OAuth.GetMultiAccountUser(r) 102 + if !s.Config.Pds.IsTnglShUser(user.Pds()) { 103 + s.Pages.Notice(w, "password-error", "Only available for tngl.sh accounts.") 104 + return 105 + } 106 + 107 + token := strings.TrimSpace(r.FormValue("token")) 108 + newPassword := r.FormValue("new_password") 109 + confirmPassword := r.FormValue("confirm_password") 110 + 111 + if token == "" || newPassword == "" || confirmPassword == "" { 112 + s.Pages.Notice(w, "password-error", "All fields are required.") 113 + return 114 + } 115 + 116 + if newPassword != confirmPassword { 117 + s.Pages.Notice(w, "password-error", "Passwords do not match.") 118 + return 119 + } 120 + 121 + err := comatproto.ServerResetPassword(context.Background(), s.pdsClient(), &comatproto.ServerResetPassword_Input{ 122 + Token: token, 123 + Password: newPassword, 124 + }) 125 + if err != nil { 126 + s.Logger.Error("resetting password", "err", err) 127 + s.Pages.Notice(w, "password-error", "Failed to reset password. The token may have expired.") 128 + return 129 + } 130 + 131 + s.Pages.DangerPasswordSuccess(w) 132 + } 133 + 134 + func (s *Settings) deactivateAccount(w http.ResponseWriter, r *http.Request) { 135 + user := s.OAuth.GetMultiAccountUser(r) 136 + if !s.Config.Pds.IsTnglShUser(user.Pds()) { 137 + s.Pages.Notice(w, "deactivate-error", "Only available for tngl.sh accounts.") 138 + return 139 + } 140 + 141 + did := s.OAuth.GetDid(r) 142 + password := r.FormValue("password") 143 + 144 + if password == "" { 145 + s.Pages.Notice(w, "deactivate-error", "Password is required.") 146 + return 147 + } 148 + 149 + session, err := s.verifyPdsPassword(did, password) 150 + if err != nil { 151 + s.Pages.Notice(w, "deactivate-error", "Password is incorrect.") 152 + return 153 + } 154 + 155 + err = comatproto.ServerDeactivateAccount(context.Background(), session.Client, &comatproto.ServerDeactivateAccount_Input{}) 156 + s.revokePdsSession(session) 157 + if err != nil { 158 + s.Logger.Error("deactivating account", "err", err) 159 + s.Pages.Notice(w, "deactivate-error", "Failed to deactivate account. Try again later.") 160 + return 161 + } 162 + 163 + if err := s.OAuth.DeleteSession(w, r); err != nil { 164 + s.Logger.Error("clearing session after deactivation", "did", did, "err", err) 165 + } 166 + if err := s.OAuth.RemoveAccount(w, r, did); err != nil { 167 + s.Logger.Error("removing account after deactivation", "did", did, "err", err) 168 + } 169 + s.Pages.HxRedirect(w, "/") 170 + } 171 + 172 + func (s *Settings) requestAccountDelete(w http.ResponseWriter, r *http.Request) { 173 + user := s.OAuth.GetMultiAccountUser(r) 174 + if !s.Config.Pds.IsTnglShUser(user.Pds()) { 175 + s.Pages.Notice(w, "delete-error", "Only available for tngl.sh accounts.") 176 + return 177 + } 178 + 179 + did := s.OAuth.GetDid(r) 180 + password := r.FormValue("password") 181 + 182 + if password == "" { 183 + s.Pages.Notice(w, "delete-error", "Password is required.") 184 + return 185 + } 186 + 187 + session, err := s.verifyPdsPassword(did, password) 188 + if err != nil { 189 + s.Pages.Notice(w, "delete-error", "Password is incorrect.") 190 + return 191 + } 192 + 193 + err = comatproto.ServerRequestAccountDelete(context.Background(), session.Client) 194 + s.revokePdsSession(session) 195 + if err != nil { 196 + s.Logger.Error("requesting account deletion", "err", err) 197 + s.Pages.Notice(w, "delete-error", "Failed to request account deletion. Try again later.") 198 + return 199 + } 200 + 201 + s.Pages.DangerDeleteTokenStep(w) 202 + } 203 + 204 + func (s *Settings) deleteAccount(w http.ResponseWriter, r *http.Request) { 205 + user := s.OAuth.GetMultiAccountUser(r) 206 + if !s.Config.Pds.IsTnglShUser(user.Pds()) { 207 + s.Pages.Notice(w, "delete-error", "Only available for tngl.sh accounts.") 208 + return 209 + } 210 + 211 + did := s.OAuth.GetDid(r) 212 + password := r.FormValue("password") 213 + token := strings.TrimSpace(r.FormValue("token")) 214 + confirmation := r.FormValue("confirmation") 215 + 216 + if password == "" || token == "" { 217 + s.Pages.Notice(w, "delete-error", "All fields are required.") 218 + return 219 + } 220 + 221 + if confirmation != "delete my account" { 222 + s.Pages.Notice(w, "delete-error", "You must type \"delete my account\" to confirm.") 223 + return 224 + } 225 + 226 + err := comatproto.ServerDeleteAccount(context.Background(), s.pdsClient(), &comatproto.ServerDeleteAccount_Input{ 227 + Did: did, 228 + Password: password, 229 + Token: token, 230 + }) 231 + if err != nil { 232 + s.Logger.Error("deleting account", "err", err) 233 + s.Pages.Notice(w, "delete-error", "Failed to delete account. Try again later.") 234 + return 235 + } 236 + 237 + if err := s.OAuth.DeleteSession(w, r); err != nil { 238 + s.Logger.Error("clearing session after account deletion", "did", did, "err", err) 239 + } 240 + if err := s.OAuth.RemoveAccount(w, r, did); err != nil { 241 + s.Logger.Error("removing account after deletion", "did", did, "err", err) 242 + } 243 + s.Pages.HxRedirect(w, "/") 244 + } 245 + 246 + func (s *Settings) isAccountDeactivated(ctx context.Context, did, pdsHost string) bool { 247 + client := &xrpc.Client{ 248 + Host: pdsHost, 249 + Client: &http.Client{Timeout: 5 * time.Second}, 250 + } 251 + 252 + _, err := comatproto.RepoDescribeRepo(ctx, client, did) 253 + if err == nil { 254 + return false 255 + } 256 + 257 + var xrpcErr *xrpc.Error 258 + var xrpcBody *xrpc.XRPCError 259 + return errors.As(err, &xrpcErr) && 260 + errors.As(xrpcErr.Wrapped, &xrpcBody) && 261 + xrpcBody.ErrStr == "RepoDeactivated" 262 + } 263 + 264 + func (s *Settings) reactivateAccount(w http.ResponseWriter, r *http.Request) { 265 + user := s.OAuth.GetMultiAccountUser(r) 266 + if !s.Config.Pds.IsTnglShUser(user.Pds()) { 267 + s.Pages.Notice(w, "reactivate-error", "Only available for tngl.sh accounts.") 268 + return 269 + } 270 + 271 + did := s.OAuth.GetDid(r) 272 + password := r.FormValue("password") 273 + 274 + if password == "" { 275 + s.Pages.Notice(w, "reactivate-error", "Password is required.") 276 + return 277 + } 278 + 279 + session, err := s.verifyPdsPassword(did, password) 280 + if err != nil { 281 + s.Pages.Notice(w, "reactivate-error", "Password is incorrect.") 282 + return 283 + } 284 + 285 + err = comatproto.ServerActivateAccount(context.Background(), session.Client) 286 + s.revokePdsSession(session) 287 + if err != nil { 288 + s.Logger.Error("reactivating account", "err", err) 289 + s.Pages.Notice(w, "reactivate-error", "Failed to reactivate account. Try again later.") 290 + return 291 + } 292 + 293 + s.Pages.HxRefresh(w) 294 + }
+129 -3
appview/settings/settings.go
··· 5 5 "database/sql" 6 6 "errors" 7 7 "fmt" 8 + "html" 8 9 "log" 9 10 "log/slog" 10 11 "net/http" 11 12 "net/url" 13 + "slices" 12 14 "strings" 13 15 "time" 14 16 ··· 26 28 "tangled.org/core/tid" 27 29 28 30 comatproto "github.com/bluesky-social/indigo/api/atproto" 31 + atpclient "github.com/bluesky-social/indigo/atproto/client" 29 32 "github.com/bluesky-social/indigo/atproto/syntax" 30 33 lexutil "github.com/bluesky-social/indigo/lex/util" 31 34 "github.com/gliderlabs/ssh" ··· 75 78 r.Put("/", s.claimSitesDomain) 76 79 r.Delete("/", s.releaseSitesDomain) 77 80 }) 81 + 82 + r.Post("/password/request", s.requestPasswordReset) 83 + r.Post("/password/reset", s.resetPassword) 84 + r.Post("/deactivate", s.deactivateAccount) 85 + r.Post("/reactivate", s.reactivateAccount) 86 + r.Post("/delete/request", s.requestAccountDelete) 87 + r.Post("/delete/confirm", s.deleteAccount) 88 + 89 + r.Get("/handle", s.elevateForHandle) 90 + r.Post("/handle", s.updateHandle) 78 91 79 92 return r 80 93 } ··· 243 256 log.Printf("failed to get users punchcard preferences: %s", err) 244 257 } 245 258 259 + isDeactivated := s.Config.Pds.IsTnglShUser(user.Pds()) && s.isAccountDeactivated(r.Context(), user.Did(), user.Pds()) 260 + 246 261 s.Pages.UserProfileSettings(w, pages.UserProfileSettingsParams{ 247 262 LoggedInUser: user, 248 263 PunchcardPreference: punchcardPreferences, 264 + IsTnglSh: s.Config.Pds.IsTnglShUser(user.Pds()), 265 + IsDeactivated: isDeactivated, 266 + PdsDomain: s.pdsDomain(), 267 + HandleOpen: r.URL.Query().Get("handle") == "1", 249 268 }) 250 269 } 251 270 ··· 605 624 _, _, _, _, err = ssh.ParseAuthorizedKey([]byte(key)) 606 625 if err != nil { 607 626 s.Logger.Error("parsing public key", "err", err) 608 - s.Pages.Notice(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.") 627 + s.Pages.NoticeHTML(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.") 609 628 return 610 629 } 611 630 ··· 689 708 690 709 // invalid record 691 710 if err != nil { 692 - s.Logger.Error("failed to delete record from PDS", "err", err) 693 - s.Pages.Notice(w, "settings-keys", "Failed to remove key from PDS.") 711 + s.Logger.Error("failed to delete record", "err", err) 712 + s.Pages.Notice(w, "settings-keys", "Failed to remove key.") 694 713 return 695 714 } 696 715 } ··· 700 719 return 701 720 } 702 721 } 722 + 723 + func (s *Settings) pdsDomain() string { 724 + parsed, err := url.Parse(s.Config.Pds.Host) 725 + if err != nil { 726 + return s.Config.Pds.Host 727 + } 728 + return parsed.Hostname() 729 + } 730 + 731 + func (s *Settings) elevateForHandle(w http.ResponseWriter, r *http.Request) { 732 + user := s.OAuth.GetMultiAccountUser(r) 733 + if !s.Config.Pds.IsTnglShUser(user.Pds()) { 734 + http.Redirect(w, r, "/settings/profile", http.StatusSeeOther) 735 + return 736 + } 737 + 738 + sess, err := s.OAuth.ResumeSession(r) 739 + if err == nil && slices.Contains(sess.Data.Scopes, "identity:handle") { 740 + http.Redirect(w, r, "/settings/profile?handle=1", http.StatusSeeOther) 741 + return 742 + } 743 + 744 + redirectURL, err := s.OAuth.StartElevatedAuthFlow( 745 + r.Context(), w, r, 746 + user.Did(), 747 + []string{"identity:handle"}, 748 + "/settings/profile?handle=1", 749 + ) 750 + if err != nil { 751 + log.Printf("failed to start elevated auth flow: %s", err) 752 + http.Redirect(w, r, "/settings/profile", http.StatusSeeOther) 753 + return 754 + } 755 + 756 + http.Redirect(w, r, redirectURL, http.StatusFound) 757 + } 758 + 759 + func (s *Settings) updateHandle(w http.ResponseWriter, r *http.Request) { 760 + user := s.OAuth.GetMultiAccountUser(r) 761 + if !s.Config.Pds.IsTnglShUser(user.Pds()) { 762 + s.Pages.Notice(w, "handle-error", "Handle changes are only available for tngl.sh accounts.") 763 + return 764 + } 765 + 766 + handleType := r.FormValue("type") 767 + handleInput := strings.TrimSpace(r.FormValue("handle")) 768 + 769 + if handleInput == "" { 770 + s.Pages.Notice(w, "handle-error", "Handle cannot be empty.") 771 + return 772 + } 773 + 774 + var newHandle string 775 + switch handleType { 776 + case "subdomain": 777 + if !isValidSubdomain(handleInput) { 778 + s.Pages.Notice(w, "handle-error", "Invalid handle. Use only lowercase letters, digits, and hyphens.") 779 + return 780 + } 781 + newHandle = handleInput + "." + s.pdsDomain() 782 + case "custom": 783 + newHandle = handleInput 784 + default: 785 + s.Pages.Notice(w, "handle-error", "Invalid handle type.") 786 + return 787 + } 788 + 789 + client, err := s.OAuth.AuthorizedClient(r) 790 + if err != nil { 791 + log.Printf("failed to get authorized client: %s", err) 792 + s.Pages.Notice(w, "handle-error", "Failed to authorize. Try logging in again.") 793 + return 794 + } 795 + 796 + err = comatproto.IdentityUpdateHandle(r.Context(), client, &comatproto.IdentityUpdateHandle_Input{ 797 + Handle: newHandle, 798 + }) 799 + if err != nil { 800 + if strings.Contains(err.Error(), "ScopeMissing") || strings.Contains(err.Error(), "insufficient_scope") { 801 + redirectURL, elevErr := s.OAuth.StartElevatedAuthFlow( 802 + r.Context(), w, r, 803 + user.Did(), 804 + []string{"identity:handle"}, 805 + "/settings/profile?handle=1", 806 + ) 807 + if elevErr != nil { 808 + log.Printf("failed to start elevated auth flow: %s", elevErr) 809 + s.Pages.Notice(w, "handle-error", "Failed to start re-authorization. Try again later.") 810 + return 811 + } 812 + 813 + s.Pages.HxRedirect(w, redirectURL) 814 + return 815 + } 816 + 817 + log.Printf("failed to update handle: %s", err) 818 + msg := err.Error() 819 + var apiErr *atpclient.APIError 820 + if errors.As(err, &apiErr) && apiErr.Message != "" { 821 + msg = apiErr.Message 822 + } 823 + s.Pages.Notice(w, "handle-error", fmt.Sprintf("Failed to update handle: %s", msg)) 824 + return 825 + } 826 + 827 + s.Pages.NoticeHTML(w, "handle-success", fmt.Sprintf("Handle updated to <strong>%s</strong>.", html.EscapeString(newHandle))) 828 + }
+33
appview/state/login.go
··· 1 1 package state 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 6 "net/http" 6 7 "strings" 8 + "time" 7 9 10 + comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/xrpc" 8 12 "tangled.org/core/appview/oauth" 9 13 "tangled.org/core/appview/pages" 10 14 ) ··· 62 66 fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social or %s.tngl.sh?", handle, handle, handle), 63 67 ) 64 68 return 69 + } 70 + 71 + ident, err := s.idResolver.ResolveIdent(r.Context(), handle) 72 + if err != nil { 73 + l.Warn("handle resolution failed", "handle", handle, "err", err) 74 + s.pages.Notice(w, "login-msg", fmt.Sprintf("Could not resolve handle \"%s\". The account may not exist.", handle)) 75 + return 76 + } 77 + 78 + pdsEndpoint := ident.PDSEndpoint() 79 + if pdsEndpoint == "" { 80 + s.pages.Notice(w, "login-msg", fmt.Sprintf("No PDS found for \"%s\".", handle)) 81 + return 82 + } 83 + 84 + pdsClient := &xrpc.Client{Host: pdsEndpoint, Client: &http.Client{Timeout: 5 * time.Second}} 85 + _, err = comatproto.RepoDescribeRepo(r.Context(), pdsClient, ident.DID.String()) 86 + if err != nil { 87 + var xrpcErr *xrpc.Error 88 + var xrpcBody *xrpc.XRPCError 89 + isDeactivated := errors.As(err, &xrpcErr) && 90 + errors.As(xrpcErr.Wrapped, &xrpcBody) && 91 + xrpcBody.ErrStr == "RepoDeactivated" 92 + 93 + if !isDeactivated { 94 + l.Warn("describeRepo failed", "handle", handle, "did", ident.DID, "pds", pdsEndpoint, "err", err) 95 + s.pages.Notice(w, "login-msg", fmt.Sprintf("Account \"%s\" is no longer available.", handle)) 96 + return 97 + } 65 98 } 66 99 67 100 if err := s.oauth.SetAuthReturn(w, r, returnURL, addAccount); err != nil {