From b2e7c9236ea7998ed309ff407984f0aa06a4f821 Mon Sep 17 00:00:00 2001 From: Aditya Date: Sun, 30 Aug 2026 02:40:35 +0530 Subject: [PATCH 1/5] 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 --- ui/src/layout/Header.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) 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 b6dbde0bf6e0033dbe4d1ebcec7c8bc24e5e60f7 Mon Sep 17 00:00:00 2001 From: Aditya Date: Sun, 30 Aug 2026 13:50:08 +0530 Subject: [PATCH 2/5] 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; From a6373d0a8fe2d21cca5ab2b43a432f4423c17fa0 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Fri, 4 Sep 2026 11:49:21 +0200 Subject: [PATCH 3/5] fix: remove theme & github icon It's now handled in the settings page --- ui/src/layout/Header.tsx | 47 +--------------------------------------- ui/src/layout/Layout.tsx | 11 ---------- 2 files changed, 1 insertion(+), 57 deletions(-) diff --git a/ui/src/layout/Header.tsx b/ui/src/layout/Header.tsx index 992ef7904..f01dc5225 100644 --- a/ui/src/layout/Header.tsx +++ b/ui/src/layout/Header.tsx @@ -9,23 +9,12 @@ import AccountCircle from '@mui/icons-material/AccountCircle'; import Chat from '@mui/icons-material/Chat'; import DevicesOther from '@mui/icons-material/DevicesOther'; import ExitToApp from '@mui/icons-material/ExitToApp'; -import Brightness4 from '@mui/icons-material/Brightness4'; -import Brightness7 from '@mui/icons-material/Brightness7'; -import BrightnessAuto from '@mui/icons-material/BrightnessAuto'; -import GitHubIcon from '@mui/icons-material/GitHub'; import MenuIcon from '@mui/icons-material/Menu'; import Apps from '@mui/icons-material/Apps'; import SupervisorAccount from '@mui/icons-material/SupervisorAccount'; import React, {CSSProperties} from 'react'; import {Link} from 'react-router'; import {useMediaQuery} from '@mui/material'; -import {ThemeKey} from './theme'; - -const themeIcons: Record = { - dark: , - light: , - system: , -}; const useStyles = makeStyles()((theme: Theme) => ({ appBar: { @@ -76,27 +65,13 @@ interface IProps { name: string; admin: boolean; version: string; - themeMode: ThemeKey; - toggleTheme: VoidFunction; logout: VoidFunction; style: CSSProperties; setNavOpen: (open: boolean) => void; } -const Header = ({ - version, - name, - loggedIn, - admin, - toggleTheme, - logout, - style, - setNavOpen, - themeMode, -}: IProps) => { +const Header = ({version, name, loggedIn, admin, logout, style, setNavOpen}: IProps) => { const {classes} = useStyles(); - const themeLabel = `Toggle theme (current: ${themeMode})`; - const themeIcon = themeIcons[themeMode]; return ( )} -
- - {themeIcon} - - - - - - - -
); diff --git a/ui/src/layout/Layout.tsx b/ui/src/layout/Layout.tsx index 61427db97..ce12746d1 100644 --- a/ui/src/layout/Layout.tsx +++ b/ui/src/layout/Layout.tsx @@ -82,15 +82,6 @@ const Layout = observer(() => { localStorage.setItem(localStorageThemeKey, next); }; - const toggleTheme = () => { - const nextMap: Record = { - dark: 'light', - light: 'system', - system: 'dark', - }; - setTheme(nextMap[currentTheme]); - }; - const authed = (children: React.ReactNode) => ( {children} @@ -121,8 +112,6 @@ const Layout = observer(() => { style={{top: !connectionErrorMessage ? 0 : 64}} version={version} loggedIn={loggedIn} - themeMode={currentTheme} - toggleTheme={toggleTheme} logout={logout} setNavOpen={setNavOpen} /> From 3c5aa2431fb22d7a5b86124529eb9e3b483a02d6 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Fri, 4 Sep 2026 11:50:18 +0200 Subject: [PATCH 4/5] fix: add drop down to user button & move logout into --- ui/src/layout/Header.tsx | 48 ++++++++++++++++++++++++++++------ ui/src/tests/authentication.ts | 4 ++- ui/src/tests/oidc.test.ts | 1 - ui/src/tests/user.test.ts | 7 +++-- 4 files changed, 48 insertions(+), 12 deletions(-) diff --git a/ui/src/layout/Header.tsx b/ui/src/layout/Header.tsx index f01dc5225..f5429550f 100644 --- a/ui/src/layout/Header.tsx +++ b/ui/src/layout/Header.tsx @@ -12,6 +12,11 @@ import ExitToApp from '@mui/icons-material/ExitToApp'; import MenuIcon from '@mui/icons-material/Menu'; import Apps from '@mui/icons-material/Apps'; import SupervisorAccount from '@mui/icons-material/SupervisorAccount'; +import SettingsIcon from '@mui/icons-material/Settings'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; import React, {CSSProperties} from 'react'; import {Link} from 'react-router'; import {useMediaQuery} from '@mui/material'; @@ -116,6 +121,8 @@ const Buttons = ({ setNavOpen: (open: boolean) => void; }) => { const {classes} = useStyles(); + const [anchorEl, setAnchorEl] = React.useState(null); + const userDropDown = Boolean(anchorEl); return (
@@ -140,16 +147,41 @@ const Buttons = ({ } label="plugins" color="inherit" /> - - } label={name} color="inherit" /> - } - label="Logout" - onClick={logout} - id="logout" + icon={} + label={name} + onClick={(e) => setAnchorEl(e.currentTarget)} + id="user-menu-button" + aria-controls={userDropDown ? 'user-menu' : undefined} + aria-haspopup="true" + aria-expanded={userDropDown ? 'true' : undefined} color="inherit" /> + setAnchorEl(null)} + anchorOrigin={{vertical: 'bottom', horizontal: 'right'}} + transformOrigin={{vertical: 'top', horizontal: 'right'}}> + setAnchorEl(null)}> + + + + Settings + + { + setAnchorEl(null); + logout(); + }}> + + + + Logout + +
); }; @@ -159,7 +191,7 @@ const ResponsiveButton: React.FC<{ sx?: ButtonProps['sx']; label: string; id?: string; - onClick?: () => void; + onClick?: (event: React.MouseEvent) => void; icon: React.ReactNode; }> = ({icon, label, ...rest}) => { const matches = useMediaQuery('(max-width:1000px)'); diff --git a/ui/src/tests/authentication.ts b/ui/src/tests/authentication.ts index 232c64718..8e8b7439a 100644 --- a/ui/src/tests/authentication.ts +++ b/ui/src/tests/authentication.ts @@ -12,10 +12,12 @@ export const login = async (page: Page, user = 'admin', pass = 'admin'): Promise await page.type($loginForm.input('.password'), pass); await page.click($loginForm.button('.login')); await waitForExists(page, selector.heading(), 'All Messages'); - await waitForExists(page, 'button', 'logout'); }; export const logout = async (page: Page): Promise => { + await page.waitForSelector('#user-menu-button'); + await page.click('#user-menu-button'); + await page.waitForSelector('#logout'); await page.click('#logout'); await waitForExists(page, selector.heading(), 'Login'); expect(page.url()).toContain('/login'); diff --git a/ui/src/tests/oidc.test.ts b/ui/src/tests/oidc.test.ts index 527b25c45..df56364e4 100644 --- a/ui/src/tests/oidc.test.ts +++ b/ui/src/tests/oidc.test.ts @@ -31,7 +31,6 @@ const loginWithOIDC = async (page: Page, user: DexUser): Promise => { const expectLoggedIn = async (page: Page): Promise => { await waitForExists(page, selector.heading(), 'All Messages'); - await page.waitForSelector('#logout'); }; const oidcError = async (page: Page): Promise => { diff --git a/ui/src/tests/user.test.ts b/ui/src/tests/user.test.ts index fd411f79c..792bd74fd 100644 --- a/ui/src/tests/user.test.ts +++ b/ui/src/tests/user.test.ts @@ -1,6 +1,6 @@ import {Page} from 'puppeteer'; import {newTest, GotifyTest} from './setup'; -import {clearField, count, innerText, waitForExists, waitToDisappear} from './utils'; +import {clearField, clickByText, count, innerText, waitForExists, waitToDisappear} from './utils'; import {afterAll, beforeAll, describe, expect, it} from 'vitest'; import * as auth from './authentication'; import * as selector from './selector'; @@ -121,7 +121,10 @@ describe('User', () => { }); it('changes password of current user', async () => { const $changepw = selector.form('#changepw-form'); - await page.click('#navigate-settings'); + await page.waitForSelector('#user-menu-button'); + await page.click('#user-menu-button'); + await clickByText(page, 'a', 'Settings'); + await waitToDisappear(page, '.MuiBackdrop-root'); await waitForExists(page, selector.heading(), 'Settings'); await page.waitForSelector($changepw.selector()); await page.type($changepw.input('.newpass'), 'changed'); From 9032508786d3e07e751a8edbd1500a16814126b5 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Fri, 4 Sep 2026 11:57:17 +0200 Subject: [PATCH 5/5] fix: extract password form to separate component --- ui/src/user/Settings.tsx | 104 ++++++++++++++++++++------------------- 1 file changed, 54 insertions(+), 50 deletions(-) diff --git a/ui/src/user/Settings.tsx b/ui/src/user/Settings.tsx index 606298c4f..ad3b0aeb8 100644 --- a/ui/src/user/Settings.tsx +++ b/ui/src/user/Settings.tsx @@ -22,15 +22,6 @@ interface IProps { } 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 ( @@ -54,54 +45,67 @@ const Settings = observer(({themeMode, setTheme}: IProps) => { - + Change Password - {localAuthEnabled && !elevateStore.elevated ? ( - - ) : ( -
{ - e.preventDefault(); - submit(); - }}> - setPass(e.target.value)} - fullWidth - /> - -
- -
-
- - )} +
); }); +const ChangePasswordForm = () => { + const [pass, setPass] = useState(''); + const {currentUser, elevateStore} = useStores(); + const localAuthEnabled = config.get('localAuth'); + + const submit = () => { + currentUser.changePassword(pass); + setPass(''); + }; + + if (!localAuthEnabled) { + return Password login is disabled on this server.; + } + + if (!elevateStore.elevated) { + return ; + } + + return ( +
{ + e.preventDefault(); + submit(); + }}> + setPass(e.target.value)} + fullWidth + /> + +
+ +
+
+ + ); +}; + export default Settings;