Browse Source

rework user and admin profiles

users and admins can now also update their email and description
Nicola Murino 3 years ago
parent
commit
ba1febba73

+ 7 - 7
.github/workflows/development.yml

@@ -45,7 +45,7 @@ jobs:
           go build -trimpath -ldflags "-s -w -X github.com/drakkan/sftpgo/v2/version.commit=$GIT_COMMIT -X github.com/drakkan/sftpgo/v2/version.date=$DATE_TIME" -o sftpgo.exe
 
       - name: Run test cases using SQLite provider
-        run: go test -v -p 1 -timeout 10m ./... -coverprofile=coverage.txt -covermode=atomic
+        run: go test -v -p 1 -timeout 15m ./... -coverprofile=coverage.txt -covermode=atomic
 
       - name: Upload coverage to Codecov
         if: ${{ matrix.upload-coverage }}
@@ -69,7 +69,7 @@ jobs:
           SFTPGO_DATA_PROVIDER__NAME: 'sftpgo_bolt.db'
 
       - name: Run test cases using memory provider
-        run: go test -v -p 1 -timeout 10m ./... -covermode=atomic
+        run: go test -v -p 1 -timeout 15m ./... -covermode=atomic
         env:
           SFTPGO_DATA_PROVIDER__DRIVER: memory
           SFTPGO_DATA_PROVIDER__NAME: ''
@@ -108,7 +108,7 @@ jobs:
           path: output
 
   test-goarch-386:
-    name: Run test cases on 32 bit arch
+    name: Run test cases on 32-bit arch
     runs-on: ubuntu-latest
 
     steps:
@@ -125,7 +125,7 @@ jobs:
           GOARCH: 386
 
       - name: Run test cases
-        run: go test -v -p 1 -timeout 10m ./... -covermode=atomic
+        run: go test -v -p 1 -timeout 15m ./... -covermode=atomic
         env:
           SFTPGO_DATA_PROVIDER__DRIVER: memory
           SFTPGO_DATA_PROVIDER__NAME: ''
@@ -177,7 +177,7 @@ jobs:
 
       - name: Run tests using PostgreSQL provider
         run: |
-          go test -v -p 1 -timeout 10m ./... -covermode=atomic
+          go test -v -p 1 -timeout 15m ./... -covermode=atomic
         env:
           SFTPGO_DATA_PROVIDER__DRIVER: postgresql
           SFTPGO_DATA_PROVIDER__NAME: sftpgo
@@ -188,7 +188,7 @@ jobs:
 
       - name: Run tests using MySQL provider
         run: |
-          go test -v -p 1 -timeout 10m ./... -covermode=atomic
+          go test -v -p 1 -timeout 15m ./... -covermode=atomic
         env:
           SFTPGO_DATA_PROVIDER__DRIVER: mysql
           SFTPGO_DATA_PROVIDER__NAME: sftpgo
@@ -201,7 +201,7 @@ jobs:
         run: |
           docker run --rm --name crdb --health-cmd "curl -I http://127.0.0.1:8080" --health-interval 10s --health-timeout 5s --health-retries 6 -p 26257:26257 -d cockroachdb/cockroach:latest start-single-node --insecure --listen-addr 0.0.0.0:26257
           docker exec crdb cockroach sql --insecure -e 'create database "sftpgo"'
-          go test -v -p 1 -timeout 10m ./... -covermode=atomic
+          go test -v -p 1 -timeout 15m ./... -covermode=atomic
           docker stop crdb
         env:
           SFTPGO_DATA_PROVIDER__DRIVER: cockroachdb

+ 5 - 0
dataprovider/user.go

@@ -736,6 +736,11 @@ func (u *User) CanChangeAPIKeyAuth() bool {
 	return !util.IsStringInSlice(sdk.WebClientAPIKeyAuthChangeDisabled, u.Filters.WebClient)
 }
 
+// CanChangeInfo returns true if this user is allowed to change its info such as email and description
+func (u *User) CanChangeInfo() bool {
+	return !util.IsStringInSlice(sdk.WebClientInfoChangeDisabled, u.Filters.WebClient)
+}
+
 // CanManagePublicKeys returns true if this user is allowed to manage public keys
 // from the web client. Used in web client UI
 func (u *User) CanManagePublicKeys() bool {

+ 3 - 3
docs/full-configuration.md

@@ -252,9 +252,9 @@ The configuration file contains the following sections:
     - `url`, string, optional. If not empty, the header will be added only if the request URL starts with the one specified here
 - **kms**, configuration for the Key Management Service, more details can be found [here](./kms.md)
   - `secrets`
-    - `url`, string. Defines the URI to the KMS service. Default empty.
-    - `master_key`, string. Defines the master encryption key as string. If not empty, it takes precedence over `master_key_path`. Default empty.
-    - `master_key_path, string. Defines the absolute path to a file containing the master encryption key. Default empty.
+    - `url`, string. Defines the URI to the KMS service. Default: empty.
+    - `master_key`, string. Defines the master encryption key as string. If not empty, it takes precedence over `master_key_path`. Default: empty.
+    - `master_key_path, string. Defines the absolute path to a file containing the master encryption key. Default: empty.
 - **mfa**, multi-factor authentication settings
   - `totp`, list of struct that define settings for time-based one time passwords (RFC 6238). Each struct has the following fields:
     - `name`, string. Unique configuration name. This name should not be changed if there are users or admins using the configuration. The name is not exposed to the authentication apps. Default: `Default`.

+ 8 - 8
go.mod

@@ -7,8 +7,8 @@ require (
 	github.com/Azure/azure-storage-blob-go v0.14.0
 	github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
 	github.com/alexedwards/argon2id v0.0.0-20210511081203-7d35d68092b8
-	github.com/aws/aws-sdk-go v1.40.49
-	github.com/cockroachdb/cockroach-go/v2 v2.1.1
+	github.com/aws/aws-sdk-go v1.40.51
+	github.com/cockroachdb/cockroach-go/v2 v2.2.0
 	github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b
 	github.com/fatih/color v1.13.0 // indirect
 	github.com/fclairamb/ftpserverlib v0.16.0
@@ -43,7 +43,7 @@ require (
 	github.com/pkg/sftp v1.13.4
 	github.com/pquerna/otp v1.3.0
 	github.com/prometheus/client_golang v1.11.0
-	github.com/prometheus/common v0.31.0 // indirect
+	github.com/prometheus/common v0.31.1 // indirect
 	github.com/rs/cors v1.8.0
 	github.com/rs/xid v1.3.0
 	github.com/rs/zerolog v1.25.0
@@ -62,17 +62,17 @@ require (
 	gocloud.dev v0.24.0
 	golang.org/x/crypto v0.0.0-20210915214749-c084706c2272
 	golang.org/x/net v0.0.0-20210924151903-3ad01bbaa167
-	golang.org/x/sys v0.0.0-20210927052749-1cf2251ac284
+	golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6
 	golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac
-	google.golang.org/api v0.57.0
-	google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0 // indirect
+	google.golang.org/api v0.58.0
+	google.golang.org/genproto v0.0.0-20210928142010-c7af6a1a74c9 // indirect
 	google.golang.org/grpc v1.41.0
 	google.golang.org/protobuf v1.27.1
 	gopkg.in/natefinch/lumberjack.v2 v2.0.0
 )
 
 require (
-	cloud.google.com/go v0.95.0 // indirect
+	cloud.google.com/go v0.96.0 // indirect
 	github.com/Azure/azure-pipeline-go v0.2.3 // indirect
 	github.com/StackExchange/wmi v1.2.1 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
@@ -85,7 +85,7 @@ require (
 	github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0 // indirect
 	github.com/fsnotify/fsnotify v1.5.1 // indirect
 	github.com/go-ole/go-ole v1.2.5 // indirect
-	github.com/goccy/go-json v0.7.8 // indirect
+	github.com/goccy/go-json v0.7.9 // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
 	github.com/golang/protobuf v1.5.2 // indirect
 	github.com/google/go-cmp v0.5.6 // indirect

+ 20 - 14
go.sum

@@ -32,8 +32,8 @@ cloud.google.com/go v0.92.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+Y
 cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
 cloud.google.com/go v0.94.0/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
 cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
-cloud.google.com/go v0.95.0 h1:JVWssQIj9cLwHmLjqWLptFa83o7HgqUictM6eyvGWJE=
-cloud.google.com/go v0.95.0/go.mod h1:MzZUAH870Y7E+c14j23Ir66FC1+PK8WLG7OG4SjP+0k=
+cloud.google.com/go v0.96.0 h1:r9XIwQ9FrJspMjHulRm1kl1uanw5gSolzSK+dukeH0E=
+cloud.google.com/go v0.96.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
 cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
 cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
 cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
@@ -136,8 +136,8 @@ github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZo
 github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go v1.38.68/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go v1.40.34/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
-github.com/aws/aws-sdk-go v1.40.49 h1:kIbJYc4FZA2r4yxNU5giIR4HHLRkG9roFReWAsk0ZVQ=
-github.com/aws/aws-sdk-go v1.40.49/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
+github.com/aws/aws-sdk-go v1.40.51 h1:FfxDcjWqhMGwy+raf5Zf6AH8qsHIl9YG2dvJIBx1Aw4=
+github.com/aws/aws-sdk-go v1.40.51/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
 github.com/aws/aws-sdk-go-v2 v1.7.0/go.mod h1:tb9wi5s61kTDA5qCkcDbt3KRVV74GGslQkl/DRdX/P4=
 github.com/aws/aws-sdk-go-v2 v1.9.0/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4=
 github.com/aws/aws-sdk-go-v2/config v1.7.0/go.mod h1:w9+nMZ7soXCe5nT46Ri354SNhXDQ6v+V5wqDjnZE+GY=
@@ -184,8 +184,8 @@ github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnht
 github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
-github.com/cockroachdb/cockroach-go/v2 v2.1.1 h1:3XzfSMuUT0wBe1a3o5C0eOTcArhmmFAg2Jzh/7hhKqo=
-github.com/cockroachdb/cockroach-go/v2 v2.1.1/go.mod h1:7NtUnP6eK+l6k483WSYNrq3Kb23bWV10IRV1TyeSpwM=
+github.com/cockroachdb/cockroach-go/v2 v2.2.0 h1:/5znzg5n373N/3ESjHF5SMLxiW4RKB05Ql//KWfeTFs=
+github.com/cockroachdb/cockroach-go/v2 v2.2.0/go.mod h1:u3MiKYGupPPjkn3ozknpMUpxPaNLTFWAya419/zv6eI=
 github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI=
 github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
 github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
@@ -288,9 +288,12 @@ github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22
 github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
 github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
 github.com/goccy/go-json v0.7.6/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
-github.com/goccy/go-json v0.7.8 h1:CvMH7LotYymYuLGEohBM1lTZWX4g6jzWUUl2aLFuBoE=
 github.com/goccy/go-json v0.7.8/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/goccy/go-json v0.7.9 h1:mSp3uo1tr6MXQTYopSNhHTUnJhd2zQ4Yk+HdJZP+ZRY=
+github.com/goccy/go-json v0.7.9/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
+github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
 github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
 github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
@@ -681,8 +684,8 @@ github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6T
 github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
 github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
 github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
-github.com/prometheus/common v0.31.0 h1:FTJdLTjtrh4dXlCjpzdZJXMnejSTL5F/nVQm5sNwD34=
-github.com/prometheus/common v0.31.0/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
+github.com/prometheus/common v0.31.1 h1:d18hG4PkHnNAKNMOmFuXFaiY8Us0nird/2m60uS1AMs=
+github.com/prometheus/common v0.31.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
 github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
 github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
 github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
@@ -954,8 +957,9 @@ golang.org/x/sys v0.0.0-20210816074244-15123e1e1f71/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210927052749-1cf2251ac284 h1:lBPNCmq8u4zFP3huKCmUQ2Fx8kcY4X+O12UgGnyKsrg=
-golang.org/x/sys v0.0.0-20210927052749-1cf2251ac284/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6 h1:foEbQz/B0Oz6YIqu/69kfXPYeFQAuuMYFkjaqXzl5Wo=
+golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1079,8 +1083,9 @@ google.golang.org/api v0.52.0/go.mod h1:Him/adpjt0sxtkWViy0b6xyKW/SD71CwdJ7HqJo7
 google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k=
 google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
 google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
-google.golang.org/api v0.57.0 h1:4t9zuDlHLcIx0ZEhmXEeFVCRsiOgpgn2QOH9N0MNjPI=
 google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI=
+google.golang.org/api v0.58.0 h1:MDkAbYIB1JpSgCTOCYYoIec/coMlKK4oVbpnBLLcyT0=
+google.golang.org/api v0.58.0/go.mod h1:cAbP2FsxoGVNwtgNAmmn3y5G1TWAiVYRmg4yku3lv+E=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -1152,9 +1157,10 @@ google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEc
 google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
 google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
 google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
-google.golang.org/genproto v0.0.0-20210921142501-181ce0d877f6/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0 h1:5Tbluzus3QxoAJx4IefGt1W0HQZW4nuMrVk684jI74Q=
+google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
 google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20210928142010-c7af6a1a74c9 h1:XTH066D35LyHehRwlYhoK3qA+Hcgvg5xREG4kFQEW1Y=
+google.golang.org/genproto v0.0.0-20210928142010-c7af6a1a74c9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=

+ 12 - 6
httpd/api_admin.go

@@ -155,7 +155,7 @@ func deleteAdmin(w http.ResponseWriter, r *http.Request) {
 	sendAPIResponse(w, r, err, "Admin deleted", http.StatusOK)
 }
 
-func getAdminAPIKeyAuthStatus(w http.ResponseWriter, r *http.Request) {
+func getAdminProfile(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	claims, err := getTokenClaims(r)
 	if err != nil || claims.Username == "" {
@@ -167,13 +167,17 @@ func getAdminAPIKeyAuthStatus(w http.ResponseWriter, r *http.Request) {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		return
 	}
-	resp := apiKeyAuth{
-		AllowAPIKeyAuth: admin.Filters.AllowAPIKeyAuth,
+	resp := adminProfile{
+		baseProfile: baseProfile{
+			Email:           admin.Email,
+			Description:     admin.Description,
+			AllowAPIKeyAuth: admin.Filters.AllowAPIKeyAuth,
+		},
 	}
 	render.JSON(w, r, resp)
 }
 
-func changeAdminAPIKeyAuthStatus(w http.ResponseWriter, r *http.Request) {
+func updateAdminProfile(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	claims, err := getTokenClaims(r)
 	if err != nil || claims.Username == "" {
@@ -185,18 +189,20 @@ func changeAdminAPIKeyAuthStatus(w http.ResponseWriter, r *http.Request) {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		return
 	}
-	var req apiKeyAuth
+	var req adminProfile
 	err = render.DecodeJSON(r.Body, &req)
 	if err != nil {
 		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
 		return
 	}
+	admin.Email = req.Email
+	admin.Description = req.Description
 	admin.Filters.AllowAPIKeyAuth = req.AllowAPIKeyAuth
 	if err := dataprovider.UpdateAdmin(&admin); err != nil {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		return
 	}
-	sendAPIResponse(w, r, err, "API key authentication status updated", http.StatusOK)
+	sendAPIResponse(w, r, err, "Profile updated", http.StatusOK)
 }
 
 func changeAdminPassword(w http.ResponseWriter, r *http.Request) {

+ 25 - 7
httpd/api_http_user.go

@@ -349,7 +349,7 @@ func setUserPublicKeys(w http.ResponseWriter, r *http.Request) {
 	sendAPIResponse(w, r, err, "Public keys updated", http.StatusOK)
 }
 
-func getUserAPIKeyAuthStatus(w http.ResponseWriter, r *http.Request) {
+func getUserProfile(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	claims, err := getTokenClaims(r)
 	if err != nil || claims.Username == "" {
@@ -361,20 +361,25 @@ func getUserAPIKeyAuthStatus(w http.ResponseWriter, r *http.Request) {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		return
 	}
-	resp := apiKeyAuth{
-		AllowAPIKeyAuth: user.Filters.AllowAPIKeyAuth,
+	resp := userProfile{
+		baseProfile: baseProfile{
+			Email:           user.Email,
+			Description:     user.Description,
+			AllowAPIKeyAuth: user.Filters.AllowAPIKeyAuth,
+		},
+		PublicKeys: user.PublicKeys,
 	}
 	render.JSON(w, r, resp)
 }
 
-func changeUserAPIKeyAuthStatus(w http.ResponseWriter, r *http.Request) {
+func updateUserProfile(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	claims, err := getTokenClaims(r)
 	if err != nil || claims.Username == "" {
 		sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
 		return
 	}
-	var req apiKeyAuth
+	var req userProfile
 	err = render.DecodeJSON(r.Body, &req)
 	if err != nil {
 		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
@@ -385,12 +390,25 @@ func changeUserAPIKeyAuthStatus(w http.ResponseWriter, r *http.Request) {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		return
 	}
-	user.Filters.AllowAPIKeyAuth = req.AllowAPIKeyAuth
+	if !user.CanManagePublicKeys() && !user.CanChangeAPIKeyAuth() && !user.CanChangeInfo() {
+		sendAPIResponse(w, r, nil, "You are not allowed to change anything", http.StatusForbidden)
+		return
+	}
+	if user.CanManagePublicKeys() {
+		user.PublicKeys = req.PublicKeys
+	}
+	if user.CanChangeAPIKeyAuth() {
+		user.Filters.AllowAPIKeyAuth = req.AllowAPIKeyAuth
+	}
+	if user.CanChangeInfo() {
+		user.Email = req.Email
+		user.Description = req.Description
+	}
 	if err := dataprovider.UpdateUser(&user); err != nil {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		return
 	}
-	sendAPIResponse(w, r, err, "API key authentication status updated", http.StatusOK)
+	sendAPIResponse(w, r, err, "Profile updated", http.StatusOK)
 }
 
 func changeUserPassword(w http.ResponseWriter, r *http.Request) {

+ 13 - 2
httpd/api_utils.go

@@ -28,8 +28,19 @@ type pwdChange struct {
 	NewPassword     string `json:"new_password"`
 }
 
-type apiKeyAuth struct {
-	AllowAPIKeyAuth bool `json:"allow_api_key_auth"`
+type baseProfile struct {
+	Email           string `json:"email,omitempty"`
+	Description     string `json:"description,omitempty"`
+	AllowAPIKeyAuth bool   `json:"allow_api_key_auth"`
+}
+
+type adminProfile struct {
+	baseProfile
+}
+
+type userProfile struct {
+	baseProfile
+	PublicKeys []string `json:"public_keys,omitempty"`
 }
 
 func sendAPIResponse(w http.ResponseWriter, r *http.Request, err error, message string, code int) {

+ 151 - 160
httpd/httpd.go

@@ -31,104 +31,101 @@ import (
 )
 
 const (
-	logSender                              = "httpd"
-	tokenPath                              = "/api/v2/token"
-	logoutPath                             = "/api/v2/logout"
-	userTokenPath                          = "/api/v2/user/token"
-	userLogoutPath                         = "/api/v2/user/logout"
-	activeConnectionsPath                  = "/api/v2/connections"
-	quotasBasePath                         = "/api/v2/quotas"
-	quotaScanPath                          = "/api/v2/quota-scans"
-	quotaScanVFolderPath                   = "/api/v2/folder-quota-scans"
-	userPath                               = "/api/v2/users"
-	versionPath                            = "/api/v2/version"
-	folderPath                             = "/api/v2/folders"
-	serverStatusPath                       = "/api/v2/status"
-	dumpDataPath                           = "/api/v2/dumpdata"
-	loadDataPath                           = "/api/v2/loaddata"
-	updateUsedQuotaPath                    = "/api/v2/quota-update"
-	updateFolderUsedQuotaPath              = "/api/v2/folder-quota-update"
-	defenderHosts                          = "/api/v2/defender/hosts"
-	defenderBanTime                        = "/api/v2/defender/bantime"
-	defenderUnban                          = "/api/v2/defender/unban"
-	defenderScore                          = "/api/v2/defender/score"
-	adminPath                              = "/api/v2/admins"
-	adminPwdPath                           = "/api/v2/admin/changepwd"
-	adminPwdCompatPath                     = "/api/v2/changepwd/admin"
-	adminManageAPIKeyPath                  = "/api/v2/admin/apikeyauth"
-	userPwdPath                            = "/api/v2/user/changepwd"
-	userPublicKeysPath                     = "/api/v2/user/publickeys"
-	userFolderPath                         = "/api/v2/user/folder"
-	userDirsPath                           = "/api/v2/user/dirs"
-	userFilePath                           = "/api/v2/user/file"
-	userFilesPath                          = "/api/v2/user/files"
-	userStreamZipPath                      = "/api/v2/user/streamzip"
-	apiKeysPath                            = "/api/v2/apikeys"
-	adminTOTPConfigsPath                   = "/api/v2/admin/totp/configs"
-	adminTOTPGeneratePath                  = "/api/v2/admin/totp/generate"
-	adminTOTPValidatePath                  = "/api/v2/admin/totp/validate"
-	adminTOTPSavePath                      = "/api/v2/admin/totp/save"
-	admin2FARecoveryCodesPath              = "/api/v2/admin/2fa/recoverycodes"
-	userTOTPConfigsPath                    = "/api/v2/user/totp/configs"
-	userTOTPGeneratePath                   = "/api/v2/user/totp/generate"
-	userTOTPValidatePath                   = "/api/v2/user/totp/validate"
-	userTOTPSavePath                       = "/api/v2/user/totp/save"
-	user2FARecoveryCodesPath               = "/api/v2/user/2fa/recoverycodes"
-	userManageAPIKeyPath                   = "/api/v2/user/apikeyauth"
-	retentionBasePath                      = "/api/v2/retention/users"
-	retentionChecksPath                    = "/api/v2/retention/users/checks"
-	healthzPath                            = "/healthz"
-	webRootPathDefault                     = "/"
-	webBasePathDefault                     = "/web"
-	webBasePathAdminDefault                = "/web/admin"
-	webBasePathClientDefault               = "/web/client"
-	webAdminSetupPathDefault               = "/web/admin/setup"
-	webLoginPathDefault                    = "/web/admin/login"
-	webAdminTwoFactorPathDefault           = "/web/admin/twofactor"
-	webAdminTwoFactorRecoveryPathDefault   = "/web/admin/twofactor-recovery"
-	webLogoutPathDefault                   = "/web/admin/logout"
-	webUsersPathDefault                    = "/web/admin/users"
-	webUserPathDefault                     = "/web/admin/user"
-	webConnectionsPathDefault              = "/web/admin/connections"
-	webFoldersPathDefault                  = "/web/admin/folders"
-	webFolderPathDefault                   = "/web/admin/folder"
-	webStatusPathDefault                   = "/web/admin/status"
-	webAdminsPathDefault                   = "/web/admin/managers"
-	webAdminPathDefault                    = "/web/admin/manager"
-	webMaintenancePathDefault              = "/web/admin/maintenance"
-	webBackupPathDefault                   = "/web/admin/backup"
-	webRestorePathDefault                  = "/web/admin/restore"
-	webScanVFolderPathDefault              = "/web/admin/quotas/scanfolder"
-	webQuotaScanPathDefault                = "/web/admin/quotas/scanuser"
-	webChangeAdminPwdPathDefault           = "/web/admin/changepwd"
-	webAdminCredentialsPathDefault         = "/web/admin/credentials"
-	webAdminMFAPathDefault                 = "/web/admin/mfa"
-	webAdminTOTPGeneratePathDefault        = "/web/admin/totp/generate"
-	webAdminTOTPValidatePathDefault        = "/web/admin/totp/validate"
-	webAdminTOTPSavePathDefault            = "/web/admin/totp/save"
-	webAdminRecoveryCodesPathDefault       = "/web/admin/recoverycodes"
-	webChangeAdminAPIKeyAccessPathDefault  = "/web/admin/apikeyaccess"
-	webTemplateUserDefault                 = "/web/admin/template/user"
-	webTemplateFolderDefault               = "/web/admin/template/folder"
-	webDefenderPathDefault                 = "/web/admin/defender"
-	webDefenderHostsPathDefault            = "/web/admin/defender/hosts"
-	webClientLoginPathDefault              = "/web/client/login"
-	webClientTwoFactorPathDefault          = "/web/client/twofactor"
-	webClientTwoFactorRecoveryPathDefault  = "/web/client/twofactor-recovery"
-	webClientFilesPathDefault              = "/web/client/files"
-	webClientDirsPathDefault               = "/web/client/dirs"
-	webClientDownloadZipPathDefault        = "/web/client/downloadzip"
-	webClientCredentialsPathDefault        = "/web/client/credentials"
-	webClientMFAPathDefault                = "/web/client/mfa"
-	webClientTOTPGeneratePathDefault       = "/web/client/totp/generate"
-	webClientTOTPValidatePathDefault       = "/web/client/totp/validate"
-	webClientTOTPSavePathDefault           = "/web/client/totp/save"
-	webClientRecoveryCodesPathDefault      = "/web/client/recoverycodes"
-	webChangeClientPwdPathDefault          = "/web/client/changepwd"
-	webChangeClientKeysPathDefault         = "/web/client/managekeys"
-	webChangeClientAPIKeyAccessPathDefault = "/web/client/apikeyaccess"
-	webClientLogoutPathDefault             = "/web/client/logout"
-	webStaticFilesPathDefault              = "/static"
+	logSender                             = "httpd"
+	tokenPath                             = "/api/v2/token"
+	logoutPath                            = "/api/v2/logout"
+	userTokenPath                         = "/api/v2/user/token"
+	userLogoutPath                        = "/api/v2/user/logout"
+	activeConnectionsPath                 = "/api/v2/connections"
+	quotasBasePath                        = "/api/v2/quotas"
+	quotaScanPath                         = "/api/v2/quota-scans"
+	quotaScanVFolderPath                  = "/api/v2/folder-quota-scans"
+	userPath                              = "/api/v2/users"
+	versionPath                           = "/api/v2/version"
+	folderPath                            = "/api/v2/folders"
+	serverStatusPath                      = "/api/v2/status"
+	dumpDataPath                          = "/api/v2/dumpdata"
+	loadDataPath                          = "/api/v2/loaddata"
+	updateUsedQuotaPath                   = "/api/v2/quota-update"
+	updateFolderUsedQuotaPath             = "/api/v2/folder-quota-update"
+	defenderHosts                         = "/api/v2/defender/hosts"
+	defenderBanTime                       = "/api/v2/defender/bantime"
+	defenderUnban                         = "/api/v2/defender/unban"
+	defenderScore                         = "/api/v2/defender/score"
+	adminPath                             = "/api/v2/admins"
+	adminPwdPath                          = "/api/v2/admin/changepwd"
+	adminPwdCompatPath                    = "/api/v2/changepwd/admin"
+	adminProfilePath                      = "/api/v2/admin/profile"
+	userPwdPath                           = "/api/v2/user/changepwd"
+	userPublicKeysPath                    = "/api/v2/user/publickeys"
+	userFolderPath                        = "/api/v2/user/folder"
+	userDirsPath                          = "/api/v2/user/dirs"
+	userFilePath                          = "/api/v2/user/file"
+	userFilesPath                         = "/api/v2/user/files"
+	userStreamZipPath                     = "/api/v2/user/streamzip"
+	apiKeysPath                           = "/api/v2/apikeys"
+	adminTOTPConfigsPath                  = "/api/v2/admin/totp/configs"
+	adminTOTPGeneratePath                 = "/api/v2/admin/totp/generate"
+	adminTOTPValidatePath                 = "/api/v2/admin/totp/validate"
+	adminTOTPSavePath                     = "/api/v2/admin/totp/save"
+	admin2FARecoveryCodesPath             = "/api/v2/admin/2fa/recoverycodes"
+	userTOTPConfigsPath                   = "/api/v2/user/totp/configs"
+	userTOTPGeneratePath                  = "/api/v2/user/totp/generate"
+	userTOTPValidatePath                  = "/api/v2/user/totp/validate"
+	userTOTPSavePath                      = "/api/v2/user/totp/save"
+	user2FARecoveryCodesPath              = "/api/v2/user/2fa/recoverycodes"
+	userProfilePath                       = "/api/v2/user/profile"
+	retentionBasePath                     = "/api/v2/retention/users"
+	retentionChecksPath                   = "/api/v2/retention/users/checks"
+	healthzPath                           = "/healthz"
+	webRootPathDefault                    = "/"
+	webBasePathDefault                    = "/web"
+	webBasePathAdminDefault               = "/web/admin"
+	webBasePathClientDefault              = "/web/client"
+	webAdminSetupPathDefault              = "/web/admin/setup"
+	webLoginPathDefault                   = "/web/admin/login"
+	webAdminTwoFactorPathDefault          = "/web/admin/twofactor"
+	webAdminTwoFactorRecoveryPathDefault  = "/web/admin/twofactor-recovery"
+	webLogoutPathDefault                  = "/web/admin/logout"
+	webUsersPathDefault                   = "/web/admin/users"
+	webUserPathDefault                    = "/web/admin/user"
+	webConnectionsPathDefault             = "/web/admin/connections"
+	webFoldersPathDefault                 = "/web/admin/folders"
+	webFolderPathDefault                  = "/web/admin/folder"
+	webStatusPathDefault                  = "/web/admin/status"
+	webAdminsPathDefault                  = "/web/admin/managers"
+	webAdminPathDefault                   = "/web/admin/manager"
+	webMaintenancePathDefault             = "/web/admin/maintenance"
+	webBackupPathDefault                  = "/web/admin/backup"
+	webRestorePathDefault                 = "/web/admin/restore"
+	webScanVFolderPathDefault             = "/web/admin/quotas/scanfolder"
+	webQuotaScanPathDefault               = "/web/admin/quotas/scanuser"
+	webChangeAdminPwdPathDefault          = "/web/admin/changepwd"
+	webAdminProfilePathDefault            = "/web/admin/profile"
+	webAdminMFAPathDefault                = "/web/admin/mfa"
+	webAdminTOTPGeneratePathDefault       = "/web/admin/totp/generate"
+	webAdminTOTPValidatePathDefault       = "/web/admin/totp/validate"
+	webAdminTOTPSavePathDefault           = "/web/admin/totp/save"
+	webAdminRecoveryCodesPathDefault      = "/web/admin/recoverycodes"
+	webTemplateUserDefault                = "/web/admin/template/user"
+	webTemplateFolderDefault              = "/web/admin/template/folder"
+	webDefenderPathDefault                = "/web/admin/defender"
+	webDefenderHostsPathDefault           = "/web/admin/defender/hosts"
+	webClientLoginPathDefault             = "/web/client/login"
+	webClientTwoFactorPathDefault         = "/web/client/twofactor"
+	webClientTwoFactorRecoveryPathDefault = "/web/client/twofactor-recovery"
+	webClientFilesPathDefault             = "/web/client/files"
+	webClientDirsPathDefault              = "/web/client/dirs"
+	webClientDownloadZipPathDefault       = "/web/client/downloadzip"
+	webClientProfilePathDefault           = "/web/client/profile"
+	webClientMFAPathDefault               = "/web/client/mfa"
+	webClientTOTPGeneratePathDefault      = "/web/client/totp/generate"
+	webClientTOTPValidatePathDefault      = "/web/client/totp/validate"
+	webClientTOTPSavePathDefault          = "/web/client/totp/save"
+	webClientRecoveryCodesPathDefault     = "/web/client/recoverycodes"
+	webChangeClientPwdPathDefault         = "/web/client/changepwd"
+	webClientLogoutPathDefault            = "/web/client/logout"
+	webStaticFilesPathDefault             = "/static"
 	// MaxRestoreSize defines the max size for the loaddata input file
 	MaxRestoreSize   = 10485760 // 10 MB
 	maxRequestSize   = 1048576  // 1MB
@@ -139,63 +136,60 @@ const (
 )
 
 var (
-	backupsPath                     string
-	certMgr                         *common.CertManager
-	cleanupTicker                   *time.Ticker
-	cleanupDone                     chan bool
-	invalidatedJWTTokens            sync.Map
-	csrfTokenAuth                   *jwtauth.JWTAuth
-	webRootPath                     string
-	webBasePath                     string
-	webBaseAdminPath                string
-	webBaseClientPath               string
-	webAdminSetupPath               string
-	webLoginPath                    string
-	webAdminTwoFactorPath           string
-	webAdminTwoFactorRecoveryPath   string
-	webLogoutPath                   string
-	webUsersPath                    string
-	webUserPath                     string
-	webConnectionsPath              string
-	webFoldersPath                  string
-	webFolderPath                   string
-	webStatusPath                   string
-	webAdminsPath                   string
-	webAdminPath                    string
-	webMaintenancePath              string
-	webBackupPath                   string
-	webRestorePath                  string
-	webScanVFolderPath              string
-	webQuotaScanPath                string
-	webAdminCredentialsPath         string
-	webAdminMFAPath                 string
-	webAdminTOTPGeneratePath        string
-	webAdminTOTPValidatePath        string
-	webAdminTOTPSavePath            string
-	webAdminRecoveryCodesPath       string
-	webChangeAdminAPIKeyAccessPath  string
-	webChangeAdminPwdPath           string
-	webTemplateUser                 string
-	webTemplateFolder               string
-	webDefenderPath                 string
-	webDefenderHostsPath            string
-	webClientLoginPath              string
-	webClientTwoFactorPath          string
-	webClientTwoFactorRecoveryPath  string
-	webClientFilesPath              string
-	webClientDirsPath               string
-	webClientDownloadZipPath        string
-	webClientCredentialsPath        string
-	webChangeClientPwdPath          string
-	webChangeClientKeysPath         string
-	webClientMFAPath                string
-	webClientTOTPGeneratePath       string
-	webClientTOTPValidatePath       string
-	webClientTOTPSavePath           string
-	webClientRecoveryCodesPath      string
-	webChangeClientAPIKeyAccessPath string
-	webClientLogoutPath             string
-	webStaticFilesPath              string
+	backupsPath                    string
+	certMgr                        *common.CertManager
+	cleanupTicker                  *time.Ticker
+	cleanupDone                    chan bool
+	invalidatedJWTTokens           sync.Map
+	csrfTokenAuth                  *jwtauth.JWTAuth
+	webRootPath                    string
+	webBasePath                    string
+	webBaseAdminPath               string
+	webBaseClientPath              string
+	webAdminSetupPath              string
+	webLoginPath                   string
+	webAdminTwoFactorPath          string
+	webAdminTwoFactorRecoveryPath  string
+	webLogoutPath                  string
+	webUsersPath                   string
+	webUserPath                    string
+	webConnectionsPath             string
+	webFoldersPath                 string
+	webFolderPath                  string
+	webStatusPath                  string
+	webAdminsPath                  string
+	webAdminPath                   string
+	webMaintenancePath             string
+	webBackupPath                  string
+	webRestorePath                 string
+	webScanVFolderPath             string
+	webQuotaScanPath               string
+	webAdminProfilePath            string
+	webAdminMFAPath                string
+	webAdminTOTPGeneratePath       string
+	webAdminTOTPValidatePath       string
+	webAdminTOTPSavePath           string
+	webAdminRecoveryCodesPath      string
+	webChangeAdminPwdPath          string
+	webTemplateUser                string
+	webTemplateFolder              string
+	webDefenderPath                string
+	webDefenderHostsPath           string
+	webClientLoginPath             string
+	webClientTwoFactorPath         string
+	webClientTwoFactorRecoveryPath string
+	webClientFilesPath             string
+	webClientDirsPath              string
+	webClientDownloadZipPath       string
+	webClientProfilePath           string
+	webChangeClientPwdPath         string
+	webClientMFAPath               string
+	webClientTOTPGeneratePath      string
+	webClientTOTPValidatePath      string
+	webClientTOTPSavePath          string
+	webClientRecoveryCodesPath     string
+	webClientLogoutPath            string
+	webStaticFilesPath             string
 	// max upload size for http clients, 1GB by default
 	maxUploadFileSize = int64(1048576000)
 )
@@ -530,10 +524,8 @@ func updateWebClientURLs(baseURL string) {
 	webClientFilesPath = path.Join(baseURL, webClientFilesPathDefault)
 	webClientDirsPath = path.Join(baseURL, webClientDirsPathDefault)
 	webClientDownloadZipPath = path.Join(baseURL, webClientDownloadZipPathDefault)
-	webClientCredentialsPath = path.Join(baseURL, webClientCredentialsPathDefault)
+	webClientProfilePath = path.Join(baseURL, webClientProfilePathDefault)
 	webChangeClientPwdPath = path.Join(baseURL, webChangeClientPwdPathDefault)
-	webChangeClientKeysPath = path.Join(baseURL, webChangeClientKeysPathDefault)
-	webChangeClientAPIKeyAccessPath = path.Join(baseURL, webChangeClientAPIKeyAccessPathDefault)
 	webClientLogoutPath = path.Join(baseURL, webClientLogoutPathDefault)
 	webClientMFAPath = path.Join(baseURL, webClientMFAPathDefault)
 	webClientTOTPGeneratePath = path.Join(baseURL, webClientTOTPGeneratePathDefault)
@@ -568,13 +560,12 @@ func updateWebAdminURLs(baseURL string) {
 	webScanVFolderPath = path.Join(baseURL, webScanVFolderPathDefault)
 	webQuotaScanPath = path.Join(baseURL, webQuotaScanPathDefault)
 	webChangeAdminPwdPath = path.Join(baseURL, webChangeAdminPwdPathDefault)
-	webAdminCredentialsPath = path.Join(baseURL, webAdminCredentialsPathDefault)
+	webAdminProfilePath = path.Join(baseURL, webAdminProfilePathDefault)
 	webAdminMFAPath = path.Join(baseURL, webAdminMFAPathDefault)
 	webAdminTOTPGeneratePath = path.Join(baseURL, webAdminTOTPGeneratePathDefault)
 	webAdminTOTPValidatePath = path.Join(baseURL, webAdminTOTPValidatePathDefault)
 	webAdminTOTPSavePath = path.Join(baseURL, webAdminTOTPSavePathDefault)
 	webAdminRecoveryCodesPath = path.Join(baseURL, webAdminRecoveryCodesPathDefault)
-	webChangeAdminAPIKeyAccessPath = path.Join(baseURL, webChangeAdminAPIKeyAccessPathDefault)
 	webTemplateUser = path.Join(baseURL, webTemplateUserDefault)
 	webTemplateFolder = path.Join(baseURL, webTemplateFolderDefault)
 	webDefenderHostsPath = path.Join(baseURL, webDefenderHostsPathDefault)

+ 297 - 184
httpd/httpd_test.go

@@ -92,13 +92,13 @@ const (
 	adminTOTPValidatePath           = "/api/v2/admin/totp/validate"
 	adminTOTPSavePath               = "/api/v2/admin/totp/save"
 	admin2FARecoveryCodesPath       = "/api/v2/admin/2fa/recoverycodes"
-	adminManageAPIKeyPath           = "/api/v2/admin/apikeyauth"
+	adminProfilePath                = "/api/v2/admin/profile"
 	userTOTPConfigsPath             = "/api/v2/user/totp/configs"
 	userTOTPGeneratePath            = "/api/v2/user/totp/generate"
 	userTOTPValidatePath            = "/api/v2/user/totp/validate"
 	userTOTPSavePath                = "/api/v2/user/totp/save"
 	user2FARecoveryCodesPath        = "/api/v2/user/2fa/recoverycodes"
-	userManageAPIKeyPath            = "/api/v2/user/apikeyauth"
+	userProfilePath                 = "/api/v2/user/profile"
 	retentionBasePath               = "/api/v2/retention/users"
 	healthzPath                     = "/healthz"
 	webBasePath                     = "/web"
@@ -117,11 +117,10 @@ const (
 	webMaintenancePath              = "/web/admin/maintenance"
 	webRestorePath                  = "/web/admin/restore"
 	webChangeAdminPwdPath           = "/web/admin/changepwd"
-	webAdminCredentialsPath         = "/web/admin/credentials"
+	webAdminProfilePath             = "/web/admin/profile"
 	webTemplateUser                 = "/web/admin/template/user"
 	webTemplateFolder               = "/web/admin/template/folder"
 	webDefenderPath                 = "/web/admin/defender"
-	webChangeAdminAPIKeyAccessPath  = "/web/admin/apikeyaccess"
 	webAdminTwoFactorPath           = "/web/admin/twofactor"
 	webAdminTwoFactorRecoveryPath   = "/web/admin/twofactor-recovery"
 	webAdminMFAPath                 = "/web/admin/mfa"
@@ -131,10 +130,8 @@ const (
 	webClientFilesPath              = "/web/client/files"
 	webClientDirsPath               = "/web/client/dirs"
 	webClientDownloadZipPath        = "/web/client/downloadzip"
-	webClientCredentialsPath        = "/web/client/credentials"
 	webChangeClientPwdPath          = "/web/client/changepwd"
-	webChangeClientKeysPath         = "/web/client/managekeys"
-	webChangeClientAPIKeyAccessPath = "/web/client/apikeyaccess"
+	webClientProfilePath            = "/web/client/profile"
 	webClientTwoFactorPath          = "/web/client/twofactor"
 	webClientTwoFactorRecoveryPath  = "/web/client/twofactor-recovery"
 	webClientLogoutPath             = "/web/client/logout"
@@ -3408,7 +3405,7 @@ func TestSkipNaturalKeysValidation(t *testing.T) {
 	assert.NoError(t, err)
 	form := make(url.Values)
 	form.Set(csrfFormToken, csrfToken)
-	req, err := http.NewRequest(http.MethodPost, webChangeClientAPIKeyAccessPath, bytes.NewBuffer([]byte(form.Encode())))
+	req, err := http.NewRequest(http.MethodPost, webClientProfilePath, bytes.NewBuffer([]byte(form.Encode())))
 	assert.NoError(t, err)
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 	setJWTCookieForReq(req, token)
@@ -3438,7 +3435,7 @@ func TestSkipNaturalKeysValidation(t *testing.T) {
 	apiKeyAuthReq["allow_api_key_auth"] = true
 	asJSON, err := json.Marshal(apiKeyAuthReq)
 	assert.NoError(t, err)
-	req, err = http.NewRequest(http.MethodPut, userManageAPIKeyPath, bytes.NewBuffer(asJSON))
+	req, err = http.NewRequest(http.MethodPut, userProfilePath, bytes.NewBuffer(asJSON))
 	assert.NoError(t, err)
 	setBearerForReq(req, userAPIToken)
 	rr = executeRequest(req)
@@ -3456,7 +3453,7 @@ func TestSkipNaturalKeysValidation(t *testing.T) {
 	assert.NoError(t, err)
 	form = make(url.Values)
 	form.Set(csrfFormToken, csrfToken)
-	req, _ = http.NewRequest(http.MethodPost, webChangeAdminAPIKeyAccessPath, bytes.NewBuffer([]byte(form.Encode())))
+	req, _ = http.NewRequest(http.MethodPost, webAdminProfilePath, bytes.NewBuffer([]byte(form.Encode())))
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 	setJWTCookieForReq(req, token)
 	rr = executeRequest(req)
@@ -3467,7 +3464,7 @@ func TestSkipNaturalKeysValidation(t *testing.T) {
 	apiKeyAuthReq["allow_api_key_auth"] = true
 	asJSON, err = json.Marshal(apiKeyAuthReq)
 	assert.NoError(t, err)
-	req, err = http.NewRequest(http.MethodPut, adminManageAPIKeyPath, bytes.NewBuffer(asJSON))
+	req, err = http.NewRequest(http.MethodPut, adminProfilePath, bytes.NewBuffer(asJSON))
 	assert.NoError(t, err)
 	setBearerForReq(req, adminAPIToken)
 	rr = executeRequest(req)
@@ -5128,6 +5125,40 @@ func TestChangeAdminPwdInvalidJsonMock(t *testing.T) {
 	checkResponseCode(t, http.StatusBadRequest, rr)
 }
 
+func TestMFAPermission(t *testing.T) {
+	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
+	assert.NoError(t, err)
+
+	webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
+
+	req, err := http.NewRequest(http.MethodGet, webClientMFAPath, nil)
+	assert.NoError(t, err)
+	req.RequestURI = webClientMFAPath
+	setJWTCookieForReq(req, webToken)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	user.Filters.WebClient = []string{sdk.WebClientMFADisabled}
+	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+
+	webToken, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
+
+	req, err = http.NewRequest(http.MethodGet, webClientMFAPath, nil)
+	assert.NoError(t, err)
+	req.RequestURI = webClientMFAPath
+	setJWTCookieForReq(req, webToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusForbidden, rr)
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+	err = os.RemoveAll(user.GetHomeDir())
+	assert.NoError(t, err)
+}
+
 func TestWebUserTwoFactorLogin(t *testing.T) {
 	u := getTestUser()
 	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
@@ -5958,104 +5989,160 @@ func TestWebUserTOTP(t *testing.T) {
 	checkResponseCode(t, http.StatusNotFound, rr)
 }
 
-func TestWebAPIChangeUserAPIKeyAuth(t *testing.T) {
+func TestWebAPIChangeUserProfileMock(t *testing.T) {
 	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
 	assert.NoError(t, err)
 	assert.False(t, user.Filters.AllowAPIKeyAuth)
 	token, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
 	assert.NoError(t, err)
 	// invalid json
-	req, err := http.NewRequest(http.MethodPut, userManageAPIKeyPath, bytes.NewBuffer([]byte("{")))
+	req, err := http.NewRequest(http.MethodPut, userProfilePath, bytes.NewBuffer([]byte("{")))
 	assert.NoError(t, err)
 	setBearerForReq(req, token)
 	rr := executeRequest(req)
 	checkResponseCode(t, http.StatusBadRequest, rr)
 
-	apiKeyAuthReq := make(map[string]bool)
-	apiKeyAuthReq["allow_api_key_auth"] = true
-	asJSON, err := json.Marshal(apiKeyAuthReq)
+	email := "userapi@example.com"
+	description := "user API description"
+	profileReq := make(map[string]interface{})
+	profileReq["allow_api_key_auth"] = true
+	profileReq["email"] = email
+	profileReq["description"] = description
+	profileReq["public_keys"] = []string{testPubKey, testPubKey1}
+	asJSON, err := json.Marshal(profileReq)
 	assert.NoError(t, err)
-	req, err = http.NewRequest(http.MethodPut, userManageAPIKeyPath, bytes.NewBuffer(asJSON))
+	req, err = http.NewRequest(http.MethodPut, userProfilePath, bytes.NewBuffer(asJSON))
 	assert.NoError(t, err)
 	setBearerForReq(req, token)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
 
-	user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
-	assert.NoError(t, err)
-	assert.True(t, user.Filters.AllowAPIKeyAuth)
-
-	apiKeyAuthReq = make(map[string]bool)
-	req, err = http.NewRequest(http.MethodGet, userManageAPIKeyPath, nil)
+	profileReq = make(map[string]interface{})
+	req, err = http.NewRequest(http.MethodGet, userProfilePath, nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, token)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
-	err = json.Unmarshal(rr.Body.Bytes(), &apiKeyAuthReq)
+	err = json.Unmarshal(rr.Body.Bytes(), &profileReq)
 	assert.NoError(t, err)
-	assert.True(t, apiKeyAuthReq["allow_api_key_auth"])
-
-	apiKeyAuthReq["allow_api_key_auth"] = false
-	asJSON, err = json.Marshal(apiKeyAuthReq)
+	assert.Equal(t, email, profileReq["email"].(string))
+	assert.Equal(t, description, profileReq["description"].(string))
+	assert.True(t, profileReq["allow_api_key_auth"].(bool))
+	assert.Len(t, profileReq["public_keys"].([]interface{}), 2)
+	// set an invalid email
+	profileReq = make(map[string]interface{})
+	profileReq["email"] = "notavalidemail"
+	asJSON, err = json.Marshal(profileReq)
 	assert.NoError(t, err)
-	req, err = http.NewRequest(http.MethodPut, userManageAPIKeyPath, bytes.NewBuffer(asJSON))
+	req, err = http.NewRequest(http.MethodPut, userProfilePath, bytes.NewBuffer(asJSON))
 	assert.NoError(t, err)
 	setBearerForReq(req, token)
 	rr = executeRequest(req)
-	checkResponseCode(t, http.StatusOK, rr)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "Validation error: email")
+	// set an invalid public key
+	profileReq = make(map[string]interface{})
+	profileReq["public_keys"] = []string{"not a public key"}
+	asJSON, err = json.Marshal(profileReq)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPut, userProfilePath, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setBearerForReq(req, token)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "Validation error: could not parse key")
 
-	user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
+	user.Filters.WebClient = []string{sdk.WebClientAPIKeyAuthChangeDisabled, sdk.WebClientPubKeyChangeDisabled}
+	user.Email = email
+	user.Description = description
+	user.Filters.AllowAPIKeyAuth = true
+	_, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+	token, err = getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
 	assert.NoError(t, err)
-	assert.False(t, user.Filters.AllowAPIKeyAuth)
 
-	apiKeyAuthReq = make(map[string]bool)
-	req, err = http.NewRequest(http.MethodGet, userManageAPIKeyPath, nil)
+	profileReq = make(map[string]interface{})
+	profileReq["allow_api_key_auth"] = false
+	profileReq["email"] = email
+	profileReq["description"] = description + "_mod"
+	profileReq["public_keys"] = []string{testPubKey}
+	asJSON, err = json.Marshal(profileReq)
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPut, userProfilePath, bytes.NewBuffer(asJSON))
 	assert.NoError(t, err)
 	setBearerForReq(req, token)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
-	err = json.Unmarshal(rr.Body.Bytes(), &apiKeyAuthReq)
+	assert.Contains(t, rr.Body.String(), "Profile updated")
+	// check that api key auth and public keys were not changed
+	profileReq = make(map[string]interface{})
+	req, err = http.NewRequest(http.MethodGet, userProfilePath, nil)
 	assert.NoError(t, err)
-	assert.False(t, apiKeyAuthReq["allow_api_key_auth"])
-
-	// remove the permission
-	user.Filters.WebClient = []string{sdk.WebClientAPIKeyAuthChangeDisabled}
-	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	setBearerForReq(req, token)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	err = json.Unmarshal(rr.Body.Bytes(), &profileReq)
 	assert.NoError(t, err)
-	assert.Len(t, user.Filters.WebClient, 1)
-	assert.Contains(t, user.Filters.WebClient, sdk.WebClientAPIKeyAuthChangeDisabled)
+	assert.Equal(t, email, profileReq["email"].(string))
+	assert.Equal(t, description+"_mod", profileReq["description"].(string))
+	assert.True(t, profileReq["allow_api_key_auth"].(bool))
+	assert.Len(t, profileReq["public_keys"].([]interface{}), 2)
 
-	newToken, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
+	user.Filters.WebClient = []string{sdk.WebClientAPIKeyAuthChangeDisabled, sdk.WebClientInfoChangeDisabled}
+	user.Description = description + "_mod"
+	_, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+	token, err = getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword)
 	assert.NoError(t, err)
 
-	apiKeyAuthReq["allow_api_key_auth"] = true
-	asJSON, err = json.Marshal(apiKeyAuthReq)
+	profileReq = make(map[string]interface{})
+	profileReq["allow_api_key_auth"] = false
+	profileReq["email"] = "newemail@apiuser.com"
+	profileReq["description"] = description
+	profileReq["public_keys"] = []string{testPubKey}
+	asJSON, err = json.Marshal(profileReq)
 	assert.NoError(t, err)
-	req, err = http.NewRequest(http.MethodPut, userManageAPIKeyPath, bytes.NewBuffer(asJSON))
+	req, err = http.NewRequest(http.MethodPut, userProfilePath, bytes.NewBuffer(asJSON))
 	assert.NoError(t, err)
-	setBearerForReq(req, newToken)
+	setBearerForReq(req, token)
 	rr = executeRequest(req)
-	checkResponseCode(t, http.StatusForbidden, rr)
-	// get will still work
-	req, err = http.NewRequest(http.MethodGet, userManageAPIKeyPath, nil)
+	checkResponseCode(t, http.StatusOK, rr)
+	profileReq = make(map[string]interface{})
+	req, err = http.NewRequest(http.MethodGet, userProfilePath, nil)
 	assert.NoError(t, err)
-	setBearerForReq(req, newToken)
+	setBearerForReq(req, token)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
+	err = json.Unmarshal(rr.Body.Bytes(), &profileReq)
+	assert.NoError(t, err)
+	assert.Equal(t, email, profileReq["email"].(string))
+	assert.Equal(t, description+"_mod", profileReq["description"].(string))
+	assert.True(t, profileReq["allow_api_key_auth"].(bool))
+	assert.Len(t, profileReq["public_keys"].([]interface{}), 1)
+	// finally disable all profile permissions
+	user.Filters.WebClient = []string{sdk.WebClientAPIKeyAuthChangeDisabled, sdk.WebClientInfoChangeDisabled,
+		sdk.WebClientPubKeyChangeDisabled}
+	_, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+	req, err = http.NewRequest(http.MethodPut, userProfilePath, bytes.NewBuffer(asJSON))
+	assert.NoError(t, err)
+	setBearerForReq(req, token)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusForbidden, rr)
+	assert.Contains(t, rr.Body.String(), "You are not allowed to change anything")
 
 	_, err = httpdtest.RemoveUser(user, http.StatusOK)
 	assert.NoError(t, err)
 	err = os.RemoveAll(user.GetHomeDir())
 	assert.NoError(t, err)
 
-	apiKeyAuthReq = make(map[string]bool)
-	req, err = http.NewRequest(http.MethodGet, userManageAPIKeyPath, nil)
+	req, err = http.NewRequest(http.MethodGet, userProfilePath, nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, token)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusNotFound, rr)
 
-	req, err = http.NewRequest(http.MethodPut, userManageAPIKeyPath, bytes.NewBuffer(asJSON))
+	req, err = http.NewRequest(http.MethodPut, userProfilePath, bytes.NewBuffer(asJSON))
 	assert.NoError(t, err)
 	setBearerForReq(req, token)
 	rr = executeRequest(req)
@@ -6136,7 +6223,7 @@ func TestLoginInvalidPasswordMock(t *testing.T) {
 	assert.Equal(t, http.StatusUnauthorized, rr.Code)
 }
 
-func TestChangeAdminAPIKeyAuth(t *testing.T) {
+func TestWebAPIChangeAdminProfileMock(t *testing.T) {
 	admin := getTestAdmin()
 	admin.Username = altAdminUsername
 	admin.Password = altAdminPassword
@@ -6147,65 +6234,59 @@ func TestChangeAdminAPIKeyAuth(t *testing.T) {
 	token, err := getJWTAPITokenFromTestServer(altAdminUsername, altAdminPassword)
 	assert.NoError(t, err)
 	// invalid json
-	req, err := http.NewRequest(http.MethodPut, adminManageAPIKeyPath, bytes.NewBuffer([]byte("{")))
+	req, err := http.NewRequest(http.MethodPut, adminProfilePath, bytes.NewBuffer([]byte("{")))
 	assert.NoError(t, err)
 	setBearerForReq(req, token)
 	rr := executeRequest(req)
 	checkResponseCode(t, http.StatusBadRequest, rr)
 
-	apiKeyAuthReq := make(map[string]bool)
-	apiKeyAuthReq["allow_api_key_auth"] = true
-	asJSON, err := json.Marshal(apiKeyAuthReq)
+	email := "adminapi@example.com"
+	description := "admin API description"
+	profileReq := make(map[string]interface{})
+	profileReq["allow_api_key_auth"] = true
+	profileReq["email"] = email
+	profileReq["description"] = description
+	asJSON, err := json.Marshal(profileReq)
 	assert.NoError(t, err)
-	req, err = http.NewRequest(http.MethodPut, adminManageAPIKeyPath, bytes.NewBuffer(asJSON))
+	req, err = http.NewRequest(http.MethodPut, adminProfilePath, bytes.NewBuffer(asJSON))
 	assert.NoError(t, err)
 	setBearerForReq(req, token)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
+	assert.Contains(t, rr.Body.String(), "Profile updated")
 
-	admin, _, err = httpdtest.GetAdminByUsername(altAdminUsername, http.StatusOK)
-	assert.NoError(t, err)
-	assert.True(t, admin.Filters.AllowAPIKeyAuth)
-
-	apiKeyAuthReq = make(map[string]bool)
-	req, err = http.NewRequest(http.MethodGet, adminManageAPIKeyPath, nil)
+	profileReq = make(map[string]interface{})
+	req, err = http.NewRequest(http.MethodGet, adminProfilePath, nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, token)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
-	err = json.Unmarshal(rr.Body.Bytes(), &apiKeyAuthReq)
+	err = json.Unmarshal(rr.Body.Bytes(), &profileReq)
 	assert.NoError(t, err)
-	assert.True(t, apiKeyAuthReq["allow_api_key_auth"])
-
-	apiKeyAuthReq["allow_api_key_auth"] = false
-	asJSON, err = json.Marshal(apiKeyAuthReq)
-	assert.NoError(t, err)
-	req, err = http.NewRequest(http.MethodPut, adminManageAPIKeyPath, bytes.NewBuffer(asJSON))
+	assert.Equal(t, email, profileReq["email"].(string))
+	assert.Equal(t, description, profileReq["description"].(string))
+	assert.True(t, profileReq["allow_api_key_auth"].(bool))
+	// set an invalid email
+	profileReq["email"] = "admin_invalid_email"
+	asJSON, err = json.Marshal(profileReq)
 	assert.NoError(t, err)
-	setBearerForReq(req, token)
-	rr = executeRequest(req)
-	checkResponseCode(t, http.StatusOK, rr)
-
-	apiKeyAuthReq = make(map[string]bool)
-	req, err = http.NewRequest(http.MethodGet, adminManageAPIKeyPath, nil)
+	req, err = http.NewRequest(http.MethodPut, adminProfilePath, bytes.NewBuffer(asJSON))
 	assert.NoError(t, err)
 	setBearerForReq(req, token)
 	rr = executeRequest(req)
-	checkResponseCode(t, http.StatusOK, rr)
-	err = json.Unmarshal(rr.Body.Bytes(), &apiKeyAuthReq)
-	assert.NoError(t, err)
-	assert.False(t, apiKeyAuthReq["allow_api_key_auth"])
+	checkResponseCode(t, http.StatusBadRequest, rr)
+	assert.Contains(t, rr.Body.String(), "Validation error: email")
 
 	_, err = httpdtest.RemoveAdmin(admin, http.StatusOK)
 	assert.NoError(t, err)
 
-	req, err = http.NewRequest(http.MethodGet, adminManageAPIKeyPath, nil)
+	req, err = http.NewRequest(http.MethodGet, adminProfilePath, nil)
 	assert.NoError(t, err)
 	setBearerForReq(req, token)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusNotFound, rr)
 
-	req, err = http.NewRequest(http.MethodPut, adminManageAPIKeyPath, bytes.NewBuffer(asJSON))
+	req, err = http.NewRequest(http.MethodPut, adminProfilePath, bytes.NewBuffer(asJSON))
 	assert.NoError(t, err)
 	setBearerForReq(req, token)
 	rr = executeRequest(req)
@@ -7473,13 +7554,13 @@ func TestWebAPILoginMock(t *testing.T) {
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
 	// API token is not valid for web usage
-	req, _ = http.NewRequest(http.MethodGet, webClientCredentialsPath, nil)
+	req, _ = http.NewRequest(http.MethodGet, webClientProfilePath, nil)
 	setJWTCookieForReq(req, apiToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusFound, rr)
 	assert.Equal(t, webClientLoginPath, rr.Header().Get("Location"))
 
-	req, _ = http.NewRequest(http.MethodGet, webClientCredentialsPath, nil)
+	req, _ = http.NewRequest(http.MethodGet, webClientProfilePath, nil)
 	setJWTCookieForReq(req, webToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
@@ -7509,13 +7590,13 @@ func TestWebClientLoginMock(t *testing.T) {
 	checkResponseCode(t, http.StatusFound, rr)
 	assert.Equal(t, webLoginPath, rr.Header().Get("Location"))
 	// bearer should not work
-	req, _ = http.NewRequest(http.MethodGet, webClientCredentialsPath, nil)
+	req, _ = http.NewRequest(http.MethodGet, webClientProfilePath, nil)
 	setBearerForReq(req, webToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusFound, rr)
 	assert.Equal(t, webClientLoginPath, rr.Header().Get("Location"))
 	// now try to render client pages
-	req, _ = http.NewRequest(http.MethodGet, webClientCredentialsPath, nil)
+	req, _ = http.NewRequest(http.MethodGet, webClientProfilePath, nil)
 	setJWTCookieForReq(req, webToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
@@ -7529,7 +7610,7 @@ func TestWebClientLoginMock(t *testing.T) {
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusFound, rr)
 	assert.Equal(t, webClientLoginPath, rr.Header().Get("Location"))
-	req, _ = http.NewRequest(http.MethodGet, webClientCredentialsPath, nil)
+	req, _ = http.NewRequest(http.MethodGet, webClientProfilePath, nil)
 	setJWTCookieForReq(req, webToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusFound, rr)
@@ -7545,7 +7626,7 @@ func TestWebClientLoginMock(t *testing.T) {
 	err = os.RemoveAll(user.GetHomeDir())
 	assert.NoError(t, err)
 
-	req, _ = http.NewRequest(http.MethodGet, webClientCredentialsPath, nil)
+	req, _ = http.NewRequest(http.MethodGet, webClientProfilePath, nil)
 	setJWTCookieForReq(req, webToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusInternalServerError, rr)
@@ -7602,7 +7683,7 @@ func TestWebClientLoginMock(t *testing.T) {
 	form := make(url.Values)
 	form.Set("public_keys", testPubKey)
 	form.Set(csrfFormToken, csrfToken)
-	req, _ = http.NewRequest(http.MethodPost, webChangeClientKeysPath, bytes.NewBuffer([]byte(form.Encode())))
+	req, _ = http.NewRequest(http.MethodPost, webClientProfilePath, bytes.NewBuffer([]byte(form.Encode())))
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 	setJWTCookieForReq(req, webToken)
 	rr = executeRequest(req)
@@ -7864,15 +7945,22 @@ func TestWebClientChangePwd(t *testing.T) {
 	csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath)
 	assert.NoError(t, err)
 
+	req, err := http.NewRequest(http.MethodGet, webChangeClientPwdPath, nil)
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
 	form := make(url.Values)
 	form.Set("current_password", defaultPassword)
 	form.Set("new_password1", defaultPassword)
 	form.Set("new_password2", defaultPassword)
 	// no csrf token
-	req, _ := http.NewRequest(http.MethodPost, webChangeClientPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	req, err = http.NewRequest(http.MethodPost, webChangeClientPwdPath, bytes.NewBuffer([]byte(form.Encode())))
+	assert.NoError(t, err)
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 	setJWTCookieForReq(req, webToken)
-	rr := executeRequest(req)
+	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusForbidden, rr)
 	assert.Contains(t, rr.Body.String(), "unable to verify form token")
 
@@ -8004,64 +8092,6 @@ func TestWebAPIPublicKeys(t *testing.T) {
 	assert.NoError(t, err)
 }
 
-func TestWebClientChangePubKeys(t *testing.T) {
-	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
-	assert.NoError(t, err)
-	webToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
-	assert.NoError(t, err)
-	csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath)
-	assert.NoError(t, err)
-	form := make(url.Values)
-	form.Set("public_keys", testPubKey)
-	form.Add("public_keys", testPubKey1)
-	// no csrf token
-	req, _ := http.NewRequest(http.MethodPost, webChangeClientKeysPath, bytes.NewBuffer([]byte(form.Encode())))
-	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
-	setJWTCookieForReq(req, webToken)
-	rr := executeRequest(req)
-	checkResponseCode(t, http.StatusForbidden, rr)
-	assert.Contains(t, rr.Body.String(), "unable to verify form token")
-
-	form.Set(csrfFormToken, csrfToken)
-	req, _ = http.NewRequest(http.MethodPost, webChangeClientKeysPath, bytes.NewBuffer([]byte(form.Encode())))
-	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
-	setJWTCookieForReq(req, webToken)
-	rr = executeRequest(req)
-	checkResponseCode(t, http.StatusOK, rr)
-	assert.Contains(t, rr.Body.String(), "Your public keys has been successfully updated")
-
-	user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
-	assert.NoError(t, err)
-	assert.Len(t, user.PublicKeys, 2)
-
-	form.Set("public_keys", "invalid")
-	req, _ = http.NewRequest(http.MethodPost, webChangeClientKeysPath, bytes.NewBuffer([]byte(form.Encode())))
-	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
-	setJWTCookieForReq(req, webToken)
-	rr = executeRequest(req)
-	checkResponseCode(t, http.StatusOK, rr)
-	assert.Contains(t, rr.Body.String(), "Validation error: could not parse key")
-
-	user.Filters.WebClient = append(user.Filters.WebClient, sdk.WebClientPubKeyChangeDisabled)
-	_, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
-	assert.NoError(t, err)
-	webToken, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
-	assert.NoError(t, err)
-	form.Set(csrfFormToken, csrfToken)
-	form.Set("public_keys", testPubKey)
-	req, _ = http.NewRequest(http.MethodPost, webChangeClientKeysPath, bytes.NewBuffer([]byte(form.Encode())))
-	req.RequestURI = webChangeClientKeysPath
-	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
-	setJWTCookieForReq(req, webToken)
-	rr = executeRequest(req)
-	checkResponseCode(t, http.StatusForbidden, rr)
-
-	_, err = httpdtest.RemoveUser(user, http.StatusOK)
-	assert.NoError(t, err)
-	err = os.RemoveAll(user.GetHomeDir())
-	assert.NoError(t, err)
-}
-
 func TestPreDownloadHook(t *testing.T) {
 	if runtime.GOOS == osWindows {
 		t.Skip("this test is not available on Windows")
@@ -9743,7 +9773,7 @@ func TestWebAdminLoginMock(t *testing.T) {
 }
 
 func TestAdminNoToken(t *testing.T) {
-	req, _ := http.NewRequest(http.MethodGet, webAdminCredentialsPath, nil)
+	req, _ := http.NewRequest(http.MethodGet, webAdminProfilePath, nil)
 	rr := executeRequest(req)
 	checkResponseCode(t, http.StatusFound, rr)
 	assert.Equal(t, webLoginPath, rr.Header().Get("Location"))
@@ -9762,10 +9792,8 @@ func TestAdminNoToken(t *testing.T) {
 	checkResponseCode(t, http.StatusUnauthorized, rr)
 }
 
-func TestWebUserAllowAPIKeyAuth(t *testing.T) {
-	u := getTestUser()
-	u.Filters.AllowAPIKeyAuth = true
-	user, _, err := httpdtest.AddUser(u, http.StatusCreated)
+func TestWebUserProfile(t *testing.T) {
+	user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
 	assert.NoError(t, err)
 
 	csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath)
@@ -9773,10 +9801,17 @@ func TestWebUserAllowAPIKeyAuth(t *testing.T) {
 	token, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
 	assert.NoError(t, err)
 
+	email := "user@user.com"
+	description := "User"
+
 	form := make(url.Values)
 	form.Set("allow_api_key_auth", "1")
+	form.Set("email", email)
+	form.Set("description", description)
+	form.Set("public_keys", testPubKey)
+	form.Add("public_keys", testPubKey1)
 	// no csrf token
-	req, err := http.NewRequest(http.MethodPost, webChangeClientAPIKeyAccessPath, bytes.NewBuffer([]byte(form.Encode())))
+	req, err := http.NewRequest(http.MethodPost, webClientProfilePath, bytes.NewBuffer([]byte(form.Encode())))
 	assert.NoError(t, err)
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 	setJWTCookieForReq(req, token)
@@ -9785,43 +9820,109 @@ func TestWebUserAllowAPIKeyAuth(t *testing.T) {
 	assert.Contains(t, rr.Body.String(), "unable to verify form token")
 
 	form.Set(csrfFormToken, csrfToken)
-	req, _ = http.NewRequest(http.MethodPost, webChangeClientAPIKeyAccessPath, bytes.NewBuffer([]byte(form.Encode())))
+	req, _ = http.NewRequest(http.MethodPost, webClientProfilePath, bytes.NewBuffer([]byte(form.Encode())))
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 	setJWTCookieForReq(req, token)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
-	assert.Contains(t, rr.Body.String(), "API key authentication updated")
+	assert.Contains(t, rr.Body.String(), "Your profile has been successfully updated")
 
 	user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
 	assert.NoError(t, err)
 	assert.True(t, user.Filters.AllowAPIKeyAuth)
+	assert.Len(t, user.PublicKeys, 2)
+	assert.Equal(t, email, user.Email)
+	assert.Equal(t, description, user.Description)
 
-	form = make(url.Values)
-	form.Set(csrfFormToken, csrfToken)
-	req, _ = http.NewRequest(http.MethodPost, webChangeClientAPIKeyAccessPath, bytes.NewBuffer([]byte(form.Encode())))
+	// set an invalid email
+	form.Set("email", "not an email")
+	req, _ = http.NewRequest(http.MethodPost, webClientProfilePath, bytes.NewBuffer([]byte(form.Encode())))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	setJWTCookieForReq(req, token)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	assert.Contains(t, rr.Body.String(), "Validation error: email")
+	// invalid public key
+	form.Set("email", email)
+	form.Set("public_keys", "invalid")
+	req, _ = http.NewRequest(http.MethodPost, webClientProfilePath, bytes.NewBuffer([]byte(form.Encode())))
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 	setJWTCookieForReq(req, token)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
-	assert.Contains(t, rr.Body.String(), "API key authentication updated")
+	assert.Contains(t, rr.Body.String(), "Validation error: could not parse key")
+	// now remove permissions
+	form.Set("public_keys", testPubKey)
+	user.Filters.WebClient = []string{sdk.WebClientAPIKeyAuthChangeDisabled}
+	_, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+	token, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
 
+	form.Set("allow_api_key_auth", "0")
+	form.Set(csrfFormToken, csrfToken)
+	req, _ = http.NewRequest(http.MethodPost, webClientProfilePath, bytes.NewBuffer([]byte(form.Encode())))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	setJWTCookieForReq(req, token)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	assert.Contains(t, rr.Body.String(), "Your profile has been successfully updated")
 	user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
 	assert.NoError(t, err)
-	assert.False(t, user.Filters.AllowAPIKeyAuth)
+	assert.True(t, user.Filters.AllowAPIKeyAuth)
+	assert.Len(t, user.PublicKeys, 1)
+	assert.Equal(t, email, user.Email)
+	assert.Equal(t, description, user.Description)
 
-	user.Filters.WebClient = []string{sdk.WebClientAPIKeyAuthChangeDisabled}
-	user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	user.Filters.WebClient = []string{sdk.WebClientAPIKeyAuthChangeDisabled, sdk.WebClientPubKeyChangeDisabled}
+	_, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+	token, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
+	form.Set("public_keys", testPubKey)
+	form.Add("public_keys", testPubKey1)
+	req, _ = http.NewRequest(http.MethodPost, webClientProfilePath, bytes.NewBuffer([]byte(form.Encode())))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	setJWTCookieForReq(req, token)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	assert.Contains(t, rr.Body.String(), "Your profile has been successfully updated")
+	user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
 	assert.NoError(t, err)
-	assert.False(t, user.CanChangeAPIKeyAuth())
+	assert.True(t, user.Filters.AllowAPIKeyAuth)
+	assert.Len(t, user.PublicKeys, 1)
+	assert.Equal(t, email, user.Email)
+	assert.Equal(t, description, user.Description)
 
-	newToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
+	user.Filters.WebClient = []string{sdk.WebClientAPIKeyAuthChangeDisabled, sdk.WebClientInfoChangeDisabled}
+	_, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
 	assert.NoError(t, err)
-	form = make(url.Values)
-	form.Set("allow_api_key_auth", "1")
-	form.Set(csrfFormToken, csrfToken)
-	req, _ = http.NewRequest(http.MethodPost, webChangeClientAPIKeyAccessPath, bytes.NewBuffer([]byte(form.Encode())))
+	token, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
+	form.Set("email", "newemail@user.com")
+	form.Set("description", "new description")
+	req, _ = http.NewRequest(http.MethodPost, webClientProfilePath, bytes.NewBuffer([]byte(form.Encode())))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	setJWTCookieForReq(req, token)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	assert.Contains(t, rr.Body.String(), "Your profile has been successfully updated")
+	user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
+	assert.NoError(t, err)
+	assert.True(t, user.Filters.AllowAPIKeyAuth)
+	assert.Len(t, user.PublicKeys, 2)
+	assert.Equal(t, email, user.Email)
+	assert.Equal(t, description, user.Description)
+	// finally disable all profile permissions
+	user.Filters.WebClient = []string{sdk.WebClientAPIKeyAuthChangeDisabled, sdk.WebClientInfoChangeDisabled,
+		sdk.WebClientPubKeyChangeDisabled}
+	_, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
+	assert.NoError(t, err)
+	token, err = getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword)
+	assert.NoError(t, err)
+	req, _ = http.NewRequest(http.MethodPost, webClientProfilePath, bytes.NewBuffer([]byte(form.Encode())))
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
-	setJWTCookieForReq(req, newToken)
+	setJWTCookieForReq(req, token)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusForbidden, rr)
 
@@ -9832,14 +9933,14 @@ func TestWebUserAllowAPIKeyAuth(t *testing.T) {
 
 	form = make(url.Values)
 	form.Set(csrfFormToken, csrfToken)
-	req, _ = http.NewRequest(http.MethodPost, webChangeClientAPIKeyAccessPath, bytes.NewBuffer([]byte(form.Encode())))
+	req, _ = http.NewRequest(http.MethodPost, webClientProfilePath, bytes.NewBuffer([]byte(form.Encode())))
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 	setJWTCookieForReq(req, token)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusInternalServerError, rr)
 }
 
-func TestWebAdminAllowAPIKeyAuth(t *testing.T) {
+func TestWebAdminProfile(t *testing.T) {
 	admin := getTestAdmin()
 	admin.Username = altAdminUsername
 	admin.Password = altAdminPassword
@@ -9849,48 +9950,60 @@ func TestWebAdminAllowAPIKeyAuth(t *testing.T) {
 	assert.NoError(t, err)
 	csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
 	assert.NoError(t, err)
+	req, err := http.NewRequest(http.MethodGet, webAdminProfilePath, nil)
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, token)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
 	form := make(url.Values)
 	form.Set("allow_api_key_auth", "1")
+	form.Set("email", "admin@example.com")
+	form.Set("description", "admin desc")
 	// no csrf token
-	req, err := http.NewRequest(http.MethodPost, webChangeAdminAPIKeyAccessPath, bytes.NewBuffer([]byte(form.Encode())))
+	req, err = http.NewRequest(http.MethodPost, webAdminProfilePath, bytes.NewBuffer([]byte(form.Encode())))
 	assert.NoError(t, err)
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 	setJWTCookieForReq(req, token)
-	rr := executeRequest(req)
+	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusForbidden, rr)
 	assert.Contains(t, rr.Body.String(), "unable to verify form token")
 
 	form.Set(csrfFormToken, csrfToken)
-	req, _ = http.NewRequest(http.MethodPost, webChangeAdminAPIKeyAccessPath, bytes.NewBuffer([]byte(form.Encode())))
+	req, _ = http.NewRequest(http.MethodPost, webAdminProfilePath, bytes.NewBuffer([]byte(form.Encode())))
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 	setJWTCookieForReq(req, token)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
-	assert.Contains(t, rr.Body.String(), "API key authentication updated")
+	assert.Contains(t, rr.Body.String(), "Your profile has been successfully updated")
 
 	admin, _, err = httpdtest.GetAdminByUsername(admin.Username, http.StatusOK)
 	assert.NoError(t, err)
 	assert.True(t, admin.Filters.AllowAPIKeyAuth)
+	assert.Equal(t, "admin@example.com", admin.Email)
+	assert.Equal(t, "admin desc", admin.Description)
 
 	form = make(url.Values)
 	form.Set(csrfFormToken, csrfToken)
-	req, _ = http.NewRequest(http.MethodPost, webChangeAdminAPIKeyAccessPath, bytes.NewBuffer([]byte(form.Encode())))
+	req, _ = http.NewRequest(http.MethodPost, webAdminProfilePath, bytes.NewBuffer([]byte(form.Encode())))
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 	setJWTCookieForReq(req, token)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
-	assert.Contains(t, rr.Body.String(), "API key authentication updated")
+	assert.Contains(t, rr.Body.String(), "Your profile has been successfully updated")
 
 	admin, _, err = httpdtest.GetAdminByUsername(admin.Username, http.StatusOK)
 	assert.NoError(t, err)
 	assert.False(t, admin.Filters.AllowAPIKeyAuth)
+	assert.Empty(t, admin.Email)
+	assert.Empty(t, admin.Description)
 
 	_, err = httpdtest.RemoveAdmin(admin, http.StatusOK)
 	assert.NoError(t, err)
 
 	form = make(url.Values)
 	form.Set(csrfFormToken, csrfToken)
-	req, _ = http.NewRequest(http.MethodPost, webChangeAdminAPIKeyAccessPath, bytes.NewBuffer([]byte(form.Encode())))
+	req, _ = http.NewRequest(http.MethodPost, webAdminProfilePath, bytes.NewBuffer([]byte(form.Encode())))
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 	setJWTCookieForReq(req, token)
 	rr = executeRequest(req)
@@ -9908,7 +10021,7 @@ func TestWebAdminPwdChange(t *testing.T) {
 	assert.NoError(t, err)
 	csrfToken, err := getCSRFToken(httpBaseURL + webLoginPath)
 	assert.NoError(t, err)
-	req, err := http.NewRequest(http.MethodGet, webAdminCredentialsPath, nil)
+	req, err := http.NewRequest(http.MethodGet, webChangeAdminPwdPath, nil)
 	assert.NoError(t, err)
 	setJWTCookieForReq(req, token)
 	rr := executeRequest(req)

+ 18 - 33
httpd/internal_test.go

@@ -411,22 +411,22 @@ func TestInvalidToken(t *testing.T) {
 	assert.Contains(t, rr.Body.String(), "Invalid token claims")
 
 	rr = httptest.NewRecorder()
-	getUserAPIKeyAuthStatus(rr, req)
+	getUserProfile(rr, req)
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
 	assert.Contains(t, rr.Body.String(), "Invalid token claims")
 
 	rr = httptest.NewRecorder()
-	changeUserAPIKeyAuthStatus(rr, req)
+	updateUserProfile(rr, req)
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
 	assert.Contains(t, rr.Body.String(), "Invalid token claims")
 
 	rr = httptest.NewRecorder()
-	getAdminAPIKeyAuthStatus(rr, req)
+	getAdminProfile(rr, req)
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
 	assert.Contains(t, rr.Body.String(), "Invalid token claims")
 
 	rr = httptest.NewRecorder()
-	changeAdminAPIKeyAuthStatus(rr, req)
+	updateAdminProfile(rr, req)
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
 	assert.Contains(t, rr.Body.String(), "Invalid token claims")
 
@@ -577,9 +577,8 @@ func TestCreateTokenError(t *testing.T) {
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 	rr = httptest.NewRecorder()
 	handleWebAdminChangePwdPost(rr, req)
-	// the claim is invalid so we fail to render the client page since
-	// we have to load the logged admin
-	assert.Equal(t, http.StatusInternalServerError, rr.Code, rr.Body.String())
+	assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
+	assert.Contains(t, rr.Body.String(), "invalid URL escape")
 
 	req, _ = http.NewRequest(http.MethodGet, webLoginPath+"?a=a%C3%A2%G3", nil)
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@@ -596,24 +595,19 @@ func TestCreateTokenError(t *testing.T) {
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 	rr = httptest.NewRecorder()
 	handleWebClientChangePwdPost(rr, req)
-	assert.Equal(t, http.StatusInternalServerError, rr.Code, rr.Body.String())
-
-	req, _ = http.NewRequest(http.MethodPost, webChangeClientKeysPath+"?a=a%C3%AO%GB", bytes.NewBuffer([]byte(form.Encode())))
-	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
-	rr = httptest.NewRecorder()
-	handleWebClientManageKeysPost(rr, req)
-	assert.Equal(t, http.StatusInternalServerError, rr.Code, rr.Body.String())
+	assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
+	assert.Contains(t, rr.Body.String(), "invalid URL escape")
 
-	req, _ = http.NewRequest(http.MethodPost, webChangeClientAPIKeyAccessPath+"?a=a%C3%AO%GA", bytes.NewBuffer([]byte(form.Encode())))
+	req, _ = http.NewRequest(http.MethodPost, webClientProfilePath+"?a=a%C3%AO%GB", bytes.NewBuffer([]byte(form.Encode())))
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 	rr = httptest.NewRecorder()
-	handleWebClientManageAPIKeyPost(rr, req)
+	handleWebClientProfilePost(rr, req)
 	assert.Equal(t, http.StatusInternalServerError, rr.Code, rr.Body.String())
 
-	req, _ = http.NewRequest(http.MethodPost, webChangeAdminAPIKeyAccessPath+"?a=a%C3%AO%GB", bytes.NewBuffer([]byte(form.Encode())))
+	req, _ = http.NewRequest(http.MethodPost, webAdminProfilePath+"?a=a%C3%AO%GB", bytes.NewBuffer([]byte(form.Encode())))
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 	rr = httptest.NewRecorder()
-	handleWebAdminManageAPIKeyPost(rr, req)
+	handleWebAdminProfilePost(rr, req)
 	assert.Equal(t, http.StatusInternalServerError, rr.Code, rr.Body.String())
 
 	req, _ = http.NewRequest(http.MethodPost, webAdminTwoFactorPath+"?a=a%C3%AO%GC", bytes.NewBuffer([]byte(form.Encode())))
@@ -754,8 +748,8 @@ func TestJWTTokenValidation(t *testing.T) {
 	permClientFn := checkHTTPUserPerm(sdk.WebClientPubKeyChangeDisabled)
 	fn = permClientFn(r)
 	rr = httptest.NewRecorder()
-	req, _ = http.NewRequest(http.MethodPost, webChangeClientKeysPath, nil)
-	req.RequestURI = webChangeClientKeysPath
+	req, _ = http.NewRequest(http.MethodPost, webClientProfilePath, nil)
+	req.RequestURI = webClientProfilePath
 	ctx = jwtauth.NewContext(req.Context(), token, errTest)
 	fn.ServeHTTP(rr, req.WithContext(ctx))
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
@@ -1745,19 +1739,10 @@ func TestInvalidClaims(t *testing.T) {
 	form := make(url.Values)
 	form.Set(csrfFormToken, createCSRFToken())
 	form.Set("public_keys", "")
-	req, _ := http.NewRequest(http.MethodPost, webChangeClientKeysPath, bytes.NewBuffer([]byte(form.Encode())))
-	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
-	req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"]))
-	handleWebClientManageKeysPost(rr, req)
-	assert.Equal(t, http.StatusInternalServerError, rr.Code)
-
-	form = make(url.Values)
-	form.Set(csrfFormToken, createCSRFToken())
-	form.Set("allow_api_key_auth", "")
-	req, _ = http.NewRequest(http.MethodPost, webChangeClientKeysPath, bytes.NewBuffer([]byte(form.Encode())))
+	req, _ := http.NewRequest(http.MethodPost, webClientProfilePath, bytes.NewBuffer([]byte(form.Encode())))
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 	req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"]))
-	handleWebClientManageAPIKeyPost(rr, req)
+	handleWebClientProfilePost(rr, req)
 	assert.Equal(t, http.StatusInternalServerError, rr.Code)
 
 	admin := dataprovider.Admin{
@@ -1774,10 +1759,10 @@ func TestInvalidClaims(t *testing.T) {
 	form = make(url.Values)
 	form.Set(csrfFormToken, createCSRFToken())
 	form.Set("allow_api_key_auth", "")
-	req, _ = http.NewRequest(http.MethodPost, webChangeClientKeysPath, bytes.NewBuffer([]byte(form.Encode())))
+	req, _ = http.NewRequest(http.MethodPost, webAdminProfilePath, bytes.NewBuffer([]byte(form.Encode())))
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 	req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"]))
-	handleWebAdminManageAPIKeyPost(rr, req)
+	handleWebAdminProfilePost(rr, req)
 	assert.Equal(t, http.StatusInternalServerError, rr.Code)
 }
 

+ 52 - 32
httpd/schema/openapi.yaml

@@ -243,25 +243,22 @@ paths:
           $ref: '#/components/responses/InternalServerError'
         default:
           $ref: '#/components/responses/DefaultResponse'
-  /admin/apikeyauth:
+  /admin/profile:
     get:
       security:
         - BearerAuth: []
       tags:
         - admins
-      summary: Get API key authentication status
-      description: 'Returns the API Key authentication status for the logged in admin'
-      operationId: get_admin_api_key_status
+      summary: Get profile
+      description: 'Returns the profile for the logged in admin'
+      operationId: get_admin_profile
       responses:
         '200':
           description: successful operation
           content:
             application/json:
               schema:
-                type: object
-                properties:
-                  allow_api_key_auth:
-                    type: boolean
+                $ref: '#/components/schemas/AdminProfile'
         '401':
           $ref: '#/components/responses/Unauthorized'
         '403':
@@ -275,18 +272,15 @@ paths:
         - BearerAuth: []
       tags:
         - admins
-      summary: Update API key auth status
-      description: 'Allows to enable/disable the API key authentication for the logged in admin. If enabled, you can impersonate this admin, in REST API, using an API key, otherwise your credentials, including two-factor authentication, if enabled, are required to use the REST API on your behalf'
-      operationId: update_admin_api_key_status
+      summary: Update admin profile
+      description: 'Allows to update the profile for the logged in admin'
+      operationId: update_admin_profile
       requestBody:
         required: true
         content:
           application/json:
             schema:
-                type: object
-                properties:
-                  allow_api_key_auth:
-                    type: boolean
+              $ref: '#/components/schemas/AdminProfile'
       responses:
         '200':
           description: successful operation
@@ -2338,8 +2332,9 @@ paths:
         - BearerAuth: []
       tags:
         - users API
+      deprecated: true
       summary: Get the user's public keys
-      description: Returns the public keys for the logged in user
+      description: 'Returns the public keys for the logged in user. Deprecated please use "/user/profile" instead'
       operationId: get_user_public_keys
       responses:
         '200':
@@ -2365,8 +2360,9 @@ paths:
         - BearerAuth: []
       tags:
         - users API
+      deprecated: true
       summary: Set the user's public keys
-      description: Sets the public keys for the logged in user. Public keys must be in OpenSSH format
+      description: 'Sets the public keys for the logged in user. Public keys must be in OpenSSH format. Deprecated please use "/user/profile" instead'
       operationId: set_user_public_keys
       requestBody:
         required: true
@@ -2395,25 +2391,22 @@ paths:
           $ref: '#/components/responses/InternalServerError'
         default:
           $ref: '#/components/responses/DefaultResponse'
-  /user/apikeyauth:
+  /user/profile:
     get:
       security:
         - BearerAuth: []
       tags:
         - users API
-      summary: Get API key authentication status
-      description: 'Returns the API Key authentication status for the logged in user'
-      operationId: get_user_api_key_status
+      summary: Get user profile
+      description: 'Returns the profile for the logged in user'
+      operationId: get_user_profile
       responses:
         '200':
           description: successful operation
           content:
             application/json:
               schema:
-                type: object
-                properties:
-                  allow_api_key_auth:
-                    type: boolean
+                $ref: '#/components/schemas/UserProfile'
         '401':
           $ref: '#/components/responses/Unauthorized'
         '403':
@@ -2427,18 +2420,15 @@ paths:
         - BearerAuth: []
       tags:
         - users API
-      summary: Update API key auth status
-      description: 'Allows to enable/disable the API key authentication for the logged in user. If enabled, you can impersonate this user, in REST API, using an API key, otherwise your credentials, including two-factor authentication, if enabled, are required to use the REST API on your behalf'
-      operationId: update_user_api_key_status
+      summary: Update profile
+      description: 'Allows to update the profile for the logged in user'
+      operationId: update_user_profile
       requestBody:
         required: true
         content:
           application/json:
             schema:
-                type: object
-                properties:
-                  allow_api_key_auth:
-                    type: boolean
+              $ref: '#/components/schemas/UserProfile'
       responses:
         '200':
           description: successful operation
@@ -3224,6 +3214,7 @@ components:
         - mfa-disabled
         - password-change-disabled
         - api-key-auth-change-disabled
+        - info-change-disabled
       description: |
         Options:
           * `publickey-change-disabled` - changing SSH public keys is not allowed
@@ -3231,6 +3222,7 @@ components:
           * `mfa-disabled` - enabling multi-factor authentication is not allowed. This option cannot be set if the user has MFA already enabled
           * `password-change-disabled` - changing password is not allowed
           * `api-key-auth-change-disabled` - enabling/disabling API key authentication is not allowed
+          * `info-change-disabled` - changing info such as email and description is not allowed
     APIKeyScope:
       type: integer
       enum:
@@ -3830,6 +3822,34 @@ components:
           type: integer
           format: int64
           description: Last user login as unix timestamp in milliseconds. It is saved at most once every 10 minutes
+    AdminProfile:
+      type: object
+      properties:
+        email:
+          type: string
+          format: email
+        description:
+          type: string
+        allow_api_key_auth:
+          type: boolean
+          description: 'If enabled, you can impersonate this admin, in REST API, using an API key. If disabled admin credentials are required for impersonation'
+    UserProfile:
+      type: object
+      properties:
+        email:
+          type: string
+          format: email
+        description:
+          type: string
+        allow_api_key_auth:
+          type: boolean
+          description: 'If enabled, you can impersonate this user, in REST API, using an API key. If disabled user credentials are required for impersonation'
+        public_keys:
+          type: array
+          items:
+            type: string
+            example: ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEUWwDwEWhTbF0MqAsp/oXK1HR2cElhM8oo1uVmL3ZeDKDiTm4ljMr92wfTgIGDqIoxmVqgYIkAOAhuykAVWBzc= user@host
+            description: Public keys in OpenSSH format
     APIKey:
       type: object
       properties:

+ 12 - 12
httpd/server.go

@@ -909,8 +909,8 @@ func (s *httpdServer) initializeRouter() {
 		})
 
 		router.With(forbidAPIKeyAuthentication).Get(logoutPath, s.logout)
-		router.With(forbidAPIKeyAuthentication).Get(adminManageAPIKeyPath, getAdminAPIKeyAuthStatus)
-		router.With(forbidAPIKeyAuthentication).Put(adminManageAPIKeyPath, changeAdminAPIKeyAuthStatus)
+		router.With(forbidAPIKeyAuthentication).Get(adminProfilePath, getAdminProfile)
+		router.With(forbidAPIKeyAuthentication).Put(adminProfilePath, updateAdminProfile)
 		router.With(forbidAPIKeyAuthentication).Put(adminPwdPath, changeAdminPassword)
 		// compatibility layer to remove in v2.2
 		router.With(forbidAPIKeyAuthentication).Put(adminPwdCompatPath, changeAdminPassword)
@@ -1003,9 +1003,8 @@ func (s *httpdServer) initializeRouter() {
 			Get(userPublicKeysPath, getUserPublicKeys)
 		router.With(forbidAPIKeyAuthentication, checkHTTPUserPerm(sdk.WebClientPubKeyChangeDisabled)).
 			Put(userPublicKeysPath, setUserPublicKeys)
-		router.With(forbidAPIKeyAuthentication).Get(userManageAPIKeyPath, getUserAPIKeyAuthStatus)
-		router.With(forbidAPIKeyAuthentication, checkHTTPUserPerm(sdk.WebClientAPIKeyAuthChangeDisabled)).
-			Put(userManageAPIKeyPath, changeUserAPIKeyAuthStatus)
+		router.With(forbidAPIKeyAuthentication).Get(userProfilePath, getUserProfile)
+		router.With(forbidAPIKeyAuthentication).Put(userProfilePath, updateUserProfile)
 		// user TOTP APIs
 		router.With(forbidAPIKeyAuthentication, checkHTTPUserPerm(sdk.WebClientMFADisabled)).
 			Get(userTOTPConfigsPath, getTOTPConfigs)
@@ -1101,13 +1100,12 @@ func (s *httpdServer) initializeRouter() {
 			router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled), verifyCSRFHeader).
 				Delete(webClientDirsPath, deleteUserDir)
 			router.With(s.refreshCookie).Get(webClientDownloadZipPath, handleWebClientDownloadZip)
-			router.With(s.refreshCookie).Get(webClientCredentialsPath, handleClientGetCredentials)
+			router.With(s.refreshCookie).Get(webClientProfilePath, handleClientGetProfile)
+			router.Post(webClientProfilePath, handleWebClientProfilePost)
+			router.With(checkHTTPUserPerm(sdk.WebClientPasswordChangeDisabled)).
+				Get(webChangeClientPwdPath, handleWebClientChangePwd)
 			router.With(checkHTTPUserPerm(sdk.WebClientPasswordChangeDisabled)).
 				Post(webChangeClientPwdPath, handleWebClientChangePwdPost)
-			router.With(checkHTTPUserPerm(sdk.WebClientAPIKeyAuthChangeDisabled)).
-				Post(webChangeClientAPIKeyAccessPath, handleWebClientManageAPIKeyPost)
-			router.With(checkHTTPUserPerm(sdk.WebClientPubKeyChangeDisabled)).
-				Post(webChangeClientKeysPath, handleWebClientManageKeysPost)
 			router.With(checkHTTPUserPerm(sdk.WebClientMFADisabled), s.refreshCookie).
 				Get(webClientMFAPath, handleWebClientMFA)
 			router.With(checkHTTPUserPerm(sdk.WebClientMFADisabled), verifyCSRFHeader).
@@ -1150,9 +1148,11 @@ func (s *httpdServer) initializeRouter() {
 			router.Use(jwtAuthenticatorWebAdmin)
 
 			router.Get(webLogoutPath, handleWebLogout)
-			router.With(s.refreshCookie).Get(webAdminCredentialsPath, handleWebAdminCredentials)
+			router.With(s.refreshCookie).Get(webAdminProfilePath, handleWebAdminProfile)
+			router.Post(webAdminProfilePath, handleWebAdminProfilePost)
+			router.With(s.refreshCookie).Get(webChangeAdminPwdPath, handleWebAdminChangePwd)
 			router.Post(webChangeAdminPwdPath, handleWebAdminChangePwdPost)
-			router.Post(webChangeAdminAPIKeyAccessPath, handleWebAdminManageAPIKeyPost)
+
 			router.With(s.refreshCookie).Get(webAdminMFAPath, handleWebAdminMFA)
 			router.With(verifyCSRFHeader).Post(webAdminTOTPGeneratePath, generateTOTPSecret)
 			router.With(verifyCSRFHeader).Post(webAdminTOTPValidatePath, validateTOTPPasscode)

+ 60 - 31
httpd/webadmin.go

@@ -56,7 +56,8 @@ const (
 	templateStatus       = "status.html"
 	templateLogin        = "login.html"
 	templateDefender     = "defender.html"
-	templateCredentials  = "credentials.html"
+	templateProfile      = "profile.html"
+	templateChangePwd    = "changepassword.html"
 	templateMaintenance  = "maintenance.html"
 	templateMFA          = "mfa.html"
 	templateSetup        = "adminsetup.html"
@@ -65,7 +66,8 @@ const (
 	pageConnectionsTitle = "Connections"
 	pageStatusTitle      = "Status"
 	pageFoldersTitle     = "Folders"
-	pageCredentialsTitle = "Manage credentials"
+	pageProfileTitle     = "My profile"
+	pageChangePwdTitle   = "Change password"
 	pageMaintenanceTitle = "Maintenance"
 	pageDefenderTitle    = "Defender"
 	pageSetupTitle       = "Create first admin user"
@@ -91,7 +93,8 @@ type basePage struct {
 	FolderTemplateURL  string
 	DefenderURL        string
 	LogoutURL          string
-	CredentialsURL     string
+	ProfileURL         string
+	ChangePwdURL       string
 	MFAURL             string
 	FolderQuotaScanURL string
 	StatusURL          string
@@ -157,13 +160,17 @@ type adminPage struct {
 	IsAdd bool
 }
 
-type credentialsPage struct {
+type profilePage struct {
 	basePage
 	Error           string
 	AllowAPIKeyAuth bool
-	ChangePwdURL    string
-	ManageAPIKeyURL string
-	APIKeyError     string
+	Email           string
+	Description     string
+}
+
+type changePasswordPage struct {
+	basePage
+	Error string
 }
 
 type mfaPage struct {
@@ -231,9 +238,13 @@ func loadAdminTemplates(templatesPath string) {
 		filepath.Join(templatesPath, templateAdminDir, templateBase),
 		filepath.Join(templatesPath, templateAdminDir, templateAdmin),
 	}
-	credentialsPaths := []string{
+	profilePaths := []string{
 		filepath.Join(templatesPath, templateAdminDir, templateBase),
-		filepath.Join(templatesPath, templateAdminDir, templateCredentials),
+		filepath.Join(templatesPath, templateAdminDir, templateProfile),
+	}
+	changePwdPaths := []string{
+		filepath.Join(templatesPath, templateAdminDir, templateBase),
+		filepath.Join(templatesPath, templateAdminDir, templateChangePwd),
 	}
 	connectionsPaths := []string{
 		filepath.Join(templatesPath, templateAdminDir, templateBase),
@@ -298,7 +309,8 @@ func loadAdminTemplates(templatesPath string) {
 	folderTmpl := util.LoadTemplate(rootTpl, folderPath...)
 	statusTmpl := util.LoadTemplate(rootTpl, statusPath...)
 	loginTmpl := util.LoadTemplate(rootTpl, loginPath...)
-	credentialsTmpl := util.LoadTemplate(rootTpl, credentialsPaths...)
+	profileTmpl := util.LoadTemplate(rootTpl, profilePaths...)
+	changePwdTmpl := util.LoadTemplate(rootTpl, changePwdPaths...)
 	maintenanceTmpl := util.LoadTemplate(rootTpl, maintenancePath...)
 	defenderTmpl := util.LoadTemplate(rootTpl, defenderPath...)
 	mfaTmpl := util.LoadTemplate(nil, mfaPath...)
@@ -316,7 +328,8 @@ func loadAdminTemplates(templatesPath string) {
 	adminTemplates[templateFolder] = folderTmpl
 	adminTemplates[templateStatus] = statusTmpl
 	adminTemplates[templateLogin] = loginTmpl
-	adminTemplates[templateCredentials] = credentialsTmpl
+	adminTemplates[templateProfile] = profileTmpl
+	adminTemplates[templateChangePwd] = changePwdTmpl
 	adminTemplates[templateMaintenance] = maintenanceTmpl
 	adminTemplates[templateDefender] = defenderTmpl
 	adminTemplates[templateMFA] = mfaTmpl
@@ -343,7 +356,8 @@ func getBasePageData(title, currentURL string, r *http.Request) basePage {
 		FolderTemplateURL:  webTemplateFolder,
 		DefenderURL:        webDefenderPath,
 		LogoutURL:          webLogoutPath,
-		CredentialsURL:     webAdminCredentialsPath,
+		ProfileURL:         webAdminProfilePath,
+		ChangePwdURL:       webChangeAdminPwdPath,
 		MFAURL:             webAdminMFAPath,
 		QuotaScanURL:       webQuotaScanPath,
 		ConnectionsURL:     webConnectionsPath,
@@ -446,13 +460,10 @@ func renderMFAPage(w http.ResponseWriter, r *http.Request) {
 	renderAdminTemplate(w, templateMFA, data)
 }
 
-func renderCredentialsPage(w http.ResponseWriter, r *http.Request, pwdError, apiKeyError string) {
-	data := credentialsPage{
-		basePage:        getBasePageData(pageCredentialsTitle, webAdminCredentialsPath, r),
-		ChangePwdURL:    webChangeAdminPwdPath,
-		ManageAPIKeyURL: webChangeAdminAPIKeyAccessPath,
-		Error:           pwdError,
-		APIKeyError:     apiKeyError,
+func renderProfilePage(w http.ResponseWriter, r *http.Request, error string) {
+	data := profilePage{
+		basePage: getBasePageData(pageProfileTitle, webAdminProfilePath, r),
+		Error:    error,
 	}
 	admin, err := dataprovider.AdminExists(data.LoggedAdmin.Username)
 	if err != nil {
@@ -460,8 +471,19 @@ func renderCredentialsPage(w http.ResponseWriter, r *http.Request, pwdError, api
 		return
 	}
 	data.AllowAPIKeyAuth = admin.Filters.AllowAPIKeyAuth
+	data.Email = admin.Email
+	data.Description = admin.Description
 
-	renderAdminTemplate(w, templateCredentials, data)
+	renderAdminTemplate(w, templateProfile, data)
+}
+
+func renderChangePasswordPage(w http.ResponseWriter, r *http.Request, error string) {
+	data := changePasswordPage{
+		basePage: getBasePageData(pageChangePwdTitle, webChangeAdminPwdPath, r),
+		Error:    error,
+	}
+
+	renderAdminTemplate(w, templateChangePwd, data)
 }
 
 func renderMaintenancePage(w http.ResponseWriter, r *http.Request, error string) {
@@ -1125,16 +1147,21 @@ func handleWebAdminMFA(w http.ResponseWriter, r *http.Request) {
 	renderMFAPage(w, r)
 }
 
-func handleWebAdminCredentials(w http.ResponseWriter, r *http.Request) {
+func handleWebAdminProfile(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	renderProfilePage(w, r, "")
+}
+
+func handleWebAdminChangePwd(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
-	renderCredentialsPage(w, r, "", "")
+	renderChangePasswordPage(w, r, "")
 }
 
 func handleWebAdminChangePwdPost(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	err := r.ParseForm()
 	if err != nil {
-		renderCredentialsPage(w, r, err.Error(), "")
+		renderChangePasswordPage(w, r, err.Error())
 		return
 	}
 	if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil {
@@ -1144,17 +1171,17 @@ func handleWebAdminChangePwdPost(w http.ResponseWriter, r *http.Request) {
 	err = doChangeAdminPassword(r, r.Form.Get("current_password"), r.Form.Get("new_password1"),
 		r.Form.Get("new_password2"))
 	if err != nil {
-		renderCredentialsPage(w, r, err.Error(), "")
+		renderChangePasswordPage(w, r, err.Error())
 		return
 	}
 	handleWebLogout(w, r)
 }
 
-func handleWebAdminManageAPIKeyPost(w http.ResponseWriter, r *http.Request) {
+func handleWebAdminProfilePost(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	err := r.ParseForm()
 	if err != nil {
-		renderCredentialsPage(w, r, err.Error(), "")
+		renderProfilePage(w, r, err.Error())
 		return
 	}
 	if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil {
@@ -1163,22 +1190,24 @@ func handleWebAdminManageAPIKeyPost(w http.ResponseWriter, r *http.Request) {
 	}
 	claims, err := getTokenClaims(r)
 	if err != nil || claims.Username == "" {
-		renderCredentialsPage(w, r, "", "Invalid token claims")
+		renderProfilePage(w, r, "Invalid token claims")
 		return
 	}
 	admin, err := dataprovider.AdminExists(claims.Username)
 	if err != nil {
-		renderCredentialsPage(w, r, "", err.Error())
+		renderProfilePage(w, r, err.Error())
 		return
 	}
 	admin.Filters.AllowAPIKeyAuth = len(r.Form.Get("allow_api_key_auth")) > 0
+	admin.Email = r.Form.Get("email")
+	admin.Description = r.Form.Get("description")
 	err = dataprovider.UpdateAdmin(&admin)
 	if err != nil {
-		renderCredentialsPage(w, r, "", err.Error())
+		renderProfilePage(w, r, err.Error())
 		return
 	}
-	renderMessagePage(w, r, "API key authentication updated", "", http.StatusOK, nil,
-		"Your API key access permission has been successfully updated")
+	renderMessagePage(w, r, "Profile updated", "", http.StatusOK, nil,
+		"Your profile has been successfully updated")
 }
 
 func handleWebLogout(w http.ResponseWriter, r *http.Request) {

+ 95 - 87
httpd/webclient.go

@@ -30,12 +30,15 @@ const (
 	templateClientLogin             = "login.html"
 	templateClientFiles             = "files.html"
 	templateClientMessage           = "message.html"
-	templateClientCredentials       = "credentials.html"
+	templateClientProfile           = "profile.html"
+	templateClientChangePwd         = "changepassword.html"
 	templateClientTwoFactor         = "twofactor.html"
 	templateClientTwoFactorRecovery = "twofactor-recovery.html"
 	templateClientMFA               = "mfa.html"
 	pageClientFilesTitle            = "My Files"
-	pageClientCredentialsTitle      = "Credentials"
+	pageClientProfileTitle          = "My Profile"
+	pageClientChangePwdTitle        = "Change password"
+	pageClient2FATitle              = "Two-factor auth"
 )
 
 // condResult is the result of an HTTP request precondition check.
@@ -59,19 +62,20 @@ func isZeroTime(t time.Time) bool {
 }
 
 type baseClientPage struct {
-	Title            string
-	CurrentURL       string
-	FilesURL         string
-	CredentialsURL   string
-	StaticURL        string
-	LogoutURL        string
-	MFAURL           string
-	MFATitle         string
-	FilesTitle       string
-	CredentialsTitle string
-	Version          string
-	CSRFToken        string
-	LoggedUser       *dataprovider.User
+	Title        string
+	CurrentURL   string
+	FilesURL     string
+	ProfileURL   string
+	ChangePwdURL string
+	StaticURL    string
+	LogoutURL    string
+	MFAURL       string
+	MFATitle     string
+	FilesTitle   string
+	ProfileTitle string
+	Version      string
+	CSRFToken    string
+	LoggedUser   *dataprovider.User
 }
 
 type dirMapping struct {
@@ -99,16 +103,19 @@ type clientMessagePage struct {
 	Success string
 }
 
-type clientCredentialsPage struct {
+type clientProfilePage struct {
 	baseClientPage
 	PublicKeys      []string
+	CanSubmit       bool
 	AllowAPIKeyAuth bool
-	ChangePwdURL    string
-	ManageKeysURL   string
-	ManageAPIKeyURL string
-	PwdError        string
-	KeyError        string
-	APIKeyError     string
+	Email           string
+	Description     string
+	Error           string
+}
+
+type changeClientPasswordPage struct {
+	baseClientPage
+	Error string
 }
 
 type clientMFAPage struct {
@@ -138,9 +145,13 @@ func loadClientTemplates(templatesPath string) {
 		filepath.Join(templatesPath, templateClientDir, templateClientBase),
 		filepath.Join(templatesPath, templateClientDir, templateClientFiles),
 	}
-	credentialsPaths := []string{
+	profilePaths := []string{
+		filepath.Join(templatesPath, templateClientDir, templateClientBase),
+		filepath.Join(templatesPath, templateClientDir, templateClientProfile),
+	}
+	changePwdPaths := []string{
 		filepath.Join(templatesPath, templateClientDir, templateClientBase),
-		filepath.Join(templatesPath, templateClientDir, templateClientCredentials),
+		filepath.Join(templatesPath, templateClientDir, templateClientChangePwd),
 	}
 	loginPath := []string{
 		filepath.Join(templatesPath, templateClientDir, templateClientBaseLogin),
@@ -164,7 +175,8 @@ func loadClientTemplates(templatesPath string) {
 	}
 
 	filesTmpl := util.LoadTemplate(nil, filesPaths...)
-	credentialsTmpl := util.LoadTemplate(nil, credentialsPaths...)
+	profileTmpl := util.LoadTemplate(nil, profilePaths...)
+	changePwdTmpl := util.LoadTemplate(nil, changePwdPaths...)
 	loginTmpl := util.LoadTemplate(nil, loginPath...)
 	messageTmpl := util.LoadTemplate(nil, messagePath...)
 	mfaTmpl := util.LoadTemplate(nil, mfaPath...)
@@ -172,7 +184,8 @@ func loadClientTemplates(templatesPath string) {
 	twoFactorRecoveryTmpl := util.LoadTemplate(nil, twoFactorRecoveryPath...)
 
 	clientTemplates[templateClientFiles] = filesTmpl
-	clientTemplates[templateClientCredentials] = credentialsTmpl
+	clientTemplates[templateClientProfile] = profileTmpl
+	clientTemplates[templateClientChangePwd] = changePwdTmpl
 	clientTemplates[templateClientLogin] = loginTmpl
 	clientTemplates[templateClientMessage] = messageTmpl
 	clientTemplates[templateClientMFA] = mfaTmpl
@@ -188,19 +201,20 @@ func getBaseClientPageData(title, currentURL string, r *http.Request) baseClient
 	v := version.Get()
 
 	return baseClientPage{
-		Title:            title,
-		CurrentURL:       currentURL,
-		FilesURL:         webClientFilesPath,
-		CredentialsURL:   webClientCredentialsPath,
-		StaticURL:        webStaticFilesPath,
-		LogoutURL:        webClientLogoutPath,
-		MFAURL:           webClientMFAPath,
-		MFATitle:         "Two-factor auth",
-		FilesTitle:       pageClientFilesTitle,
-		CredentialsTitle: pageClientCredentialsTitle,
-		Version:          fmt.Sprintf("%v-%v", v.Version, v.CommitHash),
-		CSRFToken:        csrfToken,
-		LoggedUser:       getUserFromToken(r),
+		Title:        title,
+		CurrentURL:   currentURL,
+		FilesURL:     webClientFilesPath,
+		ProfileURL:   webClientProfilePath,
+		ChangePwdURL: webChangeClientPwdPath,
+		StaticURL:    webStaticFilesPath,
+		LogoutURL:    webClientLogoutPath,
+		MFAURL:       webClientMFAPath,
+		MFATitle:     pageClient2FATitle,
+		FilesTitle:   pageClientFilesTitle,
+		ProfileTitle: pageClientProfileTitle,
+		Version:      fmt.Sprintf("%v-%v", v.Version, v.CommitHash),
+		CSRFToken:    csrfToken,
+		LoggedUser:   getUserFromToken(r),
 	}
 }
 
@@ -320,15 +334,10 @@ func renderFilesPage(w http.ResponseWriter, r *http.Request, dirName, error stri
 	renderClientTemplate(w, templateClientFiles, data)
 }
 
-func renderClientCredentialsPage(w http.ResponseWriter, r *http.Request, pwdError, keyError, apiKeyError string) {
-	data := clientCredentialsPage{
-		baseClientPage:  getBaseClientPageData(pageClientCredentialsTitle, webClientCredentialsPath, r),
-		ChangePwdURL:    webChangeClientPwdPath,
-		ManageKeysURL:   webChangeClientKeysPath,
-		ManageAPIKeyURL: webChangeClientAPIKeyAccessPath,
-		PwdError:        pwdError,
-		KeyError:        keyError,
-		APIKeyError:     apiKeyError,
+func renderClientProfilePage(w http.ResponseWriter, r *http.Request, error string) {
+	data := clientProfilePage{
+		baseClientPage: getBaseClientPageData(pageClientProfileTitle, webClientProfilePath, r),
+		Error:          error,
 	}
 	user, err := dataprovider.UserExists(data.LoggedUser.Username)
 	if err != nil {
@@ -337,7 +346,19 @@ func renderClientCredentialsPage(w http.ResponseWriter, r *http.Request, pwdErro
 	}
 	data.PublicKeys = user.PublicKeys
 	data.AllowAPIKeyAuth = user.Filters.AllowAPIKeyAuth
-	renderClientTemplate(w, templateClientCredentials, data)
+	data.Email = user.Email
+	data.Description = user.Description
+	data.CanSubmit = user.CanChangeAPIKeyAuth() || user.CanManagePublicKeys() || user.CanChangeInfo()
+	renderClientTemplate(w, templateClientProfile, data)
+}
+
+func renderClientChangePasswordPage(w http.ResponseWriter, r *http.Request, error string) {
+	data := changeClientPasswordPage{
+		baseClientPage: getBaseClientPageData(pageClientChangePwdTitle, webChangeClientPwdPath, r),
+		Error:          error,
+	}
+
+	renderClientTemplate(w, templateClientChangePwd, data)
 }
 
 func handleWebClientLogout(w http.ResponseWriter, r *http.Request) {
@@ -513,16 +534,21 @@ func handleClientGetFiles(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
-func handleClientGetCredentials(w http.ResponseWriter, r *http.Request) {
+func handleClientGetProfile(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	renderClientProfilePage(w, r, "")
+}
+
+func handleWebClientChangePwd(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
-	renderClientCredentialsPage(w, r, "", "", "")
+	renderClientChangePasswordPage(w, r, "")
 }
 
 func handleWebClientChangePwdPost(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	err := r.ParseForm()
 	if err != nil {
-		renderClientCredentialsPage(w, r, err.Error(), "", "")
+		renderClientChangePasswordPage(w, r, err.Error())
 		return
 	}
 	if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil {
@@ -532,17 +558,17 @@ func handleWebClientChangePwdPost(w http.ResponseWriter, r *http.Request) {
 	err = doChangeUserPassword(r, r.Form.Get("current_password"), r.Form.Get("new_password1"),
 		r.Form.Get("new_password2"))
 	if err != nil {
-		renderClientCredentialsPage(w, r, err.Error(), "", "")
+		renderClientChangePasswordPage(w, r, err.Error())
 		return
 	}
 	handleWebClientLogout(w, r)
 }
 
-func handleWebClientManageKeysPost(w http.ResponseWriter, r *http.Request) {
+func handleWebClientProfilePost(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	err := r.ParseForm()
 	if err != nil {
-		renderClientCredentialsPage(w, r, "", err.Error(), "")
+		renderClientProfilePage(w, r, err.Error())
 		return
 	}
 	if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil {
@@ -551,53 +577,35 @@ func handleWebClientManageKeysPost(w http.ResponseWriter, r *http.Request) {
 	}
 	claims, err := getTokenClaims(r)
 	if err != nil || claims.Username == "" {
-		renderClientCredentialsPage(w, r, "", "Invalid token claims", "")
+		renderClientProfilePage(w, r, "Invalid token claims")
 		return
 	}
 	user, err := dataprovider.UserExists(claims.Username)
 	if err != nil {
-		renderClientCredentialsPage(w, r, "", err.Error(), "")
-		return
-	}
-	user.PublicKeys = r.Form["public_keys"]
-	err = dataprovider.UpdateUser(&user)
-	if err != nil {
-		renderClientCredentialsPage(w, r, "", err.Error(), "")
+		renderClientProfilePage(w, r, err.Error())
 		return
 	}
-	renderClientMessagePage(w, r, "Public keys updated", "", http.StatusOK, nil,
-		"Your public keys has been successfully updated")
-}
-
-func handleWebClientManageAPIKeyPost(w http.ResponseWriter, r *http.Request) {
-	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
-	err := r.ParseForm()
-	if err != nil {
-		renderClientCredentialsPage(w, r, "", "", err.Error())
+	if !user.CanManagePublicKeys() && !user.CanChangeAPIKeyAuth() && !user.CanChangeInfo() {
+		renderClientForbiddenPage(w, r, "You are not allowed to change anything")
 		return
 	}
-	if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil {
-		renderClientForbiddenPage(w, r, err.Error())
-		return
+	if user.CanManagePublicKeys() {
+		user.PublicKeys = r.Form["public_keys"]
 	}
-	claims, err := getTokenClaims(r)
-	if err != nil || claims.Username == "" {
-		renderClientCredentialsPage(w, r, "", "", "Invalid token claims")
-		return
+	if user.CanChangeAPIKeyAuth() {
+		user.Filters.AllowAPIKeyAuth = len(r.Form.Get("allow_api_key_auth")) > 0
 	}
-	user, err := dataprovider.UserExists(claims.Username)
-	if err != nil {
-		renderClientCredentialsPage(w, r, "", "", err.Error())
-		return
+	if user.CanChangeInfo() {
+		user.Email = r.Form.Get("email")
+		user.Description = r.Form.Get("description")
 	}
-	user.Filters.AllowAPIKeyAuth = len(r.Form.Get("allow_api_key_auth")) > 0
 	err = dataprovider.UpdateUser(&user)
 	if err != nil {
-		renderClientCredentialsPage(w, r, "", "", err.Error())
+		renderClientProfilePage(w, r, err.Error())
 		return
 	}
-	renderClientMessagePage(w, r, "API key authentication updated", "", http.StatusOK, nil,
-		"Your API key access permission has been successfully updated")
+	renderClientMessagePage(w, r, "Profile updated", "", http.StatusOK, nil,
+		"Your profile has been successfully updated")
 }
 
 func handleWebClientMFA(w http.ResponseWriter, r *http.Request) {

+ 3 - 2
sdk/user.go

@@ -14,12 +14,13 @@ const (
 	WebClientMFADisabled              = "mfa-disabled"
 	WebClientPasswordChangeDisabled   = "password-change-disabled"
 	WebClientAPIKeyAuthChangeDisabled = "api-key-auth-change-disabled"
+	WebClientInfoChangeDisabled       = "info-change-disabled"
 )
 
 var (
 	// WebClientOptions defines the available options for the web client interface/user REST API
-	WebClientOptions = []string{WebClientPubKeyChangeDisabled, WebClientWriteDisabled, WebClientMFADisabled,
-		WebClientPasswordChangeDisabled, WebClientAPIKeyAuthChangeDisabled}
+	WebClientOptions = []string{WebClientWriteDisabled, WebClientPasswordChangeDisabled, WebClientPubKeyChangeDisabled,
+		WebClientMFADisabled, WebClientAPIKeyAuthChangeDisabled, WebClientInfoChangeDisabled}
 	// UserTypes defines the supported user type hints for auth plugins
 	UserTypes = []string{string(UserTypeLDAP), string(UserTypeOS)}
 )

+ 0 - 1
smtp/smtp.go

@@ -115,7 +115,6 @@ func (c *Config) getAuthType() mail.AuthType {
 }
 
 // SendEmail tries to send an email using the specified parameters.
-// If the contentType is 0 text/plain is assumed, otherwise text/html
 func SendEmail(to, subject, body string, contentType EmailContentType) error {
 	if smtpServer == nil {
 		return errors.New("smtp: not configured")

+ 8 - 8
templates/webadmin/admin.html

@@ -22,6 +22,14 @@
                 </div>
             </div>
 
+            <div class="form-group row">
+                <label for="idEmail" class="col-sm-2 col-form-label">Email</label>
+                <div class="col-sm-10">
+                    <input type="text" class="form-control" id="idEmail" name="email" placeholder=""
+                        value="{{.Admin.Email}}" maxlength="255">
+                </div>
+            </div>
+
             <div class="form-group row">
                 <label for="idDescription" class="col-sm-2 col-form-label">Description</label>
                 <div class="col-sm-10">
@@ -69,14 +77,6 @@
                 </div>
             </div>
 
-            <div class="form-group row">
-                <label for="idEmail" class="col-sm-2 col-form-label">Email</label>
-                <div class="col-sm-10">
-                    <input type="text" class="form-control" id="idEmail" name="email" placeholder=""
-                        value="{{.Admin.Email}}" maxlength="255">
-                </div>
-            </div>
-
             <div class="form-group row">
                 <label for="idAllowedIP" class="col-sm-2 col-form-label">Allowed IP/Mask</label>
                 <div class="col-sm-10">

+ 6 - 2
templates/webadmin/base.html

@@ -167,9 +167,13 @@
                             </a>
                             <!-- Dropdown - User Information -->
                             <div class="dropdown-menu dropdown-menu-right shadow animated--grow-in" aria-labelledby="userDropdown">
-                                <a class="dropdown-item" href="{{.CredentialsURL}}">
+                                <a class="dropdown-item" href="{{.ProfileURL}}">
+                                    <i class="fas fa-user fa-sm fa-fw mr-2 text-gray-400"></i>
+                                    Profile
+                                </a>
+                                <a class="dropdown-item" href="{{.ChangePwdURL}}">
                                     <i class="fas fa-key fa-sm fa-fw mr-2 text-gray-400"></i>
-                                    Credentials
+                                    Change password
                                 </a>
                                 {{if .LoggedAdmin.CanManageMFA}}
                                 <a class="dropdown-item" href="{{.MFAURL}}">

+ 1 - 28
templates/webadmin/credentials.html → templates/webadmin/changepassword.html

@@ -14,7 +14,7 @@
             <div class="card-body text-form-error">{{.Error}}</div>
         </div>
         {{end}}
-        <form id="user_form" action="{{.ChangePwdURL}}" method="POST" autocomplete="off">
+        <form id="user_form" action="{{.CurrentURL}}" method="POST" autocomplete="off">
             <div class="form-group row">
                 <label for="idCurrentPassword" class="col-sm-2 col-form-label">Current password</label>
                 <div class="col-sm-10">
@@ -41,31 +41,4 @@
         </form>
     </div>
 </div>
-<div class="card shadow mb-4">
-    <div class="card-header py-3">
-        <h6 class="m-0 font-weight-bold text-primary">REST API access</h6>
-    </div>
-    <div class="card-body">
-        {{if .APIKeyError}}
-        <div class="card mb-4 border-left-warning">
-            <div class="card-body text-form-error">{{.APIKeyError}}</div>
-        </div>
-        {{end}}
-        <form id="key_form" action="{{.ManageAPIKeyURL}}" method="POST">
-            <div class="form-group">
-                <div class="form-check">
-                    <input type="checkbox" class="form-check-input" id="idAllowAPIKeyAuth" name="allow_api_key_auth"
-                    {{if .AllowAPIKeyAuth}}checked{{end}} aria-describedby="allowAPIKeyAuthHelpBlock">
-                    <label for="idAllowAPIKeyAuth" class="form-check-label">Allow API key authentication</label>
-                    <small id="allowAPIKeyAuthHelpBlock" class="form-text text-muted">
-                        Allow to impersonate yourself, in REST API, with an API key. If this permission is not granted, your credentials are required to use the REST API on your behalf
-                    </small>
-                </div>
-            </div>
-
-            <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
-            <button type="submit" class="btn btn-primary float-right mt-3 px-5 px-3">Submit</button>
-        </form>
-    </div>
-</div>
 {{end}}

+ 50 - 0
templates/webadmin/profile.html

@@ -0,0 +1,50 @@
+{{template "base" .}}
+
+{{define "title"}}{{.Title}}{{end}}
+
+{{define "page_body"}}
+
+<div class="card shadow mb-4">
+    <div class="card-header py-3">
+        <h6 class="m-0 font-weight-bold text-primary">My profile - {{.LoggedAdmin.Username}}</h6>
+    </div>
+    <div class="card-body">
+        {{if .Error}}
+        <div class="card mb-4 border-left-warning">
+            <div class="card-body text-form-error">{{.Error}}</div>
+        </div>
+        {{end}}
+        <form id="profile_form" action="{{.CurrentURL}}" method="POST">
+            <div class="form-group row">
+                <label for="idEmail" class="col-sm-2 col-form-label">Email</label>
+                <div class="col-sm-10">
+                    <input type="text" class="form-control" id="idEmail" name="email" placeholder=""
+                        value="{{.Email}}" maxlength="255">
+                </div>
+            </div>
+
+            <div class="form-group row">
+                <label for="idDescription" class="col-sm-2 col-form-label">Description</label>
+                <div class="col-sm-10">
+                    <input type="text" class="form-control" id="idDescription" name="description" placeholder=""
+                        value="{{.Description}}" maxlength="255">
+                </div>
+            </div>
+
+            <div class="form-group">
+                <div class="form-check">
+                    <input type="checkbox" class="form-check-input" id="idAllowAPIKeyAuth" name="allow_api_key_auth"
+                    {{if .AllowAPIKeyAuth}}checked{{end}} aria-describedby="allowAPIKeyAuthHelpBlock">
+                    <label for="idAllowAPIKeyAuth" class="form-check-label">Allow API key authentication</label>
+                    <small id="allowAPIKeyAuthHelpBlock" class="form-text text-muted">
+                        Allow to impersonate yourself, in REST API, with an API key. If this permission is not granted, your credentials are required to use the REST API on your behalf
+                    </small>
+                </div>
+            </div>
+
+            <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
+            <button type="submit" class="btn btn-primary float-right mt-3 px-5 px-3">Submit</button>
+        </form>
+    </div>
+</div>
+{{end}}

+ 11 - 4
templates/webclient/base.html

@@ -79,10 +79,10 @@
                 </a>
             </li>
 
-            <li class="nav-item {{if eq .CurrentURL .CredentialsURL}}active{{end}}">
-                <a class="nav-link" href="{{.CredentialsURL}}">
-                    <i class="fas fa-key"></i>
-                    <span>{{.CredentialsTitle}}</span></a>
+            <li class="nav-item {{if eq .CurrentURL .ProfileURL}}active{{end}}">
+                <a class="nav-link" href="{{.ProfileURL}}">
+                    <i class="fas fa-user"></i>
+                    <span>{{.ProfileTitle}}</span></a>
             </li>
             {{if .LoggedUser.CanManageMFA}}
             <li class="nav-item {{if eq .CurrentURL .MFAURL}}active{{end}}">
@@ -129,6 +129,13 @@
                             </a>
                             <!-- Dropdown - User Information -->
                             <div class="dropdown-menu dropdown-menu-right shadow animated--grow-in" aria-labelledby="userDropdown">
+                                {{if .LoggedUser.CanChangePassword}}
+                                <a class="dropdown-item" href="{{.ChangePwdURL}}">
+                                    <i class="fas fa-key fa-sm fa-fw mr-2 text-gray-400"></i>
+                                    Change password
+                                </a>
+                                <div class="dropdown-divider"></div>
+                                {{end}}
                                 <a class="dropdown-item" href="#" data-toggle="modal" data-target="#logoutModal">
                                     <i class="fas fa-sign-out-alt fa-sm fa-fw mr-2 text-gray-400"></i>
                                     Logout

+ 44 - 0
templates/webclient/changepassword.html

@@ -0,0 +1,44 @@
+{{template "base" .}}
+
+{{define "title"}}{{.Title}}{{end}}
+
+{{define "page_body"}}
+
+<div class="card shadow mb-4">
+    <div class="card-header py-3">
+        <h6 class="m-0 font-weight-bold text-primary">Change password</h6>
+    </div>
+    <div class="card-body">
+        {{if .Error}}
+        <div class="card mb-4 border-left-warning">
+            <div class="card-body text-form-error">{{.Error}}</div>
+        </div>
+        {{end}}
+        <form id="user_form" action="{{.CurrentURL}}" method="POST" autocomplete="off">
+            <div class="form-group row">
+                <label for="idCurrentPassword" class="col-sm-2 col-form-label">Current password</label>
+                <div class="col-sm-10">
+                    <input type="password" class="form-control" id="idCurrentPassword" name="current_password" required>
+                </div>
+            </div>
+
+            <div class="form-group row">
+                <label for="idNewPassword1" class="col-sm-2 col-form-label">New password</label>
+                <div class="col-sm-10">
+                    <input type="password" class="form-control" id="idNewPassword1" name="new_password1" required>
+                </div>
+            </div>
+
+            <div class="form-group row">
+                <label for="idNewPassword2" class="col-sm-2 col-form-label">Confirm password</label>
+                <div class="col-sm-10">
+                    <input type="password" class="form-control" id="idNewPassword2" name="new_password2" required>
+                </div>
+            </div>
+
+            <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
+            <button type="submit" class="btn btn-primary float-right mt-3 px-5 px-3">Change my password</button>
+        </form>
+    </div>
+</div>
+{{end}}

+ 0 - 158
templates/webclient/credentials.html

@@ -1,158 +0,0 @@
-{{template "base" .}}
-
-{{define "title"}}{{.Title}}{{end}}
-
-{{define "page_body"}}
-
-{{if .LoggedUser.CanChangePassword}}
-<div class="card shadow mb-4">
-    <div class="card-header py-3">
-        <h6 class="m-0 font-weight-bold text-primary">Change password</h6>
-    </div>
-    <div class="card-body">
-        {{if .PwdError}}
-        <div class="card mb-4 border-left-warning">
-            <div class="card-body text-form-error">{{.PwdError}}</div>
-        </div>
-        {{end}}
-        <form id="user_form" action="{{.ChangePwdURL}}" method="POST" autocomplete="off">
-            <div class="form-group row">
-                <label for="idCurrentPassword" class="col-sm-2 col-form-label">Current password</label>
-                <div class="col-sm-10">
-                    <input type="password" class="form-control" id="idCurrentPassword" name="current_password" required>
-                </div>
-            </div>
-
-            <div class="form-group row">
-                <label for="idNewPassword1" class="col-sm-2 col-form-label">New password</label>
-                <div class="col-sm-10">
-                    <input type="password" class="form-control" id="idNewPassword1" name="new_password1" required>
-                </div>
-            </div>
-
-            <div class="form-group row">
-                <label for="idNewPassword2" class="col-sm-2 col-form-label">Confirm password</label>
-                <div class="col-sm-10">
-                    <input type="password" class="form-control" id="idNewPassword2" name="new_password2" required>
-                </div>
-            </div>
-
-            <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
-            <button type="submit" class="btn btn-primary float-right mt-3 px-5 px-3">Change my password</button>
-        </form>
-    </div>
-</div>
-{{end}}
-{{if .LoggedUser.CanManagePublicKeys}}
-<div class="card shadow mb-4">
-    <div class="card-header py-3">
-        <h6 class="m-0 font-weight-bold text-primary">Manage public keys</h6>
-    </div>
-    <div class="card-body">
-        {{if .KeyError}}
-        <div class="card mb-4 border-left-warning">
-            <div class="card-body text-form-error">{{.KeyError}}</div>
-        </div>
-        {{end}}
-        <form id="key_form" action="{{.ManageKeysURL}}" method="POST">
-            <div class="form-group row">
-                <div class="col-md-12 form_field_pk_outer">
-                    {{range $idx, $val := .PublicKeys}}
-                    <div class="row form_field_pk_outer_row">
-                        <div class="form-group col-md-11">
-                            <textarea class="form-control" id="idPublicKey{{$idx}}" name="public_keys" rows="4"
-                                placeholder="Paste your public key here">{{$val}}</textarea>
-                        </div>
-                        <div class="form-group col-md-1">
-                            <button class="btn btn-circle btn-danger remove_pk_btn_frm_field">
-                                <i class="fas fa-trash"></i>
-                            </button>
-                        </div>
-                    </div>
-                    {{else}}
-                    <div class="row form_field_pk_outer_row">
-                        <div class="form-group col-md-11">
-                            <textarea class="form-control" id="idPublicKey0" name="public_keys" rows="4"
-                                placeholder="Paste your public key here"></textarea>
-                        </div>
-                        <div class="form-group col-md-1">
-                            <button class="btn btn-circle btn-danger remove_pk_btn_frm_field" disabled>
-                                <i class="fas fa-trash"></i>
-                            </button>
-                        </div>
-                    </div>
-                    {{end}}
-                </div>
-            </div>
-
-            <div class="row mx-1">
-                <button type="button" class="btn btn-secondary add_new_pk_field_btn">
-                    <i class="fas fa-plus"></i> Add new public key
-                </button>
-            </div>
-
-            <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
-            <button type="submit" class="btn btn-primary float-right mt-3 px-5 px-3">Submit</button>
-        </form>
-    </div>
-</div>
-{{end}}
-<div class="card shadow mb-4">
-    <div class="card-header py-3">
-        <h6 class="m-0 font-weight-bold text-primary">REST API access</h6>
-    </div>
-    <div class="card-body">
-        {{if .APIKeyError}}
-        <div class="card mb-4 border-left-warning">
-            <div class="card-body text-form-error">{{.APIKeyError}}</div>
-        </div>
-        {{end}}
-        <form id="key_form" action="{{.ManageAPIKeyURL}}" method="POST">
-            <div class="form-group">
-                <div class="form-check">
-                    <input type="checkbox" class="form-check-input" id="idAllowAPIKeyAuth" name="allow_api_key_auth" {{if not .LoggedUser.CanChangeAPIKeyAuth}}disabled="disabled"{{end}}
-                    {{if .AllowAPIKeyAuth}}checked{{end}} aria-describedby="allowAPIKeyAuthHelpBlock">
-                    <label for="idAllowAPIKeyAuth" class="form-check-label">Allow API key authentication</label>
-                    <small id="allowAPIKeyAuthHelpBlock" class="form-text text-muted">
-                        Allow to impersonate yourself, in REST API, with an API key. If this permission is not granted, your credentials are required to use the REST API on your behalf
-                    </small>
-                </div>
-            </div>
-            {{if .LoggedUser.CanChangeAPIKeyAuth}}
-            <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
-            <button type="submit" class="btn btn-primary float-right mt-3 px-5 px-3">Submit</button>
-            {{end}}
-        </form>
-    </div>
-</div>
-{{end}}
-
-{{define "extra_js"}}
-<script type="text/javascript">
-    $(document).ready(function () {
-        $("body").on("click", ".add_new_pk_field_btn", function () {
-            var index = $(".form_field_pk_outer").find(".form_field_pk_outer_row").length;
-            while (document.getElementById("idPublicKey"+index) != null){
-                index++;
-            }
-            $(".form_field_pk_outer").append(`
-                    <div class="row form_field_pk_outer_row">
-                        <div class="form-group col-md-11">
-                            <textarea class="form-control" id="idPublicKey${index}" name="public_keys" rows="4"
-                                placeholder="Paste your public key here"></textarea>
-                        </div>
-                        <div class="form-group col-md-1">
-                            <button class="btn btn-circle btn-danger remove_pk_btn_frm_field">
-                                <i class="fas fa-trash"></i>
-                            </button>
-                        </div>
-                    </div>
-                `);
-        });
-
-        $("body").on("click", ".remove_pk_btn_frm_field", function () {
-            $(this).closest(".form_field_pk_outer_row").remove();
-        });
-    });
-</script>
-{{end}}

+ 128 - 0
templates/webclient/profile.html

@@ -0,0 +1,128 @@
+{{template "base" .}}
+
+{{define "title"}}{{.Title}}{{end}}
+
+{{define "page_body"}}
+
+<div class="card shadow mb-4">
+    <div class="card-header py-3">
+        <h6 class="m-0 font-weight-bold text-primary">My profile - {{.LoggedUser.Username}}</h6>
+    </div>
+    <div class="card-body">
+        {{if .Error}}
+        <div class="card mb-4 border-left-warning">
+            <div class="card-body text-form-error">{{.Error}}</div>
+        </div>
+        {{end}}
+        <form id="profile_form" action="{{.CurrentURL}}" method="POST">
+            <div class="form-group row">
+                <label for="idEmail" class="col-sm-2 col-form-label">Email</label>
+                <div class="col-sm-10">
+                    <input type="text" class="form-control" id="idEmail" name="email" placeholder=""
+                        value="{{.Email}}" maxlength="255" autocomplete="nope" {{if not .LoggedUser.CanChangeInfo}}readonly{{end}}>
+                </div>
+            </div>
+
+            <div class="form-group row">
+                <label for="idDescription" class="col-sm-2 col-form-label">Description</label>
+                <div class="col-sm-10">
+                    <input type="text" class="form-control" id="idDescription" name="description" placeholder=""
+                        value="{{.Description}}" maxlength="255" {{if not .LoggedUser.CanChangeInfo}}readonly{{end}}>
+                </div>
+            </div>
+
+            <div class="form-group">
+                <div class="form-check">
+                    <input type="checkbox" class="form-check-input" id="idAllowAPIKeyAuth" name="allow_api_key_auth" {{if not .LoggedUser.CanChangeAPIKeyAuth}}disabled="disabled"{{end}}
+                    {{if .AllowAPIKeyAuth}}checked{{end}} aria-describedby="allowAPIKeyAuthHelpBlock">
+                    <label for="idAllowAPIKeyAuth" class="form-check-label">Allow API key authentication</label>
+                    <small id="allowAPIKeyAuthHelpBlock" class="form-text text-muted">
+                        Allow to impersonate yourself, in REST API, with an API key. If this permission is not granted, your credentials are required to use the REST API on your behalf
+                    </small>
+                </div>
+            </div>
+
+            {{if .LoggedUser.CanManagePublicKeys}}
+            <div class="card bg-light mb-3">
+                <div class="card-header">
+                    Public keys
+                </div>
+                <div class="card-body">
+                    <div class="form-group row">
+                        <div class="col-md-12 form_field_pk_outer">
+                            {{range $idx, $val := .PublicKeys}}
+                            <div class="row form_field_pk_outer_row">
+                                <div class="form-group col-md-11">
+                                    <textarea class="form-control" id="idPublicKey{{$idx}}" name="public_keys" rows="4"
+                                        placeholder="Paste your public key here">{{$val}}</textarea>
+                                </div>
+                                <div class="form-group col-md-1">
+                                    <button class="btn btn-circle btn-danger remove_pk_btn_frm_field">
+                                        <i class="fas fa-trash"></i>
+                                    </button>
+                                </div>
+                            </div>
+                            {{else}}
+                            <div class="row form_field_pk_outer_row">
+                                <div class="form-group col-md-11">
+                                    <textarea class="form-control" id="idPublicKey0" name="public_keys" rows="4"
+                                        placeholder="Paste your public key here"></textarea>
+                                </div>
+                                <div class="form-group col-md-1">
+                                    <button class="btn btn-circle btn-danger remove_pk_btn_frm_field" disabled>
+                                        <i class="fas fa-trash"></i>
+                                    </button>
+                                </div>
+                            </div>
+                            {{end}}
+                        </div>
+                    </div>
+
+                    <div class="row mx-1">
+                        <button type="button" class="btn btn-secondary add_new_pk_field_btn">
+                            <i class="fas fa-plus"></i> Add new public key
+                        </button>
+                    </div>
+                </div>
+            </div>
+            {{end}}
+            {{if .CanSubmit}}
+            <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
+            <button type="submit" class="btn btn-primary float-right mt-3 px-5 px-3">Submit</button>
+            {{end}}
+        </form>
+    </div>
+</div>
+{{end}}
+
+{{define "extra_js"}}
+{{if .LoggedUser.CanManagePublicKeys}}
+<script type="text/javascript">
+    $(document).ready(function () {
+        $("body").on("click", ".add_new_pk_field_btn", function () {
+            var index = $(".form_field_pk_outer").find(".form_field_pk_outer_row").length;
+            while (document.getElementById("idPublicKey"+index) != null){
+                index++;
+            }
+            $(".form_field_pk_outer").append(`
+                    <div class="row form_field_pk_outer_row">
+                        <div class="form-group col-md-11">
+                            <textarea class="form-control" id="idPublicKey${index}" name="public_keys" rows="4"
+                                placeholder="Paste your public key here"></textarea>
+                        </div>
+                        <div class="form-group col-md-1">
+                            <button class="btn btn-circle btn-danger remove_pk_btn_frm_field">
+                                <i class="fas fa-trash"></i>
+                            </button>
+                        </div>
+                    </div>
+                `);
+        });
+
+        $("body").on("click", ".remove_pk_btn_frm_field", function () {
+            $(this).closest(".form_field_pk_outer_row").remove();
+        });
+    });
+</script>
+{{end}}
+{{end}}