feat: cloud hardening and security follow-ups (PLAN-503)

Address 11 issues identified during the PLAN-427 security review:

Critical/High:
- Stripe customer-to-user mapping with indexed lookup (TASK-505)
- OAuth provider linking with explicit consent model (TASK-504)
- CSRF tokens on admin console mutations (TASK-506)
- Rate limiting on cloud admin and OAuth endpoints (TASK-507)

Medium:
- __Host- cookie prefix for subdomain protection (TASK-510)
- Billing portal verifies customer ownership server-side (TASK-515)
- Transactional account deletion with rollback (TASK-509)
- Streaming data export with 60s timeout (TASK-508)
- Migration registration for new columns (TASK-514)

Low:
- Billing page fetches actual plan limits from API (TASK-511)
- Admin user search/filter pushed into SQL with pagination (TASK-512)
This commit is contained in:
xarmian
2026-04-12 19:37:18 +00:00
parent 9220d3bb53
commit 92580905bb
21 changed files with 934 additions and 128 deletions
+27 -1
View File
@@ -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"`
+74 -42
View File
@@ -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()
}
}
+34 -36
View File
@@ -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,7 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
})
}
if result == nil {
result = []adminUser{}
}
writeJSON(w, http.StatusOK, result)
writeJSON(w, http.StatusOK, users)
}
// handleAdminGetUser returns a single user with full detail.
+47 -9
View File
@@ -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()),
@@ -507,7 +529,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 +542,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 +573,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 +582,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 +824,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()),
+331 -14
View File
@@ -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,324 @@ 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 a usable password
// or another linked provider remaining.
providers := user.GetOAuthProviders()
hasOtherProvider := false
for _, p := range providers {
if p != input.Provider {
hasOtherProvider = true
break
}
}
// A user has a "usable" password if they set one explicitly.
// OAuth-created users have a random unusable password but may have
// set one later via the password reset flow.
// We can't easily distinguish, so we require at least one other auth method.
if !hasOtherProvider && user.PasswordHash == "" {
writeError(w, http.StatusBadRequest, "bad_request",
"Cannot unlink your only authentication method. 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 or query param) or admin auth
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
+2 -2
View File
@@ -132,7 +132,7 @@ func (s *Server) SessionAuth(next http.Handler) http.Handler {
}
// Try session cookie
cookie, err := r.Cookie(sessionCookie)
cookie, err := r.Cookie(sessionCookieName(s.secureCookies))
if err != nil {
next.ServeHTTP(w, r)
return
@@ -157,7 +157,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)
}
}
+14 -12
View File
@@ -8,8 +8,7 @@ import (
)
const (
csrfCookie = "pad_csrf"
csrfHeader = "X-CSRF-Token"
csrfHeader = "X-CSRF-Token"
csrfTokenLen = 32 // 32 bytes = 64 hex chars
)
@@ -61,7 +60,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 +86,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 +97,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.
+26 -2
View File
@@ -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)
+7 -2
View File
@@ -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)
+20
View File
@@ -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
}
@@ -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 != '';
@@ -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 '';
@@ -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 != '';
@@ -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 '';
+4
View File
@@ -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 {
+194 -2
View File
@@ -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.
+8
View File
@@ -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<APIToken[]>('/auth/tokens'),
create: (name: string) =>
+1
View File
@@ -7,6 +7,7 @@ export interface User {
name: string;
role: string;
avatar_url?: string;
oauth_providers?: string[];
created_at: string;
updated_at: string;
}
+9 -3
View File
@@ -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<string, string> = { '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)
});
}
+30 -3
View File
@@ -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 */
}
});
</script>
@@ -58,11 +81,15 @@
</div>
<div class="usage-row">
<span class="usage-label">Workspaces</span>
<span class="usage-value">{isPro ? 'Unlimited' : 'Up to 5'}</span>
<span class="usage-value">{isPro ? formatLimit(limits?.pro?.workspaces) : formatLimit(limits?.free?.workspaces)}</span>
</div>
<div class="usage-row">
<span class="usage-label">Items per workspace</span>
<span class="usage-value">{isPro ? formatLimit(limits?.pro?.items_per_workspace) : formatLimit(limits?.free?.items_per_workspace)}</span>
</div>
<div class="usage-row">
<span class="usage-label">Members per workspace</span>
<span class="usage-value">{isPro ? 'Unlimited' : 'Up to 3'}</span>
<span class="usage-value">{isPro ? formatLimit(limits?.pro?.members_per_workspace) : formatLimit(limits?.free?.members_per_workspace)}</span>
</div>
</div>
</section>
@@ -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<APIToken[]>([]);
let newTokenName = $state('');
@@ -201,6 +218,36 @@
</div>
</section>
<!-- Linked Accounts (cloud mode only) -->
{#if authStore.cloudMode}
<section class="card">
<h2 class="card-title">Linked Accounts</h2>
<div class="card-body">
<p class="section-desc">Link OAuth providers for single sign-on. You can sign in with any linked provider.</p>
{#each ['github', 'google'] as provider (provider)}
{@const linked = profile?.oauth_providers?.includes(provider) ?? false}
<div class="provider-row">
<div class="provider-info">
<span class="provider-name">{provider === 'github' ? 'GitHub' : 'Google'}</span>
{#if linked}
<span class="provider-badge linked">Linked</span>
{:else}
<span class="provider-badge">Not linked</span>
{/if}
</div>
{#if linked}
<button class="delete-btn" onclick={() => unlinkProvider(provider)}>Unlink</button>
{:else}
<a href="/auth/{provider}/link" class="primary-btn small">Link {provider === 'github' ? 'GitHub' : 'Google'}</a>
{/if}
</div>
{/each}
{#if providerMsg}<p class="success">{providerMsg}</p>{/if}
{#if providerError}<p class="error">{providerError}</p>{/if}
</div>
</section>
{/if}
<!-- API Tokens -->
<section class="card">
<h2 class="card-title">API Tokens</h2>
@@ -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;
}
</style>