diff --git a/api/user.go b/api/user.go index 204b5e34b..5a08b307a 100644 --- a/api/user.go +++ b/api/user.go @@ -63,6 +63,7 @@ type UserAPI struct { PasswordStrength int UserChangeNotifier *UserChangeNotifier Registration bool + LocalAuthEnabled bool } // GetUsers returns all the users @@ -396,6 +397,11 @@ func (a *UserAPI) DeleteUserByID(ctx *gin.Context) { // schema: // $ref: "#/definitions/Error" func (a *UserAPI) ChangePassword(ctx *gin.Context) { + if !a.LocalAuthEnabled { + ctx.AbortWithError(403, errors.New("local authentication is disabled")) + return + } + pw := model.UserExternalPass{} if err := ctx.Bind(&pw); err == nil { if err := password.ValidateNewPassword(pw.Pass); err != nil { diff --git a/api/user_test.go b/api/user_test.go index 583bb64e7..139527ff2 100644 --- a/api/user_test.go +++ b/api/user_test.go @@ -49,7 +49,7 @@ func (s *UserSuite) BeforeTest(suiteName, testName string) { s.notifiedAdd = true return nil }) - s.a = &UserAPI{DB: s.db, UserChangeNotifier: s.notifier} + s.a = &UserAPI{DB: s.db, UserChangeNotifier: s.notifier, LocalAuthEnabled: true} } func (s *UserSuite) AfterTest(suiteName, testName string) { @@ -493,6 +493,25 @@ func (s *UserSuite) Test_UpdatePassword_EmptyPassword() { assert.True(s.T(), password.ComparePassword(user.Pass, []byte("old"))) } +func (s *UserSuite) Test_UpdatePassword_LocalAuthDisabled_Expect403() { + pw, err := password.CreatePassword("old", 5) + require.NoError(s.T(), err) + s.db.CreateUser(&model.User{ID: 1, Name: "jmattheis", Pass: pw}) + s.a.LocalAuthEnabled = false + + test.WithUser(s.ctx, 1) + s.ctx.Request = httptest.NewRequest("POST", "/user/current/password", strings.NewReader(`{"pass": "new"}`)) + s.ctx.Request.Header.Set("Content-Type", "application/json") + + s.a.ChangePassword(s.ctx) + + assert.Equal(s.T(), 403, s.recorder.Code) + user, err := s.db.GetUserByID(1) + assert.NoError(s.T(), err) + assert.NotNil(s.T(), user) + assert.True(s.T(), password.ComparePassword(user.Pass, []byte("old"))) +} + func (s *UserSuite) Test_UpdatePassword_TooLongPassword_Expect400() { pw, err := password.CreatePassword("old", 5) require.NoError(s.T(), err) diff --git a/router/router.go b/router/router.go index 74e770d3b..9e7a39eeb 100644 --- a/router/router.go +++ b/router/router.go @@ -104,7 +104,7 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co } sessionHandler := api.SessionAPI{DB: db, NotifyDeleted: streamHandler.NotifyDeletedClient, SecureCookie: conf.Server.SecureCookie, LocalAuthEnabled: conf.LocalAuthEnabled} userChangeNotifier := new(api.UserChangeNotifier) - userHandler := api.UserAPI{DB: db, PasswordStrength: conf.PassStrength, UserChangeNotifier: userChangeNotifier, Registration: conf.Registration} + userHandler := api.UserAPI{DB: db, PasswordStrength: conf.PassStrength, UserChangeNotifier: userChangeNotifier, Registration: conf.Registration, LocalAuthEnabled: conf.LocalAuthEnabled} pluginManager, err := plugin.NewManager(db, conf.PluginsDir, g.Group("/plugin/:id/custom/"), streamHandler) if err != nil { diff --git a/ui/src/common/SettingsDialog.tsx b/ui/src/common/SettingsDialog.tsx deleted file mode 100644 index 04125739e..000000000 --- a/ui/src/common/SettingsDialog.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React, {useState} from 'react'; -import Button from '@mui/material/Button'; -import Dialog from '@mui/material/Dialog'; -import DialogActions from '@mui/material/DialogActions'; -import DialogContent from '@mui/material/DialogContent'; -import DialogTitle from '@mui/material/DialogTitle'; -import TextField from '@mui/material/TextField'; -import Tooltip from '@mui/material/Tooltip'; -import {observer} from 'mobx-react-lite'; -import {useStores} from '../stores'; -import ElevationForm from './ElevationForm'; - -interface IProps { - fClose: VoidFunction; -} - -const SettingsDialog = observer(({fClose}: IProps) => { - const [pass, setPass] = useState(''); - const {currentUser, elevateStore} = useStores(); - - const handleClose = () => { - elevateStore.cleanupOidcElevate(); - fClose(); - }; - - const submitAndClose = () => { - currentUser.changePassword(pass); - fClose(); - }; - - return ( - - Change Password - - {elevateStore.elevated ? ( - setPass(e.target.value)} - fullWidth - /> - ) : ( - - )} - - - - {elevateStore.elevated && ( - -
- -
-
- )} -
-
- ); -}); - -export default SettingsDialog; diff --git a/ui/src/layout/Header.tsx b/ui/src/layout/Header.tsx index 4ffc38a02..992ef7904 100644 --- a/ui/src/layout/Header.tsx +++ b/ui/src/layout/Header.tsx @@ -78,7 +78,6 @@ interface IProps { version: string; themeMode: ThemeKey; toggleTheme: VoidFunction; - showSettings: VoidFunction; logout: VoidFunction; style: CSSProperties; setNavOpen: (open: boolean) => void; @@ -93,7 +92,6 @@ const Header = ({ logout, style, setNavOpen, - showSettings, themeMode, }: IProps) => { const {classes} = useStyles(); @@ -124,13 +122,7 @@ const Header = ({ {loggedIn && ( - + )}
void; - showSettings: VoidFunction; }) => { const {classes} = useStyles(); @@ -195,13 +185,9 @@ const Buttons = ({ } label="plugins" color="inherit" /> - } - label={name} - onClick={showSettings} - id="changepw" - color="inherit" - /> + + } label={name} color="inherit" /> + } label="Logout" diff --git a/ui/src/layout/Layout.tsx b/ui/src/layout/Layout.tsx index 15f604aaa..61427db97 100644 --- a/ui/src/layout/Layout.tsx +++ b/ui/src/layout/Layout.tsx @@ -14,7 +14,6 @@ import {HashRouter, Navigate, Route, Routes} from 'react-router'; import Header from './Header'; import Navigation from './Navigation'; import ScrollUpButton from '../common/ScrollUpButton'; -import SettingsDialog from '../common/SettingsDialog'; import ElevationForm from '../common/ElevationForm'; import * as config from '../config'; import Applications from '../application/Applications'; @@ -22,6 +21,7 @@ import Clients from '../client/Clients'; import Plugins from '../plugin/Plugins'; import Login from '../user/Login'; import Messages from '../message/Messages'; +import Settings from '../user/Settings'; import Users from '../user/Users'; import {observer} from 'mobx-react-lite'; import {ConnectionErrorBanner} from '../common/ConnectionErrorBanner'; @@ -76,7 +76,11 @@ const Layout = observer(() => { ); const {version} = config.get('version'); const [navOpen, setNavOpen] = React.useState(false); - const [showSettings, setShowSettings] = React.useState(false); + + const setTheme = (next: ThemeKey) => { + setCurrentTheme(next); + localStorage.setItem(localStorageThemeKey, next); + }; const toggleTheme = () => { const nextMap: Record = { @@ -84,9 +88,7 @@ const Layout = observer(() => { light: 'system', system: 'dark', }; - const next = nextMap[currentTheme]; - setCurrentTheme(next); - localStorage.setItem(localStorageThemeKey, next); + setTheme(nextMap[currentTheme]); }; const authed = (children: React.ReactNode) => ( @@ -121,7 +123,6 @@ const Layout = observer(() => { loggedIn={loggedIn} themeMode={currentTheme} toggleTheme={toggleTheme} - showSettings={() => setShowSettings(true)} logout={logout} setNavOpen={setNavOpen} /> @@ -148,6 +149,15 @@ const Layout = observer(() => { path="/users" element={authed(elevated())} /> + + )} + /> )} /> {
- {showSettings && ( - setShowSettings(false)} /> - )} diff --git a/ui/src/tests/user.test.ts b/ui/src/tests/user.test.ts index 4d9cacebb..fd411f79c 100644 --- a/ui/src/tests/user.test.ts +++ b/ui/src/tests/user.test.ts @@ -120,8 +120,9 @@ describe('User', () => { expect(await count(page, $table.rows())).toBe(3); }); it('changes password of current user', async () => { - const $changepw = selector.form('#changepw-dialog'); - await page.click('#changepw'); + const $changepw = selector.form('#changepw-form'); + await page.click('#navigate-settings'); + await waitForExists(page, selector.heading(), 'Settings'); await page.waitForSelector($changepw.selector()); await page.type($changepw.input('.newpass'), 'changed'); await page.click($changepw.button('.change')); diff --git a/ui/src/user/Settings.tsx b/ui/src/user/Settings.tsx new file mode 100644 index 000000000..606298c4f --- /dev/null +++ b/ui/src/user/Settings.tsx @@ -0,0 +1,107 @@ +import React, {useState} from 'react'; +import Button from '@mui/material/Button'; +import FormControl from '@mui/material/FormControl'; +import Grid from '@mui/material/Grid'; +import InputLabel from '@mui/material/InputLabel'; +import MenuItem from '@mui/material/MenuItem'; +import Paper from '@mui/material/Paper'; +import Select from '@mui/material/Select'; +import TextField from '@mui/material/TextField'; +import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; +import {observer} from 'mobx-react-lite'; +import DefaultPage from '../common/DefaultPage'; +import ElevationForm from '../common/ElevationForm'; +import {ThemeKey} from '../layout/theme'; +import {useStores} from '../stores'; +import * as config from '../config'; + +interface IProps { + themeMode: ThemeKey; + setTheme: (theme: ThemeKey) => void; +} + +const Settings = observer(({themeMode, setTheme}: IProps) => { + const [pass, setPass] = useState(''); + const {currentUser, elevateStore} = useStores(); + const localAuthEnabled = config.get('localAuth'); + + const submit = () => { + currentUser.changePassword(pass); + setPass(''); + }; + + return ( + + + + + Appearance + + + Theme + + + + + + + + Change Password + + {localAuthEnabled && !elevateStore.elevated ? ( + + ) : ( +
{ + e.preventDefault(); + submit(); + }}> + setPass(e.target.value)} + fullWidth + /> + +
+ +
+
+ + )} +
+
+
+ ); +}); + +export default Settings;