Updates to change/reset password functionality (#5294)
* feat: require old password when changing password * update account settings page - require old password for changing passwords * update profile dropdown - go to /account route for updating account details * Remove all session based on user id after password change * fix: run lint-fix * remove unnecessary error page on account * fix: prevent logout if user provides wrong current password * fix: remove unused user profile page * fix: import --------- Co-authored-by: Henry <hzj94@hotmail.com>
This commit is contained in:
parent
1ae1638ed9
commit
eed7581d0e
|
|
@ -25,6 +25,7 @@ import { OrganizationUser } from '../../enterprise/database/entities/organizatio
|
|||
import { Workspace } from '../../enterprise/database/entities/workspace.entity'
|
||||
import { WorkspaceUser } from '../../enterprise/database/entities/workspace-user.entity'
|
||||
import { LoginMethod } from '../../enterprise/database/entities/login-method.entity'
|
||||
import { LoginSession } from '../../enterprise/database/entities/login-session.entity'
|
||||
|
||||
export const entities = {
|
||||
ChatFlow,
|
||||
|
|
@ -55,5 +56,6 @@ export const entities = {
|
|||
OrganizationUser,
|
||||
Workspace,
|
||||
WorkspaceUser,
|
||||
LoginMethod
|
||||
LoginMethod,
|
||||
LoginSession
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
import { Column, Entity, PrimaryColumn } from 'typeorm'
|
||||
|
||||
@Entity({ name: 'login_sessions' })
|
||||
export class LoginSession {
|
||||
@PrimaryColumn({ type: 'varchar' })
|
||||
sid: string
|
||||
|
||||
@Column({ type: 'text' })
|
||||
sess: string
|
||||
|
||||
@Column({ type: 'bigint', nullable: true })
|
||||
expire?: number
|
||||
}
|
||||
|
|
@ -3,9 +3,13 @@ import { RedisStore } from 'connect-redis'
|
|||
import { getDatabaseSSLFromEnv } from '../../../DataSource'
|
||||
import path from 'path'
|
||||
import { getUserHome } from '../../../utils'
|
||||
import type { Store } from 'express-session'
|
||||
import { LoginSession } from '../../database/entities/login-session.entity'
|
||||
import { getRunningExpressApp } from '../../../utils/getRunningExpressApp'
|
||||
|
||||
let redisClient: Redis | null = null
|
||||
let redisStore: RedisStore | null = null
|
||||
let dbStore: Store | null = null
|
||||
|
||||
export const initializeRedisClientAndStore = (): RedisStore => {
|
||||
if (!redisClient) {
|
||||
|
|
@ -35,6 +39,8 @@ export const initializeRedisClientAndStore = (): RedisStore => {
|
|||
}
|
||||
|
||||
export const initializeDBClientAndStore: any = () => {
|
||||
if (dbStore) return dbStore
|
||||
|
||||
const databaseType = process.env.DATABASE_TYPE || 'sqlite'
|
||||
switch (databaseType) {
|
||||
case 'mysql': {
|
||||
|
|
@ -51,7 +57,8 @@ export const initializeDBClientAndStore: any = () => {
|
|||
tableName: 'login_sessions'
|
||||
}
|
||||
}
|
||||
return new MySQLStore(options)
|
||||
dbStore = new MySQLStore(options)
|
||||
return dbStore
|
||||
}
|
||||
case 'mariadb':
|
||||
/* TODO: Implement MariaDB session store */
|
||||
|
|
@ -70,12 +77,13 @@ export const initializeDBClientAndStore: any = () => {
|
|||
database: process.env.DATABASE_NAME,
|
||||
ssl: getDatabaseSSLFromEnv()
|
||||
})
|
||||
return new pgSession({
|
||||
dbStore = new pgSession({
|
||||
pool: pgPool, // Connection pool
|
||||
tableName: 'login_sessions',
|
||||
schemaName: 'public',
|
||||
createTableIfMissing: true
|
||||
})
|
||||
return dbStore
|
||||
}
|
||||
case 'default':
|
||||
case 'sqlite': {
|
||||
|
|
@ -83,11 +91,93 @@ export const initializeDBClientAndStore: any = () => {
|
|||
const sqlSession = require('connect-sqlite3')(expressSession)
|
||||
let flowisePath = path.join(getUserHome(), '.flowise')
|
||||
const homePath = process.env.DATABASE_PATH ?? flowisePath
|
||||
return new sqlSession({
|
||||
dbStore = new sqlSession({
|
||||
db: 'database.sqlite',
|
||||
table: 'login_sessions',
|
||||
dir: homePath
|
||||
})
|
||||
return dbStore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getUserIdFromSession = (session: any): string | undefined => {
|
||||
try {
|
||||
const data = typeof session === 'string' ? JSON.parse(session) : session
|
||||
return data?.passport?.user?.id
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const destroyAllSessionsForUser = async (userId: string): Promise<void> => {
|
||||
try {
|
||||
if (redisStore && redisClient) {
|
||||
const prefix = (redisStore as any)?.prefix ?? 'sess:'
|
||||
const pattern = `${prefix}*`
|
||||
const keysToDelete: string[] = []
|
||||
const batchSize = 1000
|
||||
|
||||
const stream = redisClient.scanStream({
|
||||
match: pattern,
|
||||
count: batchSize
|
||||
})
|
||||
|
||||
for await (const keysBatch of stream) {
|
||||
if (keysBatch.length === 0) continue
|
||||
|
||||
const sessions = await redisClient.mget(...keysBatch)
|
||||
for (let i = 0; i < sessions.length; i++) {
|
||||
if (getUserIdFromSession(sessions[i]) === userId) {
|
||||
keysToDelete.push(keysBatch[i])
|
||||
}
|
||||
}
|
||||
|
||||
if (keysToDelete.length >= batchSize) {
|
||||
const pipeline = redisClient.pipeline()
|
||||
keysToDelete.splice(0, batchSize).forEach((key) => pipeline.del(key))
|
||||
await pipeline.exec()
|
||||
}
|
||||
}
|
||||
|
||||
if (keysToDelete.length > 0) {
|
||||
const pipeline = redisClient.pipeline()
|
||||
keysToDelete.forEach((key) => pipeline.del(key))
|
||||
await pipeline.exec()
|
||||
}
|
||||
} else if (dbStore) {
|
||||
const appServer = getRunningExpressApp()
|
||||
const dataSource = appServer.AppDataSource
|
||||
const repository = dataSource.getRepository(LoginSession)
|
||||
|
||||
const databaseType = process.env.DATABASE_TYPE || 'sqlite'
|
||||
switch (databaseType) {
|
||||
case 'sqlite':
|
||||
await repository
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.where(`json_extract(sess, '$.passport.user.id') = :userId`, { userId })
|
||||
.execute()
|
||||
break
|
||||
case 'mysql':
|
||||
await repository
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.where(`JSON_EXTRACT(sess, '$.passport.user.id') = :userId`, { userId })
|
||||
.execute()
|
||||
break
|
||||
case 'postgres':
|
||||
await repository.createQueryBuilder().delete().where(`sess->'passport'->'user'->>'id' = :userId`, { userId }).execute()
|
||||
break
|
||||
default:
|
||||
console.warn('Unsupported database type:', databaseType)
|
||||
break
|
||||
}
|
||||
} else {
|
||||
console.warn('Session store not available, skipping session invalidation')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error destroying sessions for user:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,11 @@ const _initializePassportMiddleware = async (app: express.Application) => {
|
|||
app.use(passport.initialize())
|
||||
app.use(passport.session())
|
||||
|
||||
if (options.store) {
|
||||
const appServer = getRunningExpressApp()
|
||||
appServer.sessionStore = options.store
|
||||
}
|
||||
|
||||
passport.serializeUser((user: any, done) => {
|
||||
done(null, user)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { UserErrorMessage, UserService } from './user.service'
|
|||
import { WorkspaceUserErrorMessage, WorkspaceUserService } from './workspace-user.service'
|
||||
import { WorkspaceErrorMessage, WorkspaceService } from './workspace.service'
|
||||
import { sanitizeUser } from '../../utils/sanitize.util'
|
||||
import { destroyAllSessionsForUser } from '../middleware/passport/SessionPersistance'
|
||||
|
||||
type AccountDTO = {
|
||||
user: Partial<User>
|
||||
|
|
@ -576,6 +577,9 @@ export class AccountService {
|
|||
await queryRunner.startTransaction()
|
||||
data.user = await this.userService.saveUser(data.user, queryRunner)
|
||||
await queryRunner.commitTransaction()
|
||||
|
||||
// Invalidate all sessions for this user after password reset
|
||||
await destroyAllSessionsForUser(user.id as string)
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction()
|
||||
throw error
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { StatusCodes } from 'http-status-codes'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { InternalFlowiseError } from '../../errors/internalFlowiseError'
|
||||
import { getRunningExpressApp } from '../../utils/getRunningExpressApp'
|
||||
import { Telemetry, TelemetryEventType } from '../../utils/telemetry'
|
||||
|
|
@ -8,8 +7,9 @@ import { isInvalidEmail, isInvalidName, isInvalidPassword, isInvalidUUID } from
|
|||
import { DataSource, ILike, QueryRunner } from 'typeorm'
|
||||
import { generateId } from '../../utils'
|
||||
import { GeneralErrorMessage } from '../../utils/constants'
|
||||
import { getHash } from '../utils/encryption.util'
|
||||
import { compareHash, getHash } from '../utils/encryption.util'
|
||||
import { sanitizeUser } from '../../utils/sanitize.util'
|
||||
import { destroyAllSessionsForUser } from '../middleware/passport/SessionPersistance'
|
||||
|
||||
export const enum UserErrorMessage {
|
||||
EXPIRED_TEMP_TOKEN = 'Expired Temporary Token',
|
||||
|
|
@ -24,7 +24,8 @@ export const enum UserErrorMessage {
|
|||
USER_EMAIL_UNVERIFIED = 'User Email Unverified',
|
||||
USER_NOT_FOUND = 'User Not Found',
|
||||
USER_FOUND_MULTIPLE = 'User Found Multiple',
|
||||
INCORRECT_USER_EMAIL_OR_CREDENTIALS = 'Incorrect Email or Password'
|
||||
INCORRECT_USER_EMAIL_OR_CREDENTIALS = 'Incorrect Email or Password',
|
||||
PASSWORDS_DO_NOT_MATCH = 'Passwords do not match'
|
||||
}
|
||||
export class UserService {
|
||||
private telemetry: Telemetry
|
||||
|
|
@ -134,7 +135,7 @@ export class UserService {
|
|||
return newUser
|
||||
}
|
||||
|
||||
public async updateUser(newUserData: Partial<User> & { password?: string }) {
|
||||
public async updateUser(newUserData: Partial<User> & { oldPassword?: string; newPassword?: string; confirmPassword?: string }) {
|
||||
let queryRunner: QueryRunner | undefined
|
||||
let updatedUser: Partial<User>
|
||||
try {
|
||||
|
|
@ -158,10 +159,18 @@ export class UserService {
|
|||
this.validateUserStatus(newUserData.status)
|
||||
}
|
||||
|
||||
if (newUserData.password) {
|
||||
const salt = bcrypt.genSaltSync(parseInt(process.env.PASSWORD_SALT_HASH_ROUNDS || '5'))
|
||||
// @ts-ignore
|
||||
const hash = bcrypt.hashSync(newUserData.password, salt)
|
||||
if (newUserData.oldPassword && newUserData.newPassword && newUserData.confirmPassword) {
|
||||
if (!oldUserData.credential) {
|
||||
throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, UserErrorMessage.INVALID_USER_CREDENTIAL)
|
||||
}
|
||||
// verify old password
|
||||
if (!compareHash(newUserData.oldPassword, oldUserData.credential)) {
|
||||
throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, UserErrorMessage.INVALID_USER_CREDENTIAL)
|
||||
}
|
||||
if (newUserData.newPassword !== newUserData.confirmPassword) {
|
||||
throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, UserErrorMessage.PASSWORDS_DO_NOT_MATCH)
|
||||
}
|
||||
const hash = getHash(newUserData.newPassword)
|
||||
newUserData.credential = hash
|
||||
newUserData.tempToken = ''
|
||||
newUserData.tokenExpiry = undefined
|
||||
|
|
@ -171,6 +180,11 @@ export class UserService {
|
|||
await queryRunner.startTransaction()
|
||||
await this.saveUser(updatedUser, queryRunner)
|
||||
await queryRunner.commitTransaction()
|
||||
|
||||
// Invalidate all sessions for this user if password was changed
|
||||
if (newUserData.oldPassword && newUserData.newPassword && newUserData.confirmPassword) {
|
||||
await destroyAllSessionsForUser(updatedUser.id as string)
|
||||
}
|
||||
} catch (error) {
|
||||
if (queryRunner && queryRunner.isTransactionActive) await queryRunner.rollbackTransaction()
|
||||
throw error
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export class App {
|
|||
queueManager: QueueManager
|
||||
redisSubscriber: RedisEventSubscriber
|
||||
usageCacheManager: UsageCacheManager
|
||||
sessionStore: any
|
||||
|
||||
constructor() {
|
||||
this.app = express()
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ import exportImportApi from '@/api/exportimport'
|
|||
|
||||
// Hooks
|
||||
import useApi from '@/hooks/useApi'
|
||||
import { useConfig } from '@/store/context/ConfigContext'
|
||||
import { getErrorMessage } from '@/utils/errorHandler'
|
||||
|
||||
const dataToExport = [
|
||||
|
|
@ -215,7 +214,6 @@ const ProfileSection = ({ handleLogout }) => {
|
|||
const theme = useTheme()
|
||||
|
||||
const customization = useSelector((state) => state.customization)
|
||||
const { isCloud } = useConfig()
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [aboutDialogOpen, setAboutDialogOpen] = useState(false)
|
||||
|
|
@ -500,18 +498,18 @@ const ProfileSection = ({ handleLogout }) => {
|
|||
</ListItemIcon>
|
||||
<ListItemText primary={<Typography variant='body2'>Version</Typography>} />
|
||||
</ListItemButton>
|
||||
{isAuthenticated && !currentUser.isSSO && !isCloud && (
|
||||
{isAuthenticated && !currentUser.isSSO && (
|
||||
<ListItemButton
|
||||
sx={{ borderRadius: `${customization.borderRadius}px` }}
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
navigate('/user-profile')
|
||||
navigate('/account')
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<IconUserEdit stroke={1.5} size='1.3rem' />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={<Typography variant='body2'>Update Profile</Typography>} />
|
||||
<ListItemText primary={<Typography variant='body2'>Account Settings</Typography>} />
|
||||
</ListItemButton>
|
||||
)}
|
||||
<ListItemButton
|
||||
|
|
|
|||
|
|
@ -50,7 +50,6 @@ const Evaluators = Loadable(lazy(() => import('@/views/evaluators')))
|
|||
|
||||
// account routing
|
||||
const Account = Loadable(lazy(() => import('@/views/account')))
|
||||
const UserProfile = Loadable(lazy(() => import('@/views/account/UserProfile')))
|
||||
|
||||
// files routing
|
||||
const Files = Loadable(lazy(() => import('@/views/files')))
|
||||
|
|
@ -294,11 +293,7 @@ const MainRoutes = {
|
|||
},
|
||||
{
|
||||
path: '/account',
|
||||
element: (
|
||||
<RequireAuth display={'feat:account'}>
|
||||
<Account />
|
||||
</RequireAuth>
|
||||
)
|
||||
element: <Account />
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
|
|
@ -308,10 +303,6 @@ const MainRoutes = {
|
|||
</RequireAuth>
|
||||
)
|
||||
},
|
||||
{
|
||||
path: '/user-profile',
|
||||
element: <UserProfile />
|
||||
},
|
||||
{
|
||||
path: '/roles',
|
||||
element: (
|
||||
|
|
|
|||
|
|
@ -1,294 +0,0 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
|
||||
// material-ui
|
||||
import { Box, Button, OutlinedInput, Stack, Typography } from '@mui/material'
|
||||
|
||||
// project imports
|
||||
import ErrorBoundary from '@/ErrorBoundary'
|
||||
import ViewHeader from '@/layout/MainLayout/ViewHeader'
|
||||
import { StyledButton } from '@/ui-component/button/StyledButton'
|
||||
import MainCard from '@/ui-component/cards/MainCard'
|
||||
import SettingsSection from '@/ui-component/form/settings'
|
||||
import { BackdropLoader } from '@/ui-component/loading/BackdropLoader'
|
||||
|
||||
// API
|
||||
import userApi from '@/api/user'
|
||||
import useApi from '@/hooks/useApi'
|
||||
|
||||
// Store
|
||||
import { store } from '@/store'
|
||||
import { closeSnackbar as closeSnackbarAction, enqueueSnackbar as enqueueSnackbarAction } from '@/store/actions'
|
||||
import { gridSpacing } from '@/store/constant'
|
||||
import { useError } from '@/store/context/ErrorContext'
|
||||
import { userProfileUpdated } from '@/store/reducers/authSlice'
|
||||
|
||||
// utils
|
||||
import useNotifier from '@/utils/useNotifier'
|
||||
import { validatePassword } from '@/utils/validation'
|
||||
|
||||
// Icons
|
||||
import { IconAlertTriangle, IconX } from '@tabler/icons-react'
|
||||
|
||||
const UserProfile = () => {
|
||||
useNotifier()
|
||||
const { error, setError } = useError()
|
||||
|
||||
const dispatch = useDispatch()
|
||||
const enqueueSnackbar = (...args) => dispatch(enqueueSnackbarAction(...args))
|
||||
const closeSnackbar = (...args) => dispatch(closeSnackbarAction(...args))
|
||||
|
||||
const currentUser = useSelector((state) => state.auth.user)
|
||||
const isAuthenticated = useSelector((state) => state.auth.isAuthenticated)
|
||||
|
||||
const [newPasswordVal, setNewPasswordVal] = useState('')
|
||||
const [confirmPasswordVal, setConfirmPasswordVal] = useState('')
|
||||
const [usernameVal, setUsernameVal] = useState('')
|
||||
const [emailVal, setEmailVal] = useState('')
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [authErrors, setAuthErrors] = useState([])
|
||||
|
||||
const getUserApi = useApi(userApi.getUserById)
|
||||
|
||||
const validateAndSubmit = async () => {
|
||||
const validationErrors = []
|
||||
setAuthErrors([])
|
||||
if (!isAuthenticated) {
|
||||
validationErrors.push('User is not authenticated')
|
||||
}
|
||||
if (currentUser.isSSO) {
|
||||
validationErrors.push('User is a SSO user, unable to update details')
|
||||
}
|
||||
if (!usernameVal) {
|
||||
validationErrors.push('Name cannot be left blank!')
|
||||
}
|
||||
if (!emailVal) {
|
||||
validationErrors.push('Email cannot be left blank!')
|
||||
}
|
||||
if (newPasswordVal || confirmPasswordVal) {
|
||||
if (newPasswordVal !== confirmPasswordVal) {
|
||||
validationErrors.push('New Password and Confirm Password do not match')
|
||||
}
|
||||
const passwordErrors = validatePassword(newPasswordVal)
|
||||
if (passwordErrors.length > 0) {
|
||||
validationErrors.push(...passwordErrors)
|
||||
}
|
||||
}
|
||||
if (validationErrors.length > 0) {
|
||||
setAuthErrors(validationErrors)
|
||||
return
|
||||
}
|
||||
const body = {
|
||||
id: currentUser.id,
|
||||
email: emailVal,
|
||||
name: usernameVal
|
||||
}
|
||||
if (newPasswordVal) body.password = newPasswordVal
|
||||
setLoading(true)
|
||||
try {
|
||||
const updateResponse = await userApi.updateUser(body)
|
||||
setAuthErrors([])
|
||||
setLoading(false)
|
||||
if (updateResponse.data) {
|
||||
store.dispatch(userProfileUpdated(updateResponse.data))
|
||||
enqueueSnackbar({
|
||||
message: 'User Details Updated!',
|
||||
options: {
|
||||
key: new Date().getTime() + Math.random(),
|
||||
variant: 'success',
|
||||
action: (key) => (
|
||||
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
|
||||
<IconX />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
setLoading(false)
|
||||
setAuthErrors([typeof error.response.data === 'object' ? error.response.data.message : error.response.data])
|
||||
enqueueSnackbar({
|
||||
message: `Failed to update user details`,
|
||||
options: {
|
||||
key: new Date().getTime() + Math.random(),
|
||||
variant: 'error',
|
||||
persist: true,
|
||||
action: (key) => (
|
||||
<Button style={{ color: 'white' }} onClick={() => closeSnackbar(key)}>
|
||||
<IconX />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (getUserApi.data) {
|
||||
const user = getUserApi.data
|
||||
setEmailVal(user.email)
|
||||
setUsernameVal(user.name)
|
||||
setLoading(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [getUserApi.data])
|
||||
|
||||
useEffect(() => {
|
||||
if (getUserApi.error) {
|
||||
setLoading(false)
|
||||
setError(getUserApi.error)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [getUserApi.error])
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
getUserApi.request(currentUser.id)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<MainCard>
|
||||
{error ? (
|
||||
<ErrorBoundary error={error} />
|
||||
) : (
|
||||
<Stack flexDirection='column' sx={{ gap: 3 }}>
|
||||
<ViewHeader search={false} title='Settings' />
|
||||
{authErrors && authErrors.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 10,
|
||||
background: 'rgb(254,252,191)',
|
||||
padding: 10,
|
||||
paddingTop: 15,
|
||||
marginTop: 10,
|
||||
marginBottom: 10
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<IconAlertTriangle size={25} color='orange' />
|
||||
</Box>
|
||||
<Stack flexDirection='column'>
|
||||
<span style={{ color: 'rgb(116,66,16)' }}>
|
||||
<ul>
|
||||
{authErrors.map((msg, key) => (
|
||||
<strong key={key}>
|
||||
<li>{msg}</li>
|
||||
</strong>
|
||||
))}
|
||||
</ul>
|
||||
</span>
|
||||
</Stack>
|
||||
</div>
|
||||
)}
|
||||
<SettingsSection
|
||||
action={
|
||||
<StyledButton variant='contained' style={{ borderRadius: 2, height: 40 }} onClick={validateAndSubmit}>
|
||||
Save
|
||||
</StyledButton>
|
||||
}
|
||||
title='Profile'
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: gridSpacing,
|
||||
px: 2.5,
|
||||
py: 2
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'row' }}>
|
||||
<Typography>Email</Typography>
|
||||
<div style={{ flexGrow: 1 }}></div>
|
||||
</div>
|
||||
<OutlinedInput
|
||||
id='email'
|
||||
type='string'
|
||||
fullWidth
|
||||
size='small'
|
||||
placeholder='Your login Id'
|
||||
name='name'
|
||||
onChange={(e) => setEmailVal(e.target.value)}
|
||||
value={emailVal}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'row' }}>
|
||||
<Typography>
|
||||
Full Name<span style={{ color: 'red' }}> *</span>
|
||||
</Typography>
|
||||
<div style={{ flexGrow: 1 }}></div>
|
||||
</div>
|
||||
<OutlinedInput
|
||||
id='name'
|
||||
type='string'
|
||||
fullWidth
|
||||
size='small'
|
||||
placeholder='Your Name'
|
||||
name='name'
|
||||
onChange={(e) => setUsernameVal(e.target.value)}
|
||||
value={usernameVal}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'row' }}>
|
||||
<Typography>
|
||||
New Password<span style={{ color: 'red' }}> *</span>
|
||||
</Typography>
|
||||
<div style={{ flexGrow: 1 }}></div>
|
||||
</div>
|
||||
<OutlinedInput
|
||||
id='np'
|
||||
type='password'
|
||||
fullWidth
|
||||
size='small'
|
||||
name='new_password'
|
||||
onChange={(e) => setNewPasswordVal(e.target.value)}
|
||||
value={newPasswordVal}
|
||||
/>
|
||||
<Typography variant='caption'>
|
||||
<i>
|
||||
Password must be at least 8 characters long and contain at least one lowercase letter, one
|
||||
uppercase letter, one digit, and one special character.
|
||||
</i>
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'row' }}>
|
||||
<Typography>
|
||||
Confirm Password<span style={{ color: 'red' }}> *</span>
|
||||
</Typography>
|
||||
<div style={{ flexGrow: 1 }}></div>
|
||||
</div>
|
||||
<OutlinedInput
|
||||
id='npc'
|
||||
type='password'
|
||||
fullWidth
|
||||
size='small'
|
||||
name='new_cnf_password'
|
||||
onChange={(e) => setConfirmPasswordVal(e.target.value)}
|
||||
value={confirmPasswordVal}
|
||||
/>
|
||||
<Typography variant='caption'>
|
||||
<i>Retype your new password. Must match the password typed above.</i>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</SettingsSection>
|
||||
</Stack>
|
||||
)}
|
||||
</MainCard>
|
||||
{loading && <BackdropLoader open={loading} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserProfile
|
||||
|
|
@ -25,7 +25,6 @@ import {
|
|||
import { darken, useTheme } from '@mui/material/styles'
|
||||
|
||||
// project imports
|
||||
import ErrorBoundary from '@/ErrorBoundary'
|
||||
import ViewHeader from '@/layout/MainLayout/ViewHeader'
|
||||
import { StyledButton } from '@/ui-component/button/StyledButton'
|
||||
import MainCard from '@/ui-component/cards/MainCard'
|
||||
|
|
@ -48,8 +47,7 @@ import { store } from '@/store'
|
|||
import { closeSnackbar as closeSnackbarAction, enqueueSnackbar as enqueueSnackbarAction } from '@/store/actions'
|
||||
import { gridSpacing } from '@/store/constant'
|
||||
import { useConfig } from '@/store/context/ConfigContext'
|
||||
import { useError } from '@/store/context/ErrorContext'
|
||||
import { userProfileUpdated } from '@/store/reducers/authSlice'
|
||||
import { logoutSuccess, userProfileUpdated } from '@/store/reducers/authSlice'
|
||||
|
||||
// ==============================|| ACCOUNT SETTINGS ||============================== //
|
||||
|
||||
|
|
@ -66,12 +64,12 @@ const AccountSettings = () => {
|
|||
const currentUser = useSelector((state) => state.auth.user)
|
||||
const customization = useSelector((state) => state.customization)
|
||||
|
||||
const { error, setError } = useError()
|
||||
const { isCloud } = useConfig()
|
||||
|
||||
const [isLoading, setLoading] = useState(true)
|
||||
const [profileName, setProfileName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [oldPassword, setOldPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [usage, setUsage] = useState(null)
|
||||
|
|
@ -104,10 +102,19 @@ const AccountSettings = () => {
|
|||
const getCustomerDefaultSourceApi = useApi(userApi.getCustomerDefaultSource)
|
||||
const updateAdditionalSeatsApi = useApi(userApi.updateAdditionalSeats)
|
||||
const getCurrentUsageApi = useApi(userApi.getCurrentUsage)
|
||||
const logoutApi = useApi(accountApi.logout)
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
getUserByIdApi.request(currentUser.id)
|
||||
} else {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentUser])
|
||||
|
||||
useEffect(() => {
|
||||
if (isCloud) {
|
||||
getUserByIdApi.request(currentUser.id)
|
||||
getPricingPlansApi.request()
|
||||
getAdditionalSeatsQuantityApi.request(currentUser?.activeOrganizationSubscriptionId)
|
||||
getCurrentUsageApi.request()
|
||||
|
|
@ -136,6 +143,17 @@ const AccountSettings = () => {
|
|||
}
|
||||
}, [getCurrentUsageApi.data])
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (logoutApi.data && logoutApi.data.message === 'logged_out') {
|
||||
store.dispatch(logoutSuccess())
|
||||
window.location.href = logoutApi.data.redirectTo
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}, [logoutApi.data])
|
||||
|
||||
useEffect(() => {
|
||||
if (openRemoveSeatsDialog || openAddSeatsDialog) {
|
||||
setSeatsQuantity(0)
|
||||
|
|
@ -219,7 +237,6 @@ const AccountSettings = () => {
|
|||
})
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error)
|
||||
enqueueSnackbar({
|
||||
message: `Failed to update profile: ${
|
||||
typeof error.response.data === 'object' ? error.response.data.message : error.response.data
|
||||
|
|
@ -241,6 +258,9 @@ const AccountSettings = () => {
|
|||
const savePassword = async () => {
|
||||
try {
|
||||
const validationErrors = []
|
||||
if (!oldPassword) {
|
||||
validationErrors.push('Old Password cannot be left blank')
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
validationErrors.push('New Password and Confirm Password do not match')
|
||||
}
|
||||
|
|
@ -267,11 +287,17 @@ const AccountSettings = () => {
|
|||
|
||||
const obj = {
|
||||
id: currentUser.id,
|
||||
password: newPassword
|
||||
oldPassword,
|
||||
newPassword,
|
||||
confirmPassword
|
||||
}
|
||||
const saveProfileResp = await userApi.updateUser(obj)
|
||||
if (saveProfileResp.data) {
|
||||
store.dispatch(userProfileUpdated(saveProfileResp.data))
|
||||
setOldPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
await logoutApi.request()
|
||||
enqueueSnackbar({
|
||||
message: 'Password updated',
|
||||
options: {
|
||||
|
|
@ -286,7 +312,6 @@ const AccountSettings = () => {
|
|||
})
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error)
|
||||
enqueueSnackbar({
|
||||
message: `Failed to update password: ${
|
||||
typeof error.response.data === 'object' ? error.response.data.message : error.response.data
|
||||
|
|
@ -388,9 +413,6 @@ const AccountSettings = () => {
|
|||
|
||||
return (
|
||||
<MainCard maxWidth='md'>
|
||||
{error ? (
|
||||
<ErrorBoundary error={error} />
|
||||
) : (
|
||||
<Stack flexDirection='column' sx={{ gap: 4 }}>
|
||||
<ViewHeader title='Account Settings' />
|
||||
{isLoading && !getUserByIdApi.data ? (
|
||||
|
|
@ -410,6 +432,8 @@ const AccountSettings = () => {
|
|||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
{isCloud && (
|
||||
<>
|
||||
<SettingsSection title='Subscription & Billing'>
|
||||
<Box
|
||||
|
|
@ -488,10 +512,10 @@ const AccountSettings = () => {
|
|||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
background: (theme) =>
|
||||
`linear-gradient(90deg, ${darken(theme.palette.primary.main, 0.1)} 10%, ${darken(
|
||||
theme.palette.secondary.main,
|
||||
`linear-gradient(90deg, ${darken(
|
||||
theme.palette.primary.main,
|
||||
0.1
|
||||
)} 100%)`,
|
||||
)} 10%, ${darken(theme.palette.secondary.main, 0.1)} 100%)`,
|
||||
boxShadow: '0 4px 8px rgba(0,0,0,0.3)'
|
||||
}
|
||||
}}
|
||||
|
|
@ -533,7 +557,11 @@ const AccountSettings = () => {
|
|||
<Stack sx={{ alignItems: 'center' }} flexDirection='row'>
|
||||
<Typography variant='body2'>Additional Seats Purchased:</Typography>
|
||||
<Typography sx={{ ml: 1, color: theme.palette.success.dark }} variant='h3'>
|
||||
{getAdditionalSeatsQuantityApi.loading ? <CircularProgress size={16} /> : purchasedSeats}
|
||||
{getAdditionalSeatsQuantityApi.loading ? (
|
||||
<CircularProgress size={16} />
|
||||
) : (
|
||||
purchasedSeats
|
||||
)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack sx={{ alignItems: 'center' }} flexDirection='row'>
|
||||
|
|
@ -557,10 +585,14 @@ const AccountSettings = () => {
|
|||
py: 2
|
||||
}}
|
||||
>
|
||||
{getAdditionalSeatsQuantityApi.data?.quantity > 0 && currentPlanTitle.toUpperCase() === 'PRO' && (
|
||||
{getAdditionalSeatsQuantityApi.data?.quantity > 0 &&
|
||||
currentPlanTitle.toUpperCase() === 'PRO' && (
|
||||
<Button
|
||||
variant='outlined'
|
||||
disabled={!currentUser.isOrganizationAdmin || !getAdditionalSeatsQuantityApi.data?.quantity}
|
||||
disabled={
|
||||
!currentUser.isOrganizationAdmin ||
|
||||
!getAdditionalSeatsQuantityApi.data?.quantity
|
||||
}
|
||||
onClick={() => {
|
||||
setOpenRemoveSeatsDialog(true)
|
||||
}}
|
||||
|
|
@ -662,6 +694,8 @@ const AccountSettings = () => {
|
|||
</Box>
|
||||
</Box>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
<SettingsSection
|
||||
action={
|
||||
<StyledButton onClick={saveProfileData} sx={{ borderRadius: 2, height: 40 }} variant='contained'>
|
||||
|
|
@ -709,7 +743,7 @@ const AccountSettings = () => {
|
|||
<SettingsSection
|
||||
action={
|
||||
<StyledButton
|
||||
disabled={!newPassword || !confirmPassword || newPassword !== confirmPassword}
|
||||
disabled={!oldPassword || !newPassword || !confirmPassword || newPassword !== confirmPassword}
|
||||
onClick={savePassword}
|
||||
sx={{ borderRadius: 2, height: 40 }}
|
||||
variant='contained'
|
||||
|
|
@ -728,6 +762,25 @@ const AccountSettings = () => {
|
|||
py: 2
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
gridColumn: 'span 2 / span 2',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1
|
||||
}}
|
||||
>
|
||||
<Typography variant='body1'>Old Password</Typography>
|
||||
<OutlinedInput
|
||||
id='oldPassword'
|
||||
type='password'
|
||||
fullWidth
|
||||
placeholder='Old Password'
|
||||
name='oldPassword'
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
value={oldPassword}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
gridColumn: 'span 2 / span 2',
|
||||
|
|
@ -748,8 +801,8 @@ const AccountSettings = () => {
|
|||
/>
|
||||
<Typography variant='caption'>
|
||||
<i>
|
||||
Password must be at least 8 characters long and contain at least one lowercase letter,
|
||||
one uppercase letter, one digit, and one special character.
|
||||
Password must be at least 8 characters long and contain at least one lowercase letter, one
|
||||
uppercase letter, one digit, and one special character.
|
||||
</i>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
|
@ -761,12 +814,12 @@ const AccountSettings = () => {
|
|||
gap: 1
|
||||
}}
|
||||
>
|
||||
<Typography variant='body1'>Confirm Password</Typography>
|
||||
<Typography variant='body1'>Confirm New Password</Typography>
|
||||
<OutlinedInput
|
||||
id='confirmPassword'
|
||||
type='password'
|
||||
fullWidth
|
||||
placeholder='Confirm Password'
|
||||
placeholder='Confirm New Password'
|
||||
name='confirmPassword'
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
value={confirmPassword}
|
||||
|
|
@ -778,7 +831,6 @@ const AccountSettings = () => {
|
|||
</>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
{openPricingDialog && isCloud && (
|
||||
<PricingDialog
|
||||
open={openPricingDialog}
|
||||
|
|
|
|||
Loading…
Reference in New Issue