diff --git a/internal/api/server.go b/internal/api/server.go index 576fc37b9..3db792449 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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) diff --git a/internal/api/users/profile_handlers.go b/internal/api/users/profile_handlers.go index 87def0e56..ae77237c5 100644 --- a/internal/api/users/profile_handlers.go +++ b/internal/api/users/profile_handlers.go @@ -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 @@ -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") @@ -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") } @@ -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.", }) diff --git a/internal/api/users/profile_service.go b/internal/api/users/profile_service.go index 58ce963be..2c2598b8b 100644 --- a/internal/api/users/profile_service.go +++ b/internal/api/users/profile_service.go @@ -76,13 +76,15 @@ 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, @@ -90,17 +92,17 @@ func (ps *ProfileService) UpdateUserProfile(ctx context.Context, userID int64, r }) 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") } @@ -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) } diff --git a/internal/docindex/docs/routes_guide/settings/account.md b/internal/docindex/docs/routes_guide/settings/account.md new file mode 100644 index 000000000..32854641b --- /dev/null +++ b/internal/docindex/docs/routes_guide/settings/account.md @@ -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) diff --git a/internal/docindex/docs/routes_guide/settings/settings-overview.md b/internal/docindex/docs/routes_guide/settings/settings-overview.md index 85254ea9c..b832c7915 100644 --- a/internal/docindex/docs/routes_guide/settings/settings-overview.md +++ b/internal/docindex/docs/routes_guide/settings/settings-overview.md @@ -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) | @@ -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), diff --git a/storage/users/profile_store.go b/storage/users/profile_store.go index b7adf5520..a7e5c325d 100644 --- a/storage/users/profile_store.go +++ b/storage/users/profile_store.go @@ -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, ` @@ -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) { @@ -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 diff --git a/ui/src/api/profile.ts b/ui/src/api/profile.ts new file mode 100644 index 000000000..74a4c9b13 --- /dev/null +++ b/ui/src/api/profile.ts @@ -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 => { + return apiClient.put('/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); +}; diff --git a/ui/src/components/Navbar/AccountMenu.tsx b/ui/src/components/Navbar/AccountMenu.tsx new file mode 100644 index 000000000..b08678404 --- /dev/null +++ b/ui/src/components/Navbar/AccountMenu.tsx @@ -0,0 +1,122 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Link } from 'react-router-dom'; +import classNames from 'classnames'; +import { useAppSelector } from '../../store/configureStore'; +import { Avatar } from './Avatar'; + +export type AccountMenuProps = { + onLogout?: () => void; + className?: string; +}; + +export const AccountMenu: React.FC = ({ onLogout, className }) => { + const user = useAppSelector((state) => state.Auth.user); + const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [isOpen]); + + if (!user) return null; + + const displayName = user.name || user.email; + + return ( +
+ + + {isOpen && ( +
+
+ +
+
{displayName}
+
{user.email}
+
+
+ + setIsOpen(false)} + className="flex items-center gap-2 px-4 py-2 text-sm text-slate-200 hover:bg-slate-700/60 transition-colors" + > + + + + Profile and preferences + + + setIsOpen(false)} + className="flex items-center gap-2 px-4 py-2 text-sm text-slate-200 hover:bg-slate-700/60 transition-colors" + > + + + + Change password + + + setIsOpen(false)} + className="flex items-center gap-2 px-4 py-2 text-sm text-slate-200 hover:bg-slate-700/60 transition-colors" + > + + + + API keys + + + {onLogout && ( + <> +
+ + + )} +
+ )} +
+ ); +}; diff --git a/ui/src/components/Navbar/Avatar.tsx b/ui/src/components/Navbar/Avatar.tsx new file mode 100644 index 000000000..673a5638d --- /dev/null +++ b/ui/src/components/Navbar/Avatar.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import classNames from 'classnames'; +import { avatarColorFor, avatarInitialsFor } from '../../utils/avatarIdentity'; + +export type AvatarProps = { + identity: { id?: number | string; name?: string; email?: string }; + size?: number; + className?: string; +}; + +export const Avatar: React.FC = ({ identity, size = 36, className }) => { + const backgroundColor = avatarColorFor(identity); + const initials = avatarInitialsFor(identity); + + return ( + + ); +}; diff --git a/ui/src/components/Navbar/Navbar.tsx b/ui/src/components/Navbar/Navbar.tsx index 796ee7bf8..68a079570 100644 --- a/ui/src/components/Navbar/Navbar.tsx +++ b/ui/src/components/Navbar/Navbar.tsx @@ -20,6 +20,7 @@ import { NavMegaMenu } from './NavMegaMenu'; import { EnterpriseModal } from './EnterpriseModal'; import { shortcutKeyLabel } from '../../utils/platform'; import { NotificationBell } from '../Notifications/NotificationBell'; +import { AccountMenu } from './AccountMenu'; import { add as addNotification, dismiss as dismissNotification } from '../../store/Notifications/slice'; import { prefetchRoute } from '../../utils/routePrefetch'; import { triggerNavigationProgress } from '../NavigationProgressBar'; @@ -911,20 +912,8 @@ export const Navbar: React.FC = ({ title, activePage = 'dashboard', className="ml-1 2xl:ml-2" /> - {/* Logout button */} - {onLogout && ( - - )} + {/* Account menu (profile, password, API keys, sign out) */} +
@@ -974,20 +963,10 @@ export const Navbar: React.FC = ({ title, activePage = 'dashboard', ))} - {/* Mobile logout button */} - {onLogout && ( - - )} + {/* Mobile account menu (profile, password, API keys, sign out) */} +
+ +
)} diff --git a/ui/src/components/Navbar/megaMenuData.ts b/ui/src/components/Navbar/megaMenuData.ts index ce6ebc597..9c32b60d0 100644 --- a/ui/src/components/Navbar/megaMenuData.ts +++ b/ui/src/components/Navbar/megaMenuData.ts @@ -223,6 +223,11 @@ export const buildMegaMenuSections = (): MegaMenuSection[] => [ icon: React.createElement(Icons.Settings), path: '/settings', items: [ + group('My Account', [ + link('Profile and Preferences', React.createElement(Icons.User), '/settings#account'), + link('Change Password', React.createElement(Icons.Settings), '/settings#account'), + link('My API Keys', React.createElement(Icons.Settings), '/settings#api-keys'), + ], React.createElement(Icons.User)), group('Manage Team', [ link('View Users', React.createElement(Icons.List), '/settings#users', (ctx) => ctx.isSuperAdmin || ctx.hasOrg), link('Invite User', React.createElement(Icons.UserAdd), '/settings/users/add', (ctx) => ctx.isSuperAdmin || ctx.orgRole === 'owner'), diff --git a/ui/src/components/OrganizationSelector/OrganizationSelector.tsx b/ui/src/components/OrganizationSelector/OrganizationSelector.tsx index c2740fec0..ac4292c78 100644 --- a/ui/src/components/OrganizationSelector/OrganizationSelector.tsx +++ b/ui/src/components/OrganizationSelector/OrganizationSelector.tsx @@ -5,6 +5,7 @@ import { Button, Icons } from '../UIPrimitives'; import { useOrgContext } from '../../hooks/useOrgContext'; import { Organization } from '../../store/Organizations/types'; import { CreateOrganizationModal } from '../CreateOrganizationModal'; +import { formatRoleLabel } from '../../utils/formatRole'; export interface OrganizationSelectorProps { /** @@ -87,8 +88,11 @@ export const OrganizationSelector: React.FC = ({ } }, [orgSelectorOpen]); - // Filter organizations + // Other organizations, filtered by search. The current org is pinned in + // its own section above this list (regardless of search text), so it's + // excluded here to avoid showing it twice. const filteredOrgs = userOrganizations.filter(org => + org.id !== currentOrg?.id && org.name.toLowerCase().includes(searchTerm.toLowerCase()) ); @@ -324,18 +328,42 @@ export const OrganizationSelector: React.FC = ({ - {/* Organizations List - Scrollable */} + {/* Current organization - pinned, always visible regardless of search */} + {currentOrg && ( +
+
+ Current organization +
+
+ + + +
+
{currentOrg.name}
+ {currentOrg.role && ( +
{formatRoleLabel(currentOrg.role)}
+ )} +
+ + + +
+
+ )} + + {/* Other organizations - scrollable, filtered by search */} +
+
+ Other organizations +
+
{filteredOrgs.length > 0 ? ( filteredOrgs.map((org) => ( )) ) : (
- No organizations found + {searchTerm ? 'No organizations found' : 'No other organizations'}
)}
diff --git a/ui/src/components/UserManagement/BulkOnboardingDetails.tsx b/ui/src/components/UserManagement/BulkOnboardingDetails.tsx index 3cb0688c8..61bf2d1d1 100644 --- a/ui/src/components/UserManagement/BulkOnboardingDetails.tsx +++ b/ui/src/components/UserManagement/BulkOnboardingDetails.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import { notify } from '../../utils/notify'; +import { formatRoleLabel } from '../../utils/formatRole'; import { Button } from '../UIPrimitives'; export interface BulkOnboardingRow { @@ -77,7 +78,7 @@ export const BulkOnboardingDetails: React.FC = ({ ro

{row.email}

- {row.role} + {formatRoleLabel(row.role)} {row.status} diff --git a/ui/src/components/UserManagement/UserList.tsx b/ui/src/components/UserManagement/UserList.tsx index a54b67e33..955fe34f0 100644 --- a/ui/src/components/UserManagement/UserList.tsx +++ b/ui/src/components/UserManagement/UserList.tsx @@ -18,6 +18,7 @@ import { SortIcon, SortableHeaderLabel, HeaderFilterPopover } from '../DataTable import { Member } from '../../api/users'; import { useOrgContext } from '../../hooks/useOrgContext'; import { notify } from '../../utils/notify'; +import { formatRoleLabel } from '../../utils/formatRole'; const USER_LIST_COLUMN_WIDTHS_WITH_CHECKBOX_AND_ORG = ['4%', '32%', '11%', '11%', '14%', '14%', '14%']; const USER_LIST_COLUMN_WIDTHS_WITH_CHECKBOX = ['4%', '38%', '13%', '13%', '16%', '16%']; @@ -263,7 +264,7 @@ export const UserList: React.FC = ({
), cell: ({ row }) => ( - {row.original.role} + {formatRoleLabel(row.original.role)} ), }); diff --git a/ui/src/components/UserManagement/UserOnboardingDetails.tsx b/ui/src/components/UserManagement/UserOnboardingDetails.tsx index c22e93ad7..563fca3f5 100644 --- a/ui/src/components/UserManagement/UserOnboardingDetails.tsx +++ b/ui/src/components/UserManagement/UserOnboardingDetails.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import { notify } from '../../utils/notify'; +import { formatRoleLabel } from '../../utils/formatRole'; import { Member } from '../../api/users'; import { Button } from '../UIPrimitives'; @@ -72,7 +73,7 @@ export const UserOnboardingDetails: React.FC = ({ us
Role - {user.role} + {formatRoleLabel(user.role)}
diff --git a/ui/src/pages/Settings/AccountTab.tsx b/ui/src/pages/Settings/AccountTab.tsx new file mode 100644 index 000000000..3b958a4e4 --- /dev/null +++ b/ui/src/pages/Settings/AccountTab.tsx @@ -0,0 +1,136 @@ +import React, { useState } from 'react'; +import { Button, Input } from '../../components/UIPrimitives'; +import { notify } from '../../utils/notify'; +import { updateProfile, changePassword } from '../../api/profile'; +import { useAppDispatch, useAppSelector } from '../../store/configureStore'; +import { fetchUser } from '../../store/Auth/reducer'; +import { Avatar } from '../../components/Navbar/Avatar'; + +const splitName = (name?: string): { first: string; last: string } => { + const parts = (name || '').trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return { first: '', last: '' }; + if (parts.length === 1) return { first: parts[0], last: '' }; + return { first: parts[0], last: parts.slice(1).join(' ') }; +}; + +const AccountTab: React.FC = () => { + const dispatch = useAppDispatch(); + const user = useAppSelector((state) => state.Auth.user); + const { first: initialFirst, last: initialLast } = splitName(user?.name); + + const [firstName, setFirstName] = useState(initialFirst); + const [lastName, setLastName] = useState(initialLast); + const [isSavingProfile, setIsSavingProfile] = useState(false); + + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [isSavingPassword, setIsSavingPassword] = useState(false); + + if (!user) return null; + + const handleSaveProfile = async (e: React.FormEvent) => { + e.preventDefault(); + setIsSavingProfile(true); + try { + await updateProfile({ first_name: firstName.trim(), last_name: lastName.trim() }); + await dispatch(fetchUser()); + notify.success('Profile updated.'); + } catch (err) { + notify.error(err instanceof Error ? err.message : 'Failed to update profile.'); + } finally { + setIsSavingProfile(false); + } + }; + + const handleChangePassword = async (e: React.FormEvent) => { + e.preventDefault(); + if (newPassword.length < 8) { + notify.error('New password must be at least 8 characters long.'); + return; + } + if (newPassword !== confirmPassword) { + notify.error('New password and confirmation do not match.'); + return; + } + setIsSavingPassword(true); + try { + await changePassword({ current_password: currentPassword, new_password: newPassword }); + notify.success('Password changed successfully.'); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + } catch (err) { + notify.error(err instanceof Error ? err.message : 'Failed to change password.'); + } finally { + setIsSavingPassword(false); + } + }; + + return ( +
+
+ +
+
{user.name || user.email}
+
{user.email}
+
+
+ +
+

Profile and preferences

+
+
+ + setFirstName(e.target.value)} placeholder="First name" /> +
+
+ + setLastName(e.target.value)} placeholder="Last name" /> +
+
+ +
+ +
+

Change password

+
+ + setCurrentPassword(e.target.value)} + autoComplete="current-password" + /> +
+
+
+ + setNewPassword(e.target.value)} + autoComplete="new-password" + /> +
+
+ + setConfirmPassword(e.target.value)} + autoComplete="new-password" + /> +
+
+ +
+
+ ); +}; + +export default AccountTab; diff --git a/ui/src/pages/Settings/Settings.tsx b/ui/src/pages/Settings/Settings.tsx index 087d1d87e..38454e627 100644 --- a/ui/src/pages/Settings/Settings.tsx +++ b/ui/src/pages/Settings/Settings.tsx @@ -13,6 +13,7 @@ import StorageSettingsTab from './StorageSettingsTab'; import ManagedAITab from './ManagedAITab'; import CompactionSettingsTab from './CompactionSettingsTab'; import AutoApprovePolicyTab from './AutoApprovePolicyTab'; +import AccountTab from './AccountTab'; import { UserManagement } from '../../components/UserManagement'; import LicenseManagement from '../Licenses/LicenseManagement'; import { useOrgContext } from '../../hooks/useOrgContext'; @@ -292,6 +293,8 @@ const Settings = () => { // Available tabs based on permissions const tabs = [ + // Self-service: every authenticated user can manage their own profile and password, regardless of org role + { id: 'account', name: 'Account', icon: }, // Instance-wide config: self-hosted owners may manage it, but in cloud it's shared across every tenant, so it stays super-admin-only there ...(canManageInstanceConfig ? [{ id: 'instance', name: 'Instance', icon: }] : []), ...(canManageCurrentOrg && !isCloudMode() ? [{ @@ -635,6 +638,12 @@ const Settings = () => {
+ {activeTab === 'account' && ( + + + + )} + {activeTab === 'prompts' && canAccessPrompts && ( diff --git a/ui/src/utils/avatarIdentity.ts b/ui/src/utils/avatarIdentity.ts new file mode 100644 index 000000000..31e84eff1 --- /dev/null +++ b/ui/src/utils/avatarIdentity.ts @@ -0,0 +1,68 @@ +// Deterministic "Google-style" avatar identity: the same user always gets the +// same initials and the same background color, derived from stable fields +// (user id, falling back to email) rather than anything that can change +// per-render or per-session. + +const AVATAR_PALETTE = [ + '#1d4ed8', // blue-700 + '#0f766e', // teal-700 + '#7e22ce', // purple-700 + '#b91c1c', // red-700 + '#a16207', // amber-700 + '#15803d', // green-700 + '#be185d', // pink-700 + '#4338ca', // indigo-700 + '#c2410c', // orange-700 + '#0e7490', // cyan-700 + '#6d28d9', // violet-700 + '#4d7c0f', // lime-700 +]; + +// 32-bit DJB2 hash - small, stable, good-enough distribution for picking a +// palette index (not used for anything security-sensitive). +const hashString = (value: string): number => { + let hash = 5381; + for (let i = 0; i < value.length; i++) { + hash = (hash * 33) ^ value.charCodeAt(i); + } + return Math.abs(hash >>> 0); +}; + +/** + * Picks a stable background color for a user identity. Prefers the numeric + * user id (never changes); falls back to email for callers that only have + * that (e.g. before `user.id` is loaded). + */ +export const avatarColorFor = (identity: { id?: number | string; email?: string }): string => { + const key = identity.id !== undefined && identity.id !== null && identity.id !== '' + ? `id:${identity.id}` + : `email:${(identity.email || '').trim().toLowerCase()}`; + const index = hashString(key) % AVATAR_PALETTE.length; + return AVATAR_PALETTE[index]; +}; + +/** + * Derives initials from a display name if present, otherwise from the local + * part of an email address. Always 1-2 uppercase characters. + */ +export const avatarInitialsFor = (identity: { name?: string; email?: string }): string => { + const name = (identity.name || '').trim(); + if (name) { + const words = name.split(/\s+/).filter(Boolean); + if (words.length >= 2) { + return (words[0][0] + words[1][0]).toUpperCase(); + } + return words[0].slice(0, 2).toUpperCase(); + } + + const email = (identity.email || '').trim(); + const localPart = email.split('@')[0] || ''; + const alnum = localPart.replace(/[^a-zA-Z0-9]/g, ''); + if (alnum.length >= 2) { + return alnum.slice(0, 2).toUpperCase(); + } + if (alnum.length === 1) { + return alnum.toUpperCase(); + } + return '?'; +}; diff --git a/ui/src/utils/formatRole.ts b/ui/src/utils/formatRole.ts new file mode 100644 index 000000000..12840c07a --- /dev/null +++ b/ui/src/utils/formatRole.ts @@ -0,0 +1,13 @@ +// Formats a raw org role string (e.g. "super_admin") into a display label +// (e.g. "Super Admin"). CSS `capitalize` alone can't do this - it only +// capitalizes the first letter of whitespace-separated words, so +// "super_admin" renders as "Super_admin", not "Super Admin". +export const formatRoleLabel = (role?: string | null): string => { + const raw = (role || '').trim(); + if (!raw) return ''; + return raw + .split('_') + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); +};