mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-03-28 10:16:37 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
097bda349a | ||
|
|
6e24517197 | ||
|
|
a3da943aa6 | ||
|
|
cc34aca2a0 | ||
|
|
fde4e9b38a | ||
|
|
c55143d8c9 | ||
|
|
8973e93cb6 | ||
|
|
8c9cac2655 | ||
|
|
ed8547ccc1 | ||
|
|
e7e53a8b8c | ||
|
|
02249491f8 | ||
|
|
cf0892922b | ||
|
|
99f31a7c26 | ||
|
|
68373604dd |
1843
CHANGELOG.md
1843
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@ require (
|
|||||||
github.com/lmittmann/tint v1.1.2
|
github.com/lmittmann/tint v1.1.2
|
||||||
github.com/mattn/go-isatty v0.0.20
|
github.com/mattn/go-isatty v0.0.20
|
||||||
github.com/mileusna/useragent v1.3.5
|
github.com/mileusna/useragent v1.3.5
|
||||||
|
github.com/orandin/slog-gorm v1.4.0
|
||||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8
|
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8
|
||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.9.1
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
@@ -42,13 +43,13 @@ require (
|
|||||||
go.opentelemetry.io/otel/sdk/log v0.10.0
|
go.opentelemetry.io/otel/sdk/log v0.10.0
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.35.0
|
go.opentelemetry.io/otel/sdk/metric v1.35.0
|
||||||
go.opentelemetry.io/otel/trace v1.37.0
|
go.opentelemetry.io/otel/trace v1.37.0
|
||||||
golang.org/x/crypto v0.42.0
|
golang.org/x/crypto v0.41.0
|
||||||
golang.org/x/image v0.31.0
|
golang.org/x/image v0.30.0
|
||||||
golang.org/x/sync v0.17.0
|
golang.org/x/sync v0.16.0
|
||||||
golang.org/x/text v0.29.0
|
golang.org/x/text v0.28.0
|
||||||
golang.org/x/time v0.13.0
|
golang.org/x/time v0.12.0
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/gorm v1.31.0
|
gorm.io/gorm v1.30.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -135,7 +136,7 @@ require (
|
|||||||
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect
|
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect
|
||||||
golang.org/x/net v0.43.0 // indirect
|
golang.org/x/net v0.43.0 // indirect
|
||||||
golang.org/x/oauth2 v0.27.0 // indirect
|
golang.org/x/oauth2 v0.27.0 // indirect
|
||||||
golang.org/x/sys v0.36.0 // indirect
|
golang.org/x/sys v0.35.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect
|
||||||
google.golang.org/grpc v1.71.0 // indirect
|
google.golang.org/grpc v1.71.0 // indirect
|
||||||
|
|||||||
@@ -218,6 +218,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
|
|||||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||||
|
github.com/orandin/slog-gorm v1.4.0 h1:FgA8hJufF9/jeNSYoEXmHPPBwET2gwlF3B85JdpsTUU=
|
||||||
|
github.com/orandin/slog-gorm v1.4.0/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA=
|
||||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8 h1:aM1/rO6p+XV+l+seD7UCtFZgsOefDTrFVLvPoZWjXZs=
|
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8 h1:aM1/rO6p+XV+l+seD7UCtFZgsOefDTrFVLvPoZWjXZs=
|
||||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8/go.mod h1:Jts8ztuE0PkUwY7VCJyp6B68ujQfr6G9P5Dn3Yx9u6w=
|
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8/go.mod h1:Jts8ztuE0PkUwY7VCJyp6B68ujQfr6G9P5Dn3Yx9u6w=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
@@ -332,13 +334,13 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
|||||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||||
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE=
|
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE=
|
||||||
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
|
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
|
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
|
||||||
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
|
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
@@ -370,8 +372,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
|||||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@@ -384,8 +386,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
@@ -405,10 +407,10 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
|||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||||
golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI=
|
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||||
golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
@@ -437,8 +439,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||||
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
|
gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4=
|
||||||
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||||
modernc.org/cc/v4 v4.26.3 h1:yEN8dzrkRFnn4PUUKXLYIqVf2PJYAEjMTFjO3BDGc3I=
|
modernc.org/cc/v4 v4.26.3 h1:yEN8dzrkRFnn4PUUKXLYIqVf2PJYAEjMTFjO3BDGc3I=
|
||||||
modernc.org/cc/v4 v4.26.3/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.26.3/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
@@ -16,10 +14,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// initApplicationImages copies the images from the images directory to the application-images directory
|
// initApplicationImages copies the images from the images directory to the application-images directory
|
||||||
func initApplicationImages() error {
|
// and returns a map containing the detected file extensions in the application-images directory.
|
||||||
// Images that are built into the Pocket ID binary
|
func initApplicationImages() (map[string]string, error) {
|
||||||
builtInImageHashes := getBuiltInImageHashes()
|
|
||||||
|
|
||||||
// Previous versions of images
|
// Previous versions of images
|
||||||
// If these are found, they are deleted
|
// If these are found, they are deleted
|
||||||
legacyImageHashes := imageHashMap{
|
legacyImageHashes := imageHashMap{
|
||||||
@@ -30,21 +26,31 @@ func initApplicationImages() error {
|
|||||||
|
|
||||||
sourceFiles, err := resources.FS.ReadDir("images")
|
sourceFiles, err := resources.FS.ReadDir("images")
|
||||||
if err != nil && !os.IsNotExist(err) {
|
if err != nil && !os.IsNotExist(err) {
|
||||||
return fmt.Errorf("failed to read directory: %w", err)
|
return nil, fmt.Errorf("failed to read directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
destinationFiles, err := os.ReadDir(dirPath)
|
destinationFiles, err := os.ReadDir(dirPath)
|
||||||
if err != nil && !os.IsNotExist(err) {
|
if err != nil && !os.IsNotExist(err) {
|
||||||
return fmt.Errorf("failed to read directory: %w", err)
|
return nil, fmt.Errorf("failed to read directory: %w", err)
|
||||||
}
|
}
|
||||||
destinationFilesMap := make(map[string]bool, len(destinationFiles))
|
dstNameToExt := make(map[string]string, len(destinationFiles))
|
||||||
for _, f := range destinationFiles {
|
for _, f := range destinationFiles {
|
||||||
|
if f.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
name := f.Name()
|
name := f.Name()
|
||||||
destFilePath := filepath.Join(dirPath, name)
|
nameWithoutExt, ext := utils.SplitFileName(name)
|
||||||
|
destFilePath := path.Join(dirPath, name)
|
||||||
|
|
||||||
|
// Skip directories
|
||||||
|
if f.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
h, err := utils.CreateSha256FileHash(destFilePath)
|
h, err := utils.CreateSha256FileHash(destFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get hash for file '%s': %w", name, err)
|
slog.Warn("Failed to get hash for file", slog.String("name", name), slog.Any("error", err))
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the file is a legacy one - if so, delete it
|
// Check if the file is a legacy one - if so, delete it
|
||||||
@@ -52,50 +58,43 @@ func initApplicationImages() error {
|
|||||||
slog.Info("Found legacy application image that will be removed", slog.String("name", name))
|
slog.Info("Found legacy application image that will be removed", slog.String("name", name))
|
||||||
err = os.Remove(destFilePath)
|
err = os.Remove(destFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to remove legacy file '%s': %w", name, err)
|
return nil, fmt.Errorf("failed to remove legacy file '%s': %w", name, err)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the file is a built-in one and save it in the map
|
// Track existing files
|
||||||
destinationFilesMap[getImageNameWithoutExtension(name)] = builtInImageHashes.Contains(h)
|
dstNameToExt[nameWithoutExt] = ext
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy images from the images directory to the application-images directory if they don't already exist
|
// Copy images from the images directory to the application-images directory if they don't already exist
|
||||||
for _, sourceFile := range sourceFiles {
|
for _, sourceFile := range sourceFiles {
|
||||||
// Skip if it's a directory
|
|
||||||
if sourceFile.IsDir() {
|
if sourceFile.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
name := sourceFile.Name()
|
name := sourceFile.Name()
|
||||||
srcFilePath := filepath.Join("images", name)
|
nameWithoutExt, ext := utils.SplitFileName(name)
|
||||||
destFilePath := filepath.Join(dirPath, name)
|
srcFilePath := path.Join("images", name)
|
||||||
|
destFilePath := path.Join(dirPath, name)
|
||||||
|
|
||||||
// Skip if there's already an image at the path
|
// Skip if there's already an image at the path
|
||||||
// We do not check the extension because users could have uploaded a different one
|
// We do not check the extension because users could have uploaded a different one
|
||||||
if imageAlreadyExists(sourceFile, destinationFilesMap) {
|
if _, exists := dstNameToExt[nameWithoutExt]; exists {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("Writing new application image", slog.String("name", name))
|
slog.Info("Writing new application image", slog.String("name", name))
|
||||||
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
|
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to copy file: %w", err)
|
return nil, fmt.Errorf("failed to copy file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track the newly copied file so it can be included in the extensions map later
|
||||||
|
dstNameToExt[nameWithoutExt] = ext
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return dstNameToExt, nil
|
||||||
}
|
|
||||||
|
|
||||||
func getBuiltInImageHashes() imageHashMap {
|
|
||||||
return imageHashMap{
|
|
||||||
"background.webp": mustDecodeHex("3fc436a66d6b872b01d96a4e75046c46b5c3e2daccd51e98ecdf98fd445599ab"),
|
|
||||||
"favicon.ico": mustDecodeHex("70f9c4b6bd4781ade5fc96958b1267511751e91957f83c2354fb880b35ec890a"),
|
|
||||||
"logo.svg": mustDecodeHex("f1e60707df9784152ce0847e3eb59cb68b9015f918ff160376c27ebff1eda796"),
|
|
||||||
"logoDark.svg": mustDecodeHex("0421a8d93714bacf54c78430f1db378fd0d29565f6de59b6a89090d44a82eb16"),
|
|
||||||
"logoLight.svg": mustDecodeHex("6d42c88cf6668f7e57c4f2a505e71ecc8a1e0a27534632aa6adec87b812d0bb0"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type imageHashMap map[string][]byte
|
type imageHashMap map[string][]byte
|
||||||
@@ -112,21 +111,6 @@ func (m imageHashMap) Contains(target []byte) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func imageAlreadyExists(sourceFile fs.DirEntry, destinationFiles map[string]bool) bool {
|
|
||||||
sourceFileWithoutExtension := getImageNameWithoutExtension(sourceFile.Name())
|
|
||||||
_, ok := destinationFiles[sourceFileWithoutExtension]
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func getImageNameWithoutExtension(fileName string) string {
|
|
||||||
idx := strings.LastIndexByte(fileName, '.')
|
|
||||||
if idx < 1 {
|
|
||||||
// No dot found, or fileName starts with a dot
|
|
||||||
return fileName
|
|
||||||
}
|
|
||||||
return fileName[:idx]
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustDecodeHex(str string) []byte {
|
func mustDecodeHex(str string) []byte {
|
||||||
b, err := hex.DecodeString(str)
|
b, err := hex.DecodeString(str)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
package bootstrap
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGetBuiltInImageData(t *testing.T) {
|
|
||||||
// Get the built-in image data map
|
|
||||||
builtInImages := getBuiltInImageHashes()
|
|
||||||
|
|
||||||
// Read the actual images directory from disk
|
|
||||||
imagesDir := filepath.Join("..", "..", "resources", "images")
|
|
||||||
actualFiles, err := os.ReadDir(imagesDir)
|
|
||||||
require.NoError(t, err, "Failed to read images directory")
|
|
||||||
|
|
||||||
// Create a map of actual files for comparison
|
|
||||||
actualFilesMap := make(map[string]struct{})
|
|
||||||
|
|
||||||
// Validate each actual file exists in the built-in data with correct hash
|
|
||||||
for _, file := range actualFiles {
|
|
||||||
fileName := file.Name()
|
|
||||||
if file.IsDir() || strings.HasPrefix(fileName, ".") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
actualFilesMap[fileName] = struct{}{}
|
|
||||||
|
|
||||||
// Check if the file exists in the built-in data
|
|
||||||
builtInHash, exists := builtInImages[fileName]
|
|
||||||
assert.True(t, exists, "File %s exists in images directory but not in getBuiltInImageData map", fileName)
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
filePath := filepath.Join(imagesDir, fileName)
|
|
||||||
|
|
||||||
// Validate SHA256 hash
|
|
||||||
actualHash, err := utils.CreateSha256FileHash(filePath)
|
|
||||||
require.NoError(t, err, "Failed to compute hash for %s", fileName)
|
|
||||||
assert.Equal(t, actualHash, builtInHash, "SHA256 hash mismatch for file %s", fileName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure the built-in data doesn't have extra files that don't exist in the directory
|
|
||||||
for fileName := range builtInImages {
|
|
||||||
_, exists := actualFilesMap[fileName]
|
|
||||||
assert.True(t, exists, "File %s exists in getBuiltInImageData map but not in images directory", fileName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure we have at least some files (sanity check)
|
|
||||||
assert.NotEmpty(t, actualFilesMap, "Images directory should contain at least one file")
|
|
||||||
assert.Len(t, actualFilesMap, len(builtInImages), "Number of files in directory should match number in built-in data map")
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,7 @@ func Bootstrap(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
slog.InfoContext(ctx, "Pocket ID is starting")
|
slog.InfoContext(ctx, "Pocket ID is starting")
|
||||||
|
|
||||||
err = initApplicationImages()
|
imageExtensions, err := initApplicationImages()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to initialize application images: %w", err)
|
return fmt.Errorf("failed to initialize application images: %w", err)
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,7 @@ func Bootstrap(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create all services
|
// Create all services
|
||||||
svc, err := initServices(ctx, db, httpClient)
|
svc, err := initServices(ctx, db, httpClient, imageExtensions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to initialize services: %w", err)
|
return fmt.Errorf("failed to initialize services: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
|
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||||
_ "github.com/golang-migrate/migrate/v4/source/github"
|
_ "github.com/golang-migrate/migrate/v4/source/github"
|
||||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||||
|
slogGorm "github.com/orandin/slog-gorm"
|
||||||
"gorm.io/driver/postgres"
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
gormLogger "gorm.io/gorm/logger"
|
gormLogger "gorm.io/gorm/logger"
|
||||||
@@ -415,17 +416,25 @@ func ensureSqliteTempDir(dbPath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getGormLogger() gormLogger.Interface {
|
func getGormLogger() gormLogger.Interface {
|
||||||
loggerCfg := gormLogger.Config{
|
loggerOpts := make([]slogGorm.Option, 0, 5)
|
||||||
SlowThreshold: 200 * time.Millisecond,
|
loggerOpts = append(loggerOpts,
|
||||||
IgnoreRecordNotFoundError: true,
|
slogGorm.WithSlowThreshold(200*time.Millisecond),
|
||||||
LogLevel: gormLogger.Warn,
|
slogGorm.WithErrorField("error"),
|
||||||
ParameterizedQueries: true,
|
)
|
||||||
|
|
||||||
|
if common.EnvConfig.LogLevel == "debug" {
|
||||||
|
loggerOpts = append(loggerOpts,
|
||||||
|
slogGorm.SetLogLevel(slogGorm.DefaultLogType, slog.LevelDebug),
|
||||||
|
slogGorm.WithRecordNotFoundError(),
|
||||||
|
slogGorm.WithTraceAll(),
|
||||||
|
)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
loggerOpts = append(loggerOpts,
|
||||||
|
slogGorm.SetLogLevel(slogGorm.DefaultLogType, slog.LevelWarn),
|
||||||
|
slogGorm.WithIgnoreTrace(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if common.EnvConfig.AppEnv == "debug" {
|
return slogGorm.New(loggerOpts...)
|
||||||
loggerCfg.IgnoreRecordNotFoundError = false
|
|
||||||
loggerCfg.LogLevel = gormLogger.Info
|
|
||||||
}
|
|
||||||
|
|
||||||
return gormLogger.NewSlogLogger(slog.Default(), loggerCfg)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
|||||||
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService, svc.jwtService)
|
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService, svc.jwtService)
|
||||||
controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.appConfigService)
|
controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.appConfigService)
|
||||||
controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
|
controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
|
||||||
|
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
|
||||||
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
|
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
|
||||||
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
|
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
|
||||||
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
|
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
|
||||||
@@ -181,9 +182,9 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
|||||||
|
|
||||||
func initLogger(r *gin.Engine) {
|
func initLogger(r *gin.Engine) {
|
||||||
loggerSkipPathsPrefix := []string{
|
loggerSkipPathsPrefix := []string{
|
||||||
"GET /api/application-configuration/logo",
|
"GET /api/application-images/logo",
|
||||||
"GET /api/application-configuration/background-image",
|
"GET /api/application-images/background",
|
||||||
"GET /api/application-configuration/favicon",
|
"GET /api/application-images/favicon",
|
||||||
"GET /_app",
|
"GET /_app",
|
||||||
"GET /fonts",
|
"GET /fonts",
|
||||||
"GET /healthz",
|
"GET /healthz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
type services struct {
|
type services struct {
|
||||||
appConfigService *service.AppConfigService
|
appConfigService *service.AppConfigService
|
||||||
|
appImagesService *service.AppImagesService
|
||||||
emailService *service.EmailService
|
emailService *service.EmailService
|
||||||
geoLiteService *service.GeoLiteService
|
geoLiteService *service.GeoLiteService
|
||||||
auditLogService *service.AuditLogService
|
auditLogService *service.AuditLogService
|
||||||
@@ -27,7 +28,7 @@ type services struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initializes all services
|
// Initializes all services
|
||||||
func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (svc *services, err error) {
|
func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, imageExtensions map[string]string) (svc *services, err error) {
|
||||||
svc = &services{}
|
svc = &services{}
|
||||||
|
|
||||||
svc.appConfigService, err = service.NewAppConfigService(ctx, db)
|
svc.appConfigService, err = service.NewAppConfigService(ctx, db)
|
||||||
@@ -35,6 +36,8 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (sv
|
|||||||
return nil, fmt.Errorf("failed to create app config service: %w", err)
|
return nil, fmt.Errorf("failed to create app config service: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
svc.appImagesService = service.NewAppImagesService(imageExtensions)
|
||||||
|
|
||||||
svc.emailService, err = service.NewEmailService(db, svc.appConfigService)
|
svc.emailService, err = service.NewEmailService(db, svc.appConfigService)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create email service: %w", err)
|
return nil, fmt.Errorf("failed to create email service: %w", err)
|
||||||
|
|||||||
@@ -32,17 +32,17 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type EnvConfigSchema struct {
|
type EnvConfigSchema struct {
|
||||||
AppEnv string `env:"APP_ENV"`
|
AppEnv string `env:"APP_ENV" options:"toLower"`
|
||||||
LogLevel string `env:"LOG_LEVEL"`
|
LogLevel string `env:"LOG_LEVEL" options:"toLower"`
|
||||||
AppURL string `env:"APP_URL"`
|
AppURL string `env:"APP_URL" options:"toLower"`
|
||||||
DbProvider DbProvider `env:"DB_PROVIDER"`
|
DbProvider DbProvider `env:"DB_PROVIDER" options:"toLower"`
|
||||||
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
|
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
|
||||||
UploadPath string `env:"UPLOAD_PATH"`
|
UploadPath string `env:"UPLOAD_PATH"`
|
||||||
KeysPath string `env:"KEYS_PATH"`
|
KeysPath string `env:"KEYS_PATH"`
|
||||||
KeysStorage string `env:"KEYS_STORAGE"`
|
KeysStorage string `env:"KEYS_STORAGE"`
|
||||||
EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"`
|
EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"`
|
||||||
Port string `env:"PORT"`
|
Port string `env:"PORT"`
|
||||||
Host string `env:"HOST"`
|
Host string `env:"HOST" options:"toLower"`
|
||||||
UnixSocket string `env:"UNIX_SOCKET"`
|
UnixSocket string `env:"UNIX_SOCKET"`
|
||||||
UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
|
UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
|
||||||
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY" options:"file"`
|
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY" options:"file"`
|
||||||
@@ -112,31 +112,40 @@ func parseEnvConfig() error {
|
|||||||
return fmt.Errorf("error parsing env config: %w", err)
|
return fmt.Errorf("error parsing env config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = resolveFileBasedEnvVariables(&EnvConfig)
|
err = prepareEnvConfig(&EnvConfig)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error preparing env config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = validateEnvConfig(&EnvConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the environment variables
|
return nil
|
||||||
EnvConfig.LogLevel = strings.ToLower(EnvConfig.LogLevel)
|
|
||||||
if _, err := sloggin.ParseLevel(EnvConfig.LogLevel); err != nil {
|
}
|
||||||
|
|
||||||
|
// validateEnvConfig checks the EnvConfig for required fields and valid values
|
||||||
|
func validateEnvConfig(config *EnvConfigSchema) error {
|
||||||
|
if _, err := sloggin.ParseLevel(config.LogLevel); err != nil {
|
||||||
return errors.New("invalid LOG_LEVEL value. Must be 'debug', 'info', 'warn' or 'error'")
|
return errors.New("invalid LOG_LEVEL value. Must be 'debug', 'info', 'warn' or 'error'")
|
||||||
}
|
}
|
||||||
|
|
||||||
switch EnvConfig.DbProvider {
|
switch config.DbProvider {
|
||||||
case DbProviderSqlite:
|
case DbProviderSqlite:
|
||||||
if EnvConfig.DbConnectionString == "" {
|
if config.DbConnectionString == "" {
|
||||||
EnvConfig.DbConnectionString = defaultSqliteConnString
|
config.DbConnectionString = defaultSqliteConnString
|
||||||
}
|
}
|
||||||
case DbProviderPostgres:
|
case DbProviderPostgres:
|
||||||
if EnvConfig.DbConnectionString == "" {
|
if config.DbConnectionString == "" {
|
||||||
return errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database")
|
return errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database")
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return errors.New("invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")
|
return errors.New("invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")
|
||||||
}
|
}
|
||||||
|
|
||||||
parsedAppUrl, err := url.Parse(EnvConfig.AppURL)
|
parsedAppUrl, err := url.Parse(config.AppURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("APP_URL is not a valid URL")
|
return errors.New("APP_URL is not a valid URL")
|
||||||
}
|
}
|
||||||
@@ -145,10 +154,10 @@ func parseEnvConfig() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Derive INTERNAL_APP_URL from APP_URL if not set; validate only when provided
|
// Derive INTERNAL_APP_URL from APP_URL if not set; validate only when provided
|
||||||
if EnvConfig.InternalAppURL == "" {
|
if config.InternalAppURL == "" {
|
||||||
EnvConfig.InternalAppURL = EnvConfig.AppURL
|
config.InternalAppURL = config.AppURL
|
||||||
} else {
|
} else {
|
||||||
parsedInternalAppUrl, err := url.Parse(EnvConfig.InternalAppURL)
|
parsedInternalAppUrl, err := url.Parse(config.InternalAppURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("INTERNAL_APP_URL is not a valid URL")
|
return errors.New("INTERNAL_APP_URL is not a valid URL")
|
||||||
}
|
}
|
||||||
@@ -157,25 +166,26 @@ func parseEnvConfig() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch EnvConfig.KeysStorage {
|
switch config.KeysStorage {
|
||||||
// KeysStorage defaults to "file" if empty
|
// KeysStorage defaults to "file" if empty
|
||||||
case "":
|
case "":
|
||||||
EnvConfig.KeysStorage = "file"
|
config.KeysStorage = "file"
|
||||||
case "database":
|
case "database":
|
||||||
if EnvConfig.EncryptionKey == nil {
|
if config.EncryptionKey == nil {
|
||||||
return errors.New("ENCRYPTION_KEY must be non-empty when KEYS_STORAGE is database")
|
return errors.New("ENCRYPTION_KEY must be non-empty when KEYS_STORAGE is database")
|
||||||
}
|
}
|
||||||
case "file":
|
case "file":
|
||||||
// All good, these are valid values
|
// All good, these are valid values
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("invalid value for KEYS_STORAGE: %s", EnvConfig.KeysStorage)
|
return fmt.Errorf("invalid value for KEYS_STORAGE: %s", config.KeysStorage)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveFileBasedEnvVariables uses reflection to automatically resolve file-based secrets
|
// prepareEnvConfig processes special options for EnvConfig fields
|
||||||
func resolveFileBasedEnvVariables(config *EnvConfigSchema) error {
|
func prepareEnvConfig(config *EnvConfigSchema) error {
|
||||||
val := reflect.ValueOf(config).Elem()
|
val := reflect.ValueOf(config).Elem()
|
||||||
typ := val.Type()
|
typ := val.Type()
|
||||||
|
|
||||||
@@ -183,48 +193,65 @@ func resolveFileBasedEnvVariables(config *EnvConfigSchema) error {
|
|||||||
field := val.Field(i)
|
field := val.Field(i)
|
||||||
fieldType := typ.Field(i)
|
fieldType := typ.Field(i)
|
||||||
|
|
||||||
// Only process string and []byte fields
|
|
||||||
isString := field.Kind() == reflect.String
|
|
||||||
isByteSlice := field.Kind() == reflect.Slice && field.Type().Elem().Kind() == reflect.Uint8
|
|
||||||
if !isString && !isByteSlice {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only process fields with the "options" tag set to "file"
|
|
||||||
optionsTag := fieldType.Tag.Get("options")
|
optionsTag := fieldType.Tag.Get("options")
|
||||||
if optionsTag != "file" {
|
options := strings.Split(optionsTag, ",")
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only process fields with the "env" tag
|
for _, option := range options {
|
||||||
envTag := fieldType.Tag.Get("env")
|
switch option {
|
||||||
if envTag == "" {
|
case "toLower":
|
||||||
continue
|
if field.Kind() == reflect.String {
|
||||||
}
|
field.SetString(strings.ToLower(field.String()))
|
||||||
|
}
|
||||||
envVarName := envTag
|
case "file":
|
||||||
if commaIndex := len(envTag); commaIndex > 0 {
|
err := resolveFileBasedEnvVariable(field, fieldType)
|
||||||
envVarName = envTag[:commaIndex]
|
if err != nil {
|
||||||
}
|
return err
|
||||||
|
}
|
||||||
// If the file environment variable is not set, skip
|
}
|
||||||
envVarFileName := envVarName + "_FILE"
|
|
||||||
envVarFileValue := os.Getenv(envVarFileName)
|
|
||||||
if envVarFileValue == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
fileContent, err := os.ReadFile(envVarFileValue)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read file for env var %s: %w", envVarFileName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isString {
|
|
||||||
field.SetString(strings.TrimSpace(string(fileContent)))
|
|
||||||
} else {
|
|
||||||
field.SetBytes(fileContent)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveFileBasedEnvVariable checks if an environment variable with the suffix "_FILE" is set,
|
||||||
|
// reads the content of the file specified by that variable, and sets the corresponding field's value.
|
||||||
|
func resolveFileBasedEnvVariable(field reflect.Value, fieldType reflect.StructField) error {
|
||||||
|
// Only process string and []byte fields
|
||||||
|
isString := field.Kind() == reflect.String
|
||||||
|
isByteSlice := field.Kind() == reflect.Slice && field.Type().Elem().Kind() == reflect.Uint8
|
||||||
|
if !isString && !isByteSlice {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process fields with the "env" tag
|
||||||
|
envTag := fieldType.Tag.Get("env")
|
||||||
|
if envTag == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
envVarName := envTag
|
||||||
|
if commaIndex := len(envTag); commaIndex > 0 {
|
||||||
|
envVarName = envTag[:commaIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the file environment variable is not set, skip
|
||||||
|
envVarFileName := envVarName + "_FILE"
|
||||||
|
envVarFileValue := os.Getenv(envVarFileName)
|
||||||
|
if envVarFileValue == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fileContent, err := os.ReadFile(envVarFileValue)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read file for env var %s: %w", envVarFileName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isString {
|
||||||
|
field.SetString(strings.TrimSpace(string(fileContent)))
|
||||||
|
} else {
|
||||||
|
field.SetBytes(fileContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,18 +17,19 @@ func TestParseEnvConfig(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("should parse valid SQLite config correctly", func(t *testing.T) {
|
t.Run("should parse valid SQLite config correctly", func(t *testing.T) {
|
||||||
EnvConfig = defaultConfig()
|
EnvConfig = defaultConfig()
|
||||||
t.Setenv("DB_PROVIDER", "sqlite")
|
t.Setenv("DB_PROVIDER", "SQLITE") // should be lowercased automatically
|
||||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||||
t.Setenv("APP_URL", "http://localhost:3000")
|
t.Setenv("APP_URL", "HTTP://LOCALHOST:3000")
|
||||||
|
|
||||||
err := parseEnvConfig()
|
err := parseEnvConfig()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, DbProviderSqlite, EnvConfig.DbProvider)
|
assert.Equal(t, DbProviderSqlite, EnvConfig.DbProvider)
|
||||||
|
assert.Equal(t, "http://localhost:3000", EnvConfig.AppURL)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("should parse valid Postgres config correctly", func(t *testing.T) {
|
t.Run("should parse valid Postgres config correctly", func(t *testing.T) {
|
||||||
EnvConfig = defaultConfig()
|
EnvConfig = defaultConfig()
|
||||||
t.Setenv("DB_PROVIDER", "postgres")
|
t.Setenv("DB_PROVIDER", "POSTGRES")
|
||||||
t.Setenv("DB_CONNECTION_STRING", "postgres://user:pass@localhost/db")
|
t.Setenv("DB_CONNECTION_STRING", "postgres://user:pass@localhost/db")
|
||||||
t.Setenv("APP_URL", "https://example.com")
|
t.Setenv("APP_URL", "https://example.com")
|
||||||
|
|
||||||
@@ -51,7 +52,6 @@ func TestParseEnvConfig(t *testing.T) {
|
|||||||
t.Run("should set default SQLite connection string when DB_CONNECTION_STRING is empty", func(t *testing.T) {
|
t.Run("should set default SQLite connection string when DB_CONNECTION_STRING is empty", func(t *testing.T) {
|
||||||
EnvConfig = defaultConfig()
|
EnvConfig = defaultConfig()
|
||||||
t.Setenv("DB_PROVIDER", "sqlite")
|
t.Setenv("DB_PROVIDER", "sqlite")
|
||||||
t.Setenv("DB_CONNECTION_STRING", "") // Explicitly empty
|
|
||||||
t.Setenv("APP_URL", "http://localhost:3000")
|
t.Setenv("APP_URL", "http://localhost:3000")
|
||||||
|
|
||||||
err := parseEnvConfig()
|
err := parseEnvConfig()
|
||||||
@@ -192,25 +192,25 @@ func TestParseEnvConfig(t *testing.T) {
|
|||||||
t.Setenv("DB_PROVIDER", "postgres")
|
t.Setenv("DB_PROVIDER", "postgres")
|
||||||
t.Setenv("DB_CONNECTION_STRING", "postgres://test")
|
t.Setenv("DB_CONNECTION_STRING", "postgres://test")
|
||||||
t.Setenv("APP_URL", "https://prod.example.com")
|
t.Setenv("APP_URL", "https://prod.example.com")
|
||||||
t.Setenv("APP_ENV", "staging")
|
t.Setenv("APP_ENV", "STAGING")
|
||||||
t.Setenv("UPLOAD_PATH", "/custom/uploads")
|
t.Setenv("UPLOAD_PATH", "/custom/uploads")
|
||||||
t.Setenv("KEYS_PATH", "/custom/keys")
|
t.Setenv("KEYS_PATH", "/custom/keys")
|
||||||
t.Setenv("PORT", "8080")
|
t.Setenv("PORT", "8080")
|
||||||
t.Setenv("HOST", "127.0.0.1")
|
t.Setenv("HOST", "LOCALHOST")
|
||||||
t.Setenv("UNIX_SOCKET", "/tmp/app.sock")
|
t.Setenv("UNIX_SOCKET", "/tmp/app.sock")
|
||||||
t.Setenv("MAXMIND_LICENSE_KEY", "test-license")
|
t.Setenv("MAXMIND_LICENSE_KEY", "test-license")
|
||||||
t.Setenv("GEOLITE_DB_PATH", "/custom/geolite.mmdb")
|
t.Setenv("GEOLITE_DB_PATH", "/custom/geolite.mmdb")
|
||||||
|
|
||||||
err := parseEnvConfig()
|
err := parseEnvConfig()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "staging", EnvConfig.AppEnv)
|
assert.Equal(t, "staging", EnvConfig.AppEnv) // lowercased
|
||||||
assert.Equal(t, "/custom/uploads", EnvConfig.UploadPath)
|
assert.Equal(t, "/custom/uploads", EnvConfig.UploadPath)
|
||||||
assert.Equal(t, "8080", EnvConfig.Port)
|
assert.Equal(t, "8080", EnvConfig.Port)
|
||||||
assert.Equal(t, "127.0.0.1", EnvConfig.Host)
|
assert.Equal(t, "localhost", EnvConfig.Host) // lowercased
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveFileBasedEnvVariables(t *testing.T) {
|
func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) {
|
||||||
// Create temporary directory for test files
|
// Create temporary directory for test files
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
@@ -225,103 +225,34 @@ func TestResolveFileBasedEnvVariables(t *testing.T) {
|
|||||||
err = os.WriteFile(dbConnFile, []byte(dbConnContent), 0600)
|
err = os.WriteFile(dbConnFile, []byte(dbConnContent), 0600)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Create a binary file for testing binary data handling
|
|
||||||
binaryKeyFile := tempDir + "/binary_key.bin"
|
binaryKeyFile := tempDir + "/binary_key.bin"
|
||||||
binaryKeyContent := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10}
|
binaryKeyContent := []byte{0x01, 0x02, 0x03, 0x04}
|
||||||
err = os.WriteFile(binaryKeyFile, binaryKeyContent, 0600)
|
err = os.WriteFile(binaryKeyFile, binaryKeyContent, 0600)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
t.Run("should read file content for fields with options:file tag", func(t *testing.T) {
|
t.Run("should process toLower and file options", func(t *testing.T) {
|
||||||
config := defaultConfig()
|
config := defaultConfig()
|
||||||
|
config.AppEnv = "STAGING"
|
||||||
|
config.Host = "LOCALHOST"
|
||||||
|
|
||||||
// Set environment variables pointing to files
|
|
||||||
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
|
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
|
||||||
t.Setenv("DB_CONNECTION_STRING_FILE", dbConnFile)
|
t.Setenv("DB_CONNECTION_STRING_FILE", dbConnFile)
|
||||||
|
|
||||||
err := resolveFileBasedEnvVariables(&config)
|
err := prepareEnvConfig(&config)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify file contents were read correctly
|
assert.Equal(t, "staging", config.AppEnv)
|
||||||
|
assert.Equal(t, "localhost", config.Host)
|
||||||
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
|
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
|
||||||
assert.Equal(t, dbConnContent, config.DbConnectionString)
|
assert.Equal(t, dbConnContent, config.DbConnectionString)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("should skip fields without options:file tag", func(t *testing.T) {
|
|
||||||
config := defaultConfig()
|
|
||||||
originalAppURL := config.AppURL
|
|
||||||
|
|
||||||
// Set a file for a field that doesn't have options:file tag
|
|
||||||
t.Setenv("APP_URL_FILE", "/tmp/nonexistent.txt")
|
|
||||||
|
|
||||||
err := resolveFileBasedEnvVariables(&config)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// AppURL should remain unchanged
|
|
||||||
assert.Equal(t, originalAppURL, config.AppURL)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("should skip non-string fields", func(t *testing.T) {
|
|
||||||
// This test verifies that non-string fields are skipped
|
|
||||||
// We test this indirectly by ensuring the function doesn't error
|
|
||||||
// when processing the actual EnvConfigSchema which has bool fields
|
|
||||||
config := defaultConfig()
|
|
||||||
|
|
||||||
err := resolveFileBasedEnvVariables(&config)
|
|
||||||
require.NoError(t, err)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("should skip when _FILE environment variable is not set", func(t *testing.T) {
|
|
||||||
config := defaultConfig()
|
|
||||||
originalEncryptionKey := config.EncryptionKey
|
|
||||||
|
|
||||||
// Don't set ENCRYPTION_KEY_FILE environment variable
|
|
||||||
|
|
||||||
err := resolveFileBasedEnvVariables(&config)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// EncryptionKey should remain unchanged
|
|
||||||
assert.Equal(t, originalEncryptionKey, config.EncryptionKey)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("should handle multiple file-based variables simultaneously", func(t *testing.T) {
|
|
||||||
config := defaultConfig()
|
|
||||||
|
|
||||||
// Set multiple file environment variables
|
|
||||||
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
|
|
||||||
t.Setenv("DB_CONNECTION_STRING_FILE", dbConnFile)
|
|
||||||
|
|
||||||
err := resolveFileBasedEnvVariables(&config)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// All should be resolved correctly
|
|
||||||
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
|
|
||||||
assert.Equal(t, dbConnContent, config.DbConnectionString)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("should handle mixed file and non-file environment variables", func(t *testing.T) {
|
|
||||||
config := defaultConfig()
|
|
||||||
|
|
||||||
// Set both file and non-file environment variables
|
|
||||||
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
|
|
||||||
|
|
||||||
err := resolveFileBasedEnvVariables(&config)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// File-based should be resolved, others should remain as set by env parser
|
|
||||||
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
|
|
||||||
assert.Equal(t, "http://localhost:1411", config.AppURL)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("should handle binary data correctly", func(t *testing.T) {
|
t.Run("should handle binary data correctly", func(t *testing.T) {
|
||||||
config := defaultConfig()
|
config := defaultConfig()
|
||||||
|
|
||||||
// Set environment variable pointing to binary file
|
|
||||||
t.Setenv("ENCRYPTION_KEY_FILE", binaryKeyFile)
|
t.Setenv("ENCRYPTION_KEY_FILE", binaryKeyFile)
|
||||||
|
|
||||||
err := resolveFileBasedEnvVariables(&config)
|
err := prepareEnvConfig(&config)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify binary data was read correctly without corruption
|
|
||||||
assert.Equal(t, binaryKeyContent, config.EncryptionKey)
|
assert.Equal(t, binaryKeyContent, config.EncryptionKey)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,12 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewAppConfigController creates a new controller for application configuration endpoints
|
// NewAppConfigController creates a new controller for application configuration endpoints
|
||||||
@@ -34,13 +32,6 @@ func NewAppConfigController(
|
|||||||
group.GET("/application-configuration/all", authMiddleware.Add(), acc.listAllAppConfigHandler)
|
group.GET("/application-configuration/all", authMiddleware.Add(), acc.listAllAppConfigHandler)
|
||||||
group.PUT("/application-configuration", authMiddleware.Add(), acc.updateAppConfigHandler)
|
group.PUT("/application-configuration", authMiddleware.Add(), acc.updateAppConfigHandler)
|
||||||
|
|
||||||
group.GET("/application-configuration/logo", acc.getLogoHandler)
|
|
||||||
group.GET("/application-configuration/background-image", acc.getBackgroundImageHandler)
|
|
||||||
group.GET("/application-configuration/favicon", acc.getFaviconHandler)
|
|
||||||
group.PUT("/application-configuration/logo", authMiddleware.Add(), acc.updateLogoHandler)
|
|
||||||
group.PUT("/application-configuration/favicon", authMiddleware.Add(), acc.updateFaviconHandler)
|
|
||||||
group.PUT("/application-configuration/background-image", authMiddleware.Add(), acc.updateBackgroundImageHandler)
|
|
||||||
|
|
||||||
group.POST("/application-configuration/test-email", authMiddleware.Add(), acc.testEmailHandler)
|
group.POST("/application-configuration/test-email", authMiddleware.Add(), acc.testEmailHandler)
|
||||||
group.POST("/application-configuration/sync-ldap", authMiddleware.Add(), acc.syncLdapHandler)
|
group.POST("/application-configuration/sync-ldap", authMiddleware.Add(), acc.syncLdapHandler)
|
||||||
}
|
}
|
||||||
@@ -129,147 +120,6 @@ func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, configVariablesDto)
|
c.JSON(http.StatusOK, configVariablesDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getLogoHandler godoc
|
|
||||||
// @Summary Get logo image
|
|
||||||
// @Description Get the logo image for the application
|
|
||||||
// @Tags Application Configuration
|
|
||||||
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
|
|
||||||
// @Produce image/png
|
|
||||||
// @Produce image/jpeg
|
|
||||||
// @Produce image/svg+xml
|
|
||||||
// @Success 200 {file} binary "Logo image"
|
|
||||||
// @Router /api/application-configuration/logo [get]
|
|
||||||
func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
|
|
||||||
dbConfig := acc.appConfigService.GetDbConfig()
|
|
||||||
|
|
||||||
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
|
|
||||||
|
|
||||||
var imageName, imageType string
|
|
||||||
if lightLogo {
|
|
||||||
imageName = "logoLight"
|
|
||||||
imageType = dbConfig.LogoLightImageType.Value
|
|
||||||
} else {
|
|
||||||
imageName = "logoDark"
|
|
||||||
imageType = dbConfig.LogoDarkImageType.Value
|
|
||||||
}
|
|
||||||
|
|
||||||
acc.getImage(c, imageName, imageType)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getFaviconHandler godoc
|
|
||||||
// @Summary Get favicon
|
|
||||||
// @Description Get the favicon for the application
|
|
||||||
// @Tags Application Configuration
|
|
||||||
// @Produce image/x-icon
|
|
||||||
// @Success 200 {file} binary "Favicon image"
|
|
||||||
// @Router /api/application-configuration/favicon [get]
|
|
||||||
func (acc *AppConfigController) getFaviconHandler(c *gin.Context) {
|
|
||||||
acc.getImage(c, "favicon", "ico")
|
|
||||||
}
|
|
||||||
|
|
||||||
// getBackgroundImageHandler godoc
|
|
||||||
// @Summary Get background image
|
|
||||||
// @Description Get the background image for the application
|
|
||||||
// @Tags Application Configuration
|
|
||||||
// @Produce image/png
|
|
||||||
// @Produce image/jpeg
|
|
||||||
// @Success 200 {file} binary "Background image"
|
|
||||||
// @Router /api/application-configuration/background-image [get]
|
|
||||||
func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) {
|
|
||||||
imageType := acc.appConfigService.GetDbConfig().BackgroundImageType.Value
|
|
||||||
acc.getImage(c, "background", imageType)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateLogoHandler godoc
|
|
||||||
// @Summary Update logo
|
|
||||||
// @Description Update the application logo
|
|
||||||
// @Tags Application Configuration
|
|
||||||
// @Accept multipart/form-data
|
|
||||||
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
|
|
||||||
// @Param file formData file true "Logo image file"
|
|
||||||
// @Success 204 "No Content"
|
|
||||||
// @Router /api/application-configuration/logo [put]
|
|
||||||
func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
|
|
||||||
dbConfig := acc.appConfigService.GetDbConfig()
|
|
||||||
|
|
||||||
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
|
|
||||||
|
|
||||||
var imageName, imageType string
|
|
||||||
if lightLogo {
|
|
||||||
imageName = "logoLight"
|
|
||||||
imageType = dbConfig.LogoLightImageType.Value
|
|
||||||
} else {
|
|
||||||
imageName = "logoDark"
|
|
||||||
imageType = dbConfig.LogoDarkImageType.Value
|
|
||||||
}
|
|
||||||
|
|
||||||
acc.updateImage(c, imageName, imageType)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateFaviconHandler godoc
|
|
||||||
// @Summary Update favicon
|
|
||||||
// @Description Update the application favicon
|
|
||||||
// @Tags Application Configuration
|
|
||||||
// @Accept multipart/form-data
|
|
||||||
// @Param file formData file true "Favicon file (.ico)"
|
|
||||||
// @Success 204 "No Content"
|
|
||||||
// @Router /api/application-configuration/favicon [put]
|
|
||||||
func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
|
|
||||||
file, err := c.FormFile("file")
|
|
||||||
if err != nil {
|
|
||||||
_ = c.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fileType := utils.GetFileExtension(file.Filename)
|
|
||||||
if fileType != "ico" {
|
|
||||||
_ = c.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
acc.updateImage(c, "favicon", "ico")
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateBackgroundImageHandler godoc
|
|
||||||
// @Summary Update background image
|
|
||||||
// @Description Update the application background image
|
|
||||||
// @Tags Application Configuration
|
|
||||||
// @Accept multipart/form-data
|
|
||||||
// @Param file formData file true "Background image file"
|
|
||||||
// @Success 204 "No Content"
|
|
||||||
// @Router /api/application-configuration/background-image [put]
|
|
||||||
func (acc *AppConfigController) updateBackgroundImageHandler(c *gin.Context) {
|
|
||||||
imageType := acc.appConfigService.GetDbConfig().BackgroundImageType.Value
|
|
||||||
acc.updateImage(c, "background", imageType)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getImage is a helper function to serve image files
|
|
||||||
func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType string) {
|
|
||||||
imagePath := common.EnvConfig.UploadPath + "/application-images/" + name + "." + imageType
|
|
||||||
mimeType := utils.GetImageMimeType(imageType)
|
|
||||||
|
|
||||||
c.Header("Content-Type", mimeType)
|
|
||||||
|
|
||||||
utils.SetCacheControlHeader(c, 15*time.Minute, 24*time.Hour)
|
|
||||||
c.File(imagePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateImage is a helper function to update image files
|
|
||||||
func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, oldImageType string) {
|
|
||||||
file, err := c.FormFile("file")
|
|
||||||
if err != nil {
|
|
||||||
_ = c.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = acc.appConfigService.UpdateImage(c.Request.Context(), file, imageName, oldImageType)
|
|
||||||
if err != nil {
|
|
||||||
_ = c.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// syncLdapHandler godoc
|
// syncLdapHandler godoc
|
||||||
// @Summary Synchronize LDAP
|
// @Summary Synchronize LDAP
|
||||||
// @Description Manually trigger LDAP synchronization
|
// @Description Manually trigger LDAP synchronization
|
||||||
|
|||||||
173
backend/internal/controller/app_images_controller.go
Normal file
173
backend/internal/controller/app_images_controller.go
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewAppImagesController(
|
||||||
|
group *gin.RouterGroup,
|
||||||
|
authMiddleware *middleware.AuthMiddleware,
|
||||||
|
appImagesService *service.AppImagesService,
|
||||||
|
) {
|
||||||
|
controller := &AppImagesController{
|
||||||
|
appImagesService: appImagesService,
|
||||||
|
}
|
||||||
|
|
||||||
|
group.GET("/application-images/logo", controller.getLogoHandler)
|
||||||
|
group.GET("/application-images/background", controller.getBackgroundImageHandler)
|
||||||
|
group.GET("/application-images/favicon", controller.getFaviconHandler)
|
||||||
|
|
||||||
|
group.PUT("/application-images/logo", authMiddleware.Add(), controller.updateLogoHandler)
|
||||||
|
group.PUT("/application-images/background", authMiddleware.Add(), controller.updateBackgroundImageHandler)
|
||||||
|
group.PUT("/application-images/favicon", authMiddleware.Add(), controller.updateFaviconHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppImagesController struct {
|
||||||
|
appImagesService *service.AppImagesService
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLogoHandler godoc
|
||||||
|
// @Summary Get logo image
|
||||||
|
// @Description Get the logo image for the application
|
||||||
|
// @Tags Application Images
|
||||||
|
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
|
||||||
|
// @Produce image/png
|
||||||
|
// @Produce image/jpeg
|
||||||
|
// @Produce image/svg+xml
|
||||||
|
// @Success 200 {file} binary "Logo image"
|
||||||
|
// @Router /api/application-images/logo [get]
|
||||||
|
func (c *AppImagesController) getLogoHandler(ctx *gin.Context) {
|
||||||
|
lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true"))
|
||||||
|
imageName := "logoLight"
|
||||||
|
if !lightLogo {
|
||||||
|
imageName = "logoDark"
|
||||||
|
}
|
||||||
|
|
||||||
|
c.getImage(ctx, imageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getBackgroundImageHandler godoc
|
||||||
|
// @Summary Get background image
|
||||||
|
// @Description Get the background image for the application
|
||||||
|
// @Tags Application Images
|
||||||
|
// @Produce image/png
|
||||||
|
// @Produce image/jpeg
|
||||||
|
// @Success 200 {file} binary "Background image"
|
||||||
|
// @Router /api/application-images/background [get]
|
||||||
|
func (c *AppImagesController) getBackgroundImageHandler(ctx *gin.Context) {
|
||||||
|
c.getImage(ctx, "background")
|
||||||
|
}
|
||||||
|
|
||||||
|
// getFaviconHandler godoc
|
||||||
|
// @Summary Get favicon
|
||||||
|
// @Description Get the favicon for the application
|
||||||
|
// @Tags Application Images
|
||||||
|
// @Produce image/x-icon
|
||||||
|
// @Success 200 {file} binary "Favicon image"
|
||||||
|
// @Router /api/application-images/favicon [get]
|
||||||
|
func (c *AppImagesController) getFaviconHandler(ctx *gin.Context) {
|
||||||
|
c.getImage(ctx, "favicon")
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateLogoHandler godoc
|
||||||
|
// @Summary Update logo
|
||||||
|
// @Description Update the application logo
|
||||||
|
// @Tags Application Images
|
||||||
|
// @Accept multipart/form-data
|
||||||
|
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
|
||||||
|
// @Param file formData file true "Logo image file"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /api/application-images/logo [put]
|
||||||
|
func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) {
|
||||||
|
file, err := ctx.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true"))
|
||||||
|
imageName := "logoLight"
|
||||||
|
if !lightLogo {
|
||||||
|
imageName = "logoDark"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.appImagesService.UpdateImage(file, imageName); err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateBackgroundImageHandler godoc
|
||||||
|
// @Summary Update background image
|
||||||
|
// @Description Update the application background image
|
||||||
|
// @Tags Application Images
|
||||||
|
// @Accept multipart/form-data
|
||||||
|
// @Param file formData file true "Background image file"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /api/application-images/background [put]
|
||||||
|
func (c *AppImagesController) updateBackgroundImageHandler(ctx *gin.Context) {
|
||||||
|
file, err := ctx.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.appImagesService.UpdateImage(file, "background"); err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateFaviconHandler godoc
|
||||||
|
// @Summary Update favicon
|
||||||
|
// @Description Update the application favicon
|
||||||
|
// @Tags Application Images
|
||||||
|
// @Accept multipart/form-data
|
||||||
|
// @Param file formData file true "Favicon file (.ico)"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /api/application-images/favicon [put]
|
||||||
|
func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) {
|
||||||
|
file, err := ctx.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileType := utils.GetFileExtension(file.Filename)
|
||||||
|
if fileType != "ico" {
|
||||||
|
_ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.appImagesService.UpdateImage(file, "favicon"); err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AppImagesController) getImage(ctx *gin.Context, name string) {
|
||||||
|
imagePath, mimeType, err := c.appImagesService.GetImage(name)
|
||||||
|
if err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Header("Content-Type", mimeType)
|
||||||
|
utils.SetCacheControlHeader(ctx, 15*time.Minute, 24*time.Hour)
|
||||||
|
ctx.File(imagePath)
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ type AppConfigUpdateDto struct {
|
|||||||
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"`
|
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"`
|
||||||
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"`
|
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"`
|
||||||
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"`
|
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"`
|
||||||
|
LdapAttributeUserDisplayName string `json:"ldapAttributeUserDisplayName"`
|
||||||
LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"`
|
LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"`
|
||||||
LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"`
|
LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"`
|
||||||
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`
|
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ type UserDto struct {
|
|||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Email string `json:"email" `
|
Email string `json:"email" `
|
||||||
FirstName string `json:"firstName"`
|
FirstName string `json:"firstName"`
|
||||||
LastName string `json:"lastName"`
|
LastName *string `json:"lastName"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
IsAdmin bool `json:"isAdmin"`
|
IsAdmin bool `json:"isAdmin"`
|
||||||
Locale *string `json:"locale"`
|
Locale *string `json:"locale"`
|
||||||
CustomClaims []CustomClaimDto `json:"customClaims"`
|
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||||
@@ -22,14 +23,15 @@ type UserDto struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserCreateDto struct {
|
type UserCreateDto struct {
|
||||||
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
|
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
|
||||||
Email string `json:"email" binding:"required,email" unorm:"nfc"`
|
Email string `json:"email" binding:"required,email" unorm:"nfc"`
|
||||||
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
|
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
|
||||||
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
||||||
IsAdmin bool `json:"isAdmin"`
|
DisplayName string `json:"displayName" binding:"required,max=100" unorm:"nfc"`
|
||||||
Locale *string `json:"locale"`
|
IsAdmin bool `json:"isAdmin"`
|
||||||
Disabled bool `json:"disabled"`
|
Locale *string `json:"locale"`
|
||||||
LdapID string `json:"-"`
|
Disabled bool `json:"disabled"`
|
||||||
|
LdapID string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u UserCreateDto) Validate() error {
|
func (u UserCreateDto) Validate() error {
|
||||||
|
|||||||
@@ -15,59 +15,74 @@ func TestUserCreateDto_Validate(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "valid input",
|
name: "valid input",
|
||||||
input: UserCreateDto{
|
input: UserCreateDto{
|
||||||
Username: "testuser",
|
Username: "testuser",
|
||||||
Email: "test@example.com",
|
Email: "test@example.com",
|
||||||
FirstName: "John",
|
FirstName: "John",
|
||||||
LastName: "Doe",
|
LastName: "Doe",
|
||||||
|
DisplayName: "John Doe",
|
||||||
},
|
},
|
||||||
wantErr: "",
|
wantErr: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing username",
|
name: "missing username",
|
||||||
input: UserCreateDto{
|
input: UserCreateDto{
|
||||||
Email: "test@example.com",
|
Email: "test@example.com",
|
||||||
FirstName: "John",
|
FirstName: "John",
|
||||||
LastName: "Doe",
|
LastName: "Doe",
|
||||||
|
DisplayName: "John Doe",
|
||||||
},
|
},
|
||||||
wantErr: "Field validation for 'Username' failed on the 'required' tag",
|
wantErr: "Field validation for 'Username' failed on the 'required' tag",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "username contains invalid characters",
|
name: "missing display name",
|
||||||
input: UserCreateDto{
|
input: UserCreateDto{
|
||||||
Username: "test/ser",
|
|
||||||
Email: "test@example.com",
|
Email: "test@example.com",
|
||||||
FirstName: "John",
|
FirstName: "John",
|
||||||
LastName: "Doe",
|
LastName: "Doe",
|
||||||
},
|
},
|
||||||
|
wantErr: "Field validation for 'DisplayName' failed on the 'required' tag",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "username contains invalid characters",
|
||||||
|
input: UserCreateDto{
|
||||||
|
Username: "test/ser",
|
||||||
|
Email: "test@example.com",
|
||||||
|
FirstName: "John",
|
||||||
|
LastName: "Doe",
|
||||||
|
DisplayName: "John Doe",
|
||||||
|
},
|
||||||
wantErr: "Field validation for 'Username' failed on the 'username' tag",
|
wantErr: "Field validation for 'Username' failed on the 'username' tag",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid email",
|
name: "invalid email",
|
||||||
input: UserCreateDto{
|
input: UserCreateDto{
|
||||||
Username: "testuser",
|
Username: "testuser",
|
||||||
Email: "not-an-email",
|
Email: "not-an-email",
|
||||||
FirstName: "John",
|
FirstName: "John",
|
||||||
LastName: "Doe",
|
LastName: "Doe",
|
||||||
|
DisplayName: "John Doe",
|
||||||
},
|
},
|
||||||
wantErr: "Field validation for 'Email' failed on the 'email' tag",
|
wantErr: "Field validation for 'Email' failed on the 'email' tag",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "first name too short",
|
name: "first name too short",
|
||||||
input: UserCreateDto{
|
input: UserCreateDto{
|
||||||
Username: "testuser",
|
Username: "testuser",
|
||||||
Email: "test@example.com",
|
Email: "test@example.com",
|
||||||
FirstName: "",
|
FirstName: "",
|
||||||
LastName: "Doe",
|
LastName: "Doe",
|
||||||
|
DisplayName: "John Doe",
|
||||||
},
|
},
|
||||||
wantErr: "Field validation for 'FirstName' failed on the 'required' tag",
|
wantErr: "Field validation for 'FirstName' failed on the 'required' tag",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "last name too long",
|
name: "last name too long",
|
||||||
input: UserCreateDto{
|
input: UserCreateDto{
|
||||||
Username: "testuser",
|
Username: "testuser",
|
||||||
Email: "test@example.com",
|
Email: "test@example.com",
|
||||||
FirstName: "John",
|
FirstName: "John",
|
||||||
LastName: "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz",
|
LastName: "abcdfghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz",
|
||||||
|
DisplayName: "John Doe",
|
||||||
},
|
},
|
||||||
wantErr: "Field validation for 'LastName' failed on the 'max' tag",
|
wantErr: "Field validation for 'LastName' failed on the 'max' tag",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ func handleValidationError(validationErrors validator.ValidationErrors) string {
|
|||||||
case "email":
|
case "email":
|
||||||
errorMessage = fmt.Sprintf("%s must be a valid email address", fieldName)
|
errorMessage = fmt.Sprintf("%s must be a valid email address", fieldName)
|
||||||
case "username":
|
case "username":
|
||||||
errorMessage = fmt.Sprintf("%s must only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character", fieldName)
|
errorMessage = fmt.Sprintf("%s must only contain letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character", fieldName)
|
||||||
case "url":
|
case "url":
|
||||||
errorMessage = fmt.Sprintf("%s must be a valid URL", fieldName)
|
errorMessage = fmt.Sprintf("%s must be a valid URL", fieldName)
|
||||||
case "min":
|
case "min":
|
||||||
|
|||||||
@@ -44,10 +44,7 @@ type AppConfig struct {
|
|||||||
SignupDefaultUserGroupIDs AppConfigVariable `key:"signupDefaultUserGroupIDs"`
|
SignupDefaultUserGroupIDs AppConfigVariable `key:"signupDefaultUserGroupIDs"`
|
||||||
SignupDefaultCustomClaims AppConfigVariable `key:"signupDefaultCustomClaims"`
|
SignupDefaultCustomClaims AppConfigVariable `key:"signupDefaultCustomClaims"`
|
||||||
// Internal
|
// Internal
|
||||||
BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal
|
InstanceID AppConfigVariable `key:"instanceId,internal"` // Internal
|
||||||
LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal
|
|
||||||
LogoDarkImageType AppConfigVariable `key:"logoDarkImageType,internal"` // Internal
|
|
||||||
InstanceID AppConfigVariable `key:"instanceId,internal"` // Internal
|
|
||||||
// Email
|
// Email
|
||||||
SmtpHost AppConfigVariable `key:"smtpHost"`
|
SmtpHost AppConfigVariable `key:"smtpHost"`
|
||||||
SmtpPort AppConfigVariable `key:"smtpPort"`
|
SmtpPort AppConfigVariable `key:"smtpPort"`
|
||||||
@@ -74,6 +71,7 @@ type AppConfig struct {
|
|||||||
LdapAttributeUserEmail AppConfigVariable `key:"ldapAttributeUserEmail"`
|
LdapAttributeUserEmail AppConfigVariable `key:"ldapAttributeUserEmail"`
|
||||||
LdapAttributeUserFirstName AppConfigVariable `key:"ldapAttributeUserFirstName"`
|
LdapAttributeUserFirstName AppConfigVariable `key:"ldapAttributeUserFirstName"`
|
||||||
LdapAttributeUserLastName AppConfigVariable `key:"ldapAttributeUserLastName"`
|
LdapAttributeUserLastName AppConfigVariable `key:"ldapAttributeUserLastName"`
|
||||||
|
LdapAttributeUserDisplayName AppConfigVariable `key:"ldapAttributeUserDisplayName"`
|
||||||
LdapAttributeUserProfilePicture AppConfigVariable `key:"ldapAttributeUserProfilePicture"`
|
LdapAttributeUserProfilePicture AppConfigVariable `key:"ldapAttributeUserProfilePicture"`
|
||||||
LdapAttributeGroupMember AppConfigVariable `key:"ldapAttributeGroupMember"`
|
LdapAttributeGroupMember AppConfigVariable `key:"ldapAttributeGroupMember"`
|
||||||
LdapAttributeGroupUniqueIdentifier AppConfigVariable `key:"ldapAttributeGroupUniqueIdentifier"`
|
LdapAttributeGroupUniqueIdentifier AppConfigVariable `key:"ldapAttributeGroupUniqueIdentifier"`
|
||||||
|
|||||||
@@ -13,14 +13,15 @@ import (
|
|||||||
type User struct {
|
type User struct {
|
||||||
Base
|
Base
|
||||||
|
|
||||||
Username string `sortable:"true"`
|
Username string `sortable:"true"`
|
||||||
Email string `sortable:"true"`
|
Email string `sortable:"true"`
|
||||||
FirstName string `sortable:"true"`
|
FirstName string `sortable:"true"`
|
||||||
LastName string `sortable:"true"`
|
LastName string `sortable:"true"`
|
||||||
IsAdmin bool `sortable:"true"`
|
DisplayName string `sortable:"true"`
|
||||||
Locale *string
|
IsAdmin bool `sortable:"true"`
|
||||||
LdapID *string
|
Locale *string
|
||||||
Disabled bool `sortable:"true"`
|
LdapID *string
|
||||||
|
Disabled bool `sortable:"true"`
|
||||||
|
|
||||||
CustomClaims []CustomClaim
|
CustomClaims []CustomClaim
|
||||||
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
|
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
|
||||||
@@ -31,7 +32,12 @@ func (u User) WebAuthnID() []byte { return []byte(u.ID) }
|
|||||||
|
|
||||||
func (u User) WebAuthnName() string { return u.Username }
|
func (u User) WebAuthnName() string { return u.Username }
|
||||||
|
|
||||||
func (u User) WebAuthnDisplayName() string { return u.FirstName + " " + u.LastName }
|
func (u User) WebAuthnDisplayName() string {
|
||||||
|
if u.DisplayName != "" {
|
||||||
|
return u.DisplayName
|
||||||
|
}
|
||||||
|
return u.FirstName + " " + u.LastName
|
||||||
|
}
|
||||||
|
|
||||||
func (u User) WebAuthnIcon() string { return "" }
|
func (u User) WebAuthnIcon() string { return "" }
|
||||||
|
|
||||||
@@ -66,7 +72,9 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
|
|||||||
return descriptors
|
return descriptors
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u User) FullName() string { return u.FirstName + " " + u.LastName }
|
func (u User) FullName() string {
|
||||||
|
return u.FirstName + " " + u.LastName
|
||||||
|
}
|
||||||
|
|
||||||
func (u User) Initials() string {
|
func (u User) Initials() string {
|
||||||
first := utils.GetFirstCharacter(u.FirstName)
|
first := utils.GetFirstCharacter(u.FirstName)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"mime/multipart"
|
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -70,10 +69,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
|
|||||||
SignupDefaultCustomClaims: model.AppConfigVariable{Value: "[]"},
|
SignupDefaultCustomClaims: model.AppConfigVariable{Value: "[]"},
|
||||||
AccentColor: model.AppConfigVariable{Value: "default"},
|
AccentColor: model.AppConfigVariable{Value: "default"},
|
||||||
// Internal
|
// Internal
|
||||||
BackgroundImageType: model.AppConfigVariable{Value: "webp"},
|
InstanceID: model.AppConfigVariable{Value: ""},
|
||||||
LogoLightImageType: model.AppConfigVariable{Value: "svg"},
|
|
||||||
LogoDarkImageType: model.AppConfigVariable{Value: "svg"},
|
|
||||||
InstanceID: model.AppConfigVariable{Value: ""},
|
|
||||||
// Email
|
// Email
|
||||||
SmtpHost: model.AppConfigVariable{},
|
SmtpHost: model.AppConfigVariable{},
|
||||||
SmtpPort: model.AppConfigVariable{},
|
SmtpPort: model.AppConfigVariable{},
|
||||||
@@ -100,6 +96,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
|
|||||||
LdapAttributeUserEmail: model.AppConfigVariable{},
|
LdapAttributeUserEmail: model.AppConfigVariable{},
|
||||||
LdapAttributeUserFirstName: model.AppConfigVariable{},
|
LdapAttributeUserFirstName: model.AppConfigVariable{},
|
||||||
LdapAttributeUserLastName: model.AppConfigVariable{},
|
LdapAttributeUserLastName: model.AppConfigVariable{},
|
||||||
|
LdapAttributeUserDisplayName: model.AppConfigVariable{Value: "cn"},
|
||||||
LdapAttributeUserProfilePicture: model.AppConfigVariable{},
|
LdapAttributeUserProfilePicture: model.AppConfigVariable{},
|
||||||
LdapAttributeGroupMember: model.AppConfigVariable{Value: "member"},
|
LdapAttributeGroupMember: model.AppConfigVariable{Value: "member"},
|
||||||
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{},
|
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{},
|
||||||
@@ -321,39 +318,6 @@ func (s *AppConfigService) ListAppConfig(showAll bool) []model.AppConfigVariable
|
|||||||
return s.GetDbConfig().ToAppConfigVariableSlice(showAll, true)
|
return s.GetDbConfig().ToAppConfigVariableSlice(showAll, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AppConfigService) UpdateImage(ctx context.Context, uploadedFile *multipart.FileHeader, imageName string, oldImageType string) (err error) {
|
|
||||||
fileType := strings.ToLower(utils.GetFileExtension(uploadedFile.Filename))
|
|
||||||
mimeType := utils.GetImageMimeType(fileType)
|
|
||||||
if mimeType == "" {
|
|
||||||
return &common.FileTypeNotSupportedError{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the updated image
|
|
||||||
imagePath := common.EnvConfig.UploadPath + "/application-images/" + imageName + "." + fileType
|
|
||||||
err = utils.SaveFile(uploadedFile, imagePath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the old image if it has a different file type, then update the type in the database
|
|
||||||
if fileType != oldImageType {
|
|
||||||
oldImagePath := common.EnvConfig.UploadPath + "/application-images/" + imageName + "." + oldImageType
|
|
||||||
err = os.Remove(oldImagePath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the file type in the database
|
|
||||||
err = s.UpdateAppConfigValues(ctx, imageName+"ImageType", fileType)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadDbConfig loads the configuration values from the database into the DbConfig struct.
|
// LoadDbConfig loads the configuration values from the database into the DbConfig struct.
|
||||||
func (s *AppConfigService) LoadDbConfig(ctx context.Context) (err error) {
|
func (s *AppConfigService) LoadDbConfig(ctx context.Context) (err error) {
|
||||||
dest, err := s.loadDbConfigInternal(ctx, s.db)
|
dest, err := s.loadDbConfigInternal(ctx, s.db)
|
||||||
|
|||||||
82
backend/internal/service/app_images_service.go
Normal file
82
backend/internal/service/app_images_service.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"mime/multipart"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AppImagesService struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
extensions map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAppImagesService(extensions map[string]string) *AppImagesService {
|
||||||
|
return &AppImagesService{extensions: extensions}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AppImagesService) GetImage(name string) (string, string, error) {
|
||||||
|
ext, err := s.getExtension(name)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
mimeType := utils.GetImageMimeType(ext)
|
||||||
|
if mimeType == "" {
|
||||||
|
return "", "", fmt.Errorf("unsupported image type '%s'", ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
imagePath := filepath.Join(common.EnvConfig.UploadPath, "application-images", fmt.Sprintf("%s.%s", name, ext))
|
||||||
|
return imagePath, mimeType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AppImagesService) UpdateImage(file *multipart.FileHeader, imageName string) error {
|
||||||
|
fileType := strings.ToLower(utils.GetFileExtension(file.Filename))
|
||||||
|
mimeType := utils.GetImageMimeType(fileType)
|
||||||
|
if mimeType == "" {
|
||||||
|
return &common.FileTypeNotSupportedError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
currentExt, ok := s.extensions[imageName]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unknown application image '%s'", imageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
imagePath := filepath.Join(common.EnvConfig.UploadPath, "application-images", fmt.Sprintf("%s.%s", imageName, fileType))
|
||||||
|
|
||||||
|
if err := utils.SaveFile(file, imagePath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentExt != "" && currentExt != fileType {
|
||||||
|
oldImagePath := filepath.Join(common.EnvConfig.UploadPath, "application-images", fmt.Sprintf("%s.%s", imageName, currentExt))
|
||||||
|
if err := os.Remove(oldImagePath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.extensions[imageName] = fileType
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AppImagesService) getExtension(name string) (string, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
ext, ok := s.extensions[name]
|
||||||
|
if !ok || ext == "" {
|
||||||
|
return "", fmt.Errorf("unknown application image '%s'", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.ToLower(ext), nil
|
||||||
|
}
|
||||||
88
backend/internal/service/app_images_service_test.go
Normal file
88
backend/internal/service/app_images_service_test.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io/fs"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAppImagesService_GetImage(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
originalUploadPath := common.EnvConfig.UploadPath
|
||||||
|
common.EnvConfig.UploadPath = tempDir
|
||||||
|
t.Cleanup(func() {
|
||||||
|
common.EnvConfig.UploadPath = originalUploadPath
|
||||||
|
})
|
||||||
|
|
||||||
|
imagesDir := filepath.Join(tempDir, "application-images")
|
||||||
|
require.NoError(t, os.MkdirAll(imagesDir, 0o755))
|
||||||
|
|
||||||
|
filePath := filepath.Join(imagesDir, "background.webp")
|
||||||
|
require.NoError(t, os.WriteFile(filePath, []byte("data"), fs.FileMode(0o644)))
|
||||||
|
|
||||||
|
service := NewAppImagesService(map[string]string{"background": "webp"})
|
||||||
|
|
||||||
|
path, mimeType, err := service.GetImage("background")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, filePath, path)
|
||||||
|
require.Equal(t, "image/webp", mimeType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppImagesService_UpdateImage(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
originalUploadPath := common.EnvConfig.UploadPath
|
||||||
|
common.EnvConfig.UploadPath = tempDir
|
||||||
|
t.Cleanup(func() {
|
||||||
|
common.EnvConfig.UploadPath = originalUploadPath
|
||||||
|
})
|
||||||
|
|
||||||
|
imagesDir := filepath.Join(tempDir, "application-images")
|
||||||
|
require.NoError(t, os.MkdirAll(imagesDir, 0o755))
|
||||||
|
|
||||||
|
oldPath := filepath.Join(imagesDir, "logoLight.svg")
|
||||||
|
require.NoError(t, os.WriteFile(oldPath, []byte("old"), fs.FileMode(0o644)))
|
||||||
|
|
||||||
|
service := NewAppImagesService(map[string]string{"logoLight": "svg"})
|
||||||
|
|
||||||
|
fileHeader := newFileHeader(t, "logoLight.png", []byte("new"))
|
||||||
|
|
||||||
|
require.NoError(t, service.UpdateImage(fileHeader, "logoLight"))
|
||||||
|
|
||||||
|
_, err := os.Stat(filepath.Join(imagesDir, "logoLight.png"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = os.Stat(oldPath)
|
||||||
|
require.ErrorIs(t, err, os.ErrNotExist)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFileHeader(t *testing.T, filename string, content []byte) *multipart.FileHeader {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
body := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
|
||||||
|
part, err := writer.CreateFormFile("file", filename)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = part.Write(content)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NoError(t, writer.Close())
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
|
||||||
|
_, fileHeader, err := req.FormFile("file")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return fileHeader
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ func isReservedClaim(key string) bool {
|
|||||||
"name",
|
"name",
|
||||||
"email",
|
"email",
|
||||||
"preferred_username",
|
"preferred_username",
|
||||||
|
"display_name",
|
||||||
"groups",
|
"groups",
|
||||||
TokenTypeClaim,
|
TokenTypeClaim,
|
||||||
"sub",
|
"sub",
|
||||||
|
|||||||
@@ -78,21 +78,23 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
|||||||
Base: model.Base{
|
Base: model.Base{
|
||||||
ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
|
ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
|
||||||
},
|
},
|
||||||
Username: "tim",
|
Username: "tim",
|
||||||
Email: "tim.cook@test.com",
|
Email: "tim.cook@test.com",
|
||||||
FirstName: "Tim",
|
FirstName: "Tim",
|
||||||
LastName: "Cook",
|
LastName: "Cook",
|
||||||
IsAdmin: true,
|
DisplayName: "Tim Cook",
|
||||||
|
IsAdmin: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Base: model.Base{
|
Base: model.Base{
|
||||||
ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
|
ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
|
||||||
},
|
},
|
||||||
Username: "craig",
|
Username: "craig",
|
||||||
Email: "craig.federighi@test.com",
|
Email: "craig.federighi@test.com",
|
||||||
FirstName: "Craig",
|
FirstName: "Craig",
|
||||||
LastName: "Federighi",
|
LastName: "Federighi",
|
||||||
IsAdmin: false,
|
DisplayName: "Craig Federighi",
|
||||||
|
IsAdmin: false,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ func SendEmail[V any](ctx context.Context, srv *EmailService, toEmail email.Addr
|
|||||||
|
|
||||||
data := &email.TemplateData[V]{
|
data := &email.TemplateData[V]{
|
||||||
AppName: dbConfig.AppName.Value,
|
AppName: dbConfig.AppName.Value,
|
||||||
LogoURL: common.EnvConfig.AppURL + "/api/application-configuration/logo",
|
LogoURL: common.EnvConfig.AppURL + "/api/application-images/logo",
|
||||||
Data: tData,
|
Data: tData,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -278,6 +278,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
|||||||
dbConfig.LdapAttributeUserFirstName.Value,
|
dbConfig.LdapAttributeUserFirstName.Value,
|
||||||
dbConfig.LdapAttributeUserLastName.Value,
|
dbConfig.LdapAttributeUserLastName.Value,
|
||||||
dbConfig.LdapAttributeUserProfilePicture.Value,
|
dbConfig.LdapAttributeUserProfilePicture.Value,
|
||||||
|
dbConfig.LdapAttributeUserDisplayName.Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filters must start and finish with ()!
|
// Filters must start and finish with ()!
|
||||||
@@ -346,12 +347,13 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
|||||||
}
|
}
|
||||||
|
|
||||||
newUser := dto.UserCreateDto{
|
newUser := dto.UserCreateDto{
|
||||||
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
|
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
|
||||||
Email: value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value),
|
Email: value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value),
|
||||||
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
|
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
|
||||||
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
|
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
|
||||||
IsAdmin: isAdmin,
|
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
|
||||||
LdapID: ldapId,
|
IsAdmin: isAdmin,
|
||||||
|
LdapID: ldapId,
|
||||||
}
|
}
|
||||||
dto.Normalize(newUser)
|
dto.Normalize(newUser)
|
||||||
|
|
||||||
|
|||||||
@@ -1838,13 +1838,6 @@ func (s *OidcService) getUserClaims(ctx context.Context, user *model.User, scope
|
|||||||
}
|
}
|
||||||
|
|
||||||
if slices.Contains(scopes, "profile") {
|
if slices.Contains(scopes, "profile") {
|
||||||
// Add profile claims
|
|
||||||
claims["given_name"] = user.FirstName
|
|
||||||
claims["family_name"] = user.LastName
|
|
||||||
claims["name"] = user.FullName()
|
|
||||||
claims["preferred_username"] = user.Username
|
|
||||||
claims["picture"] = common.EnvConfig.AppURL + "/api/users/" + user.ID + "/profile-picture.png"
|
|
||||||
|
|
||||||
// Add custom claims
|
// Add custom claims
|
||||||
customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(ctx, user.ID, tx)
|
customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(ctx, user.ID, tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1863,6 +1856,15 @@ func (s *OidcService) getUserClaims(ctx context.Context, user *model.User, scope
|
|||||||
claims[customClaim.Key] = customClaim.Value
|
claims[customClaim.Key] = customClaim.Value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add profile claims
|
||||||
|
claims["given_name"] = user.FirstName
|
||||||
|
claims["family_name"] = user.LastName
|
||||||
|
claims["name"] = user.FullName()
|
||||||
|
claims["display_name"] = user.DisplayName
|
||||||
|
|
||||||
|
claims["preferred_username"] = user.Username
|
||||||
|
claims["picture"] = common.EnvConfig.AppURL + "/api/users/" + user.ID + "/profile-picture.png"
|
||||||
}
|
}
|
||||||
|
|
||||||
if slices.Contains(scopes, "email") {
|
if slices.Contains(scopes, "email") {
|
||||||
|
|||||||
@@ -245,12 +245,13 @@ func (s *UserService) CreateUser(ctx context.Context, input dto.UserCreateDto) (
|
|||||||
|
|
||||||
func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error) {
|
func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error) {
|
||||||
user := model.User{
|
user := model.User{
|
||||||
FirstName: input.FirstName,
|
FirstName: input.FirstName,
|
||||||
LastName: input.LastName,
|
LastName: input.LastName,
|
||||||
Email: input.Email,
|
DisplayName: input.DisplayName,
|
||||||
Username: input.Username,
|
Email: input.Email,
|
||||||
IsAdmin: input.IsAdmin,
|
Username: input.Username,
|
||||||
Locale: input.Locale,
|
IsAdmin: input.IsAdmin,
|
||||||
|
Locale: input.Locale,
|
||||||
}
|
}
|
||||||
if input.LdapID != "" {
|
if input.LdapID != "" {
|
||||||
user.LdapID = &input.LdapID
|
user.LdapID = &input.LdapID
|
||||||
@@ -362,6 +363,7 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
|
|||||||
// Full update: Allow updating all personal fields
|
// Full update: Allow updating all personal fields
|
||||||
user.FirstName = updatedUser.FirstName
|
user.FirstName = updatedUser.FirstName
|
||||||
user.LastName = updatedUser.LastName
|
user.LastName = updatedUser.LastName
|
||||||
|
user.DisplayName = updatedUser.DisplayName
|
||||||
user.Email = updatedUser.Email
|
user.Email = updatedUser.Email
|
||||||
user.Username = updatedUser.Username
|
user.Username = updatedUser.Username
|
||||||
user.Locale = updatedUser.Locale
|
user.Locale = updatedUser.Locale
|
||||||
@@ -600,11 +602,12 @@ func (s *UserService) SignUpInitialAdmin(ctx context.Context, signUpData dto.Sig
|
|||||||
}
|
}
|
||||||
|
|
||||||
userToCreate := dto.UserCreateDto{
|
userToCreate := dto.UserCreateDto{
|
||||||
FirstName: signUpData.FirstName,
|
FirstName: signUpData.FirstName,
|
||||||
LastName: signUpData.LastName,
|
LastName: signUpData.LastName,
|
||||||
Username: signUpData.Username,
|
DisplayName: strings.TrimSpace(signUpData.FirstName + " " + signUpData.LastName),
|
||||||
Email: signUpData.Email,
|
Username: signUpData.Username,
|
||||||
IsAdmin: true,
|
Email: signUpData.Email,
|
||||||
|
IsAdmin: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := s.createUserInternal(ctx, userToCreate, false, tx)
|
user, err := s.createUserInternal(ctx, userToCreate, false, tx)
|
||||||
@@ -736,10 +739,11 @@ func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAd
|
|||||||
}
|
}
|
||||||
|
|
||||||
userToCreate := dto.UserCreateDto{
|
userToCreate := dto.UserCreateDto{
|
||||||
Username: signupData.Username,
|
Username: signupData.Username,
|
||||||
Email: signupData.Email,
|
Email: signupData.Email,
|
||||||
FirstName: signupData.FirstName,
|
FirstName: signupData.FirstName,
|
||||||
LastName: signupData.LastName,
|
LastName: signupData.LastName,
|
||||||
|
DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := s.createUserInternal(ctx, userToCreate, false, tx)
|
user, err := s.createUserInternal(ctx, userToCreate, false, tx)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package email
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
htemplate "html/template"
|
htemplate "html/template"
|
||||||
"path/filepath"
|
"path"
|
||||||
ttemplate "text/template"
|
ttemplate "text/template"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/resources"
|
"github.com/pocket-id/pocket-id/backend/resources"
|
||||||
@@ -30,7 +30,7 @@ func PrepareTextTemplates(templates []string) (map[string]*ttemplate.Template, e
|
|||||||
textTemplates := make(map[string]*ttemplate.Template, len(templates))
|
textTemplates := make(map[string]*ttemplate.Template, len(templates))
|
||||||
for _, tmpl := range templates {
|
for _, tmpl := range templates {
|
||||||
filename := tmpl + "_text.tmpl"
|
filename := tmpl + "_text.tmpl"
|
||||||
templatePath := filepath.Join("email-templates", filename)
|
templatePath := path.Join("email-templates", filename)
|
||||||
|
|
||||||
parsedTemplate, err := ttemplate.ParseFS(resources.FS, templatePath)
|
parsedTemplate, err := ttemplate.ParseFS(resources.FS, templatePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -47,7 +47,7 @@ func PrepareHTMLTemplates(templates []string) (map[string]*htemplate.Template, e
|
|||||||
htmlTemplates := make(map[string]*htemplate.Template, len(templates))
|
htmlTemplates := make(map[string]*htemplate.Template, len(templates))
|
||||||
for _, tmpl := range templates {
|
for _, tmpl := range templates {
|
||||||
filename := tmpl + "_html.tmpl"
|
filename := tmpl + "_html.tmpl"
|
||||||
templatePath := filepath.Join("email-templates", filename)
|
templatePath := path.Join("email-templates", filename)
|
||||||
|
|
||||||
parsedTemplate, err := htemplate.ParseFS(resources.FS, templatePath)
|
parsedTemplate, err := htemplate.ParseFS(resources.FS, templatePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -24,6 +25,15 @@ func GetFileExtension(filename string) string {
|
|||||||
return filename
|
return filename
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SplitFileName splits a full file name into name and extension.
|
||||||
|
func SplitFileName(fullName string) (name, ext string) {
|
||||||
|
dot := strings.LastIndex(fullName, ".")
|
||||||
|
if dot == -1 || dot == 0 {
|
||||||
|
return fullName, "" // no extension or hidden file like .gitignore
|
||||||
|
}
|
||||||
|
return fullName[:dot], fullName[dot+1:]
|
||||||
|
}
|
||||||
|
|
||||||
func GetImageMimeType(ext string) string {
|
func GetImageMimeType(ext string) string {
|
||||||
switch ext {
|
switch ext {
|
||||||
case "jpg", "jpeg":
|
case "jpg", "jpeg":
|
||||||
|
|||||||
@@ -2,8 +2,36 @@ package utils
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestSplitFileName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
fullName string
|
||||||
|
wantName string
|
||||||
|
wantExt string
|
||||||
|
}{
|
||||||
|
{"background.jpg", "background", "jpg"},
|
||||||
|
{"archive.tar.gz", "archive.tar", "gz"},
|
||||||
|
{".gitignore", ".gitignore", ""},
|
||||||
|
{"noext", "noext", ""},
|
||||||
|
{"a.b.c", "a.b", "c"},
|
||||||
|
{".hidden.ext", ".hidden", "ext"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.fullName, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
name, ext := SplitFileName(tc.fullName)
|
||||||
|
assert.Equal(t, tc.wantName, name)
|
||||||
|
assert.Equal(t, tc.wantExt, ext)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetFileExtension(t *testing.T) {
|
func TestGetFileExtension(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -3,3 +3,11 @@ package utils
|
|||||||
func Ptr[T any](v T) *T {
|
func Ptr[T any](v T) *T {
|
||||||
return &v
|
return &v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func PtrValueOrZero[T any](ptr *T) T {
|
||||||
|
if ptr == nil {
|
||||||
|
var zero T
|
||||||
|
return zero
|
||||||
|
}
|
||||||
|
return *ptr
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE users DROP COLUMN display_name;
|
||||||
|
|
||||||
|
ALTER TABLE users ALTER COLUMN username TYPE TEXT;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN display_name TEXT;
|
||||||
|
UPDATE users SET display_name = trim(coalesce(first_name,'') || ' ' || coalesce(last_name,''));
|
||||||
|
ALTER TABLE users ALTER COLUMN display_name SET NOT NULL;
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS citext;
|
||||||
|
ALTER TABLE users ALTER COLUMN username TYPE CITEXT COLLATE "C";
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
BEGIN;
|
||||||
|
ALTER TABLE users DROP COLUMN display_name;
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
PRAGMA foreign_keys = OFF;
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE users_new
|
||||||
|
(
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
created_at DATETIME,
|
||||||
|
username TEXT NOT NULL COLLATE NOCASE UNIQUE,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
first_name TEXT,
|
||||||
|
last_name TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
is_admin NUMERIC NOT NULL DEFAULT FALSE,
|
||||||
|
ldap_id TEXT,
|
||||||
|
locale TEXT,
|
||||||
|
disabled NUMERIC NOT NULL DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO users_new (id, created_at, username, email, first_name, last_name, display_name, is_admin, ldap_id, locale,
|
||||||
|
disabled)
|
||||||
|
SELECT id,
|
||||||
|
created_at,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
first_name,
|
||||||
|
COALESCE(last_name, ''),
|
||||||
|
TRIM(COALESCE(first_name, '') || ' ' || COALESCE(last_name, '')),
|
||||||
|
is_admin,
|
||||||
|
ldap_id,
|
||||||
|
locale,
|
||||||
|
disabled
|
||||||
|
FROM users;
|
||||||
|
|
||||||
|
DROP TABLE users;
|
||||||
|
|
||||||
|
ALTER TABLE users_new
|
||||||
|
RENAME TO users;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX users_ldap_id ON users (ldap_id);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
49
cliff.toml
Normal file
49
cliff.toml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# git-cliff ~ configuration file
|
||||||
|
# https://git-cliff.org/docs/configuration
|
||||||
|
|
||||||
|
[remote.github]
|
||||||
|
owner = "pocket-id"
|
||||||
|
repo = "pocket-id"
|
||||||
|
|
||||||
|
[git]
|
||||||
|
conventional_commits = true
|
||||||
|
filter_unconventional = true
|
||||||
|
commit_preprocessors = [{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" }]
|
||||||
|
commit_parsers = [
|
||||||
|
{ message = "^feat", group = "Features" },
|
||||||
|
{ message = "^fix", group = "Bug Fixes" },
|
||||||
|
{ message = "^docs", group = "Documentation" },
|
||||||
|
{ message = "^perf", group = "Performance Improvements" },
|
||||||
|
{ message = "^release", skip = true },
|
||||||
|
{ message = "update translations via Crowdin", skip = true },
|
||||||
|
{ message = ".*", group = "Other", default_scope = "other"},
|
||||||
|
]
|
||||||
|
filter_commits = false
|
||||||
|
|
||||||
|
[changelog]
|
||||||
|
trim = true
|
||||||
|
body = """
|
||||||
|
## {{ version | default(value="Unknown Version") }}
|
||||||
|
{% for group, commits in commits | group_by(attribute="group") %}
|
||||||
|
### {{ group | title }}
|
||||||
|
{% for commit in commits %}
|
||||||
|
* {{ commit.message }} \
|
||||||
|
{%- if commit.remote.pr_number -%}
|
||||||
|
([#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }}) by @{{ commit.remote.username | default(value=commit.author.name) }})
|
||||||
|
{%- else -%}
|
||||||
|
([{{ commit.id | truncate(length=7, end="") }}]({{ self::remote_url() }}/commit/{{ commit.id }}) by @{{ commit.remote.username | default(value=commit.author.name) }})
|
||||||
|
{%- endif -%}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if version %}
|
||||||
|
{% if previous.version %}
|
||||||
|
**Full Changelog**: {{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }}
|
||||||
|
{% endif %}
|
||||||
|
{% else -%}
|
||||||
|
{% raw %}\n{% endraw %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{%- macro remote_url() -%}
|
||||||
|
https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}
|
||||||
|
{%- endmacro -%}
|
||||||
|
"""
|
||||||
@@ -120,6 +120,8 @@
|
|||||||
"username": "Uživatelské jméno",
|
"username": "Uživatelské jméno",
|
||||||
"save": "Uložit",
|
"save": "Uložit",
|
||||||
"username_can_only_contain": "Uživatelské jméno může obsahovat pouze malá písmena, číslice, podtržítka, tečky, pomlčky a symbol '@'",
|
"username_can_only_contain": "Uživatelské jméno může obsahovat pouze malá písmena, číslice, podtržítka, tečky, pomlčky a symbol '@'",
|
||||||
|
"username_must_start_with": "Uživatelské jméno musí začínat alfanumerickým znakem.",
|
||||||
|
"username_must_end_with": "Uživatelské jméno musí končit alfanumerickým znakem.",
|
||||||
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Přihlaste se pomocí následujícího kódu. Platnost kódu vyprší za 15 minut.",
|
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Přihlaste se pomocí následujícího kódu. Platnost kódu vyprší za 15 minut.",
|
||||||
"or_visit": "nebo navštívit",
|
"or_visit": "nebo navštívit",
|
||||||
"added_on": "Přidáno",
|
"added_on": "Přidáno",
|
||||||
@@ -443,7 +445,10 @@
|
|||||||
"custom_client_id_description": "Nastavte vlastní ID klienta, pokud to vyžaduje vaše aplikace. V opačném případě pole nechte prázdné, aby bylo vygenerováno náhodné ID.",
|
"custom_client_id_description": "Nastavte vlastní ID klienta, pokud to vyžaduje vaše aplikace. V opačném případě pole nechte prázdné, aby bylo vygenerováno náhodné ID.",
|
||||||
"generated": "Vygenerováno",
|
"generated": "Vygenerováno",
|
||||||
"administration": "Správa",
|
"administration": "Správa",
|
||||||
"group_rdn_attribute_description": "Atribut použitý v rozlišovacím jménu skupiny (DN). Doporučená hodnota: `cn`",
|
"group_rdn_attribute_description": "Atribut použitý v rozlišovacím jménu (DN) skupiny.",
|
||||||
|
"display_name_attribute": "Atribut zobrazovaného jména",
|
||||||
|
"display_name": "Zobrazované jméno",
|
||||||
|
"configure_application_images": "Konfigurace obrazů aplikací",
|
||||||
"ui_config_disabled_info_title": "Konfigurace uživatelského rozhraní je deaktivována",
|
"ui_config_disabled_info_title": "Konfigurace uživatelského rozhraní je deaktivována",
|
||||||
"ui_config_disabled_info_description": "Konfigurace uživatelského rozhraní je deaktivována, protože nastavení konfigurace aplikace se spravuje prostřednictvím proměnných prostředí. Některá nastavení nemusí být editovatelná."
|
"ui_config_disabled_info_description": "Konfigurace uživatelského rozhraní je deaktivována, protože nastavení konfigurace aplikace se spravuje prostřednictvím proměnných prostředí. Některá nastavení nemusí být editovatelná."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,8 @@
|
|||||||
"username": "Brugernavn",
|
"username": "Brugernavn",
|
||||||
"save": "Gem",
|
"save": "Gem",
|
||||||
"username_can_only_contain": "Brugernavn må kun indeholde små bogstaver, tal, understregninger (_), punktummer (.), bindestreger (-) og @-tegn",
|
"username_can_only_contain": "Brugernavn må kun indeholde små bogstaver, tal, understregninger (_), punktummer (.), bindestreger (-) og @-tegn",
|
||||||
|
"username_must_start_with": "Brugernavnet skal begynde med et alfanumerisk tegn",
|
||||||
|
"username_must_end_with": "Brugernavnet skal slutte med et alfanumerisk tegn",
|
||||||
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Log ind med nedenstående kode. Koden udløber om 15 minutter.",
|
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Log ind med nedenstående kode. Koden udløber om 15 minutter.",
|
||||||
"or_visit": "eller besøg",
|
"or_visit": "eller besøg",
|
||||||
"added_on": "Tilføjet den",
|
"added_on": "Tilføjet den",
|
||||||
@@ -443,7 +445,10 @@
|
|||||||
"custom_client_id_description": "Indstil et brugerdefineret klient-id, hvis dette kræves af din applikation. Ellers skal du lade feltet være tomt for at generere et tilfældigt id.",
|
"custom_client_id_description": "Indstil et brugerdefineret klient-id, hvis dette kræves af din applikation. Ellers skal du lade feltet være tomt for at generere et tilfældigt id.",
|
||||||
"generated": "Genereret",
|
"generated": "Genereret",
|
||||||
"administration": "Administration",
|
"administration": "Administration",
|
||||||
"group_rdn_attribute_description": "Den attribut, der bruges i gruppernes skelnenavn (DN). Anbefalet værdi: `cn`",
|
"group_rdn_attribute_description": "Den attribut, der bruges i gruppernes skelnenavn (DN).",
|
||||||
|
"display_name_attribute": "Visningsnavn-attribut",
|
||||||
|
"display_name": "Visningsnavn",
|
||||||
|
"configure_application_images": "Konfigurer applikationsbilleder",
|
||||||
"ui_config_disabled_info_title": "UI-konfiguration deaktiveret",
|
"ui_config_disabled_info_title": "UI-konfiguration deaktiveret",
|
||||||
"ui_config_disabled_info_description": "UI-konfigurationen er deaktiveret, fordi applikationskonfigurationsindstillingerne administreres via miljøvariabler. Nogle indstillinger kan muligvis ikke redigeres."
|
"ui_config_disabled_info_description": "UI-konfigurationen er deaktiveret, fordi applikationskonfigurationsindstillingerne administreres via miljøvariabler. Nogle indstillinger kan muligvis ikke redigeres."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,8 @@
|
|||||||
"username": "Benutzername",
|
"username": "Benutzername",
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
"username_can_only_contain": "Der Benutzername darf nur Kleinbuchstaben, Ziffern, Unterstriche, Punkte, Bindestriche und das Symbol „@“ enthalten",
|
"username_can_only_contain": "Der Benutzername darf nur Kleinbuchstaben, Ziffern, Unterstriche, Punkte, Bindestriche und das Symbol „@“ enthalten",
|
||||||
|
"username_must_start_with": "Der Benutzername muss mit einem Buchstaben oder einer Zahl anfangen.",
|
||||||
|
"username_must_end_with": "Der Benutzername muss mit einem Buchstaben oder einer Zahl enden.",
|
||||||
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Melde dich mit dem folgenden Code an. Der Code läuft in 15 Minuten ab.",
|
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Melde dich mit dem folgenden Code an. Der Code läuft in 15 Minuten ab.",
|
||||||
"or_visit": "oder besuche",
|
"or_visit": "oder besuche",
|
||||||
"added_on": "Hinzugefügt am",
|
"added_on": "Hinzugefügt am",
|
||||||
@@ -443,7 +445,10 @@
|
|||||||
"custom_client_id_description": "Gib eine eigene Client-ID ein, wenn deine App das braucht. Ansonsten lass das Feld leer, damit eine zufällige ID generiert wird.",
|
"custom_client_id_description": "Gib eine eigene Client-ID ein, wenn deine App das braucht. Ansonsten lass das Feld leer, damit eine zufällige ID generiert wird.",
|
||||||
"generated": "Generiert",
|
"generated": "Generiert",
|
||||||
"administration": "Verwaltung",
|
"administration": "Verwaltung",
|
||||||
"group_rdn_attribute_description": "Das Attribut, das im Distinguished Name (DN) der Gruppen benutzt wird. Empfohlener Wert: `cn`",
|
"group_rdn_attribute_description": "Das Attribut, das im Distinguished Name (DN) der Gruppen benutzt wird.",
|
||||||
|
"display_name_attribute": "Anzeigename-Attribut",
|
||||||
|
"display_name": "Anzeigename",
|
||||||
|
"configure_application_images": "Anwendungsimages einrichten",
|
||||||
"ui_config_disabled_info_title": "UI-Konfiguration deaktiviert",
|
"ui_config_disabled_info_title": "UI-Konfiguration deaktiviert",
|
||||||
"ui_config_disabled_info_description": "Die UI-Konfiguration ist deaktiviert, weil die Anwendungseinstellungen über Umgebungsvariablen verwaltet werden. Manche Einstellungen können vielleicht nicht geändert werden."
|
"ui_config_disabled_info_description": "Die UI-Konfiguration ist deaktiviert, weil die Anwendungseinstellungen über Umgebungsvariablen verwaltet werden. Manche Einstellungen können vielleicht nicht geändert werden."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,8 @@
|
|||||||
"username": "Username",
|
"username": "Username",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"username_can_only_contain": "Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols",
|
"username_can_only_contain": "Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols",
|
||||||
|
"username_must_start_with": "Username must start with an alphanumeric character",
|
||||||
|
"username_must_end_with": "Username must end with an alphanumeric character",
|
||||||
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Sign in using the following code. The code will expire in 15 minutes.",
|
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Sign in using the following code. The code will expire in 15 minutes.",
|
||||||
"or_visit": "or visit",
|
"or_visit": "or visit",
|
||||||
"added_on": "Added on",
|
"added_on": "Added on",
|
||||||
@@ -443,7 +445,10 @@
|
|||||||
"custom_client_id_description": "Set a custom client ID if this is required by your application. Otherwise, leave it blank to generate a random one.",
|
"custom_client_id_description": "Set a custom client ID if this is required by your application. Otherwise, leave it blank to generate a random one.",
|
||||||
"generated": "Generated",
|
"generated": "Generated",
|
||||||
"administration": "Administration",
|
"administration": "Administration",
|
||||||
"group_rdn_attribute_description": "The attribute used in the groups distinguished name (DN). Recommended value: `cn`",
|
"group_rdn_attribute_description": "The attribute used in the groups distinguished name (DN).",
|
||||||
|
"display_name_attribute": "Display Name Attribute",
|
||||||
|
"display_name": "Display Name",
|
||||||
|
"configure_application_images": "Configure Application Images",
|
||||||
"ui_config_disabled_info_title": "UI Configuration Disabled",
|
"ui_config_disabled_info_title": "UI Configuration Disabled",
|
||||||
"ui_config_disabled_info_description": "The UI configuration is disabled because the application configuration settings are managed through environment variables. Some settings may not be editable."
|
"ui_config_disabled_info_description": "The UI configuration is disabled because the application configuration settings are managed through environment variables. Some settings may not be editable."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,8 @@
|
|||||||
"username": "Nombre de usuario",
|
"username": "Nombre de usuario",
|
||||||
"save": "Guardar",
|
"save": "Guardar",
|
||||||
"username_can_only_contain": "El nombre de usuario solo puede contener letras minúsculas, números, guiones bajos, puntos, guiones y símbolos '@'",
|
"username_can_only_contain": "El nombre de usuario solo puede contener letras minúsculas, números, guiones bajos, puntos, guiones y símbolos '@'",
|
||||||
|
"username_must_start_with": "El nombre de usuario debe comenzar con un carácter alfanumérico.",
|
||||||
|
"username_must_end_with": "El nombre de usuario debe terminar con un carácter alfanumérico.",
|
||||||
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Inicia sesión usando el siguiente código. El código caducará en 15 minutos.",
|
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Inicia sesión usando el siguiente código. El código caducará en 15 minutos.",
|
||||||
"or_visit": "o visita",
|
"or_visit": "o visita",
|
||||||
"added_on": "Añadido el",
|
"added_on": "Añadido el",
|
||||||
@@ -443,7 +445,10 @@
|
|||||||
"custom_client_id_description": "Establece un ID de cliente personalizado si tu aplicación lo requiere. De lo contrario, déjalo en blanco para generar uno aleatorio.",
|
"custom_client_id_description": "Establece un ID de cliente personalizado si tu aplicación lo requiere. De lo contrario, déjalo en blanco para generar uno aleatorio.",
|
||||||
"generated": "Generado",
|
"generated": "Generado",
|
||||||
"administration": "Administración",
|
"administration": "Administración",
|
||||||
"group_rdn_attribute_description": "El atributo utilizado en el nombre distintivo (DN) de los grupos. Valor recomendado: `cn`",
|
"group_rdn_attribute_description": "El atributo utilizado en el nombre distintivo (DN) de los grupos.",
|
||||||
|
"display_name_attribute": "Atributo de nombre para mostrar",
|
||||||
|
"display_name": "Nombre para mostrar",
|
||||||
|
"configure_application_images": "Configurar imágenes de aplicaciones",
|
||||||
"ui_config_disabled_info_title": "Configuración de la interfaz de usuario desactivada",
|
"ui_config_disabled_info_title": "Configuración de la interfaz de usuario desactivada",
|
||||||
"ui_config_disabled_info_description": "La configuración de la interfaz de usuario está desactivada porque los ajustes de configuración de la aplicación se gestionan a través de variables de entorno. Es posible que algunos ajustes no se puedan editar."
|
"ui_config_disabled_info_description": "La configuración de la interfaz de usuario está desactivada porque los ajustes de configuración de la aplicación se gestionan a través de variables de entorno. Es posible que algunos ajustes no se puedan editar."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,8 @@
|
|||||||
"username": "Nom d'utilisateur",
|
"username": "Nom d'utilisateur",
|
||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
"username_can_only_contain": "Le nom d'utilisateur ne peut contenir que des lettres minuscules, des chiffres, des tirets, des tirets bas et le symbole '@'",
|
"username_can_only_contain": "Le nom d'utilisateur ne peut contenir que des lettres minuscules, des chiffres, des tirets, des tirets bas et le symbole '@'",
|
||||||
|
"username_must_start_with": "Le nom d'utilisateur doit commencer par un caractère alphanumérique.",
|
||||||
|
"username_must_end_with": "Le nom d'utilisateur doit finir par un caractère alphanumérique.",
|
||||||
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Connectez-vous avec le code suivant. Le code expirera dans 15 minutes.",
|
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Connectez-vous avec le code suivant. Le code expirera dans 15 minutes.",
|
||||||
"or_visit": "ou visiter",
|
"or_visit": "ou visiter",
|
||||||
"added_on": "Ajoutée le",
|
"added_on": "Ajoutée le",
|
||||||
@@ -443,7 +445,10 @@
|
|||||||
"custom_client_id_description": "Définissez un identifiant client personnalisé si votre application l'exige. Sinon, laissez ce champ vide pour qu'un identifiant aléatoire soit généré.",
|
"custom_client_id_description": "Définissez un identifiant client personnalisé si votre application l'exige. Sinon, laissez ce champ vide pour qu'un identifiant aléatoire soit généré.",
|
||||||
"generated": "Généré",
|
"generated": "Généré",
|
||||||
"administration": "Administration",
|
"administration": "Administration",
|
||||||
"group_rdn_attribute_description": "L'attribut utilisé dans le nom distinctif (DN) des groupes. Valeur recommandée : `cn`",
|
"group_rdn_attribute_description": "L'attribut utilisé dans le nom distinctif (DN) des groupes.",
|
||||||
|
"display_name_attribute": "Attribut du nom d'affichage",
|
||||||
|
"display_name": "Nom d'affichage",
|
||||||
|
"configure_application_images": "Configurer les images d'application",
|
||||||
"ui_config_disabled_info_title": "Configuration de l'interface utilisateur désactivée",
|
"ui_config_disabled_info_title": "Configuration de l'interface utilisateur désactivée",
|
||||||
"ui_config_disabled_info_description": "La configuration de l'interface utilisateur est désactivée parce que les paramètres de configuration de l'application sont gérés par des variables d'environnement. Certains paramètres peuvent ne pas être modifiables."
|
"ui_config_disabled_info_description": "La configuration de l'interface utilisateur est désactivée parce que les paramètres de configuration de l'application sont gérés par des variables d'environnement. Certains paramètres peuvent ne pas être modifiables."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,8 @@
|
|||||||
"username": "Nome utente",
|
"username": "Nome utente",
|
||||||
"save": "Salva",
|
"save": "Salva",
|
||||||
"username_can_only_contain": "Il nome utente può contenere solo lettere minuscole, numeri, underscore, punti, trattini e simboli '@'",
|
"username_can_only_contain": "Il nome utente può contenere solo lettere minuscole, numeri, underscore, punti, trattini e simboli '@'",
|
||||||
|
"username_must_start_with": "Il nome utente deve iniziare con un carattere alfanumerico.",
|
||||||
|
"username_must_end_with": "Il nome utente deve finire con un carattere alfanumerico",
|
||||||
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Accedi utilizzando il seguente codice. Il codice scadrà tra 15 minuti.",
|
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Accedi utilizzando il seguente codice. Il codice scadrà tra 15 minuti.",
|
||||||
"or_visit": "o visita",
|
"or_visit": "o visita",
|
||||||
"added_on": "Aggiunto il",
|
"added_on": "Aggiunto il",
|
||||||
@@ -443,7 +445,10 @@
|
|||||||
"custom_client_id_description": "Imposta un ID cliente personalizzato se la tua app lo richiede. Altrimenti, lascia vuoto per generarne uno casuale.",
|
"custom_client_id_description": "Imposta un ID cliente personalizzato se la tua app lo richiede. Altrimenti, lascia vuoto per generarne uno casuale.",
|
||||||
"generated": "Generato",
|
"generated": "Generato",
|
||||||
"administration": "Amministrazione",
|
"administration": "Amministrazione",
|
||||||
"group_rdn_attribute_description": "L'attributo usato nel nome distinto (DN) dei gruppi. Valore consigliato: `cn`",
|
"group_rdn_attribute_description": "L'attributo usato nel nome distinto (DN) dei gruppi.",
|
||||||
|
"display_name_attribute": "Attributo del nome visualizzato",
|
||||||
|
"display_name": "Nome visualizzato",
|
||||||
|
"configure_application_images": "Configurare le immagini dell'applicazione",
|
||||||
"ui_config_disabled_info_title": "Configurazione dell'interfaccia utente disattivata",
|
"ui_config_disabled_info_title": "Configurazione dell'interfaccia utente disattivata",
|
||||||
"ui_config_disabled_info_description": "La configurazione dell'interfaccia utente è disattivata perché le impostazioni di configurazione dell'applicazione sono gestite tramite variabili di ambiente. Alcune impostazioni potrebbero non essere modificabili."
|
"ui_config_disabled_info_description": "La configurazione dell'interfaccia utente è disattivata perché le impostazioni di configurazione dell'applicazione sono gestite tramite variabili di ambiente. Alcune impostazioni potrebbero non essere modificabili."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,8 @@
|
|||||||
"username": "사용자 이름",
|
"username": "사용자 이름",
|
||||||
"save": "저장",
|
"save": "저장",
|
||||||
"username_can_only_contain": "사용자 이름은 영어 소문자, 숫자, 밑줄, 점, 하이픈, '@' 기호만 포함할 수 있습니다",
|
"username_can_only_contain": "사용자 이름은 영어 소문자, 숫자, 밑줄, 점, 하이픈, '@' 기호만 포함할 수 있습니다",
|
||||||
|
"username_must_start_with": "사용자 이름은 영숫자로 시작해야 합니다",
|
||||||
|
"username_must_end_with": "사용자 이름은 영숫자로 끝나야 합니다",
|
||||||
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "다음 코드를 사용하여 로그인하세요. 이 코드는 15분 후에 만료됩니다.",
|
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "다음 코드를 사용하여 로그인하세요. 이 코드는 15분 후에 만료됩니다.",
|
||||||
"or_visit": "또는",
|
"or_visit": "또는",
|
||||||
"added_on": "추가:",
|
"added_on": "추가:",
|
||||||
@@ -443,7 +445,10 @@
|
|||||||
"custom_client_id_description": "애플리케이션에서 사용자 정의 클라이언트 ID가 요구되는 경우 설정하세요. 그렇지 않으면 빈 상태로 두어서 무작위로 생성할 수 있습니다.",
|
"custom_client_id_description": "애플리케이션에서 사용자 정의 클라이언트 ID가 요구되는 경우 설정하세요. 그렇지 않으면 빈 상태로 두어서 무작위로 생성할 수 있습니다.",
|
||||||
"generated": "생성됨",
|
"generated": "생성됨",
|
||||||
"administration": "관리",
|
"administration": "관리",
|
||||||
"group_rdn_attribute_description": "그룹의 고유 식별자(DN)에 사용되는 속성. 권장 값: `cn`",
|
"group_rdn_attribute_description": "그룹의 고유 식별자(DN)에 사용되는 속성.",
|
||||||
|
"display_name_attribute": "표시 이름 속성",
|
||||||
|
"display_name": "표시 이름",
|
||||||
|
"configure_application_images": "애플리케이션 이미지 구성",
|
||||||
"ui_config_disabled_info_title": "UI 구성 비활성화됨",
|
"ui_config_disabled_info_title": "UI 구성 비활성화됨",
|
||||||
"ui_config_disabled_info_description": "UI 구성이 비활성화되었습니다. 애플리케이션 구성 설정은 환경 변수를 통해 관리되기 때문입니다. 일부 설정은 편집할 수 없을 수 있습니다."
|
"ui_config_disabled_info_description": "UI 구성이 비활성화되었습니다. 애플리케이션 구성 설정은 환경 변수를 통해 관리되기 때문입니다. 일부 설정은 편집할 수 없을 수 있습니다."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,8 @@
|
|||||||
"username": "Gebruikersnaam",
|
"username": "Gebruikersnaam",
|
||||||
"save": "Opslaan",
|
"save": "Opslaan",
|
||||||
"username_can_only_contain": "Gebruikersnaam mag alleen kleine letters, cijfers, onderstrepingstekens, punten, koppeltekens en '@'-symbolen bevatten",
|
"username_can_only_contain": "Gebruikersnaam mag alleen kleine letters, cijfers, onderstrepingstekens, punten, koppeltekens en '@'-symbolen bevatten",
|
||||||
|
"username_must_start_with": "Je gebruikersnaam moet beginnen met een letter of cijfer.",
|
||||||
|
"username_must_end_with": "Je gebruikersnaam moet eindigen met een letter of cijfer.",
|
||||||
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Meld je aan met de volgende code. De code verloopt over 15 minuten.",
|
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Meld je aan met de volgende code. De code verloopt over 15 minuten.",
|
||||||
"or_visit": "of bezoek",
|
"or_visit": "of bezoek",
|
||||||
"added_on": "Toegevoegd op",
|
"added_on": "Toegevoegd op",
|
||||||
@@ -419,7 +421,7 @@
|
|||||||
"created": "Gemaakt",
|
"created": "Gemaakt",
|
||||||
"token": "Token",
|
"token": "Token",
|
||||||
"loading": "Bezig met laden",
|
"loading": "Bezig met laden",
|
||||||
"delete_signup_token": "Registratietoken verwijderen",
|
"delete_signup_token": "Aanmeldtoken verwijderen",
|
||||||
"are_you_sure_you_want_to_delete_this_signup_token": "Weet je zeker dat je dit aanmeldingstoken wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
"are_you_sure_you_want_to_delete_this_signup_token": "Weet je zeker dat je dit aanmeldingstoken wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
||||||
"signup_with_token": "Aanmelden met token",
|
"signup_with_token": "Aanmelden met token",
|
||||||
"signup_with_token_description": "Je kunt je alleen aanmelden met een geldige aanmeldtoken die door een beheerder is aangemaakt.",
|
"signup_with_token_description": "Je kunt je alleen aanmelden met een geldige aanmeldtoken die door een beheerder is aangemaakt.",
|
||||||
@@ -439,11 +441,14 @@
|
|||||||
"revoke_access_description": "Toegang intrekken tot <b>{clientName}</b>. <b>{clientName}</b> kan je accountgegevens niet meer gebruiken.",
|
"revoke_access_description": "Toegang intrekken tot <b>{clientName}</b>. <b>{clientName}</b> kan je accountgegevens niet meer gebruiken.",
|
||||||
"revoke_access_successful": "De toegang tot {clientName} is nu succesvol geblokkeerd.",
|
"revoke_access_successful": "De toegang tot {clientName} is nu succesvol geblokkeerd.",
|
||||||
"last_signed_in_ago": "Laatst ingelogd {time} geleden",
|
"last_signed_in_ago": "Laatst ingelogd {time} geleden",
|
||||||
"invalid_client_id": "De client-ID mag alleen letters, cijfers, onderstrepingstekens en koppeltekens bevatten",
|
"invalid_client_id": "De Client-ID mag alleen letters, cijfers, onderstrepingstekens en koppeltekens bevatten.",
|
||||||
"custom_client_id_description": "Stel een aangepaste client-ID in als je app dit nodig heeft. Anders laat je het gewoon leeg en wordt er een willekeurige ID gegenereerd.",
|
"custom_client_id_description": "Stel een aangepaste Client-ID in als je app dit nodig heeft. Als je het leeg laat wordt er een willekeurige ID gegenereerd.",
|
||||||
"generated": "Gemaakt",
|
"generated": "Gemaakt",
|
||||||
"administration": "Beheer",
|
"administration": "Beheer",
|
||||||
"group_rdn_attribute_description": "Het kenmerk dat je gebruikt in de onderscheidende naam (DN) van de groepen. Aanbevolen waarde: `cn`",
|
"group_rdn_attribute_description": "Het kenmerk dat wordt gebruikt in de onderscheidende naam (DN) van de groepen.",
|
||||||
|
"display_name_attribute": "Weergavenaam-attribuut",
|
||||||
|
"display_name": "Weergavenaam",
|
||||||
|
"configure_application_images": "Configureer applicatieafbeeldingen",
|
||||||
"ui_config_disabled_info_title": "UI-configuratie uitgeschakeld",
|
"ui_config_disabled_info_title": "UI-configuratie uitgeschakeld",
|
||||||
"ui_config_disabled_info_description": "De UI-configuratie is uitgeschakeld omdat de configuratie-instellingen van de app via omgevingsvariabelen worden beheerd. Sommige instellingen kun je misschien niet aanpassen."
|
"ui_config_disabled_info_description": "De UI-configuratie is uitgeschakeld omdat de configuratie-instellingen van de app via omgevingsvariabelen worden beheerd. Sommige instellingen kun je misschien niet aanpassen."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,8 @@
|
|||||||
"username": "Nazwa użytkownika",
|
"username": "Nazwa użytkownika",
|
||||||
"save": "Zapisz",
|
"save": "Zapisz",
|
||||||
"username_can_only_contain": "Nazwa użytkownika może zawierać tylko małe litery, cyfry, podkreślenia, kropki, myślniki i symbole '@'",
|
"username_can_only_contain": "Nazwa użytkownika może zawierać tylko małe litery, cyfry, podkreślenia, kropki, myślniki i symbole '@'",
|
||||||
|
"username_must_start_with": "Nazwa użytkownika musi zaczynać się od znaku alfanumerycznego.",
|
||||||
|
"username_must_end_with": "Nazwa użytkownika musi kończyć się znakiem alfanumerycznym.",
|
||||||
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Zaloguj się, używając następującego kodu. Kod wygaśnie za 15 minut.",
|
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Zaloguj się, używając następującego kodu. Kod wygaśnie za 15 minut.",
|
||||||
"or_visit": "lub odwiedź",
|
"or_visit": "lub odwiedź",
|
||||||
"added_on": "Dodano",
|
"added_on": "Dodano",
|
||||||
@@ -443,7 +445,10 @@
|
|||||||
"custom_client_id_description": "Ustaw niestandardowy identyfikator klienta, jeśli jest to wymagane przez twoją aplikację. W przeciwnym razie pozostaw to pole puste, aby wygenerować losowy identyfikator.",
|
"custom_client_id_description": "Ustaw niestandardowy identyfikator klienta, jeśli jest to wymagane przez twoją aplikację. W przeciwnym razie pozostaw to pole puste, aby wygenerować losowy identyfikator.",
|
||||||
"generated": "Wygenerowano",
|
"generated": "Wygenerowano",
|
||||||
"administration": "Administracja",
|
"administration": "Administracja",
|
||||||
"group_rdn_attribute_description": "Atrybut używany w nazwie wyróżniającej grupy (DN). Zalecana wartość: `cn`",
|
"group_rdn_attribute_description": "Atrybut używany w nazwie wyróżniającej grupy (DN).",
|
||||||
|
"display_name_attribute": "Atrybut nazwy wyświetlanej",
|
||||||
|
"display_name": "Wyświetlana nazwa",
|
||||||
|
"configure_application_images": "Konfigurowanie obrazów aplikacji",
|
||||||
"ui_config_disabled_info_title": "Konfiguracja interfejsu użytkownika wyłączona",
|
"ui_config_disabled_info_title": "Konfiguracja interfejsu użytkownika wyłączona",
|
||||||
"ui_config_disabled_info_description": "Konfiguracja interfejsu użytkownika jest wyłączona, ponieważ ustawienia konfiguracyjne aplikacji są zarządzane za pomocą zmiennych środowiskowych. Niektóre ustawienia mogą nie być edytowalne."
|
"ui_config_disabled_info_description": "Konfiguracja interfejsu użytkownika jest wyłączona, ponieważ ustawienia konfiguracyjne aplikacji są zarządzane za pomocą zmiennych środowiskowych. Niektóre ustawienia mogą nie być edytowalne."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,8 @@
|
|||||||
"username": "Nome de usuário",
|
"username": "Nome de usuário",
|
||||||
"save": "Salvar",
|
"save": "Salvar",
|
||||||
"username_can_only_contain": "O nome de usuário só pode conter letras minúsculas, números, underscores, pontos, hífens e símbolos '@'",
|
"username_can_only_contain": "O nome de usuário só pode conter letras minúsculas, números, underscores, pontos, hífens e símbolos '@'",
|
||||||
|
"username_must_start_with": "O nome de usuário precisa começar com um caractere alfanumérico.",
|
||||||
|
"username_must_end_with": "O nome de usuário precisa terminar com um caractere alfanumérico.",
|
||||||
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Faça o login usando o código a seguir. O código irá expirar em 15 minutos.",
|
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Faça o login usando o código a seguir. O código irá expirar em 15 minutos.",
|
||||||
"or_visit": "ou visite",
|
"or_visit": "ou visite",
|
||||||
"added_on": "Adicionado em",
|
"added_on": "Adicionado em",
|
||||||
@@ -443,7 +445,10 @@
|
|||||||
"custom_client_id_description": "Defina um ID de cliente personalizado se for necessário para o seu aplicativo. Caso contrário, deixe em branco para gerar um aleatório.",
|
"custom_client_id_description": "Defina um ID de cliente personalizado se for necessário para o seu aplicativo. Caso contrário, deixe em branco para gerar um aleatório.",
|
||||||
"generated": "Gerado",
|
"generated": "Gerado",
|
||||||
"administration": "Administração",
|
"administration": "Administração",
|
||||||
"group_rdn_attribute_description": "O atributo usado no nome distinto (DN) dos grupos. Valor recomendado: `cn`",
|
"group_rdn_attribute_description": "O atributo usado no nome distinto (DN) dos grupos.",
|
||||||
|
"display_name_attribute": "Atributo Nome de exibição",
|
||||||
|
"display_name": "Nome de exibição",
|
||||||
|
"configure_application_images": "Configurar imagens de aplicativos",
|
||||||
"ui_config_disabled_info_title": "Configuração da interface do usuário desativada",
|
"ui_config_disabled_info_title": "Configuração da interface do usuário desativada",
|
||||||
"ui_config_disabled_info_description": "A configuração da interface do usuário está desativada porque as configurações do aplicativo são gerenciadas por meio de variáveis de ambiente. Algumas configurações podem não ser editáveis."
|
"ui_config_disabled_info_description": "A configuração da interface do usuário está desativada porque as configurações do aplicativo são gerenciadas por meio de variáveis de ambiente. Algumas configurações podem não ser editáveis."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,8 @@
|
|||||||
"username": "Имя пользователя",
|
"username": "Имя пользователя",
|
||||||
"save": "Сохранить",
|
"save": "Сохранить",
|
||||||
"username_can_only_contain": "Имя пользователя может содержать только строчные буквы, цифры, знак подчеркивания, точки, дефиса и символ '@'",
|
"username_can_only_contain": "Имя пользователя может содержать только строчные буквы, цифры, знак подчеркивания, точки, дефиса и символ '@'",
|
||||||
|
"username_must_start_with": "Имя пользователя должно начинаться с буквы или цифры",
|
||||||
|
"username_must_end_with": "Имя пользователя должно заканчиваться буквой или цифрой",
|
||||||
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Войдите, используя следующий код. Код истечет через 15 минут.",
|
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Войдите, используя следующий код. Код истечет через 15 минут.",
|
||||||
"or_visit": "или посетите",
|
"or_visit": "или посетите",
|
||||||
"added_on": "Добавлен",
|
"added_on": "Добавлен",
|
||||||
@@ -443,7 +445,10 @@
|
|||||||
"custom_client_id_description": "Установите пользовательский ID клиента, если это нужно для вашего приложения. Если нет, оставьте поле пустым, чтобы он был сгенерирован случайным образом.",
|
"custom_client_id_description": "Установите пользовательский ID клиента, если это нужно для вашего приложения. Если нет, оставьте поле пустым, чтобы он был сгенерирован случайным образом.",
|
||||||
"generated": "Сгенерированный",
|
"generated": "Сгенерированный",
|
||||||
"administration": "Администрирование",
|
"administration": "Администрирование",
|
||||||
"group_rdn_attribute_description": "Атрибут, который используется в distinguished name (DN) групп. Рекомендуемое значение: `cn`",
|
"group_rdn_attribute_description": "Атрибут, который используется в различающемся имени группы (DN).",
|
||||||
|
"display_name_attribute": "Атрибут отображаемого имени",
|
||||||
|
"display_name": "Отображаемое имя",
|
||||||
|
"configure_application_images": "Настройка изображений приложения",
|
||||||
"ui_config_disabled_info_title": "Конфигурация пользовательского интерфейса отключена",
|
"ui_config_disabled_info_title": "Конфигурация пользовательского интерфейса отключена",
|
||||||
"ui_config_disabled_info_description": "Конфигурация пользовательского интерфейса отключена, потому что настройки приложения управляются через переменные среды. Некоторые настройки могут быть недоступны для редактирования."
|
"ui_config_disabled_info_description": "Конфигурация пользовательского интерфейса отключена, потому что настройки приложения управляются через переменные среды. Некоторые настройки могут быть недоступны для редактирования."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,8 @@
|
|||||||
"username": "Användarnamn",
|
"username": "Användarnamn",
|
||||||
"save": "Spara",
|
"save": "Spara",
|
||||||
"username_can_only_contain": "Användarnamnet får endast innehålla små bokstäver, siffror, understreck, punkter, bindestreck och '@'-tecken",
|
"username_can_only_contain": "Användarnamnet får endast innehålla små bokstäver, siffror, understreck, punkter, bindestreck och '@'-tecken",
|
||||||
|
"username_must_start_with": "Användarnamnet måste börja med ett alfanumeriskt tecken",
|
||||||
|
"username_must_end_with": "Användarnamnet måste sluta med ett alfanumeriskt tecken",
|
||||||
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Logga in med följande kod. Koden upphör att gälla om 15 minuter.",
|
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Logga in med följande kod. Koden upphör att gälla om 15 minuter.",
|
||||||
"or_visit": "eller besök",
|
"or_visit": "eller besök",
|
||||||
"added_on": "Tillagd den",
|
"added_on": "Tillagd den",
|
||||||
@@ -443,7 +445,10 @@
|
|||||||
"custom_client_id_description": "Ange ett anpassat Client ID om detta krävs av din applikation. Annars lämnar du fältet tomt för att generera ett slumpmässigt ID.",
|
"custom_client_id_description": "Ange ett anpassat Client ID om detta krävs av din applikation. Annars lämnar du fältet tomt för att generera ett slumpmässigt ID.",
|
||||||
"generated": "Genererad",
|
"generated": "Genererad",
|
||||||
"administration": "Administration",
|
"administration": "Administration",
|
||||||
"group_rdn_attribute_description": "Attributet som används i gruppens distinguished name (DN). Rekommenderat värde: `cn`",
|
"group_rdn_attribute_description": "Attributet som används i gruppernas distinkta namn (DN).",
|
||||||
|
"display_name_attribute": "Visningsnamnattribut",
|
||||||
|
"display_name": "Visningsnamn",
|
||||||
|
"configure_application_images": "Konfigurera applikationsbilder",
|
||||||
"ui_config_disabled_info_title": "UI-konfiguration inaktiverad",
|
"ui_config_disabled_info_title": "UI-konfiguration inaktiverad",
|
||||||
"ui_config_disabled_info_description": "UI-konfigurationen är inaktiverad eftersom applikationens konfigurationsinställningar hanteras via miljövariabler. Vissa inställningar kan inte redigeras."
|
"ui_config_disabled_info_description": "UI-konfigurationen är inaktiverad eftersom applikationens konfigurationsinställningar hanteras via miljövariabler. Vissa inställningar kan inte redigeras."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,8 @@
|
|||||||
"username": "Ім’я користувача",
|
"username": "Ім’я користувача",
|
||||||
"save": "Зберегти",
|
"save": "Зберегти",
|
||||||
"username_can_only_contain": "Ім’я користувача може містити лише малі літери, цифри, підкреслення, крапки, дефіси та символ '@'",
|
"username_can_only_contain": "Ім’я користувача може містити лише малі літери, цифри, підкреслення, крапки, дефіси та символ '@'",
|
||||||
|
"username_must_start_with": "Ім'я користувача повинно починатися з буквено-цифрового символу",
|
||||||
|
"username_must_end_with": "Ім'я користувача повинно закінчуватися буквено-цифровим символом",
|
||||||
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Увійдіть, використовуючи наступний код. Код дійсний протягом 15 хвилин.",
|
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Увійдіть, використовуючи наступний код. Код дійсний протягом 15 хвилин.",
|
||||||
"or_visit": "або відвідайте",
|
"or_visit": "або відвідайте",
|
||||||
"added_on": "Додано",
|
"added_on": "Додано",
|
||||||
@@ -443,7 +445,10 @@
|
|||||||
"custom_client_id_description": "Встановіть власний ідентифікатор клієнта, якщо це потрібно для вашої програми. В іншому випадку залиште поле порожнім, щоб створити випадковий ідентифікатор.",
|
"custom_client_id_description": "Встановіть власний ідентифікатор клієнта, якщо це потрібно для вашої програми. В іншому випадку залиште поле порожнім, щоб створити випадковий ідентифікатор.",
|
||||||
"generated": "Створено",
|
"generated": "Створено",
|
||||||
"administration": "Адміністрація",
|
"administration": "Адміністрація",
|
||||||
"group_rdn_attribute_description": "Атрибут, що використовується в розпізнавальному імені групи (DN). Рекомендоване значення: `cn`",
|
"group_rdn_attribute_description": "Атрибут, що використовується в розрізнювальному імені групи (DN).",
|
||||||
|
"display_name_attribute": "Атрибут імені для відображення",
|
||||||
|
"display_name": "Ім'я для відображення",
|
||||||
|
"configure_application_images": "Налаштування зображень додатків",
|
||||||
"ui_config_disabled_info_title": "Конфігурація інтерфейсу користувача вимкнена",
|
"ui_config_disabled_info_title": "Конфігурація інтерфейсу користувача вимкнена",
|
||||||
"ui_config_disabled_info_description": "Конфігурація інтерфейсу користувача вимкнена, оскільки налаштування конфігурації програми керуються через змінні середовища. Деякі налаштування можуть бути недоступними для редагування."
|
"ui_config_disabled_info_description": "Конфігурація інтерфейсу користувача вимкнена, оскільки налаштування конфігурації програми керуються через змінні середовища. Деякі налаштування можуть бути недоступними для редагування."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,8 @@
|
|||||||
"username": "Tên đăng nhập",
|
"username": "Tên đăng nhập",
|
||||||
"save": "Lưu",
|
"save": "Lưu",
|
||||||
"username_can_only_contain": "Tên người dùng chỉ có thể chứa các ký tự chữ thường, số, dấu gạch dưới, dấu chấm, dấu gạch ngang và ký hiệu '@'.",
|
"username_can_only_contain": "Tên người dùng chỉ có thể chứa các ký tự chữ thường, số, dấu gạch dưới, dấu chấm, dấu gạch ngang và ký hiệu '@'.",
|
||||||
|
"username_must_start_with": "Tên người dùng phải bắt đầu bằng một ký tự alphanumeric.",
|
||||||
|
"username_must_end_with": "Tên người dùng phải kết thúc bằng một ký tự alphanumeric.",
|
||||||
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Đăng nhập bằng mã sau. Mã này sẽ hết hạn trong 15 phút.",
|
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Đăng nhập bằng mã sau. Mã này sẽ hết hạn trong 15 phút.",
|
||||||
"or_visit": "hoặc truy cập",
|
"or_visit": "hoặc truy cập",
|
||||||
"added_on": "Đã được thêm vào",
|
"added_on": "Đã được thêm vào",
|
||||||
@@ -443,7 +445,10 @@
|
|||||||
"custom_client_id_description": "Đặt ID khách hàng tùy chỉnh nếu ứng dụng của bạn yêu cầu. Nếu không, hãy để trống để hệ thống tự động tạo một ID ngẫu nhiên.",
|
"custom_client_id_description": "Đặt ID khách hàng tùy chỉnh nếu ứng dụng của bạn yêu cầu. Nếu không, hãy để trống để hệ thống tự động tạo một ID ngẫu nhiên.",
|
||||||
"generated": "Được tạo ra",
|
"generated": "Được tạo ra",
|
||||||
"administration": "Quản lý",
|
"administration": "Quản lý",
|
||||||
"group_rdn_attribute_description": "Thuộc tính được sử dụng trong tên phân biệt (DN) của nhóm. Giá trị được khuyến nghị: `cn`",
|
"group_rdn_attribute_description": "Thuộc tính được sử dụng trong tên phân biệt (DN) của nhóm.",
|
||||||
|
"display_name_attribute": "Thuộc tính Tên hiển thị",
|
||||||
|
"display_name": "Tên hiển thị",
|
||||||
|
"configure_application_images": "Cấu hình hình ảnh ứng dụng",
|
||||||
"ui_config_disabled_info_title": "Cấu hình giao diện người dùng đã bị vô hiệu hóa",
|
"ui_config_disabled_info_title": "Cấu hình giao diện người dùng đã bị vô hiệu hóa",
|
||||||
"ui_config_disabled_info_description": "Cấu hình giao diện người dùng (UI) đã bị vô hiệu hóa vì các thiết lập cấu hình ứng dụng được quản lý thông qua biến môi trường. Một số thiết lập có thể không thể chỉnh sửa."
|
"ui_config_disabled_info_description": "Cấu hình giao diện người dùng (UI) đã bị vô hiệu hóa vì các thiết lập cấu hình ứng dụng được quản lý thông qua biến môi trường. Một số thiết lập có thể không thể chỉnh sửa."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,8 @@
|
|||||||
"username": "用户名",
|
"username": "用户名",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"username_can_only_contain": "用户名只能包含小写字母、数字、下划线、点、连字符和 '@' 符号",
|
"username_can_only_contain": "用户名只能包含小写字母、数字、下划线、点、连字符和 '@' 符号",
|
||||||
|
"username_must_start_with": "用户名必须以字母数字字符开头",
|
||||||
|
"username_must_end_with": "用户名必须以字母数字字符结尾",
|
||||||
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "使用以下代码登录。该代码将在 15 分钟后失效。",
|
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "使用以下代码登录。该代码将在 15 分钟后失效。",
|
||||||
"or_visit": "或访问",
|
"or_visit": "或访问",
|
||||||
"added_on": "添加于",
|
"added_on": "添加于",
|
||||||
@@ -439,11 +441,14 @@
|
|||||||
"revoke_access_description": "撤销对 <b>{clientName}</b>. <b>{clientName}</b>将无法再访问您的账户信息。",
|
"revoke_access_description": "撤销对 <b>{clientName}</b>. <b>{clientName}</b>将无法再访问您的账户信息。",
|
||||||
"revoke_access_successful": "对 {clientName} 的访问权限已成功撤销。",
|
"revoke_access_successful": "对 {clientName} 的访问权限已成功撤销。",
|
||||||
"last_signed_in_ago": "最后一次登录 {time} 前",
|
"last_signed_in_ago": "最后一次登录 {time} 前",
|
||||||
"invalid_client_id": "客户 ID 只能包含字母、数字、下划线和连字符。",
|
"invalid_client_id": "客户端 ID 只能包含字母、数字、下划线和连字符。",
|
||||||
"custom_client_id_description": "此处可根据应用需要设置自定义客户端 ID。留空随机生成。",
|
"custom_client_id_description": "此处可根据应用需要设置自定义客户端 ID。留空随机生成。",
|
||||||
"generated": "已生成",
|
"generated": "已生成",
|
||||||
"administration": "管理员选项",
|
"administration": "管理员选项",
|
||||||
"group_rdn_attribute_description": "在组的区分名称(DN)中使用的属性。推荐值:`cn`",
|
"group_rdn_attribute_description": "在组的区分名称(DN)中使用的属性。",
|
||||||
|
"display_name_attribute": "显示名称属性",
|
||||||
|
"display_name": "显示名称",
|
||||||
|
"configure_application_images": "配置应用程序图标",
|
||||||
"ui_config_disabled_info_title": "用户界面配置已禁用",
|
"ui_config_disabled_info_title": "用户界面配置已禁用",
|
||||||
"ui_config_disabled_info_description": "用户界面配置已禁用,因为应用程序配置设置通过环境变量进行管理。某些设置可能无法编辑。"
|
"ui_config_disabled_info_description": "用户界面配置已禁用,因为应用程序配置设置通过环境变量进行管理。某些设置可能无法编辑。"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,8 @@
|
|||||||
"username": "使用者名稱",
|
"username": "使用者名稱",
|
||||||
"save": "儲存",
|
"save": "儲存",
|
||||||
"username_can_only_contain": "使用者名稱僅能包含小寫英文字母、數字、底線(_)、句點(.)、連字號(-)與 @ 符號",
|
"username_can_only_contain": "使用者名稱僅能包含小寫英文字母、數字、底線(_)、句點(.)、連字號(-)與 @ 符號",
|
||||||
|
"username_must_start_with": "使用者名稱必須以英數字元開頭",
|
||||||
|
"username_must_end_with": "使用者名稱必須以英數字元結尾",
|
||||||
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "使用以下代碼登入。 這個代碼將於 15 分鐘後到期。",
|
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "使用以下代碼登入。 這個代碼將於 15 分鐘後到期。",
|
||||||
"or_visit": "或造訪",
|
"or_visit": "或造訪",
|
||||||
"added_on": "新增於",
|
"added_on": "新增於",
|
||||||
@@ -443,7 +445,10 @@
|
|||||||
"custom_client_id_description": "如果您的應用程式需要,請設定自訂用戶端 ID。否則,請留空以產生隨機 ID。",
|
"custom_client_id_description": "如果您的應用程式需要,請設定自訂用戶端 ID。否則,請留空以產生隨機 ID。",
|
||||||
"generated": "產生",
|
"generated": "產生",
|
||||||
"administration": "行政管理",
|
"administration": "行政管理",
|
||||||
"group_rdn_attribute_description": "群組識別名 (DN) 中使用的屬性。建議值: `cn`",
|
"group_rdn_attribute_description": "用於群組區別名稱(DN)的屬性。",
|
||||||
|
"display_name_attribute": "顯示名稱屬性",
|
||||||
|
"display_name": "顯示名稱",
|
||||||
|
"configure_application_images": "設定應用程式映像檔",
|
||||||
"ui_config_disabled_info_title": "使用者介面設定已停用",
|
"ui_config_disabled_info_title": "使用者介面設定已停用",
|
||||||
"ui_config_disabled_info_description": "使用者介面設定已停用,因為應用程式的設定參數是透過環境變數進行管理。部分設定可能無法編輯。"
|
"ui_config_disabled_info_description": "使用者介面設定已停用,因為應用程式的設定參數是透過環境變數進行管理。部分設定可能無法編輯。"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pocket-id-frontend",
|
"name": "pocket-id-frontend",
|
||||||
"version": "1.10.0",
|
"version": "1.11.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="%lang%">
|
<html lang="%lang%">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="/api/application-configuration/favicon" />
|
<link rel="icon" href="/api/application-images/favicon" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="robots" content="noindex" />
|
<meta name="robots" content="noindex" />
|
||||||
<link rel="manifest" href="/app.webmanifest" />
|
<link rel="manifest" href="/app.webmanifest" />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { preventDefault } from '$lib/utils/event-util';
|
import { preventDefault } from '$lib/utils/event-util';
|
||||||
import { createForm } from '$lib/utils/form-util';
|
import { createForm } from '$lib/utils/form-util';
|
||||||
import { tryCatch } from '$lib/utils/try-catch-util';
|
import { tryCatch } from '$lib/utils/try-catch-util';
|
||||||
|
import { emptyToUndefined, usernameSchema } from '$lib/utils/zod-util';
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -24,12 +25,8 @@
|
|||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
firstName: z.string().min(1).max(50),
|
firstName: z.string().min(1).max(50),
|
||||||
lastName: z.string().max(50).optional(),
|
lastName: emptyToUndefined(z.string().max(50).optional()),
|
||||||
username: z
|
username: usernameSchema,
|
||||||
.string()
|
|
||||||
.min(2)
|
|
||||||
.max(30)
|
|
||||||
.regex(/^[a-z0-9_@.-]+$/, m.username_can_only_contain()),
|
|
||||||
email: z.email()
|
email: z.email()
|
||||||
});
|
});
|
||||||
type FormSchema = typeof formSchema;
|
type FormSchema = typeof formSchema;
|
||||||
|
|||||||
@@ -32,14 +32,14 @@ export default class AppConfigService extends APIService {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', favicon!);
|
formData.append('file', favicon!);
|
||||||
|
|
||||||
await this.api.put(`/application-configuration/favicon`, formData);
|
await this.api.put(`/application-images/favicon`, formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateLogo(logo: File, light = true) {
|
async updateLogo(logo: File, light = true) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', logo!);
|
formData.append('file', logo!);
|
||||||
|
|
||||||
await this.api.put(`/application-configuration/logo`, formData, {
|
await this.api.put(`/application-images/logo`, formData, {
|
||||||
params: { light }
|
params: { light }
|
||||||
});
|
});
|
||||||
cachedApplicationLogo.bustCache(light);
|
cachedApplicationLogo.bustCache(light);
|
||||||
@@ -49,7 +49,7 @@ export default class AppConfigService extends APIService {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', backgroundImage!);
|
formData.append('file', backgroundImage!);
|
||||||
|
|
||||||
await this.api.put(`/application-configuration/background-image`, formData);
|
await this.api.put(`/application-images/background`, formData);
|
||||||
cachedBackgroundImage.bustCache();
|
cachedBackgroundImage.bustCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export type AllAppConfig = AppConfig & {
|
|||||||
ldapAttributeUserEmail: string;
|
ldapAttributeUserEmail: string;
|
||||||
ldapAttributeUserFirstName: string;
|
ldapAttributeUserFirstName: string;
|
||||||
ldapAttributeUserLastName: string;
|
ldapAttributeUserLastName: string;
|
||||||
|
ldapAttributeUserDisplayName: string;
|
||||||
ldapAttributeUserProfilePicture: string;
|
ldapAttributeUserProfilePicture: string;
|
||||||
ldapAttributeGroupMember: string;
|
ldapAttributeGroupMember: string;
|
||||||
ldapAttributeGroupUniqueIdentifier: string;
|
ldapAttributeGroupUniqueIdentifier: string;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type User = {
|
|||||||
email: string;
|
email: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
|
displayName: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
userGroups: UserGroup[];
|
userGroups: UserGroup[];
|
||||||
customClaims: CustomClaim[];
|
customClaims: CustomClaim[];
|
||||||
@@ -18,6 +19,6 @@ export type User = {
|
|||||||
|
|
||||||
export type UserCreate = Omit<User, 'id' | 'customClaims' | 'ldapId' | 'userGroups'>;
|
export type UserCreate = Omit<User, 'id' | 'customClaims' | 'ldapId' | 'userGroups'>;
|
||||||
|
|
||||||
export type UserSignUp = Omit<UserCreate, 'isAdmin' | 'disabled'> & {
|
export type UserSignUp = Omit<UserCreate, 'isAdmin' | 'disabled' | 'displayName'> & {
|
||||||
token?: string;
|
token?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ type CachableImage = {
|
|||||||
|
|
||||||
export const cachedApplicationLogo: CachableImage = {
|
export const cachedApplicationLogo: CachableImage = {
|
||||||
getUrl: (light = true) => {
|
getUrl: (light = true) => {
|
||||||
let url = '/api/application-configuration/logo';
|
let url = '/api/application-images/logo';
|
||||||
if (!light) {
|
if (!light) {
|
||||||
url += '?light=false';
|
url += '?light=false';
|
||||||
}
|
}
|
||||||
return getCachedImageUrl(url);
|
return getCachedImageUrl(url);
|
||||||
},
|
},
|
||||||
bustCache: (light = true) => {
|
bustCache: (light = true) => {
|
||||||
let url = '/api/application-configuration/logo';
|
let url = '/api/application-images/logo';
|
||||||
if (!light) {
|
if (!light) {
|
||||||
url += '?light=false';
|
url += '?light=false';
|
||||||
}
|
}
|
||||||
@@ -25,8 +25,8 @@ export const cachedApplicationLogo: CachableImage = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const cachedBackgroundImage: CachableImage = {
|
export const cachedBackgroundImage: CachableImage = {
|
||||||
getUrl: () => getCachedImageUrl('/api/application-configuration/background-image'),
|
getUrl: () => getCachedImageUrl('/api/application-images/background'),
|
||||||
bustCache: () => bustImageCache('/api/application-configuration/background-image')
|
bustCache: () => bustImageCache('/api/application-images/background')
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cachedProfilePicture: CachableImage = {
|
export const cachedProfilePicture: CachableImage = {
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
import { setLocale as setParaglideLocale, type Locale } from '$lib/paraglide/runtime';
|
import {
|
||||||
|
extractLocaleFromCookie,
|
||||||
|
setLocale as setParaglideLocale,
|
||||||
|
type Locale
|
||||||
|
} from '$lib/paraglide/runtime';
|
||||||
import { setDefaultOptions } from 'date-fns';
|
import { setDefaultOptions } from 'date-fns';
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
|
|
||||||
export async function setLocale(locale: Locale, reload = true) {
|
export async function setLocale(locale: Locale, reload = true) {
|
||||||
|
await setLocaleForLibraries(locale);
|
||||||
|
setParaglideLocale(locale, { reload });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setLocaleForLibraries(
|
||||||
|
locale: Locale = (extractLocaleFromCookie() as Locale) || 'en'
|
||||||
|
) {
|
||||||
const [zodResult, dateFnsResult] = await Promise.allSettled([
|
const [zodResult, dateFnsResult] = await Promise.allSettled([
|
||||||
import(`../../../node_modules/zod/v4/locales/${locale}.js`),
|
import(`../../../node_modules/zod/v4/locales/${locale}.js`),
|
||||||
import(`../../../node_modules/date-fns/locale/${locale}.js`)
|
import(`../../../node_modules/date-fns/locale/${locale}.js`)
|
||||||
@@ -14,8 +25,6 @@ export async function setLocale(locale: Locale, reload = true) {
|
|||||||
console.warn(`Failed to load zod locale for ${locale}:`, zodResult.reason);
|
console.warn(`Failed to load zod locale for ${locale}:`, zodResult.reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
setParaglideLocale(locale, { reload });
|
|
||||||
|
|
||||||
if (dateFnsResult.status === 'fulfilled') {
|
if (dateFnsResult.status === 'fulfilled') {
|
||||||
setDefaultOptions({
|
setDefaultOptions({
|
||||||
locale: dateFnsResult.value.default
|
locale: dateFnsResult.value.default
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import z from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
|
|
||||||
export const emptyToUndefined = <T>(validation: z.ZodType<T>) =>
|
export const emptyToUndefined = <T>(validation: z.ZodType<T>) =>
|
||||||
z.preprocess((v) => (v === '' ? undefined : v), validation);
|
z.preprocess((v) => (v === '' ? undefined : v), validation.optional());
|
||||||
|
|
||||||
export const optionalUrl = z
|
export const optionalUrl = z
|
||||||
.url()
|
.url()
|
||||||
@@ -26,3 +26,11 @@ export const callbackUrlSchema = z
|
|||||||
message: m.invalid_redirect_url()
|
message: m.invalid_redirect_url()
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const usernameSchema = z
|
||||||
|
.string()
|
||||||
|
.min(2)
|
||||||
|
.max(30)
|
||||||
|
.regex(/^[a-zA-Z0-9]/, m.username_must_start_with())
|
||||||
|
.regex(/[a-zA-Z0-9]$/, m.username_must_end_with())
|
||||||
|
.regex(/^[a-zA-Z0-9_.@-]+$/, m.username_can_only_contain());
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import AppConfigService from '$lib/services/app-config-service';
|
|||||||
import UserService from '$lib/services/user-service';
|
import UserService from '$lib/services/user-service';
|
||||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||||
import userStore from '$lib/stores/user-store';
|
import userStore from '$lib/stores/user-store';
|
||||||
|
import { setLocaleForLibraries } from '$lib/utils/locale.util';
|
||||||
import type { LayoutLoad } from './$types';
|
import type { LayoutLoad } from './$types';
|
||||||
|
|
||||||
export const ssr = false;
|
export const ssr = false;
|
||||||
@@ -29,6 +30,8 @@ export const load: LayoutLoad = async () => {
|
|||||||
appConfigStore.set(appConfig);
|
appConfigStore.set(appConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await setLocaleForLibraries();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
appConfig
|
appConfig
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
import { preventDefault } from '$lib/utils/event-util';
|
import { preventDefault } from '$lib/utils/event-util';
|
||||||
import { createForm } from '$lib/utils/form-util';
|
import { createForm } from '$lib/utils/form-util';
|
||||||
|
import { emptyToUndefined, usernameSchema } from '$lib/utils/zod-util';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
|
|
||||||
@@ -26,17 +27,15 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
|
let hasManualDisplayNameEdit = $state(!!account.displayName);
|
||||||
|
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
firstName: z.string().min(1).max(50),
|
firstName: z.string().min(1).max(50),
|
||||||
lastName: z.string().max(50).optional(),
|
lastName: emptyToUndefined(z.string().max(50).optional()),
|
||||||
username: z
|
displayName: z.string().max(100),
|
||||||
.string()
|
username: usernameSchema,
|
||||||
.min(2)
|
|
||||||
.max(30)
|
|
||||||
.regex(/^[a-z0-9_@.-]+$/, m.username_can_only_contain()),
|
|
||||||
email: z.email(),
|
email: z.email(),
|
||||||
isAdmin: z.boolean()
|
isAdmin: z.boolean()
|
||||||
});
|
});
|
||||||
@@ -44,6 +43,14 @@
|
|||||||
|
|
||||||
const { inputs, ...form } = createForm<FormSchema>(formSchema, account);
|
const { inputs, ...form } = createForm<FormSchema>(formSchema, account);
|
||||||
|
|
||||||
|
function onNameInput() {
|
||||||
|
if (!hasManualDisplayNameEdit) {
|
||||||
|
$inputs.displayName.value = `${$inputs.firstName.value}${
|
||||||
|
$inputs.lastName?.value ? ' ' + $inputs.lastName.value : ''
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
const data = form.validate();
|
const data = form.validate();
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
@@ -68,7 +75,6 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form onsubmit={preventDefault(onSubmit)} class="space-y-6">
|
<form onsubmit={preventDefault(onSubmit)} class="space-y-6">
|
||||||
<!-- Profile Picture Section -->
|
|
||||||
<ProfilePictureSettings
|
<ProfilePictureSettings
|
||||||
{userId}
|
{userId}
|
||||||
{isLdapUser}
|
{isLdapUser}
|
||||||
@@ -76,31 +82,32 @@
|
|||||||
resetCallback={resetProfilePicture}
|
resetCallback={resetProfilePicture}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Divider -->
|
|
||||||
<hr class="border-border" />
|
<hr class="border-border" />
|
||||||
|
|
||||||
<!-- User Information -->
|
|
||||||
<fieldset disabled={userInfoInputDisabled}>
|
<fieldset disabled={userInfoInputDisabled}>
|
||||||
<div>
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<div class="flex flex-col gap-3 sm:flex-row">
|
<div>
|
||||||
<div class="w-full">
|
<FormInput label={m.first_name()} bind:input={$inputs.firstName} onInput={onNameInput} />
|
||||||
<FormInput label={m.first_name()} bind:input={$inputs.firstName} />
|
|
||||||
</div>
|
|
||||||
<div class="w-full">
|
|
||||||
<FormInput label={m.last_name()} bind:input={$inputs.lastName} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 flex flex-col gap-3 sm:flex-row">
|
<div>
|
||||||
<div class="w-full">
|
<FormInput label={m.last_name()} bind:input={$inputs.lastName} onInput={onNameInput} />
|
||||||
<FormInput label={m.email()} bind:input={$inputs.email} />
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div class="w-full">
|
<FormInput
|
||||||
<FormInput label={m.username()} bind:input={$inputs.username} />
|
label={m.display_name()}
|
||||||
</div>
|
bind:input={$inputs.displayName}
|
||||||
|
onInput={() => (hasManualDisplayNameEdit = true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FormInput label={m.username()} bind:input={$inputs.username} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FormInput label={m.email()} bind:input={$inputs.email} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end pt-2">
|
<div class="flex justify-end pt-4">
|
||||||
<Button {isLoading} type="submit">{m.save()}</Button>
|
<Button {isLoading} type="submit">{m.save()}</Button>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -120,7 +120,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<CollapsibleCard id="application-configuration-images" icon={LucideImage} title={m.images()}>
|
<CollapsibleCard
|
||||||
|
id="application-configuration-images"
|
||||||
|
icon={LucideImage}
|
||||||
|
title={m.images()}
|
||||||
|
description={m.configure_application_images()}
|
||||||
|
>
|
||||||
<UpdateApplicationImages callback={updateImages} />
|
<UpdateApplicationImages callback={updateImages} />
|
||||||
</CollapsibleCard>
|
</CollapsibleCard>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
ldapAttributeUserEmail: z.string().min(1),
|
ldapAttributeUserEmail: z.string().min(1),
|
||||||
ldapAttributeUserFirstName: z.string().min(1),
|
ldapAttributeUserFirstName: z.string().min(1),
|
||||||
ldapAttributeUserLastName: z.string().min(1),
|
ldapAttributeUserLastName: z.string().min(1),
|
||||||
|
ldapAttributeUserDisplayName: z.string().min(1),
|
||||||
ldapAttributeUserProfilePicture: z.string(),
|
ldapAttributeUserProfilePicture: z.string(),
|
||||||
ldapAttributeGroupMember: z.string(),
|
ldapAttributeGroupMember: z.string(),
|
||||||
ldapAttributeGroupUniqueIdentifier: z.string().min(1),
|
ldapAttributeGroupUniqueIdentifier: z.string().min(1),
|
||||||
@@ -159,6 +160,11 @@
|
|||||||
placeholder="sn"
|
placeholder="sn"
|
||||||
bind:input={$inputs.ldapAttributeUserLastName}
|
bind:input={$inputs.ldapAttributeUserLastName}
|
||||||
/>
|
/>
|
||||||
|
<FormInput
|
||||||
|
label={m.display_name_attribute()}
|
||||||
|
placeholder="displayName"
|
||||||
|
bind:input={$inputs.ldapAttributeUserDisplayName}
|
||||||
|
/>
|
||||||
<FormInput
|
<FormInput
|
||||||
label={m.user_profile_picture_attribute()}
|
label={m.user_profile_picture_attribute()}
|
||||||
description={m.the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image()}
|
description={m.the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image()}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
imageClass="size-14 p-2"
|
imageClass="size-14 p-2"
|
||||||
label={m.favicon()}
|
label={m.favicon()}
|
||||||
bind:image={favicon}
|
bind:image={favicon}
|
||||||
imageURL="/api/application-configuration/favicon"
|
imageURL="/api/application-images/favicon"
|
||||||
accept="image/x-icon"
|
accept="image/x-icon"
|
||||||
/>
|
/>
|
||||||
<ApplicationImage
|
<ApplicationImage
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
|
|
||||||
import FormInput from '$lib/components/form/form-input.svelte';
|
import FormInput from '$lib/components/form/form-input.svelte';
|
||||||
|
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||||
import type { User, UserCreate } from '$lib/types/user.type';
|
import type { User, UserCreate } from '$lib/types/user.type';
|
||||||
import { preventDefault } from '$lib/utils/event-util';
|
import { preventDefault } from '$lib/utils/event-util';
|
||||||
import { createForm } from '$lib/utils/form-util';
|
import { createForm } from '$lib/utils/form-util';
|
||||||
|
import { emptyToUndefined, usernameSchema } from '$lib/utils/zod-util';
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -19,10 +20,12 @@
|
|||||||
|
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let inputDisabled = $derived(!!existingUser?.ldapId && $appConfigStore.ldapEnabled);
|
let inputDisabled = $derived(!!existingUser?.ldapId && $appConfigStore.ldapEnabled);
|
||||||
|
let hasManualDisplayNameEdit = $state(!!existingUser?.displayName);
|
||||||
|
|
||||||
const user = {
|
const user = {
|
||||||
firstName: existingUser?.firstName || '',
|
firstName: existingUser?.firstName || '',
|
||||||
lastName: existingUser?.lastName || '',
|
lastName: existingUser?.lastName || '',
|
||||||
|
displayName: existingUser?.displayName || '',
|
||||||
email: existingUser?.email || '',
|
email: existingUser?.email || '',
|
||||||
username: existingUser?.username || '',
|
username: existingUser?.username || '',
|
||||||
isAdmin: existingUser?.isAdmin || false,
|
isAdmin: existingUser?.isAdmin || false,
|
||||||
@@ -31,12 +34,9 @@
|
|||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
firstName: z.string().min(1).max(50),
|
firstName: z.string().min(1).max(50),
|
||||||
lastName: z.string().max(50),
|
lastName: emptyToUndefined(z.string().max(50).optional()),
|
||||||
username: z
|
displayName: z.string().max(100),
|
||||||
.string()
|
username: usernameSchema,
|
||||||
.min(2)
|
|
||||||
.max(30)
|
|
||||||
.regex(/^[a-z0-9_@.-]+$/, m.username_can_only_contain()),
|
|
||||||
email: z.email(),
|
email: z.email(),
|
||||||
isAdmin: z.boolean(),
|
isAdmin: z.boolean(),
|
||||||
disabled: z.boolean()
|
disabled: z.boolean()
|
||||||
@@ -53,15 +53,29 @@
|
|||||||
if (success && !existingUser) form.reset();
|
if (success && !existingUser) form.reset();
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
|
function onNameInput() {
|
||||||
|
if (!hasManualDisplayNameEdit) {
|
||||||
|
$inputs.displayName.value = `${$inputs.firstName.value}${
|
||||||
|
$inputs.lastName?.value ? ' ' + $inputs.lastName.value : ''
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form onsubmit={preventDefault(onSubmit)}>
|
<form onsubmit={preventDefault(onSubmit)}>
|
||||||
<fieldset disabled={inputDisabled}>
|
<fieldset disabled={inputDisabled}>
|
||||||
<div class="grid grid-cols-1 items-start gap-5 md:grid-cols-2">
|
<div class="grid grid-cols-1 items-start gap-5 md:grid-cols-2">
|
||||||
<FormInput label={m.first_name()} bind:input={$inputs.firstName} />
|
<FormInput label={m.first_name()} oninput={onNameInput} bind:input={$inputs.firstName} />
|
||||||
<FormInput label={m.last_name()} bind:input={$inputs.lastName} />
|
<FormInput label={m.last_name()} oninput={onNameInput} bind:input={$inputs.lastName} />
|
||||||
|
<FormInput
|
||||||
|
label={m.display_name()}
|
||||||
|
oninput={() => (hasManualDisplayNameEdit = true)}
|
||||||
|
bind:input={$inputs.displayName}
|
||||||
|
/>
|
||||||
<FormInput label={m.username()} bind:input={$inputs.username} />
|
<FormInput label={m.username()} bind:input={$inputs.username} />
|
||||||
<FormInput label={m.email()} bind:input={$inputs.email} />
|
<FormInput label={m.email()} bind:input={$inputs.email} />
|
||||||
|
</div>
|
||||||
|
<div class="mt-5 grid grid-cols-1 items-start gap-5 md:grid-cols-2">
|
||||||
<SwitchWithLabel
|
<SwitchWithLabel
|
||||||
id="admin-privileges"
|
id="admin-privileges"
|
||||||
label={m.admin_privileges()}
|
label={m.admin_privileges()}
|
||||||
|
|||||||
@@ -103,6 +103,7 @@
|
|||||||
columns={[
|
columns={[
|
||||||
{ label: m.first_name(), sortColumn: 'firstName' },
|
{ label: m.first_name(), sortColumn: 'firstName' },
|
||||||
{ label: m.last_name(), sortColumn: 'lastName' },
|
{ label: m.last_name(), sortColumn: 'lastName' },
|
||||||
|
{ label: m.display_name(), sortColumn: 'displayName' },
|
||||||
{ label: m.email(), sortColumn: 'email' },
|
{ label: m.email(), sortColumn: 'email' },
|
||||||
{ label: m.username(), sortColumn: 'username' },
|
{ label: m.username(), sortColumn: 'username' },
|
||||||
{ label: m.role(), sortColumn: 'isAdmin' },
|
{ label: m.role(), sortColumn: 'isAdmin' },
|
||||||
@@ -114,6 +115,7 @@
|
|||||||
{#snippet rows({ item })}
|
{#snippet rows({ item })}
|
||||||
<Table.Cell>{item.firstName}</Table.Cell>
|
<Table.Cell>{item.firstName}</Table.Cell>
|
||||||
<Table.Cell>{item.lastName}</Table.Cell>
|
<Table.Cell>{item.lastName}</Table.Cell>
|
||||||
|
<Table.Cell>{item.displayName}</Table.Cell>
|
||||||
<Table.Cell>{item.email}</Table.Cell>
|
<Table.Cell>{item.email}</Table.Cell>
|
||||||
<Table.Cell>{item.username}</Table.Cell>
|
<Table.Cell>{item.username}</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card.Root
|
<Card.Root
|
||||||
class="border-muted group relative h-[140px] p-5 transition-all duration-200 hover:shadow-md"
|
class="border-muted group relative h-[140px] p-5 transition-all duration-200 hover:shadow-md sm:max-w-[50vw] md:max-w-[400px]"
|
||||||
data-testid="authorized-oidc-client-card"
|
data-testid="authorized-oidc-client-card"
|
||||||
>
|
>
|
||||||
<Card.Content class=" p-0">
|
<Card.Content class=" p-0">
|
||||||
@@ -49,14 +49,14 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="mb-1 flex items-start gap-2">
|
<div class="mb-1 flex items-start gap-2">
|
||||||
<h3
|
<h3
|
||||||
class="text-foreground line-clamp-2 text-ellipsis break-words break-all font-semibold leading-tight"
|
class="text-foreground line-clamp-2 leading-tight font-semibold break-words break-all text-ellipsis"
|
||||||
>
|
>
|
||||||
{client.name}
|
{client.name}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
{#if client.launchURL}
|
{#if client.launchURL}
|
||||||
<p
|
<p
|
||||||
class="text-muted-foreground line-clamp-1 text-ellipsis break-words break-all text-xs"
|
class="text-muted-foreground line-clamp-1 text-xs break-words break-all text-ellipsis"
|
||||||
>
|
>
|
||||||
{new URL(client.launchURL).hostname}
|
{new URL(client.launchURL).hostname}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -4,15 +4,10 @@ if [ ! -f .version ] || [ ! -f frontend/package.json ] || [ ! -f CHANGELOG.md ];
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check if conventional-changelog is installed, if not install it
|
# Check if git cliff is installed
|
||||||
if ! command -v conventional-changelog &>/dev/null; then
|
if ! command -v git cliff &>/dev/null; then
|
||||||
echo "conventional-changelog not found, installing..."
|
echo "Error: git cliff is not installed. Please install it from https://git-cliff.org/docs/installation."
|
||||||
npm install -g conventional-changelog-cli
|
exit 1
|
||||||
# Verify installation was successful
|
|
||||||
if ! command -v conventional-changelog &>/dev/null; then
|
|
||||||
echo "Error: Failed to install conventional-changelog-cli."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check if GitHub CLI is installed
|
# Check if GitHub CLI is installed
|
||||||
@@ -113,7 +108,7 @@ git add frontend/package.json
|
|||||||
|
|
||||||
# Generate changelog
|
# Generate changelog
|
||||||
echo "Generating changelog..."
|
echo "Generating changelog..."
|
||||||
conventional-changelog -p conventionalcommits -i CHANGELOG.md -s
|
git cliff --github-token=$(gh auth token) --prepend CHANGELOG.md --tag "v$NEW_VERSION" --unreleased
|
||||||
git add CHANGELOG.md
|
git add CHANGELOG.md
|
||||||
|
|
||||||
# Commit the changes with the new version
|
# Commit the changes with the new version
|
||||||
@@ -128,7 +123,7 @@ git push --tags
|
|||||||
|
|
||||||
# Extract the changelog content for the latest release
|
# Extract the changelog content for the latest release
|
||||||
echo "Extracting changelog content for version $NEW_VERSION..."
|
echo "Extracting changelog content for version $NEW_VERSION..."
|
||||||
CHANGELOG=$(awk '/^## / {if (NR > 1) exit} NR > 1 {print}' CHANGELOG.md | awk 'NR > 2 || NF {print}')
|
CHANGELOG=$(awk '/^## v[0-9]/ { if (found) exit; found=1; next } found' CHANGELOG.md)
|
||||||
|
|
||||||
if [ -z "$CHANGELOG" ]; then
|
if [ -z "$CHANGELOG" ]; then
|
||||||
echo "Error: Could not extract changelog for version $NEW_VERSION."
|
echo "Error: Could not extract changelog for version $NEW_VERSION."
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export const users = {
|
|||||||
id: 'f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e',
|
id: 'f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e',
|
||||||
firstname: 'Tim',
|
firstname: 'Tim',
|
||||||
lastname: 'Cook',
|
lastname: 'Cook',
|
||||||
|
displayName: 'Tim Cook',
|
||||||
email: 'tim.cook@test.com',
|
email: 'tim.cook@test.com',
|
||||||
username: 'tim'
|
username: 'tim'
|
||||||
},
|
},
|
||||||
@@ -10,12 +11,14 @@ export const users = {
|
|||||||
id: '1cd19686-f9a6-43f4-a41f-14a0bf5b4036',
|
id: '1cd19686-f9a6-43f4-a41f-14a0bf5b4036',
|
||||||
firstname: 'Craig',
|
firstname: 'Craig',
|
||||||
lastname: 'Federighi',
|
lastname: 'Federighi',
|
||||||
|
displayName: 'Craig Federighi',
|
||||||
email: 'craig.federighi@test.com',
|
email: 'craig.federighi@test.com',
|
||||||
username: 'craig'
|
username: 'craig'
|
||||||
},
|
},
|
||||||
steve: {
|
steve: {
|
||||||
firstname: 'Steve',
|
firstname: 'Steve',
|
||||||
lastname: 'Jobs',
|
lastname: 'Jobs',
|
||||||
|
displayName: 'Steve Jobs',
|
||||||
email: 'steve.jobs@test.com',
|
email: 'steve.jobs@test.com',
|
||||||
username: 'steve'
|
username: 'steve'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ test.beforeEach(async () => await cleanupBackend());
|
|||||||
test('Update account details', async ({ page }) => {
|
test('Update account details', async ({ page }) => {
|
||||||
await page.goto('/settings/account');
|
await page.goto('/settings/account');
|
||||||
|
|
||||||
|
await page.getByLabel('Display Name').fill('Tim Apple');
|
||||||
await page.getByLabel('First name').fill('Timothy');
|
await page.getByLabel('First name').fill('Timothy');
|
||||||
await page.getByLabel('Last name').fill('Apple');
|
await page.getByLabel('Last name').fill('Apple');
|
||||||
|
await page.getByLabel('Display Name').fill('Timothy Apple');
|
||||||
await page.getByLabel('Email').fill('timothy.apple@test.com');
|
await page.getByLabel('Email').fill('timothy.apple@test.com');
|
||||||
await page.getByLabel('Username').fill('timothy');
|
await page.getByLabel('Username').fill('timothy');
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
@@ -40,6 +42,18 @@ test('Update account details fails with already taken username', async ({ page }
|
|||||||
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
|
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Update account details fails with already taken username in different casing', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await page.goto('/settings/account');
|
||||||
|
|
||||||
|
await page.getByLabel('Username').fill(users.craig.username.toUpperCase());
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
|
||||||
|
});
|
||||||
|
|
||||||
test('Change Locale', async ({ page }) => {
|
test('Change Locale', async ({ page }) => {
|
||||||
await page.goto('/settings/account');
|
await page.goto('/settings/account');
|
||||||
|
|
||||||
|
|||||||
@@ -128,15 +128,15 @@ test('Update application images', async ({ page }) => {
|
|||||||
await expect(page.locator('[data-type="success"]')).toHaveText('Images updated successfully');
|
await expect(page.locator('[data-type="success"]')).toHaveText('Images updated successfully');
|
||||||
|
|
||||||
await page.request
|
await page.request
|
||||||
.get('/api/application-configuration/favicon')
|
.get('/api/application-images/favicon')
|
||||||
.then((res) => expect.soft(res.status()).toBe(200));
|
.then((res) => expect.soft(res.status()).toBe(200));
|
||||||
await page.request
|
await page.request
|
||||||
.get('/api/application-configuration/logo?light=true')
|
.get('/api/application-images/logo?light=true')
|
||||||
.then((res) => expect.soft(res.status()).toBe(200));
|
.then((res) => expect.soft(res.status()).toBe(200));
|
||||||
await page.request
|
await page.request
|
||||||
.get('/api/application-configuration/logo?light=false')
|
.get('/api/application-images/logo?light=false')
|
||||||
.then((res) => expect.soft(res.status()).toBe(200));
|
.then((res) => expect.soft(res.status()).toBe(200));
|
||||||
await page.request
|
await page.request
|
||||||
.get('/api/application-configuration/background-image')
|
.get('/api/application-images/background')
|
||||||
.then((res) => expect.soft(res.status()).toBe(200));
|
.then((res) => expect.soft(res.status()).toBe(200));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ test('Create user', async ({ page }) => {
|
|||||||
await page.getByLabel('Last name').fill(user.lastname);
|
await page.getByLabel('Last name').fill(user.lastname);
|
||||||
await page.getByLabel('Email').fill(user.email);
|
await page.getByLabel('Email').fill(user.email);
|
||||||
await page.getByLabel('Username').fill(user.username);
|
await page.getByLabel('Username').fill(user.username);
|
||||||
|
|
||||||
|
await expect(page.getByLabel('Display Name')).toHaveValue(`${user.firstname} ${user.lastname}`);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('row', { name: `${user.firstname} ${user.lastname}` })).toBeVisible();
|
await expect(page.getByRole('row', { name: `${user.firstname} ${user.lastname}` })).toBeVisible();
|
||||||
@@ -50,6 +53,21 @@ test('Create user fails with already taken username', async ({ page }) => {
|
|||||||
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
|
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Create user fails with already taken username in different casing', async ({ page }) => {
|
||||||
|
const user = users.steve;
|
||||||
|
|
||||||
|
await page.goto('/settings/admin/users');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Add User' }).click();
|
||||||
|
await page.getByLabel('First name').fill(user.firstname);
|
||||||
|
await page.getByLabel('Last name').fill(user.lastname);
|
||||||
|
await page.getByLabel('Email').fill(user.email);
|
||||||
|
await page.getByLabel('Username').fill(users.tim.username.toUpperCase());
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
|
||||||
|
});
|
||||||
|
|
||||||
test('Create one time access token', async ({ page, context }) => {
|
test('Create one time access token', async ({ page, context }) => {
|
||||||
await page.goto('/settings/admin/users');
|
await page.goto('/settings/admin/users');
|
||||||
|
|
||||||
@@ -106,6 +124,7 @@ test('Update user', async ({ page }) => {
|
|||||||
|
|
||||||
await page.getByLabel('First name').fill('Crack');
|
await page.getByLabel('First name').fill('Crack');
|
||||||
await page.getByLabel('Last name').fill('Apple');
|
await page.getByLabel('Last name').fill('Apple');
|
||||||
|
await page.getByLabel('Display Name').fill('Crack Apple');
|
||||||
await page.getByLabel('Email').fill('crack.apple@test.com');
|
await page.getByLabel('Email').fill('crack.apple@test.com');
|
||||||
await page.getByLabel('Username').fill('crack');
|
await page.getByLabel('Username').fill('crack');
|
||||||
await page.getByRole('button', { name: 'Save' }).first().click();
|
await page.getByRole('button', { name: 'Save' }).first().click();
|
||||||
@@ -147,6 +166,23 @@ test('Update user fails with already taken username', async ({ page }) => {
|
|||||||
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
|
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Update user fails with already taken username in different casing', async ({ page }) => {
|
||||||
|
const user = users.craig;
|
||||||
|
|
||||||
|
await page.goto('/settings/admin/users');
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('row', { name: `${user.firstname} ${user.lastname}` })
|
||||||
|
.getByRole('button')
|
||||||
|
.click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||||
|
|
||||||
|
await page.getByLabel('Username').fill(users.tim.username.toUpperCase());
|
||||||
|
await page.getByRole('button', { name: 'Save' }).first().click();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-type="error"]')).toHaveText('Username is already in use');
|
||||||
|
});
|
||||||
|
|
||||||
test('Update user custom claims', async ({ page }) => {
|
test('Update user custom claims', async ({ page }) => {
|
||||||
await page.goto(`/settings/admin/users/${users.craig.id}`);
|
await page.goto(`/settings/admin/users/${users.craig.id}`);
|
||||||
|
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ test.describe('User Signup', () => {
|
|||||||
|
|
||||||
await page.getByRole('button', { name: 'Sign Up' }).click();
|
await page.getByRole('button', { name: 'Sign Up' }).click();
|
||||||
|
|
||||||
await expect(page.getByText('Invalid input').first()).toBeVisible();
|
await expect(page.getByText('Invalid email address').first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Open signup - duplicate email shows error', async ({ page }) => {
|
test('Open signup - duplicate email shows error', async ({ page }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user