1
0
forked from wrenn/wrenn

Add /v1/me account management endpoints

Adds self-service endpoints: GET/PATCH/DELETE /v1/me, POST /v1/me/password,
POST /v1/me/password/reset{/confirm}, GET/DELETE /v1/me/providers/{provider}.
Includes OAuth account-linking flow via cookie, hard-delete cleanup goroutine
(24h ticker, 15-day grace period), and OpenAPI spec for all new routes.
This commit is contained in:
2026-04-16 03:24:55 +06:00
parent bc8348b199
commit f69fa8cded
5 changed files with 923 additions and 0 deletions

545
internal/api/handlers_me.go Normal file
View File

@ -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[:])
}

View File

@ -137,6 +137,73 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
email := strings.TrimSpace(strings.ToLower(profile.Email)) 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. // Check if this OAuth identity already exists.
existing, err := h.db.GetOAuthProvider(ctx, db.GetOAuthProviderParams{ existing, err := h.db.GetOAuthProvider(ctx, db.GetOAuthProviderParams{
Provider: provider, Provider: provider,

View File

@ -175,6 +175,252 @@ paths:
"302": "302":
description: Redirect to frontend with token or error 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: /v1/api-keys:
post: post:
summary: Create an API key summary: Create an API key
@ -2780,6 +3026,37 @@ components:
nullable: true nullable: true
description: Webhook secret. Only returned on creation, never again. 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: Error:
type: object type: object
properties: properties:

View File

@ -84,6 +84,7 @@ func New(
ptyH := newPtyHandler(queries, pool) ptyH := newPtyHandler(queries, pool)
processH := newProcessHandler(queries, pool) processH := newProcessHandler(queries, pool)
adminCapsules := newAdminCapsuleHandler(sandboxSvc, queries, pool, al) adminCapsules := newAdminCapsuleHandler(sandboxSvc, queries, pool, al)
meH := newMeHandler(queries, pgPool, rdb, jwtSecret, mailer, oauthRegistry, oauthRedirectURL)
// OpenAPI spec and docs. // OpenAPI spec and docs.
r.Get("/openapi.yaml", serveOpenAPI) r.Get("/openapi.yaml", serveOpenAPI)
@ -95,6 +96,21 @@ func New(
r.Get("/auth/oauth/{provider}", oauthH.Redirect) r.Get("/auth/oauth/{provider}", oauthH.Redirect)
r.Get("/auth/oauth/{provider}/callback", oauthH.Callback) 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. // JWT-authenticated: switch active team.
r.With(requireJWT(jwtSecret, queries)).Post("/v1/auth/switch-team", authH.SwitchTeam) r.With(requireJWT(jwtSecret, queries)).Post("/v1/auth/switch-team", authH.SwitchTeam)

View File

@ -188,6 +188,24 @@ func Run(opts ...Option) {
monitor := api.NewHostMonitor(queries, hostPool, al, 30*time.Second) monitor := api.NewHostMonitor(queries, hostPool, al, 30*time.Second)
monitor.Start(ctx) 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). // Start metrics sampler (records per-team sandbox stats every 10s).
sampler := api.NewMetricsSampler(queries, 10*time.Second) sampler := api.NewMetricsSampler(queries, 10*time.Second)
sampler.Start(ctx) sampler.Start(ctx)