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"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"testing"
|
"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) {
|
func newTestServerWithAuth(t *testing.T, verifier auth.Verifier) (*Server, *store.DB) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
s, db, _ := newTestServer(t)
|
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+"/")
|
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())
|
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)
|
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"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -116,6 +117,36 @@ func (s *Server) syncFor(ctx context.Context, userID int64) (*appsync.Service, e
|
|||||||
return svc, nil
|
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
|
// RunIncrementalSyncForAllUsers is called on a timer (see main.go) to sync
|
||||||
// every provisioned user in turn, replacing the old single-global-Service
|
// every provisioned user in turn, replacing the old single-global-Service
|
||||||
// background loop.
|
// background loop.
|
||||||
@@ -167,6 +198,7 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Route("/profile", func(r chi.Router) {
|
r.Route("/profile", func(r chi.Router) {
|
||||||
r.Get("/", s.handleGetProfile)
|
r.Get("/", s.handleGetProfile)
|
||||||
r.Put("/", s.handleUpdateProfile)
|
r.Put("/", s.handleUpdateProfile)
|
||||||
|
r.Delete("/", s.handleDeleteProfile)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Route("/auth", func(r chi.Router) {
|
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)
|
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) {
|
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.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) {
|
func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type Verifier struct {
|
|||||||
CallbackResult auth.LoginResult
|
CallbackResult auth.LoginResult
|
||||||
CallbackErr error
|
CallbackErr error
|
||||||
EndSessionResult string // if empty, EndSessionURL returns postLogoutRedirectURL unchanged
|
EndSessionResult string // if empty, EndSessionURL returns postLogoutRedirectURL unchanged
|
||||||
|
LastIDTokenHint string // records the idTokenHint passed to the last EndSessionURL call
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ auth.Verifier = (*Verifier)(nil)
|
var _ auth.Verifier = (*Verifier)(nil)
|
||||||
@@ -33,7 +34,8 @@ func (v *Verifier) HandleCallback(ctx context.Context, txn auth.TxnState, query
|
|||||||
return v.CallbackResult, nil
|
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 != "" {
|
if v.EndSessionResult != "" {
|
||||||
return v.EndSessionResult
|
return v.EndSessionResult
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"slices"
|
||||||
|
|
||||||
"github.com/coreos/go-oidc/v3/oidc"
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
@@ -24,7 +25,11 @@ type Verifier interface {
|
|||||||
HandleCallback(ctx context.Context, txn TxnState, query url.Values) (LoginResult, error)
|
HandleCallback(ctx context.Context, txn TxnState, query url.Values) (LoginResult, error)
|
||||||
// EndSessionURL builds the identity provider's logout URL, redirecting
|
// EndSessionURL builds the identity provider's logout URL, redirecting
|
||||||
// back to postLogoutRedirectURL once Keycloak's own session is cleared.
|
// 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.
|
// LoginResult is what a completed callback exchange resolves to.
|
||||||
@@ -55,12 +60,7 @@ type idTokenClaims struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c idTokenClaims) hasRole(required string) bool {
|
func (c idTokenClaims) hasRole(required string) bool {
|
||||||
for _, r := range c.RealmAccess.Roles {
|
return slices.Contains(c.RealmAccess.Roles, required)
|
||||||
if r == required {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type oidcVerifier struct {
|
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{}, fmt.Errorf("decode id_token claims: %w", err)
|
||||||
}
|
}
|
||||||
return LoginResult{
|
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),
|
Authorized: claims.hasRole(v.requiredRole),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *oidcVerifier) EndSessionURL(postLogoutRedirectURL string) string {
|
func (v *oidcVerifier) EndSessionURL(postLogoutRedirectURL, idTokenHint string) string {
|
||||||
var discovery struct {
|
var discovery struct {
|
||||||
EndSessionEndpoint string `json:"end_session_endpoint"`
|
EndSessionEndpoint string `json:"end_session_endpoint"`
|
||||||
}
|
}
|
||||||
@@ -154,6 +154,9 @@ func (v *oidcVerifier) EndSessionURL(postLogoutRedirectURL string) string {
|
|||||||
q := u.Query()
|
q := u.Query()
|
||||||
q.Set("client_id", v.oauth2Config.ClientID)
|
q.Set("client_id", v.oauth2Config.ClientID)
|
||||||
q.Set("post_logout_redirect_uri", postLogoutRedirectURL)
|
q.Set("post_logout_redirect_uri", postLogoutRedirectURL)
|
||||||
|
if idTokenHint != "" {
|
||||||
|
q.Set("id_token_hint", idTokenHint)
|
||||||
|
}
|
||||||
u.RawQuery = q.Encode()
|
u.RawQuery = q.Encode()
|
||||||
return u.String()
|
return u.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,83 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"testing"
|
"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) {
|
func TestIDTokenClaims_HasRole(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
// Package auth implements geniusrun's own login gate: an OIDC Authorization
|
// Package auth implements geniusrun's own login gate: an OIDC Authorization
|
||||||
// Code flow against an existing Keycloak realm (see oidc.go), backed by a
|
// Code flow against an existing Keycloak realm (see oidc.go), backed by a
|
||||||
// signed session cookie geniusrun mints itself (this file) and a chi
|
// signed session cookie geniusrun mints itself (this file) and a chi
|
||||||
// middleware that checks it (middleware.go). Keycloak's own tokens are never
|
// middleware that checks it (middleware.go). Keycloak's own access/refresh
|
||||||
// stored or refreshed -- once HandleCallback verifies the ID token and role,
|
// tokens are never stored or refreshed -- once HandleCallback verifies the
|
||||||
// only this package's own cookie matters for subsequent requests.
|
// 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
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -26,17 +32,21 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Claims identifies the authenticated user, carried in the signed session
|
// 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 {
|
type Claims struct {
|
||||||
Sub string
|
Sub string
|
||||||
Name string
|
Name string
|
||||||
Email string
|
Email string
|
||||||
|
IDToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
type sessionClaims struct {
|
type sessionClaims struct {
|
||||||
Sub string `json:"sub"`
|
Sub string `json:"sub"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
IDToken string `json:"id_token,omitempty"`
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,9 +68,10 @@ type txnClaims struct {
|
|||||||
func MintSessionCookie(claims Claims, secret []byte, duration time.Duration, secure bool) (*http.Cookie, error) {
|
func MintSessionCookie(claims Claims, secret []byte, duration time.Duration, secure bool) (*http.Cookie, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, sessionClaims{
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, sessionClaims{
|
||||||
Sub: claims.Sub,
|
Sub: claims.Sub,
|
||||||
Name: claims.Name,
|
Name: claims.Name,
|
||||||
Email: claims.Email,
|
Email: claims.Email,
|
||||||
|
IDToken: claims.IDToken,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
IssuedAt: jwt.NewNumericDate(now),
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
ExpiresAt: jwt.NewNumericDate(now.Add(duration)),
|
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 {
|
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{}, 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.
|
// 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!"
|
const testSecret = "test-secret-at-least-32-bytes-long!"
|
||||||
|
|
||||||
func TestMintAndParseSessionCookie(t *testing.T) {
|
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)
|
cookie, err := MintSessionCookie(claims, []byte(testSecret), time.Hour, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("mint: %v", err)
|
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")
|
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.
|
-- below) in one transaction when a new account signs up.
|
||||||
CREATE TABLE profile (
|
CREATE TABLE profile (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
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',
|
name TEXT NOT NULL DEFAULT 'Default',
|
||||||
garmin_email TEXT NOT NULL DEFAULT '',
|
garmin_email TEXT NOT NULL DEFAULT '',
|
||||||
garmin_password 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.
|
-- recursive AND/OR condition tree evaluated by internal/classify.
|
||||||
CREATE TABLE workout_kinds (
|
CREATE TABLE workout_kinds (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
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,
|
name TEXT NOT NULL,
|
||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
color 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 --
|
-- synced activity log is the history. No user_id column of its own --
|
||||||
-- ownership is checked via a JOIN to workout_kinds.
|
-- ownership is checked via a JOIN to workout_kinds.
|
||||||
CREATE TABLE workout_type_paces (
|
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_min_sec_per_km REAL,
|
||||||
pace_max_sec_per_km REAL,
|
pace_max_sec_per_km REAL,
|
||||||
hr_min_pct_hrr REAL,
|
hr_min_pct_hrr REAL,
|
||||||
@@ -118,7 +118,7 @@ CREATE TABLE workout_type_paces (
|
|||||||
-- instead of storing a redundant copy.
|
-- instead of storing a redundant copy.
|
||||||
CREATE TABLE activities (
|
CREATE TABLE activities (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
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,
|
garmin_activity_id INTEGER NOT NULL,
|
||||||
-- Derived at sync time from Garmin's own eventType.typeKey=="race" --
|
-- Derived at sync time from Garmin's own eventType.typeKey=="race" --
|
||||||
-- a hard fact, not a rule the user tunes (see internal/classify's
|
-- 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.
|
-- re-walking years of already-known history on every call.
|
||||||
CREATE TABLE sync_state (
|
CREATE TABLE sync_state (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
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,
|
earliest_synced_date TEXT,
|
||||||
backfill_complete INTEGER NOT NULL DEFAULT 0,
|
backfill_complete INTEGER NOT NULL DEFAULT 0,
|
||||||
UNIQUE(user_id)
|
UNIQUE(user_id)
|
||||||
@@ -229,7 +229,7 @@ CREATE TABLE sync_state (
|
|||||||
-- status the frontend polls.
|
-- status the frontend polls.
|
||||||
CREATE TABLE sync_runs (
|
CREATE TABLE sync_runs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
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')),
|
kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental','full')),
|
||||||
started_at TEXT NOT NULL,
|
started_at TEXT NOT NULL,
|
||||||
finished_at TEXT,
|
finished_at TEXT,
|
||||||
|
|||||||
@@ -119,3 +119,17 @@ func (db *DB) ProvisionUser(ctx context.Context, oidcSub, displayName string) (i
|
|||||||
|
|
||||||
return userID, tx.Commit()
|
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")
|
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
|
```sql
|
||||||
CREATE TABLE profile (
|
CREATE TABLE profile (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
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',
|
name TEXT NOT NULL DEFAULT 'Default',
|
||||||
garmin_email TEXT NOT NULL DEFAULT '',
|
garmin_email TEXT NOT NULL DEFAULT '',
|
||||||
garmin_password TEXT NOT NULL DEFAULT '',
|
garmin_password TEXT NOT NULL DEFAULT '',
|
||||||
@@ -87,7 +87,7 @@ CREATE TABLE profile (
|
|||||||
```sql
|
```sql
|
||||||
CREATE TABLE workout_kinds (
|
CREATE TABLE workout_kinds (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
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,
|
name TEXT NOT NULL,
|
||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
color TEXT NOT NULL DEFAULT '',
|
color TEXT NOT NULL DEFAULT '',
|
||||||
@@ -104,7 +104,7 @@ CREATE TABLE workout_kinds (
|
|||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE workout_type_paces (
|
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_min_sec_per_km REAL,
|
||||||
pace_max_sec_per_km REAL,
|
pace_max_sec_per_km REAL,
|
||||||
hr_min_pct_hrr REAL,
|
hr_min_pct_hrr REAL,
|
||||||
@@ -117,7 +117,7 @@ CREATE TABLE workout_type_paces (
|
|||||||
```sql
|
```sql
|
||||||
CREATE TABLE activities (
|
CREATE TABLE activities (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
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,
|
garmin_activity_id INTEGER NOT NULL,
|
||||||
-- Derived at sync time from Garmin's own eventType.typeKey=="race" --
|
-- Derived at sync time from Garmin's own eventType.typeKey=="race" --
|
||||||
-- a hard fact, not a rule the user tunes (see internal/classify's
|
-- 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
|
```sql
|
||||||
CREATE TABLE sync_state (
|
CREATE TABLE sync_state (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
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,
|
earliest_synced_date TEXT,
|
||||||
backfill_complete INTEGER NOT NULL DEFAULT 0,
|
backfill_complete INTEGER NOT NULL DEFAULT 0,
|
||||||
UNIQUE(user_id)
|
UNIQUE(user_id)
|
||||||
@@ -239,7 +239,7 @@ CREATE TABLE sync_state (
|
|||||||
```sql
|
```sql
|
||||||
CREATE TABLE sync_runs (
|
CREATE TABLE sync_runs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
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')),
|
kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental','full')),
|
||||||
started_at TEXT NOT NULL,
|
started_at TEXT NOT NULL,
|
||||||
finished_at TEXT,
|
finished_at TEXT,
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ for that history) and remove it from here once a spec exists.
|
|||||||
## Backlog
|
## 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
|
- 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)
|
- 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
|
- 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 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)
|
- 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
|
- add icon to indicate if an activity has an associated workout
|
||||||
|
- modern error displays: have error messages displayed in an ephemeral window
|
||||||
|
|
||||||
## Someday / maybe
|
## 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;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.danger-zone-confirm {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-zone-confirm p {
|
||||||
|
margin: 0;
|
||||||
|
color: #9aa0ab;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-pills {
|
.filter-pills {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ export const api = {
|
|||||||
getProfile: () => request<Profile>("/api/profile"),
|
getProfile: () => request<Profile>("/api/profile"),
|
||||||
updateProfile: (profile: Profile) =>
|
updateProfile: (profile: Profile) =>
|
||||||
request<Profile>("/api/profile", { method: "PUT", body: JSON.stringify(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
|
// Review queue -- cursor-paginated: pass the previous page's next_cursor
|
||||||
// as `before` to fetch the next one. Fetching every activity's laps and
|
// as `before` to fetch the next one. Fetching every activity's laps and
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { api } from "../api/client";
|
import { api, BASE_URL } from "../api/client";
|
||||||
import { ColorField } from "../components/ColorField";
|
import { ColorField } from "../components/ColorField";
|
||||||
import { GarminConnection } from "../components/GarminConnection";
|
import { GarminConnection } from "../components/GarminConnection";
|
||||||
import { NullableNumberField } from "../components/NullableNumberField";
|
import { NullableNumberField } from "../components/NullableNumberField";
|
||||||
@@ -47,6 +47,9 @@ export function Profile({ onSaved }: { onSaved?: (p: ProfileType) => void }) {
|
|||||||
const profileRef = useRef<ProfileType | null>(null);
|
const profileRef = useRef<ProfileType | null>(null);
|
||||||
profileRef.current = profile;
|
profileRef.current = profile;
|
||||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||||
|
const [deleteConfirmText, setDeleteConfirmText] = useState("");
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getProfile().then(setProfile).catch((e) => setError(String(e)));
|
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) {
|
if (!profile) {
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
@@ -240,6 +263,51 @@ export function Profile({ onSaved }: { onSaved?: (p: ProfileType) => void }) {
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<TrainingTypesCard />
|
<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>
|
</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