Files
multica/server/internal/handler/auth.go
T
5f52380439 feat(i18n): add French locale (#7141)
* feat(i18n): add French locale

Adds `fr` as a supported UI locale for web and desktop.

- 25 translated resource files under `packages/views/locales/fr/`, at full
  parity with the English source (`locales/parity.test.ts` passes).
- Registers the locale in `SUPPORTED_LOCALES`, the views resource bundle, the
  settings language picker, and the `HTML_LANG` maps for web and desktop.
- Translates the celestial workspace name list. Proper nouns with no
  established French form are left unchanged.
- Landing page: adds the `FR` label but leaves the public `locales` array at
  four, since the marketing copy is not translated.
- Onboarding templates map `fr` to English content, matching the existing
  fallback for locales without authored templates.
- `pick-locale.test.ts` used `fr` as its example of an unsupported locale;
  those assertions now use `es`, plus a new case asserting `fr-FR` resolves
  to `fr`.

Terminology follows the glossary in
`apps/docs/content/docs/developers/conventions.mdx`. The UI uses the formal
register (vouvoiement) consistently.

* fix(i18n): translate the backlog status label in French

The French locale translated six of the seven issue statuses and left
`backlog` as "Backlog". The other locales translate all seven — `待规划`,
`백로그`, `バックログ` — so the odd one out was an oversight, not a choice.

`conventions.mdx` does say role and status enums stay in lowercase English,
but that rule is about surfacing the identifier inside prose ("switched to
`in_progress`"), not about the `status` display block, which every shipped
locale translates.

"À planifier" reads as a pair with "À faire" for `todo`, and matches what
`待规划` conveys: the issue exists but is not scheduled yet.

* fix(i18n): realign the French locale with main

Rebasing on main surfaced two drifts that the parity test caught:

- `settings.json` gained `shortcuts.fixed.open_settings` upstream; added
  the French string.
- `search.json` lost its `pages.*` block in #7054, which now generates the
  command palette's Pages group from the nav registry. Dropped the eight
  French keys that no longer have an English counterpart.

* fix(i18n): resync the French locale with main

157 English keys had no French translation and 97 French keys no longer had an
English counterpart. The parity suite caught all of them.

Added: custom issue statuses (76 keys), the Telegram integration (55), issue
revision conflicts and the status catalogue (20), plugin panels and hook
activity (24), revoked agent access in chat (4), autopilot run quota and the
worktree execution mode (2).

Removed: 97 keys upstream has dropped or renamed, most of them under plugins.*.

Two shape changes worth naming, because a key count does not show them:

- plugins.install used to be the string "Install" and is now an object with
  title/description/placeholder/review. A locale that keeps the old shape does
  not have a missing key, it has a key of the wrong TYPE, and parity alone
  stays silent about it — only writing to it fails.
- telegram_bind mirrors slack_bind key for key, so it is derived from the
  existing translation with the service name substituted rather than written
  again. Two texts that say the same thing drift when they are typed twice.

Register: vouvoiement throughout, consistent with the rest of the locale.

* fix(i18n): complete French locale integration and refresh translations

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-16 02:52:49 +08:00

838 lines
26 KiB
Go

package handler
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"strings"
"time"
"unicode/utf8"
"github.com/golang-jwt/jwt/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/analytics"
"github.com/multica-ai/multica/server/internal/auth"
"github.com/multica-ai/multica/server/internal/logger"
obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// SignupError represents signup restriction errors
type SignupError struct {
Message string
}
func (e SignupError) Error() string {
return e.Message
}
var ErrSignupProhibited = SignupError{Message: "user registration is disabled on this self-hosted instance"}
var ErrEmailNotAllowed = SignupError{Message: "email address or domain not allowed on this instance"}
const (
googleLoginCodeAccountDisabled = "account_disabled"
googleLoginCodeSignupProhibited = "signup_prohibited"
googleLoginCodeEmailNotAllowed = "email_not_allowed"
googleLoginCodeAccountWithoutEmail = "google_account_no_email"
googleLoginCodeInvalidOAuthCode = "oauth_code_invalid"
)
const devVerificationCodeEnv = "MULTICA_DEV_VERIFICATION_CODE"
// supportedLanguages mirrors `SUPPORTED_LOCALES` in packages/core/i18n/types.ts.
// Keep both lists in sync when adding a locale — the user-controlled `language`
// field round-trips through GetMe back into i18n.changeLanguage(), so without
// validation an arbitrary string would persist and echo to every device.
var supportedLanguages = map[string]struct{}{
"en": {},
"zh-Hans": {},
"ko": {},
"ja": {},
"fr": {},
}
type UserResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
AvatarURL *string `json:"avatar_url"`
Language *string `json:"language"`
// Pinned IANA tz; nil = no preference (use browser-detected tz).
Timezone *string `json:"timezone"`
OnboardedAt *string `json:"onboarded_at"`
OnboardingQuestionnaire json.RawMessage `json:"onboarding_questionnaire"`
StarterContentState *string `json:"starter_content_state"`
ProfileDescription string `json:"profile_description"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// MaxProfileDescriptionLen caps the user-supplied profile_description body.
// Picked at 2000 chars per MUL-2406: enough room for role / stack / a few
// preferences, short enough that injecting it into every agent brief
// doesn't move the needle on prompt cost.
const MaxProfileDescriptionLen = 2000
func (h *Handler) userToResponse(u db.User) UserResponse {
// JSONB column is []byte with DEFAULT '{}', so it's never nil at the DB
// level. Defensive coalesce just in case a future ALTER makes the column
// nullable and some row comes back with no default applied.
q := u.OnboardingQuestionnaire
if len(q) == 0 {
q = []byte("{}")
}
return UserResponse{
ID: uuidToString(u.ID),
Name: u.Name,
Email: u.Email,
AvatarURL: h.resolveAvatarURLPtr(textToPtr(u.AvatarUrl)),
Language: textToPtr(u.Language),
Timezone: textToPtr(u.Timezone),
OnboardedAt: timestampToPtr(u.OnboardedAt),
OnboardingQuestionnaire: json.RawMessage(q),
StarterContentState: textToPtr(u.StarterContentState),
ProfileDescription: u.ProfileDescription,
CreatedAt: timestampToString(u.CreatedAt),
UpdatedAt: timestampToString(u.UpdatedAt),
}
}
type LoginResponse struct {
Token string `json:"token"`
User UserResponse `json:"user"`
}
type SendCodeRequest struct {
Email string `json:"email"`
}
type VerifyCodeRequest struct {
Email string `json:"email"`
Code string `json:"code"`
}
func generateCode() (string, error) {
var buf [4]byte
if _, err := rand.Read(buf[:]); err != nil {
return "", err
}
n := binary.BigEndian.Uint32(buf[:]) % 1000000
return fmt.Sprintf("%06d", n), nil
}
func isDevVerificationCode(code string) bool {
if isProductionEnv() {
return false
}
devCode := strings.TrimSpace(os.Getenv(devVerificationCodeEnv))
if !isSixDigitCode(devCode) {
return false
}
return subtle.ConstantTimeCompare([]byte(code), []byte(devCode)) == 1
}
func isProductionEnv() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv("APP_ENV")), "production")
}
func isSixDigitCode(code string) bool {
if len(code) != 6 {
return false
}
for _, ch := range code {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
func (h *Handler) issueJWT(user db.User) (string, error) {
if auth.IsTemporarilyDisabledUser(uuidToString(user.ID), user.Email) {
return "", auth.ErrTemporarilyDisabledUser
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": uuidToString(user.ID),
"email": user.Email,
"name": user.Name,
"exp": time.Now().Add(auth.AuthTokenTTL()).Unix(),
"iat": time.Now().Unix(),
})
return token.SignedString(auth.JWTSecret())
}
// findOrCreateUser returns the existing user for an email, or creates one if
// none exists. isNew reports whether this call created the user — the signup
// event fires on that edge, covering both the verification-code and Google
// OAuth entry points.
func (h *Handler) findOrCreateUser(ctx context.Context, email string) (user db.User, isNew bool, err error) {
if auth.IsTemporarilyDisabledUserEmail(email) {
return db.User{}, false, auth.ErrTemporarilyDisabledUser
}
user, err = h.Queries.GetUserByEmail(ctx, email)
isNew = isNotFound(err)
if err != nil && !isNew {
return db.User{}, false, err
}
if !isNew && auth.IsTemporarilyDisabledUser(uuidToString(user.ID), user.Email) {
return db.User{}, false, auth.ErrTemporarilyDisabledUser
}
if err := h.checkSignupAllowed(email, isNew); err != nil {
return db.User{}, false, err
}
if !isNew {
return user, false, nil
}
name := email
if at := strings.Index(email, "@"); at > 0 {
name = email[:at]
}
created, err := h.Queries.CreateUser(ctx, db.CreateUserParams{
Name: name,
Email: email,
})
if err != nil {
return db.User{}, false, err
}
return created, true, nil
}
// signupSourceFromRequest reads the attribution cookie the web frontend
// sets on the first pageview (UTM + referrer bundle). The frontend writes
// a JSON string URL-encoded into the cookie value — Go does not
// auto-decode Cookie.Value, so we have to unescape here before the string
// lands in PostHog. Missing cookie / decode failures collapse to the
// empty string; that simply omits signup_source from the event rather
// than sending percent-encoded garbage. Never fall back to r.Referer() —
// the frontend has already sanitised attribution and a raw referer can
// leak OAuth code/state from the callback URL.
//
// The cap is the server-side defence against a client that manages to set
// an oversize cookie; it matches SIGNUP_SOURCE_MAX_LEN on the frontend.
const signupSourceMaxLen = 512
func signupSourceFromRequest(r *http.Request) string {
c, err := r.Cookie("multica_signup_source")
if err != nil || c == nil {
return ""
}
decoded, err := url.QueryUnescape(c.Value)
if err != nil {
return ""
}
if len(decoded) > signupSourceMaxLen {
return ""
}
return decoded
}
func (h *Handler) checkSignupAllowed(email string, isNewUser bool) error {
if !isNewUser {
return nil // existing users always allowed to log in
}
email = strings.ToLower(email)
domain := ""
if at := strings.Index(email, "@"); at > 0 {
domain = email[at+1:]
}
// 1. explicit email whitelist always wins
if len(h.cfg.AllowedEmails) > 0 && contains(h.cfg.AllowedEmails, email) {
return nil
}
// 2. domain whitelist always wins
if len(h.cfg.AllowedEmailDomains) > 0 && contains(h.cfg.AllowedEmailDomains, domain) {
return nil
}
// 3. general signup flag
if !h.cfg.AllowSignup {
return ErrSignupProhibited
}
// 4. if allowlists are set but didn't match, block
if len(h.cfg.AllowedEmailDomains) > 0 || len(h.cfg.AllowedEmails) > 0 {
return ErrEmailNotAllowed
}
return nil
}
func contains(slice []string, s string) bool {
for _, item := range slice {
if strings.EqualFold(item, s) {
return true
}
}
return false
}
func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) {
var req SendCodeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
email := strings.ToLower(strings.TrimSpace(req.Email))
if email == "" {
writeError(w, http.StatusBadRequest, "email is required")
return
}
if auth.IsTemporarilyDisabledUserEmail(email) {
writeError(w, http.StatusForbidden, auth.TemporarilyDisabledUserError)
return
}
// Check signup restrictions before sending magic link
existingUser, err := h.Queries.GetUserByEmail(r.Context(), email)
if err != nil {
if !isNotFound(err) {
// Real database/query error → return 500
writeError(w, http.StatusInternalServerError, "failed to lookup user")
return
}
// User does not exist → treat as new user
isNewUser := true
if err := h.checkSignupAllowed(email, isNewUser); err != nil {
var signupErr SignupError
if errors.As(err, &signupErr) {
writeError(w, http.StatusForbidden, signupErr.Error())
} else {
writeError(w, http.StatusForbidden, "user registration is disabled")
}
return
}
} else {
// User already exists → always allowed to login
if auth.IsTemporarilyDisabledUser(uuidToString(existingUser.ID), existingUser.Email) {
writeError(w, http.StatusForbidden, auth.TemporarilyDisabledUserError)
return
}
isNewUser := false
if err := h.checkSignupAllowed(email, isNewUser); err != nil {
// This should rarely happen, but handle it anyway
var signupErr SignupError
if errors.As(err, &signupErr) {
writeError(w, http.StatusForbidden, signupErr.Error())
} else {
writeError(w, http.StatusForbidden, "user registration is disabled")
}
return
}
}
// Rate limit: max 1 code per 60 seconds per email
latest, err := h.Queries.GetLatestCodeByEmail(r.Context(), email)
if err == nil && time.Since(latest.CreatedAt.Time) < 60*time.Second {
writeError(w, http.StatusTooManyRequests, "please wait before requesting another code")
return
}
code, err := generateCode()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate code")
return
}
_, err = h.Queries.CreateVerificationCode(r.Context(), db.CreateVerificationCodeParams{
Email: email,
Code: code,
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(10 * time.Minute), Valid: true},
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to store verification code")
return
}
if err := h.EmailService.SendVerificationCode(email, code); err != nil {
slog.Error("failed to send verification code", "email", email, "error", err)
writeError(w, http.StatusInternalServerError, "failed to send verification code")
return
}
// Best-effort cleanup of expired codes
_ = h.Queries.DeleteExpiredVerificationCodes(r.Context())
writeJSON(w, http.StatusOK, map[string]string{"message": "Verification code sent"})
}
func (h *Handler) VerifyCode(w http.ResponseWriter, r *http.Request) {
var req VerifyCodeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
email := strings.ToLower(strings.TrimSpace(req.Email))
code := strings.TrimSpace(req.Code)
if email == "" || code == "" {
writeError(w, http.StatusBadRequest, "email and code are required")
return
}
if auth.IsTemporarilyDisabledUserEmail(email) {
writeError(w, http.StatusForbidden, auth.TemporarilyDisabledUserError)
return
}
dbCode, err := h.Queries.GetLatestVerificationCode(r.Context(), email)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid or expired code")
return
}
isDevCode := isDevVerificationCode(code)
if !isDevCode && subtle.ConstantTimeCompare([]byte(code), []byte(dbCode.Code)) != 1 {
_ = h.Queries.IncrementVerificationCodeAttempts(r.Context(), dbCode.ID)
writeError(w, http.StatusBadRequest, "invalid or expired code")
return
}
if err := h.Queries.MarkVerificationCodeUsed(r.Context(), dbCode.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to verify code")
return
}
user, isNew, err := h.findOrCreateUser(r.Context(), email)
if err != nil {
if errors.Is(err, auth.ErrTemporarilyDisabledUser) {
writeError(w, http.StatusForbidden, auth.TemporarilyDisabledUserError)
return
}
var signupErr SignupError
if errors.As(err, &signupErr) {
writeError(w, http.StatusForbidden, signupErr.Error())
return
}
writeError(w, http.StatusInternalServerError, "failed to create user")
return
}
if isNew {
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.Signup(uuidToString(user.ID), user.Email, signupSourceFromRequest(r)))
}
tokenString, err := h.issueJWT(user)
if err != nil {
if errors.Is(err, auth.ErrTemporarilyDisabledUser) {
writeError(w, http.StatusForbidden, auth.TemporarilyDisabledUserError)
return
}
slog.Warn("login failed", append(logger.RequestAttrs(r), "error", err, "email", req.Email)...)
writeError(w, http.StatusInternalServerError, "failed to generate token")
return
}
// Set HttpOnly auth cookie (browser clients) + CSRF cookie.
if err := auth.SetAuthCookies(w, tokenString); err != nil {
slog.Warn("failed to set auth cookies", "error", err)
}
// Set CloudFront signed cookies for CDN access.
if h.CFSigner != nil {
for _, cookie := range h.CFSigner.SignedCookies(time.Now().Add(auth.AuthTokenTTL())) {
http.SetCookie(w, cookie)
}
}
slog.Info("user logged in", append(logger.RequestAttrs(r), "user_id", uuidToString(user.ID), "email", user.Email)...)
writeJSON(w, http.StatusOK, LoginResponse{
Token: tokenString,
User: h.userToResponse(user),
})
}
func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
user, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
if errors.Is(err, pgx.ErrNoRows) {
// The credential no longer identifies an existing user. Return the
// same terminal status as an expired token so clients can sign in again.
writeError(w, http.StatusUnauthorized, "user not found")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load user")
return
}
writeJSON(w, http.StatusOK, h.userToResponse(user))
}
type UpdateMeRequest struct {
Name *string `json:"name"`
AvatarURL *string `json:"avatar_url"`
Language *string `json:"language"`
ProfileDescription *string `json:"profile_description"`
// IANA tz to pin; "" clears back to NULL; nil leaves untouched.
Timezone *string `json:"timezone"`
}
type GoogleLoginRequest struct {
Code string `json:"code"`
RedirectURI string `json:"redirect_uri"`
}
type googleTokenResponse struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
TokenType string `json:"token_type"`
}
type googleUserInfo struct {
Email string `json:"email"`
Name string `json:"name"`
Picture string `json:"picture"`
}
func writeGoogleLoginActionableError(w http.ResponseWriter, err error) bool {
switch {
case errors.Is(err, auth.ErrTemporarilyDisabledUser):
writeErrorCode(w, http.StatusForbidden, googleLoginCodeAccountDisabled, auth.TemporarilyDisabledUserError)
case errors.Is(err, ErrSignupProhibited):
writeErrorCode(w, http.StatusForbidden, googleLoginCodeSignupProhibited, ErrSignupProhibited.Error())
case errors.Is(err, ErrEmailNotAllowed):
writeErrorCode(w, http.StatusForbidden, googleLoginCodeEmailNotAllowed, ErrEmailNotAllowed.Error())
default:
var signupErr SignupError
if !errors.As(err, &signupErr) {
return false
}
// Preserve the actionable fallback for signup restrictions without a code.
writeError(w, http.StatusForbidden, signupErr.Error())
}
return true
}
func (h *Handler) googleHTTPClient() *http.Client {
if h.googleOAuthHTTPClient != nil {
return h.googleOAuthHTTPClient
}
return http.DefaultClient
}
func (h *Handler) GoogleLogin(w http.ResponseWriter, r *http.Request) {
var req GoogleLoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Code == "" {
writeError(w, http.StatusBadRequest, "code is required")
return
}
clientID := os.Getenv("GOOGLE_CLIENT_ID")
clientSecret := os.Getenv("GOOGLE_CLIENT_SECRET")
if clientID == "" || clientSecret == "" {
writeError(w, http.StatusServiceUnavailable, "Google login is not configured")
return
}
redirectURI := req.RedirectURI
if redirectURI == "" {
redirectURI = os.Getenv("GOOGLE_REDIRECT_URI")
}
// Exchange authorization code for tokens.
tokenResp, err := h.googleHTTPClient().PostForm("https://oauth2.googleapis.com/token", url.Values{
"code": {req.Code},
"client_id": {clientID},
"client_secret": {clientSecret},
"redirect_uri": {redirectURI},
"grant_type": {"authorization_code"},
})
if err != nil {
slog.Error("google oauth token exchange failed", "error", err)
writeError(w, http.StatusBadGateway, "failed to exchange code with Google")
return
}
defer tokenResp.Body.Close()
tokenBody, err := io.ReadAll(tokenResp.Body)
if err != nil {
writeError(w, http.StatusBadGateway, "failed to read Google token response")
return
}
if tokenResp.StatusCode != http.StatusOK {
slog.Error("google oauth token exchange returned error", "status", tokenResp.StatusCode, "body", string(tokenBody))
var tokenErr struct {
Error string `json:"error"`
}
// Only a valid invalid_grant response identifies a rejected authorization
// code. Configuration, provider and malformed responses are server failures.
if err := json.Unmarshal(tokenBody, &tokenErr); err == nil &&
tokenResp.StatusCode == http.StatusBadRequest && tokenErr.Error == "invalid_grant" {
writeErrorCode(w, http.StatusBadRequest, googleLoginCodeInvalidOAuthCode, "failed to exchange code with Google")
return
}
writeError(w, http.StatusBadGateway, "failed to exchange code with Google")
return
}
var gToken googleTokenResponse
if err := json.Unmarshal(tokenBody, &gToken); err != nil {
writeError(w, http.StatusBadGateway, "failed to parse Google token response")
return
}
if strings.TrimSpace(gToken.AccessToken) == "" {
slog.Error("google oauth token response has no access token")
writeError(w, http.StatusBadGateway, "invalid Google token response")
return
}
// Fetch user info from Google.
userInfoReq, err := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://www.googleapis.com/oauth2/v2/userinfo", nil)
if err != nil {
slog.Error("failed to create userinfo request", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
userInfoReq.Header.Set("Authorization", "Bearer "+gToken.AccessToken)
userInfoResp, err := h.googleHTTPClient().Do(userInfoReq)
if err != nil {
slog.Error("google userinfo fetch failed", "error", err)
writeError(w, http.StatusBadGateway, "failed to fetch user info from Google")
return
}
defer userInfoResp.Body.Close()
if userInfoResp.StatusCode != http.StatusOK {
body, readErr := io.ReadAll(io.LimitReader(userInfoResp.Body, 4096))
if readErr != nil {
slog.Error("google userinfo error response could not be read", "status", userInfoResp.StatusCode, "error", readErr)
} else {
slog.Error("google userinfo returned error", "status", userInfoResp.StatusCode, "body", string(body))
}
writeError(w, http.StatusBadGateway, "failed to fetch user info from Google")
return
}
var gUser *googleUserInfo
if err := json.NewDecoder(userInfoResp.Body).Decode(&gUser); err != nil {
writeError(w, http.StatusBadGateway, "failed to parse Google user info")
return
}
if gUser == nil {
writeError(w, http.StatusBadGateway, "invalid Google user info")
return
}
email := strings.ToLower(strings.TrimSpace(gUser.Email))
if email == "" {
writeErrorCode(w, http.StatusBadRequest, googleLoginCodeAccountWithoutEmail, "Google did not provide an email address for this sign-in")
return
}
if auth.IsTemporarilyDisabledUserEmail(email) {
writeErrorCode(w, http.StatusForbidden, googleLoginCodeAccountDisabled, auth.TemporarilyDisabledUserError)
return
}
user, isNew, err := h.findOrCreateUser(r.Context(), email)
if err != nil {
if writeGoogleLoginActionableError(w, err) {
return
}
writeError(w, http.StatusInternalServerError, "failed to create user")
return
}
if isNew {
evt := analytics.Signup(uuidToString(user.ID), user.Email, signupSourceFromRequest(r))
evt.Properties["auth_method"] = "google"
obsmetrics.RecordEvent(h.Analytics, h.Metrics, evt)
}
// Update name and avatar from Google profile if the user was just created
// (default name is email prefix) or has no avatar yet.
needsUpdate := false
newName := user.Name
newAvatar := user.AvatarUrl
if gUser.Name != "" && user.Name == strings.Split(email, "@")[0] {
newName = gUser.Name
needsUpdate = true
}
if gUser.Picture != "" && !user.AvatarUrl.Valid {
newAvatar = pgtype.Text{String: gUser.Picture, Valid: true}
needsUpdate = true
}
if needsUpdate {
updated, err := h.Queries.UpdateUser(r.Context(), db.UpdateUserParams{
ID: user.ID,
Name: newName,
AvatarUrl: newAvatar,
})
if err == nil {
user = updated
}
}
tokenString, err := h.issueJWT(user)
if err != nil {
if writeGoogleLoginActionableError(w, err) {
return
}
slog.Warn("google login failed", append(logger.RequestAttrs(r), "error", err, "email", email)...)
writeError(w, http.StatusInternalServerError, "failed to generate token")
return
}
if err := auth.SetAuthCookies(w, tokenString); err != nil {
slog.Warn("failed to set auth cookies", "error", err)
}
if h.CFSigner != nil {
for _, cookie := range h.CFSigner.SignedCookies(time.Now().Add(72 * time.Hour)) {
http.SetCookie(w, cookie)
}
}
slog.Info("user logged in via google", append(logger.RequestAttrs(r), "user_id", uuidToString(user.ID), "email", user.Email)...)
writeJSON(w, http.StatusOK, LoginResponse{
Token: tokenString,
User: h.userToResponse(user),
})
}
// IssueCliToken returns a fresh JWT for the authenticated user.
// This allows cookie-authenticated browser sessions to obtain a bearer token
// that can be handed off to the CLI via the cli_callback redirect.
func (h *Handler) IssueCliToken(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
user, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
if err != nil {
writeError(w, http.StatusNotFound, "user not found")
return
}
tokenString, err := h.issueJWT(user)
if err != nil {
if errors.Is(err, auth.ErrTemporarilyDisabledUser) {
writeError(w, http.StatusForbidden, auth.TemporarilyDisabledUserError)
return
}
slog.Warn("cli-token: failed to issue JWT", append(logger.RequestAttrs(r), "error", err, "user_id", userID)...)
writeError(w, http.StatusInternalServerError, "failed to generate token")
return
}
writeJSON(w, http.StatusOK, map[string]string{"token": tokenString})
}
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
auth.ClearAuthCookies(w)
writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"})
}
func (h *Handler) UpdateMe(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
var req UpdateMeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
currentUser, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
if err != nil {
writeError(w, http.StatusNotFound, "user not found")
return
}
name := currentUser.Name
if req.Name != nil {
name = strings.TrimSpace(*req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
}
params := db.UpdateUserParams{
ID: currentUser.ID,
Name: name,
}
if req.AvatarURL != nil {
avatarURL, ok := h.acceptAvatarURL(w, r, *req.AvatarURL, currentUser.AvatarUrl.String)
if !ok {
return
}
params.AvatarUrl = pgtype.Text{String: avatarURL, Valid: true}
}
if req.Language != nil {
lang := strings.TrimSpace(*req.Language)
if _, ok := supportedLanguages[lang]; !ok {
writeError(w, http.StatusBadRequest, "unsupported language")
return
}
params.Language = pgtype.Text{String: lang, Valid: true}
}
if req.ProfileDescription != nil {
// Count runes, not bytes: 2000 chars of Chinese must not be rejected
// as ~6000 bytes. utf8.RuneCountInString handles invalid UTF-8 by
// counting each bad byte as one rune, which still bounds the column.
desc := strings.TrimSpace(*req.ProfileDescription)
if utf8.RuneCountInString(desc) > MaxProfileDescriptionLen {
writeError(w, http.StatusBadRequest, fmt.Sprintf("profile_description exceeds %d characters", MaxProfileDescriptionLen))
return
}
params.ProfileDescription = pgtype.Text{String: desc, Valid: true}
}
if req.Timezone != nil {
// Valid=false → column untouched; Valid=true + "" → clear to
// NULL; Valid=true + IANA → set. Three-way semantics enforced
// in the UpdateUser SQL CASE.
tz := strings.TrimSpace(*req.Timezone)
if tz != "" {
if loc, err := time.LoadLocation(tz); err != nil || loc == nil {
writeError(w, http.StatusBadRequest, "invalid timezone")
return
}
}
params.Timezone = pgtype.Text{String: tz, Valid: true}
}
updatedUser, err := h.Queries.UpdateUser(r.Context(), params)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update user")
return
}
writeJSON(w, http.StatusOK, h.userToResponse(updatedUser))
}