Monorepo for Tangled tangled.org

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

merged opened by oyster.cafe targeting master from appview-acc-mgmt-ui

tngl.sh users should be able to:

  • change handle
  • deactivate account
  • delete account
  • change password
Labels

None yet.

assignee

None yet.

Participants 2
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3mgu4bwsml322
+1009 -6
Diff #0
+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" ··· 88 89 AdminSecret string `env:"ADMIN_SECRET"` 89 90 } 90 91 92 + func (p *PdsConfig) IsTnglShUser(pdsHost string) bool { 93 + return strings.TrimRight(pdsHost, "/") == strings.TrimRight(p.Host, "/") 94 + } 95 + 91 96 type R2Config struct { 92 97 AccessKeyID string `env:"ACCESS_KEY_ID"` 93 98 SecretAccessKey string `env:"SECRET_ACCESS_KEY"`
+6
appview/knots/knots.go
··· 71 71 k.Pages.Knots(w, pages.KnotsParams{ 72 72 LoggedInUser: user, 73 73 Registrations: registrations, 74 + IsTnglSh: k.isTnglShUser(user.Pds()), 74 75 }) 75 76 } 76 77 ··· 133 134 Members: members, 134 135 Repos: repoMap, 135 136 IsOwner: true, 137 + IsTnglSh: k.isTnglShUser(user.Pds()), 136 138 }) 137 139 } 138 140 ··· 680 682 // ok 681 683 k.Pages.HxRefresh(w) 682 684 } 685 + 686 + func (k *Knots) isTnglShUser(pdsHost string) bool { 687 + return k.Config.Pds.IsTnglShUser(pdsHost) 688 + }
+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) 113 119 } 114 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" 141 + } 142 + 115 143 func (o *OAuth) addToDefaultSpindle(did string) { 116 144 l := o.Logger.With("subject", did) 117 145
+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 + }
+83 -1
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) 14 + 15 + w.Header().Set("Content-Type", "text/html") 16 + w.WriteHeader(http.StatusOK) 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)) 26 + } 27 + 28 + func (s *Pages) DangerPasswordTokenStep(w http.ResponseWriter) { 29 + html := `<div id="password-form-container" hx-swap-oob="innerHTML"> 30 + <label class="uppercase text-sm font-bold p-0">Change password</label> 31 + <p class="text-sm text-gray-500 dark:text-gray-400 pt-1">Check your email for a password reset code.</p> 32 + <form hx-post="/settings/password/reset" hx-swap="none" hx-disabled-elt="find button[type='submit']" class="flex flex-col gap-3 pt-2"> 33 + <div class="flex flex-col"> 34 + <label for="token">reset code</label> 35 + <input type="text" id="token" name="token" required autocomplete="off" placeholder="xxxx-xxxx" /> 36 + </div> 37 + <div class="flex flex-col"> 38 + <label for="new-password">new password</label> 39 + <input type="password" id="new-password" name="new_password" required autocomplete="new-password" /> 40 + </div> 41 + <div class="flex flex-col"> 42 + <label for="confirm-password">confirm new password</label> 43 + <input type="password" id="confirm-password" name="confirm_password" required autocomplete="new-password" /> 44 + </div> 45 + <div class="flex gap-2 pt-2"> 46 + <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> 47 + <button type="submit" class="btn w-1/2 flex items-center justify-center gap-2">set new password</button> 48 + </div> 49 + <div id="password-error" class="text-red-500 dark:text-red-400 text-sm empty:hidden"></div> 50 + </form> 51 + </div>` 52 + 53 + w.Header().Set("Content-Type", "text/html") 54 + w.WriteHeader(http.StatusOK) 55 + w.Write([]byte(html)) 56 + } 57 + 58 + func (s *Pages) DangerPasswordSuccess(w http.ResponseWriter) { 59 + html := `<div id="password-form-container" hx-swap-oob="innerHTML"> 60 + <label class="uppercase text-sm font-bold p-0">Change password</label> 61 + <p class="text-green-500 dark:text-green-400 pt-2">Password changed.</p> 62 + </div>` 63 + 64 + w.Header().Set("Content-Type", "text/html") 65 + w.WriteHeader(http.StatusOK) 66 + w.Write([]byte(html)) 67 + } 68 + 69 + func (s *Pages) DangerDeleteTokenStep(w http.ResponseWriter) { 70 + html := `<div id="delete-form-container" hx-swap-oob="innerHTML"> 71 + <label class="uppercase text-sm font-bold p-0 text-red-600 dark:text-red-400">Delete account</label> 72 + <p class="text-sm text-gray-500 dark:text-gray-400 pt-1">Check your email for an account deletion code.</p> 73 + <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"> 74 + <div class="flex flex-col"> 75 + <label for="delete-token">deletion code</label> 76 + <input type="text" id="delete-token" name="token" required autocomplete="off" placeholder="xxxx-xxxx" /> 77 + </div> 78 + <div class="flex flex-col"> 79 + <label for="delete-password-confirm">password</label> 80 + <input type="password" id="delete-password-confirm" name="password" required autocomplete="current-password" /> 81 + </div> 82 + <div class="flex flex-col"> 83 + <label for="delete-confirmation">confirmation</label> 84 + <input type="text" id="delete-confirmation" name="confirmation" required autocomplete="off" placeholder="delete my account" /> 85 + <span class="text-sm text-gray-500 mt-1">Type <strong>delete my account</strong> to confirm.</span> 86 + </div> 87 + <div class="flex gap-2 pt-2"> 88 + <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> 89 + <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> 90 + </div> 91 + <div id="delete-error" class="text-red-500 dark:text-red-400 text-sm empty:hidden"></div> 92 + </form> 93 + </div>` 12 94 13 95 w.Header().Set("Content-Type", "text/html") 14 96 w.WriteHeader(http.StatusOK)
+12
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 { ··· 402 406 type UserKeysSettingsParams struct { 403 407 LoggedInUser *oauth.MultiAccountUser 404 408 PubKeys []models.PublicKey 409 + IsTnglSh bool 405 410 Tab string 406 411 } 407 412 ··· 413 418 type UserEmailsSettingsParams struct { 414 419 LoggedInUser *oauth.MultiAccountUser 415 420 Emails []models.Email 421 + IsTnglSh bool 416 422 Tab string 417 423 } 418 424 ··· 424 430 type UserNotificationSettingsParams struct { 425 431 LoggedInUser *oauth.MultiAccountUser 426 432 Preferences *models.NotificationPreferences 433 + IsTnglSh bool 427 434 Tab string 428 435 } 429 436 ··· 437 444 Claim *models.DomainClaim 438 445 SitesDomain string 439 446 IsTnglHandle bool 447 + IsTnglSh bool 440 448 Tab string 441 449 } 442 450 ··· 457 465 type KnotsParams struct { 458 466 LoggedInUser *oauth.MultiAccountUser 459 467 Registrations []models.Registration 468 + IsTnglSh bool 460 469 Tab string 461 470 } 462 471 ··· 471 480 Members []string 472 481 Repos map[string][]models.Repo 473 482 IsOwner bool 483 + IsTnglSh bool 474 484 Tab string 475 485 } 476 486 ··· 489 499 type SpindlesParams struct { 490 500 LoggedInUser *oauth.MultiAccountUser 491 501 Spindles []models.Spindle 502 + IsTnglSh bool 492 503 Tab string 493 504 } 494 505 ··· 511 522 Spindle models.Spindle 512 523 Members []string 513 524 Repos map[string][]models.Repo 525 + IsTnglSh bool 514 526 Tab string 515 527 } 516 528
+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>
+399
appview/settings/danger.go
··· 1 + package settings 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "errors" 8 + "fmt" 9 + "io" 10 + "net/http" 11 + "strings" 12 + "time" 13 + 14 + comatproto "github.com/bluesky-social/indigo/api/atproto" 15 + "github.com/bluesky-social/indigo/xrpc" 16 + ) 17 + 18 + var pdsClient = &http.Client{Timeout: 15 * time.Second} 19 + 20 + type createSessionResponse struct { 21 + AccessJwt string `json:"accessJwt"` 22 + Did string `json:"did"` 23 + Email string `json:"email"` 24 + } 25 + 26 + func (s *Settings) verifyPdsPassword(did, password string) (*createSessionResponse, error) { 27 + body := map[string]string{ 28 + "identifier": did, 29 + "password": password, 30 + } 31 + 32 + jsonData, err := json.Marshal(body) 33 + if err != nil { 34 + return nil, err 35 + } 36 + 37 + url := fmt.Sprintf("%s/xrpc/com.atproto.server.createSession", s.Config.Pds.Host) 38 + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) 39 + if err != nil { 40 + return nil, err 41 + } 42 + req.Header.Set("Content-Type", "application/json") 43 + 44 + resp, err := pdsClient.Do(req) 45 + if err != nil { 46 + return nil, err 47 + } 48 + defer resp.Body.Close() 49 + 50 + if resp.StatusCode != http.StatusOK { 51 + respBody, _ := io.ReadAll(resp.Body) 52 + var errResp struct { 53 + Error string `json:"error"` 54 + Message string `json:"message"` 55 + } 56 + if json.Unmarshal(respBody, &errResp) == nil && errResp.Message != "" { 57 + return nil, fmt.Errorf("%s", errResp.Message) 58 + } 59 + return nil, fmt.Errorf("authentication failed (status %d)", resp.StatusCode) 60 + } 61 + 62 + var session createSessionResponse 63 + if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { 64 + return nil, fmt.Errorf("failed to decode session response: %w", err) 65 + } 66 + 67 + return &session, nil 68 + } 69 + 70 + func (s *Settings) pdsPost(endpoint string, body any, bearerToken string) (*http.Response, error) { 71 + var bodyReader io.Reader 72 + if body != nil { 73 + jsonData, err := json.Marshal(body) 74 + if err != nil { 75 + return nil, err 76 + } 77 + bodyReader = bytes.NewBuffer(jsonData) 78 + } 79 + 80 + url := fmt.Sprintf("%s/xrpc/%s", s.Config.Pds.Host, endpoint) 81 + req, err := http.NewRequest("POST", url, bodyReader) 82 + if err != nil { 83 + return nil, err 84 + } 85 + 86 + if body != nil { 87 + req.Header.Set("Content-Type", "application/json") 88 + } 89 + if bearerToken != "" { 90 + req.Header.Set("Authorization", "Bearer "+bearerToken) 91 + } 92 + 93 + return pdsClient.Do(req) 94 + } 95 + 96 + func (s *Settings) revokePdsSession(accessJwt string) { 97 + resp, err := s.pdsPost("com.atproto.server.deleteSession", nil, accessJwt) 98 + if err != nil { 99 + s.Logger.Warn("failed to revoke session", "err", err) 100 + return 101 + } 102 + resp.Body.Close() 103 + } 104 + 105 + func (s *Settings) requestPasswordReset(w http.ResponseWriter, r *http.Request) { 106 + user := s.OAuth.GetMultiAccountUser(r) 107 + if !s.isTnglShUser(user.Pds()) { 108 + s.Pages.Notice(w, "password-error", "Only available for tngl.sh accounts.") 109 + return 110 + } 111 + 112 + did := s.OAuth.GetDid(r) 113 + password := r.FormValue("current_password") 114 + if password == "" { 115 + s.Pages.Notice(w, "password-error", "Password is required.") 116 + return 117 + } 118 + 119 + session, err := s.verifyPdsPassword(did, password) 120 + if err != nil { 121 + s.Pages.Notice(w, "password-error", "Current password is incorrect.") 122 + return 123 + } 124 + 125 + if session.Email == "" { 126 + s.revokePdsSession(session.AccessJwt) 127 + s.Logger.Error("requesting password reset: no email on account", "did", did) 128 + s.Pages.Notice(w, "password-error", "No email associated with your account.") 129 + return 130 + } 131 + 132 + s.revokePdsSession(session.AccessJwt) 133 + 134 + resp, err := s.pdsPost("com.atproto.server.requestPasswordReset", map[string]string{ 135 + "email": session.Email, 136 + }, "") 137 + if err != nil { 138 + s.Logger.Error("requesting password reset", "err", err) 139 + s.Pages.Notice(w, "password-error", "Failed to request password reset. Try again later.") 140 + return 141 + } 142 + defer resp.Body.Close() 143 + 144 + if resp.StatusCode != http.StatusOK { 145 + s.Logger.Error("requesting password reset", "status", resp.StatusCode) 146 + s.Pages.Notice(w, "password-error", "Failed to request password reset. Try again later.") 147 + return 148 + } 149 + 150 + s.Pages.DangerPasswordTokenStep(w) 151 + } 152 + 153 + func (s *Settings) resetPassword(w http.ResponseWriter, r *http.Request) { 154 + user := s.OAuth.GetMultiAccountUser(r) 155 + if !s.isTnglShUser(user.Pds()) { 156 + s.Pages.Notice(w, "password-error", "Only available for tngl.sh accounts.") 157 + return 158 + } 159 + 160 + token := strings.TrimSpace(r.FormValue("token")) 161 + newPassword := r.FormValue("new_password") 162 + confirmPassword := r.FormValue("confirm_password") 163 + 164 + if token == "" || newPassword == "" || confirmPassword == "" { 165 + s.Pages.Notice(w, "password-error", "All fields are required.") 166 + return 167 + } 168 + 169 + if newPassword != confirmPassword { 170 + s.Pages.Notice(w, "password-error", "Passwords do not match.") 171 + return 172 + } 173 + 174 + resp, err := s.pdsPost("com.atproto.server.resetPassword", map[string]string{ 175 + "token": token, 176 + "password": newPassword, 177 + }, "") 178 + if err != nil { 179 + s.Logger.Error("resetting password", "err", err) 180 + s.Pages.Notice(w, "password-error", "Failed to reset password. Try again later.") 181 + return 182 + } 183 + defer resp.Body.Close() 184 + 185 + if resp.StatusCode != http.StatusOK { 186 + respBody, _ := io.ReadAll(resp.Body) 187 + var errResp struct { 188 + Message string `json:"message"` 189 + } 190 + if json.Unmarshal(respBody, &errResp) == nil && errResp.Message != "" { 191 + s.Pages.Notice(w, "password-error", errResp.Message) 192 + return 193 + } 194 + s.Logger.Error("resetting password", "status", resp.StatusCode) 195 + s.Pages.Notice(w, "password-error", "Failed to reset password. The token may have expired.") 196 + return 197 + } 198 + 199 + s.Pages.DangerPasswordSuccess(w) 200 + } 201 + 202 + func (s *Settings) deactivateAccount(w http.ResponseWriter, r *http.Request) { 203 + user := s.OAuth.GetMultiAccountUser(r) 204 + if !s.isTnglShUser(user.Pds()) { 205 + s.Pages.Notice(w, "deactivate-error", "Only available for tngl.sh accounts.") 206 + return 207 + } 208 + 209 + did := s.OAuth.GetDid(r) 210 + password := r.FormValue("password") 211 + 212 + if password == "" { 213 + s.Pages.Notice(w, "deactivate-error", "Password is required.") 214 + return 215 + } 216 + 217 + session, err := s.verifyPdsPassword(did, password) 218 + if err != nil { 219 + s.Pages.Notice(w, "deactivate-error", "Password is incorrect.") 220 + return 221 + } 222 + 223 + resp, err := s.pdsPost("com.atproto.server.deactivateAccount", map[string]any{}, session.AccessJwt) 224 + s.revokePdsSession(session.AccessJwt) 225 + if err != nil { 226 + s.Logger.Error("deactivating account", "err", err) 227 + s.Pages.Notice(w, "deactivate-error", "Failed to deactivate account. Try again later.") 228 + return 229 + } 230 + defer resp.Body.Close() 231 + 232 + if resp.StatusCode != http.StatusOK { 233 + s.Logger.Error("deactivating account", "status", resp.StatusCode) 234 + s.Pages.Notice(w, "deactivate-error", "Failed to deactivate account. Try again later.") 235 + return 236 + } 237 + 238 + if err := s.OAuth.DeleteSession(w, r); err != nil { 239 + s.Logger.Error("clearing session after deactivation", "did", did, "err", err) 240 + } 241 + if err := s.OAuth.RemoveAccount(w, r, did); err != nil { 242 + s.Logger.Error("removing account after deactivation", "did", did, "err", err) 243 + } 244 + s.Pages.HxRedirect(w, "/") 245 + } 246 + 247 + func (s *Settings) requestAccountDelete(w http.ResponseWriter, r *http.Request) { 248 + user := s.OAuth.GetMultiAccountUser(r) 249 + if !s.isTnglShUser(user.Pds()) { 250 + s.Pages.Notice(w, "delete-error", "Only available for tngl.sh accounts.") 251 + return 252 + } 253 + 254 + did := s.OAuth.GetDid(r) 255 + password := r.FormValue("password") 256 + 257 + if password == "" { 258 + s.Pages.Notice(w, "delete-error", "Password is required.") 259 + return 260 + } 261 + 262 + session, err := s.verifyPdsPassword(did, password) 263 + if err != nil { 264 + s.Pages.Notice(w, "delete-error", "Password is incorrect.") 265 + return 266 + } 267 + 268 + resp, err := s.pdsPost("com.atproto.server.requestAccountDelete", nil, session.AccessJwt) 269 + s.revokePdsSession(session.AccessJwt) 270 + if err != nil { 271 + s.Logger.Error("requesting account deletion", "err", err) 272 + s.Pages.Notice(w, "delete-error", "Failed to request account deletion. Try again later.") 273 + return 274 + } 275 + defer resp.Body.Close() 276 + 277 + if resp.StatusCode != http.StatusOK { 278 + respBody, _ := io.ReadAll(resp.Body) 279 + s.Logger.Error("requesting account deletion", "status", resp.StatusCode, "body", string(respBody)) 280 + s.Pages.Notice(w, "delete-error", "Failed to request account deletion. Try again later.") 281 + return 282 + } 283 + 284 + s.Pages.DangerDeleteTokenStep(w) 285 + } 286 + 287 + func (s *Settings) deleteAccount(w http.ResponseWriter, r *http.Request) { 288 + user := s.OAuth.GetMultiAccountUser(r) 289 + if !s.isTnglShUser(user.Pds()) { 290 + s.Pages.Notice(w, "delete-error", "Only available for tngl.sh accounts.") 291 + return 292 + } 293 + 294 + did := s.OAuth.GetDid(r) 295 + password := r.FormValue("password") 296 + token := strings.TrimSpace(r.FormValue("token")) 297 + confirmation := r.FormValue("confirmation") 298 + 299 + if password == "" || token == "" { 300 + s.Pages.Notice(w, "delete-error", "All fields are required.") 301 + return 302 + } 303 + 304 + if confirmation != "delete my account" { 305 + s.Pages.Notice(w, "delete-error", "You must type \"delete my account\" to confirm.") 306 + return 307 + } 308 + 309 + resp, err := s.pdsPost("com.atproto.server.deleteAccount", map[string]string{ 310 + "did": did, 311 + "password": password, 312 + "token": token, 313 + }, "") 314 + if err != nil { 315 + s.Logger.Error("deleting account", "err", err) 316 + s.Pages.Notice(w, "delete-error", "Failed to delete account. Try again later.") 317 + return 318 + } 319 + defer resp.Body.Close() 320 + 321 + if resp.StatusCode != http.StatusOK { 322 + respBody, _ := io.ReadAll(resp.Body) 323 + var errResp struct { 324 + Message string `json:"message"` 325 + } 326 + if json.Unmarshal(respBody, &errResp) == nil && errResp.Message != "" { 327 + s.Pages.Notice(w, "delete-error", errResp.Message) 328 + return 329 + } 330 + s.Logger.Error("deleting account", "status", resp.StatusCode) 331 + s.Pages.Notice(w, "delete-error", "Failed to delete account. No dice!") 332 + return 333 + } 334 + 335 + if err := s.OAuth.DeleteSession(w, r); err != nil { 336 + s.Logger.Error("clearing session after account deletion", "did", did, "err", err) 337 + } 338 + if err := s.OAuth.RemoveAccount(w, r, did); err != nil { 339 + s.Logger.Error("removing account after deletion", "did", did, "err", err) 340 + } 341 + s.Pages.HxRedirect(w, "/") 342 + } 343 + 344 + func (s *Settings) isAccountDeactivated(ctx context.Context, did, pdsHost string) bool { 345 + client := &xrpc.Client{ 346 + Host: pdsHost, 347 + Client: &http.Client{Timeout: 5 * time.Second}, 348 + } 349 + 350 + _, err := comatproto.RepoDescribeRepo(ctx, client, did) 351 + if err == nil { 352 + return false 353 + } 354 + 355 + var xrpcErr *xrpc.Error 356 + var xrpcBody *xrpc.XRPCError 357 + return errors.As(err, &xrpcErr) && 358 + errors.As(xrpcErr.Wrapped, &xrpcBody) && 359 + xrpcBody.ErrStr == "RepoDeactivated" 360 + } 361 + 362 + func (s *Settings) reactivateAccount(w http.ResponseWriter, r *http.Request) { 363 + user := s.OAuth.GetMultiAccountUser(r) 364 + if !s.isTnglShUser(user.Pds()) { 365 + s.Pages.Notice(w, "reactivate-error", "Only available for tngl.sh accounts.") 366 + return 367 + } 368 + 369 + did := s.OAuth.GetDid(r) 370 + password := r.FormValue("password") 371 + 372 + if password == "" { 373 + s.Pages.Notice(w, "reactivate-error", "Password is required.") 374 + return 375 + } 376 + 377 + session, err := s.verifyPdsPassword(did, password) 378 + if err != nil { 379 + s.Pages.Notice(w, "reactivate-error", "Password is incorrect.") 380 + return 381 + } 382 + 383 + resp, err := s.pdsPost("com.atproto.server.activateAccount", nil, session.AccessJwt) 384 + s.revokePdsSession(session.AccessJwt) 385 + if err != nil { 386 + s.Logger.Error("reactivating account", "err", err) 387 + s.Pages.Notice(w, "reactivate-error", "Failed to reactivate account. Try again later.") 388 + return 389 + } 390 + defer resp.Body.Close() 391 + 392 + if resp.StatusCode != http.StatusOK { 393 + s.Logger.Error("reactivating account", "status", resp.StatusCode) 394 + s.Pages.Notice(w, "reactivate-error", "Failed to reactivate account. Try again later.") 395 + return 396 + } 397 + 398 + s.Pages.HxRefresh(w) 399 + }
+137 -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" ··· 76 79 r.Delete("/", s.releaseSitesDomain) 77 80 }) 78 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) 91 + 79 92 return r 80 93 } 81 94 ··· 106 119 Claim: claim, 107 120 SitesDomain: s.Config.Sites.Domain, 108 121 IsTnglHandle: isTnglHandle, 122 + IsTnglSh: s.isTnglShUser(user.Pds()), 109 123 }) 110 124 } 111 125 ··· 238 252 log.Printf("failed to get users punchcard preferences: %s", err) 239 253 } 240 254 255 + isDeactivated := s.isTnglShUser(user.Pds()) && s.isAccountDeactivated(r.Context(), user.Did(), user.Pds()) 256 + 241 257 s.Pages.UserProfileSettings(w, pages.UserProfileSettingsParams{ 242 258 LoggedInUser: user, 243 259 PunchcardPreference: punchcardPreferences, 260 + IsTnglSh: s.isTnglShUser(user.Pds()), 261 + IsDeactivated: isDeactivated, 262 + PdsDomain: s.pdsDomain(), 263 + HandleOpen: r.URL.Query().Get("handle") == "1", 244 264 }) 245 265 } 246 266 ··· 258 278 s.Pages.UserNotificationSettings(w, pages.UserNotificationSettingsParams{ 259 279 LoggedInUser: user, 260 280 Preferences: prefs, 281 + IsTnglSh: s.isTnglShUser(user.Pds()), 261 282 }) 262 283 } 263 284 ··· 298 319 s.Pages.UserKeysSettings(w, pages.UserKeysSettingsParams{ 299 320 LoggedInUser: user, 300 321 PubKeys: pubKeys, 322 + IsTnglSh: s.isTnglShUser(user.Pds()), 301 323 }) 302 324 } 303 325 ··· 311 333 s.Pages.UserEmailsSettings(w, pages.UserEmailsSettingsParams{ 312 334 LoggedInUser: user, 313 335 Emails: emails, 336 + IsTnglSh: s.isTnglShUser(user.Pds()), 314 337 }) 315 338 } 316 339 ··· 600 623 _, _, _, _, err = ssh.ParseAuthorizedKey([]byte(key)) 601 624 if err != nil { 602 625 s.Logger.Error("parsing public key", "err", err) 603 - s.Pages.Notice(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.") 626 + s.Pages.NoticeHTML(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.") 604 627 return 605 628 } 606 629 ··· 684 707 685 708 // invalid record 686 709 if err != nil { 687 - s.Logger.Error("failed to delete record from PDS", "err", err) 688 - s.Pages.Notice(w, "settings-keys", "Failed to remove key from PDS.") 710 + s.Logger.Error("failed to delete record", "err", err) 711 + s.Pages.Notice(w, "settings-keys", "Failed to remove key.") 689 712 return 690 713 } 691 714 } ··· 695 718 return 696 719 } 697 720 } 721 + 722 + func (s *Settings) isTnglShUser(pdsHost string) bool { 723 + return s.Config.Pds.IsTnglShUser(pdsHost) 724 + } 725 + 726 + func (s *Settings) pdsDomain() string { 727 + parsed, err := url.Parse(s.Config.Pds.Host) 728 + if err != nil { 729 + return s.Config.Pds.Host 730 + } 731 + return parsed.Hostname() 732 + } 733 + 734 + func (s *Settings) elevateForHandle(w http.ResponseWriter, r *http.Request) { 735 + user := s.OAuth.GetMultiAccountUser(r) 736 + if !s.isTnglShUser(user.Pds()) { 737 + http.Redirect(w, r, "/settings/profile", http.StatusSeeOther) 738 + return 739 + } 740 + 741 + sess, err := s.OAuth.ResumeSession(r) 742 + if err == nil && slices.Contains(sess.Data.Scopes, "identity:handle") { 743 + http.Redirect(w, r, "/settings/profile?handle=1", http.StatusSeeOther) 744 + return 745 + } 746 + 747 + redirectURL, err := s.OAuth.StartElevatedAuthFlow( 748 + r.Context(), w, r, 749 + user.Did(), 750 + []string{"identity:handle"}, 751 + "/settings/profile?handle=1", 752 + ) 753 + if err != nil { 754 + log.Printf("failed to start elevated auth flow: %s", err) 755 + http.Redirect(w, r, "/settings/profile", http.StatusSeeOther) 756 + return 757 + } 758 + 759 + http.Redirect(w, r, redirectURL, http.StatusFound) 760 + } 761 + 762 + func (s *Settings) updateHandle(w http.ResponseWriter, r *http.Request) { 763 + user := s.OAuth.GetMultiAccountUser(r) 764 + if !s.isTnglShUser(user.Pds()) { 765 + s.Pages.Notice(w, "handle-error", "Handle changes are only available for tngl.sh accounts.") 766 + return 767 + } 768 + 769 + handleType := r.FormValue("type") 770 + handleInput := strings.TrimSpace(r.FormValue("handle")) 771 + 772 + if handleInput == "" { 773 + s.Pages.Notice(w, "handle-error", "Handle cannot be empty.") 774 + return 775 + } 776 + 777 + var newHandle string 778 + switch handleType { 779 + case "subdomain": 780 + if !isValidSubdomain(handleInput) { 781 + s.Pages.Notice(w, "handle-error", "Invalid handle. Use only lowercase letters, digits, and hyphens.") 782 + return 783 + } 784 + newHandle = handleInput + "." + s.pdsDomain() 785 + case "custom": 786 + newHandle = handleInput 787 + default: 788 + s.Pages.Notice(w, "handle-error", "Invalid handle type.") 789 + return 790 + } 791 + 792 + client, err := s.OAuth.AuthorizedClient(r) 793 + if err != nil { 794 + log.Printf("failed to get authorized client: %s", err) 795 + s.Pages.Notice(w, "handle-error", "Failed to authorize. Try logging in again.") 796 + return 797 + } 798 + 799 + err = comatproto.IdentityUpdateHandle(r.Context(), client, &comatproto.IdentityUpdateHandle_Input{ 800 + Handle: newHandle, 801 + }) 802 + if err != nil { 803 + if strings.Contains(err.Error(), "ScopeMissing") || strings.Contains(err.Error(), "insufficient_scope") { 804 + redirectURL, elevErr := s.OAuth.StartElevatedAuthFlow( 805 + r.Context(), w, r, 806 + user.Did(), 807 + []string{"identity:handle"}, 808 + "/settings/profile?handle=1", 809 + ) 810 + if elevErr != nil { 811 + log.Printf("failed to start elevated auth flow: %s", elevErr) 812 + s.Pages.Notice(w, "handle-error", "Failed to start re-authorization. Try again later.") 813 + return 814 + } 815 + 816 + s.Pages.HxRedirect(w, redirectURL) 817 + return 818 + } 819 + 820 + log.Printf("failed to update handle: %s", err) 821 + msg := err.Error() 822 + var apiErr *atpclient.APIError 823 + if errors.As(err, &apiErr) && apiErr.Message != "" { 824 + msg = apiErr.Message 825 + } 826 + s.Pages.Notice(w, "handle-error", fmt.Sprintf("Failed to update handle: %s", msg)) 827 + return 828 + } 829 + 830 + s.Pages.NoticeHTML(w, "handle-success", fmt.Sprintf("Handle updated to <strong>%s</strong>.", html.EscapeString(newHandle))) 831 + }
+6
appview/spindles/spindles.go
··· 70 70 s.Pages.Spindles(w, pages.SpindlesParams{ 71 71 LoggedInUser: user, 72 72 Spindles: all, 73 + IsTnglSh: s.isTnglShUser(user.Pds()), 73 74 Tab: "spindles", 74 75 }) 75 76 } ··· 129 130 Spindle: spindle, 130 131 Members: members, 131 132 Repos: repoMap, 133 + IsTnglSh: s.isTnglShUser(user.Pds()), 132 134 Tab: "spindles", 133 135 }) 134 136 } ··· 717 719 // ok 718 720 s.Pages.HxRefresh(w) 719 721 } 722 + 723 + func (s *Spindles) isTnglShUser(pdsHost string) bool { 724 + return s.Config.Pds.IsTnglShUser(pdsHost) 725 + }
+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 ) ··· 64 68 return 65 69 } 66 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 + } 98 + } 99 + 67 100 if err := s.oauth.SetAuthReturn(w, r, returnURL, addAccount); err != nil { 68 101 l.Error("failed to set auth return", "err", err) 69 102 }

History

7 rounds 4 comments
sign up or login to add to the discussion
1 commit
expand
appview/settings: add account management UI for tngl.sh users
3/3 success
expand
expand 0 comments
pull request successfully merged
1 commit
expand
appview/settings: add account management UI for tngl.sh users
3/3 success
expand
expand 0 comments
1 commit
expand
appview/settings: add account management UI for tngl.sh users
3/3 success
expand
expand 1 comment
  • this can go in pages.go
1 commit
expand
appview/settings: add account management UI for tngl.sh users
3/3 success
expand
expand 0 comments
1 commit
expand
appview/settings: add account management UI for tngl.sh users
2/3 failed, 1/3 success
expand
expand 3 comments
  • here could you explain what an elevated auth flow is
  • here why not a template here? does this need to be in htmx.go?
  • here do we need this IsTnglSh bool?
  • here are there any indigo bits to achieve this?
  • here we have defined isTnglShUser once here, and another time here

do we need both?

1 commit
expand
appview/settings: add account management UI for tngl.sh users
3/3 failed
expand
expand 0 comments
oyster.cafe submitted #0
1 commit
expand
appview/settings: add account management UI for tngl.sh users
3/3 success
expand
expand 0 comments