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,
})
}
diff --git a/internal/api/handlers_oauth.go b/internal/api/handlers_oauth.go
index e52bb6f..1c72285 100644
--- a/internal/api/handlers_oauth.go
+++ b/internal/api/handlers_oauth.go
@@ -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)
}
diff --git a/internal/api/handlers_sandbox.go b/internal/api/handlers_sandbox.go
index a312e5f..08f99b0 100644
--- a/internal/api/handlers_sandbox.go
+++ b/internal/api/handlers_sandbox.go
@@ -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,
diff --git a/internal/api/handlers_team.go b/internal/api/handlers_team.go
index f1df8f8..e852583 100644
--- a/internal/api/handlers_team.go
+++ b/internal/api/handlers_team.go
@@ -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),
diff --git a/internal/api/middleware_auth.go b/internal/api/middleware_auth.go
index 4cde817..41daf36 100644
--- a/internal/api/middleware_auth.go
+++ b/internal/api/middleware_auth.go
@@ -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))
diff --git a/internal/api/middleware_jwt.go b/internal/api/middleware_jwt.go
index bfe1438..c0b17fa 100644
--- a/internal/api/middleware_jwt.go
+++ b/internal/api/middleware_jwt.go
@@ -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))
diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml
index 02bcd77..5f8dff5 100644
--- a/internal/api/openapi.yaml
+++ b/internal/api/openapi.yaml
@@ -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
diff --git a/internal/auth/context.go b/internal/auth/context.go
index e5ae893..36dd06c 100644
--- a/internal/auth/context.go
+++ b/internal/auth/context.go
@@ -11,6 +11,7 @@ type AuthContext struct {
TeamID string
UserID string // empty when authenticated via API key
Email string // empty when authenticated via API key
+ Name string // empty when authenticated via API key
Role string // owner, admin, or member; empty when authenticated via API key
}
diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go
index 331cae2..eebba31 100644
--- a/internal/auth/jwt.go
+++ b/internal/auth/jwt.go
@@ -16,16 +16,18 @@ type Claims struct {
TeamID string `json:"team_id"`
Role string `json:"role"` // owner, admin, or member within TeamID
Email string `json:"email"`
+ Name string `json:"name"`
jwt.RegisteredClaims
}
// SignJWT signs a new 6-hour JWT for the given user.
-func SignJWT(secret []byte, userID, teamID, email, role string) (string, error) {
+func SignJWT(secret []byte, userID, teamID, email, name, role string) (string, error) {
now := time.Now()
claims := Claims{
TeamID: teamID,
Role: role,
Email: email,
+ Name: name,
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID,
IssuedAt: jwt.NewNumericDate(now),
diff --git a/internal/db/models.go b/internal/db/models.go
index 6e29928..158193b 100644
--- a/internal/db/models.go
+++ b/internal/db/models.go
@@ -112,6 +112,7 @@ type User struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
IsAdmin bool `json:"is_admin"`
+ Name string `json:"name"`
}
type UsersTeam struct {
diff --git a/internal/db/teams.sql.go b/internal/db/teams.sql.go
index 7324763..a00f5ef 100644
--- a/internal/db/teams.sql.go
+++ b/internal/db/teams.sql.go
@@ -26,7 +26,7 @@ func (q *Queries) DeleteTeamMember(ctx context.Context, arg DeleteTeamMemberPara
}
const getBYOCTeams = `-- name: GetBYOCTeams :many
-SELECT id, name, created_at, is_byoc, slug, deleted_at FROM teams WHERE is_byoc = TRUE ORDER BY created_at
+SELECT id, name, created_at, is_byoc, slug, deleted_at FROM teams WHERE is_byoc = TRUE AND deleted_at IS NULL ORDER BY created_at
`
func (q *Queries) GetBYOCTeams(ctx context.Context) ([]Team, error) {
@@ -59,7 +59,7 @@ func (q *Queries) GetBYOCTeams(ctx context.Context) ([]Team, error) {
const getDefaultTeamForUser = `-- name: GetDefaultTeamForUser :one
SELECT t.id, t.name, t.created_at, t.is_byoc, t.slug, t.deleted_at FROM teams t
JOIN users_teams ut ON ut.team_id = t.id
-WHERE ut.user_id = $1 AND ut.is_default = TRUE
+WHERE ut.user_id = $1 AND ut.is_default = TRUE AND t.deleted_at IS NULL
LIMIT 1
`
@@ -114,7 +114,7 @@ func (q *Queries) GetTeamBySlug(ctx context.Context, slug string) (Team, error)
}
const getTeamMembers = `-- name: GetTeamMembers :many
-SELECT u.id, u.email, ut.role, ut.created_at AS joined_at
+SELECT u.id, u.name, u.email, ut.role, ut.created_at AS joined_at
FROM users_teams ut
JOIN users u ON u.id = ut.user_id
WHERE ut.team_id = $1
@@ -123,6 +123,7 @@ ORDER BY ut.created_at
type GetTeamMembersRow struct {
ID string `json:"id"`
+ Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
JoinedAt pgtype.Timestamptz `json:"joined_at"`
@@ -139,6 +140,7 @@ func (q *Queries) GetTeamMembers(ctx context.Context, teamID string) ([]GetTeamM
var i GetTeamMembersRow
if err := rows.Scan(
&i.ID,
+ &i.Name,
&i.Email,
&i.Role,
&i.JoinedAt,
diff --git a/internal/db/users.sql.go b/internal/db/users.sql.go
index 82c7c4b..50ba287 100644
--- a/internal/db/users.sql.go
+++ b/internal/db/users.sql.go
@@ -55,7 +55,7 @@ func (q *Queries) GetAdminPermissions(ctx context.Context, userID string) ([]Adm
}
const getAdminUsers = `-- name: GetAdminUsers :many
-SELECT id, email, password_hash, created_at, updated_at, is_admin FROM users WHERE is_admin = TRUE ORDER BY created_at
+SELECT id, email, password_hash, created_at, updated_at, is_admin, name FROM users WHERE is_admin = TRUE ORDER BY created_at
`
func (q *Queries) GetAdminUsers(ctx context.Context) ([]User, error) {
@@ -74,6 +74,7 @@ func (q *Queries) GetAdminUsers(ctx context.Context) ([]User, error) {
&i.CreatedAt,
&i.UpdatedAt,
&i.IsAdmin,
+ &i.Name,
); err != nil {
return nil, err
}
@@ -86,7 +87,7 @@ func (q *Queries) GetAdminUsers(ctx context.Context) ([]User, error) {
}
const getUserByEmail = `-- name: GetUserByEmail :one
-SELECT id, email, password_hash, created_at, updated_at, is_admin FROM users WHERE email = $1
+SELECT id, email, password_hash, created_at, updated_at, is_admin, name FROM users WHERE email = $1
`
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) {
@@ -99,12 +100,13 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error
&i.CreatedAt,
&i.UpdatedAt,
&i.IsAdmin,
+ &i.Name,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
-SELECT id, email, password_hash, created_at, updated_at, is_admin FROM users WHERE id = $1
+SELECT id, email, password_hash, created_at, updated_at, is_admin, name FROM users WHERE id = $1
`
func (q *Queries) GetUserByID(ctx context.Context, id string) (User, error) {
@@ -117,6 +119,7 @@ func (q *Queries) GetUserByID(ctx context.Context, id string) (User, error) {
&i.CreatedAt,
&i.UpdatedAt,
&i.IsAdmin,
+ &i.Name,
)
return i, err
}
@@ -156,19 +159,25 @@ func (q *Queries) InsertAdminPermission(ctx context.Context, arg InsertAdminPerm
}
const insertUser = `-- name: InsertUser :one
-INSERT INTO users (id, email, password_hash)
-VALUES ($1, $2, $3)
-RETURNING id, email, password_hash, created_at, updated_at, is_admin
+INSERT INTO users (id, email, password_hash, name)
+VALUES ($1, $2, $3, $4)
+RETURNING id, email, password_hash, created_at, updated_at, is_admin, name
`
type InsertUserParams struct {
ID string `json:"id"`
Email string `json:"email"`
PasswordHash pgtype.Text `json:"password_hash"`
+ Name string `json:"name"`
}
func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (User, error) {
- row := q.db.QueryRow(ctx, insertUser, arg.ID, arg.Email, arg.PasswordHash)
+ row := q.db.QueryRow(ctx, insertUser,
+ arg.ID,
+ arg.Email,
+ arg.PasswordHash,
+ arg.Name,
+ )
var i User
err := row.Scan(
&i.ID,
@@ -177,23 +186,25 @@ func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (User, e
&i.CreatedAt,
&i.UpdatedAt,
&i.IsAdmin,
+ &i.Name,
)
return i, err
}
const insertUserOAuth = `-- name: InsertUserOAuth :one
-INSERT INTO users (id, email)
-VALUES ($1, $2)
-RETURNING id, email, password_hash, created_at, updated_at, is_admin
+INSERT INTO users (id, email, name)
+VALUES ($1, $2, $3)
+RETURNING id, email, password_hash, created_at, updated_at, is_admin, name
`
type InsertUserOAuthParams struct {
ID string `json:"id"`
Email string `json:"email"`
+ Name string `json:"name"`
}
func (q *Queries) InsertUserOAuth(ctx context.Context, arg InsertUserOAuthParams) (User, error) {
- row := q.db.QueryRow(ctx, insertUserOAuth, arg.ID, arg.Email)
+ row := q.db.QueryRow(ctx, insertUserOAuth, arg.ID, arg.Email, arg.Name)
var i User
err := row.Scan(
&i.ID,
@@ -202,6 +213,7 @@ func (q *Queries) InsertUserOAuth(ctx context.Context, arg InsertUserOAuthParams
&i.CreatedAt,
&i.UpdatedAt,
&i.IsAdmin,
+ &i.Name,
)
return i, err
}
@@ -248,3 +260,17 @@ func (q *Queries) SetUserAdmin(ctx context.Context, arg SetUserAdminParams) erro
_, err := q.db.Exec(ctx, setUserAdmin, arg.ID, arg.IsAdmin)
return err
}
+
+const updateUserName = `-- name: UpdateUserName :exec
+UPDATE users SET name = $2, updated_at = NOW() WHERE id = $1
+`
+
+type UpdateUserNameParams struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+func (q *Queries) UpdateUserName(ctx context.Context, arg UpdateUserNameParams) error {
+ _, err := q.db.Exec(ctx, updateUserName, arg.ID, arg.Name)
+ return err
+}
diff --git a/internal/service/host.go b/internal/service/host.go
index bae412e..a331a58 100644
--- a/internal/service/host.go
+++ b/internal/service/host.go
@@ -96,9 +96,10 @@ func (s *HostService) Create(ctx context.Context, p HostCreateParams) (HostCreat
}
}
- // Validate team exists for BYOC hosts.
+ // Validate team exists and is not deleted for BYOC hosts.
if p.TeamID != "" {
- if _, err := s.DB.GetTeam(ctx, p.TeamID); err != nil {
+ team, err := s.DB.GetTeam(ctx, p.TeamID)
+ if err != nil || team.DeletedAt.Valid {
return HostCreateResult{}, fmt.Errorf("invalid request: team not found")
}
}
diff --git a/internal/service/team.go b/internal/service/team.go
index 888e53f..87baadc 100644
--- a/internal/service/team.go
+++ b/internal/service/team.go
@@ -33,9 +33,10 @@ type TeamWithRole struct {
Role string `json:"role"`
}
-// MemberInfo is a team member with resolved email.
+// MemberInfo is a team member with resolved user details.
type MemberInfo struct {
UserID string `json:"user_id"`
+ Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
JoinedAt time.Time `json:"joined_at"`
@@ -215,6 +216,7 @@ func (s *TeamService) GetMembers(ctx context.Context, teamID string) ([]MemberIn
}
members[i] = MemberInfo{
UserID: r.ID,
+ Name: r.Name,
Email: r.Email,
Role: r.Role,
JoinedAt: joinedAt,
@@ -262,7 +264,7 @@ func (s *TeamService) AddMember(ctx context.Context, teamID, callerUserID, email
return MemberInfo{}, fmt.Errorf("insert member: %w", err)
}
- return MemberInfo{UserID: target.ID, Email: target.Email, Role: "member"}, nil
+ return MemberInfo{UserID: target.ID, Name: target.Name, Email: target.Email, Role: "member"}, nil
}
// RemoveMember removes a user from the team.