1
0
forked from wrenn/wrenn

Add user names, team-scoped sandbox guard, and login robustness fixes

- Add name column to users (migration + sqlc regen); propagate through JWT
  claims, auth context, all auth/OAuth handlers, service layer, and frontend
- Sidebar and team page show name instead of email; team page splits Name/Email
  into separate columns
- Block sandbox creation in UI and API when user has no active team context
- loginTeam helper falls back to first active team when no default is set,
  fixing login for invited users with no is_default membership
- Exclude soft-deleted teams from GetDefaultTeamForUser, GetBYOCTeams queries
- Guard host creation against soft-deleted teams in service/host.go
- SwitchTeam re-fetches name from DB instead of trusting stale JWT claim
- Reset teams store on login so stale data from a previous session never persists
- Update openapi.yaml: add name to SignupRequest and AuthResponse schemas
This commit is contained in:
2026-03-24 16:56:10 +06:00
parent aaeccd32ce
commit 3932bc056e
26 changed files with 228 additions and 77 deletions

View File

@ -1,6 +1,7 @@
package api
import (
"context"
"errors"
"net/http"
"strings"
@ -15,6 +16,41 @@ import (
"git.omukk.dev/wrenn/sandbox/internal/id"
)
// loginTeam returns the team and role to stamp into a login JWT.
// It prefers the user's default team; if none is flagged as default it falls
// back to the earliest-joined team. Returns pgx.ErrNoRows when the user has
// no team memberships at all.
func loginTeam(ctx context.Context, q *db.Queries, userID string) (db.Team, string, error) {
team, err := q.GetDefaultTeamForUser(ctx, userID)
if err == nil {
membership, err := q.GetTeamMembership(ctx, db.GetTeamMembershipParams{UserID: userID, TeamID: team.ID})
if err != nil {
return db.Team{}, "", err
}
return team, membership.Role, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return db.Team{}, "", err
}
// No default set — fall back to earliest-joined team.
rows, err := q.GetTeamsForUser(ctx, userID)
if err != nil {
return db.Team{}, "", err
}
if len(rows) == 0 {
return db.Team{}, "", pgx.ErrNoRows
}
first := rows[0]
return db.Team{
ID: first.ID,
Name: first.Name,
Slug: first.Slug,
IsByoc: first.IsByoc,
CreatedAt: first.CreatedAt,
DeletedAt: first.DeletedAt,
}, first.Role, nil
}
type switchTeamRequest struct {
TeamID string `json:"team_id"`
}
@ -32,6 +68,7 @@ func newAuthHandler(db *db.Queries, pool *pgxpool.Pool, jwtSecret []byte) *authH
type signupRequest struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
}
type loginRequest struct {
@ -44,6 +81,7 @@ type authResponse struct {
UserID string `json:"user_id"`
TeamID string `json:"team_id"`
Email string `json:"email"`
Name string `json:"name"`
}
// Signup handles POST /v1/auth/signup.
@ -55,6 +93,7 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
}
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
req.Name = strings.TrimSpace(req.Name)
if !strings.Contains(req.Email, "@") || len(req.Email) < 3 {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid email address")
return
@ -63,6 +102,10 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid_request", "password must be at least 8 characters")
return
}
if req.Name == "" || len(req.Name) > 100 {
writeError(w, http.StatusBadRequest, "invalid_request", "name must be between 1 and 100 characters")
return
}
ctx := r.Context()
@ -87,6 +130,7 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
ID: userID,
Email: req.Email,
PasswordHash: pgtype.Text{String: passwordHash, Valid: true},
Name: req.Name,
})
if err != nil {
var pgErr *pgconn.PgError
@ -102,7 +146,7 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
teamID := id.NewTeamID()
if _, err := qtx.InsertTeam(ctx, db.InsertTeamParams{
ID: teamID,
Name: req.Email + "'s Team",
Name: req.Name + "'s Team",
Slug: id.NewTeamSlug(),
}); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to create team")
@ -124,7 +168,7 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
return
}
token, err := auth.SignJWT(h.jwtSecret, userID, teamID, req.Email, "owner")
token, err := auth.SignJWT(h.jwtSecret, userID, teamID, req.Email, req.Name, "owner")
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token")
return
@ -135,6 +179,7 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
UserID: userID,
TeamID: teamID,
Email: req.Email,
Name: req.Name,
})
}
@ -173,19 +218,17 @@ func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) {
return
}
team, err := h.db.GetDefaultTeamForUser(ctx, user.ID)
team, role, err := loginTeam(ctx, h.db, user.ID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusForbidden, "no_team", "user is not a member of any team")
return
}
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up team")
return
}
membership, err := h.db.GetTeamMembership(ctx, db.GetTeamMembershipParams{UserID: user.ID, TeamID: team.ID})
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up membership")
return
}
token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email, membership.Role)
token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email, user.Name, role)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token")
return
@ -196,6 +239,7 @@ func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) {
UserID: user.ID,
TeamID: team.ID,
Email: user.Email,
Name: user.Name,
})
}
@ -247,7 +291,14 @@ func (h *authHandler) SwitchTeam(w http.ResponseWriter, r *http.Request) {
return
}
token, err := auth.SignJWT(h.jwtSecret, ac.UserID, req.TeamID, ac.Email, membership.Role)
// Fetch current name from DB — JWT name is not trusted here (may be stale or empty for old tokens).
user, err := h.db.GetUserByID(ctx, ac.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up user")
return
}
token, err := auth.SignJWT(h.jwtSecret, ac.UserID, req.TeamID, ac.Email, user.Name, membership.Role)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token")
return
@ -258,5 +309,6 @@ func (h *authHandler) SwitchTeam(w http.ResponseWriter, r *http.Request) {
UserID: ac.UserID,
TeamID: req.TeamID,
Email: ac.Email,
Name: user.Name,
})
}

View File

@ -150,25 +150,19 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
redirectWithError(w, r, redirectBase, "db_error")
return
}
team, err := h.db.GetDefaultTeamForUser(ctx, user.ID)
team, role, err := loginTeam(ctx, h.db, user.ID)
if err != nil {
slog.Error("oauth login: failed to get team", "error", err)
redirectWithError(w, r, redirectBase, "db_error")
return
}
membership, err := h.db.GetTeamMembership(ctx, db.GetTeamMembershipParams{UserID: user.ID, TeamID: team.ID})
if err != nil {
slog.Error("oauth login: failed to get membership", "error", err)
redirectWithError(w, r, redirectBase, "db_error")
return
}
token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email, membership.Role)
token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email, user.Name, role)
if err != nil {
slog.Error("oauth login: failed to sign jwt", "error", err)
redirectWithError(w, r, redirectBase, "internal_error")
return
}
redirectWithToken(w, r, redirectBase, token, user.ID, team.ID, user.Email)
redirectWithToken(w, r, redirectBase, token, user.ID, team.ID, user.Email, user.Name)
return
}
if !errors.Is(err, pgx.ErrNoRows) {
@ -205,6 +199,7 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
_, err = qtx.InsertUserOAuth(ctx, db.InsertUserOAuthParams{
ID: userID,
Email: email,
Name: profile.Name,
})
if err != nil {
var pgErr *pgconn.PgError
@ -260,14 +255,14 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
return
}
token, err := auth.SignJWT(h.jwtSecret, userID, teamID, email, "owner")
token, err := auth.SignJWT(h.jwtSecret, userID, teamID, email, profile.Name, "owner")
if err != nil {
slog.Error("oauth: failed to sign jwt", "error", err)
redirectWithError(w, r, redirectBase, "internal_error")
return
}
redirectWithToken(w, r, redirectBase, token, userID, teamID, email)
redirectWithToken(w, r, redirectBase, token, userID, teamID, email, profile.Name)
}
// retryAsLogin handles the race where a concurrent request already created the user.
@ -289,33 +284,28 @@ func (h *oauthHandler) retryAsLogin(w http.ResponseWriter, r *http.Request, prov
redirectWithError(w, r, redirectBase, "db_error")
return
}
team, err := h.db.GetDefaultTeamForUser(ctx, user.ID)
team, role, err := loginTeam(ctx, h.db, user.ID)
if err != nil {
slog.Error("oauth: retry login: failed to get team", "error", err)
redirectWithError(w, r, redirectBase, "db_error")
return
}
membership, err := h.db.GetTeamMembership(ctx, db.GetTeamMembershipParams{UserID: user.ID, TeamID: team.ID})
if err != nil {
slog.Error("oauth: retry login: failed to get membership", "error", err)
redirectWithError(w, r, redirectBase, "db_error")
return
}
token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email, membership.Role)
token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email, user.Name, role)
if err != nil {
slog.Error("oauth: retry login: failed to sign jwt", "error", err)
redirectWithError(w, r, redirectBase, "internal_error")
return
}
redirectWithToken(w, r, redirectBase, token, user.ID, team.ID, user.Email)
redirectWithToken(w, r, redirectBase, token, user.ID, team.ID, user.Email, user.Name)
}
func redirectWithToken(w http.ResponseWriter, r *http.Request, base, token, userID, teamID, email string) {
func redirectWithToken(w http.ResponseWriter, r *http.Request, base, token, userID, teamID, email, name string) {
u := base + "?" + url.Values{
"token": {token},
"user_id": {userID},
"team_id": {teamID},
"email": {email},
"name": {name},
}.Encode()
http.Redirect(w, r, u, http.StatusFound)
}

View File

@ -79,6 +79,10 @@ func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) {
}
ac := auth.MustFromContext(r.Context())
if ac.TeamID == "" {
writeError(w, http.StatusForbidden, "no_team", "no active team context; re-authenticate")
return
}
sb, err := h.svc.Create(r.Context(), service.SandboxCreateParams{
TeamID: ac.TeamID,

View File

@ -36,6 +36,7 @@ type teamWithRoleResponse struct {
type memberResponse struct {
UserID string `json:"user_id"`
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
JoinedAt string `json:"joined_at,omitempty"`
@ -56,6 +57,7 @@ func teamToResponse(t db.Team) teamResponse {
func memberInfoToResponse(m service.MemberInfo) memberResponse {
return memberResponse{
UserID: m.UserID,
Name: m.Name,
Email: m.Email,
Role: m.Role,
JoinedAt: m.JoinedAt.Format(time.RFC3339),

View File

@ -45,6 +45,7 @@ func requireAPIKeyOrJWT(queries *db.Queries, jwtSecret []byte) func(http.Handler
TeamID: claims.TeamID,
UserID: claims.Subject,
Email: claims.Email,
Name: claims.Name,
Role: claims.Role,
})
next.ServeHTTP(w, r.WithContext(ctx))

View File

@ -29,6 +29,7 @@ func requireJWT(secret []byte) func(http.Handler) http.Handler {
TeamID: claims.TeamID,
UserID: claims.Subject,
Email: claims.Email,
Name: claims.Name,
Role: claims.Role,
})
next.ServeHTTP(w, r.WithContext(ctx))

View File

@ -1410,7 +1410,7 @@ components:
schemas:
SignupRequest:
type: object
required: [email, password]
required: [email, password, name]
properties:
email:
type: string
@ -1418,6 +1418,9 @@ components:
password:
type: string
minLength: 8
name:
type: string
maxLength: 100
LoginRequest:
type: object
@ -1441,6 +1444,8 @@ components:
type: string
email:
type: string
name:
type: string
CreateAPIKeyRequest:
type: object