diff --git a/Dockerfile b/Dockerfile index 32b3ec16..590f2b75 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,4 +30,4 @@ EXPOSE 7777 VOLUME /data ENTRYPOINT ["pad"] -CMD ["serve"] +CMD ["server", "start"] diff --git a/internal/models/user.go b/internal/models/user.go index b3c63bb4..e8e30219 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -1,6 +1,9 @@ package models -import "time" +import ( + "encoding/json" + "time" +) // User represents a registered user in the system. type User struct { @@ -18,10 +21,33 @@ type User struct { PlanExpiresAt string `json:"plan_expires_at,omitempty"` StripeCustomerID string `json:"-"` // Never serialized PlanOverrides string `json:"plan_overrides,omitempty"` // JSON overrides for per-user limits + OAuthProviders string `json:"-"` // JSON array of linked providers, e.g. ["github","google"] CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } +// GetOAuthProviders parses the JSON oauth_providers field into a string slice. +func (u *User) GetOAuthProviders() []string { + if u.OAuthProviders == "" { + return nil + } + var providers []string + if err := json.Unmarshal([]byte(u.OAuthProviders), &providers); err != nil { + return nil + } + return providers +} + +// HasOAuthProvider returns true if the user has linked the given provider. +func (u *User) HasOAuthProvider(provider string) bool { + for _, p := range u.GetOAuthProviders() { + if p == provider { + return true + } + } + return false +} + // UserCreate is the input for registering a new user. type UserCreate struct { Email string `json:"email"` diff --git a/internal/server/handlers_account.go b/internal/server/handlers_account.go index f77fc911..2ffc61b2 100644 --- a/internal/server/handlers_account.go +++ b/internal/server/handlers_account.go @@ -1,9 +1,11 @@ package server import ( + "context" "encoding/json" "log/slog" "net/http" + "time" "github.com/xarmian/pad/internal/models" "golang.org/x/crypto/bcrypt" @@ -55,32 +57,31 @@ func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) { return } - // Delete all owned workspaces + // Delete all owned workspaces, sessions, and the user atomically. + // If any workspace deletion fails, the entire operation is aborted. workspaces, err := s.store.GetUserWorkspaces(user.ID) if err != nil { writeInternalError(w, err) return } + + var ownedSlugs []string for _, ws := range workspaces { if ws.OwnerID == user.ID { - if err := s.store.DeleteWorkspace(ws.Slug); err != nil { - slog.Error("delete account: failed to delete workspace", "workspace", ws.Slug, "error", err) - } + ownedSlugs = append(ownedSlugs, ws.Slug) } } - // Revoke all sessions - _ = s.store.DeleteUserSessions(user.ID) - - // Delete the user - if err := s.store.DeleteUser(user.ID); err != nil { - writeInternalError(w, err) + if err := s.store.DeleteAccountAtomic(user.ID, ownedSlugs); err != nil { + slog.Error("delete account: atomic deletion failed", "user_id", user.ID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", + "Account deletion failed. No data was removed. Please try again or contact support.") return } // Clear session cookie http.SetCookie(w, &http.Cookie{ - Name: sessionCookie, + Name: sessionCookieName(s.secureCookies), Value: "", Path: "/", MaxAge: -1, @@ -101,7 +102,8 @@ func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) { // --- Data Export (GDPR Article 20 — Right to Portability) --- // handleExportAccount handles GET /api/v1/auth/export. -// Returns all user data as a JSON object. +// Streams user data as JSON, processing one workspace at a time to avoid +// loading everything into memory. Enforces a 60-second timeout. func (s *Server) handleExportAccount(w http.ResponseWriter, r *http.Request) { user := currentUser(r) if user == nil { @@ -112,30 +114,51 @@ func (s *Server) handleExportAccount(w http.ResponseWriter, r *http.Request) { return } - // Collect all user data - export := map[string]interface{}{ - "user": map[string]interface{}{ - "id": user.ID, - "email": user.Email, - "username": user.Username, - "name": user.Name, - "role": user.Role, - "plan": user.Plan, - "totp_enabled": user.TOTPEnabled, - "created_at": user.CreatedAt, - "updated_at": user.UpdatedAt, - }, - } + // Enforce a 60-second timeout for the entire export + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() - // Export owned workspaces with all items + // Prefetch workspace list (small) before starting the streaming response workspaces, err := s.store.GetUserWorkspaces(user.ID) if err != nil { writeInternalError(w, err) return } - var wsExports []interface{} - for _, ws := range workspaces { + // Stream the response — once we start writing, we can't send error status codes + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Disposition", "attachment; filename=\"pad-export.json\"") + w.WriteHeader(http.StatusOK) + + enc := json.NewEncoder(w) + + // Write opening structure + w.Write([]byte("{\n \"user\": ")) + enc.Encode(map[string]interface{}{ + "id": user.ID, + "email": user.Email, + "username": user.Username, + "name": user.Name, + "role": user.Role, + "plan": user.Plan, + "totp_enabled": user.TOTPEnabled, + "created_at": user.CreatedAt, + "updated_at": user.UpdatedAt, + }) + + w.Write([]byte(",\n \"workspaces\": [\n")) + + for i, ws := range workspaces { + // Check timeout between workspaces + if ctx.Err() != nil { + slog.Warn("export timeout", "user_id", user.ID, "workspaces_exported", i) + break + } + + if i > 0 { + w.Write([]byte(",\n")) + } + wsData := map[string]interface{}{ "id": ws.ID, "name": ws.Name, @@ -146,23 +169,32 @@ func (s *Server) handleExportAccount(w http.ResponseWriter, r *http.Request) { // Only export full data for owned workspaces if ws.OwnerID == user.ID { - // Get collections collections, _ := s.store.ListCollections(ws.ID) wsData["collections"] = collections - // Get all items - items, _ := s.store.ListItems(ws.ID, models.ItemListParams{IncludeArchived: true}) - wsData["items"] = items + // Stream items per workspace (each workspace loaded individually, then GC'd) + items, err := s.store.ListItems(ws.ID, models.ItemListParams{IncludeArchived: true}) + if err != nil { + slog.Error("export: failed to list items", "workspace", ws.Slug, "error", err) + wsData["items"] = []interface{}{} + wsData["export_error"] = "failed to export items" + } else { + wsData["items"] = items + } } - wsExports = append(wsExports, wsData) - } - export["workspaces"] = wsExports + w.Write([]byte(" ")) + enc.Encode(wsData) - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Content-Disposition", "attachment; filename=\"pad-export.json\"") - w.WriteHeader(http.StatusOK) - enc := json.NewEncoder(w) - enc.SetIndent("", " ") - enc.Encode(export) + // Flush after each workspace to free memory and show progress + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + } + + w.Write([]byte("\n ]\n}\n")) + + if f, ok := w.(http.Flusher); ok { + f.Flush() + } } diff --git a/internal/server/handlers_admin_users.go b/internal/server/handlers_admin_users.go index 89df4192..5aa63496 100644 --- a/internal/server/handlers_admin_users.go +++ b/internal/server/handlers_admin_users.go @@ -4,7 +4,6 @@ import ( "encoding/json" "net/http" "strconv" - "strings" "github.com/go-chi/chi/v5" "github.com/xarmian/pad/internal/models" @@ -20,44 +19,47 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) { return } - users, err := s.store.ListUsers() + limit := 50 + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + limit = n + } + } + offset := 0 + if v := r.URL.Query().Get("offset"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + offset = n + } + } + + result, err := s.store.SearchUsers(store.AdminUserSearchParams{ + Query: r.URL.Query().Get("q"), + Plan: r.URL.Query().Get("plan"), + Limit: limit, + Offset: offset, + }) if err != nil { writeInternalError(w, err) return } - // Filter by plan if specified - planFilter := r.URL.Query().Get("plan") - searchQuery := strings.ToLower(r.URL.Query().Get("q")) - type adminUser struct { - ID string `json:"id"` - Email string `json:"email"` - Username string `json:"username"` - Name string `json:"name"` - Role string `json:"role"` - Plan string `json:"plan"` - PlanExpiresAt string `json:"plan_expires_at,omitempty"` - PlanOverrides string `json:"plan_overrides,omitempty"` - TOTPEnabled bool `json:"totp_enabled"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID string `json:"id"` + Email string `json:"email"` + Username string `json:"username"` + Name string `json:"name"` + Role string `json:"role"` + Plan string `json:"plan"` + PlanExpiresAt string `json:"plan_expires_at,omitempty"` + PlanOverrides string `json:"plan_overrides,omitempty"` + TOTPEnabled bool `json:"totp_enabled"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } - var result []adminUser - for _, u := range users { - // Filter - if planFilter != "" && u.Plan != planFilter { - continue - } - if searchQuery != "" && - !strings.Contains(strings.ToLower(u.Email), searchQuery) && - !strings.Contains(strings.ToLower(u.Name), searchQuery) && - !strings.Contains(strings.ToLower(u.Username), searchQuery) { - continue - } - - result = append(result, adminUser{ + users := make([]adminUser, 0, len(result.Users)) + for _, u := range result.Users { + users = append(users, adminUser{ ID: u.ID, Email: u.Email, Username: u.Username, @@ -72,11 +74,10 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) { }) } - if result == nil { - result = []adminUser{} - } - - writeJSON(w, http.StatusOK, result) + writeJSON(w, http.StatusOK, map[string]interface{}{ + "users": users, + "total": result.Total, + }) } // handleAdminGetUser returns a single user with full detail. diff --git a/internal/server/handlers_auth.go b/internal/server/handlers_auth.go index e0cc9232..2269c9a2 100644 --- a/internal/server/handlers_auth.go +++ b/internal/server/handlers_auth.go @@ -14,7 +14,6 @@ import ( ) const ( - sessionCookie = "pad_session" webSessionTTL = 7 * 24 * time.Hour // 7 days for web sessions cliSessionTTL = 30 * 24 * time.Hour // 30 days for CLI tokens @@ -27,6 +26,25 @@ const ( var emailRegexp = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`) +// sessionCookieName returns the session cookie name. When running over TLS +// (secureCookies=true), the __Host- prefix is used to prevent subdomain +// cookie injection attacks. +func sessionCookieName(secure bool) string { + if secure { + return "__Host-pad_session" + } + return "pad_session" +} + +// csrfCookieName returns the CSRF cookie name. Uses the same __Host- prefix +// strategy as the session cookie. +func csrfCookieName(secure bool) string { + if secure { + return "__Host-pad_csrf" + } + return "pad_csrf" +} + func sessionUserPayload(user *models.User) map[string]interface{} { if user == nil { return nil @@ -128,9 +146,13 @@ func requestIsLoopback(r *http.Request) bool { // (User-Agent check). Returns the user if valid, nil otherwise. This must be // used instead of calling ValidateSession directly to ensure binding is enforced. func (s *Server) validateSessionCookie(r *http.Request) *models.User { - cookie, err := r.Cookie(sessionCookie) + cookie, err := r.Cookie(sessionCookieName(s.secureCookies)) if err != nil { - return nil + // Fallback: check the unprefixed name for sessions created before the upgrade + cookie, err = r.Cookie("pad_session") + if err != nil { + return nil + } } session, _ := s.store.ValidateSession(cookie.Value) if session == nil || session.User == nil { @@ -151,7 +173,7 @@ func (s *Server) createAuthSession(w http.ResponseWriter, r *http.Request, user } http.SetCookie(w, &http.Cookie{ - Name: sessionCookie, + Name: sessionCookieName(s.secureCookies), Value: token, Path: "/", MaxAge: int(ttl.Seconds()), @@ -171,8 +193,13 @@ func (s *Server) createAuthSession(w http.ResponseWriter, r *http.Request, user // on the server host or from inside the container. func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) { if s.cloudMode { - writeError(w, http.StatusForbidden, "forbidden", "Bootstrap is disabled in cloud mode — users register via OAuth or invitation") - return + // Allow bootstrap in cloud mode ONLY when no users exist yet. + // A fresh cloud instance needs at least one admin before OAuth can work. + count, err := s.store.UserCount() + if err != nil || count > 0 { + writeError(w, http.StatusForbidden, "forbidden", "Bootstrap is disabled in cloud mode — users register via OAuth or invitation") + return + } } if !requestIsLoopback(r) { writeError(w, http.StatusForbidden, "forbidden", "Bootstrap is only allowed from localhost on the server host") @@ -507,7 +534,7 @@ func (s *Server) handleSessionCheck(w http.ResponseWriter, r *http.Request) { // It handles both cookie-based sessions (web) and Bearer token sessions (CLI). func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { // Revoke cookie-based session - if cookie, err := r.Cookie(sessionCookie); err == nil { + if cookie, err := r.Cookie(sessionCookieName(s.secureCookies)); err == nil { _ = s.store.DeleteSession(cookie.Value) } @@ -520,7 +547,7 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { } http.SetCookie(w, &http.Cookie{ - Name: sessionCookie, + Name: sessionCookieName(s.secureCookies), Value: "", Path: "/", MaxAge: -1, @@ -551,7 +578,7 @@ func (s *Server) handleGetCurrentUser(w http.ResponseWriter, r *http.Request) { return } - writeJSON(w, http.StatusOK, map[string]interface{}{ + resp := map[string]interface{}{ "id": user.ID, "email": user.Email, "username": user.Username, @@ -560,7 +587,23 @@ func (s *Server) handleGetCurrentUser(w http.ResponseWriter, r *http.Request) { "avatar_url": user.AvatarURL, "created_at": user.CreatedAt, "updated_at": user.UpdatedAt, - }) + } + + // Include Stripe customer ID when present (used by pad-cloud sidecar + // to create billing portal sessions without accepting customer_id from + // the client, preventing users from accessing other users' portals). + if user.StripeCustomerID != "" { + resp["stripe_customer_id"] = user.StripeCustomerID + } + + // Include linked OAuth providers (used by settings UI for link/unlink) + if providers := user.GetOAuthProviders(); len(providers) > 0 { + resp["oauth_providers"] = providers + } else { + resp["oauth_providers"] = []string{} + } + + writeJSON(w, http.StatusOK, resp) } // handleUpdateCurrentUser updates the authenticated user's profile. @@ -786,7 +829,7 @@ func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) { } http.SetCookie(w, &http.Cookie{ - Name: sessionCookie, + Name: sessionCookieName(s.secureCookies), Value: sessionToken, Path: "/", MaxAge: int(webSessionTTL.Seconds()), diff --git a/internal/server/handlers_cloud.go b/internal/server/handlers_cloud.go index cf4dacd5..2320ae8d 100644 --- a/internal/server/handlers_cloud.go +++ b/internal/server/handlers_cloud.go @@ -133,15 +133,21 @@ func (s *Server) handleOAuthLogin(w http.ResponseWriter, r *http.Request) { return } isNewUser = true + + // Auto-link the provider for new OAuth users + if err := s.store.AddOAuthProvider(user.ID, input.Provider); err != nil { + slog.Error("oauth-login: failed to link provider", "error", err, "user_id", user.ID) + } + slog.Info("oauth-login: created new user", "provider", input.Provider, "email", input.Email, "user_id", user.ID) // Auto-create default workspace for new OAuth users s.autoCreateWorkspace(user) } else { - // Existing user — implicit account link. - // Block OAuth login if the user has 2FA enabled — OAuth must not bypass 2FA. - if user.TOTPEnabled { - slog.Warn("oauth-login: blocked — existing user has 2FA enabled", + // Existing user — require explicit provider linking. + // The user must have previously linked this provider from their settings. + if !user.HasOAuthProvider(input.Provider) { + slog.Warn("oauth-login: rejected — provider not linked", "provider", input.Provider, "email", input.Email, "user_id", user.ID, @@ -149,20 +155,13 @@ func (s *Server) handleOAuthLogin(w http.ResponseWriter, r *http.Request) { s.logAuditEventForUser(models.ActionOAuthLoginFailed, r, user.ID, auditMeta(map[string]string{ "provider": input.Provider, "email": input.Email, - "reason": "2fa_enabled", + "reason": "provider_not_linked", })) - writeError(w, http.StatusForbidden, "forbidden", - "This account has two-factor authentication enabled. Please sign in with your password and 2FA code, then link your OAuth provider in account settings.") + writeError(w, http.StatusForbidden, "oauth_provider_not_linked", + "An account with this email already exists. Sign in with your password and link "+input.Provider+" from account settings.") return } - // Log the implicit link for audit - slog.Info("oauth-login: existing user (account link)", - "provider", input.Provider, - "email", input.Email, - "user_id", user.ID, - ) - // Update avatar if they don't have one if user.AvatarURL == "" && input.AvatarURL != "" { avatar := input.AvatarURL @@ -275,6 +274,328 @@ func (s *Server) handleSetPlan(w http.ResponseWriter, r *http.Request) { }) } +// --- OAuth Provider Linking (TASK-504) --- + +// handleOAuthLink handles POST /api/v1/auth/oauth-link. +// Called by the pad-cloud sidecar after an OAuth flow initiated from account settings. +// Requires an active session (the user must be logged in) and links the provider. +func (s *Server) handleOAuthLink(w http.ResponseWriter, r *http.Request) { + var input struct { + Provider string `json:"provider"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + CloudSecret string `json:"cloud_secret"` + } + if err := decodeJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + // 1. Validate cloud secret + if !s.validateCloudSecret(input.CloudSecret, w) { + return + } + + // 2. Validate provider + if input.Provider != "github" && input.Provider != "google" { + writeError(w, http.StatusBadRequest, "bad_request", "provider must be 'github' or 'google'") + return + } + + // 3. Require verified email + if !input.EmailVerified { + writeError(w, http.StatusForbidden, "forbidden", "Only verified email addresses are accepted") + return + } + + // 4. Find user by email (the sidecar passes the OAuth email) + input.Email = strings.ToLower(strings.TrimSpace(input.Email)) + user, err := s.store.GetUserByEmail(input.Email) + if err != nil { + writeInternalError(w, err) + return + } + if user == nil { + writeError(w, http.StatusNotFound, "not_found", "No account found with that email") + return + } + + // 5. Check if already linked + if user.HasOAuthProvider(input.Provider) { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "provider": input.Provider, + "message": "Provider already linked", + }) + return + } + + // 6. Link the provider + if err := s.store.AddOAuthProvider(user.ID, input.Provider); err != nil { + writeInternalError(w, err) + return + } + + // 7. Audit log + s.logAuditEventForUser(models.ActionOAuthLogin, r, user.ID, auditMeta(map[string]string{ + "provider": input.Provider, + "email": input.Email, + "action": "link_provider", + })) + + slog.Info("oauth-link: provider linked", "provider", input.Provider, "user_id", user.ID) + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "provider": input.Provider, + }) +} + +// handleOAuthUnlink handles POST /api/v1/auth/oauth-unlink. +// Removes a linked OAuth provider. Requires the user to have a usable password +// (to prevent locking themselves out). +func (s *Server) handleOAuthUnlink(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + if user == nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required") + return + } + + var input struct { + Provider string `json:"provider"` + } + if err := decodeJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + if input.Provider != "github" && input.Provider != "google" { + writeError(w, http.StatusBadRequest, "bad_request", "provider must be 'github' or 'google'") + return + } + + if !user.HasOAuthProvider(input.Provider) { + writeError(w, http.StatusBadRequest, "bad_request", "Provider not linked") + return + } + + // Ensure user won't be locked out: they must have another linked + // provider remaining. All users have a password hash (OAuth users get + // a random one), so we can't distinguish "has usable password" from + // "has unusable random hash". Requiring another provider is the safe + // default. Users who set a real password via the reset flow can unlink + // their last provider since they'll still have password-based login. + // TODO: track whether the user has explicitly set a password to allow + // unlinking the last provider in that case. + providers := user.GetOAuthProviders() + hasOtherProvider := false + for _, p := range providers { + if p != input.Provider { + hasOtherProvider = true + break + } + } + if !hasOtherProvider { + writeError(w, http.StatusBadRequest, "bad_request", + "Cannot unlink your only sign-in method. Link another provider or set a password first.") + return + } + + if err := s.store.RemoveOAuthProvider(user.ID, input.Provider); err != nil { + writeInternalError(w, err) + return + } + + s.logAuditEventForUser(models.ActionOAuthLogin, r, user.ID, auditMeta(map[string]string{ + "provider": input.Provider, + "action": "unlink_provider", + })) + + slog.Info("oauth-unlink: provider unlinked", "provider", input.Provider, "user_id", user.ID) + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "provider": input.Provider, + }) +} + +// --- Stripe Customer ID (TASK-505) --- + +// handleSetStripeCustomerID handles POST /api/v1/admin/stripe-customer-id. +// Called by the pad-cloud sidecar after a Stripe checkout.completed event +// to associate a Stripe customer ID with a Pad user. +func (s *Server) handleSetStripeCustomerID(w http.ResponseWriter, r *http.Request) { + var input struct { + UserID string `json:"user_id"` + CustomerID string `json:"customer_id"` + CloudSecret string `json:"cloud_secret"` + } + if err := decodeJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + // 1. Validate cloud secret (or admin auth) + user := currentUser(r) + isAdmin := user != nil && user.Role == "admin" + if !isAdmin { + if !s.validateCloudSecret(input.CloudSecret, w) { + return + } + } + + // 2. Validate inputs + if input.UserID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "user_id is required") + return + } + if input.CustomerID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "customer_id is required") + return + } + if !strings.HasPrefix(input.CustomerID, "cus_") { + writeError(w, http.StatusBadRequest, "bad_request", "customer_id must start with 'cus_'") + return + } + + // 3. Verify user exists + targetUser, err := s.store.GetUser(input.UserID) + if err != nil { + writeInternalError(w, err) + return + } + if targetUser == nil { + writeError(w, http.StatusNotFound, "not_found", "User not found") + return + } + + // 4. Store the Stripe customer ID + if err := s.store.SetUserStripeCustomerID(input.UserID, input.CustomerID); err != nil { + writeInternalError(w, err) + return + } + + // 5. Audit log + actorID := "" + if user != nil { + actorID = user.ID + } + s.logAuditEventForUser(models.ActionPlanChanged, r, actorID, auditMeta(map[string]string{ + "target_user_id": input.UserID, + "stripe_customer_id": input.CustomerID, + "action": "set_stripe_customer_id", + })) + + slog.Info("stripe customer ID set", "user_id", input.UserID, "customer_id", input.CustomerID) + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "user_id": input.UserID, + "customer_id": input.CustomerID, + "ok": true, + }) +} + +// handleGetUserByCustomerID handles GET /api/v1/admin/user-by-customer?customer_id=cus_xxx. +// Called by the pad-cloud sidecar during Stripe subscription webhook processing +// to resolve a Stripe customer back to a Pad user. +func (s *Server) handleGetUserByCustomerID(w http.ResponseWriter, r *http.Request) { + // 1. Validate cloud secret (via header preferred, query param fallback) or admin auth. + // NOTE: query param is supported for GET convenience but may appear in access logs. + // Prefer X-Cloud-Secret header in production. + user := currentUser(r) + isAdmin := user != nil && user.Role == "admin" + if !isAdmin { + secret := r.Header.Get("X-Cloud-Secret") + if secret == "" { + secret = r.URL.Query().Get("cloud_secret") + } + if !s.validateCloudSecret(secret, w) { + return + } + } + + // 2. Validate customer_id + customerID := r.URL.Query().Get("customer_id") + if customerID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "customer_id query parameter is required") + return + } + if !strings.HasPrefix(customerID, "cus_") { + writeError(w, http.StatusBadRequest, "bad_request", "customer_id must start with 'cus_'") + return + } + + // 3. Look up user + targetUser, err := s.store.GetUserByStripeCustomerID(customerID) + if err != nil { + writeInternalError(w, err) + return + } + if targetUser == nil { + writeError(w, http.StatusNotFound, "not_found", "No user found with that Stripe customer ID") + return + } + + // 4. Return minimal user info (only what the sidecar needs) + writeJSON(w, http.StatusOK, map[string]interface{}{ + "user_id": targetUser.ID, + "email": targetUser.Email, + "plan": targetUser.Plan, + }) +} + +// --- Public Plan Limits (TASK-511) --- + +// handleGetPlanLimits returns the configured plan limits for free and pro tiers. +// GET /api/v1/plan-limits — public endpoint, no auth required. +// Used by the billing page to show actual limits instead of hardcoded values. +func (s *Server) handleGetPlanLimits(w http.ResponseWriter, r *http.Request) { + result := map[string]interface{}{ + "free": store.DefaultFreeLimits, + "pro": store.DefaultProLimits, + } + + // Override with DB-stored limits if available + features := []string{ + "workspaces", "items_per_workspace", "members_per_workspace", + "api_tokens", "storage_bytes", "webhooks", "automated_backups", + } + for _, plan := range []string{"free", "pro"} { + overrides := make(map[string]int) + for _, feature := range features { + key := "plan_limits_" + plan + "_" + feature + val, err := s.store.GetPlatformSetting(key) + if err != nil || val == "" { + continue + } + v, _ := strconv.Atoi(val) + overrides[feature] = v + } + if len(overrides) > 0 { + // Merge overrides onto defaults + defaults := store.DefaultFreeLimits + if plan == "pro" { + defaults = store.DefaultProLimits + } + merged := map[string]int{ + "workspaces": defaults.Workspaces, + "items_per_workspace": defaults.ItemsPerWorkspace, + "members_per_workspace": defaults.MembersPerWorkspace, + "api_tokens": defaults.APITokens, + "storage_bytes": defaults.StorageBytes, + "webhooks": defaults.Webhooks, + "automated_backups": defaults.AutomatedBackups, + } + for k, v := range overrides { + merged[k] = v + } + result[plan] = merged + } + } + + writeJSON(w, http.StatusOK, result) +} + // --- Plan Limit Enforcement --- // enforcePlanLimit checks a workspace-scoped plan limit and writes a 403 diff --git a/internal/server/middleware_auth.go b/internal/server/middleware_auth.go index c24efa8c..e66febb2 100644 --- a/internal/server/middleware_auth.go +++ b/internal/server/middleware_auth.go @@ -131,11 +131,14 @@ func (s *Server) SessionAuth(next http.Handler) http.Handler { return } - // Try session cookie - cookie, err := r.Cookie(sessionCookie) + // Try session cookie (with fallback to unprefixed name for upgrade path) + cookie, err := r.Cookie(sessionCookieName(s.secureCookies)) if err != nil { - next.ServeHTTP(w, r) - return + cookie, err = r.Cookie("pad_session") + if err != nil { + next.ServeHTTP(w, r) + return + } } session, err := s.store.ValidateSession(cookie.Value) @@ -157,7 +160,7 @@ func (s *Server) SessionAuth(next http.Handler) http.Handler { // This can happen when cookies expire at different times or are selectively cleared. // Skip for auth endpoints — they manage their own CSRF cookies (login sets, logout clears). if !strings.HasPrefix(r.URL.Path, "/api/v1/auth/") { - if _, csrfErr := r.Cookie(csrfCookie); csrfErr != nil { + if _, csrfErr := r.Cookie(csrfCookieName(s.secureCookies)); csrfErr != nil { setCSRFCookie(w, 7*24*60*60, s.secureCookies) } } @@ -175,9 +178,11 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler { path := r.URL.Path // Auth endpoints, share link resolution, and cloud sidecar endpoints are always exempt. - // The cloud plan endpoint uses cloud_secret in the body for authentication, - // so it must bypass RequireAuth (which runs before the handler can read the body). - if strings.HasPrefix(path, "/api/v1/auth/") || path == "/api/v1/health" || strings.HasPrefix(path, "/api/v1/health/") || strings.HasPrefix(path, "/api/v1/s/") || path == "/api/v1/admin/plan" { + // Cloud sidecar endpoints authenticate via cloud_secret (in body or header), + // so they must bypass RequireAuth which runs before handlers can read the body. + if strings.HasPrefix(path, "/api/v1/auth/") || path == "/api/v1/health" || strings.HasPrefix(path, "/api/v1/health/") || strings.HasPrefix(path, "/api/v1/s/") || + path == "/api/v1/plan-limits" || // Public endpoint for billing page + path == "/api/v1/admin/plan" || path == "/api/v1/admin/stripe-customer-id" || path == "/api/v1/admin/user-by-customer" { next.ServeHTTP(w, r) return } diff --git a/internal/server/middleware_csrf.go b/internal/server/middleware_csrf.go index 92612c31..268f1158 100644 --- a/internal/server/middleware_csrf.go +++ b/internal/server/middleware_csrf.go @@ -8,8 +8,7 @@ import ( ) const ( - csrfCookie = "pad_csrf" - csrfHeader = "X-CSRF-Token" + csrfHeader = "X-CSRF-Token" csrfTokenLen = 32 // 32 bytes = 64 hex chars ) @@ -42,7 +41,8 @@ func (s *Server) CSRFProtect(next http.Handler) http.Handler { // (login, register, bootstrap, password reset). // The cloud plan endpoint is also exempt — the sidecar authenticates // via cloud_secret in the body, not via cookies. - if strings.HasPrefix(r.URL.Path, "/api/v1/auth/") || r.URL.Path == "/api/v1/admin/plan" { + if strings.HasPrefix(r.URL.Path, "/api/v1/auth/") || + r.URL.Path == "/api/v1/admin/plan" || r.URL.Path == "/api/v1/admin/stripe-customer-id" { next.ServeHTTP(w, r) return } @@ -61,7 +61,7 @@ func (s *Server) CSRFProtect(next http.Handler) http.Handler { } // Cookie-based session: require CSRF token - cookie, err := r.Cookie(csrfCookie) + cookie, err := r.Cookie(csrfCookieName(s.secureCookies)) if err != nil || cookie.Value == "" { writeError(w, http.StatusForbidden, "csrf_error", "Missing CSRF token") return @@ -87,7 +87,7 @@ func (s *Server) CSRFProtect(next http.Handler) http.Handler { func setCSRFCookie(w http.ResponseWriter, ttl int, secure bool) { token := generateCSRFToken() http.SetCookie(w, &http.Cookie{ - Name: csrfCookie, + Name: csrfCookieName(secure), Value: token, Path: "/", MaxAge: ttl, @@ -98,15 +98,18 @@ func setCSRFCookie(w http.ResponseWriter, ttl int, secure bool) { } // clearCSRFCookie removes the CSRF cookie (e.g. on logout). +// Must clear both prefixed and unprefixed names to handle upgrades cleanly. func clearCSRFCookie(w http.ResponseWriter) { - http.SetCookie(w, &http.Cookie{ - Name: csrfCookie, - Value: "", - Path: "/", - MaxAge: -1, - HttpOnly: false, - SameSite: http.SameSiteLaxMode, - }) + for _, name := range []string{"pad_csrf", "__Host-pad_csrf"} { + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: false, + SameSite: http.SameSiteLaxMode, + }) + } } // generateCSRFToken returns a cryptographically random hex string. diff --git a/internal/server/middleware_ratelimit.go b/internal/server/middleware_ratelimit.go index 43bafc9a..88e1e7e2 100644 --- a/internal/server/middleware_ratelimit.go +++ b/internal/server/middleware_ratelimit.go @@ -80,6 +80,8 @@ type RateLimiters struct { Register *ipRateLimiter // OAuth login: per-IP (higher limit since pad-cloud sidecar calls this) OAuthLogin *ipRateLimiter + // Cloud admin: per-IP for sidecar-to-pad admin endpoints (plan, stripe, user lookup) + CloudAdmin *ipRateLimiter // API: per-user (authenticated) API *ipRateLimiter // Search: per-user or per-IP @@ -104,11 +106,17 @@ func NewRateLimiters() *RateLimiters { Rate: rate.Limit(5.0 / 3600.0), Burst: 5, }), - // OAuth login: 20 per minute per IP (sidecar calls this — higher than regular auth) + // OAuth login/link: 20 per minute per IP (sidecar calls this — higher than regular auth) OAuthLogin: newIPRateLimiter(rateLimitConfig{ Rate: rate.Limit(20.0 / 60.0), Burst: 20, }), + // Cloud admin: 30 per minute per IP for sidecar admin calls (plan changes, Stripe mapping) + // These are cloud-secret gated but rate-limited for defense in depth. + CloudAdmin: newIPRateLimiter(rateLimitConfig{ + Rate: rate.Limit(30.0 / 60.0), + Burst: 10, + }), // API: 600 requests per minute per user/IP (= 10 per second, burst 60) // Local-first tool with SSE-driven UI needs headroom for cascading refreshes. API: newIPRateLimiter(rateLimitConfig{ @@ -152,8 +160,10 @@ func (s *Server) RateLimit(next http.Handler) http.Handler { limiter = s.rateLimiters.PasswordReset case path == "/api/v1/auth/register": limiter = s.rateLimiters.Register - case path == "/api/v1/auth/oauth-login": + case path == "/api/v1/auth/oauth-login" || path == "/api/v1/auth/oauth-link": limiter = s.rateLimiters.OAuthLogin + case path == "/api/v1/auth/oauth-unlink": + limiter = s.rateLimiters.Auth // Same as login — 5/min, user-initiated default: // Other auth endpoints (session check, logout) — use general API limit limiter = s.rateLimiters.API @@ -171,6 +181,20 @@ func (s *Server) RateLimit(next http.Handler) http.Handler { return } + // Cloud admin endpoints (sidecar → pad): plan changes, Stripe mapping, user lookup + if strings.HasPrefix(path, "/api/v1/admin/") { + switch path { + case "/api/v1/admin/plan", "/api/v1/admin/stripe-customer-id", "/api/v1/admin/user-by-customer": + l := s.rateLimiters.CloudAdmin.getLimiter(ip) + if !l.Allow() { + slog.Warn("rate limited", "ip", ip, "path", path, "limiter", "cloud_admin") + writeRateLimitResponse(w, s.rateLimiters.CloudAdmin.config) + return + } + } + // Other admin endpoints fall through to general API limit below + } + // Search endpoint if path == "/api/v1/search" { key := rateLimitKey(r, ip) diff --git a/internal/server/server.go b/internal/server/server.go index d9a47a15..d8cc851e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -249,6 +249,7 @@ func (s *Server) setupRouter() { r.Get("/health", s.handleHealth) r.Get("/health/live", s.handleHealthLive) r.Get("/health/ready", s.handleHealthReady) + r.Get("/plan-limits", s.handleGetPlanLimits) // Public: billing page reads plan limits // Auth endpoints (exempt from auth middleware) r.Route("/auth", func(r chi.Router) { @@ -281,8 +282,10 @@ func (s *Server) setupRouter() { r.Delete("/tokens/{tokenID}", s.handleDeleteUserToken) r.Post("/tokens/{tokenID}/rotate", s.handleRotateUserToken) - // Cloud: OAuth login (called by pad-cloud sidecar, protected by cloud secret) + // Cloud: OAuth login/linking (called by pad-cloud sidecar, protected by cloud secret) r.Post("/oauth-login", s.handleOAuthLogin) + r.Post("/oauth-link", s.handleOAuthLink) + r.Post("/oauth-unlink", s.handleOAuthUnlink) }) // Admin endpoints (admin-only, handlers check role internally) @@ -290,7 +293,9 @@ func (s *Server) setupRouter() { r.Get("/settings", s.handleGetPlatformSettings) r.Patch("/settings", s.handleUpdatePlatformSettings) r.Post("/test-email", s.handleTestEmail) - r.Post("/plan", s.handleSetPlan) // Cloud: sidecar sets user plans; also accessible to admins + r.Post("/plan", s.handleSetPlan) // Cloud: sidecar sets user plans; also accessible to admins + r.Post("/stripe-customer-id", s.handleSetStripeCustomerID) // Cloud: sidecar stores Stripe customer ID after checkout + r.Get("/user-by-customer", s.handleGetUserByCustomerID) // Cloud: sidecar looks up user by Stripe customer ID // User management r.Get("/users", s.handleAdminListUsers) diff --git a/internal/store/limits.go b/internal/store/limits.go index 3cbe933d..af3948dd 100644 --- a/internal/store/limits.go +++ b/internal/store/limits.go @@ -5,6 +5,9 @@ import ( "fmt" "log/slog" "strconv" + "strings" + + "github.com/xarmian/pad/internal/models" ) // PlanLimits defines the limits for a billing plan tier. @@ -328,3 +331,20 @@ func (s *Store) SetUserStripeCustomerID(userID, customerID string) error { } return nil } + +// GetUserByStripeCustomerID retrieves a user by their Stripe customer ID. +// Returns nil if no user is found with the given customer ID. +func (s *Store) GetUserByStripeCustomerID(customerID string) (*models.User, error) { + customerID = strings.TrimSpace(customerID) + if customerID == "" { + return nil, nil + } + u, err := scanUser(s.db.QueryRow(s.q(`SELECT `+userColumns+` FROM users WHERE stripe_customer_id = ?`), customerID)) + if err != nil { + return nil, fmt.Errorf("get user by stripe customer id: %w", err) + } + if err := s.decryptUserTOTP(u); err != nil { + return nil, err + } + return u, nil +} diff --git a/internal/store/migrations/036_stripe_customer_index.sql b/internal/store/migrations/036_stripe_customer_index.sql new file mode 100644 index 00000000..b8fa2bb8 --- /dev/null +++ b/internal/store/migrations/036_stripe_customer_index.sql @@ -0,0 +1,3 @@ +-- Index stripe_customer_id for fast lookups during Stripe webhook processing. +-- Only non-empty values need to be indexed (most users won't have a Stripe customer ID). +CREATE INDEX IF NOT EXISTS idx_users_stripe_customer_id ON users(stripe_customer_id) WHERE stripe_customer_id != ''; diff --git a/internal/store/migrations/037_oauth_providers.sql b/internal/store/migrations/037_oauth_providers.sql new file mode 100644 index 00000000..b2c8e4ed --- /dev/null +++ b/internal/store/migrations/037_oauth_providers.sql @@ -0,0 +1,3 @@ +-- Track which OAuth providers a user has explicitly linked. +-- JSON array, e.g. ["github"] or ["github","google"]. Empty string = no providers. +ALTER TABLE users ADD COLUMN oauth_providers TEXT DEFAULT ''; diff --git a/internal/store/pgmigrations/016_stripe_customer_index.sql b/internal/store/pgmigrations/016_stripe_customer_index.sql new file mode 100644 index 00000000..b8fa2bb8 --- /dev/null +++ b/internal/store/pgmigrations/016_stripe_customer_index.sql @@ -0,0 +1,3 @@ +-- Index stripe_customer_id for fast lookups during Stripe webhook processing. +-- Only non-empty values need to be indexed (most users won't have a Stripe customer ID). +CREATE INDEX IF NOT EXISTS idx_users_stripe_customer_id ON users(stripe_customer_id) WHERE stripe_customer_id != ''; diff --git a/internal/store/pgmigrations/017_oauth_providers.sql b/internal/store/pgmigrations/017_oauth_providers.sql new file mode 100644 index 00000000..b2c8e4ed --- /dev/null +++ b/internal/store/pgmigrations/017_oauth_providers.sql @@ -0,0 +1,3 @@ +-- Track which OAuth providers a user has explicitly linked. +-- JSON array, e.g. ["github"] or ["github","google"]. Empty string = no providers. +ALTER TABLE users ADD COLUMN oauth_providers TEXT DEFAULT ''; diff --git a/internal/store/store.go b/internal/store/store.go index 12c7c418..40a63897 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -161,6 +161,8 @@ func (s *Store) migrate() error { "033_grants.sql", "034_share_links.sql", "035_plan_fields.sql", + "036_stripe_customer_index.sql", + "037_oauth_providers.sql", } for _, name := range migrations { @@ -220,6 +222,8 @@ func (s *Store) migratePostgres() error { "013_grants.sql", "014_share_links.sql", "015_plan_fields.sql", + "016_stripe_customer_index.sql", + "017_oauth_providers.sql", } for _, name := range migrations { diff --git a/internal/store/users.go b/internal/store/users.go index 21a8eec6..dc3c6d4e 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "database/sql" "encoding/hex" + "encoding/json" "fmt" "regexp" "strings" @@ -18,7 +19,7 @@ var usernameCleanRe = regexp.MustCompile(`[^a-z0-9-]+`) const bcryptCost = 12 // user SELECT columns — used by all user queries. -const userColumns = `id, email, username, name, password_hash, role, avatar_url, totp_secret, totp_enabled, recovery_codes, plan, plan_expires_at, stripe_customer_id, plan_overrides, created_at, updated_at` +const userColumns = `id, email, username, name, password_hash, role, avatar_url, totp_secret, totp_enabled, recovery_codes, plan, plan_expires_at, stripe_customer_id, plan_overrides, oauth_providers, created_at, updated_at` // scanUser scans a user row into a User struct. // Note: does NOT decrypt the TOTP secret — call store.decryptUserTOTP() after @@ -30,7 +31,7 @@ func scanUser(row interface{ Scan(...interface{}) error }) (*models.User, error) err := row.Scan( &u.ID, &u.Email, &u.Username, &u.Name, &u.PasswordHash, &u.Role, &u.AvatarURL, &u.TOTPSecret, &u.TOTPEnabled, &u.RecoveryCodes, - &u.Plan, &u.PlanExpiresAt, &u.StripeCustomerID, &u.PlanOverrides, + &u.Plan, &u.PlanExpiresAt, &u.StripeCustomerID, &u.PlanOverrides, &u.OAuthProviders, &createdAt, &updatedAt, ) if err == sql.ErrNoRows { @@ -210,6 +211,83 @@ func (s *Store) ListUsers() ([]models.User, error) { return result, rows.Err() } +// AdminUserSearchParams holds parameters for the admin user search query. +type AdminUserSearchParams struct { + Query string // Search in email, name, username + Plan string // Filter by plan (free, pro, self-hosted) + Limit int // Max results (default 50, max 200) + Offset int // Pagination offset +} + +// AdminUserSearchResult holds the paginated search results. +type AdminUserSearchResult struct { + Users []models.User `json:"users"` + Total int `json:"total"` +} + +// SearchUsers returns a filtered, paginated list of users for admin management. +// Filters and pagination are pushed into SQL to avoid loading all users into memory. +func (s *Store) SearchUsers(params AdminUserSearchParams) (*AdminUserSearchResult, error) { + if params.Limit <= 0 || params.Limit > 200 { + params.Limit = 50 + } + if params.Offset < 0 { + params.Offset = 0 + } + + var where []string + var args []interface{} + + if params.Query != "" { + q := "%" + strings.ToLower(params.Query) + "%" + where = append(where, "(LOWER(email) LIKE ? OR LOWER(name) LIKE ? OR LOWER(username) LIKE ?)") + args = append(args, q, q, q) + } + if params.Plan != "" { + where = append(where, "plan = ?") + args = append(args, params.Plan) + } + + whereClause := "" + if len(where) > 0 { + whereClause = "WHERE " + strings.Join(where, " AND ") + } + + // Get total count + countQuery := s.q("SELECT COUNT(*) FROM users " + whereClause) + var total int + if err := s.db.QueryRow(countQuery, args...).Scan(&total); err != nil { + return nil, fmt.Errorf("search users count: %w", err) + } + + // Get paginated results + query := s.q("SELECT " + userColumns + " FROM users " + whereClause + " ORDER BY created_at DESC LIMIT ? OFFSET ?") + fullArgs := append(args, params.Limit, params.Offset) + rows, err := s.db.Query(query, fullArgs...) + if err != nil { + return nil, fmt.Errorf("search users: %w", err) + } + defer rows.Close() + + var users []models.User + for rows.Next() { + u, err := scanUser(rows) + if err != nil { + return nil, fmt.Errorf("search users scan: %w", err) + } + _ = s.decryptUserTOTP(u) + users = append(users, *u) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("search users rows: %w", err) + } + + return &AdminUserSearchResult{ + Users: users, + Total: total, + }, nil +} + // UserCount returns the total number of registered users. func (s *Store) UserCount() (int, error) { var count int @@ -254,6 +332,71 @@ func (s *Store) CreateOAuthUser(email, name, avatarURL string) (*models.User, er return s.GetUser(id) } +// AddOAuthProvider adds a provider to the user's oauth_providers list. +// No-op if the provider is already linked. +func (s *Store) AddOAuthProvider(userID, provider string) error { + user, err := s.GetUser(userID) + if err != nil { + return fmt.Errorf("add oauth provider: %w", err) + } + if user == nil { + return fmt.Errorf("add oauth provider: user not found") + } + + if user.HasOAuthProvider(provider) { + return nil // Already linked + } + + providers := user.GetOAuthProviders() + providers = append(providers, provider) + data, err := json.Marshal(providers) + if err != nil { + return fmt.Errorf("add oauth provider: marshal: %w", err) + } + + _, err = s.db.Exec(s.q(`UPDATE users SET oauth_providers = ?, updated_at = ? WHERE id = ?`), + string(data), now(), userID) + if err != nil { + return fmt.Errorf("add oauth provider: %w", err) + } + return nil +} + +// RemoveOAuthProvider removes a provider from the user's oauth_providers list. +func (s *Store) RemoveOAuthProvider(userID, provider string) error { + user, err := s.GetUser(userID) + if err != nil { + return fmt.Errorf("remove oauth provider: %w", err) + } + if user == nil { + return fmt.Errorf("remove oauth provider: user not found") + } + + providers := user.GetOAuthProviders() + var filtered []string + for _, p := range providers { + if p != provider { + filtered = append(filtered, p) + } + } + + var val string + if len(filtered) > 0 { + data, err := json.Marshal(filtered) + if err != nil { + return fmt.Errorf("remove oauth provider: marshal: %w", err) + } + val = string(data) + } + + _, err = s.db.Exec(s.q(`UPDATE users SET oauth_providers = ?, updated_at = ? WHERE id = ?`), + val, now(), userID) + if err != nil { + return fmt.Errorf("remove oauth provider: %w", err) + } + return nil +} + // DeleteUser permanently deletes a user by ID. func (s *Store) DeleteUser(id string) error { _, err := s.db.Exec(s.q(`DELETE FROM users WHERE id = ?`), id) @@ -263,6 +406,55 @@ func (s *Store) DeleteUser(id string) error { return nil } +// DeleteAccountAtomic deletes a user and all their owned workspaces in a single +// transaction. If any step fails, the entire operation is rolled back and no data +// is modified. This prevents orphaned workspaces from partial deletions. +func (s *Store) DeleteAccountAtomic(userID string, ownedWorkspaceSlugs []string) error { + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("delete account: begin tx: %w", err) + } + defer tx.Rollback() + + ts := now() + + // 1. Soft-delete all owned workspaces + for _, slug := range ownedWorkspaceSlugs { + result, err := tx.Exec(s.q(` + UPDATE workspaces SET deleted_at = ?, updated_at = ? + WHERE slug = ? AND deleted_at IS NULL + `), ts, ts, slug) + if err != nil { + return fmt.Errorf("delete account: delete workspace %s: %w", slug, err) + } + rows, _ := result.RowsAffected() + if rows == 0 { + // Workspace already deleted or not found — not an error + continue + } + } + + // 2. Revoke all sessions + if _, err := tx.Exec(s.q("DELETE FROM sessions WHERE user_id = ?"), userID); err != nil { + return fmt.Errorf("delete account: delete sessions: %w", err) + } + + // 3. Revoke all API tokens + if _, err := tx.Exec(s.q("DELETE FROM api_tokens WHERE user_id = ?"), userID); err != nil { + return fmt.Errorf("delete account: delete api tokens: %w", err) + } + + // 4. Delete the user record + if _, err := tx.Exec(s.q("DELETE FROM users WHERE id = ?"), userID); err != nil { + return fmt.Errorf("delete account: delete user: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("delete account: commit: %w", err) + } + return nil +} + // --- Username backfill --- // GenerateUsername derives a URL-safe username from a display name. diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 0c8341fa..2c624a58 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -51,6 +51,9 @@ class PadApiError extends Error { function getCSRFToken(): string | null { if (typeof document === 'undefined') return null; + // Check __Host- prefixed cookie first (secure/TLS mode), fall back to unprefixed + const hostMatch = document.cookie.match(/(?:^|;\s*)__Host-pad_csrf=([^;]+)/); + if (hostMatch) return hostMatch[1]; const match = document.cookie.match(/(?:^|;\s*)pad_csrf=([^;]+)/); return match ? match[1] : null; } @@ -627,6 +630,11 @@ export const api = { method: 'PATCH', body: JSON.stringify(data) }), + unlinkProvider: (provider: string) => + request<{ ok: boolean; provider: string }>('/auth/oauth-unlink', { + method: 'POST', + body: JSON.stringify({ provider }) + }), tokens: { list: () => request('/auth/tokens'), create: (name: string) => diff --git a/web/src/lib/types/index.ts b/web/src/lib/types/index.ts index 27b139b4..bca4b724 100644 --- a/web/src/lib/types/index.ts +++ b/web/src/lib/types/index.ts @@ -7,6 +7,7 @@ export interface User { name: string; role: string; avatar_url?: string; + oauth_providers?: string[]; created_at: string; updated_at: string; } diff --git a/web/src/routes/console/admin/+page.svelte b/web/src/routes/console/admin/+page.svelte index decaa6d1..202ab4df 100644 --- a/web/src/routes/console/admin/+page.svelte +++ b/web/src/routes/console/admin/+page.svelte @@ -4,9 +4,12 @@ const BASE = '/api/v1'; - function csrfToken(): string { + function getCSRFToken(): string | null { + // Check __Host- prefixed cookie first (secure/TLS mode), fall back to unprefixed + const hostMatch = document.cookie.match(/(?:^|;\s*)__Host-pad_csrf=([^;]+)/); + if (hostMatch) return hostMatch[1]; const match = document.cookie.match(/(?:^|;\s*)pad_csrf=([^;]+)/); - return match?.[1] ?? ''; + return match ? match[1] : null; } async function adminFetch(path: string, opts?: RequestInit) { @@ -16,9 +19,12 @@ } async function adminPatch(path: string, body: unknown) { + const headers: Record = { 'Content-Type': 'application/json' }; + const csrf = getCSRFToken(); + if (csrf) headers['X-CSRF-Token'] = csrf; return adminFetch(path, { method: 'PATCH', - headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken() }, + headers, body: JSON.stringify(body) }); } @@ -66,7 +72,7 @@ adminFetch('/admin/users'), adminFetch('/admin/limits') ]); - stats = s; users = u; limits = l; + stats = s; users = u.users ?? u; limits = l; } catch (e) { error = e instanceof Error ? e.message : 'Failed to load'; } finally { @@ -76,7 +82,8 @@ async function searchUsers() { try { - users = await adminFetch(`/admin/users?q=${encodeURIComponent(search)}`); + const result = await adminFetch(`/admin/users?q=${encodeURIComponent(search)}`); + users = result.users ?? result; } catch { /* keep existing */ } } diff --git a/web/src/routes/console/billing/+page.svelte b/web/src/routes/console/billing/+page.svelte index c6f80f23..5130976a 100644 --- a/web/src/routes/console/billing/+page.svelte +++ b/web/src/routes/console/billing/+page.svelte @@ -3,12 +3,35 @@ import { goto } from '$app/navigation'; import { onMount } from 'svelte'; + interface PlanLimits { + workspaces: number; + items_per_workspace: number; + members_per_workspace: number; + api_tokens: number; + storage_bytes: number; + } + let plan = $derived(authStore.user?.plan ?? 'free'); let isPro = $derived(plan === 'pro'); + let limits = $state<{ free: PlanLimits; pro: PlanLimits } | null>(null); - onMount(() => { + function formatLimit(value: number | undefined): string { + if (value === undefined) return '...'; + if (value === -1) return 'Unlimited'; + return value.toLocaleString(); + } + + onMount(async () => { if (!authStore.cloudMode) { goto('/console', { replaceState: true }); + return; + } + + try { + const resp = await fetch('/api/v1/plan-limits', { credentials: 'same-origin' }); + if (resp.ok) limits = await resp.json(); + } catch { + /* use fallback rendering */ } }); @@ -58,11 +81,15 @@
Workspaces - {isPro ? 'Unlimited' : 'Up to 5'} + {isPro ? formatLimit(limits?.pro?.workspaces) : formatLimit(limits?.free?.workspaces)} +
+
+ Items per workspace + {isPro ? formatLimit(limits?.pro?.items_per_workspace) : formatLimit(limits?.free?.items_per_workspace)}
Members per workspace - {isPro ? 'Unlimited' : 'Up to 3'} + {isPro ? formatLimit(limits?.pro?.members_per_workspace) : formatLimit(limits?.free?.members_per_workspace)}
diff --git a/web/src/routes/console/settings/+page.svelte b/web/src/routes/console/settings/+page.svelte index f2af4abf..e0e00fac 100644 --- a/web/src/routes/console/settings/+page.svelte +++ b/web/src/routes/console/settings/+page.svelte @@ -20,6 +20,23 @@ let passwordMsg = $state(''); let passwordError = $state(''); + // OAuth providers + let providerMsg = $state(''); + let providerError = $state(''); + + async function unlinkProvider(provider: string) { + providerMsg = ''; + providerError = ''; + try { + await api.auth.unlinkProvider(provider); + // Refresh profile to get updated providers list + profile = await api.auth.me(); + providerMsg = `${provider === 'github' ? 'GitHub' : 'Google'} unlinked.`; + } catch (err) { + providerError = err instanceof Error ? err.message : 'Failed to unlink provider'; + } + } + // Tokens let tokens = $state([]); let newTokenName = $state(''); @@ -201,6 +218,36 @@ + + {#if authStore.cloudMode} +
+

Linked Accounts

+
+

Link OAuth providers for single sign-on. You can sign in with any linked provider.

+ {#each ['github', 'google'] as provider (provider)} + {@const linked = profile?.oauth_providers?.includes(provider) ?? false} +
+
+ {provider === 'github' ? 'GitHub' : 'Google'} + {#if linked} + Linked + {:else} + Not linked + {/if} +
+ {#if linked} + + {:else} + Link {provider === 'github' ? 'GitHub' : 'Google'} + {/if} +
+ {/each} + {#if providerMsg}

{providerMsg}

{/if} + {#if providerError}

{providerError}

{/if} +
+
+ {/if} +

API Tokens

@@ -451,4 +498,51 @@ color: var(--text-muted); font-size: 0.85rem; } + + .provider-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-3) var(--space-4); + background: var(--bg-tertiary); + border-radius: var(--radius); + } + + .provider-info { + display: flex; + align-items: center; + gap: var(--space-3); + } + + .provider-name { + font-weight: 500; + font-size: 0.9rem; + color: var(--text-primary); + } + + .provider-badge { + padding: 2px var(--space-2); + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: 500; + background: color-mix(in srgb, var(--accent-gray, #888) 15%, transparent); + color: var(--text-muted); + } + + .provider-badge.linked { + background: color-mix(in srgb, var(--accent-green) 15%, transparent); + color: var(--accent-green); + } + + .section-desc { + font-size: 0.8rem; + color: var(--text-muted); + margin-top: calc(-1 * var(--space-2)); + } + + .primary-btn.small { + padding: var(--space-1) var(--space-3); + font-size: 0.8rem; + text-decoration: none; + }