Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ func appContext(port int, versionInfo *VersionInfo) (*Server, error) {

// Initialize profile management system
profileService := users.NewProfileService(db)
profileHandlers := users.NewProfileHandlers(profileService)
profileHandlers := users.NewProfileHandlers(profileService, db, auditRecorder)

// Initialize organization management system
logger := log.New(os.Stdout, "[ORG] ", log.LstdFlags)
Expand Down
128 changes: 114 additions & 14 deletions internal/api/users/profile_handlers.go
Original file line number Diff line number Diff line change
@@ -1,26 +1,60 @@
package users

import (
"database/sql"
"errors"
"net/http"
"strconv"

"github.com/labstack/echo/v4"
"github.com/livereview/internal/api/auth"
"github.com/livereview/internal/auditlog"
storageusers "github.com/livereview/storage/users"
)

// ProfileHandlers contains the profile management handler methods
type ProfileHandlers struct {
profileService *ProfileService
db *sql.DB
audit *auditlog.Recorder
}

// NewProfileHandlers creates a new profile handlers instance
func NewProfileHandlers(profileService *ProfileService) *ProfileHandlers {
func NewProfileHandlers(profileService *ProfileService, db *sql.DB, audit *auditlog.Recorder) *ProfileHandlers {
return &ProfileHandlers{
profileService: profileService,
db: db,
audit: audit,
}
}

// requireSession rejects API-key-authenticated requests. Self-service profile
// and password changes are credential/account changes, so per the scoping
// guardrails they require an active user session (JWT), never an API key.
func requireSession(c echo.Context) error {
if method, _ := c.Get("auth_method").(string); method == "api_key" {
return echo.NewHTTPError(http.StatusForbidden, "this action requires an active user session")
}
return nil
}

// actorFor reports the audit actor kind/capacity for a self-service action,
// mirroring AuthHandlers.actorFor: these routes aren't org-scoped, so the
// generic echo-context enrichment can't infer super-admin status on its own.
func (ph *ProfileHandlers) actorFor(userID int64) (auditlog.ActorKind, auditlog.ActorCapacity) {
var isSuperAdmin bool
_ = ph.db.QueryRow(`
SELECT EXISTS(
SELECT 1 FROM user_roles ur
JOIN roles r ON ur.role_id = r.id
WHERE ur.user_id = $1 AND r.name = $2
)`, userID, auth.RoleSuperAdmin).Scan(&isSuperAdmin)
if isSuperAdmin {
return auditlog.ActorKindSuperAdmin, auditlog.ActorCapacityAdmin
}
return auditlog.ActorKindMember, auditlog.ActorCapacityMember
}

// GetProfile handles getting the current user's profile
func (ph *ProfileHandlers) GetProfile(c echo.Context) error {
// Get user from auth context
Expand Down Expand Up @@ -52,41 +86,79 @@ func (ph *ProfileHandlers) GetProfile(c echo.Context) error {
return c.JSON(http.StatusOK, response)
}

// UpdateProfile handles updating the current user's profile
// UpdateProfile handles updating the current user's profile. Session-only,
// and the write and its audit event commit atomically (fail-closed).
func (ph *ProfileHandlers) UpdateProfile(c echo.Context) error {
// Get user from auth context
if err := requireSession(c); err != nil {
return err
}

user := auth.GetUser(c)
if user == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "User not found in context")
}

// Parse request
var req UpdateProfileRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid request body")
}

// Update profile
profile, err := ph.profileService.UpdateUserProfile(c.Request().Context(), user.ID, req)
tx, err := ph.db.BeginTx(c.Request().Context(), nil)
if err != nil {
if err.Error() == "user not found" {
return echo.NewHTTPError(http.StatusNotFound, "User profile not found")
}
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to update user profile")
}
defer func() { _ = tx.Rollback() }()

if err := ph.profileService.UpdateUserProfile(c.Request().Context(), tx, user.ID, req); err != nil {
if errors.Is(err, storageusers.ErrEmailInUse) {
return echo.NewHTTPError(http.StatusConflict, err.Error())
}
if err.Error() == "email cannot be empty" || err.Error() == "email is invalid" || err.Error() == "first_name is too long" || err.Error() == "last_name is too long" {
switch err.Error() {
case "email cannot be empty", "email is invalid", "first_name is too long", "last_name is too long":
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to update user profile")
}

if ph.audit != nil {
uid := user.ID
actorKind, actorCapacity := ph.actorFor(user.ID)
if err := ph.audit.RecordTxEcho(c, tx, auditlog.Event{
ActorUserID: &uid,
ActorEmail: user.Email,
ActorKind: actorKind,
ActorCapacity: actorCapacity,
AuthMethod: "session",
Action: auditlog.ActionUserUpdate,
ResourceType: "user",
ResourceID: strconv.FormatInt(user.ID, 10),
Outcome: auditlog.OutcomeSuccess,
Severity: auditlog.SeverityWarning,
Details: auditlog.Details("self_service", true),
}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to record audit event")
}
}

if err := tx.Commit(); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to update user profile")
}

profile, err := ph.profileService.GetUserProfile(c.Request().Context(), user.ID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get user profile")
}

return c.JSON(http.StatusOK, profile)
}

// ChangePassword handles changing the current user's password
// ChangePassword handles changing the current user's password. Session-only,
// and the write and its audit event commit atomically (fail-closed).
func (ph *ProfileHandlers) ChangePassword(c echo.Context) error {
// Get user from auth context
if err := requireSession(c); err != nil {
return err
}

user := auth.GetUser(c)
if user == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "User not found in context")
Expand All @@ -103,9 +175,13 @@ func (ph *ProfileHandlers) ChangePassword(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, "New password must be at least 8 characters long")
}

// Change password
err := ph.profileService.ChangePassword(c.Request().Context(), user.ID, req)
tx, err := ph.db.BeginTx(c.Request().Context(), nil)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to change password")
}
defer func() { _ = tx.Rollback() }()

if err := ph.profileService.ChangePassword(c.Request().Context(), tx, user.ID, req); err != nil {
if err.Error() == "user not found" {
return echo.NewHTTPError(http.StatusNotFound, "User not found")
}
Expand All @@ -115,6 +191,30 @@ func (ph *ProfileHandlers) ChangePassword(c echo.Context) error {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to change password")
}

if ph.audit != nil {
uid := user.ID
actorKind, actorCapacity := ph.actorFor(user.ID)
if err := ph.audit.RecordTxEcho(c, tx, auditlog.Event{
ActorUserID: &uid,
ActorEmail: user.Email,
ActorKind: actorKind,
ActorCapacity: actorCapacity,
AuthMethod: "session",
Action: auditlog.ActionPasswordChange,
ResourceType: "user",
ResourceID: strconv.FormatInt(user.ID, 10),
Outcome: auditlog.OutcomeSuccess,
Severity: auditlog.SeverityWarning,
Details: auditlog.Details("method", "password"),
}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to record audit event")
}
}

if err := tx.Commit(); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to change password")
}

return c.JSON(http.StatusOK, map[string]string{
"message": "Password changed successfully. Please log in again with your new password.",
})
Expand Down
24 changes: 13 additions & 11 deletions internal/api/users/profile_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,31 +76,33 @@ func (ps *ProfileService) GetUserProfile(ctx context.Context, userID int64) (*Us
}, nil
}

// UpdateUserProfile updates the user's profile information
func (ps *ProfileService) UpdateUserProfile(ctx context.Context, userID int64, req UpdateProfileRequest) (*UserProfile, error) {
// UpdateUserProfile applies the profile change on tx, so the handler can
// commit it atomically with the audit event. Read the refreshed profile back
// with GetUserProfile after the commit, not before.
func (ps *ProfileService) UpdateUserProfile(ctx context.Context, tx *sql.Tx, userID int64, req UpdateProfileRequest) error {
if err := validateUpdateProfileRequest(req); err != nil {
return nil, err
return err
}

err := ps.store.UpdateUserProfile(ctx, storageusers.UpdateProfileInput{
err := ps.store.UpdateUserProfile(ctx, tx, storageusers.UpdateProfileInput{
UserID: userID,
FirstName: req.FirstName,
LastName: req.LastName,
Email: req.Email,
})
if err != nil {
if errors.Is(err, storageusers.ErrEmailInUse) {
return nil, storageusers.ErrEmailInUse
return storageusers.ErrEmailInUse
}
return nil, fmt.Errorf("failed to update user profile: %w", err)
return fmt.Errorf("failed to update user profile: %w", err)
}

// Get updated profile
return ps.GetUserProfile(ctx, userID)
return nil
}

// ChangePassword changes the user's password
func (ps *ProfileService) ChangePassword(ctx context.Context, userID int64, req ChangePasswordRequest) error {
// ChangePassword changes the user's password on tx, so the handler can
// commit it atomically with the audit event.
func (ps *ProfileService) ChangePassword(ctx context.Context, tx *sql.Tx, userID int64, req ChangePasswordRequest) error {
if strings.TrimSpace(req.CurrentPassword) == "" || strings.TrimSpace(req.NewPassword) == "" {
return fmt.Errorf("current and new password are required")
}
Expand All @@ -125,7 +127,7 @@ func (ps *ProfileService) ChangePassword(ctx context.Context, userID int64, req
return fmt.Errorf("failed to hash new password: %w", err)
}

updated, err := ps.store.UpdatePasswordIfCurrentHash(ctx, userID, currentHash, string(hashedPassword))
updated, err := ps.store.UpdatePasswordIfCurrentHash(ctx, tx, userID, currentHash, string(hashedPassword))
if err != nil {
return fmt.Errorf("failed to update password: %w", err)
}
Expand Down
21 changes: 21 additions & 0 deletions internal/docindex/docs/routes_guide/settings/account.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Settings → Account

**Route:** `/settings#account`
**Who sees it:** every authenticated user (self-service, no org-role gate)

## Purpose

Manage your own profile and credentials: display name and password. This
is the only Settings tab that isn't permission-gated by org role — it acts
on the signed-in user's own account, not on org or instance configuration.

## Key actions

- Update your first/last name.
- Change your password (requires the current password; requires an active
user session — rejected if called via an API key, per the scoping
guardrails for sensitive account actions).

## Related pages

[Settings overview](settings-overview.md), [API Keys](api-keys.md)
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ tabs. Which tabs are visible depends on the user's role

| Tab | id | Who sees it |
|---|---|---|
| Account | `account` | every authenticated user (self-service, no role gate) |
| Instance | `instance` | super_admin, or org owner (self-hosted only) |
| SMTP | `smtp` | super_admin or org owner, self-hosted only |
| Storage | `storage` | super_admin, or org owner (self-hosted only) |
Expand All @@ -35,7 +36,7 @@ the `subscriptions` tab.

## Related pages

Each tab has its own file in this folder: [Instance](instance.md),
Each tab has its own file in this folder: [Account](account.md), [Instance](instance.md),
[SMTP](smtp.md), [Storage](storage.md), [Deployment](deployment.md),
[License](license.md), [Managed AI](managed-ai.md), [Prompts](prompts.md), [Auto Approve](auto-approve.md),
[Learnings](learnings.md),
Expand Down
22 changes: 10 additions & 12 deletions storage/users/profile_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,23 +74,19 @@ func (s *ProfileStore) GetUserProfile(ctx context.Context, userID int64) (*UserP
return rec, nil
}

func (s *ProfileStore) UpdateUserProfile(ctx context.Context, input UpdateProfileInput) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()

// UpdateUserProfile applies the profile change on the caller's transaction so
// it can be committed atomically with the audit event (self-service profile
// edits are fail-closed, like password changes).
func (s *ProfileStore) UpdateUserProfile(ctx context.Context, tx *sql.Tx, input UpdateProfileInput) error {
if input.Email != nil {
var existingUserID int64
err = tx.QueryRowContext(ctx, "SELECT id FROM users WHERE email = $1 AND id != $2", *input.Email, input.UserID).Scan(&existingUserID)
err := tx.QueryRowContext(ctx, "SELECT id FROM users WHERE email = $1 AND id != $2", *input.Email, input.UserID).Scan(&existingUserID)
if err == nil {
return ErrEmailInUse
}
if err != sql.ErrNoRows {
return fmt.Errorf("failed to check existing email: %w", err)
}

}

if _, err := tx.ExecContext(ctx, `
Expand All @@ -108,7 +104,7 @@ func (s *ProfileStore) UpdateUserProfile(ctx context.Context, input UpdateProfil
return err
}

return tx.Commit()
return nil
}

func (s *ProfileStore) GetCurrentPasswordHash(ctx context.Context, userID int64) (string, error) {
Expand All @@ -120,8 +116,10 @@ func (s *ProfileStore) GetCurrentPasswordHash(ctx context.Context, userID int64)
return currentHash, nil
}

func (s *ProfileStore) UpdatePasswordIfCurrentHash(ctx context.Context, userID int64, currentHash, passwordHash string) (bool, error) {
result, err := s.db.ExecContext(ctx, `
// UpdatePasswordIfCurrentHash applies the password change on the caller's
// transaction so it can be committed atomically with the audit event.
func (s *ProfileStore) UpdatePasswordIfCurrentHash(ctx context.Context, tx *sql.Tx, userID int64, currentHash, passwordHash string) (bool, error) {
result, err := tx.ExecContext(ctx, `
UPDATE users
SET password_hash = $1, password_reset_required = false, updated_at = NOW()
WHERE id = $2 AND password_hash = $3
Expand Down
39 changes: 39 additions & 0 deletions ui/src/api/profile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import apiClient from './apiClient';

export interface UserProfile {
id: number;
email: string;
first_name: string | null;
last_name: string | null;
is_active: boolean;
created_at: string;
updated_at: string;
last_login_at: string | null;
plan_type: string | null;
license_expires_at: string | null;
}

export interface UpdateProfileRequest {
first_name?: string;
last_name?: string;
email?: string;
}

export interface ChangePasswordRequest {
current_password: string;
new_password: string;
}

/**
* Update the current user's own name/email. Session-only on the backend.
*/
export const updateProfile = async (req: UpdateProfileRequest): Promise<UserProfile> => {
return apiClient.put<UserProfile>('/users/profile', req);
};

/**
* Change the current user's own password. Session-only on the backend.
*/
export const changePassword = async (req: ChangePasswordRequest): Promise<{ message: string }> => {
return apiClient.put<{ message: string }>('/users/password', req);
};
Loading
Loading