diff --git a/appview/config/config.go b/appview/config/config.go --- a/appview/config/config.go +++ b/appview/config/config.go @@ -4,6 +4,7 @@ "context" "fmt" "net/url" + "strings" "time" "github.com/sethvargo/go-envconfig" @@ -86,6 +87,10 @@ type PdsConfig struct { Host string `env:"HOST, default=https://tngl.sh"` AdminSecret string `env:"ADMIN_SECRET"` +} + +func (p *PdsConfig) IsTnglShUser(pdsHost string) bool { + return strings.TrimRight(pdsHost, "/") == strings.TrimRight(p.Host, "/") } type R2Config struct { diff --git a/appview/oauth/handler.go b/appview/oauth/handler.go --- a/appview/oauth/handler.go +++ b/appview/oauth/handler.go @@ -15,6 +15,7 @@ comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/auth/oauth" lexutil "github.com/bluesky-social/indigo/lex/util" + xrpc "github.com/bluesky-social/indigo/xrpc" "github.com/go-chi/chi/v5" "github.com/posthog/posthog-go" "tangled.org/core/api/tangled" @@ -40,6 +41,7 @@ doc.JWKSURI = &o.JwksUri doc.ClientName = &o.ClientName doc.ClientURI = &o.ClientUri + doc.Scope = doc.Scope + " identity:handle" w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(doc); err != nil { @@ -109,7 +111,33 @@ redirectURL = authReturn.ReturnURL } + if o.isAccountDeactivated(sessData) { + redirectURL = "/settings/profile" + } + http.Redirect(w, r, redirectURL, http.StatusFound) +} + +func (o *OAuth) isAccountDeactivated(sessData *oauth.ClientSessionData) bool { + pdsClient := &xrpc.Client{ + Host: sessData.HostURL, + Client: &http.Client{Timeout: 5 * time.Second}, + } + + _, err := comatproto.RepoDescribeRepo( + context.Background(), + pdsClient, + sessData.AccountDID.String(), + ) + if err == nil { + return false + } + + var xrpcErr *xrpc.Error + var xrpcBody *xrpc.XRPCError + return errors.As(err, &xrpcErr) && + errors.As(xrpcErr.Wrapped, &xrpcBody) && + xrpcBody.ErrStr == "RepoDeactivated" } func (o *OAuth) addToDefaultSpindle(did string) { diff --git a/appview/oauth/oauth.go b/appview/oauth/oauth.go --- a/appview/oauth/oauth.go +++ b/appview/oauth/oauth.go @@ -1,10 +1,12 @@ package oauth import ( + "context" "errors" "fmt" "log/slog" "net/http" + "net/url" "sync" "time" @@ -362,4 +364,60 @@ Timeout: opts.timeout, }, }, nil +} + +func (o *OAuth) StartElevatedAuthFlow(ctx context.Context, w http.ResponseWriter, r *http.Request, did string, extraScopes []string, returnURL string) (string, error) { + parsedDid, err := syntax.ParseDID(did) + if err != nil { + return "", fmt.Errorf("invalid DID: %w", err) + } + + ident, err := o.ClientApp.Dir.Lookup(ctx, parsedDid.AtIdentifier()) + if err != nil { + return "", fmt.Errorf("failed to resolve DID (%s): %w", did, err) + } + + host := ident.PDSEndpoint() + if host == "" { + return "", fmt.Errorf("identity does not link to an atproto host (PDS)") + } + + authserverURL, err := o.ClientApp.Resolver.ResolveAuthServerURL(ctx, host) + if err != nil { + return "", fmt.Errorf("resolving auth server: %w", err) + } + + authserverMeta, err := o.ClientApp.Resolver.ResolveAuthServerMetadata(ctx, authserverURL) + if err != nil { + return "", fmt.Errorf("fetching auth server metadata: %w", err) + } + + scopes := make([]string, 0, len(TangledScopes)+len(extraScopes)) + scopes = append(scopes, TangledScopes...) + scopes = append(scopes, extraScopes...) + + loginHint := did + if ident.Handle != "" && !ident.Handle.IsInvalidHandle() { + loginHint = ident.Handle.String() + } + + info, err := o.ClientApp.SendAuthRequest(ctx, authserverMeta, scopes, loginHint) + if err != nil { + return "", fmt.Errorf("auth request failed: %w", err) + } + + info.AccountDID = &parsedDid + o.ClientApp.Store.SaveAuthRequestInfo(ctx, *info) + + if err := o.SetAuthReturn(w, r, returnURL, false); err != nil { + return "", fmt.Errorf("failed to set auth return: %w", err) + } + + redirectURL := fmt.Sprintf("%s?client_id=%s&request_uri=%s", + authserverMeta.AuthorizationEndpoint, + url.QueryEscape(o.ClientApp.Config.ClientID), + url.QueryEscape(info.RequestURI), + ) + + return redirectURL, nil } diff --git a/appview/pages/htmx.go b/appview/pages/htmx.go --- a/appview/pages/htmx.go +++ b/appview/pages/htmx.go @@ -2,17 +2,27 @@ import ( "fmt" + "html" "net/http" ) // Notice performs a hx-oob-swap to replace the content of an element with a message. // Pass the id of the element and the message to display. func (s *Pages) Notice(w http.ResponseWriter, id, msg string) { - html := fmt.Sprintf(`%s`, id, msg) + escaped := html.EscapeString(msg) + markup := fmt.Sprintf(`%s`, id, escaped) w.Header().Set("Content-Type", "text/html") w.WriteHeader(http.StatusOK) - w.Write([]byte(html)) + w.Write([]byte(markup)) +} + +func (s *Pages) NoticeHTML(w http.ResponseWriter, id string, trustedHTML string) { + markup := fmt.Sprintf(`%s`, id, trustedHTML) + + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + w.Write([]byte(markup)) } // HxRefresh is a client-side full refresh of the page. diff --git a/appview/pages/pages.go b/appview/pages/pages.go --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -364,6 +364,10 @@ LoggedInUser *oauth.MultiAccountUser Tab string PunchcardPreference models.PunchcardPreference + IsTnglSh bool + IsDeactivated bool + PdsDomain string + HandleOpen bool } func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error { @@ -1571,6 +1575,18 @@ } return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash +} + +func (p *Pages) DangerPasswordTokenStep(w io.Writer) error { + return p.executePlain("user/settings/fragments/dangerPasswordToken", w, nil) +} + +func (p *Pages) DangerPasswordSuccess(w io.Writer) error { + return p.executePlain("user/settings/fragments/dangerPasswordSuccess", w, nil) +} + +func (p *Pages) DangerDeleteTokenStep(w io.Writer) error { + return p.executePlain("user/settings/fragments/dangerDeleteToken", w, nil) } func (p *Pages) Error500(w io.Writer) error { diff --git a/appview/settings/danger.go b/appview/settings/danger.go new file mode 100644 --- /dev/null +++ b/appview/settings/danger.go @@ -0,0 +1,294 @@ +package settings + +import ( + "context" + "errors" + "net/http" + "strings" + "time" + + comatproto "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/xrpc" +) + +type pdsSession struct { + Client *xrpc.Client + Did string + Email string + AccessJwt string +} + +func (s *Settings) pdsClient() *xrpc.Client { + return &xrpc.Client{ + Host: s.Config.Pds.Host, + Client: &http.Client{Timeout: 15 * time.Second}, + } +} + +func (s *Settings) verifyPdsPassword(did, password string) (*pdsSession, error) { + client := s.pdsClient() + resp, err := comatproto.ServerCreateSession(context.Background(), client, &comatproto.ServerCreateSession_Input{ + Identifier: did, + Password: password, + }) + if err != nil { + return nil, err + } + + client.Auth = &xrpc.AuthInfo{AccessJwt: resp.AccessJwt} + + var email string + if resp.Email != nil { + email = *resp.Email + } + + return &pdsSession{ + Client: client, + Did: resp.Did, + Email: email, + AccessJwt: resp.AccessJwt, + }, nil +} + +func (s *Settings) revokePdsSession(session *pdsSession) { + if err := comatproto.ServerDeleteSession(context.Background(), session.Client); err != nil { + s.Logger.Warn("failed to revoke session", "err", err) + } +} + +func (s *Settings) requestPasswordReset(w http.ResponseWriter, r *http.Request) { + user := s.OAuth.GetMultiAccountUser(r) + if !s.Config.Pds.IsTnglShUser(user.Pds()) { + s.Pages.Notice(w, "password-error", "Only available for tngl.sh accounts.") + return + } + + did := s.OAuth.GetDid(r) + password := r.FormValue("current_password") + if password == "" { + s.Pages.Notice(w, "password-error", "Password is required.") + return + } + + session, err := s.verifyPdsPassword(did, password) + if err != nil { + s.Pages.Notice(w, "password-error", "Current password is incorrect.") + return + } + + if session.Email == "" { + s.revokePdsSession(session) + s.Logger.Error("requesting password reset: no email on account", "did", did) + s.Pages.Notice(w, "password-error", "No email associated with your account.") + return + } + + s.revokePdsSession(session) + + err = comatproto.ServerRequestPasswordReset(context.Background(), s.pdsClient(), &comatproto.ServerRequestPasswordReset_Input{ + Email: session.Email, + }) + if err != nil { + s.Logger.Error("requesting password reset", "err", err) + s.Pages.Notice(w, "password-error", "Failed to request password reset. Try again later.") + return + } + + s.Pages.DangerPasswordTokenStep(w) +} + +func (s *Settings) resetPassword(w http.ResponseWriter, r *http.Request) { + user := s.OAuth.GetMultiAccountUser(r) + if !s.Config.Pds.IsTnglShUser(user.Pds()) { + s.Pages.Notice(w, "password-error", "Only available for tngl.sh accounts.") + return + } + + token := strings.TrimSpace(r.FormValue("token")) + newPassword := r.FormValue("new_password") + confirmPassword := r.FormValue("confirm_password") + + if token == "" || newPassword == "" || confirmPassword == "" { + s.Pages.Notice(w, "password-error", "All fields are required.") + return + } + + if newPassword != confirmPassword { + s.Pages.Notice(w, "password-error", "Passwords do not match.") + return + } + + err := comatproto.ServerResetPassword(context.Background(), s.pdsClient(), &comatproto.ServerResetPassword_Input{ + Token: token, + Password: newPassword, + }) + if err != nil { + s.Logger.Error("resetting password", "err", err) + s.Pages.Notice(w, "password-error", "Failed to reset password. The token may have expired.") + return + } + + s.Pages.DangerPasswordSuccess(w) +} + +func (s *Settings) deactivateAccount(w http.ResponseWriter, r *http.Request) { + user := s.OAuth.GetMultiAccountUser(r) + if !s.Config.Pds.IsTnglShUser(user.Pds()) { + s.Pages.Notice(w, "deactivate-error", "Only available for tngl.sh accounts.") + return + } + + did := s.OAuth.GetDid(r) + password := r.FormValue("password") + + if password == "" { + s.Pages.Notice(w, "deactivate-error", "Password is required.") + return + } + + session, err := s.verifyPdsPassword(did, password) + if err != nil { + s.Pages.Notice(w, "deactivate-error", "Password is incorrect.") + return + } + + err = comatproto.ServerDeactivateAccount(context.Background(), session.Client, &comatproto.ServerDeactivateAccount_Input{}) + s.revokePdsSession(session) + if err != nil { + s.Logger.Error("deactivating account", "err", err) + s.Pages.Notice(w, "deactivate-error", "Failed to deactivate account. Try again later.") + return + } + + if err := s.OAuth.DeleteSession(w, r); err != nil { + s.Logger.Error("clearing session after deactivation", "did", did, "err", err) + } + if err := s.OAuth.RemoveAccount(w, r, did); err != nil { + s.Logger.Error("removing account after deactivation", "did", did, "err", err) + } + s.Pages.HxRedirect(w, "/") +} + +func (s *Settings) requestAccountDelete(w http.ResponseWriter, r *http.Request) { + user := s.OAuth.GetMultiAccountUser(r) + if !s.Config.Pds.IsTnglShUser(user.Pds()) { + s.Pages.Notice(w, "delete-error", "Only available for tngl.sh accounts.") + return + } + + did := s.OAuth.GetDid(r) + password := r.FormValue("password") + + if password == "" { + s.Pages.Notice(w, "delete-error", "Password is required.") + return + } + + session, err := s.verifyPdsPassword(did, password) + if err != nil { + s.Pages.Notice(w, "delete-error", "Password is incorrect.") + return + } + + err = comatproto.ServerRequestAccountDelete(context.Background(), session.Client) + s.revokePdsSession(session) + if err != nil { + s.Logger.Error("requesting account deletion", "err", err) + s.Pages.Notice(w, "delete-error", "Failed to request account deletion. Try again later.") + return + } + + s.Pages.DangerDeleteTokenStep(w) +} + +func (s *Settings) deleteAccount(w http.ResponseWriter, r *http.Request) { + user := s.OAuth.GetMultiAccountUser(r) + if !s.Config.Pds.IsTnglShUser(user.Pds()) { + s.Pages.Notice(w, "delete-error", "Only available for tngl.sh accounts.") + return + } + + did := s.OAuth.GetDid(r) + password := r.FormValue("password") + token := strings.TrimSpace(r.FormValue("token")) + confirmation := r.FormValue("confirmation") + + if password == "" || token == "" { + s.Pages.Notice(w, "delete-error", "All fields are required.") + return + } + + if confirmation != "delete my account" { + s.Pages.Notice(w, "delete-error", "You must type \"delete my account\" to confirm.") + return + } + + err := comatproto.ServerDeleteAccount(context.Background(), s.pdsClient(), &comatproto.ServerDeleteAccount_Input{ + Did: did, + Password: password, + Token: token, + }) + if err != nil { + s.Logger.Error("deleting account", "err", err) + s.Pages.Notice(w, "delete-error", "Failed to delete account. Try again later.") + return + } + + if err := s.OAuth.DeleteSession(w, r); err != nil { + s.Logger.Error("clearing session after account deletion", "did", did, "err", err) + } + if err := s.OAuth.RemoveAccount(w, r, did); err != nil { + s.Logger.Error("removing account after deletion", "did", did, "err", err) + } + s.Pages.HxRedirect(w, "/") +} + +func (s *Settings) isAccountDeactivated(ctx context.Context, did, pdsHost string) bool { + client := &xrpc.Client{ + Host: pdsHost, + Client: &http.Client{Timeout: 5 * time.Second}, + } + + _, err := comatproto.RepoDescribeRepo(ctx, client, did) + if err == nil { + return false + } + + var xrpcErr *xrpc.Error + var xrpcBody *xrpc.XRPCError + return errors.As(err, &xrpcErr) && + errors.As(xrpcErr.Wrapped, &xrpcBody) && + xrpcBody.ErrStr == "RepoDeactivated" +} + +func (s *Settings) reactivateAccount(w http.ResponseWriter, r *http.Request) { + user := s.OAuth.GetMultiAccountUser(r) + if !s.Config.Pds.IsTnglShUser(user.Pds()) { + s.Pages.Notice(w, "reactivate-error", "Only available for tngl.sh accounts.") + return + } + + did := s.OAuth.GetDid(r) + password := r.FormValue("password") + + if password == "" { + s.Pages.Notice(w, "reactivate-error", "Password is required.") + return + } + + session, err := s.verifyPdsPassword(did, password) + if err != nil { + s.Pages.Notice(w, "reactivate-error", "Password is incorrect.") + return + } + + err = comatproto.ServerActivateAccount(context.Background(), session.Client) + s.revokePdsSession(session) + if err != nil { + s.Logger.Error("reactivating account", "err", err) + s.Pages.Notice(w, "reactivate-error", "Failed to reactivate account. Try again later.") + return + } + + s.Pages.HxRefresh(w) +} diff --git a/appview/settings/settings.go b/appview/settings/settings.go --- a/appview/settings/settings.go +++ b/appview/settings/settings.go @@ -5,10 +5,12 @@ "database/sql" "errors" "fmt" + "html" "log" "log/slog" "net/http" "net/url" + "slices" "strings" "time" @@ -26,6 +28,7 @@ "tangled.org/core/tid" comatproto "github.com/bluesky-social/indigo/api/atproto" + atpclient "github.com/bluesky-social/indigo/atproto/client" "github.com/bluesky-social/indigo/atproto/syntax" lexutil "github.com/bluesky-social/indigo/lex/util" "github.com/gliderlabs/ssh" @@ -75,6 +78,16 @@ r.Put("/", s.claimSitesDomain) r.Delete("/", s.releaseSitesDomain) }) + + r.Post("/password/request", s.requestPasswordReset) + r.Post("/password/reset", s.resetPassword) + r.Post("/deactivate", s.deactivateAccount) + r.Post("/reactivate", s.reactivateAccount) + r.Post("/delete/request", s.requestAccountDelete) + r.Post("/delete/confirm", s.deleteAccount) + + r.Get("/handle", s.elevateForHandle) + r.Post("/handle", s.updateHandle) return r } @@ -243,9 +256,15 @@ log.Printf("failed to get users punchcard preferences: %s", err) } + isDeactivated := s.Config.Pds.IsTnglShUser(user.Pds()) && s.isAccountDeactivated(r.Context(), user.Did(), user.Pds()) + s.Pages.UserProfileSettings(w, pages.UserProfileSettingsParams{ LoggedInUser: user, PunchcardPreference: punchcardPreferences, + IsTnglSh: s.Config.Pds.IsTnglShUser(user.Pds()), + IsDeactivated: isDeactivated, + PdsDomain: s.pdsDomain(), + HandleOpen: r.URL.Query().Get("handle") == "1", }) } @@ -605,7 +624,7 @@ _, _, _, _, err = ssh.ParseAuthorizedKey([]byte(key)) if err != nil { s.Logger.Error("parsing public key", "err", err) - s.Pages.Notice(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a public key.") + s.Pages.NoticeHTML(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a public key.") return } @@ -689,8 +708,8 @@ // invalid record if err != nil { - s.Logger.Error("failed to delete record from PDS", "err", err) - s.Pages.Notice(w, "settings-keys", "Failed to remove key from PDS.") + s.Logger.Error("failed to delete record", "err", err) + s.Pages.Notice(w, "settings-keys", "Failed to remove key.") return } } @@ -699,4 +718,111 @@ s.Pages.HxLocation(w, "/settings/keys") return } +} + +func (s *Settings) pdsDomain() string { + parsed, err := url.Parse(s.Config.Pds.Host) + if err != nil { + return s.Config.Pds.Host + } + return parsed.Hostname() +} + +func (s *Settings) elevateForHandle(w http.ResponseWriter, r *http.Request) { + user := s.OAuth.GetMultiAccountUser(r) + if !s.Config.Pds.IsTnglShUser(user.Pds()) { + http.Redirect(w, r, "/settings/profile", http.StatusSeeOther) + return + } + + sess, err := s.OAuth.ResumeSession(r) + if err == nil && slices.Contains(sess.Data.Scopes, "identity:handle") { + http.Redirect(w, r, "/settings/profile?handle=1", http.StatusSeeOther) + return + } + + redirectURL, err := s.OAuth.StartElevatedAuthFlow( + r.Context(), w, r, + user.Did(), + []string{"identity:handle"}, + "/settings/profile?handle=1", + ) + if err != nil { + log.Printf("failed to start elevated auth flow: %s", err) + http.Redirect(w, r, "/settings/profile", http.StatusSeeOther) + return + } + + http.Redirect(w, r, redirectURL, http.StatusFound) +} + +func (s *Settings) updateHandle(w http.ResponseWriter, r *http.Request) { + user := s.OAuth.GetMultiAccountUser(r) + if !s.Config.Pds.IsTnglShUser(user.Pds()) { + s.Pages.Notice(w, "handle-error", "Handle changes are only available for tngl.sh accounts.") + return + } + + handleType := r.FormValue("type") + handleInput := strings.TrimSpace(r.FormValue("handle")) + + if handleInput == "" { + s.Pages.Notice(w, "handle-error", "Handle cannot be empty.") + return + } + + var newHandle string + switch handleType { + case "subdomain": + if !isValidSubdomain(handleInput) { + s.Pages.Notice(w, "handle-error", "Invalid handle. Use only lowercase letters, digits, and hyphens.") + return + } + newHandle = handleInput + "." + s.pdsDomain() + case "custom": + newHandle = handleInput + default: + s.Pages.Notice(w, "handle-error", "Invalid handle type.") + return + } + + client, err := s.OAuth.AuthorizedClient(r) + if err != nil { + log.Printf("failed to get authorized client: %s", err) + s.Pages.Notice(w, "handle-error", "Failed to authorize. Try logging in again.") + return + } + + err = comatproto.IdentityUpdateHandle(r.Context(), client, &comatproto.IdentityUpdateHandle_Input{ + Handle: newHandle, + }) + if err != nil { + if strings.Contains(err.Error(), "ScopeMissing") || strings.Contains(err.Error(), "insufficient_scope") { + redirectURL, elevErr := s.OAuth.StartElevatedAuthFlow( + r.Context(), w, r, + user.Did(), + []string{"identity:handle"}, + "/settings/profile?handle=1", + ) + if elevErr != nil { + log.Printf("failed to start elevated auth flow: %s", elevErr) + s.Pages.Notice(w, "handle-error", "Failed to start re-authorization. Try again later.") + return + } + + s.Pages.HxRedirect(w, redirectURL) + return + } + + log.Printf("failed to update handle: %s", err) + msg := err.Error() + var apiErr *atpclient.APIError + if errors.As(err, &apiErr) && apiErr.Message != "" { + msg = apiErr.Message + } + s.Pages.Notice(w, "handle-error", fmt.Sprintf("Failed to update handle: %s", msg)) + return + } + + s.Pages.NoticeHTML(w, "handle-success", fmt.Sprintf("Handle updated to %s.", html.EscapeString(newHandle))) } diff --git a/appview/state/login.go b/appview/state/login.go --- a/appview/state/login.go +++ b/appview/state/login.go @@ -1,10 +1,14 @@ package state import ( + "errors" "fmt" "net/http" "strings" + "time" + comatproto "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/xrpc" "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" ) @@ -62,6 +66,35 @@ fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social or %s.tngl.sh?", handle, handle, handle), ) return + } + + ident, err := s.idResolver.ResolveIdent(r.Context(), handle) + if err != nil { + l.Warn("handle resolution failed", "handle", handle, "err", err) + s.pages.Notice(w, "login-msg", fmt.Sprintf("Could not resolve handle \"%s\". The account may not exist.", handle)) + return + } + + pdsEndpoint := ident.PDSEndpoint() + if pdsEndpoint == "" { + s.pages.Notice(w, "login-msg", fmt.Sprintf("No PDS found for \"%s\".", handle)) + return + } + + pdsClient := &xrpc.Client{Host: pdsEndpoint, Client: &http.Client{Timeout: 5 * time.Second}} + _, err = comatproto.RepoDescribeRepo(r.Context(), pdsClient, ident.DID.String()) + if err != nil { + var xrpcErr *xrpc.Error + var xrpcBody *xrpc.XRPCError + isDeactivated := errors.As(err, &xrpcErr) && + errors.As(xrpcErr.Wrapped, &xrpcBody) && + xrpcBody.ErrStr == "RepoDeactivated" + + if !isDeactivated { + l.Warn("describeRepo failed", "handle", handle, "did", ident.DID, "pds", pdsEndpoint, "err", err) + s.pages.Notice(w, "login-msg", fmt.Sprintf("Account \"%s\" is no longer available.", handle)) + return + } } if err := s.oauth.SetAuthReturn(w, r, returnURL, addAccount); err != nil { diff --git a/appview/pages/templates/user/settings/profile.html b/appview/pages/templates/user/settings/profile.html --- a/appview/pages/templates/user/settings/profile.html +++ b/appview/pages/templates/user/settings/profile.html @@ -11,6 +11,9 @@
This will restore your profile and repositories, making them accessible again.
+ + +Your profile and repositories will become inaccessible. You can reactivate by logging in again.
+ + +This permanently deletes your account and all associated data. This cannot be undone.
+ + +Configure punchcard visibility and preferences.
-