From 1d8b04e279475cef4a135269e952e2e98234530e Mon Sep 17 00:00:00 2001 From: xarmian Date: Sun, 5 Jul 2026 18:44:26 -0400 Subject: [PATCH] feat(server): expose password_set on /auth/me + TS User type (#819) The delete-account UI must branch between a password prompt (self-host or any user with a password) and a confirm-only flow (OAuth-only users with no password). The client had no signal for this: oauth_providers is not a valid proxy since a user can have both a password and linked OAuth. Add "password_set": user.HasPassword() to the /auth/me response map and password_set?: boolean to the TS User interface. A handler test asserts the field for both a password user (true, via bootstrap) and an OAuth-only user (false, via CreateOAuthUser). Closes TASK-1957. Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST --- internal/server/handlers_auth.go | 1 + internal/server/handlers_auth_test.go | 39 +++++++++++++++++++++++++++ web/src/lib/types/index.ts | 1 + 3 files changed, 41 insertions(+) diff --git a/internal/server/handlers_auth.go b/internal/server/handlers_auth.go index 384495f8..b00e4c77 100644 --- a/internal/server/handlers_auth.go +++ b/internal/server/handlers_auth.go @@ -904,6 +904,7 @@ func (s *Server) handleGetCurrentUser(w http.ResponseWriter, r *http.Request) { "role": user.Role, "avatar_url": user.AvatarURL, "totp_enabled": user.TOTPEnabled, + "password_set": user.HasPassword(), "created_at": user.CreatedAt, "updated_at": user.UpdatedAt, } diff --git a/internal/server/handlers_auth_test.go b/internal/server/handlers_auth_test.go index 7f9934a0..897c207c 100644 --- a/internal/server/handlers_auth_test.go +++ b/internal/server/handlers_auth_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/PerpetualSoftware/pad/internal/models" ) @@ -410,6 +411,44 @@ func TestAuthMeEndpoint(t *testing.T) { if user["email"] != "me@test.com" { t.Errorf("expected email 'me@test.com', got %v", user["email"]) } + // A bootstrapped user set a password, so password_set must be true. The + // delete-account UI keys off this to show a password prompt vs a + // confirm-only flow (TASK-1957). + if set, ok := user["password_set"].(bool); !ok || !set { + t.Errorf("expected password_set=true for a password user, got %v", user["password_set"]) + } +} + +// TestAuthMeReportsPasswordSetForOAuthUser pins the OAuth-only branch of +// TASK-1957: a user created via OAuth (no password) must report +// password_set=false so the client can offer confirm-only account deletion. +func TestAuthMeReportsPasswordSetForOAuthUser(t *testing.T) { + srv := testServer(t) + // Bootstrap a first user so the instance is initialized and auth is enforced. + bootstrapFirstUser(t, srv, "admin@test.com", "Admin") + + oauthUser, err := srv.store.CreateOAuthUser("oauth@test.com", "OAuth Only", "") + if err != nil { + t.Fatalf("CreateOAuthUser: %v", err) + } + if oauthUser.HasPassword() { + t.Fatal("expected freshly created OAuth user to have no password") + } + token, err := srv.store.CreateSession(oauthUser.ID, "go-test", "192.0.2.1", "", 24*time.Hour) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + + rr := doRequestWithCookie(srv, "GET", "/api/v1/auth/me", nil, token) + if rr.Code != http.StatusOK { + t.Fatalf("me: expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + var user map[string]interface{} + parseJSON(t, rr, &user) + if set, ok := user["password_set"].(bool); !ok || set { + t.Errorf("expected password_set=false for an OAuth-only user, got %v", user["password_set"]) + } } func TestDuplicateRegistration(t *testing.T) { diff --git a/web/src/lib/types/index.ts b/web/src/lib/types/index.ts index aa7d378c..e09086ca 100644 --- a/web/src/lib/types/index.ts +++ b/web/src/lib/types/index.ts @@ -9,6 +9,7 @@ export interface User { avatar_url?: string; oauth_providers?: string[]; totp_enabled?: boolean; + password_set?: boolean; created_at: string; updated_at: string; }