Pārlūkot izejas kodu

WIP new WebAdmin: group page

Signed-off-by: Nicola Murino <[email protected]>
Nicola Murino 1 gadu atpakaļ
vecāks
revīzija
bf94f8b87c

+ 2 - 0
README.md

@@ -370,6 +370,8 @@ Thank you to [ysura](https://www.ysura.com/) for granting us stable access to a
 
 Thank you to [KeenThemes](https://keenthemes.com/) for granting us a custom license to use their amazing [Mega Bundle](https://keenthemes.com/products/templates-mega-bundle) for SFTPGo UI.
 
+Thank you to [Incode](https://www.incode.it/) for helping us to improve the UI/UX.
+
 ## License
 
 GNU AGPL-3.0-only

+ 7 - 8
go.mod

@@ -16,7 +16,7 @@ require (
 	github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.11
 	github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.19.6
 	github.com/aws/aws-sdk-go-v2/service/s3 v1.48.0
-	github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.26.1
+	github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.26.2
 	github.com/aws/aws-sdk-go-v2/service/sts v1.26.7
 	github.com/bmatcuk/doublestar/v4 v4.6.1
 	github.com/cockroachdb/cockroach-go/v2 v2.3.5
@@ -36,7 +36,7 @@ require (
 	github.com/hashicorp/go-hclog v1.6.2
 	github.com/hashicorp/go-plugin v1.6.0
 	github.com/hashicorp/go-retryablehttp v0.7.5
-	github.com/jackc/pgx/v5 v5.5.1
+	github.com/jackc/pgx/v5 v5.5.2
 	github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126
 	github.com/klauspost/compress v1.17.4
 	github.com/lestrrat-go/jwx/v2 v2.0.19
@@ -74,12 +74,12 @@ require (
 	golang.org/x/sys v0.16.0
 	golang.org/x/term v0.16.0
 	golang.org/x/time v0.5.0
-	google.golang.org/api v0.155.0
+	google.golang.org/api v0.156.0
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
 )
 
 require (
-	cloud.google.com/go v0.111.0 // indirect
+	cloud.google.com/go v0.112.0 // indirect
 	cloud.google.com/go/compute v1.23.3 // indirect
 	cloud.google.com/go/compute/metadata v0.2.3 // indirect
 	cloud.google.com/go/iam v1.1.5 // indirect
@@ -136,7 +136,6 @@ require (
 	github.com/magiconair/properties v1.8.7 // indirect
 	github.com/mattn/go-colorable v0.1.13 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
-	github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
 	github.com/miekg/dns v1.1.57 // indirect
 	github.com/mitchellh/go-testing-interface v1.14.1 // indirect
 	github.com/mitchellh/mapstructure v1.5.0 // indirect
@@ -145,7 +144,7 @@ require (
 	github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
 	github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b // indirect
 	github.com/prometheus/client_model v0.5.0 // indirect
-	github.com/prometheus/common v0.45.0 // indirect
+	github.com/prometheus/common v0.46.0 // indirect
 	github.com/prometheus/procfs v0.12.0 // indirect
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
 	github.com/sagikazarmark/locafero v0.4.0 // indirect
@@ -165,11 +164,11 @@ require (
 	go.opentelemetry.io/otel/metric v1.21.0 // indirect
 	go.opentelemetry.io/otel/trace v1.21.0 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
-	golang.org/x/exp v0.0.0-20240110193028-0dcbfd608b1e // indirect
+	golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 // indirect
 	golang.org/x/mod v0.14.0 // indirect
 	golang.org/x/sync v0.6.0 // indirect
 	golang.org/x/text v0.14.0 // indirect
-	golang.org/x/tools v0.16.1 // indirect
+	golang.org/x/tools v0.17.0 // indirect
 	golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
 	google.golang.org/appengine v1.6.8 // indirect
 	google.golang.org/genproto v0.0.0-20240108191215-35c7eff3a6b1 // indirect

+ 16 - 18
go.sum

@@ -1,6 +1,6 @@
 cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.111.0 h1:YHLKNupSD1KqjDbQ3+LVdQ81h/UJbJyZG203cEfnQgM=
-cloud.google.com/go v0.111.0/go.mod h1:0mibmpKP1TyOOFYQY5izo0LnT+ecvOQ0Sg3OdmMiNRU=
+cloud.google.com/go v0.112.0 h1:tpFCD7hpHFlQ8yPwT3x+QeXqc2T6+n6T+hmABHfDUSM=
+cloud.google.com/go v0.112.0/go.mod h1:3jEEVwZ/MHU4djK5t5RHuKOA/GbLddgTdVubX1qnPD4=
 cloud.google.com/go/compute v1.23.3 h1:6sVlXXBmbd7jNX0Ipq0trII3e4n1/MsADLK6a+aiVlk=
 cloud.google.com/go/compute v1.23.3/go.mod h1:VCgBUoMnIVIR0CscqQiPJLAG25E3ZRZMzcFZeQ+h8CI=
 cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
@@ -65,8 +65,8 @@ github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.19.6 h1:JWy+uLKZQR/9
 github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.19.6/go.mod h1:T2NcfuIuXWcuwVwg3rBIW6h1cfzCdrzSn4Hs0KltND8=
 github.com/aws/aws-sdk-go-v2/service/s3 v1.48.0 h1:PJTdBMsyvra6FtED7JZtDpQrIAflYDHFoZAu/sKYkwU=
 github.com/aws/aws-sdk-go-v2/service/s3 v1.48.0/go.mod h1:4qXHrG1Ne3VGIMZPCB8OjH/pLFO94sKABIusjh0KWPU=
-github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.26.1 h1:Sn3MAV9YeACCULaxNWWYFH1a6G4wYFwBn3/TA5MwE2Q=
-github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.26.1/go.mod h1:qutL00aW8GSo2D0I6UEOqMvRS3ZyuBrOC1BLe5D2jPc=
+github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.26.2 h1:A5sGOT/mukuU+4At1vkSIWAN8tPwPCoYZBp7aruR540=
+github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.26.2/go.mod h1:qutL00aW8GSo2D0I6UEOqMvRS3ZyuBrOC1BLe5D2jPc=
 github.com/aws/aws-sdk-go-v2/service/sso v1.18.6 h1:dGrs+Q/WzhsiUKh82SfTVN66QzyulXuMDTV/G8ZxOac=
 github.com/aws/aws-sdk-go-v2/service/sso v1.18.6/go.mod h1:+mJNDdF+qiUlNKNC3fxn74WWNN+sOiGOEImje+3ScPM=
 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.6 h1:Yf2MIo9x+0tyv76GljxzqA3WtC5mw7NmazD2chwjxE4=
@@ -235,8 +235,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
 github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
 github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
 github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
-github.com/jackc/pgx/v5 v5.5.1 h1:5I9etrGkLrN+2XPCsi6XLlV5DITbSL/xBZdmAxFcXPI=
-github.com/jackc/pgx/v5 v5.5.1/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
+github.com/jackc/pgx/v5 v5.5.2 h1:iLlpgp4Cp/gC9Xuscl7lFL1PhhW+ZLtXZcrfCt4C3tA=
+github.com/jackc/pgx/v5 v5.5.2/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
 github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
 github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
 github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c=
@@ -288,8 +288,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
 github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI=
 github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
-github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
-github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
 github.com/mhale/smtpd v0.8.1 h1:O02u8O3eYAGxZCGf4E98WjyB+rA3DVFZtchEialjX4s=
 github.com/mhale/smtpd v0.8.1/go.mod h1:MQl+y2hwIEQCXtNhe5+55n0GZOjSmeqORDIXbqUL3x4=
 github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM=
@@ -330,8 +328,8 @@ github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlk
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
 github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
-github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
-github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
+github.com/prometheus/common v0.46.0 h1:doXzt5ybi1HBKpsZOL0sSkaNHJJqkyfEWZGGqqScV0Y=
+github.com/prometheus/common v0.46.0/go.mod h1:Tp0qkxpb9Jsg54QMe+EAmqXkSV7Evdy1BTn+g2pa/hQ=
 github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
 github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
 github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
@@ -419,8 +417,8 @@ go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc=
 go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo=
 go.opentelemetry.io/otel/metric v1.21.0 h1:tlYWfeo+Bocx5kLEloTjbcDwBuELRrIFxwdQ36PlJu4=
 go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM=
-go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o=
-go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A=
+go.opentelemetry.io/otel/sdk v1.21.0 h1:FTt8qirL1EysG6sTQRZ5TokkU8d0ugCj8htOgThZXQ8=
+go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E=
 go.opentelemetry.io/otel/trace v1.21.0 h1:WD9i5gzvoUPuXIXH24ZNBudiarZDKuekPqi/E8fpfLc=
 go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ=
 go.uber.org/automaxprocs v1.5.3 h1:kWazyxZUrS3Gs4qUpbwo5kEIMGe/DAvi5Z4tl2NW4j8=
@@ -430,8 +428,8 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8
 gocloud.dev v0.36.0 h1:q5zoXux4xkOZP473e1EZbG8Gq9f0vlg1VNH5Du/ybus=
 gocloud.dev v0.36.0/go.mod h1:bLxah6JQVKBaIxzsr5BQLYB4IYdWHkMZdzCXlo6F0gg=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/exp v0.0.0-20240110193028-0dcbfd608b1e h1:723BNChdd0c2Wk6WOE320qGBiPtYx0F0Bbm1kriShfE=
-golang.org/x/exp v0.0.0-20240110193028-0dcbfd608b1e/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
+golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 h1:hNQpMuAJe5CtcUqCXaWga3FHu+kQvCqcsoVaQgSV60o=
+golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
 golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
@@ -516,16 +514,16 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
 golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/tools v0.16.1 h1:TLyB3WofjdOEepBHAU20JdNC1Zbg87elYofWYAY5oZA=
-golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0=
+golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc=
+golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
-google.golang.org/api v0.155.0 h1:vBmGhCYs0djJttDNynWo44zosHlPvHmA0XiN2zP2DtA=
-google.golang.org/api v0.155.0/go.mod h1:GI5qK5f40kCpHfPn6+YzGAByIKWv8ujFnmoWm7Igduk=
+google.golang.org/api v0.156.0 h1:yloYcGbBtVYjLKQe4enCunxvwn3s2w/XPrrhVf6MsvQ=
+google.golang.org/api v0.156.0/go.mod h1:bUSmn4KFO0Q+69zo9CNIDp4Psi6BqM0np0CbzKRSiSY=
 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.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=

+ 8 - 0
internal/httpd/web.go

@@ -152,6 +152,14 @@ func getI18NErrorString(err error, fallback string) string {
 	return fallback
 }
 
+func getI18nError(err error) *util.I18nError {
+	var errI18n *util.I18nError
+	if err != nil {
+		errI18n = util.NewI18nError(err, util.I18nError500Message)
+	}
+	return errI18n
+}
+
 func handlePingRequest(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	render.PlainText(w, r, "PONG")

+ 22 - 23
internal/httpd/webadmin.go

@@ -316,7 +316,7 @@ type folderPage struct {
 type groupPage struct {
 	basePage
 	Group              *dataprovider.Group
-	Error              string
+	Error              *util.I18nError
 	Mode               genericPageMode
 	ValidPerms         []string
 	ValidLoginMethods  []string
@@ -447,10 +447,9 @@ func loadAdminTemplates(templatesPath string) {
 		filepath.Join(templatesPath, templateAdminDir, templateGroups),
 	}
 	groupPaths := []string{
-		filepath.Join(templatesPath, templateCommonDir, templateCommonCSS),
+		filepath.Join(templatesPath, templateCommonDir, templateCommonBase),
 		filepath.Join(templatesPath, templateAdminDir, templateBase),
 		filepath.Join(templatesPath, templateAdminDir, templateFsConfig),
-		filepath.Join(templatesPath, templateAdminDir, templateSharedComponents),
 		filepath.Join(templatesPath, templateAdminDir, templateGroup),
 	}
 	eventRulesPaths := []string{
@@ -993,14 +992,10 @@ func (s *httpdServer) renderUserPage(w http.ResponseWriter, r *http.Request, use
 	if errGroups != nil {
 		return
 	}
-	var errI18n *util.I18nError
-	if err != nil {
-		errI18n = util.NewI18nError(err, util.I18nError500Message)
-	}
 	data := userPage{
 		basePage:           basePage,
 		Mode:               mode,
-		Error:              errI18n,
+		Error:              getI18nError(err),
 		User:               user,
 		ValidPerms:         dataprovider.ValidPerms,
 		ValidLoginMethods:  dataprovider.ValidLoginMethods,
@@ -1067,10 +1062,10 @@ func (s *httpdServer) renderRolePage(w http.ResponseWriter, r *http.Request, rol
 }
 
 func (s *httpdServer) renderGroupPage(w http.ResponseWriter, r *http.Request, group dataprovider.Group,
-	mode genericPageMode, error string,
+	mode genericPageMode, err error,
 ) {
-	folders, err := s.getWebVirtualFolders(w, r, defaultQueryLimit, true)
-	if err != nil {
+	folders, errFolders := s.getWebVirtualFolders(w, r, defaultQueryLimit, true)
+	if errFolders != nil {
 		return
 	}
 	group.SetEmptySecretsIfNil()
@@ -1078,10 +1073,10 @@ func (s *httpdServer) renderGroupPage(w http.ResponseWriter, r *http.Request, gr
 	var title, currentURL string
 	switch mode {
 	case genericPageModeAdd:
-		title = "Add a new group"
+		title = util.I18nAddGroupTitle
 		currentURL = webGroupPath
 	case genericPageModeUpdate:
-		title = "Update group"
+		title = util.I18nUpdateGroupTitle
 		currentURL = fmt.Sprintf("%v/%v", webGroupPath, url.PathEscape(group.Name))
 	}
 	group.UserSettings.FsConfig.RedactedSecret = redactedSecret
@@ -1089,7 +1084,7 @@ func (s *httpdServer) renderGroupPage(w http.ResponseWriter, r *http.Request, gr
 
 	data := groupPage{
 		basePage:           s.getBasePageData(title, currentURL, r),
-		Error:              error,
+		Error:              getI18nError(err),
 		Group:              &group,
 		Mode:               mode,
 		ValidPerms:         dataprovider.ValidPerms,
@@ -1977,7 +1972,7 @@ func getQuotaLimits(r *http.Request) (int64, int, error) {
 	return quotaSize, quotaFiles, nil
 }
 
-func updateUserFormFields(r *http.Request) {
+func updateRepeaterFormFields(r *http.Request) {
 	for k := range r.Form {
 		if hasPrefixAndSuffix(k, "public_keys[", "][public_key]") {
 			r.Form.Add("public_keys", r.Form.Get(k))
@@ -2029,7 +2024,9 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
 		return user, util.NewI18nError(err, util.I18nErrorInvalidForm)
 	}
 	defer r.MultipartForm.RemoveAll() //nolint:errcheck
-	updateUserFormFields(r)
+
+	updateRepeaterFormFields(r)
+
 	uid, err := strconv.Atoi(r.Form.Get("uid"))
 	if err != nil {
 		return user, fmt.Errorf("invalid uid: %w", err)
@@ -2118,10 +2115,12 @@ func getGroupFromPostFields(r *http.Request) (dataprovider.Group, error) {
 	group := dataprovider.Group{}
 	err := r.ParseMultipartForm(maxRequestSize)
 	if err != nil {
-		return group, err
+		return group, util.NewI18nError(err, util.I18nErrorInvalidForm)
 	}
 	defer r.MultipartForm.RemoveAll() //nolint:errcheck
 
+	updateRepeaterFormFields(r)
+
 	maxSessions, err := strconv.Atoi(r.Form.Get("max_sessions"))
 	if err != nil {
 		return group, fmt.Errorf("invalid max sessions: %w", err)
@@ -3536,7 +3535,7 @@ func (s *httpdServer) handleWebGetGroups(w http.ResponseWriter, r *http.Request)
 
 func (s *httpdServer) handleWebAddGroupGet(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
-	s.renderGroupPage(w, r, dataprovider.Group{}, genericPageModeAdd, "")
+	s.renderGroupPage(w, r, dataprovider.Group{}, genericPageModeAdd, nil)
 }
 
 func (s *httpdServer) handleWebAddGroupPost(w http.ResponseWriter, r *http.Request) {
@@ -3548,7 +3547,7 @@ func (s *httpdServer) handleWebAddGroupPost(w http.ResponseWriter, r *http.Reque
 	}
 	group, err := getGroupFromPostFields(r)
 	if err != nil {
-		s.renderGroupPage(w, r, group, genericPageModeAdd, err.Error())
+		s.renderGroupPage(w, r, group, genericPageModeAdd, err)
 		return
 	}
 	ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
@@ -3558,7 +3557,7 @@ func (s *httpdServer) handleWebAddGroupPost(w http.ResponseWriter, r *http.Reque
 	}
 	err = dataprovider.AddGroup(&group, claims.Username, ipAddr, claims.Role)
 	if err != nil {
-		s.renderGroupPage(w, r, group, genericPageModeAdd, err.Error())
+		s.renderGroupPage(w, r, group, genericPageModeAdd, err)
 		return
 	}
 	http.Redirect(w, r, webGroupsPath, http.StatusSeeOther)
@@ -3569,7 +3568,7 @@ func (s *httpdServer) handleWebUpdateGroupGet(w http.ResponseWriter, r *http.Req
 	name := getURLParam(r, "name")
 	group, err := dataprovider.GroupExists(name)
 	if err == nil {
-		s.renderGroupPage(w, r, group, genericPageModeUpdate, "")
+		s.renderGroupPage(w, r, group, genericPageModeUpdate, nil)
 	} else if errors.Is(err, util.ErrNotFound) {
 		s.renderNotFoundPage(w, r, err)
 	} else {
@@ -3595,7 +3594,7 @@ func (s *httpdServer) handleWebUpdateGroupPost(w http.ResponseWriter, r *http.Re
 	}
 	updatedGroup, err := getGroupFromPostFields(r)
 	if err != nil {
-		s.renderGroupPage(w, r, group, genericPageModeUpdate, err.Error())
+		s.renderGroupPage(w, r, group, genericPageModeUpdate, err)
 		return
 	}
 	ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
@@ -3616,7 +3615,7 @@ func (s *httpdServer) handleWebUpdateGroupPost(w http.ResponseWriter, r *http.Re
 
 	err = dataprovider.UpdateGroup(&updatedGroup, group.Users, claims.Username, ipAddr, claims.Role)
 	if err != nil {
-		s.renderGroupPage(w, r, updatedGroup, genericPageModeUpdate, err.Error())
+		s.renderGroupPage(w, r, updatedGroup, genericPageModeUpdate, err)
 		return
 	}
 	http.Redirect(w, r, webGroupsPath, http.StatusSeeOther)

+ 2 - 0
internal/util/i18n.go

@@ -180,6 +180,8 @@ const (
 	I18nErrorEndpointInvalid           = "storage.endpoint_invalid"
 	I18nErrorEndpointRequired          = "storage.endpoint_required"
 	I18nErrorFsUsernameRequired        = "storage.username_required"
+	I18nAddGroupTitle                  = "title.add_group"
+	I18nUpdateGroupTitle               = "title.update_group"
 )
 
 // NewI18nError returns a I18nError wrappring the provided error

+ 7 - 3
static/locales/en/translation.json

@@ -47,7 +47,9 @@
         "status": "Status",
         "add_user": "Add user",
         "update_user": "Update user",
-        "template_user": "User template"
+        "template_user": "User template",
+        "add_group": "Add group",
+        "update_group": "Update group"
     },
     "setup": {
         "desc": "To start using SFTPGo you need to create an administrator user",
@@ -462,7 +464,9 @@
         "disconnect_help": "This way you force the user to login again, if connected, and so to use the new configuration",
         "submit_generate": "Generate and save users",
         "submit_export": "Generate and export users",
-        "invalid_quota_size": "Invalid quota size"
+        "invalid_quota_size": "Invalid quota size",
+        "expires_in": "Expires in",
+        "expires_in_help": "Account expiration as number of days from the creation. 0 means no expiration"
     },
     "group": {
         "view_manage": "View and manage groups",
@@ -621,7 +625,7 @@
         "is_anonymous_help": "Anonymous users are supported for FTP and WebDAV protocols and have read-only access",
         "disable_fs_checks": "Disable filesystem checks",
         "disable_fs_checks_help": "Disable checks for existence and automatic creation of home directory and virtual folders",
-        "api_key_auth_help": "Allow to impersonate this user, in REST API, with an API key",
+        "api_key_auth_help": "Allow to impersonate the user, in REST API, with an API key",
         "external_auth_cache_time": "External auth cache time",
         "external_auth_cache_time_help": "Cache time, in seconds, for users authenticated using an external auth hook. 0 means no cache"
     }

+ 7 - 3
static/locales/it/translation.json

@@ -47,7 +47,9 @@
         "status": "Stato",
         "add_user": "Aggiungi utente",
         "update_user": "Aggiorna utente",
-        "template_user": "Modello utente"
+        "template_user": "Modello utente",
+        "add_group": "Aggiungi gruppo",
+        "update_group": "Aggiorna gruppo"
     },
     "setup": {
         "desc": "Per iniziare a utilizzare SFTPGo devi creare un utente amministratore",
@@ -462,7 +464,9 @@
         "disconnect_help": "In questo modo si obbliga l'utente a effettuare nuovamente il login, se connesso, e quindi ad utilizzare la nuova configurazione",
         "submit_generate": "Genera e salva utenti",
         "submit_export": "Genera ed esporta utenti",
-        "invalid_quota_size": "Quota (dimensione) non valida"
+        "invalid_quota_size": "Quota (dimensione) non valida",
+        "expires_in": "Scadenza",
+        "expires_in_help": "Scadenza dell'account espressa in numero di giorni dalla creazione. 0 significa nessuna scadenza"
     },
     "group": {
         "view_manage": "Visualizza e gestisci gruppi",
@@ -621,7 +625,7 @@
         "is_anonymous_help": "Gli utenti anonimi sono supportati per i protocolli FTP e WebDAV e hanno accesso di sola lettura",
         "disable_fs_checks": "Disabilita i controlli del filesystem",
         "disable_fs_checks_help": "Disabilita i controlli sull'esistenza e la creazione automatica della directory home e delle cartelle virtuali",
-        "api_key_auth_help": "Permetti di impersonare questo utente nelle API REST utilizzando una chiave API",
+        "api_key_auth_help": "Permetti di impersonare l'utente nelle API REST utilizzando una chiave API",
         "external_auth_cache_time": "Cache per autenticazione esterna",
         "external_auth_cache_time_help": "Tempo di memorizzazione nella cache, in secondi, per gli utenti autenticati utilizzando un hook di autenticazione esterno. 0 significa nessuna cache"
     }

+ 354 - 0
templates/webadmin/fsconfig.html

@@ -508,4 +508,358 @@ explicit grant from the SFTPGo Team ([email protected]).
 
     </div>
 </div>
+{{- end}}
+
+{{- define "user_group_perms"}}
+<div class="card mt-10">
+    <div class="card-header bg-light">
+        <h3 data-i18n="filters.directory_patterns" class="card-title section-title-inner">Per-directory name patterns restrictions</h3>
+    </div>
+    <div class="card-body">
+        <div id="directory_patterns">
+            <p class="fs-5 fw-semibold mb-4" data-i18n="filters.directory_patterns_help"></p>
+            <div class="form-group">
+                <div data-repeater-list="directory_patterns">
+                    {{- range $idx, $pattern := .GetFlatFilePatterns -}}
+                    <div data-repeater-item>
+                        <div class="form-group row">
+                            <div class="col-md-4 mt-3 mt-md-8">
+                                <input data-i18n="[placeholder]filters.directory_path_help" type="text" class="form-control" name="pattern_path" value="{{$pattern.Path}}" />
+                            </div>
+                            <div class="col-md-3 mt-3 mt-md-8">
+                                <input type="text" class="form-control" name="patterns" placeholder="*.png,*.zip" value="{{$pattern.GetCommaSeparatedPatterns}}" />
+                            </div>
+                            <div class="col-md-2 mt-3 mt-md-8">
+                                <select name="pattern_type" class="form-select select-repetear select-first" data-hide-search="true">
+                                    <option value="denied" data-i18n="general.denied" {{- if $pattern.IsDenied}} selected{{- end}}>Denied</option>
+                                    <option value="allowed" data-i18n="general.allowed" {{- if $pattern.IsAllowed}} selected{{- end}}>Allowed</option>
+                                </select>
+                            </div>
+                            <div class="col-md-2 mt-3 mt-md-8">
+                                <select name="pattern_policy" class="form-select select-repetear select-first" data-hide-search="true">
+                                    <option value="0" data-i18n="general.visible" {{- if eq $pattern.DenyPolicy 0}} selected{{- end}}>Visible</option>
+                                    <option value="1" data-i18n="general.hidden" {{- if eq $pattern.DenyPolicy 1}} selected{{- end}}>Hidden</option>
+                                </select>
+                            </div>
+                            <div class="col-md-1 mt-3 mt-md-8">
+                                <a href="#" data-repeater-delete
+                                    class="btn btn-light-danger ps-5 pe-4">
+                                    <i class="ki-duotone ki-trash fs-2">
+                                        <span class="path1"></span>
+                                        <span class="path2"></span>
+                                        <span class="path3"></span>
+                                        <span class="path4"></span>
+                                        <span class="path5"></span>
+                                    </i>
+                                </a>
+                            </div>
+                        </div>
+                    </div>
+                    {{- else}}
+                    <div data-repeater-item>
+                        <div class="form-group row">
+                            <div class="col-md-4 mt-3 mt-md-8">
+                                <input data-i18n="[placeholder]filters.directory_path_help" type="text" class="form-control" name="pattern_path" value="" />
+                            </div>
+                            <div class="col-md-3 mt-3 mt-md-8">
+                                <input type="text" class="form-control" name="patterns" placeholder="*.png,*.zip" value="" />
+                            </div>
+                            <div class="col-md-2 mt-3 mt-md-8">
+                                <select name="pattern_type" class="form-select select-repetear select-first" data-hide-search="true">
+                                    <option value="denied" data-i18n="general.denied">Denied</option>
+                                    <option value="allowed" data-i18n="general.allowed">Allowed</option>
+                                </select>
+                            </div>
+                            <div class="col-md-2 mt-3 mt-md-8">
+                                <select name="pattern_policy" class="form-select select-repetear select-first" data-hide-search="true">
+                                    <option value="0" data-i18n="general.visible">Visible</option>
+                                    <option value="1" data-i18n="general.hidden">Hidden</option>
+                                </select>
+                            </div>
+                            <div class="col-md-1 mt-3 mt-md-8">
+                                <a href="#" data-repeater-delete
+                                    class="btn btn-light-danger ps-5 pe-4">
+                                    <i class="ki-duotone ki-trash fs-2">
+                                        <span class="path1"></span>
+                                        <span class="path2"></span>
+                                        <span class="path3"></span>
+                                        <span class="path4"></span>
+                                        <span class="path5"></span>
+                                    </i>
+                                </a>
+                            </div>
+                        </div>
+                    </div>
+                    {{- end}}
+                </div>
+            </div>
+
+            <div class="form-group mt-5">
+                <a href="#" data-repeater-create class="btn btn-light-primary">
+                    <i class="ki-duotone ki-plus fs-3"></i>
+                    <span data-i18n="general.add">Add</span>
+                </a>
+            </div>
+        </div>
+    </div>
+</div>
+{{- end}}
+
+{{- define "user_group_quota"}}
+<div class="form-group row mt-10">
+    <label for="idQuotaSize" data-i18n="virtual_folders.quota_size" class="col-md-3 col-form-label">Quota size</label>
+    <div class="col-md-3">
+        <input id="idQuotaSize" type="text" class="form-control" name="quota_size" value="{{HumanizeBytes .QuotaSize}}" aria-describedby="idQuotaSizeHelp" />
+        <div id="idQuotaSizeHelp" class="form-text" data-i18n="virtual_folders.quota_size_help"></div>
+    </div>
+    <div class="col-md-1"></div>
+    <label for="idQuotaFiles" data-i18n="virtual_folders.quota_files" class="col-md-2 col-form-label">Quota files</label>
+    <div class="col-md-3">
+        <input id="idQuotaFiles" type="number" min="0" class="form-control" name="quota_files" value="{{.QuotaFiles}}" aria-describedby="idQuotaFilesHelp" />
+        <div id="idQuotaFilesHelp" class="form-text" data-i18n="general.zero_no_limit_help"></div>
+    </div>
+</div>
+
+<div class="form-group row mt-10">
+    <label for="idMaxUploadSize" data-i18n="filters.max_upload_size" class="col-md-3 col-form-label">Max file upload size</label>
+    <div class="col-md-9">
+        <input id="idMaxUploadSize" type="text" class="form-control" name="max_upload_file_size" value="{{HumanizeBytes .Filters.MaxUploadFileSize}}" aria-describedby="idMaxUploadSizeHelp" />
+        <div id="idMaxUploadSizeHelp" class="form-text" data-i18n="filters.max_upload_size_help"></div>
+    </div>
+</div>
+
+<div class="form-group row mt-10">
+    <label for="idUploadBandwidth" data-i18n="filters.upload_bandwidth" class="col-md-3 col-form-label">Bandwidth UL (KB/s)</label>
+    <div class="col-md-3">
+        <input id="idUploadBandwidth" type="number" min="0" class="form-control" name="upload_bandwidth" value="{{.UploadBandwidth}}" aria-describedby="idUploadBandwidthHelp" />
+        <div id="idUploadBandwidthHelp" class="form-text" data-i18n="general.zero_no_limit_help"></div>
+    </div>
+    <div class="col-md-1"></div>
+    <label for="idDownloadBandwidth" data-i18n="filters.download_bandwidth" class="col-md-2 col-form-label">Bandwidth DL (KB/s)</label>
+    <div class="col-md-3">
+        <input id="idDownloadBandwidth" type="number" min="0" class="form-control" name="download_bandwidth" value="{{.DownloadBandwidth}}" aria-describedby="idDownloadBandwidthHelp" />
+        <div id="idDownloadBandwidthHelp" class="form-text" data-i18n="general.zero_no_limit_help"></div>
+    </div>
+</div>
+
+<div class="card mt-10">
+    <div class="card-header bg-light">
+        <h3 data-i18n="filters.src_bandwidth_limit" class="card-title section-title-inner">Per-source bandwidth speed limits</h3>
+    </div>
+    <div class="card-body">
+        <div id="src_bandwidth_limits">
+            <div class="form-group">
+                <div data-repeater-list="src_bandwidth_limits">
+                    {{- range $idx, $bwLimit := .Filters.BandwidthLimits -}}
+                    <div data-repeater-item>
+                        <div class="form-group row">
+                            <div class="col-md-7 mt-3 mt-md-8">
+                                <textarea class="form-control" name="bandwidth_limit_sources" rows="4">{{$bwLimit.GetSourcesAsString}}</textarea>
+                                <div class="form-text" data-i18n="general.ip_mask_help"></div>
+                            </div>
+                            <div class="col-md-2 mt-3 mt-md-8">
+                                <input type="number" min="0" class="form-control" name="upload_bandwidth_source" value="{{$bwLimit.UploadBandwidth}}" />
+                                <div class="form-text" data-i18n="filters.upload_bandwidth_help"></div>
+                            </div>
+                            <div class="col-md-2 mt-3 mt-md-8">
+                                <input type="number" min="0" class="form-control" name="download_bandwidth_source" value="{{$bwLimit.DownloadBandwidth}}" />
+                                <div class="form-text" data-i18n="filters.download_bandwidth_help"></div>
+                            </div>
+                            <div class="col-md-1 mt-3 mt-md-8">
+                                <a href="#" data-repeater-delete
+                                    class="btn btn-light-danger ps-5 pe-4">
+                                    <i class="ki-duotone ki-trash fs-2">
+                                        <span class="path1"></span>
+                                        <span class="path2"></span>
+                                        <span class="path3"></span>
+                                        <span class="path4"></span>
+                                        <span class="path5"></span>
+                                    </i>
+                                </a>
+                            </div>
+                        </div>
+                    </div>
+                    {{- else}}
+                    <div data-repeater-item>
+                        <div class="form-group row">
+                            <div class="col-md-7 mt-3 mt-md-8">
+                                <textarea class="form-control" name="bandwidth_limit_sources" rows="4"></textarea>
+                                <div class="form-text" data-i18n="general.ip_mask_help"></div>
+                            </div>
+                            <div class="col-md-2 mt-3 mt-md-8">
+                                <input type="number" min="0" class="form-control" name="upload_bandwidth_source" value="" />
+                                <div class="form-text" data-i18n="filters.upload_bandwidth_help"></div>
+                            </div>
+                            <div class="col-md-2 mt-3 mt-md-8">
+                                <input type="number" min="0" class="form-control" name="download_bandwidth_source" value="" />
+                                <div class="form-text" data-i18n="filters.download_bandwidth_help"></div>
+                            </div>
+                            <div class="col-md-1 mt-3 mt-md-8">
+                                <a href="#" data-repeater-delete
+                                    class="btn btn-light-danger ps-5 pe-4">
+                                    <i class="ki-duotone ki-trash fs-2">
+                                        <span class="path1"></span>
+                                        <span class="path2"></span>
+                                        <span class="path3"></span>
+                                        <span class="path4"></span>
+                                        <span class="path5"></span>
+                                    </i>
+                                </a>
+                            </div>
+                        </div>
+                    </div>
+                    {{- end}}
+                </div>
+            </div>
+
+            <div class="form-group mt-5">
+                <a href="#" data-repeater-create class="btn btn-light-primary">
+                    <i class="ki-duotone ki-plus fs-3"></i>
+                    <span data-i18n="general.add">Add</span>
+                </a>
+            </div>
+        </div>
+    </div>
+</div>
+
+<div class="form-group row mt-10">
+    <label for="idTransferUL" data-i18n="filters.upload_data_transfer" class="col-md-3 col-form-label">Upload data transfer (MB)</label>
+    <div class="col-md-3">
+        <input id="idTransferUL" type="number" min="0" class="form-control" name="upload_data_transfer" value="{{.UploadDataTransfer}}" aria-describedby="idTransferULHelp" />
+        <div id="idTransferULHelp" class="form-text" data-i18n="filters.upload_data_transfer_help"></div>
+    </div>
+    <div class="col-md-1"></div>
+    <label for="idTransferDL" data-i18n="filters.download_data_transfer" class="col-md-2 col-form-label">Download data transfer (MB)</label>
+    <div class="col-md-3">
+        <input id="idTransferDL" type="number" min="0" class="form-control" name="download_data_transfer" value="{{.DownloadDataTransfer}}" aria-describedby="idTransferDLhHelp" />
+        <div id="idTransferDLhHelp" class="form-text" data-i18n="filters.download_data_transfer_help"></div>
+    </div>
+</div>
+
+<div class="form-group row mt-10">
+    <label for="idTransferTotal" data-i18n="filters.total_data_transfer" class="col-md-3 col-form-label">Total data transfer (MB)</label>
+    <div class="col-md-9">
+        <input id="idTransferTotal" type="number" min="0" class="form-control" name="total_data_transfer" value="{{.TotalDataTransfer}}" aria-describedby="idTransferTotalHelp" />
+        <div id="idTransferTotalHelp" class="form-text" data-i18n="filters.total_data_transfer_help"></div>
+    </div>
+</div>
+{{- end}}
+
+{{- define "user_group_advanced"}}
+<div class="form-group row mt-10">
+    <label for="idStartDirectory" data-i18n="filters.start_directory" class="col-md-3 col-form-label">Start directory</label>
+    <div class="col-md-9">
+        <input id="idStartDirectory" type="text" class="form-control" name="start_directory" value="{{.StartDirectory}}" aria-describedby="idStartDirectoryHelp" />
+        <div id="idStartDirectoryHelp" class="form-text" data-i18n="filters.start_directory_help"></div>
+    </div>
+</div>
+
+<div class="form-group row mt-10">
+    <label for="idTLSUsername" data-i18n="filters.tls_username" class="col-md-3 col-form-label">TLS username</label>
+    <div class="col-md-9">
+        <select id="idTLSUsername" name="tls_username" class="form-select" data-control="i18n-select2" data-hide-search="true" aria-describedby="idTLSUsernameHelp">
+            <option value="" {{if or (eq .TLSUsername "None") (eq .TLSUsername "") }}selected{{end}}>---</option>
+            <option value="CommonName" {{if eq .TLSUsername "CommonName" }}selected{{end}}>Common Name</option>
+        </select>
+        <div id="idTLSUsernameHelp" class="form-text" data-i18n="filters.tls_username_help"></div>
+    </div>
+</div>
+
+<div class="form-group row mt-10">
+    <label for="idFTPSecurity" data-i18n="filters.ftp_security" class="col-md-3 col-form-label">FTP security</label>
+    <div class="col-md-9">
+        <select id="idFTPSecurity" name="ftp_security" class="form-select" data-control="i18n-select2" data-hide-search="true" aria-describedby="idFTPSecurityHelp">
+            <option value="" data-i18n="general.global_settings" {{if eq .FTPSecurity 0 }}selected{{end}}>Server settings</option>
+            <option value="1" data-i18n="general.mandatory_encryption" {{if eq .FTPSecurity 1 }}selected{{end}}>Mandatory encryption</option>
+        </select>
+        <div id="idFTPSecurityHelp" class="form-text" data-i18n="filters.ftp_security_help"></div>
+    </div>
+</div>
+
+<div class="form-group row mt-10">
+    <label for="idHooks" data-i18n="filters.hooks" class="col-md-3 col-form-label">Hooks</label>
+    <div class="col-md-9">
+        <select id="idHooks" name="hooks" class="form-select" data-control="i18n-select2" data-hide-search="true" data-close-on-select="false" multiple>
+            <option value="external_auth_disabled" data-i18n="filters.hook_ext_auth_disabled" {{if .Hooks.ExternalAuthDisabled}}selected{{end}}>
+                External auth disabled
+            </option>
+            <option value="pre_login_disabled" data-i18n="filters.hook_pre_login_disabled" {{if .Hooks.PreLoginDisabled}}selected{{end}}>
+                Pre-login disabled
+            </option>
+            <option value="check_password_disabled" data-i18n="filters.hook_check_password_disabled" {{if .Hooks.CheckPasswordDisabled}}selected{{end}}>
+                Check password disabled
+            </option>
+        </select>
+    </div>
+</div>
+
+<div class="form-group row align-items-center mt-10">
+    <label data-i18n="filters.is_anonymous" class="col-md-3 col-form-label" for="idAnonymous">Is Anonymous</label>
+    <div class="col-md-9">
+        <div class="form-check form-switch form-check-custom form-check-solid">
+            <input class="form-check-input" type="checkbox" id="idAnonymous" name="is_anonymous" {{if .IsAnonymous}}checked{{end}}/>
+            <label data-i18n="filters.is_anonymous_help" class="form-check-label fw-semibold text-gray-800" for="idAnonymous">
+                Anonymous users are supported for FTP and WebDAV protocols and have read-only access
+            </label>
+        </div>
+    </div>
+</div>
+
+<div class="form-group row align-items-center mt-10">
+    <label data-i18n="filters.disable_fs_checks" class="col-md-3 col-form-label" for="idDisableFsChecks">Disable filesystem checks</label>
+    <div class="col-md-9">
+        <div class="form-check form-switch form-check-custom form-check-solid">
+            <input class="form-check-input" type="checkbox" id="idDisableFsChecks" name="disable_fs_checks" {{if .DisableFsChecks}}checked{{end}}/>
+            <label data-i18n="filters.disable_fs_checks_help" class="form-check-label fw-semibold text-gray-800" for="idDisableFsChecks">
+                Disable checks for existence and automatic creation of home directory and virtual folders
+            </label>
+        </div>
+    </div>
+</div>
+
+<div class="form-group row align-items-center mt-10">
+    <label data-i18n="general.api_key_auth" class="col-md-3 col-form-label" for="idAllowAPIKeyAuth">API key authentication</label>
+    <div class="col-md-9">
+        <div class="form-check form-switch form-check-custom form-check-solid">
+            <input class="form-check-input" type="checkbox" id="idAllowAPIKeyAuth" name="allow_api_key_auth" {{if .AllowAPIKeyAuth}}checked{{end}}/>
+            <label data-i18n="filters.api_key_auth_help" class="form-check-label fw-semibold text-gray-800" for="idAllowAPIKeyAuth">
+                Allow to impersonate this user, in REST API, with an API key
+            </label>
+        </div>
+    </div>
+</div>
+{{- end}}
+
+{{- define "user_group_profile"}}
+<div class="form-group row mt-10">
+    <label for="idPasswordStrength" data-i18n="filters.password_strength" class="col-md-3 col-form-label">Password strength</label>
+    <div class="col-md-9">
+        <input id="idPasswordStrength" type="number" min="0" max="100" class="form-control" name="password_strength" value="{{.PasswordStrength}}" aria-describedby="idPasswordStrengthHelp"/>
+        <div id="idPasswordStrengthHelp" class="form-text" data-i18n="filters.password_strength_help"></div>
+    </div>
+</div>
+
+<div class="form-group row mt-10">
+    <label for="idPasswordExpiration" data-i18n="filters.password_expiration" class="col-md-3 col-form-label">Password expiration</label>
+    <div class="col-md-9">
+        <input id="idPasswordExpiration" type="number" min="0" class="form-control" name="password_expiration" value="{{.PasswordExpiration}}" aria-describedby="idPasswordExpirationHelp"/>
+        <div id="idPasswordExpirationHelp" class="form-text" data-i18n="filters.password_expiration_help"></div>
+    </div>
+</div>
+
+<div class="form-group row mt-10">
+    <label for="idDefaultSharesExpiration" data-i18n="filters.default_shares_expiration" class="col-md-3 col-form-label">Default shares expiration</label>
+    <div class="col-md-9">
+        <input id="idDefaultSharesExpiration" type="number" min="0" class="form-control" name="default_shares_expiration" value="{{.DefaultSharesExpiration}}" aria-describedby="idDefaultSharesExpirationHelp"/>
+        <div id="idDefaultSharesExpirationHelp" class="form-text" data-i18n="filters.default_shares_expiration_help"></div>
+    </div>
+</div>
+
+<div class="form-group row mt-10">
+    <label for="idMaxSharesExpiration" data-i18n="filters.max_shares_expiration" class="col-md-3 col-form-label">Max shares expiration</label>
+    <div class="col-md-9">
+        <input id="idMaxSharesExpiration" type="number" min="0" class="form-control" name="max_shares_expiration" value="{{.MaxSharesExpiration}}" aria-describedby="idMaxSharesExpirationHelp"/>
+        <div id="idMaxSharesExpirationHelp" class="form-text" data-i18n="filters.max_shares_expiration_help"></div>
+    </div>
+</div>
 {{- end}}

+ 321 - 658
templates/webadmin/group.html

@@ -1,752 +1,415 @@
 <!--
-Copyright (C) 2019 Nicola Murino
+Copyright (C) 2024 Nicola Murino
 
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as published
-by the Free Software Foundation, version 3.
+This WebUI uses the KeenThemes Mega Bundle, a proprietary theme:
 
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-GNU Affero General Public License for more details.
+https://keenthemes.com/products/templates-mega-bundle
 
-You should have received a copy of the GNU Affero General Public License
-along with this program. If not, see <https://www.gnu.org/licenses/>.
+KeenThemes HTML/CSS/JS components are allowed for use only within the
+SFTPGo product and restricted to be used in a resealable HTML template
+that can compete with KeenThemes products anyhow.
+
+This WebUI is allowed for use only within the SFTPGo product and
+therefore cannot be used in derivative works/products without an
+explicit grant from the SFTPGo Team ([email protected]).
 -->
 {{template "base" .}}
 
-{{define "title"}}{{.Title}}{{end}}
-
-{{define "extra_css"}}
-<link href="{{.StaticURL}}/vendor/bootstrap-select/css/bootstrap-select.min.css" rel="stylesheet">
-{{end}}
-
-{{define "page_body"}}
-<!-- Page Heading -->
-<div class="card shadow mb-4">
-    <div class="card-header py-3">
-        <h6 class="m-0 font-weight-bold text-primary">{{.Title}}</h6>
+{{- define "page_body"}}
+<div class="card shadow-sm">
+    <div class="card-header bg-light">
+        <h3 data-i18n="{{.Title}}" class="card-title section-title"></h3>
     </div>
     <div class="card-body">
-        {{if .Error}}
-        <div class="alert alert-warning alert-dismissible fade show" role="alert">
-            {{.Error}}
-            <button type="button" class="close" data-dismiss="alert" aria-label="Close">
-                <span aria-hidden="true">&times;</span>
-            </button>
-        </div>
-        {{end}}
-        <div class="card mb-4 border-left-info">
-            <div class="card-body">
-                The <span class="text-success">%username%</span> placeholder will be replaced with the username of the associated users.
-            </div>
-        </div>
+        {{- template "errmsg" .Error}}
         <form id="group_form" enctype="multipart/form-data" action="{{.CurrentURL}}" method="POST" autocomplete="off">
+
             <div class="form-group row">
-                <label for="idGroupName" class="col-sm-2 col-form-label">Name</label>
-                <div class="col-sm-10">
-                    <input type="text" class="form-control" id="idGroupName" name="name" placeholder=""
-                        value="{{.Group.Name}}" maxlength="255" autocomplete="nope" required {{if eq .Mode 2}}readonly{{end}}>
+                <label for="idGroupName" data-i18n="general.name" class="col-md-3 col-form-label">Name</label>
+                <div class="col-md-9">
+                    <input id="idGroupName" type="text" class="form-control" placeholder="" name="name" value="{{.Group.Name}}"
+                        maxlength="255" autocomplete="nope" spellcheck="false" required {{if eq .Mode 2}}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="{{.Group.Description}}" maxlength="255" aria-describedby="descriptionHelpBlock">
-                    <small id="descriptionHelpBlock" class="form-text text-muted">
-                        Optional description
-                    </small>
+
+            <div class="form-group row mt-10">
+                <label for="idDescription" data-i18n="general.description" class="col-md-3 col-form-label">Description</label>
+                <div class="col-md-9">
+                    <input id="idDescription" type="text" class="form-control" name="description" value="{{.Group.Description}}" maxlength="255">
                 </div>
             </div>
 
-            {{template "fshtml" .FsWrapper}}
-            {{if .VirtualFolders}}
-            <div class="card bg-light mb-3">
-                <div class="card-header">
-                    <b>Virtual folders</b>
+            {{- template "fshtml" .FsWrapper}}
+            {{- if .VirtualFolders}}
+            <div class="card mt-10 {{if .LoggedUser.Filters.Preferences.HideVirtualFolders}}d-none{{end}}">
+                <div class="card-header bg-light">
+                    <h3 data-i18n="title.folders" class="card-title section-title-inner">Virtual folders</h3>
                 </div>
                 <div class="card-body">
-                    <h6 class="card-title mb-4">Quota size -1 means included within user quota, 0 unlimited. Don't set -1 for shared folders. You can use MB/GB/TB suffix. With no suffix we assume bytes</h6>
-                    <div class="form-group row">
-                        <div class="col-md-12 form_field_vfolders_outer">
-                            {{range $idx, $val := .Group.VirtualFolders}}
-                            <div class="row form_field_vfolder_outer_row">
-                                <div class="form-group col-md-3">
-                                    <input type="text" class="form-control" id="idVolderPath{{$idx}}" name="vfolder_path" placeholder="mount path, i.e. /vfolder" value="{{$val.VirtualPath}}" maxlength="255">
-                                </div>
-                                <div class="form-group col-md-3">
-                                    <select class="form-control selectpicker" data-live-search="true" id="idVfolderName{{$idx}}" name="vfolder_name">
-                                        <option value=""></option>
-                                        {{range $.VirtualFolders}}
-                                        <option value="{{.Name}}" {{if eq $val.Name .Name}}selected{{end}}>{{.Name}}</option>
-                                        {{end}}
-                                    </select>
-                                </div>
-                                <div class="form-group col-md-3">
-                                    <input type="text" class="form-control" id="idVfolderQuotaSize{{$idx}}" name="vfolder_quota_size"
-                                        value="{{HumanizeBytes $val.QuotaSize}}" aria-describedby="vqsHelpBlock{{$idx}}">
-                                    <small id="vqsHelpBlock{{$idx}}" class="form-text text-muted">
-                                        Quota size
-                                    </small>
-                                </div>
-                                <div class="form-group col-md-2">
-                                    <input type="number" class="form-control" id="idVfolderQuotaFiles{{$idx}}" name="vfolder_quota_files"
-                                        value="{{$val.QuotaFiles}}" min="-1" aria-describedby="vqfHelpBlock{{$idx}}">
-                                        <small id="vqfHelpBlock{{$idx}}" class="form-text text-muted">
-                                            Quota files
-                                        </small>
-                                </div>
-                                <div class="form-group col-md-1">
-                                    <button class="btn btn-circle btn-danger remove_vfolder_btn_frm_field">
-                                        <i class="fas fa-trash"></i>
-                                    </button>
-                                </div>
-                            </div>
-                            {{else}}
-                            <div class="row form_field_vfolder_outer_row">
-                                <div class="form-group col-md-3">
-                                    <input type="text" class="form-control" id="idVolderPath0" name="vfolder_path" placeholder="mount path, i.e. /vfolder" value="" maxlength="255">
-                                </div>
-                                <div class="form-group col-md-3">
-                                    <select class="form-control selectpicker" data-live-search="true" id="idVfolderName0" name="vfolder_name">
-                                        <option value=""></option>
-                                        {{range .VirtualFolders}}
-                                        <option value="{{.Name}}">{{.Name}}</option>
-                                        {{end}}
-                                    </select>
-                                </div>
-                                <div class="form-group col-md-3">
-                                    <input type="text" class="form-control" id="idVfolderQuotaSize0" name="vfolder_quota_size"
-                                        value="" aria-describedby="vqsHelpBlock0">
-                                    <small id="vqsHelpBlock0" class="form-text text-muted">
-                                        Quota size
-                                    </small>
-                                </div>
-                                <div class="form-group col-md-2">
-                                    <input type="number" class="form-control" id="idVfolderQuotaFiles0" name="vfolder_quota_files"
-                                        value="" min="-1" aria-describedby="vqfHelpBlock0">
-                                        <small id="vqfHelpBlock0" class="form-text text-muted">
-                                            Quota files
-                                        </small>
-                                </div>
-                                <div class="form-group col-md-1">
-                                    <button class="btn btn-circle btn-danger remove_vfolder_btn_frm_field">
-                                        <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_vfolder_field_btn">
-                            <i class="fas fa-plus"></i> Add new virtual folder
-                        </button>
-                    </div>
-                </div>
-            </div>
-            {{end}}
-            <div class="accordion" id="accordionUser">
-                <div class="card">
-                    <div class="card-header" id="headingPermissions">
-                        <h2 class="mb-0">
-                            <button class="btn btn-link btn-block text-left" type="button" data-toggle="collapse"
-                                data-target="#collapsePermissions" aria-expanded="true" aria-controls="collapsePermissions">
-                                <h6 class="m-0 font-weight-bold text-primary">ACLs</h6>
-                            </button>
-                        </h2>
-                    </div>
-
-                    <div id="collapsePermissions" class="collapse" aria-labelledby="headingPermissions" data-parent="#accordionUser">
-                        <div class="card-body">
-                            <div class="card bg-light mb-3">
-                                <div class="card-header">
-                                    <b>Per-directory permissions. Wildcards are supported in paths, for example "/incoming/*" matches any directory within "/incoming"</b>
-                                </div>
-                                <div class="card-body">
-                                    <div class="form-group row">
-                                        <div class="col-md-12 form_field_dirperms_outer">
-                                            {{range $idx, $dirPerms := .Group.GetPermissions -}}
-                                            <div class="row form_field_dirperms_outer_row">
-                                                <div class="form-group col-md-8">
-                                                    <input type="text" class="form-control" id="idSubDirPermsPath{{$idx}}" name="sub_perm_path{{$idx}}" placeholder="directory path, i.e. /dir" value="{{$dirPerms.Path}}" maxlength="255">
-                                                </div>
-                                                <div class="form-group col-md-3">
-                                                    <select class="form-control selectpicker" id="idSubDirPermissions{{$idx}}" name="sub_perm_permissions{{$idx}}" multiple>
-                                                        {{range $validPerm := $.ValidPerms}}
-                                                        <option value="{{$validPerm}}" {{range $perm := $dirPerms.Permissions }}{{if eq $perm $validPerm}}selected{{end}}{{end}}>{{$validPerm}}</option>
-                                                        {{end}}
-                                                    </select>
-                                                </div>
-                                                <div class="form-group col-md-1">
-                                                    <button class="btn btn-circle btn-danger remove_dirperms_btn_frm_field">
-                                                        <i class="fas fa-trash"></i>
-                                                    </button>
-                                                </div>
+                    <div id="virtual_folders">
+                        <p class="fs-5 fw-semibold mb-4" data-i18n="user.virtual_folders_help">
+                            Quota size/files -1 means included within user quota, 0 unlimited. Don't set -1 for shared folders. You can use MB/GB/TB suffix. Without suffix we assume bytes
+                        </p>
+                        <div class="form-group">
+                            <div data-repeater-list="virtual_folders">
+                                {{- range $idx, $val := .Group.VirtualFolders}}
+                                <div data-repeater-item>
+                                    <div data-repeater-item>
+                                        <div class="form-group row">
+                                            <div class="col-md-3 mt-3 mt-md-8">
+                                                <input data-i18n="[placeholder]virtual_folders.mount_path" type="text" class="form-control" name="vfolder_path" value="{{$val.VirtualPath}}" />
                                             </div>
-                                            {{else}}
-                                            <div class="row form_field_dirperms_outer_row">
-                                                <div class="form-group col-md-8">
-                                                    <input type="text" class="form-control" id="idSubDirPermsPath0" name="sub_perm_path0" placeholder="directory path, i.e. /dir" value="" maxlength="255">
-                                                </div>
-                                                <div class="form-group col-md-3">
-                                                    <select class="form-control selectpicker" id="idSubDirPermissions0" name="sub_perm_permissions0" multiple>
-                                                        {{range $validPerm := .ValidPerms}}
-                                                        <option value="{{$validPerm}}">{{$validPerm}}</option>
-                                                        {{end}}
-                                                    </select>
-                                                </div>
-                                                <div class="form-group col-md-1">
-                                                    <button class="btn btn-circle btn-danger remove_dirperms_btn_frm_field">
-                                                        <i class="fas fa-trash"></i>
-                                                    </button>
-                                                </div>
+                                            <div class="col-md-3 mt-3 mt-md-8">
+                                                <select name="vfolder_name" data-i18n="[data-placeholder]general.folder_placeholder" class="form-select select-repetear" data-placeholder="Select a folder" data-allow-clear="true">
+                                                    <option value=""></option>
+                                                    {{- range $.VirtualFolders}}
+                                                    <option value="{{.Name}}" {{- if eq $val.Name .Name}} selected{{- end}}>{{.Name}}</option>
+                                                    {{- end}}
+                                                </select>
+                                            </div>
+                                            <div class="col-md-3 mt-3 mt-md-8">
+                                                <input type="text" class="form-control" name="vfolder_quota_size" value="{{HumanizeBytes $val.QuotaSize}}" />
+                                                <div class="form-text" data-i18n="virtual_folders.quota_size"></div>
+                                            </div>
+                                            <div class="col-md-2 mt-3 mt-md-8">
+                                                <input type="number" min="-1" class="form-control" name="vfolder_quota_files" value="{{$val.QuotaFiles}}" />
+                                                <div class="form-text" data-i18n="virtual_folders.quota_files"></div>
+                                            </div>
+                                            <div class="col-md-1 mt-3 mt-md-8">
+                                                <a href="#" data-repeater-delete
+                                                    class="btn btn-light-danger ps-5 pe-4">
+                                                    <i class="ki-duotone ki-trash fs-2">
+                                                        <span class="path1"></span>
+                                                        <span class="path2"></span>
+                                                        <span class="path3"></span>
+                                                        <span class="path4"></span>
+                                                        <span class="path5"></span>
+                                                    </i>
+                                                </a>
                                             </div>
-                                            {{end}}
                                         </div>
                                     </div>
-
-                                    <div class="row mx-1">
-                                        <button type="button" class="btn btn-secondary add_new_dirperms_field_btn">
-                                            <i class="fas fa-plus"></i> Add new directory permissions
-                                        </button>
-                                    </div>
                                 </div>
-                            </div>
-
-                            <div class="card bg-light mb-3">
-                                <div class="card-header">
-                                    <b>Per-directory pattern restrictions</b>
-                                </div>
-                                <div class="card-body">
-                                    <h6 class="card-title mb-4">Comma separated denied or allowed files/directories, based on shell patterns.</h6>
-                                    <p class="card-text">Match is case insensitive, set you patterns as lowercase. Denied entries are visible in directory listing by default, you can hide them by setting the "Hidden" policy, but please be aware that this may cause performance issues for large directories. Setting a denied pattern as "*" and allowed pattern/s for the same directory you can create denied except rules, but note that if you allow a directory, everything in it will be allowed unless more specific patterns/permissions are defined.</p>
+                                {{- else}}
+                                <div data-repeater-item>
                                     <div class="form-group row">
-                                        <div class="col-md-12 form_field_patterns_outer">
-                                            {{range $idx, $pattern := .Group.UserSettings.Filters.GetFlatFilePatterns -}}
-                                            <div class="row form_field_patterns_outer_row">
-                                                <div class="form-group col-md-3">
-                                                    <input type="text" class="form-control" id="idPatternPath{{$idx}}" name="pattern_path{{$idx}}" placeholder="directory path, i.e. /dir" value="{{$pattern.Path}}" maxlength="255">
-                                                </div>
-                                                <div class="form-group col-md-4">
-                                                    <input type="text" class="form-control" id="idPatterns{{$idx}}" name="patterns{{$idx}}" placeholder="*.zip,?.txt" value="{{$pattern.GetCommaSeparatedPatterns}}" maxlength="255">
-                                                </div>
-                                                <div class="form-group col-md-2">
-                                                    <select class="form-control selectpicker" id="idPatternType{{$idx}}" name="pattern_type{{$idx}}">
-                                                        <option value="denied" {{if $pattern.IsDenied}}selected{{end}}>Denied</option>
-                                                        <option value="allowed" {{if $pattern.IsAllowed}}selected{{end}}>Allowed</option>
-                                                    </select>
-                                                </div>
-                                                <div class="form-group col-md-2">
-                                                    <select class="form-control selectpicker" id="idPatternPolicy{{$idx}}" name="pattern_policy{{$idx}}">
-                                                        <option value="0" {{if eq $pattern.DenyPolicy 0}}selected{{end}}>Visible</option>
-                                                        <option value="1" {{if eq $pattern.DenyPolicy 1}}selected{{end}}>Hidden</option>
-                                                    </select>
-                                                </div>
-                                                <div class="form-group col-md-1">
-                                                    <button class="btn btn-circle btn-danger remove_pattern_btn_frm_field">
-                                                        <i class="fas fa-trash"></i>
-                                                    </button>
-                                                </div>
-                                            </div>
-                                            {{else}}
-                                            <div class="row form_field_patterns_outer_row">
-                                                <div class="form-group col-md-3">
-                                                    <input type="text" class="form-control" id="idPatternPath0" name="pattern_path0" placeholder="directory path, i.e. /dir" value="" maxlength="255">
-                                                </div>
-                                                <div class="form-group col-md-4">
-                                                    <input type="text" class="form-control" id="idPatterns0" name="patterns0" placeholder="*.zip,?.txt" value="" maxlength="255">
-                                                </div>
-                                                <div class="form-group col-md-2">
-                                                    <select class="form-control selectpicker" id="idPatternType0" name="pattern_type0">
-                                                        <option value="denied">Denied</option>
-                                                        <option value="allowed">Allowed</option>
-                                                    </select>
-                                                </div>
-                                                <div class="form-group col-md-2">
-                                                    <select class="form-control selectpicker" id="idPatternPolicy0" name="pattern_policy0">
-                                                        <option value="0">Visible</option>
-                                                        <option value="1">Hidden</option>
-                                                    </select>
-                                                </div>
-                                                <div class="form-group col-md-1">
-                                                    <button class="btn btn-circle btn-danger remove_pattern_btn_frm_field">
-                                                        <i class="fas fa-trash"></i>
-                                                    </button>
-                                                </div>
-                                            </div>
-                                            {{end}}
+                                        <div class="col-md-3 mt-3 mt-md-8">
+                                            <input data-i18n="[placeholder]virtual_folders.mount_path" type="text" class="form-control" name="vfolder_path" value="" />
+                                        </div>
+                                        <div class="col-md-3 mt-3 mt-md-8">
+                                            <select name="vfolder_name" data-i18n="[data-placeholder]general.folder_placeholder" class="form-select select-repetear" data-placeholder="Select a folder" data-allow-clear="true">
+                                                <option value=""></option>
+                                                {{- range .VirtualFolders}}
+                                                <option value="{{.Name}}">{{.Name}}</option>
+                                                {{- end}}
+                                            </select>
+                                        </div>
+                                        <div class="col-md-3 mt-3 mt-md-8">
+                                            <input type="text" class="form-control" name="vfolder_quota_size" value="" />
+                                            <div class="form-text" data-i18n="virtual_folders.quota_size"></div>
+                                        </div>
+                                        <div class="col-md-2 mt-3 mt-md-8">
+                                            <input type="number" min="-1" class="form-control" name="vfolder_quota_files" value="" />
+                                            <div class="form-text" data-i18n="virtual_folders.quota_files"></div>
+                                        </div>
+                                        <div class="col-md-1 mt-3 mt-md-8">
+                                            <a href="#" data-repeater-delete
+                                                class="btn btn-light-danger ps-5 pe-4">
+                                                <i class="ki-duotone ki-trash fs-2">
+                                                    <span class="path1"></span>
+                                                    <span class="path2"></span>
+                                                    <span class="path3"></span>
+                                                    <span class="path4"></span>
+                                                    <span class="path5"></span>
+                                                </i>
+                                            </a>
                                         </div>
                                     </div>
-
-                                    <div class="row mx-1">
-                                        <button type="button" class="btn btn-secondary add_new_pattern_field_btn">
-                                            <i class="fas fa-plus"></i> Add new file pattern
-                                        </button>
-                                    </div>
-                                </div>
-                            </div>
-
-                            <div class="form-group row">
-                                <label for="idMaxSessions" class="col-sm-2 col-form-label">Max sessions</label>
-                                <div class="col-sm-10">
-                                    <input type="number" class="form-control" id="idMaxSessions" name="max_sessions" placeholder=""
-                                        value="{{.Group.UserSettings.MaxSessions}}" min="0" aria-describedby="sessionsHelpBlock">
-                                    <small id="sessionsHelpBlock" class="form-text text-muted">
-                                        Maximun number of concurrent sessions. 0 means no limit
-                                    </small>
-                                </div>
-                            </div>
-
-                            <div class="form-group row">
-                                <label for="idProtocols" class="col-sm-2 col-form-label">Denied protocols</label>
-                                <div class="col-sm-10">
-                                    <select class="form-control selectpicker" id="idProtocols" name="denied_protocols" multiple>
-                                        {{range $protocol := .ValidProtocols}}
-                                        <option value="{{$protocol}}" {{range $p :=$.Group.UserSettings.Filters.DeniedProtocols }}{{if eq $p $protocol}}selected{{end}}{{end}}>{{$protocol}}
-                                        </option>
-                                        {{end}}
-                                    </select>
-                                </div>
-                            </div>
-
-                            <div class="form-group row">
-                                <label for="idLoginMethods" class="col-sm-2 col-form-label">Denied login methods</label>
-                                <div class="col-sm-10">
-                                    <select class="form-control selectpicker" id="idLoginMethods" name="denied_login_methods" multiple aria-describedby="deniedLoginMethodsHelpBlock">
-                                        {{range $method := .ValidLoginMethods}}
-                                        <option value="{{$method}}" {{range $m :=$.Group.UserSettings.Filters.DeniedLoginMethods }}{{if eq $m $method}}selected{{end}}{{end}}>{{$method}}
-                                        </option>
-                                        {{end}}
-                                    </select>
-                                    <small id="deniedLoginMethodsHelpBlock" class="form-text text-muted">
-                                        "password" is valid for all supported protocols, "password-over-SSH" only for SSH/SFTP/SCP
-                                    </small>
-                                </div>
-                            </div>
-
-                            <div class="form-group row">
-                                <label for="idTwoFactorProtocols" class="col-sm-2 col-form-label">Require two-factor auth for</label>
-                                <div class="col-sm-10">
-                                    <select class="form-control selectpicker" id="idTwoFactorProtocols" name="required_two_factor_protocols" multiple>
-                                        {{range $protocol := .TwoFactorProtocols}}
-                                        <option value="{{$protocol}}" {{range $p :=$.Group.UserSettings.Filters.TwoFactorAuthProtocols }}{{if eq $p $protocol}}selected{{end}}{{end}}>{{$protocol}}
-                                        </option>
-                                        {{end}}
-                                    </select>
-                                </div>
-                            </div>
-
-                            <div class="form-group row">
-                                <label for="idWebClient" class="col-sm-2 col-form-label">Web client/REST API</label>
-                                <div class="col-sm-10">
-                                    <select class="form-control selectpicker" id="idWebClient" name="web_client_options" multiple>
-                                        {{range $option := .WebClientOptions}}
-                                        <option value="{{$option}}" {{range $p :=$.Group.UserSettings.Filters.WebClient }}{{if eq $p $option}}selected{{end}}{{end}}>{{$option}}
-                                        </option>
-                                        {{end}}
-                                    </select>
-                                </div>
-                            </div>
-
-                            <div class="form-group row">
-                                <label for="idDeniedIP" class="col-sm-2 col-form-label">Denied IP/Mask</label>
-                                <div class="col-sm-10">
-                                    <textarea class="form-control" id="idDeniedIP" name="denied_ip" rows="3" placeholder=""
-                                        aria-describedby="deniedIPHelpBlock">{{.Group.GetDeniedIPAsString}}</textarea>
-                                    <small id="deniedIPHelpBlock" class="form-text text-muted">
-                                        Comma separated IP/Mask in CIDR format, example: "192.168.1.0/24,10.8.0.100/32"
-                                    </small>
-                                </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">
-                                    <textarea class="form-control" id="idAllowedIP" name="allowed_ip" rows="3" placeholder=""
-                                        aria-describedby="allowedIPHelpBlock">{{.Group.GetAllowedIPAsString}}</textarea>
-                                    <small id="allowedIPHelpBlock" class="form-text text-muted">
-                                        Comma separated IP/Mask in CIDR format, example: "192.168.1.0/24,10.8.0.100/32"
-                                    </small>
                                 </div>
+                                {{- end}}
                             </div>
+                        </div>
 
+                        <div class="form-group mt-5">
+                            <a href="#" data-repeater-create class="btn btn-light-primary">
+                                <i class="ki-duotone ki-plus fs-3"></i>
+                                <span data-i18n="general.add">Add</span>
+                            </a>
                         </div>
                     </div>
                 </div>
-                <div class="card">
-                    <div class="card-header" id="headingQuota">
-                        <h2 class="mb-0">
-                            <button class="btn btn-link btn-block text-left collapsed" type="button" data-toggle="collapse"
-                                data-target="#collapseQuota" aria-expanded="false" aria-controls="collapseQuota">
-                                <h6 class="m-0 font-weight-bold text-primary">Disk quota and bandwidth limits</h6>
-                            </button>
-                        </h2>
-                    </div>
-                    <div id="collapseQuota" class="collapse" aria-labelledby="headingQuota" data-parent="#accordionUser">
-                        <div class="card-body">
-
-                            <div class="form-group row">
-                                <label for="idQuotaSize" class="col-sm-2 col-form-label">Quota size</label>
-                                <div class="col-sm-3">
-                                    <input type="text" class="form-control" id="idQuotaSize" name="quota_size" placeholder=""
-                                        value="{{HumanizeBytes .Group.UserSettings.QuotaSize}}" aria-describedby="qsHelpBlock">
-                                    <small id="qsHelpBlock" class="form-text text-muted">
-                                        0 means no limit. You can use MB/GB/TB suffix
-                                    </small>
-                                </div>
-                                <div class="col-sm-2"></div>
-                                <label for="idQuotaFiles" class="col-sm-2 col-form-label">Quota files</label>
-                                <div class="col-sm-3">
-                                    <input type="number" class="form-control" id="idQuotaFiles" name="quota_files" placeholder=""
-                                        value="{{.Group.UserSettings.QuotaFiles}}" min="0" aria-describedby="qfHelpBlock">
-                                    <small id="qfHelpBlock" class="form-text text-muted">
-                                        0 means no limit
-                                    </small>
-                                </div>
-                            </div>
+            </div>
+            {{- end}}
 
-                            <div class="form-group row">
-                                <label for="idMaxUploadSize" class="col-sm-2 col-form-label">Max file upload size</label>
-                                <div class="col-sm-10">
-                                    <input type="text" class="form-control" id="idMaxUploadSize" name="max_upload_file_size"
-                                        placeholder="" value="{{HumanizeBytes .Group.UserSettings.Filters.MaxUploadFileSize}}"
-                                        aria-describedby="fqsHelpBlock">
-                                    <small id="fqsHelpBlock" class="form-text text-muted">
-                                        Maximum upload size for a single file. 0 means no limit. You can use MB/GB/TB suffix
-                                    </small>
-                                </div>
-                            </div>
+            <div class="accordion shadow-sm mt-10" id="accordionUser">
 
-                            <div class="form-group row">
-                                <label for="idUploadBandwidth" class="col-sm-2 col-form-label">Bandwidth UL (KB/s)</label>
-                                <div class="col-sm-3">
-                                    <input type="number" class="form-control" id="idUploadBandwidth" name="upload_bandwidth"
-                                        placeholder="" value="{{.Group.UserSettings.UploadBandwidth}}" min="0" aria-describedby="ulHelpBlock">
-                                    <small id="ulHelpBlock" class="form-text text-muted">
-                                        0 means no limit
-                                    </small>
-                                </div>
-                                <div class="col-sm-2"></div>
-                                <label for="idDownloadBandwidth" class="col-sm-2 col-form-label">Bandwidth DL (KB/s)</label>
-                                <div class="col-sm-3">
-                                    <input type="number" class="form-control" id="idDownloadBandwidth" name="download_bandwidth"
-                                        placeholder="" value="{{.Group.UserSettings.DownloadBandwidth}}" min="0" aria-describedby="dlHelpBlock">
-                                    <small id="dlHelpBlock" class="form-text text-muted">
-                                        0 means no limit
-                                    </small>
-                                </div>
-                            </div>
+                <div class="accordion-item">
+                    <h2 class="accordion-header" id="headingPermissions">
+                        <button class="accordion-button section-title-inner text-primary collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapsePermissions" aria-expanded="true" aria-controls="collapsePermissions">
+                            <span data-i18n="general.acls">ACLs</span>
+                        </button>
+                    </h2>
+                    <div id="collapsePermissions" class="accordion-collapse collapse" aria-labelledby="headingPermissions" data-bs-parent="#accordionUser">
+                        <div class="accordion-body">
 
-                            <div class="card bg-light mb-3">
-                                <div class="card-header">
-                                    <b>Per-source bandwidth speed limits</b>
+                            <div class="card mt-10">
+                                <div class="card-header bg-light">
+                                    <h3 data-i18n="filters.directory_permissions" class="card-title section-title-inner">Per-directory permissions</h3>
                                 </div>
                                 <div class="card-body">
-                                    <div class="form-group row">
-                                        <div class="col-md-12 form_field_bwlimits_outer">
-                                            {{range $idx, $bwLimit := .Group.UserSettings.Filters.BandwidthLimits -}}
-                                            <div class="row form_field_bwlimits_outer_row">
-                                                <div class="form-group col-md-8">
-                                                    <textarea class="form-control" id="idBandwidthLimitSources{{$idx}}" name="bandwidth_limit_sources{{$idx}}" rows="4" placeholder=""
-                                                            aria-describedby="bwLimitSourcesHelpBlock{{$idx}}">{{$bwLimit.GetSourcesAsString}}</textarea>
-                                                    <small id="bwLimitSourcesHelpBlock{{$idx}}" class="form-text text-muted">
-                                                        Comma separated IP/Mask in CIDR format, example: "192.168.1.0/24,10.8.0.100/32"
-                                                    </small>
-                                                </div>
-                                                <div class="col-md-3">
-                                                    <div class="form-group">
-                                                        <input type="number" class="form-control" id="idUploadBandwidthSource{{$idx}}" name="upload_bandwidth_source{{$idx}}"
-                                                            placeholder="" value="{{$bwLimit.UploadBandwidth}}" min="0" aria-describedby="ulHelpBlock{{$idx}}">
-                                                        <small id="ulHelpBlock{{$idx}}" class="form-text text-muted">
-                                                            UL (KB/s). 0 means no limit
-                                                        </small>
-                                                    </div>
-                                                    <div class="form-group">
-                                                        <input type="number" class="form-control" id="idDownloadBandwidthSource{{$idx}}" name="download_bandwidth_source{{$idx}}"
-                                                            placeholder="" value="{{$bwLimit.DownloadBandwidth}}" min="0" aria-describedby="dlHelpBlock{{$idx}}">
-                                                        <small id="dlHelpBlock{{$idx}}" class="form-text text-muted">
-                                                            DL (KB/s). 0 means no limit
-                                                        </small>
+                                    <div id="directory_permissions">
+                                        <p class="fs-5 fw-semibold mb-4" data-i18n="filters.directory_permissions_help"></p>
+                                        <div class="form-group">
+                                            <div data-repeater-list="directory_permissions">
+                                                {{- range $idx, $dirPerms := .Group.GetPermissions -}}
+                                                <div data-repeater-item>
+                                                    <div class="form-group row">
+                                                        <div class="col-md-6 mt-3 mt-md-8">
+                                                            <input data-i18n="[placeholder]filters.directory_path_help" type="text" class="form-control" name="sub_perm_path" value="{{$dirPerms.Path}}" />
+                                                        </div>
+                                                        <div class="col-md-5 mt-3 mt-md-8">
+                                                            <select name="sub_perm_permissions" data-i18n="[data-placeholder]general.permissions" class="form-select select-repetear" data-hide-search="true" data-close-on-select="false" multiple>
+                                                                {{- range $validPerm := $.ValidPerms}}
+                                                                <option value="{{$validPerm}}" {{- range $perm := $dirPerms.Permissions }}{{- if eq $perm $validPerm}} selected{{- end}}{{- end}}>{{$validPerm}}</option>
+                                                                {{- end}}
+                                                            </select>
+                                                        </div>
+                                                        <div class="col-md-1 mt-3 mt-md-8">
+                                                            <a href="#" data-repeater-delete
+                                                                class="btn btn-light-danger ps-5 pe-4">
+                                                                <i class="ki-duotone ki-trash fs-2">
+                                                                    <span class="path1"></span>
+                                                                    <span class="path2"></span>
+                                                                    <span class="path3"></span>
+                                                                    <span class="path4"></span>
+                                                                    <span class="path5"></span>
+                                                                </i>
+                                                            </a>
+                                                        </div>
                                                     </div>
                                                 </div>
-
-                                                <div class="form-group col-md-1">
-                                                    <button class="btn btn-circle btn-danger remove_bwlimit_btn_frm_field">
-                                                        <i class="fas fa-trash"></i>
-                                                    </button>
-                                                </div>
-                                            </div>
-                                            {{else}}
-                                            <div class="row form_field_bwlimits_outer_row">
-                                                <div class="form-group col-md-8">
-                                                    <textarea class="form-control" id="idBandwidthLimitSources0" name="bandwidth_limit_sources0" rows="4" placeholder=""
-                                                            aria-describedby="bwLimitSourcesHelpBlock0"></textarea>
-                                                    <small id="bwLimitSourcesHelpBlock0" class="form-text text-muted">
-                                                        Comma separated IP/Mask in CIDR format, example: "192.168.1.0/24,10.8.0.100/32"
-                                                    </small>
-                                                </div>
-                                                <div class="col-md-3">
-                                                    <div class="form-group">
-                                                        <input type="number" class="form-control" id="idUploadBandwidthSource0" name="upload_bandwidth_source0"
-                                                            placeholder="" value="" min="0" aria-describedby="ulHelpBlock0">
-                                                        <small id="ulHelpBlock0" class="form-text text-muted">
-                                                            UL (KB/s). 0 means no limit
-                                                        </small>
-                                                    </div>
-                                                    <div class="form-group">
-                                                        <input type="number" class="form-control" id="idDownloadBandwidthSource0" name="download_bandwidth_source0"
-                                                            placeholder="" value="" min="0" aria-describedby="dlHelpBlock0">
-                                                        <small id="dlHelpBlock0" class="form-text text-muted">
-                                                            DL (KB/s). 0 means no limit
-                                                        </small>
+                                                {{- else}}
+                                                <div data-repeater-item>
+                                                    <div class="form-group row">
+                                                        <div class="col-md-6 mt-3 mt-md-8">
+                                                            <input data-i18n="[placeholder]filters.directory_path_help" type="text" class="form-control" name="sub_perm_path" value="" />
+                                                        </div>
+                                                        <div class="col-md-5 mt-3 mt-md-8">
+                                                            <select name="sub_perm_permissions" data-i18n="[data-placeholder]general.permissions" class="form-select select-repetear" data-hide-search="true" data-close-on-select="false" multiple>
+                                                                {{- range $validPerm := .ValidPerms}}
+                                                                <option value="{{$validPerm}}">{{$validPerm}}</option>
+                                                                {{- end}}
+                                                            </select>
+                                                        </div>
+                                                        <div class="col-md-1 mt-3 mt-md-8">
+                                                            <a href="#" data-repeater-delete
+                                                                class="btn btn-light-danger ps-5 pe-4">
+                                                                <i class="ki-duotone ki-trash fs-2">
+                                                                    <span class="path1"></span>
+                                                                    <span class="path2"></span>
+                                                                    <span class="path3"></span>
+                                                                    <span class="path4"></span>
+                                                                    <span class="path5"></span>
+                                                                </i>
+                                                            </a>
+                                                        </div>
                                                     </div>
                                                 </div>
-                                                <div class="form-group col-md-1">
-                                                    <button class="btn btn-circle btn-danger remove_bwlimit_btn_frm_field">
-                                                        <i class="fas fa-trash"></i>
-                                                    </button>
-                                                </div>
+                                                {{- end}}
                                             </div>
-                                            {{end}}
                                         </div>
-                                    </div>
 
-                                    <div class="row mx-1">
-                                        <button type="button" class="btn btn-secondary add_new_bwlimit_field_btn">
-                                            <i class="fas fa-plus"></i> Add new speed limit
-                                        </button>
+                                        <div class="form-group mt-5">
+                                            <a href="#" data-repeater-create class="btn btn-light-primary">
+                                                <i class="ki-duotone ki-plus fs-3"></i>
+                                                <span data-i18n="general.add">Add</span>
+                                            </a>
+                                        </div>
                                     </div>
                                 </div>
                             </div>
 
-                            <div class="form-group row">
-                                <label for="idTransferUL" class="col-sm-2 col-form-label">Upload data transfer (MB)</label>
-                                <div class="col-sm-3">
-                                    <input type="number" class="form-control" id="idTransferUL" name="upload_data_transfer" placeholder=""
-                                        value="{{.Group.UserSettings.UploadDataTransfer}}" min="0" aria-describedby="ulTransferHelpBlock">
-                                    <small id="ulTransferHelpBlock" class="form-text text-muted">
-                                        Maximum data transfer allowed for uploads. 0 means no limit
-                                    </small>
-                                </div>
-                                <div class="col-sm-2"></div>
-                                <label for="idTransferDL" class="col-sm-2 col-form-label">Download data transfer (MB)</label>
-                                <div class="col-sm-3">
-                                    <input type="number" class="form-control" id="idTransferDL" name="download_data_transfer" placeholder=""
-                                        value="{{.Group.UserSettings.DownloadDataTransfer}}" min="0" aria-describedby="dlTransferHelpBlock">
-                                    <small id="dlTransferHelpBlock" class="form-text text-muted">
-                                        Maximum data transfer allowed for downloads. 0 means no limit
-                                    </small>
-                                </div>
-                            </div>
+                            {{- template "user_group_perms" .Group.UserSettings.Filters}}
 
-                            <div class="form-group row">
-                                <label for="idTransferTotal" class="col-sm-2 col-form-label">Total data transfer (MB)</label>
-                                <div class="col-sm-10">
-                                    <input type="number" class="form-control" id="idTransferTotal" name="total_data_transfer"
-                                        placeholder="" value="{{.Group.UserSettings.TotalDataTransfer}}" min="0"
-                                        aria-describedby="totalTransferHelpBlock">
-                                    <small id="totalTransferHelpBlock" class="form-text text-muted">
-                                        Maximum data transfer allowed for uploads + downloads. Replace the individual limits. 0 means no limit
-                                    </small>
+                            <div class="form-group row mt-10">
+                                <label for="idMaxSessions" data-i18n="filters.max_sessions" class="col-md-3 col-form-label">Max sessions</label>
+                                <div class="col-md-9">
+                                    <input id="idMaxSessions" type="number" min="0" class="form-control" name="max_sessions" value="{{.Group.UserSettings.MaxSessions}}" aria-describedby="idMaxSessionsHelp" />
+                                    <div id="idMaxSessionsHelp" class="form-text" data-i18n="filters.max_sessions_help"></div>
                                 </div>
                             </div>
 
-                        </div>
-                    </div>
-                </div>
-
-                <div class="card">
-                    <div class="card-header" id="headingAdvanced">
-                        <h2 class="mb-0">
-                            <button class="btn btn-link btn-block text-left collapsed" type="button" data-toggle="collapse"
-                                data-target="#collapseAdvanced" aria-expanded="false" aria-controls="collapseAdvanced">
-                                <h6 class="m-0 font-weight-bold text-primary">More</h6>
-                            </button>
-                        </h2>
-                    </div>
-                    <div id="collapseAdvanced" class="collapse" aria-labelledby="headingAdvanced" data-parent="#accordionUser">
-                        <div class="card-body">
-
-                            <div class="form-group row">
-                                <label for="idStartDirectory" class="col-sm-2 col-form-label">Start directory</label>
-                                <div class="col-sm-10">
-                                    <input type="text" class="form-control" id="idStartDirectory" name="start_directory" placeholder=""
-                                        value="{{.Group.UserSettings.Filters.StartDirectory}}" aria-describedby="startDirHelpBlock">
-                                    <small id="startDirHelpBlock" class="form-text text-muted">
-                                        Alternate start directory to use instead of "/". Supported for SFTP/FTP/HTTP
-                                    </small>
+                            <div class="form-group row mt-10">
+                                <label for="idProtocols" data-i18n="filters.denied_protocols" class="col-md-3 col-form-label">
+                                    Denied protocols
+                                </label>
+                                <div class="col-md-9">
+                                    <select id="idProtocols" name="denied_protocols" class="form-select" data-control="i18n-select2" data-close-on-select="false" multiple>
+                                        {{- range $protocol := .ValidProtocols}}
+                                        <option value="{{$protocol}}" {{- range $p :=$.Group.UserSettings.Filters.DeniedProtocols }}{{- if eq $p $protocol}} selected{{- end}}{{- end}}>{{$protocol}}</option>
+                                        {{- end}}
+                                    </select>
                                 </div>
                             </div>
 
-                            <div class="form-group row">
-                                <label for="idTLSUsername" class="col-sm-2 col-form-label">TLS username</label>
-                                <div class="col-sm-10">
-                                    <select class="form-control selectpicker" id="idTLSUsername" name="tls_username" aria-describedby="tlsUsernameHelpBlock">
-                                        <option value="" {{if or (eq .Group.UserSettings.Filters.TLSUsername "None") (eq .Group.UserSettings.Filters.TLSUsername "") }}selected{{end}}>None</option>
-                                        <option value="CommonName" {{if eq .Group.UserSettings.Filters.TLSUsername "CommonName" }}selected{{end}}>Common Name</option>
+                            <div class="form-group row mt-10">
+                                <label for="idLoginMethods" data-i18n="filters.denied_login_methods" class="col-md-3 col-form-label">
+                                    Denied login methods
+                                </label>
+                                <div class="col-md-9">
+                                    <select id="idLoginMethods" name="denied_login_methods" class="form-select" data-control="i18n-select2" data-close-on-select="false" multiple aria-describedby="idLoginMethodsHelp">
+                                        {{- range $method := .ValidLoginMethods}}
+                                        <option value="{{$method}}" {{- range $m :=$.Group.UserSettings.Filters.DeniedLoginMethods }}{{- if eq $m $method}} selected{{- end}}{{- end}}>{{$method}}</option>
+                                        {{- end}}
                                     </select>
-                                    <small id="tlsUsernameHelpBlock" class="form-text text-muted">
-                                        Defines the TLS certificate field to use as username. Ignored if mutual TLS is disabled
-                                    </small>
+                                    <div id="idLoginMethodsHelp" data-i18n="filters.denied_login_methods_help" class="form-text">
+                                    </div>
                                 </div>
                             </div>
 
-                            <div class="form-group row">
-                                <label for="idFTPSecurity" class="col-sm-2 col-form-label">FTP security</label>
-                                <div class="col-sm-10">
-                                    <select class="form-control selectpicker" id="idFTPSecurity" name="ftp_security" aria-describedby="ftpSecurityHelpBlock">
-                                        <option value="" {{if eq .Group.UserSettings.Filters.FTPSecurity 0 }}selected{{end}}>Server settings</option>
-                                        <option value="1" {{if eq .Group.UserSettings.Filters.FTPSecurity 1 }}selected{{end}}>Mandatory encryption</option>
+                            <div class="form-group row mt-10">
+                                <label for="idTwoFactorProtocols" data-i18n="2fa.require_for" class="col-md-3 col-form-label">
+                                    Require 2FA for
+                                </label>
+                                <div class="col-md-9">
+                                    <select id="idTwoFactorProtocols" name="required_two_factor_protocols" class="form-select" data-control="i18n-select2" data-close-on-select="false" multiple>
+                                        {{- range $protocol := .TwoFactorProtocols}}
+                                        <option value="{{$protocol}}" {{- range $p :=$.Group.UserSettings.Filters.TwoFactorAuthProtocols }}{{- if eq $p $protocol}} selected{{- end}}{{- end}}>{{$protocol}}</option>
+                                        {{end}}
                                     </select>
-                                    <small id="ftpSecurityHelpBlock" class="form-text text-muted">
-                                        Ignored if TLS is globally required for all FTP users
-                                    </small>
                                 </div>
                             </div>
 
-                            <div class="form-group row">
-                                <label for="idExpiresIn" class="col-sm-2 col-form-label">Expires in</label>
-                                <div class="col-sm-10">
-                                    <input type="number" class="form-control" id="idExpiresIn" name="expires_in"
-                                        value="{{.Group.UserSettings.ExpiresIn}}" min="0" aria-describedby="expiresInHelpBlock">
-                                    <small id="expiresInHelpBlock" class="form-text text-muted">
-                                        Account expiration as number of days from the creation. 0 means no expiration
-                                    </small>
+                            <div class="form-group row mt-10">
+                                <label for="idWebClient" data-i18n="filters.web_client_options" class="col-md-3 col-form-label">
+                                    Web client/REST API
+                                </label>
+                                <div class="col-md-9">
+                                    <select id="idWebClient" name="web_client_options" class="form-select" data-control="i18n-select2" data-close-on-select="false" multiple>
+                                        {{- range $option := .WebClientOptions}}
+                                        <option value="{{$option}}" {{- range $p :=$.Group.UserSettings.Filters.WebClient }}{{- if eq $p $option}}selected{{- end}}{{- end}}>{{$option}}</option>
+                                        {{- end}}
+                                    </select>
                                 </div>
                             </div>
 
-                            <div class="form-group row">
-                                <label for="idPasswordStrength" class="col-sm-2 col-form-label">Password strength</label>
-                                <div class="col-sm-10">
-                                    <input type="number" class="form-control" id="idPasswordStrength" name="password_strength"
-                                        value="{{.Group.UserSettings.Filters.PasswordStrength}}" min="0" max="100" aria-describedby="passwordStrengthHelpBlock">
-                                    <small id="passwordStrengthHelpBlock" class="form-text text-muted">
-                                        Values in the 50-70 range are suggested for common use cases. 0 means disabled, any password will be accepted. Applied when users change their password
-                                    </small>
+                            <div class="form-group row mt-10">
+                                <label for="idDeniedIP" data-i18n="general.denied_ip_mask" class="col-md-3 col-form-label">Denied IP/Mask</label>
+                                <div class="col-md-9">
+                                    <textarea class="form-control" id="idDeniedIP" name="denied_ip" aria-describedby="idDeniedIPHelp"
+                                        rows="3">{{.Group.GetDeniedIPAsString}}</textarea>
+                                    <div id="idDeniedIPHelp" class="form-text" data-i18n="general.ip_mask_help"></div>
                                 </div>
                             </div>
 
-                            <div class="form-group row">
-                                <label for="idPasswordExpiration" class="col-sm-2 col-form-label">Password expiration</label>
-                                <div class="col-sm-10">
-                                    <input type="number" class="form-control" id="idPasswordExpiration" name="password_expiration"
-                                        value="{{.Group.UserSettings.Filters.PasswordExpiration}}" min="0" aria-describedby="passwordExpirationHelpBlock">
-                                    <small id="passwordExpirationHelpBlock" class="form-text text-muted">
-                                        Password expiration as number of days. 0 means no expiration
-                                    </small>
+                            <div class="form-group row mt-10">
+                                <label for="idAllowedIP" data-i18n="general.allowed_ip_mask" class="col-md-3 col-form-label">Allowed IP/Mask</label>
+                                <div class="col-md-9">
+                                    <textarea class="form-control" id="idAllowedIP" name="allowed_ip" aria-describedby="idAllowedIPHelp"
+                                        rows="3">{{.Group.GetAllowedIPAsString}}</textarea>
+                                    <div id="idAllowedIPHelp" class="form-text" data-i18n="general.ip_mask_help"></div>
                                 </div>
                             </div>
 
-                            <div class="form-group row">
-                                <label for="idDefaultSharesExpiration" class="col-sm-2 col-form-label">Default shares expiration</label>
-                                <div class="col-sm-10">
-                                    <input type="number" class="form-control" id="idDefaultSharesExpiration" name="default_shares_expiration"
-                                        value="{{.Group.UserSettings.Filters.DefaultSharesExpiration}}" min="0" aria-describedby="defaultSharesExpirationHelpBlock">
-                                    <small id="defaultSharesExpirationHelpBlock" class="form-text text-muted">
-                                        Default expiration for newly created shares as number of days
-                                    </small>
-                                </div>
-                            </div>
+                        </div>
+                    </div>
 
-                            <div class="form-group row">
-                                <label for="idMaxSharesExpiration" class="col-sm-2 col-form-label">Max shares expiration</label>
-                                <div class="col-sm-10">
-                                    <input type="number" class="form-control" id="idMaxSharesExpiration" name="max_shares_expiration"
-                                        value="{{.Group.UserSettings.Filters.MaxSharesExpiration}}" min="0" aria-describedby="maxSharesExpirationHelpBlock">
-                                    <small id="maxSharesExpirationHelpBlock" class="form-text text-muted">
-                                        Maximum allowed expiration, as number of days, when a user creates or updates a share
-                                    </small>
-                                </div>
-                            </div>
+                </div>
 
-                            <div class="form-group row">
-                                <label for="idHooks" class="col-sm-2 col-form-label">Hooks</label>
-                                <div class="col-sm-10">
-                                    <select class="form-control selectpicker" id="idHooks" name="hooks" multiple>
-                                        <option value="external_auth_disabled" {{if .Group.UserSettings.Filters.Hooks.ExternalAuthDisabled}}selected{{end}}>
-                                            External auth disabled
-                                        </option>
-                                        <option value="pre_login_disabled" {{if .Group.UserSettings.Filters.Hooks.PreLoginDisabled}}selected{{end}}>
-                                            Pre-login disabled
-                                        </option>
-                                        <option value="check_password_disabled" {{if .Group.UserSettings.Filters.Hooks.CheckPasswordDisabled}}selected{{end}}>
-                                            Check password disabled
-                                        </option>
-                                    </select>
-                                </div>
-                            </div>
+                <div class="accordion-item">
+                    <h2 class="accordion-header" id="headingQuota">
+                        <button class="accordion-button section-title-inner text-primary collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseQuota" aria-expanded="true" aria-controls="collapseQuota">
+                            <span data-i18n="general.quota_limits">Disk quota and bandwidth limits</span>
+                        </button>
+                    </h2>
+                    <div id="collapseQuota" class="accordion-collapse collapse" aria-labelledby="headingQuota" data-bs-parent="#accordionUser">
+                        <div class="accordion-body">
+                            {{- template "user_group_quota" .Group.UserSettings}}
+                        </div>
+                    </div>
+                </div>
 
-                            <div class="form-group">
-                                <div class="form-check">
-                                    <input type="checkbox" class="form-check-input" id="idAnonymous" name="is_anonymous"
-                                    {{if .Group.UserSettings.Filters.IsAnonymous}}checked{{end}} aria-describedby="anonymousHelpBlock">
-                                    <label for="idAnonymous" class="form-check-label">Is Anonymous</label>
-                                    <small id="anonymousHelpBlock" class="form-text text-muted">
-                                        Anonymous users are supported for FTP and WebDAV protocols and have read-only access
-                                    </small>
-                                </div>
-                            </div>
+                <div class="accordion-item">
+                    <h2 class="accordion-header" id="headingAdvanced">
+                        <button class="accordion-button section-title-inner text-primary collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseAdvanced" aria-expanded="true" aria-controls="collapseAdvanced">
+                            <span data-i18n="general.advanced_settings">Advanced settings</span>
+                        </button>
+                    </h2>
+                    <div id="collapseAdvanced" class="accordion-collapse collapse" aria-labelledby="headingAdvanced" data-bs-parent="#accordionUser">
+                        <div class="accordion-body">
 
-                            <div class="form-group">
-                                <div class="form-check">
-                                    <input type="checkbox" class="form-check-input" id="idDisableFsChecks" name="disable_fs_checks"
-                                    {{if .Group.UserSettings.Filters.DisableFsChecks}}checked{{end}} aria-describedby="disableFsChecksHelpBlock">
-                                    <label for="idDisableFsChecks" class="form-check-label">Disable filesystem checks</label>
-                                    <small id="disableFsChecksHelpBlock" class="form-text text-muted">
-                                        Disable checks for existence and automatic creation of home directory and virtual folders
-                                    </small>
+                            <div class="form-group row mt-10">
+                                <label for="idExpiresIn" data-i18n="user.expires_in" class="col-md-3 col-form-label">Expires in</label>
+                                <div class="col-md-9">
+                                    <input id="idExpiresIn" type="number" min="0" class="form-control" name="expires_in" value="{{.Group.UserSettings.ExpiresIn}}" aria-describedby="idExpiresIn" />
+                                    <div id="idExpiresInHelp" class="form-text" data-i18n="user.expires_in_help"></div>
                                 </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 .Group.UserSettings.Filters.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 the associated users, in REST API, with an API key
-                                    </small>
-                                </div>
-                            </div>
+                            {{- template "user_group_profile" .Group.UserSettings.Filters}}
+
+                            {{- template "user_group_advanced" .Group.UserSettings.Filters}}
 
-                            <div class="form-group row {{if not .Group.HasExternalAuth}}d-none{{end}}">
-                                <label for="idExtAuthCacheTime" class="col-sm-2 col-form-label">External auth cache time</label>
-                                <div class="col-sm-10">
-                                    <input type="number" min="0" class="form-control" id="idExtAuthCacheTime" name="external_auth_cache_time" placeholder=""
-                                        value="{{.Group.UserSettings.Filters.ExternalAuthCacheTime}}" aria-describedby="extAuthCacheHelpBlock">
-                                    <small id="extAuthCacheHelpBlock" class="form-text text-muted">
-                                        Cache time, in seconds, for users authenticated using an external auth hook. 0 means no cache
-                                    </small>
+                            <div class="form-group row mt-10 {{if not .Group.HasExternalAuth}}d-none{{end}}">
+                                <label for="idExtAuthCacheTime" data-i18n="filters.external_auth_cache_time" class="col-md-3 col-form-label">External auth cache time</label>
+                                <div class="col-md-9">
+                                    <input id="idExtAuthCacheTime" type="number" min="0" class="form-control" name="external_auth_cache_time" value="{{.Group.UserSettings.Filters.ExternalAuthCacheTime}}" aria-describedby="idExtAuthCacheTimeHelp" />
+                                    <div id="idExtAuthCacheTimeHelp" class="form-text" data-i18n="filters.external_auth_cache_time_help"></div>
                                 </div>
                             </div>
 
                         </div>
                     </div>
                 </div>
+
             </div>
-            <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
-            <div class="col-sm-12 text-right px-0">
-                <button type="submit" class="btn btn-primary mt-3 ml-3 px-5" name="form_action" value="submit">Submit</button>
+
+            <div class="d-flex justify-content-end mt-12">
+                <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
+                <button type="submit" id="form_submit" class="btn btn-primary px-10" name="form_action" value="submit">
+                    <span data-i18n="general.submit" class="indicator-label">
+                        Submit
+                    </span>
+                    <span data-i18n="general.wait" class="indicator-progress">
+                        Please wait...
+                        <span class="spinner-border spinner-border-sm align-middle ms-2"></span>
+                    </span>
+                </button>
             </div>
         </form>
     </div>
 </div>
-{{end}}
-
-{{define "extra_js"}}
-<script src="{{.StaticURL}}/vendor/bootstrap-select/js/bootstrap-select.min.js"></script>
-<script type="text/javascript">
-    $(document).ready(function () {
-        {{if .Error}}
-        $('#accordionUser .collapse').removeAttr("data-parent").collapse('show');
-        {{end}}
+{{- end}}
+{{- define "extra_js"}}
+<script {{- if .CSPNonce}} nonce="{{.CSPNonce}}"{{- end}} src="{{.StaticURL}}/assets/plugins/custom/formrepeater/formrepeater.bundle.js"></script>
+<script type="text/javascript" {{- if .CSPNonce}} nonce="{{.CSPNonce}}"{{- end}}>
+    function onFilesystemChanged(val){
+        $('.form-group.fsconfig').hide();
+        $('.form-group.fsconfig-'+val).show();
+    }
+
+    $(document).on("i18nload", function(){
+        initRepeater('#virtual_folders');
+        initRepeater('#directory_permissions');
+        initRepeater('#directory_patterns');
+        initRepeater('#src_bandwidth_limits');
+        initRepeaterItems();
+        //{{- if .Error}}
+        $('#accordionUser .collapse').removeAttr("data-bs-parent").collapse('show');
+        //{{- end}}
         onFilesystemChanged('{{.Group.UserSettings.FsConfig.Provider.Name}}');
+
+        $('#idFilesystem').on("change", function(){
+            onFilesystemChanged(this.value);
+        });
     });
-</script>
 
-{{template "fsjs"}}
-{{template "shared_user_group" .}}
-{{end}}
+    $(document).on("i18nshow", function(){
+        $('#group_form').submit(function (event) {
+			let submitButton = document.querySelector('#form_submit');
+			submitButton.setAttribute('data-kt-indicator', 'on');
+			submitButton.disabled = true;
+        });
+    });
+</script>
+{{- end}}

+ 10 - 348
templates/webadmin/user.html

@@ -400,37 +400,7 @@ explicit grant from the SFTPGo Team ([email protected]).
                                 </div>
                             </div>
 
-                            <div class="form-group row mt-10">
-                                <label for="idPasswordStrength" data-i18n="filters.password_strength" class="col-md-3 col-form-label">Password strength</label>
-                                <div class="col-md-9">
-                                    <input id="idPasswordStrength" type="number" min="0" max="100" class="form-control" name="password_strength" value="{{.User.Filters.PasswordStrength}}" aria-describedby="idPasswordStrengthHelp"/>
-                                    <div id="idPasswordStrengthHelp" class="form-text" data-i18n="filters.password_strength_help"></div>
-                                </div>
-                            </div>
-
-                            <div class="form-group row mt-10">
-                                <label for="idPasswordExpiration" data-i18n="filters.password_expiration" class="col-md-3 col-form-label">Password expiration</label>
-                                <div class="col-md-9">
-                                    <input id="idPasswordExpiration" type="number" min="0" class="form-control" name="password_expiration" value="{{.User.Filters.PasswordExpiration}}" aria-describedby="idPasswordExpirationHelp"/>
-                                    <div id="idPasswordExpirationHelp" class="form-text" data-i18n="filters.password_expiration_help"></div>
-                                </div>
-                            </div>
-
-                            <div class="form-group row mt-10">
-                                <label for="idDefaultSharesExpiration" data-i18n="filters.default_shares_expiration" class="col-md-3 col-form-label">Default shares expiration</label>
-                                <div class="col-md-9">
-                                    <input id="idDefaultSharesExpiration" type="number" min="0" class="form-control" name="default_shares_expiration" value="{{.User.Filters.DefaultSharesExpiration}}" aria-describedby="idDefaultSharesExpirationHelp"/>
-                                    <div id="idDefaultSharesExpirationHelp" class="form-text" data-i18n="filters.default_shares_expiration_help"></div>
-                                </div>
-                            </div>
-
-                            <div class="form-group row mt-10">
-                                <label for="idMaxSharesExpiration" data-i18n="filters.max_shares_expiration" class="col-md-3 col-form-label">Max shares expiration</label>
-                                <div class="col-md-9">
-                                    <input id="idMaxSharesExpiration" type="number" min="0" class="form-control" name="max_shares_expiration" value="{{.User.Filters.MaxSharesExpiration}}" aria-describedby="idMaxSharesExpirationHelp"/>
-                                    <div id="idMaxSharesExpirationHelp" class="form-text" data-i18n="filters.max_shares_expiration_help"></div>
-                                </div>
-                            </div>
+                            {{- template "user_group_profile" .User.Filters}}
 
                             <div class="form-group row mt-10">
                                 <label for="idDescription" data-i18n="general.description" class="col-md-3 col-form-label">Description</label>
@@ -549,98 +519,7 @@ explicit grant from the SFTPGo Team ([email protected]).
                                 </div>
                             </div>
 
-                            <div class="card mt-10">
-                                <div class="card-header bg-light">
-                                    <h3 data-i18n="filters.directory_patterns" class="card-title section-title-inner">Per-directory name patterns restrictions</h3>
-                                </div>
-                                <div class="card-body">
-                                    <div id="directory_patterns">
-                                        <p class="fs-5 fw-semibold mb-4" data-i18n="filters.directory_patterns_help"></p>
-                                        <div class="form-group">
-                                            <div data-repeater-list="directory_patterns">
-                                                {{- range $idx, $pattern := .User.Filters.GetFlatFilePatterns -}}
-                                                <div data-repeater-item>
-                                                    <div class="form-group row">
-                                                        <div class="col-md-4 mt-3 mt-md-8">
-                                                            <input data-i18n="[placeholder]filters.directory_path_help" type="text" class="form-control" name="pattern_path" value="{{$pattern.Path}}" />
-                                                        </div>
-                                                        <div class="col-md-3 mt-3 mt-md-8">
-                                                            <input type="text" class="form-control" name="patterns" placeholder="*.png,*.zip" value="{{$pattern.GetCommaSeparatedPatterns}}" />
-                                                        </div>
-                                                        <div class="col-md-2 mt-3 mt-md-8">
-                                                            <select name="pattern_type" class="form-select select-repetear select-first" data-hide-search="true">
-                                                                <option value="denied" data-i18n="general.denied" {{- if $pattern.IsDenied}} selected{{- end}}>Denied</option>
-                                                                <option value="allowed" data-i18n="general.allowed" {{- if $pattern.IsAllowed}} selected{{- end}}>Allowed</option>
-                                                            </select>
-                                                        </div>
-                                                        <div class="col-md-2 mt-3 mt-md-8">
-                                                            <select name="pattern_policy" class="form-select select-repetear select-first" data-hide-search="true">
-                                                                <option value="0" data-i18n="general.visible" {{- if eq $pattern.DenyPolicy 0}} selected{{- end}}>Visible</option>
-                                                                <option value="1" data-i18n="general.hidden" {{- if eq $pattern.DenyPolicy 1}} selected{{- end}}>Hidden</option>
-                                                            </select>
-                                                        </div>
-                                                        <div class="col-md-1 mt-3 mt-md-8">
-                                                            <a href="#" data-repeater-delete
-                                                                class="btn btn-light-danger ps-5 pe-4">
-                                                                <i class="ki-duotone ki-trash fs-2">
-                                                                    <span class="path1"></span>
-                                                                    <span class="path2"></span>
-                                                                    <span class="path3"></span>
-                                                                    <span class="path4"></span>
-                                                                    <span class="path5"></span>
-                                                                </i>
-                                                            </a>
-                                                        </div>
-                                                    </div>
-                                                </div>
-                                                {{- else}}
-                                                <div data-repeater-item>
-                                                    <div class="form-group row">
-                                                        <div class="col-md-4 mt-3 mt-md-8">
-                                                            <input data-i18n="[placeholder]filters.directory_path_help" type="text" class="form-control" name="pattern_path" value="" />
-                                                        </div>
-                                                        <div class="col-md-3 mt-3 mt-md-8">
-                                                            <input type="text" class="form-control" name="patterns" placeholder="*.png,*.zip" value="" />
-                                                        </div>
-                                                        <div class="col-md-2 mt-3 mt-md-8">
-                                                            <select name="pattern_type" class="form-select select-repetear select-first" data-hide-search="true">
-                                                                <option value="denied" data-i18n="general.denied">Denied</option>
-                                                                <option value="allowed" data-i18n="general.allowed">Allowed</option>
-                                                            </select>
-                                                        </div>
-                                                        <div class="col-md-2 mt-3 mt-md-8">
-                                                            <select name="pattern_policy" class="form-select select-repetear select-first" data-hide-search="true">
-                                                                <option value="0" data-i18n="general.visible">Visible</option>
-                                                                <option value="1" data-i18n="general.hidden">Hidden</option>
-                                                            </select>
-                                                        </div>
-                                                        <div class="col-md-1 mt-3 mt-md-8">
-                                                            <a href="#" data-repeater-delete
-                                                                class="btn btn-light-danger ps-5 pe-4">
-                                                                <i class="ki-duotone ki-trash fs-2">
-                                                                    <span class="path1"></span>
-                                                                    <span class="path2"></span>
-                                                                    <span class="path3"></span>
-                                                                    <span class="path4"></span>
-                                                                    <span class="path5"></span>
-                                                                </i>
-                                                            </a>
-                                                        </div>
-                                                    </div>
-                                                </div>
-                                                {{- end}}
-                                            </div>
-                                        </div>
-
-                                        <div class="form-group mt-5">
-                                            <a href="#" data-repeater-create class="btn btn-light-primary">
-                                                <i class="ki-duotone ki-plus fs-3"></i>
-                                                <span data-i18n="general.add">Add</span>
-                                            </a>
-                                        </div>
-                                    </div>
-                                </div>
-                            </div>
+                            {{- template "user_group_perms" .User.Filters}}
 
                             <div class="form-group row mt-10">
                                 <label for="idMaxSessions" data-i18n="filters.max_sessions" class="col-md-3 col-form-label">Max sessions</label>
@@ -665,7 +544,7 @@ explicit grant from the SFTPGo Team ([email protected]).
 
                             <div class="form-group row mt-10">
                                 <label for="idLoginMethods" data-i18n="filters.denied_login_methods" class="col-md-3 col-form-label">
-                                    Denied protocols
+                                    Denied login methods
                                 </label>
                                 <div class="col-md-9">
                                     <select id="idLoginMethods" name="denied_login_methods" class="form-select" data-control="i18n-select2" data-close-on-select="false" multiple aria-describedby="idLoginMethodsHelp">
@@ -717,7 +596,7 @@ explicit grant from the SFTPGo Team ([email protected]).
                                 <label for="idAllowedIP" data-i18n="general.allowed_ip_mask" class="col-md-3 col-form-label">Allowed IP/Mask</label>
                                 <div class="col-md-9">
                                     <textarea class="form-control" id="idAllowedIP" name="allowed_ip" aria-describedby="idAllowedIPHelp"
-                                        rows="3">{{.User.GetDeniedIPAsString}}</textarea>
+                                        rows="3">{{.User.GetAllowedIPAsString}}</textarea>
                                     <div id="idAllowedIPHelp" class="form-text" data-i18n="general.ip_mask_help"></div>
                                 </div>
                             </div>
@@ -735,143 +614,7 @@ explicit grant from the SFTPGo Team ([email protected]).
                     <div id="collapseQuota" class="accordion-collapse collapse" aria-labelledby="headingQuota" data-bs-parent="#accordionUser">
                         <div class="accordion-body">
 
-                            <div class="form-group row mt-10">
-                                <label for="idQuotaSize" data-i18n="virtual_folders.quota_size" class="col-md-3 col-form-label">Quota size</label>
-                                <div class="col-md-3">
-                                    <input id="idQuotaSize" type="text" class="form-control" name="quota_size" value="{{HumanizeBytes .User.QuotaSize}}" aria-describedby="idQuotaSizeHelp" />
-                                    <div id="idQuotaSizeHelp" class="form-text" data-i18n="virtual_folders.quota_size_help"></div>
-                                </div>
-                                <div class="col-md-1"></div>
-                                <label for="idQuotaFiles" data-i18n="virtual_folders.quota_files" class="col-md-2 col-form-label">Quota files</label>
-                                <div class="col-md-3">
-                                    <input id="idQuotaFiles" type="number" min="0" class="form-control" name="quota_files" value="{{.User.QuotaFiles}}" aria-describedby="idQuotaFilesHelp" />
-                                    <div id="idQuotaFilesHelp" class="form-text" data-i18n="general.zero_no_limit_help"></div>
-                                </div>
-                            </div>
-
-                            <div class="form-group row mt-10">
-                                <label for="idMaxUploadSize" data-i18n="filters.max_upload_size" class="col-md-3 col-form-label">Max file upload size</label>
-                                <div class="col-md-9">
-                                    <input id="idMaxUploadSize" type="text" class="form-control" name="max_upload_file_size" value="{{HumanizeBytes .User.Filters.MaxUploadFileSize}}" aria-describedby="idMaxUploadSizeHelp" />
-                                    <div id="idMaxUploadSizeHelp" class="form-text" data-i18n="filters.max_upload_size_help"></div>
-                                </div>
-                            </div>
-
-                            <div class="form-group row mt-10">
-                                <label for="idUploadBandwidth" data-i18n="filters.upload_bandwidth" class="col-md-3 col-form-label">Bandwidth UL (KB/s)</label>
-                                <div class="col-md-3">
-                                    <input id="idUploadBandwidth" type="number" min="0" class="form-control" name="upload_bandwidth" value="{{.User.UploadBandwidth}}" aria-describedby="idUploadBandwidthHelp" />
-                                    <div id="idUploadBandwidthHelp" class="form-text" data-i18n="general.zero_no_limit_help"></div>
-                                </div>
-                                <div class="col-md-1"></div>
-                                <label for="idDownloadBandwidth" data-i18n="filters.download_bandwidth" class="col-md-2 col-form-label">Bandwidth DL (KB/s)</label>
-                                <div class="col-md-3">
-                                    <input id="idDownloadBandwidth" type="number" min="0" class="form-control" name="download_bandwidth" value="{{.User.DownloadBandwidth}}" aria-describedby="idDownloadBandwidthHelp" />
-                                    <div id="idDownloadBandwidthHelp" class="form-text" data-i18n="general.zero_no_limit_help"></div>
-                                </div>
-                            </div>
-
-                            <div class="card mt-10">
-                                <div class="card-header bg-light">
-                                    <h3 data-i18n="filters.src_bandwidth_limit" class="card-title section-title-inner">Per-source bandwidth speed limits</h3>
-                                </div>
-                                <div class="card-body">
-                                    <div id="src_bandwidth_limits">
-                                        <div class="form-group">
-                                            <div data-repeater-list="src_bandwidth_limits">
-                                                {{- range $idx, $bwLimit := .User.Filters.BandwidthLimits -}}
-                                                <div data-repeater-item>
-                                                    <div class="form-group row">
-                                                        <div class="col-md-7 mt-3 mt-md-8">
-                                                            <textarea class="form-control" name="bandwidth_limit_sources" rows="4">{{$bwLimit.GetSourcesAsString}}</textarea>
-                                                            <div class="form-text" data-i18n="general.ip_mask_help"></div>
-                                                        </div>
-                                                        <div class="col-md-2 mt-3 mt-md-8">
-                                                            <input type="number" min="0" class="form-control" name="upload_bandwidth_source" value="{{$bwLimit.UploadBandwidth}}" />
-                                                            <div class="form-text" data-i18n="filters.upload_bandwidth_help"></div>
-                                                        </div>
-                                                        <div class="col-md-2 mt-3 mt-md-8">
-                                                            <input type="number" min="0" class="form-control" name="download_bandwidth_source" value="{{$bwLimit.DownloadBandwidth}}" />
-                                                            <div class="form-text" data-i18n="filters.download_bandwidth_help"></div>
-                                                        </div>
-                                                        <div class="col-md-1 mt-3 mt-md-8">
-                                                            <a href="#" data-repeater-delete
-                                                                class="btn btn-light-danger ps-5 pe-4">
-                                                                <i class="ki-duotone ki-trash fs-2">
-                                                                    <span class="path1"></span>
-                                                                    <span class="path2"></span>
-                                                                    <span class="path3"></span>
-                                                                    <span class="path4"></span>
-                                                                    <span class="path5"></span>
-                                                                </i>
-                                                            </a>
-                                                        </div>
-                                                    </div>
-                                                </div>
-                                                {{- else}}
-                                                <div data-repeater-item>
-                                                    <div class="form-group row">
-                                                        <div class="col-md-7 mt-3 mt-md-8">
-                                                            <textarea class="form-control" name="bandwidth_limit_sources" rows="4"></textarea>
-                                                            <div class="form-text" data-i18n="general.ip_mask_help"></div>
-                                                        </div>
-                                                        <div class="col-md-2 mt-3 mt-md-8">
-                                                            <input type="number" min="0" class="form-control" name="upload_bandwidth_source" value="" />
-                                                            <div class="form-text" data-i18n="filters.upload_bandwidth_help"></div>
-                                                        </div>
-                                                        <div class="col-md-2 mt-3 mt-md-8">
-                                                            <input type="number" min="0" class="form-control" name="download_bandwidth_source" value="" />
-                                                            <div class="form-text" data-i18n="filters.download_bandwidth_help"></div>
-                                                        </div>
-                                                        <div class="col-md-1 mt-3 mt-md-8">
-                                                            <a href="#" data-repeater-delete
-                                                                class="btn btn-light-danger ps-5 pe-4">
-                                                                <i class="ki-duotone ki-trash fs-2">
-                                                                    <span class="path1"></span>
-                                                                    <span class="path2"></span>
-                                                                    <span class="path3"></span>
-                                                                    <span class="path4"></span>
-                                                                    <span class="path5"></span>
-                                                                </i>
-                                                            </a>
-                                                        </div>
-                                                    </div>
-                                                </div>
-                                                {{- end}}
-                                            </div>
-                                        </div>
-
-                                        <div class="form-group mt-5">
-                                            <a href="#" data-repeater-create class="btn btn-light-primary">
-                                                <i class="ki-duotone ki-plus fs-3"></i>
-                                                <span data-i18n="general.add">Add</span>
-                                            </a>
-                                        </div>
-                                    </div>
-                                </div>
-                            </div>
-
-                            <div class="form-group row mt-10">
-                                <label for="idTransferUL" data-i18n="filters.upload_data_transfer" class="col-md-3 col-form-label">Upload data transfer (MB)</label>
-                                <div class="col-md-3">
-                                    <input id="idTransferUL" type="number" min="0" class="form-control" name="upload_data_transfer" value="{{.User.UploadDataTransfer}}" aria-describedby="idTransferULHelp" />
-                                    <div id="idTransferULHelp" class="form-text" data-i18n="filters.upload_data_transfer_help"></div>
-                                </div>
-                                <div class="col-md-1"></div>
-                                <label for="idTransferDL" data-i18n="filters.download_data_transfer" class="col-md-2 col-form-label">Download data transfer (MB)</label>
-                                <div class="col-md-3">
-                                    <input id="idTransferDL" type="number" min="0" class="form-control" name="download_data_transfer" value="{{.User.DownloadDataTransfer}}" aria-describedby="idTransferDLhHelp" />
-                                    <div id="idTransferDLhHelp" class="form-text" data-i18n="filters.download_data_transfer_help"></div>
-                                </div>
-                            </div>
-
-                            <div class="form-group row mt-10">
-                                <label for="idTransferTotal" data-i18n="filters.total_data_transfer" class="col-md-3 col-form-label">Total data transfer (MB)</label>
-                                <div class="col-md-9">
-                                    <input id="idTransferTotal" type="number" min="0" class="form-control" name="total_data_transfer" value="{{.User.TotalDataTransfer}}" aria-describedby="idTransferTotalHelp" />
-                                    <div id="idTransferTotalHelp" class="form-text" data-i18n="filters.total_data_transfer_help"></div>
-                                </div>
-                            </div>
+                            {{template "user_group_quota" .User}}
 
                         </div>
                     </div>
@@ -886,50 +629,13 @@ explicit grant from the SFTPGo Team ([email protected]).
                     <div id="collapseAdvanced" class="accordion-collapse collapse" aria-labelledby="headingAdvanced" data-bs-parent="#accordionUser">
                         <div class="accordion-body">
 
-                            <div class="form-group row mt-10">
-                                <label for="idStartDirectory" data-i18n="filters.start_directory" class="col-md-3 col-form-label">Start directory</label>
-                                <div class="col-md-9">
-                                    <input id="idStartDirectory" type="text" class="form-control" name="start_directory" value="{{.User.Filters.StartDirectory}}" aria-describedby="idStartDirectoryHelp" />
-                                    <div id="idStartDirectoryHelp" class="form-text" data-i18n="filters.start_directory_help"></div>
-                                </div>
-                            </div>
-
-                            <div class="form-group row mt-10">
-                                <label for="idTLSUsername" data-i18n="filters.tls_username" class="col-md-3 col-form-label">TLS username</label>
-                                <div class="col-md-9">
-                                    <select id="idTLSUsername" name="tls_username" class="form-select" data-control="i18n-select2" data-hide-search="true" aria-describedby="idTLSUsernameHelp">
-                                        <option value="" {{if or (eq .User.Filters.TLSUsername "None") (eq .User.Filters.TLSUsername "") }}selected{{end}}>---</option>
-                                        <option value="CommonName" {{if eq .User.Filters.TLSUsername "CommonName" }}selected{{end}}>Common Name</option>
-                                    </select>
-                                    <div id="idTLSUsernameHelp" class="form-text" data-i18n="filters.tls_username_help"></div>
-                                </div>
-                            </div>
-
-                            <div class="form-group row mt-10">
-                                <label for="idFTPSecurity" data-i18n="filters.ftp_security" class="col-md-3 col-form-label">FTP security</label>
-                                <div class="col-md-9">
-                                    <select id="idFTPSecurity" name="ftp_security" class="form-select" data-control="i18n-select2" data-hide-search="true" aria-describedby="idFTPSecurityHelp">
-                                        <option value="" data-i18n="general.global_settings" {{if eq .User.Filters.FTPSecurity 0 }}selected{{end}}>Server settings</option>
-                                        <option value="1" data-i18n="general.mandatory_encryption" {{if eq .User.Filters.FTPSecurity 1 }}selected{{end}}>Mandatory encryption</option>
-                                    </select>
-                                    <div id="idFTPSecurityHelp" class="form-text" data-i18n="filters.ftp_security_help"></div>
-                                </div>
-                            </div>
+                            {{template "user_group_advanced" .User.Filters}}
 
-                            <div class="form-group row mt-10">
-                                <label for="idHooks" data-i18n="filters.hooks" class="col-md-3 col-form-label">Hooks</label>
+                            <div class="form-group row mt-10 {{if not .User.HasExternalAuth}}d-none{{end}}">
+                                <label for="idExtAuthCacheTime" data-i18n="filters.external_auth_cache_time" class="col-md-3 col-form-label">External auth cache time</label>
                                 <div class="col-md-9">
-                                    <select id="idHooks" name="hooks" class="form-select" data-control="i18n-select2" data-hide-search="true" data-close-on-select="false" multiple>
-                                        <option value="external_auth_disabled" data-i18n="filters.hook_ext_auth_disabled" {{if .User.Filters.Hooks.ExternalAuthDisabled}}selected{{end}}>
-                                            External auth disabled
-                                        </option>
-                                        <option value="pre_login_disabled" data-i18n="filters.hook_pre_login_disabled" {{if .User.Filters.Hooks.PreLoginDisabled}}selected{{end}}>
-                                            Pre-login disabled
-                                        </option>
-                                        <option value="check_password_disabled" data-i18n="filters.hook_check_password_disabled" {{if .User.Filters.Hooks.CheckPasswordDisabled}}selected{{end}}>
-                                            Check password disabled
-                                        </option>
-                                    </select>
+                                    <input id="idExtAuthCacheTime" type="number" min="0" class="form-control" name="external_auth_cache_time" value="{{.User.Filters.ExternalAuthCacheTime}}" aria-describedby="idExtAuthCacheTimeHelp" />
+                                    <div id="idExtAuthCacheTimeHelp" class="form-text" data-i18n="filters.external_auth_cache_time_help"></div>
                                 </div>
                             </div>
 
@@ -945,50 +651,6 @@ explicit grant from the SFTPGo Team ([email protected]).
                                 </div>
                             </div>
 
-                            <div class="form-group row align-items-center mt-10">
-                                <label data-i18n="filters.is_anonymous" class="col-md-3 col-form-label" for="idAnonymous">Is Anonymous</label>
-                                <div class="col-md-9">
-                                    <div class="form-check form-switch form-check-custom form-check-solid">
-                                        <input class="form-check-input" type="checkbox" id="idAnonymous" name="is_anonymous" {{if .User.Filters.IsAnonymous}}checked{{end}}/>
-                                        <label data-i18n="filters.is_anonymous_help" class="form-check-label fw-semibold text-gray-800" for="idAnonymous">
-                                            Anonymous users are supported for FTP and WebDAV protocols and have read-only access
-                                        </label>
-                                    </div>
-                                </div>
-                            </div>
-
-                            <div class="form-group row align-items-center mt-10">
-                                <label data-i18n="filters.disable_fs_checks" class="col-md-3 col-form-label" for="idDisableFsChecks">Disable filesystem checks</label>
-                                <div class="col-md-9">
-                                    <div class="form-check form-switch form-check-custom form-check-solid">
-                                        <input class="form-check-input" type="checkbox" id="idDisableFsChecks" name="disable_fs_checks" {{if .User.Filters.DisableFsChecks}}checked{{end}}/>
-                                        <label data-i18n="filters.disable_fs_checks_help" class="form-check-label fw-semibold text-gray-800" for="idDisableFsChecks">
-                                            Disable checks for existence and automatic creation of home directory and virtual folders
-                                        </label>
-                                    </div>
-                                </div>
-                            </div>
-
-                            <div class="form-group row align-items-center mt-10">
-                                <label data-i18n="general.api_key_auth" class="col-md-3 col-form-label" for="idAllowAPIKeyAuth">API key authentication</label>
-                                <div class="col-md-9">
-                                    <div class="form-check form-switch form-check-custom form-check-solid">
-                                        <input class="form-check-input" type="checkbox" id="idAllowAPIKeyAuth" name="allow_api_key_auth" {{if .User.Filters.AllowAPIKeyAuth}}checked{{end}}/>
-                                        <label data-i18n="filters.api_key_auth_help" class="form-check-label fw-semibold text-gray-800" for="idAllowAPIKeyAuth">
-                                            Allow to impersonate this user, in REST API, with an API key
-                                        </label>
-                                    </div>
-                                </div>
-                            </div>
-
-                            <div class="form-group row mt-10 {{if not .User.HasExternalAuth}}d-none{{end}}">
-                                <label for="idExtAuthCacheTime" data-i18n="filters.external_auth_cache_time" class="col-md-3 col-form-label">External auth cache time</label>
-                                <div class="col-md-9">
-                                    <input id="idExtAuthCacheTime" type="number" min="0" class="form-control" name="external_auth_cache_time" value="{{.User.Filters.ExternalAuthCacheTime}}" aria-describedby="idExtAuthCacheTimeHelp" />
-                                    <div id="idExtAuthCacheTimeHelp" class="form-text" data-i18n="filters.external_auth_cache_time_help"></div>
-                                </div>
-                            </div>
-
                         </div>
                     </div>
                 </div>