diff --git a/internal/api/handlers_me.go b/internal/api/handlers_me.go new file mode 100644 index 0000000..7b8b83c --- /dev/null +++ b/internal/api/handlers_me.go @@ -0,0 +1,545 @@ +package api + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" + + "git.omukk.dev/wrenn/wrenn/internal/email" + "git.omukk.dev/wrenn/wrenn/pkg/auth" + "git.omukk.dev/wrenn/wrenn/pkg/auth/oauth" + "git.omukk.dev/wrenn/wrenn/pkg/db" + "git.omukk.dev/wrenn/wrenn/pkg/id" +) + +const ( + passwordResetKeyPrefix = "wrenn:password_reset:" + passwordResetTTL = 15 * time.Minute +) + +type meHandler struct { + db *db.Queries + pool *pgxpool.Pool + rdb *redis.Client + jwtSecret []byte + mailer email.Mailer + oauthRegistry *oauth.Registry + redirectURL string +} + +func newMeHandler( + db *db.Queries, + pool *pgxpool.Pool, + rdb *redis.Client, + jwtSecret []byte, + mailer email.Mailer, + registry *oauth.Registry, + redirectURL string, +) *meHandler { + return &meHandler{ + db: db, + pool: pool, + rdb: rdb, + jwtSecret: jwtSecret, + mailer: mailer, + oauthRegistry: registry, + redirectURL: strings.TrimRight(redirectURL, "/"), + } +} + +type meResponse struct { + Name string `json:"name"` + Email string `json:"email"` + HasPassword bool `json:"has_password"` + Providers []string `json:"providers"` +} + +type updateNameRequest struct { + Name string `json:"name"` +} + +type changePasswordRequest struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` + ConfirmPassword string `json:"confirm_password"` +} + +type requestPasswordResetRequest struct { + Email string `json:"email"` +} + +type confirmPasswordResetRequest struct { + Token string `json:"token"` + NewPassword string `json:"new_password"` +} + +type deleteAccountRequest struct { + Confirmation string `json:"confirmation"` +} + +// GetMe handles GET /v1/me. +func (h *meHandler) GetMe(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) + ctx := r.Context() + + user, err := h.db.GetUserByID(ctx, ac.UserID) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to get user") + return + } + + providers, err := h.db.GetOAuthProvidersByUserID(ctx, ac.UserID) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to get providers") + return + } + + providerNames := make([]string, 0, len(providers)) + for _, p := range providers { + providerNames = append(providerNames, p.Provider) + } + + writeJSON(w, http.StatusOK, meResponse{ + Name: user.Name, + Email: user.Email, + HasPassword: user.PasswordHash.Valid, + Providers: providerNames, + }) +} + +// UpdateName handles PATCH /v1/me — updates the user's name and re-issues a JWT. +func (h *meHandler) UpdateName(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) + ctx := r.Context() + + var req updateNameRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body") + return + } + + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" || len(req.Name) > 100 { + writeError(w, http.StatusBadRequest, "invalid_request", "name must be between 1 and 100 characters") + return + } + + if err := h.db.UpdateUserName(ctx, db.UpdateUserNameParams{ + ID: ac.UserID, + Name: req.Name, + }); err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to update name") + return + } + + user, err := h.db.GetUserByID(ctx, ac.UserID) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to get user") + return + } + + team, role, err := loginTeam(ctx, h.db, ac.UserID) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to get team") + return + } + + token, err := auth.SignJWT(h.jwtSecret, ac.UserID, team.ID, user.Email, req.Name, role, user.IsAdmin) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token") + return + } + + writeJSON(w, http.StatusOK, authResponse{ + Token: token, + UserID: id.FormatUserID(ac.UserID), + TeamID: id.FormatTeamID(team.ID), + Email: user.Email, + Name: req.Name, + }) +} + +// ChangePassword handles POST /v1/me/password. +// For users with a password: requires current_password + new_password. +// For OAuth-only users: requires new_password + confirm_password. +func (h *meHandler) ChangePassword(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) + ctx := r.Context() + + var req changePasswordRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body") + return + } + + user, err := h.db.GetUserByID(ctx, ac.UserID) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to get user") + return + } + + if user.PasswordHash.Valid { + // Changing existing password — verify current. + if req.CurrentPassword == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "current_password is required") + return + } + if err := auth.CheckPassword(user.PasswordHash.String, req.CurrentPassword); err != nil { + writeError(w, http.StatusUnauthorized, "wrong_password", "current password is incorrect") + return + } + } else { + // OAuth user adding a password — confirm must match. + if req.ConfirmPassword == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "confirm_password is required") + return + } + if req.NewPassword != req.ConfirmPassword { + writeError(w, http.StatusBadRequest, "invalid_request", "passwords do not match") + return + } + } + + if len(req.NewPassword) < 8 { + writeError(w, http.StatusBadRequest, "invalid_request", "password must be at least 8 characters") + return + } + + hash, err := auth.HashPassword(req.NewPassword) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "failed to hash password") + return + } + + if err := h.db.UpdateUserPassword(ctx, db.UpdateUserPasswordParams{ + ID: ac.UserID, + PasswordHash: pgtype.Text{String: hash, Valid: true}, + }); err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to update password") + return + } + + isAdding := !user.PasswordHash.Valid + go func() { + sendCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + subject, message := "Your Wrenn password was changed", "Your account password was successfully updated. If you did not make this change, reset your password immediately." + if isAdding { + subject = "Password added to your Wrenn account" + message = "A password has been added to your Wrenn account. You can now sign in with your email and password in addition to any connected OAuth providers." + } + if err := h.mailer.Send(sendCtx, user.Email, subject, email.EmailData{ + RecipientName: user.Name, + Message: message, + Closing: "If you didn't make this change, contact support immediately.", + }); err != nil { + slog.Warn("change password: failed to send notification", "email", user.Email, "error", err) + } + }() + + w.WriteHeader(http.StatusNoContent) +} + +// RequestPasswordReset handles POST /v1/me/password/reset (unauthenticated). +// Always returns 200 to avoid leaking account existence. +func (h *meHandler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) { + var req requestPasswordResetRequest + if err := decodeJSON(r, &req); err != nil { + w.WriteHeader(http.StatusNoContent) + return + } + + req.Email = strings.TrimSpace(strings.ToLower(req.Email)) + if req.Email == "" { + w.WriteHeader(http.StatusNoContent) + return + } + + ctx := r.Context() + + user, err := h.db.GetUserByEmail(ctx, req.Email) + if err != nil { + // Don't leak whether the email exists. + w.WriteHeader(http.StatusNoContent) + return + } + + if !user.IsActive || user.DeletedAt.Valid { + w.WriteHeader(http.StatusNoContent) + return + } + + rawToken := generateResetToken() + tokenHash := hashResetToken(rawToken) + redisKey := passwordResetKeyPrefix + tokenHash + + if err := h.rdb.Set(ctx, redisKey, id.FormatUserID(user.ID), passwordResetTTL).Err(); err != nil { + slog.Error("password reset: failed to store token in redis", "error", err) + w.WriteHeader(http.StatusNoContent) + return + } + + resetURL := h.redirectURL + "/reset-password?token=" + rawToken + go func() { + sendCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := h.mailer.Send(sendCtx, user.Email, "Reset your Wrenn password", email.EmailData{ + RecipientName: user.Name, + Message: "We received a request to reset your password. Click the button below to set a new password. This link expires in 15 minutes.", + Button: &email.Button{Text: "Reset Password", URL: resetURL}, + Closing: "If you didn't request a password reset, you can safely ignore this email.", + }); err != nil { + slog.Error("password reset: failed to send email", "email", user.Email, "error", err) + } + }() + + w.WriteHeader(http.StatusNoContent) +} + +// ConfirmPasswordReset handles POST /v1/me/password/reset/confirm (unauthenticated). +func (h *meHandler) ConfirmPasswordReset(w http.ResponseWriter, r *http.Request) { + var req confirmPasswordResetRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body") + return + } + + if req.Token == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "token is required") + return + } + if len(req.NewPassword) < 8 { + writeError(w, http.StatusBadRequest, "invalid_request", "password must be at least 8 characters") + return + } + + ctx := r.Context() + tokenHash := hashResetToken(req.Token) + redisKey := passwordResetKeyPrefix + tokenHash + + // GetDel atomically retrieves and removes the token in a single round-trip, + // preventing concurrent requests from both consuming the same token. + userIDStr, err := h.rdb.GetDel(ctx, redisKey).Result() + if errors.Is(err, redis.Nil) { + writeError(w, http.StatusBadRequest, "invalid_token", "reset token is invalid or has expired") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "failed to verify token") + return + } + + userID, err := id.ParseUserID(userIDStr) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "invalid stored user ID") + return + } + + user, err := h.db.GetUserByID(ctx, userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to get user") + return + } + + hash, err := auth.HashPassword(req.NewPassword) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "failed to hash password") + return + } + + if err := h.db.UpdateUserPassword(ctx, db.UpdateUserPasswordParams{ + ID: userID, + PasswordHash: pgtype.Text{String: hash, Valid: true}, + }); err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to update password") + return + } + + go func() { + sendCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := h.mailer.Send(sendCtx, user.Email, "Your Wrenn password was reset", email.EmailData{ + RecipientName: user.Name, + Message: "Your password has been successfully reset. You can now sign in with your new password.", + Closing: "If you didn't request this change, contact support immediately.", + }); err != nil { + slog.Warn("confirm password reset: failed to send notification", "email", user.Email, "error", err) + } + }() + + w.WriteHeader(http.StatusNoContent) +} + +// ConnectProvider handles GET /v1/me/providers/{provider}/connect. +// Sets OAuth state + link cookies and returns the provider auth URL. +func (h *meHandler) ConnectProvider(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) + provider := chi.URLParam(r, "provider") + + p, ok := h.oauthRegistry.Get(provider) + if !ok { + writeError(w, http.StatusNotFound, "provider_not_found", "unsupported OAuth provider") + return + } + + state, err := generateState() + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate state") + return + } + + mac := computeHMAC(h.jwtSecret, state) + http.SetCookie(w, &http.Cookie{ + Name: "oauth_state", + Value: state + ":" + mac, + Path: "/", + MaxAge: 600, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: isSecure(r), + }) + + userIDStr := id.FormatUserID(ac.UserID) + linkMac := computeHMAC(h.jwtSecret, userIDStr) + http.SetCookie(w, &http.Cookie{ + Name: "oauth_link_user_id", + Value: userIDStr + ":" + linkMac, + Path: "/", + MaxAge: 600, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: isSecure(r), + }) + + writeJSON(w, http.StatusOK, map[string]string{"auth_url": p.AuthCodeURL(state)}) +} + +// DisconnectProvider handles DELETE /v1/me/providers/{provider}. +func (h *meHandler) DisconnectProvider(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) + provider := chi.URLParam(r, "provider") + ctx := r.Context() + + user, err := h.db.GetUserByID(ctx, ac.UserID) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to get user") + return + } + + providers, err := h.db.GetOAuthProvidersByUserID(ctx, ac.UserID) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to get providers") + return + } + + // Ensure the user will still have at least one login method after disconnecting. + if !user.PasswordHash.Valid && len(providers) <= 1 { + writeError(w, http.StatusBadRequest, "last_login_method", "cannot disconnect your only login method — add a password first") + return + } + + // Check the provider is actually linked to this user. + found := false + for _, p := range providers { + if p.Provider == provider { + found = true + break + } + } + if !found { + writeError(w, http.StatusNotFound, "not_found", "provider not connected") + return + } + + if err := h.db.DeleteOAuthProvider(ctx, db.DeleteOAuthProviderParams{ + UserID: ac.UserID, + Provider: provider, + }); err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to disconnect provider") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// DeleteAccount handles DELETE /v1/me — soft-deletes the user's account. +func (h *meHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) + ctx := r.Context() + + var req deleteAccountRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body") + return + } + + user, err := h.db.GetUserByID(ctx, ac.UserID) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to get user") + return + } + + if !strings.EqualFold(strings.TrimSpace(req.Confirmation), user.Email) { + writeError(w, http.StatusBadRequest, "invalid_request", "confirmation does not match your email address") + return + } + + teamsBlocking, err := h.db.CountUserOwnedTeamsWithOtherMembers(ctx, ac.UserID) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to check team ownership") + return + } + if teamsBlocking > 0 { + writeError(w, http.StatusConflict, "owns_team_with_members", + fmt.Sprintf("you own %d team(s) with other members — transfer ownership or remove members before deleting your account", teamsBlocking)) + return + } + + if err := h.db.SoftDeleteUser(ctx, ac.UserID); err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to delete account") + return + } + + slog.Info("account soft-deleted", "user_id", id.FormatUserID(ac.UserID), "email", user.Email) + + go func() { + sendCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := h.mailer.Send(sendCtx, user.Email, "Your Wrenn account has been deleted", email.EmailData{ + RecipientName: user.Name, + Message: "Your Wrenn account has been deactivated and is scheduled for permanent deletion in 15 days. If this was a mistake, contact support before then to recover your account.", + Closing: "Thank you for using Wrenn.", + }); err != nil { + slog.Warn("delete account: failed to send notification", "email", user.Email, "error", err) + } + }() + + w.WriteHeader(http.StatusNoContent) +} + +// --- helpers --- + +func generateResetToken() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + panic(fmt.Sprintf("crypto/rand failed: %v", err)) + } + return hex.EncodeToString(b) +} + +func hashResetToken(raw string) string { + h := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(h[:]) +} diff --git a/internal/api/handlers_oauth.go b/internal/api/handlers_oauth.go index eef0977..d87bc25 100644 --- a/internal/api/handlers_oauth.go +++ b/internal/api/handlers_oauth.go @@ -137,6 +137,73 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) { email := strings.TrimSpace(strings.ToLower(profile.Email)) + // Check for a link operation initiated from the settings page. + if linkCookie, err := r.Cookie("oauth_link_user_id"); err == nil && linkCookie.Value != "" { + // Clear the link cookie immediately. + http.SetCookie(w, &http.Cookie{ + Name: "oauth_link_user_id", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: isSecure(r), + }) + + settingsBase := h.redirectURL + "/dashboard/settings" + + // Verify the HMAC to prevent cookie forgery. + linkParts := strings.SplitN(linkCookie.Value, ":", 2) + if len(linkParts) != 2 || !hmac.Equal([]byte(computeHMAC(h.jwtSecret, linkParts[0])), []byte(linkParts[1])) { + slog.Warn("oauth link: invalid or tampered link cookie") + http.Redirect(w, r, settingsBase+"?connect_error=invalid_state", http.StatusFound) + return + } + + userID, parseErr := id.ParseUserID(linkParts[0]) + if parseErr != nil { + slog.Error("oauth link: invalid user ID in cookie", "error", parseErr) + http.Redirect(w, r, settingsBase+"?connect_error=invalid_state", http.StatusFound) + return + } + + // Ensure the GitHub account isn't already linked to a different user. + existing, lookupErr := h.db.GetOAuthProvider(ctx, db.GetOAuthProviderParams{ + Provider: provider, + ProviderID: profile.ProviderID, + }) + if lookupErr == nil && existing.UserID != userID { + slog.Warn("oauth link: provider already linked to another account", "provider", provider) + http.Redirect(w, r, settingsBase+"?connect_error=already_linked", http.StatusFound) + return + } + if lookupErr == nil && existing.UserID == userID { + // Already linked to this user — treat as success. + http.Redirect(w, r, settingsBase+"?connected="+provider, http.StatusFound) + return + } + if !errors.Is(lookupErr, pgx.ErrNoRows) { + slog.Error("oauth link: db lookup failed", "error", lookupErr) + http.Redirect(w, r, settingsBase+"?connect_error=db_error", http.StatusFound) + return + } + + if insertErr := h.db.InsertOAuthProvider(ctx, db.InsertOAuthProviderParams{ + Provider: provider, + ProviderID: profile.ProviderID, + UserID: userID, + Email: email, + }); insertErr != nil { + slog.Error("oauth link: failed to insert provider", "error", insertErr) + http.Redirect(w, r, settingsBase+"?connect_error=db_error", http.StatusFound) + return + } + + slog.Info("oauth link: provider linked", "provider", provider, "user_id", id.FormatUserID(userID)) + http.Redirect(w, r, settingsBase+"?connected="+provider, http.StatusFound) + return + } + // Check if this OAuth identity already exists. existing, err := h.db.GetOAuthProvider(ctx, db.GetOAuthProviderParams{ Provider: provider, diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml index 984a37d..0ad8cd2 100644 --- a/internal/api/openapi.yaml +++ b/internal/api/openapi.yaml @@ -175,6 +175,252 @@ paths: "302": description: Redirect to frontend with token or error + /v1/me: + get: + summary: Get current user profile + operationId: getMe + tags: [account] + security: + - bearerAuth: [] + responses: + "200": + description: User profile + content: + application/json: + schema: + $ref: "#/components/schemas/MeResponse" + + patch: + summary: Update display name + operationId: updateName + tags: [account] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: + type: string + minLength: 1 + maxLength: 100 + responses: + "200": + description: Name updated, new JWT issued + content: + application/json: + schema: + $ref: "#/components/schemas/AuthResponse" + "400": + description: Invalid name + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + delete: + summary: Delete current account + operationId: deleteAccount + tags: [account] + security: + - bearerAuth: [] + description: | + Soft-deletes the account (sets is_active=false, deleted_at=now). + The account is permanently removed after 15 days. Blocked if the user + owns any team that has other members. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [confirmation] + properties: + confirmation: + type: string + description: Must match the user's email address (case-insensitive) + responses: + "204": + description: Account scheduled for deletion + "400": + description: Confirmation does not match email + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: User owns teams with other members + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/me/password: + post: + summary: Change or add password + operationId: changePassword + tags: [account] + security: + - bearerAuth: [] + description: | + For users with an existing password: requires `current_password` and `new_password`. + For OAuth-only users adding a password: requires `new_password` and `confirm_password`. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ChangePasswordRequest" + responses: + "204": + description: Password updated + "400": + description: Invalid request (short password, mismatch, etc.) + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Current password is incorrect + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/me/password/reset: + post: + summary: Request a password reset email + operationId: requestPasswordReset + tags: [account] + description: | + Sends a password reset link to the given email. Always returns 200 + regardless of whether the email exists, to prevent account enumeration. + The reset token expires in 15 minutes. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email] + properties: + email: + type: string + format: email + responses: + "204": + description: Request accepted (email sent if account exists) + + /v1/me/password/reset/confirm: + post: + summary: Confirm password reset + operationId: confirmPasswordReset + tags: [account] + description: | + Consumes a password reset token and sets a new password. The token is + single-use and expires after 15 minutes. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token, new_password] + properties: + token: + type: string + description: Raw reset token from the email link + new_password: + type: string + minLength: 8 + responses: + "204": + description: Password reset successful + "400": + description: Invalid or expired token, or password too short + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/me/providers/{provider}/connect: + parameters: + - name: provider + in: path + required: true + schema: + type: string + enum: [github] + description: OAuth provider name + + get: + summary: Initiate OAuth provider link + operationId: connectProvider + tags: [account] + security: + - bearerAuth: [] + description: | + Sets OAuth state and link cookies, then returns the provider's + authorization URL. The frontend navigates to this URL to start the + OAuth flow. On callback, the provider is linked to the current account + (not a new registration). + responses: + "200": + description: Authorization URL + content: + application/json: + schema: + type: object + properties: + auth_url: + type: string + format: uri + "404": + description: Provider not found or not configured + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/me/providers/{provider}: + parameters: + - name: provider + in: path + required: true + schema: + type: string + enum: [github] + description: OAuth provider name + + delete: + summary: Disconnect an OAuth provider + operationId: disconnectProvider + tags: [account] + security: + - bearerAuth: [] + description: | + Unlinks the OAuth provider from the current account. Blocked if this + is the user's only login method (no password and no other providers). + responses: + "204": + description: Provider disconnected + "400": + description: Cannot disconnect last login method + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: Provider not connected + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /v1/api-keys: post: summary: Create an API key @@ -2780,6 +3026,37 @@ components: nullable: true description: Webhook secret. Only returned on creation, never again. + MeResponse: + type: object + properties: + name: + type: string + email: + type: string + format: email + has_password: + type: boolean + description: Whether the user has a password set (false for OAuth-only accounts) + providers: + type: array + items: + type: string + description: List of linked OAuth provider names (e.g. ["github"]) + + ChangePasswordRequest: + type: object + required: [new_password] + properties: + current_password: + type: string + description: Required when changing an existing password + new_password: + type: string + minLength: 8 + confirm_password: + type: string + description: Required when adding a password to an OAuth-only account (must match new_password) + Error: type: object properties: diff --git a/internal/api/server.go b/internal/api/server.go index c687f5f..fc2c3d5 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -84,6 +84,7 @@ func New( ptyH := newPtyHandler(queries, pool) processH := newProcessHandler(queries, pool) adminCapsules := newAdminCapsuleHandler(sandboxSvc, queries, pool, al) + meH := newMeHandler(queries, pgPool, rdb, jwtSecret, mailer, oauthRegistry, oauthRedirectURL) // OpenAPI spec and docs. r.Get("/openapi.yaml", serveOpenAPI) @@ -95,6 +96,21 @@ func New( r.Get("/auth/oauth/{provider}", oauthH.Redirect) r.Get("/auth/oauth/{provider}/callback", oauthH.Callback) + // Unauthenticated: password reset request and confirmation. + r.Post("/v1/me/password/reset", meH.RequestPasswordReset) + r.Post("/v1/me/password/reset/confirm", meH.ConfirmPasswordReset) + + // JWT-authenticated: self-service account management. + r.Route("/v1/me", func(r chi.Router) { + r.Use(requireJWT(jwtSecret, queries)) + r.Get("/", meH.GetMe) + r.Patch("/", meH.UpdateName) + r.Post("/password", meH.ChangePassword) + r.Get("/providers/{provider}/connect", meH.ConnectProvider) + r.Delete("/providers/{provider}", meH.DisconnectProvider) + r.Delete("/", meH.DeleteAccount) + }) + // JWT-authenticated: switch active team. r.With(requireJWT(jwtSecret, queries)).Post("/v1/auth/switch-team", authH.SwitchTeam) diff --git a/pkg/cpserver/run.go b/pkg/cpserver/run.go index d7be9ad..aeb21ac 100644 --- a/pkg/cpserver/run.go +++ b/pkg/cpserver/run.go @@ -188,6 +188,24 @@ func Run(opts ...Option) { monitor := api.NewHostMonitor(queries, hostPool, al, 30*time.Second) monitor.Start(ctx) + // Hard-delete accounts that have been soft-deleted for more than 15 days (runs every 24h). + go func() { + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := queries.HardDeleteExpiredUsers(ctx); err != nil { + slog.Error("account cleanup: failed to hard-delete expired users", "error", err) + } else { + slog.Info("account cleanup: hard-deleted expired users") + } + } + } + }() + // Start metrics sampler (records per-team sandbox stats every 10s). sampler := api.NewMetricsSampler(queries, 10*time.Second) sampler.Start(ctx)