From d08317a77152424388dbee5778139dddd3530246 Mon Sep 17 00:00:00 2001 From: Aditya Date: Sun, 30 Aug 2026 02:40:35 +0530 Subject: [PATCH 1/2] fix: reject password changes when local auth is disabled The WebUI offered a "Change Password" prompt even with GOTIFY_LOCALAUTH_ENABLED=false, and the endpoint behind it accepted the change: ChangePassword never consulted the setting, so a user on an OIDC-only server could still set a local password that the login form no longer accepts. Guard the handler the same way SessionAPI.Login already does, and hide the header entry that opens the dialog when local auth is off. Closes #1040 --- api/user.go | 6 ++++++ api/user_test.go | 21 ++++++++++++++++++++- router/router.go | 2 +- ui/src/layout/Header.tsx | 17 ++++++++++------- 4 files changed, 37 insertions(+), 9 deletions(-) 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/layout/Header.tsx b/ui/src/layout/Header.tsx index 4ffc38a02..0cc7a24ed 100644 --- a/ui/src/layout/Header.tsx +++ b/ui/src/layout/Header.tsx @@ -20,6 +20,7 @@ import React, {CSSProperties} from 'react'; import {Link} from 'react-router'; import {useMediaQuery} from '@mui/material'; import {ThemeKey} from './theme'; +import * as config from '../config'; const themeIcons: Record = { dark: , @@ -195,13 +196,15 @@ const Buttons = ({ } label="plugins" color="inherit" /> - } - label={name} - onClick={showSettings} - id="changepw" - color="inherit" - /> + {config.get('localAuth') && ( + } + label={name} + onClick={showSettings} + id="changepw" + color="inherit" + /> + )} } label="Logout" From e52a1da7403b03c833fe2dda4bf95d4945c81aad Mon Sep 17 00:00:00 2001 From: Aditya Date: Sun, 30 Aug 2026 13:50:08 +0530 Subject: [PATCH 2/2] feat: add settings page with theme select and password change The header account entry stays as the username display and now links to a new /settings page instead of opening the change password dialog. The page holds a theme select (light, dark, system) and the change password form, rendered disabled when local auth is off. SettingsDialog is removed since nothing opens it anymore. --- ui/src/common/SettingsDialog.tsx | 75 ---------------------- ui/src/layout/Header.tsx | 25 ++------ ui/src/layout/Layout.tsx | 25 +++++--- ui/src/tests/user.test.ts | 5 +- ui/src/user/Settings.tsx | 107 +++++++++++++++++++++++++++++++ 5 files changed, 130 insertions(+), 107 deletions(-) delete mode 100644 ui/src/common/SettingsDialog.tsx create mode 100644 ui/src/user/Settings.tsx 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 0cc7a24ed..992ef7904 100644 --- a/ui/src/layout/Header.tsx +++ b/ui/src/layout/Header.tsx @@ -20,7 +20,6 @@ import React, {CSSProperties} from 'react'; import {Link} from 'react-router'; import {useMediaQuery} from '@mui/material'; import {ThemeKey} from './theme'; -import * as config from '../config'; const themeIcons: Record = { dark: , @@ -79,7 +78,6 @@ interface IProps { version: string; themeMode: ThemeKey; toggleTheme: VoidFunction; - showSettings: VoidFunction; logout: VoidFunction; style: CSSProperties; setNavOpen: (open: boolean) => void; @@ -94,7 +92,6 @@ const Header = ({ logout, style, setNavOpen, - showSettings, themeMode, }: IProps) => { const {classes} = useStyles(); @@ -125,13 +122,7 @@ const Header = ({ {loggedIn && ( - + )}
void; - showSettings: VoidFunction; }) => { const {classes} = useStyles(); @@ -196,15 +185,9 @@ const Buttons = ({ } label="plugins" color="inherit" /> - {config.get('localAuth') && ( - } - 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;