package api import ( "context" "encoding/json" "errors" "net/http" "regexp" "strings" "time" "github.com/agent-matrix/matrix-runtime/internal/store" ) var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`) // userJSON is the public shape of a user returned to the console. func userJSON(u *store.User) map[string]any { return map[string]any{ "id": u.ID, "name": u.Name, "email": u.Email, "role": u.Role, "workspace": u.WorkspaceName, "workspace_slug": u.WorkspaceSlug, "workspace_id": u.WorkspaceID, } } func (s *Server) requireStore(w http.ResponseWriter) bool { if s.store == nil { writeError(w, http.StatusServiceUnavailable, "user store is not available") return false } return true } func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) { if !s.requireStore(w) { return } var req struct{ Name, Email, Password, Workspace string } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON body") return } req.Email = strings.TrimSpace(req.Email) if !emailRe.MatchString(req.Email) { writeError(w, http.StatusBadRequest, "enter a valid email address") return } if len(req.Password) < 6 { writeError(w, http.StatusBadRequest, "password must be at least 6 characters") return } if strings.TrimSpace(req.Name) == "" { writeError(w, http.StatusBadRequest, "name is required") return } u, err := s.store.Signup(req.Name, req.Email, req.Password, req.Workspace) if err != nil { if errors.Is(err, store.ErrEmailTaken) { writeError(w, http.StatusConflict, err.Error()) return } writeError(w, http.StatusInternalServerError, "could not create account") return } token, err := s.store.CreateSession(u.ID) if err != nil { writeError(w, http.StatusInternalServerError, "could not start session") return } s.audit(r, u.WorkspaceID, u.ID, "user.signup", u.Email, "success", nil) // Best-effort welcome + verification email (never blocks signup success). if verifyTok, verr := s.store.CreateEmailVerification(u.ID, 24*time.Hour); verr == nil { go func() { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() _ = s.email.SendWelcome(ctx, u.Email, u.Name, verifyTok) }() } writeJSON(w, http.StatusCreated, map[string]any{"token": token, "user": userJSON(u)}) } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { if !s.requireStore(w) { return } var req struct{ Email, Password string } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON body") return } u, err := s.store.Login(req.Email, req.Password) if err != nil { s.audit(r, "", strings.ToLower(strings.TrimSpace(req.Email)), "user.login", req.Email, "failure", nil) writeError(w, http.StatusUnauthorized, "invalid email or password") return } token, err := s.store.CreateSession(u.ID) if err != nil { writeError(w, http.StatusInternalServerError, "could not start session") return } s.audit(r, u.WorkspaceID, u.ID, "user.login", u.Email, "success", nil) writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": userJSON(u)}) } func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { if !s.requireStore(w) { return } u, err := s.store.UserBySession(bearer(r)) if err != nil { writeError(w, http.StatusUnauthorized, "not authenticated") return } writeJSON(w, http.StatusOK, map[string]any{"user": userJSON(u)}) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { if !s.requireStore(w) { return } token := bearer(r) all := r.URL.Query().Get("all") == "true" if all { if u, err := s.store.UserBySession(token); err == nil { _ = s.store.DeleteUserSessions(u.ID) } } _ = s.store.DeleteSession(token) writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } func bearer(r *http.Request) string { return strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) } // handleForgotPassword always responds 200 (to avoid leaking which emails are // registered). When the email matches a user, a reset link is emailed via // Resend with a single-use, 1-hour token. func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) { if !s.requireStore(w) { return } var req struct{ Email string } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON body") return } const ok = "If an account exists for that email, a reset link is on its way." u, err := s.store.UserByEmail(req.Email) if err != nil { // Don't reveal absence; respond identically. writeJSON(w, http.StatusOK, map[string]any{"ok": true, "message": ok}) return } token, err := s.store.CreatePasswordReset(u.ID, time.Hour) if err != nil { writeError(w, http.StatusInternalServerError, "could not start password reset") return } if err := s.email.SendPasswordReset(r.Context(), u.Email, token); err != nil { writeError(w, http.StatusBadGateway, "could not send reset email") return } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "message": ok}) } // handleResetPassword consumes a reset token and sets a new password. func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) { if !s.requireStore(w) { return } var req struct{ Token, Password string } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON body") return } if err := s.store.ResetPassword(strings.TrimSpace(req.Token), req.Password); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "Your password has been updated. Please sign in."}) } // handleVerifyEmail consumes an email-verification token. func (s *Server) handleVerifyEmail(w http.ResponseWriter, r *http.Request) { if !s.requireStore(w) { return } var req struct{ Token string } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON body") return } if _, err := s.store.VerifyEmail(strings.TrimSpace(req.Token)); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "message": "Email verified."}) }