From f123273a9abc272e7367b1c20269ebe90d7a5d25 Mon Sep 17 00:00:00 2001 From: Shrijith Venkatramana Date: Tue, 27 Oct 2026 10:22:44 +0000 Subject: [PATCH] Add account menu with avatar, self-service profile/password, and org selector fixes Replaces the bare logout icon in the navbar with an account menu (deterministic avatar with stable initials/color, profile/password/API-key links, sign out). Adds a self-service Account settings tab backed by the existing /users/profile and /users/password endpoints, which are now session-only and commit their audit event atomically with the write (previously unaudited and reachable via API key, despite being credential changes). Also fixes the organization selector to pin the current org at the top of the switcher (instead of wherever it falls in the list) and to render role labels properly ("Super Admin" instead of "Super_admin"), applied consistently wherever org roles are rendered. LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%) --- internal/api/server.go | 2 +- internal/api/users/profile_handlers.go | 128 +++++++++++++++-- internal/api/users/profile_service.go | 24 ++-- .../docs/routes_guide/settings/account.md | 21 +++ .../settings/settings-overview.md | 3 +- storage/users/profile_store.go | 22 ++- ui/src/api/profile.ts | 39 +++++ ui/src/components/Navbar/AccountMenu.tsx | 122 ++++++++++++++++ ui/src/components/Navbar/Avatar.tsx | 29 ++++ ui/src/components/Navbar/Navbar.tsx | 35 +---- ui/src/components/Navbar/megaMenuData.ts | 5 + .../OrganizationSelector.tsx | 51 +++++-- .../UserManagement/BulkOnboardingDetails.tsx | 3 +- ui/src/components/UserManagement/UserList.tsx | 3 +- .../UserManagement/UserOnboardingDetails.tsx | 3 +- ui/src/pages/Settings/AccountTab.tsx | 136 ++++++++++++++++++ ui/src/pages/Settings/Settings.tsx | 9 ++ ui/src/utils/avatarIdentity.ts | 68 +++++++++ ui/src/utils/formatRole.ts | 13 ++ 19 files changed, 632 insertions(+), 84 deletions(-) create mode 100644 internal/docindex/docs/routes_guide/settings/account.md create mode 100644 ui/src/api/profile.ts create mode 100644 ui/src/components/Navbar/AccountMenu.tsx create mode 100644 ui/src/components/Navbar/Avatar.tsx create mode 100644 ui/src/pages/Settings/AccountTab.tsx create mode 100644 ui/src/utils/avatarIdentity.ts create mode 100644 ui/src/utils/formatRole.ts 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(' '); +};