Compare commits
11 Commits
65a2dc90d0
...
d1d2d612a5
| Author | SHA1 | Date | |
|---|---|---|---|
| d1d2d612a5 | |||
| 4c939be6d6 | |||
| f7bf7e9c79 | |||
| 8353cd148b | |||
| d7202eb9bb | |||
| ce5057a309 | |||
| efbe6a6760 | |||
| e2533bd1a8 | |||
| ab8cdea214 | |||
| e8c340a00e | |||
| c5faaf5a17 |
10
backend/.idea/.gitignore
generated
vendored
10
backend/.idea/.gitignore
generated
vendored
@@ -1,10 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
9
backend/.idea/backend.iml
generated
9
backend/.idea/backend.iml
generated
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="Go" enabled="true" />
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
10
backend/.idea/go.imports.xml
generated
10
backend/.idea/go.imports.xml
generated
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GoImports">
|
||||
<option name="excludedPackages">
|
||||
<array>
|
||||
<option value="golang.org/x/net/context" />
|
||||
</array>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
8
backend/.idea/modules.xml
generated
8
backend/.idea/modules.xml
generated
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/backend.iml" filepath="$PROJECT_DIR$/.idea/backend.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
backend/.idea/vcs.xml
generated
6
backend/.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
@@ -764,6 +765,85 @@ func TestProfile_RejectsInvalidHRZones(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteProfile_DeletesUserAndTerminatesGarminClient(t *testing.T) {
|
||||
s, db, userID := newTestServer(t)
|
||||
router := s.Router()
|
||||
|
||||
// Force the per-user Garmin client to be built and cached.
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/auth/login", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
client, err := s.garminFor(context.Background(), userID)
|
||||
if err != nil {
|
||||
t.Fatalf("garminFor: %v", err)
|
||||
}
|
||||
mockClient, ok := client.(*mock.Client)
|
||||
if !ok {
|
||||
t.Fatalf("expected *mock.Client, got %T", client)
|
||||
}
|
||||
|
||||
rec = doJSON(t, router, http.MethodDelete, "/api/profile", nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
if _, found, err := db.GetUserBySub(context.Background(), "test-user"); err != nil || found {
|
||||
t.Fatalf("expected user gone after delete, found=%v err=%v", found, err)
|
||||
}
|
||||
if !mockClient.ClosedCalled {
|
||||
t.Error("expected the cached garmin client to be Close()d on profile deletion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteProfile_RejectsWhileSyncInProgress(t *testing.T) {
|
||||
s, db, userID := newTestServer(t)
|
||||
s.mu.Lock()
|
||||
s.userSyncRunning[userID] = true
|
||||
s.mu.Unlock()
|
||||
|
||||
rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, found, err := db.GetUserBySub(context.Background(), "test-user"); err != nil || !found {
|
||||
t.Fatalf("expected user to survive a rejected delete, found=%v err=%v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteProfile_RemovesTokenStoreDirectory(t *testing.T) {
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
userID, err := db.ProvisionUser(context.Background(), "test-user", "Test User")
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionUser: %v", err)
|
||||
}
|
||||
|
||||
tokenStoreRoot := t.TempDir()
|
||||
userTokenDir := filepath.Join(tokenStoreRoot, strconv.FormatInt(userID, 10))
|
||||
if err := os.MkdirAll(userTokenDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(userTokenDir, "session.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
m := &mock.Client{}
|
||||
garminFactory := func(garmin.Config) garmin.Client { return m }
|
||||
s := NewServer(db, garminFactory, garmin.Config{TokenStorePath: tokenStoreRoot}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
|
||||
rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(userTokenDir); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected token store dir %q to be removed, stat err = %v", userTokenDir, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestServerWithAuth(t *testing.T, verifier auth.Verifier) (*Server, *store.DB) {
|
||||
t.Helper()
|
||||
s, db, _ := newTestServer(t)
|
||||
@@ -933,3 +1013,72 @@ func TestSessionLogout_PostLogoutRedirectUsesFrontendURL(t *testing.T) {
|
||||
t.Fatalf("status = %d, Location = %q, want post_logout_redirect_uri = %q", rec.Code, rec.Header().Get("Location"), testSessionConfig.FrontendURL+"/")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionLogout_PassesIDTokenHintFromSessionCookie confirms the raw ID
|
||||
// token carried in the session cookie (minted at callback time) is handed
|
||||
// back to EndSessionURL on logout, so Keycloak can skip its own
|
||||
// logout-confirmation prompt instead of leaving the user a chance to cancel
|
||||
// out of it after their geniusrun account is already deleted.
|
||||
func TestSessionLogout_PassesIDTokenHintFromSessionCookie(t *testing.T) {
|
||||
verifier := &authmock.Verifier{EndSessionResult: "https://keycloak.example.com/logout"}
|
||||
s, _ := newTestServerWithAuth(t, verifier)
|
||||
|
||||
cookie, err := auth.MintSessionCookie(
|
||||
auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com", IDToken: "raw-id-token-jwt"},
|
||||
testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("mint session cookie: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/session/logout", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
s.Router().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("status = %d, want 302, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if verifier.LastIDTokenHint != "raw-id-token-jwt" {
|
||||
t.Errorf("LastIDTokenHint = %q, want %q", verifier.LastIDTokenHint, "raw-id-token-jwt")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionCallback_MintsSessionCookieCarryingIDToken confirms the raw ID
|
||||
// token from a completed OIDC callback ends up in the session cookie (not
|
||||
// just Sub/Name/Email), since that's the only place logout can later read
|
||||
// it back from to build id_token_hint.
|
||||
func TestSessionCallback_MintsSessionCookieCarryingIDToken(t *testing.T) {
|
||||
verifier := &authmock.Verifier{
|
||||
CallbackResult: auth.LoginResult{
|
||||
Claims: auth.Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com", IDToken: "raw-id-token-jwt"},
|
||||
Authorized: true,
|
||||
},
|
||||
}
|
||||
s, _ := newTestServerWithAuth(t, verifier)
|
||||
|
||||
txnCookie, err := auth.MintTxnCookie(auth.TxnState{State: "s1", CodeVerifier: "v1"}, testSessionConfig.Secret, false)
|
||||
if err != nil {
|
||||
t.Fatalf("mint txn cookie: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/session/callback?code=abc&state=s1", nil)
|
||||
req.AddCookie(txnCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
s.Router().ServeHTTP(rec, req)
|
||||
|
||||
var sessionCookie *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == auth.SessionCookieName {
|
||||
sessionCookie = c
|
||||
}
|
||||
}
|
||||
if sessionCookie == nil {
|
||||
t.Fatal("expected a session cookie to be set")
|
||||
}
|
||||
claims, err := auth.ParseSessionCookie(sessionCookie, testSessionConfig.Secret)
|
||||
if err != nil {
|
||||
t.Fatalf("parse session cookie: %v", err)
|
||||
}
|
||||
if claims.IDToken != "raw-id-token-jwt" {
|
||||
t.Errorf("claims.IDToken = %q, want %q", claims.IDToken, "raw-id-token-jwt")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,3 +153,31 @@ func TestIsolation_RequireProvisionedUser_BlocksDataRoutesUntilSetup(t *testing.
|
||||
t.Fatalf("status = %d, want 403 (no profile provisioned yet), body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsolation_DeleteProfileOnlyDeletesOwnAccount confirms one user
|
||||
// deleting their own profile never touches another user's account, even
|
||||
// though DeleteUser is keyed purely by the session-resolved userID.
|
||||
func TestIsolation_DeleteProfileOnlyDeletesOwnAccount(t *testing.T) {
|
||||
s, db, userA := newTestServer(t) // provisions "test-user" (userA)
|
||||
if _, err := db.ProvisionUser(newCtx(), "user-b", "B"); err != nil {
|
||||
t.Fatalf("ProvisionUser(b): %v", err)
|
||||
}
|
||||
|
||||
router := s.Router()
|
||||
rec := doJSONAs(t, router, "user-b", http.MethodDelete, "/api/profile", nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("userB delete status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
if _, found, err := db.GetUserBySub(newCtx(), "user-b"); err != nil || found {
|
||||
t.Fatalf("expected userB gone after their own delete, found=%v err=%v", found, err)
|
||||
}
|
||||
u, found, err := db.GetUserBySub(newCtx(), "test-user")
|
||||
if err != nil || !found || u.ID != userA {
|
||||
t.Fatalf("expected userA to survive userB's deletion, found=%v err=%v id=%d want=%d", found, err, u.ID, userA)
|
||||
}
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/profile", nil) // as userA
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("userA profile status after userB deleted themselves = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,3 +75,29 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// handleDeleteProfile permanently deletes the signed-in user's entire
|
||||
// geniusrun account (profile, workout kinds/paces, activities and
|
||||
// everything under them, sync state/runs -- see schema.sql's ON DELETE
|
||||
// CASCADE from users(id)) and tears down their cached Garmin client and
|
||||
// token-store directory. It does not touch the session cookie itself --
|
||||
// the frontend follows a successful call with a real logout navigation
|
||||
// (see docs/superpowers/specs/2026-07-26-profile-deletion-design.md).
|
||||
func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
|
||||
s.mu.Lock()
|
||||
inProgress := s.userSyncRunning[userID]
|
||||
s.mu.Unlock()
|
||||
if inProgress {
|
||||
writeError(w, http.StatusConflict, "a sync is in progress for this account; wait for it to finish before deleting your profile")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.DB.DeleteUser(r.Context(), userID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.removeGarminClient(userID)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
@@ -116,6 +117,36 @@ func (s *Server) syncFor(ctx context.Context, userID int64) (*appsync.Service, e
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// removeGarminClient drops userID's cached garmin.Client/sync.Service (if
|
||||
// any) and every other per-user in-memory entry for userID, terminating the
|
||||
// client's subprocess and best-effort removing its on-disk token-store
|
||||
// directory. Called when a user's account has just been deleted from the
|
||||
// DB, so nothing in memory keeps referencing a userID that no longer
|
||||
// exists.
|
||||
func (s *Server) removeGarminClient(userID int64) {
|
||||
s.mu.Lock()
|
||||
client, ok := s.userGarmin[userID]
|
||||
delete(s.userGarmin, userID)
|
||||
delete(s.userSync, userID)
|
||||
delete(s.userAuthStatus, userID)
|
||||
delete(s.userAuthMessage, userID)
|
||||
delete(s.userSyncRunning, userID)
|
||||
s.mu.Unlock()
|
||||
|
||||
if ok {
|
||||
if err := client.Close(); err != nil {
|
||||
log.Printf("api: close garmin client for deleted user %d: %v", userID, err)
|
||||
}
|
||||
}
|
||||
if s.GarminBase.TokenStorePath == "" {
|
||||
return
|
||||
}
|
||||
tokenStoreDir := filepath.Join(s.GarminBase.TokenStorePath, strconv.FormatInt(userID, 10))
|
||||
if err := os.RemoveAll(tokenStoreDir); err != nil {
|
||||
log.Printf("api: remove token store dir for deleted user %d: %v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// RunIncrementalSyncForAllUsers is called on a timer (see main.go) to sync
|
||||
// every provisioned user in turn, replacing the old single-global-Service
|
||||
// background loop.
|
||||
@@ -167,6 +198,7 @@ func (s *Server) Router() http.Handler {
|
||||
r.Route("/profile", func(r chi.Router) {
|
||||
r.Get("/", s.handleGetProfile)
|
||||
r.Put("/", s.handleUpdateProfile)
|
||||
r.Delete("/", s.handleDeleteProfile)
|
||||
})
|
||||
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
|
||||
@@ -89,9 +89,16 @@ func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, s.Session.FrontendURL+"/", http.StatusFound)
|
||||
}
|
||||
|
||||
// handleSessionLogout clears geniusrun's own session cookie and redirects
|
||||
// through Keycloak's end-session endpoint, passing the session's ID token
|
||||
// as id_token_hint (see auth.Claims.IDToken) so Keycloak can skip its own
|
||||
// logout-confirmation prompt -- otherwise a user could cancel out of it and
|
||||
// land back on the app with a Keycloak SSO session but no geniusrun profile
|
||||
// (already deleted, in the profile-deletion case this exists for).
|
||||
func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) {
|
||||
claims, _ := auth.ClaimsFromContext(r.Context())
|
||||
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.Session.Secure))
|
||||
http.Redirect(w, r, s.Auth.EndSessionURL(s.Session.FrontendURL+"/"), http.StatusFound)
|
||||
http.Redirect(w, r, s.Auth.EndSessionURL(s.Session.FrontendURL+"/", claims.IDToken), http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -18,6 +18,7 @@ type Verifier struct {
|
||||
CallbackResult auth.LoginResult
|
||||
CallbackErr error
|
||||
EndSessionResult string // if empty, EndSessionURL returns postLogoutRedirectURL unchanged
|
||||
LastIDTokenHint string // records the idTokenHint passed to the last EndSessionURL call
|
||||
}
|
||||
|
||||
var _ auth.Verifier = (*Verifier)(nil)
|
||||
@@ -33,7 +34,8 @@ func (v *Verifier) HandleCallback(ctx context.Context, txn auth.TxnState, query
|
||||
return v.CallbackResult, nil
|
||||
}
|
||||
|
||||
func (v *Verifier) EndSessionURL(postLogoutRedirectURL string) string {
|
||||
func (v *Verifier) EndSessionURL(postLogoutRedirectURL, idTokenHint string) string {
|
||||
v.LastIDTokenHint = idTokenHint
|
||||
if v.EndSessionResult != "" {
|
||||
return v.EndSessionResult
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"slices"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
@@ -24,7 +25,11 @@ type Verifier interface {
|
||||
HandleCallback(ctx context.Context, txn TxnState, query url.Values) (LoginResult, error)
|
||||
// EndSessionURL builds the identity provider's logout URL, redirecting
|
||||
// back to postLogoutRedirectURL once Keycloak's own session is cleared.
|
||||
EndSessionURL(postLogoutRedirectURL string) string
|
||||
// idTokenHint, if non-empty, is passed as id_token_hint so Keycloak can
|
||||
// positively identify the session being ended and skip its own
|
||||
// logout-confirmation prompt (which would otherwise let the user cancel
|
||||
// out of logout after their geniusrun account is already deleted).
|
||||
EndSessionURL(postLogoutRedirectURL, idTokenHint string) string
|
||||
}
|
||||
|
||||
// LoginResult is what a completed callback exchange resolves to.
|
||||
@@ -55,12 +60,7 @@ type idTokenClaims struct {
|
||||
}
|
||||
|
||||
func (c idTokenClaims) hasRole(required string) bool {
|
||||
for _, r := range c.RealmAccess.Roles {
|
||||
if r == required {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return slices.Contains(c.RealmAccess.Roles, required)
|
||||
}
|
||||
|
||||
type oidcVerifier struct {
|
||||
@@ -135,12 +135,12 @@ func (v *oidcVerifier) HandleCallback(ctx context.Context, txn TxnState, query u
|
||||
return LoginResult{}, fmt.Errorf("decode id_token claims: %w", err)
|
||||
}
|
||||
return LoginResult{
|
||||
Claims: Claims{Sub: claims.Sub, Name: claims.Name, Email: claims.Email},
|
||||
Claims: Claims{Sub: claims.Sub, Name: claims.Name, Email: claims.Email, IDToken: rawIDToken},
|
||||
Authorized: claims.hasRole(v.requiredRole),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (v *oidcVerifier) EndSessionURL(postLogoutRedirectURL string) string {
|
||||
func (v *oidcVerifier) EndSessionURL(postLogoutRedirectURL, idTokenHint string) string {
|
||||
var discovery struct {
|
||||
EndSessionEndpoint string `json:"end_session_endpoint"`
|
||||
}
|
||||
@@ -154,6 +154,9 @@ func (v *oidcVerifier) EndSessionURL(postLogoutRedirectURL string) string {
|
||||
q := u.Query()
|
||||
q.Set("client_id", v.oauth2Config.ClientID)
|
||||
q.Set("post_logout_redirect_uri", postLogoutRedirectURL)
|
||||
if idTokenHint != "" {
|
||||
q.Set("id_token_hint", idTokenHint)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
@@ -1,10 +1,83 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newTestOIDCVerifier spins up a fake OIDC discovery endpoint (just enough
|
||||
// for oidc.NewProvider's discovery GET to succeed) and returns a real
|
||||
// oidcVerifier backed by it, for tests that exercise EndSessionURL without
|
||||
// a live Keycloak.
|
||||
func newTestOIDCVerifier(t *testing.T) Verifier {
|
||||
t.Helper()
|
||||
var issuerURL string
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{
|
||||
"issuer": %[1]q,
|
||||
"authorization_endpoint": "%[1]s/auth",
|
||||
"token_endpoint": "%[1]s/token",
|
||||
"end_session_endpoint": "%[1]s/logout",
|
||||
"jwks_uri": "%[1]s/certs"
|
||||
}`, issuerURL)
|
||||
})
|
||||
mux.HandleFunc("/certs", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"keys":[]}`)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
issuerURL = srv.URL
|
||||
|
||||
verifier, err := NewOIDCVerifier(context.Background(), OIDCConfig{
|
||||
IssuerURL: issuerURL, ClientID: "geniusrun", ClientSecret: "secret", RedirectURL: issuerURL + "/callback",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewOIDCVerifier: %v", err)
|
||||
}
|
||||
return verifier
|
||||
}
|
||||
|
||||
func TestEndSessionURL_IncludesIDTokenHintWhenProvided(t *testing.T) {
|
||||
verifier := newTestOIDCVerifier(t)
|
||||
|
||||
got := verifier.EndSessionURL("https://app.example.com/", "raw-id-token-jwt")
|
||||
u, err := url.Parse(got)
|
||||
if err != nil {
|
||||
t.Fatalf("parse EndSessionURL result %q: %v", got, err)
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("id_token_hint") != "raw-id-token-jwt" {
|
||||
t.Errorf("id_token_hint = %q, want %q", q.Get("id_token_hint"), "raw-id-token-jwt")
|
||||
}
|
||||
if q.Get("client_id") != "geniusrun" {
|
||||
t.Errorf("client_id = %q, want geniusrun", q.Get("client_id"))
|
||||
}
|
||||
if q.Get("post_logout_redirect_uri") != "https://app.example.com/" {
|
||||
t.Errorf("post_logout_redirect_uri = %q, want https://app.example.com/", q.Get("post_logout_redirect_uri"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndSessionURL_OmitsIDTokenHintWhenEmpty(t *testing.T) {
|
||||
verifier := newTestOIDCVerifier(t)
|
||||
|
||||
got := verifier.EndSessionURL("https://app.example.com/", "")
|
||||
u, err := url.Parse(got)
|
||||
if err != nil {
|
||||
t.Fatalf("parse EndSessionURL result %q: %v", got, err)
|
||||
}
|
||||
if u.Query().Has("id_token_hint") {
|
||||
t.Errorf("expected no id_token_hint param when idTokenHint is empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIDTokenClaims_HasRole(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
// Package auth implements geniusrun's own login gate: an OIDC Authorization
|
||||
// Code flow against an existing Keycloak realm (see oidc.go), backed by a
|
||||
// signed session cookie geniusrun mints itself (this file) and a chi
|
||||
// middleware that checks it (middleware.go). Keycloak's own tokens are never
|
||||
// stored or refreshed -- once HandleCallback verifies the ID token and role,
|
||||
// only this package's own cookie matters for subsequent requests.
|
||||
// middleware that checks it (middleware.go). Keycloak's own access/refresh
|
||||
// tokens are never stored or refreshed -- once HandleCallback verifies the
|
||||
// ID token and role, only this package's own cookie matters for subsequent
|
||||
// requests. The one exception is the raw ID token itself, carried opaquely
|
||||
// inside the signed session cookie (Claims.IDToken) solely so a later
|
||||
// logout can pass it back to Keycloak as id_token_hint -- letting Keycloak
|
||||
// skip its own logout-confirmation prompt for a session it can positively
|
||||
// identify, rather than leaving the user a chance to cancel out of it after
|
||||
// their geniusrun account (and profile) is already gone.
|
||||
package auth
|
||||
|
||||
import (
|
||||
@@ -26,17 +32,21 @@ const (
|
||||
)
|
||||
|
||||
// Claims identifies the authenticated user, carried in the signed session
|
||||
// cookie.
|
||||
// cookie. IDToken is the raw Keycloak ID token JWT from the OIDC callback,
|
||||
// carried opaquely so a later logout can pass it back to Keycloak as
|
||||
// id_token_hint (see EndSessionURL) -- geniusrun never inspects it itself.
|
||||
type Claims struct {
|
||||
Sub string
|
||||
Name string
|
||||
Email string
|
||||
Sub string
|
||||
Name string
|
||||
Email string
|
||||
IDToken string
|
||||
}
|
||||
|
||||
type sessionClaims struct {
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
@@ -58,9 +68,10 @@ type txnClaims struct {
|
||||
func MintSessionCookie(claims Claims, secret []byte, duration time.Duration, secure bool) (*http.Cookie, error) {
|
||||
now := time.Now()
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, sessionClaims{
|
||||
Sub: claims.Sub,
|
||||
Name: claims.Name,
|
||||
Email: claims.Email,
|
||||
Sub: claims.Sub,
|
||||
Name: claims.Name,
|
||||
Email: claims.Email,
|
||||
IDToken: claims.IDToken,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(duration)),
|
||||
@@ -91,7 +102,7 @@ func ParseSessionCookie(cookie *http.Cookie, secret []byte) (Claims, error) {
|
||||
if _, err := jwt.ParseWithClaims(cookie.Value, &sc, keyfunc(secret), jwt.WithValidMethods([]string{"HS256"})); err != nil {
|
||||
return Claims{}, fmt.Errorf("parse session token: %w", err)
|
||||
}
|
||||
return Claims{Sub: sc.Sub, Name: sc.Name, Email: sc.Email}, nil
|
||||
return Claims{Sub: sc.Sub, Name: sc.Name, Email: sc.Email, IDToken: sc.IDToken}, nil
|
||||
}
|
||||
|
||||
// MintTxnCookie signs an OIDC login transaction into a short-lived cookie.
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
const testSecret = "test-secret-at-least-32-bytes-long!"
|
||||
|
||||
func TestMintAndParseSessionCookie(t *testing.T) {
|
||||
claims := Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"}
|
||||
claims := Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com", IDToken: "raw-id-token-jwt"}
|
||||
cookie, err := MintSessionCookie(claims, []byte(testSecret), time.Hour, true)
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
|
||||
@@ -161,3 +161,46 @@ func TestIsolation_SyncStateAndRunsNeverLeakAcrossUsers(t *testing.T) {
|
||||
t.Fatal("userB's FinishSyncRun call was able to mutate userA's sync run")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsolation_DeleteUserLeavesOtherUsersDataIntact confirms deleting one
|
||||
// user's account never touches another user's profile, taxonomy, or
|
||||
// activities, even though DeleteUser is a single blunt DELETE FROM users.
|
||||
func TestIsolation_DeleteUserLeavesOtherUsersDataIntact(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionUser(a): %v", err)
|
||||
}
|
||||
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionUser(b): %v", err)
|
||||
}
|
||||
if _, err := db.UpsertActivity(ctx, userB, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil {
|
||||
t.Fatalf("UpsertActivity(b): %v", err)
|
||||
}
|
||||
|
||||
if err := db.DeleteUser(ctx, userA); err != nil {
|
||||
t.Fatalf("DeleteUser(a): %v", err)
|
||||
}
|
||||
|
||||
if _, found, err := db.GetUserBySub(ctx, "sub-b"); err != nil || !found {
|
||||
t.Fatalf("expected userB to survive userA's deletion, found=%v err=%v", found, err)
|
||||
}
|
||||
profileB, err := db.GetProfile(ctx, userB)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProfile(b) after deleting a: %v", err)
|
||||
}
|
||||
if profileB.Name != "B" {
|
||||
t.Errorf("userB's profile changed after deleting userA: %+v", profileB)
|
||||
}
|
||||
kindsB, err := db.ListWorkoutKinds(ctx, userB, false)
|
||||
if err != nil || len(kindsB) != 8 {
|
||||
t.Fatalf("userB's taxonomy affected by deleting userA: len=%d err=%v", len(kindsB), err)
|
||||
}
|
||||
activitiesB, err := db.ListActivities(ctx, userB, ActivityFilter{})
|
||||
if err != nil || len(activitiesB) != 1 {
|
||||
t.Fatalf("userB's activities affected by deleting userA: len=%d err=%v", len(activitiesB), err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ CREATE TABLE users (
|
||||
-- below) in one transaction when a new account signs up.
|
||||
CREATE TABLE profile (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT 'Default',
|
||||
garmin_email TEXT NOT NULL DEFAULT '',
|
||||
garmin_password TEXT NOT NULL DEFAULT '',
|
||||
@@ -82,7 +82,7 @@ CREATE TABLE profile (
|
||||
-- recursive AND/OR condition tree evaluated by internal/classify.
|
||||
CREATE TABLE workout_kinds (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
color TEXT NOT NULL DEFAULT '',
|
||||
@@ -100,7 +100,7 @@ CREATE TABLE workout_kinds (
|
||||
-- synced activity log is the history. No user_id column of its own --
|
||||
-- ownership is checked via a JOIN to workout_kinds.
|
||||
CREATE TABLE workout_type_paces (
|
||||
workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id),
|
||||
workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id) ON DELETE CASCADE,
|
||||
pace_min_sec_per_km REAL,
|
||||
pace_max_sec_per_km REAL,
|
||||
hr_min_pct_hrr REAL,
|
||||
@@ -118,7 +118,7 @@ CREATE TABLE workout_type_paces (
|
||||
-- instead of storing a redundant copy.
|
||||
CREATE TABLE activities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
garmin_activity_id INTEGER NOT NULL,
|
||||
-- Derived at sync time from Garmin's own eventType.typeKey=="race" --
|
||||
-- a hard fact, not a rule the user tunes (see internal/classify's
|
||||
@@ -219,7 +219,7 @@ JOIN (
|
||||
-- re-walking years of already-known history on every call.
|
||||
CREATE TABLE sync_state (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
earliest_synced_date TEXT,
|
||||
backfill_complete INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(user_id)
|
||||
@@ -229,7 +229,7 @@ CREATE TABLE sync_state (
|
||||
-- status the frontend polls.
|
||||
CREATE TABLE sync_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental','full')),
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
|
||||
@@ -119,3 +119,17 @@ func (db *DB) ProvisionUser(ctx context.Context, oidcSub, displayName string) (i
|
||||
|
||||
return userID, tx.Commit()
|
||||
}
|
||||
|
||||
// DeleteUser permanently deletes userID's account. Every row that belongs
|
||||
// to it -- profile, workout kinds (and their paces), activities (and their
|
||||
// laps/samples/kind_assignments), sync_state, and sync_runs -- cascades
|
||||
// away via the ON DELETE CASCADE foreign keys in schema.sql, so this is a
|
||||
// single statement rather than per-table deletes. Irreversible; the API
|
||||
// layer gates this behind a UI confirmation (see
|
||||
// docs/superpowers/specs/2026-07-26-profile-deletion-design.md).
|
||||
func (db *DB) DeleteUser(ctx context.Context, userID int64) error {
|
||||
if _, err := db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID); err != nil {
|
||||
return fmt.Errorf("delete user %d: %w", userID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -89,3 +89,76 @@ func TestProvisionUser_TwoUsersGetIndependentTaxonomies(t *testing.T) {
|
||||
t.Fatal("expected each user's seeded kinds to be distinct rows")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUser_RemovesUserAndCascadesEverything(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userID, err := db.ProvisionUser(ctx, "delete-me", "Delete Me")
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionUser: %v", err)
|
||||
}
|
||||
|
||||
activityID, err := db.UpsertActivity(ctx, userID, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertActivity: %v", err)
|
||||
}
|
||||
kindID, err := db.CreateWorkoutKind(ctx, userID, WorkoutKind{Name: "Test Delete Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorkoutKind: %v", err)
|
||||
}
|
||||
if err := db.ReplaceLaps(ctx, userID, activityID, []Lap{{LapIndex: 1}}); err != nil {
|
||||
t.Fatalf("ReplaceLaps: %v", err)
|
||||
}
|
||||
if err := db.ReplaceActivitySamples(ctx, userID, activityID, []Sample{{ElapsedSeconds: 0}}); err != nil {
|
||||
t.Fatalf("ReplaceActivitySamples: %v", err)
|
||||
}
|
||||
if _, err := db.InsertKindAssignment(ctx, userID, KindAssignment{
|
||||
ActivityID: activityID, WorkoutKindID: &kindID, AssignmentSource: AssignmentSourceRuleEngine,
|
||||
Status: AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
||||
}); err != nil {
|
||||
t.Fatalf("InsertKindAssignment: %v", err)
|
||||
}
|
||||
minPace := 300.0
|
||||
if err := db.UpdateWorkoutTypePace(ctx, userID, WorkoutTypePace{WorkoutKindID: kindID, PaceMinSecPerKm: &minPace}); err != nil {
|
||||
t.Fatalf("UpdateWorkoutTypePace: %v", err)
|
||||
}
|
||||
if err := db.UpdateSyncState(ctx, userID, "2020-01-01", true); err != nil {
|
||||
t.Fatalf("UpdateSyncState: %v", err)
|
||||
}
|
||||
if _, err := db.StartSyncRun(ctx, userID, SyncKindBackfill); err != nil {
|
||||
t.Fatalf("StartSyncRun: %v", err)
|
||||
}
|
||||
|
||||
if err := db.DeleteUser(ctx, userID); err != nil {
|
||||
t.Fatalf("DeleteUser: %v", err)
|
||||
}
|
||||
|
||||
if _, found, err := db.GetUserBySub(ctx, "delete-me"); err != nil || found {
|
||||
t.Fatalf("expected user gone after DeleteUser, found=%v err=%v", found, err)
|
||||
}
|
||||
|
||||
checks := []struct {
|
||||
query string
|
||||
arg int64
|
||||
}{
|
||||
{`SELECT COUNT(*) FROM profile WHERE user_id = ?`, userID},
|
||||
{`SELECT COUNT(*) FROM workout_kinds WHERE user_id = ?`, userID},
|
||||
{`SELECT COUNT(*) FROM workout_type_paces WHERE workout_kind_id = ?`, kindID},
|
||||
{`SELECT COUNT(*) FROM activities WHERE user_id = ?`, userID},
|
||||
{`SELECT COUNT(*) FROM laps WHERE activity_id = ?`, activityID},
|
||||
{`SELECT COUNT(*) FROM activity_samples WHERE activity_id = ?`, activityID},
|
||||
{`SELECT COUNT(*) FROM kind_assignments WHERE activity_id = ?`, activityID},
|
||||
{`SELECT COUNT(*) FROM sync_state WHERE user_id = ?`, userID},
|
||||
{`SELECT COUNT(*) FROM sync_runs WHERE user_id = ?`, userID},
|
||||
}
|
||||
for _, c := range checks {
|
||||
var count int
|
||||
if err := db.QueryRowContext(ctx, c.query, c.arg).Scan(&count); err != nil {
|
||||
t.Fatalf("count query %q: %v", c.query, err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("query %q: got %d rows, want 0 after DeleteUser", c.query, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ CREATE TABLE users (
|
||||
```sql
|
||||
CREATE TABLE profile (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT 'Default',
|
||||
garmin_email TEXT NOT NULL DEFAULT '',
|
||||
garmin_password TEXT NOT NULL DEFAULT '',
|
||||
@@ -87,7 +87,7 @@ CREATE TABLE profile (
|
||||
```sql
|
||||
CREATE TABLE workout_kinds (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
color TEXT NOT NULL DEFAULT '',
|
||||
@@ -104,7 +104,7 @@ CREATE TABLE workout_kinds (
|
||||
|
||||
```sql
|
||||
CREATE TABLE workout_type_paces (
|
||||
workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id),
|
||||
workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id) ON DELETE CASCADE,
|
||||
pace_min_sec_per_km REAL,
|
||||
pace_max_sec_per_km REAL,
|
||||
hr_min_pct_hrr REAL,
|
||||
@@ -117,7 +117,7 @@ CREATE TABLE workout_type_paces (
|
||||
```sql
|
||||
CREATE TABLE activities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
garmin_activity_id INTEGER NOT NULL,
|
||||
-- Derived at sync time from Garmin's own eventType.typeKey=="race" --
|
||||
-- a hard fact, not a rule the user tunes (see internal/classify's
|
||||
@@ -227,7 +227,7 @@ CREATE INDEX idx_kind_assignments_status ON kind_assignments(status);
|
||||
```sql
|
||||
CREATE TABLE sync_state (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
earliest_synced_date TEXT,
|
||||
backfill_complete INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(user_id)
|
||||
@@ -239,7 +239,7 @@ CREATE TABLE sync_state (
|
||||
```sql
|
||||
CREATE TABLE sync_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental','full')),
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
|
||||
@@ -9,12 +9,12 @@ for that history) and remove it from here once a spec exists.
|
||||
## Backlog
|
||||
|
||||
- improve first connection page: in CreateProfile.tsx, in addition to the display name, we should directly ask garmin login/password and manage MFA from there, so the synchronization experience in profile page will be easier later on
|
||||
- profile deletion: add a way to delete our profile with a dedicated button on the profile page (which by extension will also logout the user, to be consistent with the logic that login will trigger the profile creation)
|
||||
- dedicated workouts section: add workouts table in database and create a dedicated page in UI (use an "objective" icon)
|
||||
- improve activities/workouts download: make it modal, add progress bar, error management
|
||||
- better UX when backend is not available (instead of "TypeError failed to fetch")
|
||||
- better UX for activities (list layout without the graphs, detailed view with different graphs for each phase incl. delta with workout)
|
||||
- add icon to indicate if an activity has an associated workout
|
||||
- modern error displays: have error messages displayed in an ephemeral window
|
||||
|
||||
## Someday / maybe
|
||||
|
||||
|
||||
766
docs/superpowers/plans/2026-07-26-profile-deletion.md
Normal file
766
docs/superpowers/plans/2026-07-26-profile-deletion.md
Normal file
@@ -0,0 +1,766 @@
|
||||
# Profile Deletion Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let a signed-in geniusrun user permanently delete their entire account (Garmin credentials, custom rules, every synced activity) from a "Danger zone" button on the Profile page, and log them out afterward.
|
||||
|
||||
**Architecture:** Add `ON DELETE CASCADE` to every FK pointing at `users(id)` (and `workout_kinds(id)`) in `schema.sql`, so a single `DELETE FROM users` removes everything. Wrap that in a new `store.DeleteUser`, expose it via `DELETE /api/profile`, and have that handler also tear down the deleted user's cached Garmin subprocess/client and on-disk token-store directory. The frontend gates the call behind a type-`DELETE`-to-confirm UI, then performs a real POST navigation to the existing `/api/session/logout` route (the same one the header's Log out button already uses) so the OIDC session ends too.
|
||||
|
||||
**Tech Stack:** Go (`net/http`, `database/sql` via `modernc.org/sqlite`, chi router), React + TypeScript (Vite), no frontend test framework.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- `internal/store/schema.sql` is edited directly, never migrated — this is a pre-production app (see `CLAUDE.md`).
|
||||
- Every store/API method that touches per-user data takes an explicit `userID` and must filter/scope by it — the entire cross-user isolation boundary depends on this (see `CLAUDE.md`'s Authentication section).
|
||||
- Cross-user isolation must be tested adversarially (two real users, real IDs), not just checked for non-collision — matches `internal/store/isolation_test.go` / `internal/api/isolation_test.go` convention.
|
||||
- After any `schema.sql` change, regenerate `docs/DATABASE.md` via `go run ./cmd/dumpschema` (run from `backend/`).
|
||||
- `gofmt -l .` must report nothing before committing; `go vet ./...` and `go build ./...` must pass.
|
||||
- No frontend test suite exists — frontend changes are verified via `npm run build`, `npm run lint`, and a manual browser check.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Cascading deletes in the store layer
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/internal/store/schema.sql:29,85,103,121,222,232`
|
||||
- Modify: `backend/internal/store/users.go` (add `DeleteUser`)
|
||||
- Modify: `backend/internal/store/users_test.go` (add `TestDeleteUser_RemovesUserAndCascadesEverything`)
|
||||
- Modify: `backend/internal/store/isolation_test.go` (add `TestIsolation_DeleteUserLeavesOtherUsersDataIntact`)
|
||||
- Modify: `docs/DATABASE.md` (regenerated, not hand-edited)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `func (db *DB) DeleteUser(ctx context.Context, userID int64) error` — deletes the `users` row for `userID`; every owned row (profile, workout_kinds, workout_type_paces, activities, laps, activity_samples, kind_assignments, sync_state, sync_runs) cascades away via the schema FKs added in this task.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `backend/internal/store/users_test.go`:
|
||||
|
||||
```go
|
||||
func TestDeleteUser_RemovesUserAndCascadesEverything(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userID, err := db.ProvisionUser(ctx, "delete-me", "Delete Me")
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionUser: %v", err)
|
||||
}
|
||||
|
||||
activityID, err := db.UpsertActivity(ctx, userID, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertActivity: %v", err)
|
||||
}
|
||||
kindID, err := db.CreateWorkoutKind(ctx, userID, WorkoutKind{Name: "Test Delete Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorkoutKind: %v", err)
|
||||
}
|
||||
if err := db.ReplaceLaps(ctx, userID, activityID, []Lap{{LapIndex: 1}}); err != nil {
|
||||
t.Fatalf("ReplaceLaps: %v", err)
|
||||
}
|
||||
if err := db.ReplaceActivitySamples(ctx, userID, activityID, []Sample{{ElapsedSeconds: 0}}); err != nil {
|
||||
t.Fatalf("ReplaceActivitySamples: %v", err)
|
||||
}
|
||||
if _, err := db.InsertKindAssignment(ctx, userID, KindAssignment{
|
||||
ActivityID: activityID, WorkoutKindID: &kindID, AssignmentSource: AssignmentSourceRuleEngine,
|
||||
Status: AssignmentStatusAssigned, CandidateKindsJSON: "[]",
|
||||
}); err != nil {
|
||||
t.Fatalf("InsertKindAssignment: %v", err)
|
||||
}
|
||||
minPace := 300.0
|
||||
if err := db.UpdateWorkoutTypePace(ctx, userID, WorkoutTypePace{WorkoutKindID: kindID, PaceMinSecPerKm: &minPace}); err != nil {
|
||||
t.Fatalf("UpdateWorkoutTypePace: %v", err)
|
||||
}
|
||||
if err := db.UpdateSyncState(ctx, userID, "2020-01-01", true); err != nil {
|
||||
t.Fatalf("UpdateSyncState: %v", err)
|
||||
}
|
||||
if _, err := db.StartSyncRun(ctx, userID, SyncKindBackfill); err != nil {
|
||||
t.Fatalf("StartSyncRun: %v", err)
|
||||
}
|
||||
|
||||
if err := db.DeleteUser(ctx, userID); err != nil {
|
||||
t.Fatalf("DeleteUser: %v", err)
|
||||
}
|
||||
|
||||
if _, found, err := db.GetUserBySub(ctx, "delete-me"); err != nil || found {
|
||||
t.Fatalf("expected user gone after DeleteUser, found=%v err=%v", found, err)
|
||||
}
|
||||
|
||||
checks := []struct {
|
||||
query string
|
||||
arg int64
|
||||
}{
|
||||
{`SELECT COUNT(*) FROM profile WHERE user_id = ?`, userID},
|
||||
{`SELECT COUNT(*) FROM workout_kinds WHERE user_id = ?`, userID},
|
||||
{`SELECT COUNT(*) FROM workout_type_paces WHERE workout_kind_id = ?`, kindID},
|
||||
{`SELECT COUNT(*) FROM activities WHERE user_id = ?`, userID},
|
||||
{`SELECT COUNT(*) FROM laps WHERE activity_id = ?`, activityID},
|
||||
{`SELECT COUNT(*) FROM activity_samples WHERE activity_id = ?`, activityID},
|
||||
{`SELECT COUNT(*) FROM kind_assignments WHERE activity_id = ?`, activityID},
|
||||
{`SELECT COUNT(*) FROM sync_state WHERE user_id = ?`, userID},
|
||||
{`SELECT COUNT(*) FROM sync_runs WHERE user_id = ?`, userID},
|
||||
}
|
||||
for _, c := range checks {
|
||||
var count int
|
||||
if err := db.QueryRowContext(ctx, c.query, c.arg).Scan(&count); err != nil {
|
||||
t.Fatalf("count query %q: %v", c.query, err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("query %q: got %d rows, want 0 after DeleteUser", c.query, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add to `backend/internal/store/isolation_test.go`:
|
||||
|
||||
```go
|
||||
// TestIsolation_DeleteUserLeavesOtherUsersDataIntact confirms deleting one
|
||||
// user's account never touches another user's profile, taxonomy, or
|
||||
// activities, even though DeleteUser is a single blunt DELETE FROM users.
|
||||
func TestIsolation_DeleteUserLeavesOtherUsersDataIntact(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionUser(a): %v", err)
|
||||
}
|
||||
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionUser(b): %v", err)
|
||||
}
|
||||
if _, err := db.UpsertActivity(ctx, userB, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil {
|
||||
t.Fatalf("UpsertActivity(b): %v", err)
|
||||
}
|
||||
|
||||
if err := db.DeleteUser(ctx, userA); err != nil {
|
||||
t.Fatalf("DeleteUser(a): %v", err)
|
||||
}
|
||||
|
||||
if _, found, err := db.GetUserBySub(ctx, "sub-b"); err != nil || !found {
|
||||
t.Fatalf("expected userB to survive userA's deletion, found=%v err=%v", found, err)
|
||||
}
|
||||
profileB, err := db.GetProfile(ctx, userB)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProfile(b) after deleting a: %v", err)
|
||||
}
|
||||
if profileB.Name != "B" {
|
||||
t.Errorf("userB's profile changed after deleting userA: %+v", profileB)
|
||||
}
|
||||
kindsB, err := db.ListWorkoutKinds(ctx, userB, false)
|
||||
if err != nil || len(kindsB) != 8 {
|
||||
t.Fatalf("userB's taxonomy affected by deleting userA: len=%d err=%v", len(kindsB), err)
|
||||
}
|
||||
activitiesB, err := db.ListActivities(ctx, userB, ActivityFilter{})
|
||||
if err != nil || len(activitiesB) != 1 {
|
||||
t.Fatalf("userB's activities affected by deleting userA: len=%d err=%v", len(activitiesB), err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd backend && go test ./internal/store/... -run 'TestDeleteUser|TestIsolation_DeleteUser' -v`
|
||||
Expected: FAIL — `db.DeleteUser undefined (type *DB has no field or method DeleteUser)`.
|
||||
|
||||
- [ ] **Step 3: Add `ON DELETE CASCADE` to schema.sql**
|
||||
|
||||
In `backend/internal/store/schema.sql`, change these six lines (each currently ends `REFERENCES users(id),` or `REFERENCES workout_kinds(id),`):
|
||||
|
||||
Line 29 (`profile.user_id`), from:
|
||||
```sql
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
```
|
||||
to:
|
||||
```sql
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
```
|
||||
|
||||
Line 85 (`workout_kinds.user_id`), from:
|
||||
```sql
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
```
|
||||
to:
|
||||
```sql
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
```
|
||||
|
||||
Line 103 (`workout_type_paces.workout_kind_id`), from:
|
||||
```sql
|
||||
workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id),
|
||||
```
|
||||
to:
|
||||
```sql
|
||||
workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id) ON DELETE CASCADE,
|
||||
```
|
||||
|
||||
Line 121 (`activities.user_id`), from:
|
||||
```sql
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
```
|
||||
to:
|
||||
```sql
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
```
|
||||
|
||||
Line 222 (`sync_state.user_id`), from:
|
||||
```sql
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
```
|
||||
to:
|
||||
```sql
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
```
|
||||
|
||||
Line 232 (`sync_runs.user_id`), from:
|
||||
```sql
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
```
|
||||
to:
|
||||
```sql
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
```
|
||||
|
||||
`laps`/`activity_samples`/`kind_assignments` already cascade off `activities(id)` — no change needed there. SQLite's `foreign_keys` pragma is already on for every connection (`db.go`), so these cascades (including the two-level `users → workout_kinds → workout_type_paces` chain) take effect immediately, no code change required for the pragma itself.
|
||||
|
||||
- [ ] **Step 4: Add `DeleteUser` to `backend/internal/store/users.go`**
|
||||
|
||||
Append after `ProvisionUser` (end of file):
|
||||
|
||||
```go
|
||||
|
||||
// DeleteUser permanently deletes userID's account. Every row that belongs
|
||||
// to it -- profile, workout kinds (and their paces), activities (and their
|
||||
// laps/samples/kind_assignments), sync_state, and sync_runs -- cascades
|
||||
// away via the ON DELETE CASCADE foreign keys in schema.sql, so this is a
|
||||
// single statement rather than per-table deletes. Irreversible; the API
|
||||
// layer gates this behind a UI confirmation (see
|
||||
// docs/superpowers/specs/2026-07-26-profile-deletion-design.md).
|
||||
func (db *DB) DeleteUser(ctx context.Context, userID int64) error {
|
||||
if _, err := db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID); err != nil {
|
||||
return fmt.Errorf("delete user %d: %w", userID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run tests to verify they pass**
|
||||
|
||||
Run: `cd backend && go test ./internal/store/... -run 'TestDeleteUser|TestIsolation_DeleteUser' -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Run the full store test suite**
|
||||
|
||||
Run: `cd backend && go test ./internal/store/...`
|
||||
Expected: PASS (confirms the new `ON DELETE CASCADE` clauses didn't break `ResetAllSyncedData` or any other existing behavior).
|
||||
|
||||
- [ ] **Step 7: Regenerate `docs/DATABASE.md`**
|
||||
|
||||
Run: `cd backend && go run ./cmd/dumpschema`
|
||||
Expected: `docs/DATABASE.md` updates to show `ON DELETE CASCADE` on the six changed columns.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/internal/store/schema.sql backend/internal/store/users.go backend/internal/store/users_test.go backend/internal/store/isolation_test.go docs/DATABASE.md
|
||||
git commit -m "feat(store): add DeleteUser with cascading account deletion"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `DELETE /api/profile` and Garmin client/tokenstore teardown
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/internal/api/server.go:5-21` (imports), `:95-117` (new `removeGarminClient` after `syncFor`), `:167-170` (route)
|
||||
- Modify: `backend/internal/api/profile.go` (add `handleDeleteProfile`)
|
||||
- Modify: `backend/internal/api/api_test.go` (imports + 3 new tests)
|
||||
- Modify: `backend/internal/api/isolation_test.go` (1 new test)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `store.DB.DeleteUser(ctx, userID) error` (Task 1); `garmin.Client.Close() error` and `.UpdateCredentials` (existing); `Server.userGarmin/userSync/userAuthStatus/userAuthMessage/userSyncRunning map[int64]...` and `Server.mu sync.Mutex` (existing fields); `Server.GarminBase garmin.Config` (existing, has `.TokenStorePath string`).
|
||||
- Produces: `func (s *Server) removeGarminClient(userID int64)` — used only within this task's handler, not exported further. `DELETE /api/profile` route → `handleDeleteProfile`, responding `204` on success, `409` if a sync is running for that user.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add `"os"` to `backend/internal/api/api_test.go`'s import block (needed for Step 1's token-store test):
|
||||
|
||||
```go
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"geniusrun/backend/internal/auth"
|
||||
authmock "geniusrun/backend/internal/auth/mock"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/garmin/mock"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
)
|
||||
```
|
||||
|
||||
Append to `backend/internal/api/api_test.go`:
|
||||
|
||||
```go
|
||||
func TestDeleteProfile_DeletesUserAndTerminatesGarminClient(t *testing.T) {
|
||||
s, db, userID := newTestServer(t)
|
||||
router := s.Router()
|
||||
|
||||
// Force the per-user Garmin client to be built and cached.
|
||||
rec := doJSON(t, router, http.MethodPost, "/api/auth/login", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
client, err := s.garminFor(context.Background(), userID)
|
||||
if err != nil {
|
||||
t.Fatalf("garminFor: %v", err)
|
||||
}
|
||||
mockClient, ok := client.(*mock.Client)
|
||||
if !ok {
|
||||
t.Fatalf("expected *mock.Client, got %T", client)
|
||||
}
|
||||
|
||||
rec = doJSON(t, router, http.MethodDelete, "/api/profile", nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
if _, found, err := db.GetUserBySub(context.Background(), "test-user"); err != nil || found {
|
||||
t.Fatalf("expected user gone after delete, found=%v err=%v", found, err)
|
||||
}
|
||||
if !mockClient.ClosedCalled {
|
||||
t.Error("expected the cached garmin client to be Close()d on profile deletion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteProfile_RejectsWhileSyncInProgress(t *testing.T) {
|
||||
s, db, userID := newTestServer(t)
|
||||
s.mu.Lock()
|
||||
s.userSyncRunning[userID] = true
|
||||
s.mu.Unlock()
|
||||
|
||||
rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, found, err := db.GetUserBySub(context.Background(), "test-user"); err != nil || !found {
|
||||
t.Fatalf("expected user to survive a rejected delete, found=%v err=%v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteProfile_RemovesTokenStoreDirectory(t *testing.T) {
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
userID, err := db.ProvisionUser(context.Background(), "test-user", "Test User")
|
||||
if err != nil {
|
||||
t.Fatalf("ProvisionUser: %v", err)
|
||||
}
|
||||
|
||||
tokenStoreRoot := t.TempDir()
|
||||
userTokenDir := filepath.Join(tokenStoreRoot, strconv.FormatInt(userID, 10))
|
||||
if err := os.MkdirAll(userTokenDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(userTokenDir, "session.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
m := &mock.Client{}
|
||||
garminFactory := func(garmin.Config) garmin.Client { return m }
|
||||
s := NewServer(db, garminFactory, garmin.Config{TokenStorePath: tokenStoreRoot}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||||
|
||||
rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(userTokenDir); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected token store dir %q to be removed, stat err = %v", userTokenDir, err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Append to `backend/internal/api/isolation_test.go`:
|
||||
|
||||
```go
|
||||
// TestIsolation_DeleteProfileOnlyDeletesOwnAccount confirms one user
|
||||
// deleting their own profile never touches another user's account, even
|
||||
// though DeleteUser is keyed purely by the session-resolved userID.
|
||||
func TestIsolation_DeleteProfileOnlyDeletesOwnAccount(t *testing.T) {
|
||||
s, db, userA := newTestServer(t) // provisions "test-user" (userA)
|
||||
if _, err := db.ProvisionUser(newCtx(), "user-b", "B"); err != nil {
|
||||
t.Fatalf("ProvisionUser(b): %v", err)
|
||||
}
|
||||
|
||||
router := s.Router()
|
||||
rec := doJSONAs(t, router, "user-b", http.MethodDelete, "/api/profile", nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("userB delete status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
if _, found, err := db.GetUserBySub(newCtx(), "user-b"); err != nil || found {
|
||||
t.Fatalf("expected userB gone after their own delete, found=%v err=%v", found, err)
|
||||
}
|
||||
u, found, err := db.GetUserBySub(newCtx(), "test-user")
|
||||
if err != nil || !found || u.ID != userA {
|
||||
t.Fatalf("expected userA to survive userB's deletion, found=%v err=%v id=%d want=%d", found, err, u.ID, userA)
|
||||
}
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/profile", nil) // as userA
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("userA profile status after userB deleted themselves = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd backend && go test ./internal/api/... -run 'TestDeleteProfile|TestIsolation_DeleteProfile' -v`
|
||||
Expected: FAIL — 404s (no `DELETE /api/profile` route registered yet).
|
||||
|
||||
- [ ] **Step 3: Add the `os` import and `removeGarminClient` to `server.go`**
|
||||
|
||||
Change the import block (add `"os"` between `"net/http"` and `"path/filepath"`):
|
||||
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"geniusrun/backend/internal/auth"
|
||||
"geniusrun/backend/internal/garmin"
|
||||
"geniusrun/backend/internal/store"
|
||||
appsync "geniusrun/backend/internal/sync"
|
||||
)
|
||||
```
|
||||
|
||||
Insert this new method right after `syncFor` (i.e. between the existing `syncFor` closing brace and the `RunIncrementalSyncForAllUsers` doc comment):
|
||||
|
||||
```go
|
||||
|
||||
// removeGarminClient drops userID's cached garmin.Client/sync.Service (if
|
||||
// any) and every other per-user in-memory entry for userID, terminating the
|
||||
// client's subprocess and best-effort removing its on-disk token-store
|
||||
// directory. Called when a user's account has just been deleted from the
|
||||
// DB, so nothing in memory keeps referencing a userID that no longer
|
||||
// exists.
|
||||
func (s *Server) removeGarminClient(userID int64) {
|
||||
s.mu.Lock()
|
||||
client, ok := s.userGarmin[userID]
|
||||
delete(s.userGarmin, userID)
|
||||
delete(s.userSync, userID)
|
||||
delete(s.userAuthStatus, userID)
|
||||
delete(s.userAuthMessage, userID)
|
||||
delete(s.userSyncRunning, userID)
|
||||
s.mu.Unlock()
|
||||
|
||||
if ok {
|
||||
if err := client.Close(); err != nil {
|
||||
log.Printf("api: close garmin client for deleted user %d: %v", userID, err)
|
||||
}
|
||||
}
|
||||
if s.GarminBase.TokenStorePath == "" {
|
||||
return
|
||||
}
|
||||
tokenStoreDir := filepath.Join(s.GarminBase.TokenStorePath, strconv.FormatInt(userID, 10))
|
||||
if err := os.RemoveAll(tokenStoreDir); err != nil {
|
||||
log.Printf("api: remove token store dir for deleted user %d: %v", userID, err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Register the route in `Router()`**
|
||||
|
||||
In `server.go`, change:
|
||||
|
||||
```go
|
||||
r.Route("/profile", func(r chi.Router) {
|
||||
r.Get("/", s.handleGetProfile)
|
||||
r.Put("/", s.handleUpdateProfile)
|
||||
})
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```go
|
||||
r.Route("/profile", func(r chi.Router) {
|
||||
r.Get("/", s.handleGetProfile)
|
||||
r.Put("/", s.handleUpdateProfile)
|
||||
r.Delete("/", s.handleDeleteProfile)
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add `handleDeleteProfile` to `profile.go`**
|
||||
|
||||
Append to `backend/internal/api/profile.go`:
|
||||
|
||||
```go
|
||||
|
||||
// handleDeleteProfile permanently deletes the signed-in user's entire
|
||||
// geniusrun account (profile, workout kinds/paces, activities and
|
||||
// everything under them, sync state/runs -- see schema.sql's ON DELETE
|
||||
// CASCADE from users(id)) and tears down their cached Garmin client and
|
||||
// token-store directory. It does not touch the session cookie itself --
|
||||
// the frontend follows a successful call with a real logout navigation
|
||||
// (see docs/superpowers/specs/2026-07-26-profile-deletion-design.md).
|
||||
func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
|
||||
s.mu.Lock()
|
||||
inProgress := s.userSyncRunning[userID]
|
||||
s.mu.Unlock()
|
||||
if inProgress {
|
||||
writeError(w, http.StatusConflict, "a sync is in progress for this account; wait for it to finish before deleting your profile")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.DB.DeleteUser(r.Context(), userID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.removeGarminClient(userID)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run tests to verify they pass**
|
||||
|
||||
Run: `cd backend && go test ./internal/api/... -run 'TestDeleteProfile|TestIsolation_DeleteProfile' -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Run the full backend test suite**
|
||||
|
||||
Run: `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...`
|
||||
Expected: `gofmt -l .` prints nothing; `go build`/`go vet`/`go test` all succeed.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/internal/api/server.go backend/internal/api/profile.go backend/internal/api/api_test.go backend/internal/api/isolation_test.go
|
||||
git commit -m "feat(api): add DELETE /api/profile with Garmin client/tokenstore teardown"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Frontend Danger zone UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/api/client.ts` (add `deleteProfile`)
|
||||
- Modify: `frontend/src/pages/Profile.tsx` (add Danger zone fieldset + confirm flow)
|
||||
- Modify: `frontend/src/App.css` (small `.danger-zone-confirm` rule)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `DELETE /api/profile` (Task 2, returns `204` or throws via the existing `request()` helper's non-2xx handling); `BASE_URL` (already exported from `client.ts`).
|
||||
- Produces: `api.deleteProfile(): Promise<void>`, used only from `Profile.tsx`.
|
||||
|
||||
- [ ] **Step 1: Add `deleteProfile` to the API client**
|
||||
|
||||
In `frontend/src/api/client.ts`, in the `// Profile` section, change:
|
||||
|
||||
```ts
|
||||
// Profile
|
||||
getProfile: () => request<Profile>("/api/profile"),
|
||||
updateProfile: (profile: Profile) =>
|
||||
request<Profile>("/api/profile", { method: "PUT", body: JSON.stringify(profile) }),
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```ts
|
||||
// Profile
|
||||
getProfile: () => request<Profile>("/api/profile"),
|
||||
updateProfile: (profile: Profile) =>
|
||||
request<Profile>("/api/profile", { method: "PUT", body: JSON.stringify(profile) }),
|
||||
// Permanently deletes the signed-in user's entire account. The caller is
|
||||
// responsible for following a successful call with a real logout
|
||||
// navigation -- this does not touch the session cookie (see Profile.tsx).
|
||||
deleteProfile: () => request<void>("/api/profile", { method: "DELETE" }),
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the Danger zone section to `Profile.tsx`**
|
||||
|
||||
Change the import line:
|
||||
|
||||
```tsx
|
||||
import { api } from "../api/client";
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```tsx
|
||||
import { api, BASE_URL } from "../api/client";
|
||||
```
|
||||
|
||||
Add these three state variables inside `Profile`, right after the existing `saveTimeoutRef` declaration:
|
||||
|
||||
```tsx
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState("");
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
```
|
||||
|
||||
Add this function right after `flushPendingSave`:
|
||||
|
||||
```tsx
|
||||
async function deleteAccount() {
|
||||
setDeleting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.deleteProfile();
|
||||
// Deletion doesn't clear the session cookie -- follow with a real
|
||||
// logout navigation (must be a POST, same as the header's Log out
|
||||
// button in App.tsx) so Keycloak's own SSO session ends too, not just
|
||||
// geniusrun's local one.
|
||||
const form = document.createElement("form");
|
||||
form.method = "post";
|
||||
form.action = `${BASE_URL}/api/session/logout`;
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add this fieldset right after `<TrainingTypesCard />` (still inside the closing `</div>` of `.profile-page`):
|
||||
|
||||
```tsx
|
||||
<fieldset className="kind-editor danger-zone">
|
||||
<legend>Danger zone</legend>
|
||||
{!deleteConfirmOpen ? (
|
||||
<button type="button" className="button-danger" onClick={() => setDeleteConfirmOpen(true)}>
|
||||
Delete profile
|
||||
</button>
|
||||
) : (
|
||||
<div className="danger-zone-confirm">
|
||||
<p>
|
||||
This permanently deletes your account -- Garmin credentials, every custom rule, and every synced
|
||||
activity -- and logs you out. This cannot be undone.
|
||||
</p>
|
||||
<label>
|
||||
Type DELETE to confirm
|
||||
<input
|
||||
type="text"
|
||||
value={deleteConfirmText}
|
||||
onChange={(e) => setDeleteConfirmText(e.target.value)}
|
||||
disabled={deleting}
|
||||
/>
|
||||
</label>
|
||||
<div className="controls">
|
||||
<button
|
||||
type="button"
|
||||
disabled={deleting}
|
||||
onClick={() => {
|
||||
setDeleteConfirmOpen(false);
|
||||
setDeleteConfirmText("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button-danger"
|
||||
disabled={deleting || deleteConfirmText !== "DELETE"}
|
||||
onClick={deleteAccount}
|
||||
>
|
||||
Permanently delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the confirmation block's CSS**
|
||||
|
||||
In `frontend/src/App.css`, add right after the `.button-danger:hover:not(:disabled)` rule:
|
||||
|
||||
```css
|
||||
.danger-zone-confirm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.danger-zone-confirm p {
|
||||
margin: 0;
|
||||
color: #9aa0ab;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build and lint**
|
||||
|
||||
Run: `cd frontend && npm run build && npm run lint`
|
||||
Expected: build succeeds; lint reports no new warnings (the 3 pre-existing `PaceField.tsx` `only-export-components` warnings are unrelated and expected to remain).
|
||||
|
||||
- [ ] **Step 5: Manual browser verification**
|
||||
|
||||
With the backend running (`cd backend && ./start.sh`) and frontend dev server running (`cd frontend && ./start.sh`), in a browser:
|
||||
1. Log in, go to the Profile page, scroll to "Danger zone".
|
||||
2. Click "Delete profile" — the confirm block appears; "Permanently delete" is disabled.
|
||||
3. Type `delete` (lowercase) — button stays disabled. Type `DELETE` — button enables.
|
||||
4. Click "Cancel" — block collapses, typed text is cleared if reopened.
|
||||
5. Reopen, type `DELETE`, click "Permanently delete" — expect a full-page navigation ending on the login screen (Keycloak or geniusrun's own login gate, matching what the existing "Log out" button already does).
|
||||
6. Log back in with the same account — expect the "Create your profile" screen (`CreateProfile.tsx`), confirming the account was actually deleted, not just logged out.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/api/client.ts frontend/src/pages/Profile.tsx frontend/src/App.css
|
||||
git commit -m "feat(profile): add Danger zone account deletion UI"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Final verification
|
||||
|
||||
**Files:** none (verification only)
|
||||
|
||||
- [ ] **Step 1: Full backend check**
|
||||
|
||||
Run: `cd backend && gofmt -l . && go vet ./... && go build ./... && go test ./...`
|
||||
Expected: `gofmt -l .` empty; everything else passes.
|
||||
|
||||
- [ ] **Step 2: Full frontend check**
|
||||
|
||||
Run: `cd frontend && npm run build && npm run lint`
|
||||
Expected: both succeed, no new lint warnings.
|
||||
|
||||
- [ ] **Step 3: Confirm `docs/DATABASE.md` is current**
|
||||
|
||||
Run: `cd backend && go run ./cmd/dumpschema && git status --short docs/DATABASE.md`
|
||||
Expected: no output from `git status` (already committed in Task 1, and hasn't drifted since).
|
||||
|
||||
- [ ] **Step 4: Update `docs/IDEAS.md`**
|
||||
|
||||
Remove the now-implemented line from `docs/IDEAS.md`'s Backlog section:
|
||||
|
||||
```
|
||||
- profile deletion: add a way to delete our profile with a dedicated button on the profile page (which by extension will also logout the user, to be consistent with the logic that login will trigger the profile creation)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/IDEAS.md
|
||||
git commit -m "docs: remove profile deletion from IDEAS backlog (implemented)"
|
||||
```
|
||||
142
docs/superpowers/specs/2026-07-26-profile-deletion-design.md
Normal file
142
docs/superpowers/specs/2026-07-26-profile-deletion-design.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Profile deletion
|
||||
|
||||
Status: approved, not yet implemented.
|
||||
|
||||
## Problem
|
||||
|
||||
`docs/IDEAS.md` backlog item: "profile deletion: add a way to delete our
|
||||
profile with a dedicated button on the profile page (which by extension will
|
||||
also logout the user, to be consistent with the logic that login will trigger
|
||||
the profile creation)".
|
||||
|
||||
Today there is no way for a user to remove their geniusrun account. The
|
||||
closest existing feature, "Reset all" (`GarminConnection.tsx` /
|
||||
`POST /api/sync/reset`), only wipes synced activity data and rewinds the
|
||||
backfill watermark -- it leaves the `users` row, `profile`, Garmin
|
||||
credentials, and custom workout-kind rules untouched. Profile deletion is a
|
||||
strictly bigger, irreversible operation: the entire account for the signed-in
|
||||
OIDC subject, gone, followed by a real logout.
|
||||
|
||||
## Design
|
||||
|
||||
### Data model: cascading deletes
|
||||
|
||||
The schema has FK columns pointing at `users(id)` (`profile.user_id`,
|
||||
`workout_kinds.user_id`, `activities.user_id`, `sync_state.user_id`,
|
||||
`sync_runs.user_id`) and at `workout_kinds(id)`
|
||||
(`workout_type_paces.workout_kind_id`), none of which currently cascade.
|
||||
`laps`/`activity_samples`/`kind_assignments` already cascade off `activities`
|
||||
(`ON DELETE CASCADE`), which is what lets `ResetAllSyncedData` get away with
|
||||
a single `DELETE FROM activities`. SQLite's `foreign_keys` pragma is already
|
||||
enabled on every connection (`db.go`'s `_pragma=foreign_keys(1)`), so the same
|
||||
mechanism works for the rest of the schema.
|
||||
|
||||
`schema.sql` is edited directly (no migration history, per project
|
||||
convention) to add `ON DELETE CASCADE` to:
|
||||
|
||||
- `profile.user_id REFERENCES users(id)`
|
||||
- `workout_kinds.user_id REFERENCES users(id)`
|
||||
- `workout_type_paces.workout_kind_id REFERENCES workout_kinds(id)`
|
||||
- `activities.user_id REFERENCES users(id)`
|
||||
- `sync_state.user_id REFERENCES users(id)`
|
||||
- `sync_runs.user_id REFERENCES users(id)`
|
||||
|
||||
With that in place, deleting a user becomes one statement:
|
||||
|
||||
```go
|
||||
// store/users.go
|
||||
func (db *DB) DeleteUser(ctx context.Context, userID int64) error {
|
||||
_, err := db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID)
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
After this change, regenerate `docs/DATABASE.md` (`go run ./cmd/dumpschema`).
|
||||
|
||||
### API: `DELETE /api/profile`
|
||||
|
||||
New handler alongside the existing `GET`/`PUT /api/profile` routes, inside
|
||||
the `requireProvisionedUser` group (`server.go`). `userID` comes from
|
||||
`userIDFromContext`, same as every other handler -- no client-supplied id.
|
||||
|
||||
1. If a sync is currently running for this user (`s.userSyncRunning[userID]`,
|
||||
the same flag `backgroundSync` sets), respond `409 Conflict` ("a sync is
|
||||
in progress for this account; wait for it to finish before deleting your
|
||||
profile") rather than deleting rows out from under an in-flight write.
|
||||
2. Call `s.DB.DeleteUser(ctx, userID)`.
|
||||
3. Tear down this user's cached Garmin state (new `Server` method, e.g.
|
||||
`removeGarminClient(userID)`):
|
||||
- Under `s.mu`, pop `s.userGarmin[userID]` and delete the userID entry
|
||||
from `userSync`, `userAuthStatus`, `userAuthMessage`, and
|
||||
`userSyncRunning`.
|
||||
- If a client existed, call `client.Close()` outside the lock to
|
||||
terminate its subprocess (mirrors how `handleUpdateProfile` calls
|
||||
`client.UpdateCredentials` outside `s.mu`).
|
||||
- Best-effort `os.RemoveAll` on `filepath.Join(s.GarminBase.TokenStorePath,
|
||||
strconv.FormatInt(userID, 10))` when `TokenStorePath` is configured --
|
||||
the same path `garminFor` computes when building a client. Log on
|
||||
error; this is cleanup of an already-orphaned directory, not something
|
||||
that should fail the request that already deleted the DB row.
|
||||
4. Respond `204 No Content`.
|
||||
|
||||
### Frontend
|
||||
|
||||
- `api/client.ts`: `deleteProfile: () => request<void>("/api/profile", {
|
||||
method: "DELETE" })`.
|
||||
- `Profile.tsx`: a final `<fieldset className="kind-editor danger-zone">`
|
||||
("Danger zone") containing a `button-danger` "Delete profile" button
|
||||
(same class `GarminConnection`'s "Reset all" uses). Clicking it reveals an
|
||||
inline confirmation block (local component state, no separate modal
|
||||
component -- same "reveal inline" shape `ClassifyControl` already uses for
|
||||
its dropdown):
|
||||
- Explanatory copy: this permanently deletes the account -- Garmin
|
||||
credentials, every custom rule, every synced activity -- and logs the
|
||||
user out. Cannot be undone.
|
||||
- A text input; a "Permanently delete" button stays disabled until the
|
||||
input's value is exactly `DELETE`.
|
||||
- A "Cancel" button collapses the block back.
|
||||
- On confirm: call `api.deleteProfile()`. On success, build a real
|
||||
`<form method="post" action="{BASE_URL}/api/session/logout">` via the DOM
|
||||
and submit it immediately -- the same POST-navigation the header's Log out
|
||||
button already performs (`App.tsx`), required because logout must be a
|
||||
real browser navigation through Keycloak's end-session redirect, not a
|
||||
`fetch`. On failure, surface the error via the same `error` state /
|
||||
`<p className="error">` pattern the rest of `Profile.tsx` already uses,
|
||||
and leave the confirmation block open so the user can retry.
|
||||
|
||||
### Error handling
|
||||
|
||||
- Sync-in-progress: `409`, clear message, surfaced like every other API
|
||||
error on this page.
|
||||
- DB/teardown errors: `500` with `err.Error()`, same as the rest of this
|
||||
codebase's handlers.
|
||||
- The type-`DELETE`-to-confirm gate is UI-only -- same trust boundary as
|
||||
every other destructive action in this app (e.g. Reset all's
|
||||
`window.confirm`). The backend does not require a second confirmation
|
||||
token.
|
||||
|
||||
### Testing
|
||||
|
||||
- `backend/internal/store`: `TestDeleteUser` -- provision a user, give them
|
||||
activities (with laps/samples/kind_assignments), workout kinds (with
|
||||
paces), sync_state, and sync_runs; delete; assert every row is gone.
|
||||
Adversarial isolation check per this repo's convention
|
||||
(`isolation_test.go`): provision two users, delete one, assert the other's
|
||||
rows (profile, workout kinds, activities, etc.) are untouched.
|
||||
- `backend/internal/api`: handler test for `DELETE /api/profile` against
|
||||
`newTestServer` + `mock.Client` -- success path (204, user actually gone
|
||||
from `GetUserBySub`), and the sync-in-progress 409 (set
|
||||
`userSyncRunning[userID] = true` first).
|
||||
- No frontend test suite exists yet (per `CLAUDE.md`) -- manually
|
||||
smoke-tested in the browser: delete flow ends up back at the login/create
|
||||
profile screen, a fresh login for that OIDC subject lands on "Create your
|
||||
profile" again (proving the account is truly gone, not just logged out).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Any "export my data before deleting" flow -- not requested.
|
||||
- Re-authentication (password/MFA re-entry) before deletion -- this app has
|
||||
no such step anywhere else (e.g. Reset all), so it isn't introduced here
|
||||
either.
|
||||
- Admin-initiated deletion of another user's account -- there is no admin UI
|
||||
in this app.
|
||||
@@ -231,6 +231,17 @@ button:disabled {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.danger-zone-confirm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.danger-zone-confirm p {
|
||||
margin: 0;
|
||||
color: #9aa0ab;
|
||||
}
|
||||
|
||||
.filter-pills {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
@@ -106,6 +106,10 @@ export const api = {
|
||||
getProfile: () => request<Profile>("/api/profile"),
|
||||
updateProfile: (profile: Profile) =>
|
||||
request<Profile>("/api/profile", { method: "PUT", body: JSON.stringify(profile) }),
|
||||
// Permanently deletes the signed-in user's entire account. The caller is
|
||||
// responsible for following a successful call with a real logout
|
||||
// navigation -- this does not touch the session cookie (see Profile.tsx).
|
||||
deleteProfile: () => request<void>("/api/profile", { method: "DELETE" }),
|
||||
|
||||
// Review queue -- cursor-paginated: pass the previous page's next_cursor
|
||||
// as `before` to fetch the next one. Fetching every activity's laps and
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
import { api, BASE_URL } from "../api/client";
|
||||
import { ColorField } from "../components/ColorField";
|
||||
import { GarminConnection } from "../components/GarminConnection";
|
||||
import { NullableNumberField } from "../components/NullableNumberField";
|
||||
@@ -47,6 +47,9 @@ export function Profile({ onSaved }: { onSaved?: (p: ProfileType) => void }) {
|
||||
const profileRef = useRef<ProfileType | null>(null);
|
||||
profileRef.current = profile;
|
||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState("");
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getProfile().then(setProfile).catch((e) => setError(String(e)));
|
||||
@@ -100,6 +103,26 @@ export function Profile({ onSaved }: { onSaved?: (p: ProfileType) => void }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAccount() {
|
||||
setDeleting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.deleteProfile();
|
||||
// Deletion doesn't clear the session cookie -- follow with a real
|
||||
// logout navigation (must be a POST, same as the header's Log out
|
||||
// button in App.tsx) so Keycloak's own SSO session ends too, not just
|
||||
// geniusrun's local one.
|
||||
const form = document.createElement("form");
|
||||
form.method = "post";
|
||||
form.action = `${BASE_URL}/api/session/logout`;
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return (
|
||||
<div className="page">
|
||||
@@ -240,6 +263,51 @@ export function Profile({ onSaved }: { onSaved?: (p: ProfileType) => void }) {
|
||||
</fieldset>
|
||||
|
||||
<TrainingTypesCard />
|
||||
|
||||
<fieldset className="kind-editor danger-zone">
|
||||
<legend>Danger zone</legend>
|
||||
{!deleteConfirmOpen ? (
|
||||
<button type="button" className="button-danger" onClick={() => setDeleteConfirmOpen(true)}>
|
||||
Delete profile
|
||||
</button>
|
||||
) : (
|
||||
<div className="danger-zone-confirm">
|
||||
<p>
|
||||
This permanently deletes your account -- Garmin credentials, every custom rule, and every synced
|
||||
activity -- and logs you out. This cannot be undone.
|
||||
</p>
|
||||
<label>
|
||||
Type DELETE to confirm
|
||||
<input
|
||||
type="text"
|
||||
value={deleteConfirmText}
|
||||
onChange={(e) => setDeleteConfirmText(e.target.value)}
|
||||
disabled={deleting}
|
||||
/>
|
||||
</label>
|
||||
<div className="controls">
|
||||
<button
|
||||
type="button"
|
||||
disabled={deleting}
|
||||
onClick={() => {
|
||||
setDeleteConfirmOpen(false);
|
||||
setDeleteConfirmText("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button-danger"
|
||||
disabled={deleting || deleteConfirmText !== "DELETE"}
|
||||
onClick={deleteAccount}
|
||||
>
|
||||
Permanently delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
2
go.work.sum
Normal file
2
go.work.sum
Normal file
@@ -0,0 +1,2 @@
|
||||
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
|
||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||
Reference in New Issue
Block a user