Просмотр исходного кода

Merge pull request #1413 from wunter8/change-password-provisioned-user

prevent changing a provisioned user's password
Philipp C. Heckel 6 месяцев назад
Родитель
Сommit
eac523dcf9

+ 8 - 0
cmd/user_test.go

@@ -60,6 +60,9 @@ func TestCLI_User_Add_Password_Mismatch(t *testing.T) {
 
 
 func TestCLI_User_ChangePass(t *testing.T) {
 func TestCLI_User_ChangePass(t *testing.T) {
 	s, conf, port := newTestServerWithAuth(t)
 	s, conf, port := newTestServerWithAuth(t)
+	conf.AuthUsers = []*user.User{
+		{Name: "philuser", Hash: "$2a$10$U4WSIYY6evyGmZaraavM2e2JeVG6EMGUKN1uUwufUeeRd4Jpg6cGC", Role: user.RoleUser}, // philuser:philpass
+	}
 	defer test.StopServer(t, s, port)
 	defer test.StopServer(t, s, port)
 
 
 	// Add user
 	// Add user
@@ -73,6 +76,11 @@ func TestCLI_User_ChangePass(t *testing.T) {
 	stdin.WriteString("newpass\nnewpass")
 	stdin.WriteString("newpass\nnewpass")
 	require.Nil(t, runUserCommand(app, conf, "change-pass", "phil"))
 	require.Nil(t, runUserCommand(app, conf, "change-pass", "phil"))
 	require.Contains(t, stdout.String(), "changed password for user phil")
 	require.Contains(t, stdout.String(), "changed password for user phil")
+
+	// Cannot change provisioned user's pass
+	app, stdin, _, _ = newTestApp()
+	stdin.WriteString("newpass\nnewpass")
+	require.Error(t, runUserCommand(app, conf, "change-pass", "philuser"))
 }
 }
 
 
 func TestCLI_User_ChangeRole(t *testing.T) {
 func TestCLI_User_ChangeRole(t *testing.T) {

+ 30 - 30
docs/install.md

@@ -30,37 +30,37 @@ deb/rpm packages.
 
 
 === "x86_64/amd64"
 === "x86_64/amd64"
     ```bash
     ```bash
-    wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_amd64.tar.gz
-    tar zxvf ntfy_2.13.0_linux_amd64.tar.gz
-    sudo cp -a ntfy_2.13.0_linux_amd64/ntfy /usr/local/bin/ntfy
-    sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.0_linux_amd64/{client,server}/*.yml /etc/ntfy
+    wget https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_amd64.tar.gz
+    tar zxvf ntfy_2.14.0_linux_amd64.tar.gz
+    sudo cp -a ntfy_2.14.0_linux_amd64/ntfy /usr/local/bin/ntfy
+    sudo mkdir /etc/ntfy && sudo cp ntfy_2.14.0_linux_amd64/{client,server}/*.yml /etc/ntfy
     sudo ntfy serve
     sudo ntfy serve
     ```
     ```
 
 
 === "armv6"
 === "armv6"
     ```bash
     ```bash
-    wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv6.tar.gz
-    tar zxvf ntfy_2.13.0_linux_armv6.tar.gz
-    sudo cp -a ntfy_2.13.0_linux_armv6/ntfy /usr/bin/ntfy
-    sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.0_linux_armv6/{client,server}/*.yml /etc/ntfy
+    wget https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_armv6.tar.gz
+    tar zxvf ntfy_2.14.0_linux_armv6.tar.gz
+    sudo cp -a ntfy_2.14.0_linux_armv6/ntfy /usr/bin/ntfy
+    sudo mkdir /etc/ntfy && sudo cp ntfy_2.14.0_linux_armv6/{client,server}/*.yml /etc/ntfy
     sudo ntfy serve
     sudo ntfy serve
     ```
     ```
 
 
 === "armv7/armhf"
 === "armv7/armhf"
     ```bash
     ```bash
-    wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv7.tar.gz
-    tar zxvf ntfy_2.13.0_linux_armv7.tar.gz
-    sudo cp -a ntfy_2.13.0_linux_armv7/ntfy /usr/bin/ntfy
-    sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.0_linux_armv7/{client,server}/*.yml /etc/ntfy
+    wget https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_armv7.tar.gz
+    tar zxvf ntfy_2.14.0_linux_armv7.tar.gz
+    sudo cp -a ntfy_2.14.0_linux_armv7/ntfy /usr/bin/ntfy
+    sudo mkdir /etc/ntfy && sudo cp ntfy_2.14.0_linux_armv7/{client,server}/*.yml /etc/ntfy
     sudo ntfy serve
     sudo ntfy serve
     ```
     ```
 
 
 === "arm64"
 === "arm64"
     ```bash
     ```bash
-    wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_arm64.tar.gz
-    tar zxvf ntfy_2.13.0_linux_arm64.tar.gz
-    sudo cp -a ntfy_2.13.0_linux_arm64/ntfy /usr/bin/ntfy
-    sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.0_linux_arm64/{client,server}/*.yml /etc/ntfy
+    wget https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_arm64.tar.gz
+    tar zxvf ntfy_2.14.0_linux_arm64.tar.gz
+    sudo cp -a ntfy_2.14.0_linux_arm64/ntfy /usr/bin/ntfy
+    sudo mkdir /etc/ntfy && sudo cp ntfy_2.14.0_linux_arm64/{client,server}/*.yml /etc/ntfy
     sudo ntfy serve
     sudo ntfy serve
     ```
     ```
 
 
@@ -110,7 +110,7 @@ Manually installing the .deb file:
 
 
 === "x86_64/amd64"
 === "x86_64/amd64"
     ```bash
     ```bash
-    wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_amd64.deb
+    wget https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_amd64.deb
     sudo dpkg -i ntfy_*.deb
     sudo dpkg -i ntfy_*.deb
     sudo systemctl enable ntfy
     sudo systemctl enable ntfy
     sudo systemctl start ntfy
     sudo systemctl start ntfy
@@ -118,7 +118,7 @@ Manually installing the .deb file:
 
 
 === "armv6"
 === "armv6"
     ```bash
     ```bash
-    wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv6.deb
+    wget https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_armv6.deb
     sudo dpkg -i ntfy_*.deb
     sudo dpkg -i ntfy_*.deb
     sudo systemctl enable ntfy
     sudo systemctl enable ntfy
     sudo systemctl start ntfy
     sudo systemctl start ntfy
@@ -126,7 +126,7 @@ Manually installing the .deb file:
 
 
 === "armv7/armhf"
 === "armv7/armhf"
     ```bash
     ```bash
-    wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv7.deb
+    wget https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_armv7.deb
     sudo dpkg -i ntfy_*.deb
     sudo dpkg -i ntfy_*.deb
     sudo systemctl enable ntfy
     sudo systemctl enable ntfy
     sudo systemctl start ntfy
     sudo systemctl start ntfy
@@ -134,7 +134,7 @@ Manually installing the .deb file:
 
 
 === "arm64"
 === "arm64"
     ```bash
     ```bash
-    wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_arm64.deb
+    wget https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_arm64.deb
     sudo dpkg -i ntfy_*.deb
     sudo dpkg -i ntfy_*.deb
     sudo systemctl enable ntfy
     sudo systemctl enable ntfy
     sudo systemctl start ntfy
     sudo systemctl start ntfy
@@ -144,28 +144,28 @@ Manually installing the .deb file:
 
 
 === "x86_64/amd64"
 === "x86_64/amd64"
     ```bash
     ```bash
-    sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_amd64.rpm
+    sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_amd64.rpm
     sudo systemctl enable ntfy 
     sudo systemctl enable ntfy 
     sudo systemctl start ntfy
     sudo systemctl start ntfy
     ```
     ```
 
 
 === "armv6"
 === "armv6"
     ```bash
     ```bash
-    sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv6.rpm
+    sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_armv6.rpm
     sudo systemctl enable ntfy
     sudo systemctl enable ntfy
     sudo systemctl start ntfy
     sudo systemctl start ntfy
     ```
     ```
 
 
 === "armv7/armhf"
 === "armv7/armhf"
     ```bash
     ```bash
-    sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv7.rpm
+    sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_armv7.rpm
     sudo systemctl enable ntfy 
     sudo systemctl enable ntfy 
     sudo systemctl start ntfy
     sudo systemctl start ntfy
     ```
     ```
 
 
 === "arm64"
 === "arm64"
     ```bash
     ```bash
-    sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_arm64.rpm
+    sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_arm64.rpm
     sudo systemctl enable ntfy 
     sudo systemctl enable ntfy 
     sudo systemctl start ntfy
     sudo systemctl start ntfy
     ```
     ```
@@ -195,18 +195,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos.
 
 
 ## macOS
 ## macOS
 The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well. 
 The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well. 
-To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_darwin_all.tar.gz), 
+To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_darwin_all.tar.gz), 
 extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`). 
 extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`). 
 
 
 If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at 
 If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at 
 `~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
 `~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
 
 
 ```bash
 ```bash
-curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_darwin_all.tar.gz > ntfy_2.13.0_darwin_all.tar.gz
-tar zxvf ntfy_2.13.0_darwin_all.tar.gz
-sudo cp -a ntfy_2.13.0_darwin_all/ntfy /usr/local/bin/ntfy
+curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_darwin_all.tar.gz > ntfy_2.14.0_darwin_all.tar.gz
+tar zxvf ntfy_2.14.0_darwin_all.tar.gz
+sudo cp -a ntfy_2.14.0_darwin_all/ntfy /usr/local/bin/ntfy
 mkdir ~/Library/Application\ Support/ntfy 
 mkdir ~/Library/Application\ Support/ntfy 
-cp ntfy_2.13.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
+cp ntfy_2.14.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
 ntfy --help
 ntfy --help
 ```
 ```
 
 
@@ -224,7 +224,7 @@ brew install ntfy
 
 
 ## Windows
 ## Windows
 The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
 The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
-To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_windows_amd64.zip),
+To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_windows_amd64.zip),
 extract it and place the `ntfy.exe` binary somewhere in your `%Path%`. 
 extract it and place the `ntfy.exe` binary somewhere in your `%Path%`. 
 
 
 The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
 The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).

+ 16 - 8
docs/releases.md

@@ -2,6 +2,22 @@
 Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
 Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
 and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
 and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
 
 
+### ntfy server v2.14.0
+Released August 5, 2025
+
+This release adds support for [declarative users](config.md#users-via-the-config), [declarative ACL entries](config.md#acl-entries-via-the-config) and [declarative tokens](config.md#tokens-via-the-config). This allows you to define users, ACL entries and tokens in the config file, which is useful for static deployments or deployments that use a configuration management system.
+
+It also adds support for [pre-defined templates](publish.md#pre-defined-templates) and [custom templates](publish.md#custom-templates) for enhanced JSON webhook support, as well as advanced [template functions](publish.md#template-functions) based on the [Sprig](https://github.com/Masterminds/sprig) functions.
+
+❤️ If you like ntfy, **please consider sponsoring me** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier), [Liberapay](https://en.liberapay.com/ntfy/), Bitcoin (`1626wjrw3uWk9adyjCfYwafw4sQWujyjn8`), or by buying a [paid plan via the web app](https://ntfy.sh/app). ntfy
+will always remain open source.
+
+**Features:**
+
+* [Declarative users](config.md#users-via-the-config), [declarative ACL entries](config.md#acl-entries-via-the-config) and [declarative tokens](config.md#tokens-via-the-config) ([#464](https://github.com/binwiederhier/ntfy/issues/464), [#1384](https://github.com/binwiederhier/ntfy/pull/1384), [#1413](https://github.com/binwiederhier/ntfy/pull/1413), thanks to [pinpox](https://github.com/pinpox) for reporting, to [@wunter8](https://github.com/wunter8) for reviewing and implementing parts of it)
+* [Pre-defined templates](publish.md#pre-defined-templates) and [custom templates](publish.md#custom-templates) for enhanced JSON webhook support ([#1390](https://github.com/binwiederhier/ntfy/pull/1390))
+* Support of advanced [template functions](publish.md#template-functions) based on the [Sprig](https://github.com/Masterminds/sprig) library ([#1121](https://github.com/binwiederhier/ntfy/issues/1121), thanks to [@davidatkinsondoyle](https://github.com/davidatkinsondoyle) for reporting, to [@wunter8](https://github.com/wunter8) for implementing, and to the Sprig team for their work)
+
 ### ntfy server v2.13.0
 ### ntfy server v2.13.0
 Released July 10, 2025
 Released July 10, 2025
 
 
@@ -1452,14 +1468,6 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
 
 
 ## Not released yet
 ## Not released yet
 
 
-### ntfy server v2.14.0 (UNRELEASED)
-
-**Features:**
-
-* [Declarative users](config.md#users-via-the-config), [declarative ACL entries](config.md#acl-entries-via-the-config) and [declarative tokens](config.md#tokens-via-the-config) ([#464](https://github.com/binwiederhier/ntfy/issues/464), [#1384](https://github.com/binwiederhier/ntfy/pull/1384), thanks to [pinpox](https://github.com/pinpox) for reporting, to [@wunter8](https://github.com/wunter8) for reviewing)
-* [Pre-defined templates](publish.md#pre-defined-templates) and [custom templates](publish.md#custom-templates) for enhanced JSON webhook support ([#1390](https://github.com/binwiederhier/ntfy/pull/1390))
-* Support of advanced [template functions](publish.md#template-functions) based on the [Sprig](https://github.com/Masterminds/sprig) library ([#1121](https://github.com/binwiederhier/ntfy/issues/1121), thanks to [@davidatkinsondoyle](https://github.com/davidatkinsondoyle) for reporting, to [@wunter8](https://github.com/wunter8) for implementing, and to the Sprig team for their work)
-
 ### ntfy Android app v1.16.1 (UNRELEASED)
 ### ntfy Android app v1.16.1 (UNRELEASED)
 
 
 **Features:**
 **Features:**

+ 6 - 6
go.mod

@@ -30,10 +30,10 @@ replace github.com/emersion/go-smtp => github.com/emersion/go-smtp v0.17.0 // Pi
 require github.com/pkg/errors v0.9.1 // indirect
 require github.com/pkg/errors v0.9.1 // indirect
 
 
 require (
 require (
-	firebase.google.com/go/v4 v4.17.0
+	firebase.google.com/go/v4 v4.18.0
 	github.com/SherClockHolmes/webpush-go v1.4.0
 	github.com/SherClockHolmes/webpush-go v1.4.0
 	github.com/microcosm-cc/bluemonday v1.0.27
 	github.com/microcosm-cc/bluemonday v1.0.27
-	github.com/prometheus/client_golang v1.22.0
+	github.com/prometheus/client_golang v1.23.0
 	github.com/stripe/stripe-go/v74 v74.30.0
 	github.com/stripe/stripe-go/v74 v74.30.0
 	golang.org/x/text v0.27.0
 	golang.org/x/text v0.27.0
 )
 )
@@ -61,7 +61,7 @@ require (
 	github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
 	github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
 	github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
 	github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
 	github.com/felixge/httpsnoop v1.0.4 // indirect
 	github.com/felixge/httpsnoop v1.0.4 // indirect
-	github.com/go-jose/go-jose/v4 v4.1.1 // indirect
+	github.com/go-jose/go-jose/v4 v4.1.2 // indirect
 	github.com/go-logr/logr v1.4.3 // indirect
 	github.com/go-logr/logr v1.4.3 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
 	github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
 	github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
@@ -95,9 +95,9 @@ require (
 	golang.org/x/net v0.42.0 // indirect
 	golang.org/x/net v0.42.0 // indirect
 	golang.org/x/sys v0.34.0 // indirect
 	golang.org/x/sys v0.34.0 // indirect
 	google.golang.org/appengine/v2 v2.0.6 // indirect
 	google.golang.org/appengine/v2 v2.0.6 // indirect
-	google.golang.org/genproto v0.0.0-20250728155136-f173205681a0 // indirect
-	google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect
+	google.golang.org/genproto v0.0.0-20250804133106-a7a43d27e69b // indirect
+	google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect
 	google.golang.org/grpc v1.74.2 // indirect
 	google.golang.org/grpc v1.74.2 // indirect
 	google.golang.org/protobuf v1.36.6 // indirect
 	google.golang.org/protobuf v1.36.6 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect

+ 12 - 12
go.sum

@@ -22,8 +22,8 @@ cloud.google.com/go/storage v1.56.0 h1:iixmq2Fse2tqxMbWhLWC9HfBj1qdxqAmiK8/eqtsL
 cloud.google.com/go/storage v1.56.0/go.mod h1:Tpuj6t4NweCLzlNbw9Z9iwxEkrSem20AetIeH/shgVU=
 cloud.google.com/go/storage v1.56.0/go.mod h1:Tpuj6t4NweCLzlNbw9Z9iwxEkrSem20AetIeH/shgVU=
 cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4=
 cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4=
 cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=
 cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=
-firebase.google.com/go/v4 v4.17.0 h1:Bih69QV/k0YKPA1qUX04ln0aPT9IERrAo2ezibcngzE=
-firebase.google.com/go/v4 v4.17.0/go.mod h1:aAPJq/bOyb23tBlc1K6GR+2E8sOGAeJSc8wIJVgl9SM=
+firebase.google.com/go/v4 v4.18.0 h1:S+g0P72oDGqOaG4wlLErX3zQmU9plVdu7j+Bc3R1qFw=
+firebase.google.com/go/v4 v4.18.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs=
 github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
 github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
 github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
 github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
 github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
 github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
@@ -70,8 +70,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
 github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
 github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
 github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
 github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
-github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI=
-github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA=
+github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI=
+github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo=
 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
 github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
 github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -127,8 +127,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
-github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
+github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
+github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
 github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
 github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
 github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
 github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
 github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
 github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
@@ -265,12 +265,12 @@ google.golang.org/api v0.244.0 h1:lpkP8wVibSKr++NCD36XzTk/IzeKJ3klj7vbj+XU5pE=
 google.golang.org/api v0.244.0/go.mod h1:dMVhVcylamkirHdzEBAIQWUCgqY885ivNeZYd7VAVr8=
 google.golang.org/api v0.244.0/go.mod h1:dMVhVcylamkirHdzEBAIQWUCgqY885ivNeZYd7VAVr8=
 google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
 google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
 google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
 google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
-google.golang.org/genproto v0.0.0-20250728155136-f173205681a0 h1:btBcgujH2+KIWEfz0s7Cdtt9R7hpwM4SAEXAdXf/ddw=
-google.golang.org/genproto v0.0.0-20250728155136-f173205681a0/go.mod h1:Q4yZQ3kmmIyg6HsMjCGx2vQ8gzN+dntaPmFWz6Zj0fo=
-google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0 h1:0UOBWO4dC+e51ui0NFKSPbkHHiQ4TmrEfEZMLDyRmY8=
-google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0/go.mod h1:8ytArBbtOy2xfht+y2fqKd5DRDJRUQhqbyEnQ4bDChs=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 h1:MAKi5q709QWfnkkpNQ0M12hYJ1+e8qYVDyowc4U1XZM=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
+google.golang.org/genproto v0.0.0-20250804133106-a7a43d27e69b h1:eZTgydvqZO44zyTZAvMaSyAxccZZdraiSAGvqOczVvk=
+google.golang.org/genproto v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:suyz2QBHQKlGIF92HEEsCfO1SwxXdk7PFLz+Zd9Uah4=
+google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b h1:ULiyYQ0FdsJhwwZUwbaXpZF5yUE3h+RA+gxvBu37ucc=
+google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
 google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
 google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
 google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
 google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=

+ 2 - 0
server/errors.go

@@ -132,6 +132,8 @@ var (
 	errHTTPConflictTopicReserved                     = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", "", nil}
 	errHTTPConflictTopicReserved                     = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", "", nil}
 	errHTTPConflictSubscriptionExists                = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil}
 	errHTTPConflictSubscriptionExists                = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil}
 	errHTTPConflictPhoneNumberExists                 = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil}
 	errHTTPConflictPhoneNumberExists                 = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil}
+	errHTTPConflictProvisionedUserChange             = &errHTTP{40905, http.StatusConflict, "conflict: cannot change or delete provisioned user", "", nil}
+	errHTTPConflictProvisionedTokenChange            = &errHTTP{40906, http.StatusConflict, "conflict: cannot change or delete provisioned token", "", nil}
 	errHTTPGonePhoneVerificationExpired              = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil}
 	errHTTPGonePhoneVerificationExpired              = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil}
 	errHTTPEntityTooLargeAttachment                  = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil}
 	errHTTPEntityTooLargeAttachment                  = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil}
 	errHTTPEntityTooLargeMatrixRequest               = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
 	errHTTPEntityTooLargeMatrixRequest               = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}

+ 22 - 5
server/server_account.go

@@ -85,6 +85,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
 		response.Username = u.Name
 		response.Username = u.Name
 		response.Role = string(u.Role)
 		response.Role = string(u.Role)
 		response.SyncTopic = u.SyncTopic
 		response.SyncTopic = u.SyncTopic
+		response.Provisioned = u.Provisioned
 		if u.Prefs != nil {
 		if u.Prefs != nil {
 			if u.Prefs.Language != nil {
 			if u.Prefs.Language != nil {
 				response.Language = *u.Prefs.Language
 				response.Language = *u.Prefs.Language
@@ -139,11 +140,12 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
 					lastOrigin = t.LastOrigin.String()
 					lastOrigin = t.LastOrigin.String()
 				}
 				}
 				response.Tokens = append(response.Tokens, &apiAccountTokenResponse{
 				response.Tokens = append(response.Tokens, &apiAccountTokenResponse{
-					Token:      t.Value,
-					Label:      t.Label,
-					LastAccess: t.LastAccess.Unix(),
-					LastOrigin: lastOrigin,
-					Expires:    t.Expires.Unix(),
+					Token:       t.Value,
+					Label:       t.Label,
+					LastAccess:  t.LastAccess.Unix(),
+					LastOrigin:  lastOrigin,
+					Expires:     t.Expires.Unix(),
+					Provisioned: t.Provisioned,
 				})
 				})
 			}
 			}
 		}
 		}
@@ -174,6 +176,12 @@ func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v *
 	if _, err := s.userManager.Authenticate(u.Name, req.Password); err != nil {
 	if _, err := s.userManager.Authenticate(u.Name, req.Password); err != nil {
 		return errHTTPBadRequestIncorrectPasswordConfirmation
 		return errHTTPBadRequestIncorrectPasswordConfirmation
 	}
 	}
+	if err := s.userManager.CanChangeUser(u.Name); err != nil {
+		if errors.Is(err, user.ErrProvisionedUserChange) {
+			return errHTTPConflictProvisionedUserChange
+		}
+		return err
+	}
 	if s.webPush != nil && u.ID != "" {
 	if s.webPush != nil && u.ID != "" {
 		if err := s.webPush.RemoveSubscriptionsByUserID(u.ID); err != nil {
 		if err := s.webPush.RemoveSubscriptionsByUserID(u.ID); err != nil {
 			logvr(v, r).Err(err).Warn("Error removing web push subscriptions for %s", u.Name)
 			logvr(v, r).Err(err).Warn("Error removing web push subscriptions for %s", u.Name)
@@ -208,6 +216,9 @@ func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Requ
 	}
 	}
 	logvr(v, r).Tag(tagAccount).Debug("Changing password for user %s", u.Name)
 	logvr(v, r).Tag(tagAccount).Debug("Changing password for user %s", u.Name)
 	if err := s.userManager.ChangePassword(u.Name, req.NewPassword, false); err != nil {
 	if err := s.userManager.ChangePassword(u.Name, req.NewPassword, false); err != nil {
+		if errors.Is(err, user.ErrProvisionedUserChange) {
+			return errHTTPConflictProvisionedUserChange
+		}
 		return err
 		return err
 	}
 	}
 	return s.writeJSON(w, newSuccessResponse())
 	return s.writeJSON(w, newSuccessResponse())
@@ -274,6 +285,9 @@ func (s *Server) handleAccountTokenUpdate(w http.ResponseWriter, r *http.Request
 		Debug("Updating token for user %s as deleted", u.Name)
 		Debug("Updating token for user %s as deleted", u.Name)
 	token, err := s.userManager.ChangeToken(u.ID, req.Token, req.Label, expires)
 	token, err := s.userManager.ChangeToken(u.ID, req.Token, req.Label, expires)
 	if err != nil {
 	if err != nil {
+		if errors.Is(err, user.ErrProvisionedTokenChange) {
+			return errHTTPConflictProvisionedTokenChange
+		}
 		return err
 		return err
 	}
 	}
 	response := &apiAccountTokenResponse{
 	response := &apiAccountTokenResponse{
@@ -296,6 +310,9 @@ func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, r *http.Request
 		}
 		}
 	}
 	}
 	if err := s.userManager.RemoveToken(u.ID, token); err != nil {
 	if err := s.userManager.RemoveToken(u.ID, token); err != nil {
+		if errors.Is(err, user.ErrProvisionedTokenChange) {
+			return errHTTPConflictProvisionedTokenChange
+		}
 		return err
 		return err
 	}
 	}
 	logvr(v, r).
 	logvr(v, r).

+ 11 - 1
server/server_account_test.go

@@ -251,7 +251,11 @@ func TestAccount_Subscription_AddUpdateDelete(t *testing.T) {
 }
 }
 
 
 func TestAccount_ChangePassword(t *testing.T) {
 func TestAccount_ChangePassword(t *testing.T) {
-	s := newTestServer(t, newTestConfigWithAuthFile(t))
+	conf := newTestConfigWithAuthFile(t)
+	conf.AuthUsers = []*user.User{
+		{Name: "philuser", Hash: "$2a$10$U4WSIYY6evyGmZaraavM2e2JeVG6EMGUKN1uUwufUeeRd4Jpg6cGC", Role: user.RoleUser}, // philuser:philpass
+	}
+	s := newTestServer(t, conf)
 	defer s.closeDatabases()
 	defer s.closeDatabases()
 
 
 	require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
 	require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
@@ -281,6 +285,12 @@ func TestAccount_ChangePassword(t *testing.T) {
 		"Authorization": util.BasicAuth("phil", "new password"),
 		"Authorization": util.BasicAuth("phil", "new password"),
 	})
 	})
 	require.Equal(t, 200, rr.Code)
 	require.Equal(t, 200, rr.Code)
+
+	// Cannot change password of provisioned user
+	rr = request(t, s, "POST", "/v1/account/password", `{"password": "philpass", "new_password": "new password"}`, map[string]string{
+		"Authorization": util.BasicAuth("philuser", "philpass"),
+	})
+	require.Equal(t, 409, rr.Code)
 }
 }
 
 
 func TestAccount_ChangePassword_NoAccount(t *testing.T) {
 func TestAccount_ChangePassword_NoAccount(t *testing.T) {

+ 7 - 5
server/types.go

@@ -360,11 +360,12 @@ type apiAccountTokenUpdateRequest struct {
 }
 }
 
 
 type apiAccountTokenResponse struct {
 type apiAccountTokenResponse struct {
-	Token      string `json:"token"`
-	Label      string `json:"label,omitempty"`
-	LastAccess int64  `json:"last_access,omitempty"`
-	LastOrigin string `json:"last_origin,omitempty"`
-	Expires    int64  `json:"expires,omitempty"` // Unix timestamp
+	Token       string `json:"token"`
+	Label       string `json:"label,omitempty"`
+	LastAccess  int64  `json:"last_access,omitempty"`
+	LastOrigin  string `json:"last_origin,omitempty"`
+	Expires     int64  `json:"expires,omitempty"`     // Unix timestamp
+	Provisioned bool   `json:"provisioned,omitempty"` // True if this token was provisioned by the server config
 }
 }
 
 
 type apiAccountPhoneNumberVerifyRequest struct {
 type apiAccountPhoneNumberVerifyRequest struct {
@@ -426,6 +427,7 @@ type apiAccountResponse struct {
 	Username      string                     `json:"username"`
 	Username      string                     `json:"username"`
 	Role          string                     `json:"role,omitempty"`
 	Role          string                     `json:"role,omitempty"`
 	SyncTopic     string                     `json:"sync_topic,omitempty"`
 	SyncTopic     string                     `json:"sync_topic,omitempty"`
+	Provisioned   bool                       `json:"provisioned,omitempty"`
 	Language      string                     `json:"language,omitempty"`
 	Language      string                     `json:"language,omitempty"`
 	Notification  *user.NotificationPrefs    `json:"notification,omitempty"`
 	Notification  *user.NotificationPrefs    `json:"notification,omitempty"`
 	Subscriptions []*user.Subscription       `json:"subscriptions,omitempty"`
 	Subscriptions []*user.Subscription       `json:"subscriptions,omitempty"`

+ 40 - 8
user/manager.go

@@ -773,6 +773,9 @@ func (a *Manager) ChangeToken(userID, token string, label *string, expires *time
 	if token == "" {
 	if token == "" {
 		return nil, errNoTokenProvided
 		return nil, errNoTokenProvided
 	}
 	}
+	if err := a.CanChangeToken(userID, token); err != nil {
+		return nil, err
+	}
 	tx, err := a.db.Begin()
 	tx, err := a.db.Begin()
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
@@ -796,6 +799,9 @@ func (a *Manager) ChangeToken(userID, token string, label *string, expires *time
 
 
 // RemoveToken deletes the token defined in User.Token
 // RemoveToken deletes the token defined in User.Token
 func (a *Manager) RemoveToken(userID, token string) error {
 func (a *Manager) RemoveToken(userID, token string) error {
+	if err := a.CanChangeToken(userID, token); err != nil {
+		return err
+	}
 	return execTx(a.db, func(tx *sql.Tx) error {
 	return execTx(a.db, func(tx *sql.Tx) error {
 		return a.removeTokenTx(tx, userID, token)
 		return a.removeTokenTx(tx, userID, token)
 	})
 	})
@@ -811,6 +817,17 @@ func (a *Manager) removeTokenTx(tx *sql.Tx, userID, token string) error {
 	return nil
 	return nil
 }
 }
 
 
+// CanChangeToken checks if the token can be changed. If the token is provisioned, it cannot be changed.
+func (a *Manager) CanChangeToken(userID, token string) error {
+	t, err := a.Token(userID, token)
+	if err != nil {
+		return err
+	} else if t.Provisioned {
+		return ErrProvisionedTokenChange
+	}
+	return nil
+}
+
 // RemoveExpiredTokens deletes all expired tokens from the database
 // RemoveExpiredTokens deletes all expired tokens from the database
 func (a *Manager) RemoveExpiredTokens() error {
 func (a *Manager) RemoveExpiredTokens() error {
 	if _, err := a.db.Exec(deleteExpiredTokensQuery, time.Now().Unix()); err != nil {
 	if _, err := a.db.Exec(deleteExpiredTokensQuery, time.Now().Unix()); err != nil {
@@ -1072,6 +1089,9 @@ func (a *Manager) addUserTx(tx *sql.Tx, username, password string, role Role, ha
 // RemoveUser deletes the user with the given username. The function returns nil on success, even
 // RemoveUser deletes the user with the given username. The function returns nil on success, even
 // if the user did not exist in the first place.
 // if the user did not exist in the first place.
 func (a *Manager) RemoveUser(username string) error {
 func (a *Manager) RemoveUser(username string) error {
+	if err := a.CanChangeUser(username); err != nil {
+		return err
+	}
 	return execTx(a.db, func(tx *sql.Tx) error {
 	return execTx(a.db, func(tx *sql.Tx) error {
 		return a.removeUserTx(tx, username)
 		return a.removeUserTx(tx, username)
 	})
 	})
@@ -1389,11 +1409,26 @@ func (a *Manager) ReservationOwner(topic string) (string, error) {
 
 
 // ChangePassword changes a user's password
 // ChangePassword changes a user's password
 func (a *Manager) ChangePassword(username, password string, hashed bool) error {
 func (a *Manager) ChangePassword(username, password string, hashed bool) error {
+	if err := a.CanChangeUser(username); err != nil {
+		return err
+	}
 	return execTx(a.db, func(tx *sql.Tx) error {
 	return execTx(a.db, func(tx *sql.Tx) error {
 		return a.changePasswordTx(tx, username, password, hashed)
 		return a.changePasswordTx(tx, username, password, hashed)
 	})
 	})
 }
 }
 
 
+// CanChangeUser checks if the user with the given username can be changed.
+// This is used to prevent changes to provisioned users, which are defined in the config file.
+func (a *Manager) CanChangeUser(username string) error {
+	user, err := a.User(username)
+	if err != nil {
+		return err
+	} else if user.Provisioned {
+		return ErrProvisionedUserChange
+	}
+	return nil
+}
+
 func (a *Manager) changePasswordTx(tx *sql.Tx, username, password string, hashed bool) error {
 func (a *Manager) changePasswordTx(tx *sql.Tx, username, password string, hashed bool) error {
 	var hash string
 	var hash string
 	var err error
 	var err error
@@ -1417,6 +1452,9 @@ func (a *Manager) changePasswordTx(tx *sql.Tx, username, password string, hashed
 // ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin,
 // ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin,
 // all existing access control entries (Grant) are removed, since they are no longer needed.
 // all existing access control entries (Grant) are removed, since they are no longer needed.
 func (a *Manager) ChangeRole(username string, role Role) error {
 func (a *Manager) ChangeRole(username string, role Role) error {
+	if err := a.CanChangeUser(username); err != nil {
+		return err
+	}
 	return execTx(a.db, func(tx *sql.Tx) error {
 	return execTx(a.db, func(tx *sql.Tx) error {
 		return a.changeRoleTx(tx, username, role)
 		return a.changeRoleTx(tx, username, role)
 	})
 	})
@@ -1437,14 +1475,8 @@ func (a *Manager) changeRoleTx(tx *sql.Tx, username string, role Role) error {
 	return nil
 	return nil
 }
 }
 
 
-// ChangeProvisioned changes the provisioned status of a user. This is used to mark users as
+// changeProvisionedTx changes the provisioned status of a user. This is used to mark users as
 // provisioned. A provisioned user is a user defined in the config file.
 // provisioned. A provisioned user is a user defined in the config file.
-func (a *Manager) ChangeProvisioned(username string, provisioned bool) error {
-	return execTx(a.db, func(tx *sql.Tx) error {
-		return a.changeProvisionedTx(tx, username, provisioned)
-	})
-}
-
 func (a *Manager) changeProvisionedTx(tx *sql.Tx, username string, provisioned bool) error {
 func (a *Manager) changeProvisionedTx(tx *sql.Tx, username string, provisioned bool) error {
 	if _, err := tx.Exec(updateUserProvisionedQuery, provisioned, username); err != nil {
 	if _, err := tx.Exec(updateUserProvisionedQuery, provisioned, username); err != nil {
 		return err
 		return err
@@ -1670,7 +1702,7 @@ func (a *Manager) Tiers() ([]*Tier, error) {
 	tiers := make([]*Tier, 0)
 	tiers := make([]*Tier, 0)
 	for {
 	for {
 		tier, err := a.readTier(rows)
 		tier, err := a.readTier(rows)
-		if err == ErrTierNotFound {
+		if errors.Is(err, ErrTierNotFound) {
 			break
 			break
 		} else if err != nil {
 		} else if err != nil {
 			return nil, err
 			return nil, err

+ 3 - 0
user/manager_test.go

@@ -1209,6 +1209,9 @@ func TestManager_WithProvisionedUsers(t *testing.T) {
 	require.Equal(t, "tk_u48wqendnkx9er21pqqcadlytbutx", tokens[1].Value)
 	require.Equal(t, "tk_u48wqendnkx9er21pqqcadlytbutx", tokens[1].Value)
 	require.Equal(t, "Another token", tokens[1].Label)
 	require.Equal(t, "Another token", tokens[1].Label)
 
 
+	// Try changing provisioned user's password
+	require.Error(t, a.ChangePassword("philuser", "new-pass", false))
+
 	// Re-open the DB again (third app start)
 	// Re-open the DB again (third app start)
 	require.Nil(t, a.db.Close())
 	require.Nil(t, a.db.Close())
 	conf.Users = []*User{}
 	conf.Users = []*User{}

+ 13 - 11
user/types.go

@@ -244,15 +244,17 @@ const (
 
 
 // Error constants used by the package
 // Error constants used by the package
 var (
 var (
-	ErrUnauthenticated     = errors.New("unauthenticated")
-	ErrUnauthorized        = errors.New("unauthorized")
-	ErrInvalidArgument     = errors.New("invalid argument")
-	ErrUserNotFound        = errors.New("user not found")
-	ErrUserExists          = errors.New("user already exists")
-	ErrPasswordHashInvalid = errors.New("password hash but be a bcrypt hash, use 'ntfy user hash' to generate")
-	ErrTierNotFound        = errors.New("tier not found")
-	ErrTokenNotFound       = errors.New("token not found")
-	ErrPhoneNumberNotFound = errors.New("phone number not found")
-	ErrTooManyReservations = errors.New("new tier has lower reservation limit")
-	ErrPhoneNumberExists   = errors.New("phone number already exists")
+	ErrUnauthenticated        = errors.New("unauthenticated")
+	ErrUnauthorized           = errors.New("unauthorized")
+	ErrInvalidArgument        = errors.New("invalid argument")
+	ErrUserNotFound           = errors.New("user not found")
+	ErrUserExists             = errors.New("user already exists")
+	ErrPasswordHashInvalid    = errors.New("password hash but be a bcrypt hash, use 'ntfy user hash' to generate")
+	ErrTierNotFound           = errors.New("tier not found")
+	ErrTokenNotFound          = errors.New("token not found")
+	ErrPhoneNumberNotFound    = errors.New("phone number not found")
+	ErrTooManyReservations    = errors.New("new tier has lower reservation limit")
+	ErrPhoneNumberExists      = errors.New("phone number already exists")
+	ErrProvisionedUserChange  = errors.New("cannot change or delete provisioned user")
+	ErrProvisionedTokenChange = errors.New("cannot change or delete provisioned token")
 )
 )

+ 15 - 16
web/package-lock.json

@@ -3066,13 +3066,13 @@
       }
       }
     },
     },
     "node_modules/@types/babel__traverse": {
     "node_modules/@types/babel__traverse": {
-      "version": "7.20.7",
-      "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz",
-      "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==",
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+      "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
-        "@babel/types": "^7.20.7"
+        "@babel/types": "^7.28.2"
       }
       }
     },
     },
     "node_modules/@types/estree": {
     "node_modules/@types/estree": {
@@ -3819,9 +3819,9 @@
       "license": "MIT"
       "license": "MIT"
     },
     },
     "node_modules/core-js-compat": {
     "node_modules/core-js-compat": {
-      "version": "3.44.0",
-      "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.44.0.tgz",
-      "integrity": "sha512-JepmAj2zfl6ogy34qfWtcE7nHKAJnKsQFRn++scjVS2bZFllwptzw61BZcZFYBPpUznLfAvh0LGhxKppk04ClA==",
+      "version": "3.45.0",
+      "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.45.0.tgz",
+      "integrity": "sha512-gRoVMBawZg0OnxaVv3zpqLLxaHmsubEGyTnqdpI/CEBvX4JadI1dMSHxagThprYRtSVbuQxvi6iUatdPxohHpA==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
@@ -4112,9 +4112,9 @@
       }
       }
     },
     },
     "node_modules/electron-to-chromium": {
     "node_modules/electron-to-chromium": {
-      "version": "1.5.193",
-      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.193.tgz",
-      "integrity": "sha512-eePuBZXM9OVCwfYUhd2OzESeNGnWmLyeu0XAEjf7xjijNjHFdeJSzuRUGN4ueT2tEYo5YqjHramKEFxz67p3XA==",
+      "version": "1.5.195",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.195.tgz",
+      "integrity": "sha512-URclP0iIaDUzqcAyV1v2PgduJ9N0IdXmWsnPzPfelvBmjmZzEy6xJcjb1cXj+TbYqXgtLrjHEoaSIdTYhw4ezg==",
       "dev": true,
       "dev": true,
       "license": "ISC"
       "license": "ISC"
     },
     },
@@ -6045,16 +6045,15 @@
       }
       }
     },
     },
     "node_modules/jake": {
     "node_modules/jake": {
-      "version": "10.9.2",
-      "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz",
-      "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==",
+      "version": "10.9.4",
+      "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
+      "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
       "dev": true,
       "dev": true,
       "license": "Apache-2.0",
       "license": "Apache-2.0",
       "dependencies": {
       "dependencies": {
-        "async": "^3.2.3",
-        "chalk": "^4.0.2",
+        "async": "^3.2.6",
         "filelist": "^1.0.4",
         "filelist": "^1.0.4",
-        "minimatch": "^3.1.2"
+        "picocolors": "^1.1.1"
       },
       },
       "bin": {
       "bin": {
         "jake": "bin/cli.js"
         "jake": "bin/cli.js"

+ 2 - 0
web/public/static/langs/en.json

@@ -212,6 +212,7 @@
   "account_basics_phone_numbers_dialog_check_verification_button": "Confirm code",
   "account_basics_phone_numbers_dialog_check_verification_button": "Confirm code",
   "account_basics_phone_numbers_dialog_channel_sms": "SMS",
   "account_basics_phone_numbers_dialog_channel_sms": "SMS",
   "account_basics_phone_numbers_dialog_channel_call": "Call",
   "account_basics_phone_numbers_dialog_channel_call": "Call",
+  "account_basics_cannot_edit_or_delete_provisioned_user": "A provisioned user cannot be edited or deleted",
   "account_usage_title": "Usage",
   "account_usage_title": "Usage",
   "account_usage_of_limit": "of {{limit}}",
   "account_usage_of_limit": "of {{limit}}",
   "account_usage_unlimited": "Unlimited",
   "account_usage_unlimited": "Unlimited",
@@ -291,6 +292,7 @@
   "account_tokens_table_current_session": "Current browser session",
   "account_tokens_table_current_session": "Current browser session",
   "account_tokens_table_copied_to_clipboard": "Access token copied",
   "account_tokens_table_copied_to_clipboard": "Access token copied",
   "account_tokens_table_cannot_delete_or_edit": "Cannot edit or delete current session token",
   "account_tokens_table_cannot_delete_or_edit": "Cannot edit or delete current session token",
+  "account_tokens_table_cannot_delete_or_edit_provisioned_token": "Cannot edit or delete provisioned token",
   "account_tokens_table_create_token_button": "Create access token",
   "account_tokens_table_create_token_button": "Create access token",
   "account_tokens_table_last_origin_tooltip": "From IP address {{ip}}, click to lookup",
   "account_tokens_table_last_origin_tooltip": "From IP address {{ip}}, click to lookup",
   "account_tokens_dialog_title_create": "Create access token",
   "account_tokens_dialog_title_create": "Create access token",

+ 42 - 10
web/src/components/Account.jsx

@@ -100,15 +100,13 @@ const Username = () => {
     <Pref labelId={labelId} title={t("account_basics_username_title")} description={t("account_basics_username_description")}>
     <Pref labelId={labelId} title={t("account_basics_username_title")} description={t("account_basics_username_description")}>
       <div aria-labelledby={labelId}>
       <div aria-labelledby={labelId}>
         {session.username()}
         {session.username()}
-        {account?.role === Role.ADMIN ? (
+        {account?.role === Role.ADMIN && (
           <>
           <>
             {" "}
             {" "}
             <Tooltip title={t("account_basics_username_admin_tooltip")}>
             <Tooltip title={t("account_basics_username_admin_tooltip")}>
               <span style={{ cursor: "default" }}>👑</span>
               <span style={{ cursor: "default" }}>👑</span>
             </Tooltip>
             </Tooltip>
           </>
           </>
-        ) : (
-          ""
         )}
         )}
       </div>
       </div>
     </Pref>
     </Pref>
@@ -119,6 +117,7 @@ const ChangePassword = () => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [dialogKey, setDialogKey] = useState(0);
   const [dialogKey, setDialogKey] = useState(0);
   const [dialogOpen, setDialogOpen] = useState(false);
   const [dialogOpen, setDialogOpen] = useState(false);
+  const { account } = useContext(AccountContext);
   const labelId = "prefChangePassword";
   const labelId = "prefChangePassword";
 
 
   const handleDialogOpen = () => {
   const handleDialogOpen = () => {
@@ -136,9 +135,19 @@ const ChangePassword = () => {
         <Typography color="gray" sx={{ float: "left", fontSize: "0.7rem", lineHeight: "3.5" }}>
         <Typography color="gray" sx={{ float: "left", fontSize: "0.7rem", lineHeight: "3.5" }}>
           ⬤⬤⬤⬤⬤⬤⬤⬤⬤⬤
           ⬤⬤⬤⬤⬤⬤⬤⬤⬤⬤
         </Typography>
         </Typography>
-        <IconButton onClick={handleDialogOpen} aria-label={t("account_basics_password_description")}>
-          <EditIcon />
-        </IconButton>
+        {!account?.provisioned ? (
+          <IconButton onClick={handleDialogOpen} aria-label={t("account_basics_password_description")}>
+            <EditIcon />
+          </IconButton>
+        ) : (
+          <Tooltip title={t("account_basics_cannot_edit_or_delete_provisioned_user")}>
+            <span>
+              <IconButton disabled>
+                <EditIcon />
+              </IconButton>
+            </span>
+          </Tooltip>
+        )}
       </div>
       </div>
       <ChangePasswordDialog key={`changePasswordDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
       <ChangePasswordDialog key={`changePasswordDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
     </Pref>
     </Pref>
@@ -888,7 +897,7 @@ const TokensTable = (props) => {
               </div>
               </div>
             </TableCell>
             </TableCell>
             <TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
             <TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
-              {token.token !== session.token() && (
+              {token.token !== session.token() && !token.provisioned && (
                 <>
                 <>
                   <IconButton onClick={() => handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}>
                   <IconButton onClick={() => handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}>
                     <EditIcon />
                     <EditIcon />
@@ -910,6 +919,18 @@ const TokensTable = (props) => {
                   </span>
                   </span>
                 </Tooltip>
                 </Tooltip>
               )}
               )}
+              {token.provisioned && (
+                <Tooltip title={t("account_tokens_table_cannot_delete_or_edit_provisioned_token")}>
+                  <span>
+                    <IconButton disabled>
+                      <EditIcon />
+                    </IconButton>
+                    <IconButton disabled>
+                      <CloseIcon />
+                    </IconButton>
+                  </span>
+                </Tooltip>
+              )}
             </TableCell>
             </TableCell>
           </TableRow>
           </TableRow>
         ))}
         ))}
@@ -1048,6 +1069,7 @@ const DeleteAccount = () => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [dialogKey, setDialogKey] = useState(0);
   const [dialogKey, setDialogKey] = useState(0);
   const [dialogOpen, setDialogOpen] = useState(false);
   const [dialogOpen, setDialogOpen] = useState(false);
+  const { account } = useContext(AccountContext);
 
 
   const handleDialogOpen = () => {
   const handleDialogOpen = () => {
     setDialogKey((prev) => prev + 1);
     setDialogKey((prev) => prev + 1);
@@ -1061,9 +1083,19 @@ const DeleteAccount = () => {
   return (
   return (
     <Pref title={t("account_delete_title")} description={t("account_delete_description")}>
     <Pref title={t("account_delete_title")} description={t("account_delete_description")}>
       <div>
       <div>
-        <Button fullWidth={false} variant="outlined" color="error" startIcon={<DeleteOutlineIcon />} onClick={handleDialogOpen}>
-          {t("account_delete_title")}
-        </Button>
+        {!account?.provisioned ? (
+          <Button fullWidth={false} variant="outlined" color="error" startIcon={<DeleteOutlineIcon />} onClick={handleDialogOpen}>
+            {t("account_delete_title")}
+          </Button>
+        ) : (
+          <Tooltip title={t("account_basics_cannot_edit_or_delete_provisioned_user")}>
+            <span>
+              <Button fullWidth={false} variant="outlined" color="error" startIcon={<DeleteOutlineIcon />} disabled>
+                {t("account_delete_title")}
+              </Button>
+            </span>
+          </Tooltip>
+        )}
       </div>
       </div>
       <DeleteAccountDialog key={`deleteAccountDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
       <DeleteAccountDialog key={`deleteAccountDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
     </Pref>
     </Pref>