Compare commits

...

20 Commits

Author SHA1 Message Date
Elias Schneider
eb34535c5a release: 0.43.1 2025-03-20 21:38:02 +01:00
Elias Schneider
3120ebf239 fix: wrong base locale causes crash 2025-03-20 21:36:05 +01:00
Elias Schneider
2fb41937ca ci/cd: ignore e2e tests on Crowdin branch 2025-03-20 20:49:17 +01:00
Elias Schneider
d78a1c6974 release: 0.43.0 2025-03-20 20:47:17 +01:00
Elias Schneider
c578baba95 chore: add language request issue template 2025-03-20 20:38:33 +01:00
Elias Schneider
bb23194e88 chore(translations): remove unused messages 2025-03-20 20:26:43 +01:00
Elias Schneider
31ac56004a refactor: use language code with country for messages 2025-03-20 20:15:26 +01:00
Elias Schneider
d59ec01b33 Update Crowdin configuration file 2025-03-20 20:12:48 +01:00
Elias Schneider
3ee26a2cfb chore: update Crowdin configuration 2025-03-20 20:09:05 +01:00
Elias Schneider
39395c79c3 Update Crowdin configuration file 2025-03-20 20:08:24 +01:00
Jonas Claes
269b5a3c92 feat: add support for translations (#349)
Co-authored-by: Kyle Mendell <kmendell@outlook.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-20 18:57:41 +00:00
Kyle Mendell
041c565dc1 feat(passkeys): name new passkeys based on agguids (#332)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-20 15:35:08 +00:00
Elias Schneider
e486dbd771 release: 0.42.1 2025-03-18 23:03:50 +01:00
Elias Schneider
f7e36a422e fix: kid not added to JWTs 2025-03-18 23:03:34 +01:00
Elias Schneider
f74c7bf95d release: 0.42.0 2025-03-18 21:11:19 +01:00
Alessandro (Ale) Segala
a7c9741802 feat: store keys as JWK on disk (#339)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-03-18 21:08:33 +01:00
Elias Schneider
e9b2d981b7 release: 0.41.0 2025-03-18 21:04:53 +01:00
Kyle Mendell
8f146188d5 feat(profile-picture): allow reset of profile picture (#355)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-18 19:59:31 +00:00
Viktor Szépe
a0f93bda49 chor: correct misspellings (#352) 2025-03-18 12:54:39 +01:00
Savely Krasovsky
0423d354f5 fix: own avatar not loading (#351) 2025-03-18 12:02:59 +01:00
108 changed files with 3233 additions and 656 deletions

View File

@@ -49,7 +49,7 @@ body:
required: false
attributes:
label: "Log Output"
description: "Output of log files when the issue occured to help us diagnose the issue."
description: "Output of log files when the issue occurred to help us diagnose the issue."
- type: markdown
attributes:
value: |

View File

@@ -0,0 +1,20 @@
name: "🌐 Language request"
description: "You want to contribute to a language that isn't on Crowdin yet?"
title: "🌐 Language Request: <language name in english>"
labels: [language-request]
body:
- type: input
id: language-name-native
attributes:
label: "🌐 Language Name (native)"
placeholder: "Schweizerdeutsch"
validations:
required: true
- type: input
id: language-code
attributes:
label: "🌐 ISO 639-1 Language Code"
description: "You can find your language code [here](https://www.andiamo.co.uk/resources/iso-language-codes/)."
placeholder: "de-CH"
validations:
required: true

View File

@@ -2,6 +2,7 @@ name: E2E Tests
on:
push:
branches: [main]
branches-ignore: [i18n_crowdin]
paths-ignore:
- "docs/**"
- "**.md"

34
.github/workflows/update-aaguids.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: Update AAGUIDs
on:
schedule:
- cron: "0 0 * * 1" # Runs every Monday at midnight
workflow_dispatch: # Allows manual triggering of the workflow
permissions:
contents: write
jobs:
update-aaguids:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Fetch JSON data
run: |
curl -o data.json https://raw.githubusercontent.com/pocket-id/passkey-aaguids/refs/heads/main/combined_aaguid.json
- name: Process JSON data
run: |
mkdir -p backend/resources
jq -c 'map_values(.name)' data.json > backend/resources/aaguids.json
- name: Commit changes
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add backend/resources/aaguids.json
git diff --staged --quiet || git commit -m "chore: update AAGUIDs"
git push

View File

@@ -1 +1 @@
0.40.1
0.43.1

5
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"recommendations": [
"inlang.vs-code-extension"
]
}

View File

@@ -1,3 +1,44 @@
## [](https://github.com/pocket-id/pocket-id/compare/v0.43.0...v) (2025-03-20)
### Bug Fixes
* wrong base locale causes crash ([3120ebf](https://github.com/pocket-id/pocket-id/commit/3120ebf239b90f0bc0a0af33f30622e034782398))
## [](https://github.com/pocket-id/pocket-id/compare/v0.42.1...v) (2025-03-20)
### Features
* add support for translations ([#349](https://github.com/pocket-id/pocket-id/issues/349)) ([269b5a3](https://github.com/pocket-id/pocket-id/commit/269b5a3c9249bb8081c74741141d3d5a69ea42a2))
* **passkeys:** name new passkeys based on agguids ([#332](https://github.com/pocket-id/pocket-id/issues/332)) ([041c565](https://github.com/pocket-id/pocket-id/commit/041c565dc10f15edb3e8ab58e9a4df5e48a2a6d3))
## [](https://github.com/pocket-id/pocket-id/compare/v0.42.0...v) (2025-03-18)
### Bug Fixes
* kid not added to JWTs ([f7e36a4](https://github.com/pocket-id/pocket-id/commit/f7e36a422ea6b5327360c9a13308ae408ff7fffe))
## [](https://github.com/pocket-id/pocket-id/compare/v0.41.0...v) (2025-03-18)
### Features
* store keys as JWK on disk ([#339](https://github.com/pocket-id/pocket-id/issues/339)) ([a7c9741](https://github.com/pocket-id/pocket-id/commit/a7c9741802667811c530ef4e6313b71615ec6a9b))
## [](https://github.com/pocket-id/pocket-id/compare/v0.40.1...v) (2025-03-18)
### Features
* **profile-picture:** allow reset of profile picture ([#355](https://github.com/pocket-id/pocket-id/issues/355)) ([8f14618](https://github.com/pocket-id/pocket-id/commit/8f146188d57b5c08a4c6204674c15379232280d8))
### Bug Fixes
* own avatar not loading ([#351](https://github.com/pocket-id/pocket-id/issues/351)) ([0423d35](https://github.com/pocket-id/pocket-id/commit/0423d354f533d2ff4fd431859af3eea7d4d7044f))
## [](https://github.com/pocket-id/pocket-id/compare/v0.40.0...v) (2025-03-16)

View File

@@ -18,9 +18,11 @@ require (
github.com/golang-migrate/migrate/v4 v4.18.2
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/lestrrat-go/jwx/v3 v3.0.0-alpha3
github.com/mileusna/useragent v1.3.5
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2
golang.org/x/crypto v0.35.0
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.36.0
golang.org/x/image v0.24.0
golang.org/x/time v0.9.0
gorm.io/driver/postgres v1.5.11
@@ -33,6 +35,8 @@ require (
github.com/bytedance/sonic v1.12.8 // indirect
github.com/bytedance/sonic/loader v0.2.3 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/disintegration/gift v1.1.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
@@ -55,6 +59,10 @@ require (
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc/v3 v3.0.0-beta1 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect
@@ -62,7 +70,9 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/x448/float16 v0.8.4 // indirect
@@ -70,9 +80,9 @@ require (
golang.org/x/arch v0.13.0 // indirect
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
golang.org/x/net v0.36.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/sync v0.12.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
google.golang.org/protobuf v1.36.4 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -20,6 +20,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8=
github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM=
github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
@@ -137,6 +139,16 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.0-beta1 h1:pzDjP9dSONCFQC/AE3mWUnHILGiYPiMKzQIS+weKJXA=
github.com/lestrrat-go/httprc/v3 v3.0.0-beta1/go.mod h1:wdsgouffPvWPEYh8t7PRH/PidR5sfVqt0na4Nhj60Ms=
github.com/lestrrat-go/jwx/v3 v3.0.0-alpha3 h1:HHT8iW+UcPBgBr5A3soZQQsL5cBor/u6BkLB+wzY/R0=
github.com/lestrrat-go/jwx/v3 v3.0.0-alpha3/go.mod h1:ak32WoNtHE0aLowVWBcCvXngcAnW4tuC0YhFwOr/kwc=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -176,12 +188,15 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
@@ -217,8 +232,8 @@ 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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
@@ -249,8 +264,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.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -263,8 +278,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.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.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -283,8 +298,8 @@ 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.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.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@@ -11,5 +11,7 @@ func Bootstrap() {
db := newDatabase()
appConfigService := service.NewAppConfigService(db)
migrateKey()
initRouter(db, appConfigService)
}

View File

@@ -0,0 +1,133 @@
package bootstrap
import (
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"fmt"
"log"
"os"
"path/filepath"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
const (
privateKeyFilePem = "jwt_private_key.pem"
)
func migrateKey() {
err := migrateKeyInternal(common.EnvConfig.KeysPath)
if err != nil {
log.Fatalf("failed to perform migration of keys: %v", err)
}
}
func migrateKeyInternal(basePath string) error {
// First, check if there's already a JWK stored
jwkPath := filepath.Join(basePath, service.PrivateKeyFile)
ok, err := utils.FileExists(jwkPath)
if err != nil {
return fmt.Errorf("failed to check if private key file (JWK) exists at path '%s': %w", jwkPath, err)
}
if ok {
// There's already a key as JWK, so we don't do anything else here
return nil
}
// Check if there's a PEM file
pemPath := filepath.Join(basePath, privateKeyFilePem)
ok, err = utils.FileExists(pemPath)
if err != nil {
return fmt.Errorf("failed to check if private key file (PEM) exists at path '%s': %w", pemPath, err)
}
if !ok {
// No file to migrate, return
return nil
}
// Load and validate the key
key, err := loadKeyPEM(pemPath)
if err != nil {
return fmt.Errorf("failed to load private key file (PEM) at path '%s': %w", pemPath, err)
}
err = service.ValidateKey(key)
if err != nil {
return fmt.Errorf("key object is invalid: %w", err)
}
// Save the key as JWK
err = service.SaveKeyJWK(key, jwkPath)
if err != nil {
return fmt.Errorf("failed to save private key file at path '%s': %w", jwkPath, err)
}
// Finally, delete the PEM file
err = os.Remove(pemPath)
if err != nil {
return fmt.Errorf("failed to remove migrated key at path '%s': %w", pemPath, err)
}
return nil
}
func loadKeyPEM(path string) (jwk.Key, error) {
// Load the key from disk and parse it
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read key data: %w", err)
}
key, err := jwk.ParseKey(data, jwk.WithPEM(true))
if err != nil {
return nil, fmt.Errorf("failed to parse key: %w", err)
}
// Populate the key ID using the "legacy" algorithm
keyId, err := generateKeyID(key)
if err != nil {
return nil, fmt.Errorf("failed to generate key ID: %w", err)
}
key.Set(jwk.KeyIDKey, keyId)
// Populate other required fields
_ = key.Set(jwk.KeyUsageKey, service.KeyUsageSigning)
service.EnsureAlgInKey(key)
return key, nil
}
// generateKeyID generates a Key ID for the public key using the first 8 bytes of the SHA-256 hash of the public key's PKIX-serialized structure.
// This is used for legacy keys, imported from PEM.
func generateKeyID(key jwk.Key) (string, error) {
// Export the public key and serialize it to PKIX (not in a PEM block)
// This is for backwards-compatibility with the algorithm used before the switch to JWK
pubKey, err := key.PublicKey()
if err != nil {
return "", fmt.Errorf("failed to get public key: %w", err)
}
var pubKeyRaw any
err = jwk.Export(pubKey, &pubKeyRaw)
if err != nil {
return "", fmt.Errorf("failed to export public key: %w", err)
}
pubASN1, err := x509.MarshalPKIXPublicKey(pubKeyRaw)
if err != nil {
return "", fmt.Errorf("failed to marshal public key: %w", err)
}
// Compute SHA-256 hash of the public key
hash := sha256.New()
hash.Write(pubASN1)
hashed := hash.Sum(nil)
// Truncate the hash to the first 8 bytes for a shorter Key ID
shortHash := hashed[:8]
// Return Base64 encoded truncated hash as Key ID
return base64.RawURLEncoding.EncodeToString(shortHash), nil
}

View File

@@ -0,0 +1,190 @@
package bootstrap
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"os"
"path/filepath"
"testing"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
func TestMigrateKey(t *testing.T) {
// Create a temporary directory for testing
tempDir := t.TempDir()
t.Run("no keys exist", func(t *testing.T) {
// Test when no keys exist
err := migrateKeyInternal(tempDir)
require.NoError(t, err)
})
t.Run("jwk already exists", func(t *testing.T) {
// Create a JWK file
jwkPath := filepath.Join(tempDir, service.PrivateKeyFile)
key, err := createTestRSAKey()
require.NoError(t, err)
err = service.SaveKeyJWK(key, jwkPath)
require.NoError(t, err)
// Run migration - should do nothing
err = migrateKeyInternal(tempDir)
require.NoError(t, err)
// Check the file still exists
exists, err := utils.FileExists(jwkPath)
require.NoError(t, err)
assert.True(t, exists)
// Delete for next test
err = os.Remove(jwkPath)
require.NoError(t, err)
})
t.Run("migrate pem to jwk", func(t *testing.T) {
// Create a PEM file
pemPath := filepath.Join(tempDir, privateKeyFilePem)
jwkPath := filepath.Join(tempDir, service.PrivateKeyFile)
// Generate RSA key and save as PEM
createRSAPrivateKeyPEM(t, pemPath)
// Run migration
err := migrateKeyInternal(tempDir)
require.NoError(t, err)
// Check PEM file is gone
exists, err := utils.FileExists(pemPath)
require.NoError(t, err)
assert.False(t, exists)
// Check JWK file exists
exists, err = utils.FileExists(jwkPath)
require.NoError(t, err)
assert.True(t, exists)
// Verify the JWK can be loaded
data, err := os.ReadFile(jwkPath)
require.NoError(t, err)
_, err = jwk.ParseKey(data)
require.NoError(t, err)
})
}
func TestLoadKeyPEM(t *testing.T) {
// Create a temporary directory for testing
tempDir := t.TempDir()
t.Run("successfully load PEM key", func(t *testing.T) {
pemPath := filepath.Join(tempDir, "test_key.pem")
// Generate RSA key and save as PEM
createRSAPrivateKeyPEM(t, pemPath)
// Load the key
key, err := loadKeyPEM(pemPath)
require.NoError(t, err)
// Verify key properties
assert.NotEmpty(t, key)
// Check key ID is set
var keyID string
err = key.Get(jwk.KeyIDKey, &keyID)
assert.NoError(t, err)
assert.NotEmpty(t, keyID)
// Check algorithm is set
var alg jwa.SignatureAlgorithm
err = key.Get(jwk.AlgorithmKey, &alg)
assert.NoError(t, err)
assert.NotEmpty(t, alg)
// Check key usage is set
var keyUsage string
err = key.Get(jwk.KeyUsageKey, &keyUsage)
assert.NoError(t, err)
assert.Equal(t, service.KeyUsageSigning, keyUsage)
})
t.Run("file not found", func(t *testing.T) {
key, err := loadKeyPEM(filepath.Join(tempDir, "nonexistent.pem"))
assert.Error(t, err)
assert.Nil(t, key)
})
t.Run("invalid file content", func(t *testing.T) {
invalidPath := filepath.Join(tempDir, "invalid.pem")
err := os.WriteFile(invalidPath, []byte("not a valid PEM"), 0600)
require.NoError(t, err)
key, err := loadKeyPEM(invalidPath)
assert.Error(t, err)
assert.Nil(t, key)
})
}
func TestGenerateKeyID(t *testing.T) {
key, err := createTestRSAKey()
require.NoError(t, err)
keyID, err := generateKeyID(key)
require.NoError(t, err)
// Key ID should be non-empty
assert.NotEmpty(t, keyID)
// Generate another key ID to prove it depends on the key
key2, err := createTestRSAKey()
require.NoError(t, err)
keyID2, err := generateKeyID(key2)
require.NoError(t, err)
// The two key IDs should be different
assert.NotEqual(t, keyID, keyID2)
}
// Helper functions
func createTestRSAKey() (jwk.Key, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, err
}
key, err := jwk.Import(privateKey)
if err != nil {
return nil, err
}
return key, nil
}
// createRSAPrivateKeyPEM generates an RSA private key and returns its PEM-encoded form
func createRSAPrivateKeyPEM(t *testing.T, pemPath string) ([]byte, *rsa.PrivateKey) {
// Generate RSA key
privKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
// Encode to PEM format
pemData := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privKey),
})
err = os.WriteFile(pemPath, pemData, 0600)
require.NoError(t, err)
return pemData, privKey
}

View File

@@ -47,6 +47,9 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler)
group.POST("/one-time-access-token/setup", uc.getSetupAccessTokenHandler)
group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.requestOneTimeAccessEmailHandler)
group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler)
group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler)
}
type UserController struct {
@@ -480,3 +483,40 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
c.JSON(http.StatusOK, userDto)
}
// resetUserProfilePictureHandler godoc
// @Summary Reset user profile picture
// @Description Reset a specific user's profile picture to the default
// @Tags Users
// @Produce json
// @Param id path string true "User ID"
// @Success 204 "No Content"
// @Router /users/{id}/profile-picture [delete]
func (uc *UserController) resetUserProfilePictureHandler(c *gin.Context) {
userID := c.Param("id")
if err := uc.userService.ResetProfilePicture(userID); err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// resetCurrentUserProfilePictureHandler godoc
// @Summary Reset current user's profile picture
// @Description Reset the currently authenticated user's profile picture to the default
// @Tags Users
// @Produce json
// @Success 204 "No Content"
// @Router /users/me/profile-picture [delete]
func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context) {
userID := c.GetString("userID")
if err := uc.userService.ResetProfilePicture(userID); err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}

View File

@@ -30,13 +30,13 @@ type WellKnownController struct {
// @Success 200 {object} object "{ \"keys\": []interface{} }"
// @Router /.well-known/jwks.json [get]
func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
jwk, err := wkc.jwtService.GetJWK()
jwks, err := wkc.jwtService.GetPublicJWKSAsJSON()
if err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{"keys": []interface{}{jwk}})
c.Data(http.StatusOK, "application/json; charset=utf-8", jwks)
}
// openIDConfigurationHandler godoc

View File

@@ -9,18 +9,20 @@ type UserDto struct {
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
CustomClaims []CustomClaimDto `json:"customClaims"`
UserGroups []UserGroupDto `json:"userGroups"`
LdapID *string `json:"ldapId"`
}
type UserCreateDto struct {
Username string `json:"username" binding:"required,username,min=2,max=50"`
Email string `json:"email" binding:"required,email"`
FirstName string `json:"firstName" binding:"required,min=1,max=50"`
LastName string `json:"lastName" binding:"required,min=1,max=50"`
IsAdmin bool `json:"isAdmin"`
LdapID string `json:"-"`
Username string `json:"username" binding:"required,username,min=2,max=50"`
Email string `json:"email" binding:"required,email"`
FirstName string `json:"firstName" binding:"required,min=1,max=50"`
LastName string `json:"lastName" binding:"required,min=1,max=50"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
LdapID string `json:"-"`
}
type OneTimeAccessTokenCreateDto struct {

View File

@@ -14,6 +14,7 @@ type User struct {
FirstName string `sortable:"true"`
LastName string `sortable:"true"`
IsAdmin bool `sortable:"true"`
Locale *string
LdapID *string
CustomClaims []CustomClaim

View File

@@ -3,14 +3,12 @@ package service
import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math/big"
"os"
"path/filepath"
"slices"
@@ -18,80 +16,154 @@ import (
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
const (
privateKeyFile = "jwt_private_key.pem"
// PrivateKeyFile is the path in the data/keys folder where the key is stored
// This is a JSON file containing a key encoded as JWK
PrivateKeyFile = "jwt_private_key.json"
// RsaKeySize is the size, in bits, of the RSA key to generate if none is found
RsaKeySize = 2048
// KeyUsageSigning is the usage for the private keys, for the "use" property
KeyUsageSigning = "sig"
)
type JwtService struct {
privateKey *rsa.PrivateKey
privateKey jwk.Key
keyId string
appConfigService *AppConfigService
jwksEncoded []byte
}
func NewJwtService(appConfigService *AppConfigService) *JwtService {
service := &JwtService{
appConfigService: appConfigService,
}
service := &JwtService{}
// Ensure keys are generated or loaded
if err := service.loadOrGenerateKey(common.EnvConfig.KeysPath); err != nil {
if err := service.init(appConfigService, common.EnvConfig.KeysPath); err != nil {
log.Fatalf("Failed to initialize jwt service: %v", err)
}
return service
}
func (s *JwtService) init(appConfigService *AppConfigService, keysPath string) error {
s.appConfigService = appConfigService
// Ensure keys are generated or loaded
return s.loadOrGenerateKey(keysPath)
}
type AccessTokenJWTClaims struct {
jwt.RegisteredClaims
IsAdmin bool `json:"isAdmin,omitempty"`
}
type JWK struct {
Kid string `json:"kid"`
Kty string `json:"kty"`
Use string `json:"use"`
Alg string `json:"alg"`
N string `json:"n"`
E string `json:"e"`
}
// loadOrGenerateKey loads RSA keys from the given paths or generates them if they do not exist.
// loadOrGenerateKey loads the private key from the given path or generates it if not existing.
func (s *JwtService) loadOrGenerateKey(keysPath string) error {
privateKeyPath := filepath.Join(keysPath, privateKeyFile)
var key jwk.Key
if _, err := os.Stat(privateKeyPath); os.IsNotExist(err) {
if err := s.generateKey(keysPath); err != nil {
return fmt.Errorf("can't generate key: %w", err)
// First, check if we have a JWK file
// If we do, then we just load that
jwkPath := filepath.Join(keysPath, PrivateKeyFile)
ok, err := utils.FileExists(jwkPath)
if err != nil {
return fmt.Errorf("failed to check if private key file (JWK) exists at path '%s': %w", jwkPath, err)
}
if ok {
key, err = s.loadKeyJWK(jwkPath)
if err != nil {
return fmt.Errorf("failed to load private key file (JWK) at path '%s': %w", jwkPath, err)
}
// Set the key, and we are done
err = s.SetKey(key)
if err != nil {
return fmt.Errorf("failed to set private key: %w", err)
}
return nil
}
privateKeyBytes, err := os.ReadFile(privateKeyPath)
// If we are here, we need to generate a new key
key, err = s.generateNewRSAKey()
if err != nil {
return fmt.Errorf("can't read jwt private key: %w", err)
}
privateKey, err := jwt.ParseRSAPrivateKeyFromPEM(privateKeyBytes)
if err != nil {
return fmt.Errorf("can't parse jwt private key: %w", err)
return fmt.Errorf("failed to generate new private key: %w", err)
}
err = s.SetKey(privateKey)
// Set the key in the object, which also validates it
err = s.SetKey(key)
if err != nil {
return fmt.Errorf("failed to set private key: %w", err)
}
// Save the key as JWK
err = SaveKeyJWK(s.privateKey, jwkPath)
if err != nil {
return fmt.Errorf("failed to save private key file at path '%s': %w", jwkPath, err)
}
return nil
}
func (s *JwtService) SetKey(privateKey *rsa.PrivateKey) (err error) {
func ValidateKey(privateKey jwk.Key) error {
// Validate the loaded key
err := privateKey.Validate()
if err != nil {
return fmt.Errorf("key object is invalid: %w", err)
}
keyID, ok := privateKey.KeyID()
if !ok || keyID == "" {
return errors.New("key object does not contain a key ID")
}
usage, ok := privateKey.KeyUsage()
if !ok || usage != KeyUsageSigning {
return errors.New("key object is not valid for signing")
}
ok, err = jwk.IsPrivateKey(privateKey)
if err != nil || !ok {
return errors.New("key object is not a private key")
}
return nil
}
func (s *JwtService) SetKey(privateKey jwk.Key) error {
// Validate the loaded key
err := ValidateKey(privateKey)
if err != nil {
return fmt.Errorf("private key is not valid: %w", err)
}
// Set the private key and key id in the object
s.privateKey = privateKey
s.keyId, err = s.generateKeyID()
keyId, ok := privateKey.KeyID()
if !ok {
return errors.New("key object does not contain a key ID")
}
s.keyId = keyId
// Create and encode a JWKS containing the public key
publicKey, err := s.GetPublicJWK()
if err != nil {
return fmt.Errorf("can't generate key ID: %w", err)
return fmt.Errorf("failed to get public JWK: %w", err)
}
jwks := jwk.NewSet()
err = jwks.AddKey(publicKey)
if err != nil {
return fmt.Errorf("failed to add public key to JWKS: %w", err)
}
s.jwksEncoded, err = json.Marshal(jwks)
if err != nil {
return fmt.Errorf("failed to encode JWKS to JSON: %w", err)
}
return nil
@@ -112,12 +184,23 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
token.Header["kid"] = s.keyId
return token.SignedString(s.privateKey)
var privateKeyRaw any
err := jwk.Export(s.privateKey, &privateKeyRaw)
if err != nil {
return "", fmt.Errorf("failed to export private key object: %w", err)
}
signed, err := token.SignedString(privateKeyRaw)
if err != nil {
return "", fmt.Errorf("failed to sign token: %w", err)
}
return signed, nil
}
func (s *JwtService) VerifyAccessToken(tokenString string) (*AccessTokenJWTClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &AccessTokenJWTClaims{}, func(token *jwt.Token) (interface{}, error) {
return &s.privateKey.PublicKey, nil
token, err := jwt.ParseWithClaims(tokenString, &AccessTokenJWTClaims{}, func(token *jwt.Token) (any, error) {
return s.getPublicKeyRaw()
})
if err != nil || !token.Valid {
return nil, errors.New("couldn't handle this token")
@@ -135,12 +218,12 @@ func (s *JwtService) VerifyAccessToken(tokenString string) (*AccessTokenJWTClaim
}
func (s *JwtService) GenerateIDToken(userClaims map[string]interface{}, clientID string, nonce string) (string, error) {
claims := jwt.MapClaims{
"aud": clientID,
"exp": jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
"iat": jwt.NewNumericDate(time.Now()),
"iss": common.EnvConfig.AppURL,
}
// Initialize with capacity for userClaims, + 4 fixed claims, + 2 claims which may be set in some cases, to avoid re-allocations
claims := make(jwt.MapClaims, len(userClaims)+6)
claims["aud"] = clientID
claims["exp"] = jwt.NewNumericDate(time.Now().Add(1 * time.Hour))
claims["iat"] = jwt.NewNumericDate(time.Now())
claims["iss"] = common.EnvConfig.AppURL
for k, v := range userClaims {
claims[k] = v
@@ -153,43 +236,18 @@ func (s *JwtService) GenerateIDToken(userClaims map[string]interface{}, clientID
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = s.keyId
return token.SignedString(s.privateKey)
}
func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) {
claim := jwt.RegisteredClaims{
Subject: user.ID,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Audience: jwt.ClaimStrings{clientID},
Issuer: common.EnvConfig.AppURL,
var privateKeyRaw any
err := jwk.Export(s.privateKey, &privateKeyRaw)
if err != nil {
return "", fmt.Errorf("failed to export private key object: %w", err)
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
token.Header["kid"] = s.keyId
return token.SignedString(s.privateKey)
}
func (s *JwtService) VerifyOauthAccessToken(tokenString string) (*jwt.RegisteredClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
return &s.privateKey.PublicKey, nil
})
if err != nil || !token.Valid {
return nil, errors.New("couldn't handle this token")
}
claims, isValid := token.Claims.(*jwt.RegisteredClaims)
if !isValid {
return nil, errors.New("can't parse claims")
}
return claims, nil
return token.SignedString(privateKeyRaw)
}
func (s *JwtService) VerifyIdToken(tokenString string) (*jwt.RegisteredClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
return &s.privateKey.PublicKey, nil
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (any, error) {
return s.getPublicKeyRaw()
}, jwt.WithIssuer(common.EnvConfig.AppURL))
if err != nil && !errors.Is(err, jwt.ErrTokenExpired) {
@@ -204,78 +262,179 @@ func (s *JwtService) VerifyIdToken(tokenString string) (*jwt.RegisteredClaims, e
return claims, nil
}
// GetJWK returns the JSON Web Key (JWK) for the public key.
func (s *JwtService) GetJWK() (JWK, error) {
func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) {
claim := jwt.RegisteredClaims{
Subject: user.ID,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Audience: jwt.ClaimStrings{clientID},
Issuer: common.EnvConfig.AppURL,
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
token.Header["kid"] = s.keyId
var privateKeyRaw any
err := jwk.Export(s.privateKey, &privateKeyRaw)
if err != nil {
return "", fmt.Errorf("failed to export private key object: %w", err)
}
return token.SignedString(privateKeyRaw)
}
func (s *JwtService) VerifyOauthAccessToken(tokenString string) (*jwt.RegisteredClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (any, error) {
return s.getPublicKeyRaw()
})
if err != nil || !token.Valid {
return nil, errors.New("couldn't handle this token")
}
claims, isValid := token.Claims.(*jwt.RegisteredClaims)
if !isValid {
return nil, errors.New("can't parse claims")
}
return claims, nil
}
// GetPublicJWK returns the JSON Web Key (JWK) for the public key.
func (s *JwtService) GetPublicJWK() (jwk.Key, error) {
if s.privateKey == nil {
return JWK{}, errors.New("public key is not initialized")
return nil, errors.New("key is not initialized")
}
jwk := JWK{
Kid: s.keyId,
Kty: "RSA",
Use: "sig",
Alg: "RS256",
N: base64.RawURLEncoding.EncodeToString(s.privateKey.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(s.privateKey.E)).Bytes()),
}
return jwk, nil
}
// GenerateKeyID generates a Key ID for the public key using the first 8 bytes of the SHA-256 hash of the public key.
func (s *JwtService) generateKeyID() (string, error) {
pubASN1, err := x509.MarshalPKIXPublicKey(&s.privateKey.PublicKey)
pubKey, err := s.privateKey.PublicKey()
if err != nil {
return "", fmt.Errorf("failed to marshal public key: %w", err)
return nil, fmt.Errorf("failed to get public key: %w", err)
}
// Compute SHA-256 hash of the public key
hash := sha256.New()
hash.Write(pubASN1)
hashed := hash.Sum(nil)
EnsureAlgInKey(pubKey)
// Truncate the hash to the first 8 bytes for a shorter Key ID
shortHash := hashed[:8]
// Return Base64 encoded truncated hash as Key ID
return base64.RawURLEncoding.EncodeToString(shortHash), nil
return pubKey, nil
}
// generateKey generates a new RSA key and saves it to the specified path.
func (s *JwtService) generateKey(keysPath string) error {
if err := os.MkdirAll(keysPath, 0700); err != nil {
return fmt.Errorf("failed to create directories for keys: %w", err)
// GetPublicJWKSAsJSON returns the JSON Web Key Set (JWKS) for the public key, encoded as JSON.
// The value is cached since the key is static.
func (s *JwtService) GetPublicJWKSAsJSON() ([]byte, error) {
if len(s.jwksEncoded) == 0 {
return nil, errors.New("key is not initialized")
}
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
return s.jwksEncoded, nil
}
func (s *JwtService) getPublicKeyRaw() (any, error) {
pubKey, err := s.privateKey.PublicKey()
if err != nil {
return fmt.Errorf("failed to generate private key: %w", err)
return nil, fmt.Errorf("failed to get public key: %w", err)
}
privateKeyPath := filepath.Join(keysPath, privateKeyFile)
if err := s.savePEMKey(privateKeyPath, x509.MarshalPKCS1PrivateKey(privateKey), "RSA PRIVATE KEY"); err != nil {
return err
var pubKeyRaw any
err = jwk.Export(pubKey, &pubKeyRaw)
if err != nil {
return nil, fmt.Errorf("failed to export raw public key: %w", err)
}
return nil
return pubKeyRaw, nil
}
// savePEMKey saves a PEM encoded key to a file.
func (s *JwtService) savePEMKey(path string, keyBytes []byte, keyType string) error {
keyFile, err := os.Create(path)
func (s *JwtService) loadKeyJWK(path string) (jwk.Key, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read key data: %w", err)
}
key, err := jwk.ParseKey(data)
if err != nil {
return nil, fmt.Errorf("failed to parse key: %w", err)
}
return key, nil
}
// EnsureAlgInKey ensures that the key contains an "alg" parameter, set depending on the key type
func EnsureAlgInKey(key jwk.Key) {
_, ok := key.Algorithm()
if ok {
// Algorithm is already set
return
}
switch key.KeyType() {
case jwa.RSA():
// Default to RS256 for RSA keys
_ = key.Set(jwk.AlgorithmKey, jwa.RS256())
case jwa.EC():
// Default to ES256 for ECDSA keys
_ = key.Set(jwk.AlgorithmKey, jwa.ES256())
case jwa.OKP():
// Default to EdDSA for OKP keys
_ = key.Set(jwk.AlgorithmKey, jwa.EdDSA())
}
}
func (s *JwtService) generateNewRSAKey() (jwk.Key, error) {
// We generate RSA keys only
rawKey, err := rsa.GenerateKey(rand.Reader, RsaKeySize)
if err != nil {
return nil, fmt.Errorf("failed to generate RSA private key: %w", err)
}
// Import the raw key
return importRawKey(rawKey)
}
func importRawKey(rawKey any) (jwk.Key, error) {
key, err := jwk.Import(rawKey)
if err != nil {
return nil, fmt.Errorf("failed to import generated private key: %w", err)
}
// Generate the key ID
kid, err := generateRandomKeyID()
if err != nil {
return nil, fmt.Errorf("failed to generate key ID: %w", err)
}
_ = key.Set(jwk.KeyIDKey, kid)
// Set other required fields
_ = key.Set(jwk.KeyUsageKey, KeyUsageSigning)
EnsureAlgInKey(key)
return key, err
}
// SaveKeyJWK saves a JWK to a file
func SaveKeyJWK(key jwk.Key, path string) error {
dir := filepath.Dir(path)
err := os.MkdirAll(dir, 0700)
if err != nil {
return fmt.Errorf("failed to create directory '%s' for key file: %w", dir, err)
}
keyFile, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to create key file: %w", err)
}
defer keyFile.Close()
keyPEM := pem.EncodeToMemory(&pem.Block{
Type: keyType,
Bytes: keyBytes,
})
if _, err := keyFile.Write(keyPEM); err != nil {
// Write the JSON file to disk
enc := json.NewEncoder(keyFile)
enc.SetEscapeHTML(false)
err = enc.Encode(key)
if err != nil {
return fmt.Errorf("failed to write key file: %w", err)
}
return nil
}
// generateRandomKeyID generates a random key ID.
func generateRandomKeyID() (string, error) {
buf := make([]byte, 8)
_, err := io.ReadFull(rand.Reader, buf)
if err != nil {
return "", fmt.Errorf("failed to read random bytes: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}

View File

@@ -0,0 +1,546 @@
package service
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"os"
"path/filepath"
"testing"
"time"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
func TestJwtService_Init(t *testing.T) {
t.Run("should generate new key when none exists", func(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Create a mock AppConfigService
appConfigService := &AppConfigService{}
// Initialize the JWT service
service := &JwtService{}
err := service.init(appConfigService, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
// Verify the private key was set
require.NotNil(t, service.privateKey, "Private key should be set")
// Verify the key has been saved to disk as JWK
jwkPath := filepath.Join(tempDir, PrivateKeyFile)
_, err = os.Stat(jwkPath)
assert.NoError(t, err, "JWK file should exist")
// Verify the generated key is valid
keyData, err := os.ReadFile(jwkPath)
require.NoError(t, err)
key, err := jwk.ParseKey(keyData)
require.NoError(t, err)
// Key should have required properties
keyID, ok := key.KeyID()
assert.True(t, ok, "Key should have a key ID")
assert.NotEmpty(t, keyID)
keyUsage, ok := key.KeyUsage()
assert.True(t, ok, "Key should have a key usage")
assert.Equal(t, "sig", keyUsage)
})
t.Run("should load existing JWK key", func(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// First create a service to generate a key
firstService := &JwtService{}
err := firstService.init(&AppConfigService{}, tempDir)
require.NoError(t, err)
// Get the key ID of the first service
origKeyID, ok := firstService.privateKey.KeyID()
require.True(t, ok)
// Now create a new service that should load the existing key
secondService := &JwtService{}
err = secondService.init(&AppConfigService{}, tempDir)
require.NoError(t, err)
// Verify the loaded key has the same ID as the original
loadedKeyID, ok := secondService.privateKey.KeyID()
require.True(t, ok)
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
})
t.Run("should load existing JWK for EC keys", func(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Create a new JWK and save it to disk
origKeyID := createECKeyJWK(t, tempDir)
// Now create a new service that should load the existing key
svc := &JwtService{}
err := svc.init(&AppConfigService{}, tempDir)
require.NoError(t, err)
// Verify the loaded key has the same ID as the original
loadedKeyID, ok := svc.privateKey.KeyID()
require.True(t, ok)
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
})
}
func TestJwtService_GetPublicJWK(t *testing.T) {
t.Run("returns public key when private key is initialized", func(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Create a JWT service with initialized key
service := &JwtService{}
err := service.init(&AppConfigService{}, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
// Get the JWK (public key)
publicKey, err := service.GetPublicJWK()
require.NoError(t, err, "GetPublicJWK should not return an error when private key is initialized")
// Verify the returned key is valid
require.NotNil(t, publicKey, "Public key should not be nil")
// Validate it's actually a public key
isPrivate, err := jwk.IsPrivateKey(publicKey)
require.NoError(t, err)
assert.False(t, isPrivate, "Returned key should be a public key")
// Check that key has required properties
keyID, ok := publicKey.KeyID()
require.True(t, ok, "Public key should have a key ID")
assert.NotEmpty(t, keyID, "Key ID should not be empty")
alg, ok := publicKey.Algorithm()
require.True(t, ok, "Public key should have an algorithm")
assert.Equal(t, "RS256", alg.String(), "Algorithm should be RS256")
})
t.Run("returns public key when ECDSA private key is initialized", func(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Create an ECDSA key and save it as JWK
originalKeyID := createECKeyJWK(t, tempDir)
// Create a JWT service that loads the ECDSA key
service := &JwtService{}
err := service.init(&AppConfigService{}, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
// Get the JWK (public key)
publicKey, err := service.GetPublicJWK()
require.NoError(t, err, "GetPublicJWK should not return an error when private key is initialized")
// Verify the returned key is valid
require.NotNil(t, publicKey, "Public key should not be nil")
// Validate it's actually a public key
isPrivate, err := jwk.IsPrivateKey(publicKey)
require.NoError(t, err)
assert.False(t, isPrivate, "Returned key should be a public key")
// Check that key has required properties
keyID, ok := publicKey.KeyID()
require.True(t, ok, "Public key should have a key ID")
assert.Equal(t, originalKeyID, keyID, "Key ID should match the original key ID")
// Check that the key type is EC
assert.Equal(t, "EC", publicKey.KeyType().String(), "Key type should be EC")
// Check that the algorithm is ES256
alg, ok := publicKey.Algorithm()
require.True(t, ok, "Public key should have an algorithm")
assert.Equal(t, "ES256", alg.String(), "Algorithm should be ES256")
})
t.Run("returns error when private key is not initialized", func(t *testing.T) {
// Create a service with nil private key
service := &JwtService{
privateKey: nil,
}
// Try to get the JWK
publicKey, err := service.GetPublicJWK()
// Verify it returns an error
require.Error(t, err, "GetPublicJWK should return an error when private key is nil")
assert.Contains(t, err.Error(), "key is not initialized", "Error message should indicate key is not initialized")
assert.Nil(t, publicKey, "Public key should be nil when there's an error")
})
}
func TestGenerateVerifyAccessToken(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Initialize the JWT service with a mock AppConfigService
mockConfig := &AppConfigService{
DbConfig: &model.AppConfig{
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
},
}
// Setup the environment variable required by the token verification
originalAppURL := common.EnvConfig.AppURL
common.EnvConfig.AppURL = "https://test.example.com"
defer func() {
common.EnvConfig.AppURL = originalAppURL
}()
t.Run("generates token for regular user", func(t *testing.T) {
// Create a JWT service
service := &JwtService{}
err := service.init(mockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
// Create a test user
user := model.User{
Base: model.Base{
ID: "user123",
},
Email: "user@example.com",
IsAdmin: false,
}
// Generate a token
tokenString, err := service.GenerateAccessToken(user)
require.NoError(t, err, "Failed to generate access token")
assert.NotEmpty(t, tokenString, "Token should not be empty")
// Verify the token
claims, err := service.VerifyAccessToken(tokenString)
require.NoError(t, err, "Failed to verify generated token")
// Check the claims
assert.Equal(t, user.ID, claims.Subject, "Token subject should match user ID")
assert.Equal(t, false, claims.IsAdmin, "IsAdmin should be false")
assert.Contains(t, claims.Audience, "https://test.example.com", "Audience should contain the app URL")
// Check token expiration time is approximately 60 minutes from now
expectedExp := time.Now().Add(60 * time.Minute)
tokenExp := claims.ExpiresAt.Time
timeDiff := expectedExp.Sub(tokenExp).Minutes()
assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 60 minutes")
})
t.Run("generates token for admin user", func(t *testing.T) {
// Create a JWT service
service := &JwtService{}
err := service.init(mockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
// Create a test admin user
adminUser := model.User{
Base: model.Base{
ID: "admin123",
},
Email: "admin@example.com",
IsAdmin: true,
}
// Generate a token
tokenString, err := service.GenerateAccessToken(adminUser)
require.NoError(t, err, "Failed to generate access token")
// Verify the token
claims, err := service.VerifyAccessToken(tokenString)
require.NoError(t, err, "Failed to verify generated token")
// Check the IsAdmin claim is true
assert.Equal(t, true, claims.IsAdmin, "IsAdmin should be true for admin users")
assert.Equal(t, adminUser.ID, claims.Subject, "Token subject should match admin ID")
})
t.Run("uses session duration from config", func(t *testing.T) {
// Create a JWT service with a different session duration
customMockConfig := &AppConfigService{
DbConfig: &model.AppConfig{
SessionDuration: model.AppConfigVariable{Value: "30"}, // 30 minutes
},
}
service := &JwtService{}
err := service.init(customMockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
// Create a test user
user := model.User{
Base: model.Base{
ID: "user456",
},
}
// Generate a token
tokenString, err := service.GenerateAccessToken(user)
require.NoError(t, err, "Failed to generate access token")
// Verify the token
claims, err := service.VerifyAccessToken(tokenString)
require.NoError(t, err, "Failed to verify generated token")
// Check token expiration time is approximately 30 minutes from now
expectedExp := time.Now().Add(30 * time.Minute)
tokenExp := claims.ExpiresAt.Time
timeDiff := expectedExp.Sub(tokenExp).Minutes()
assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 30 minutes")
})
}
func TestGenerateVerifyIdToken(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Initialize the JWT service with a mock AppConfigService
mockConfig := &AppConfigService{
DbConfig: &model.AppConfig{
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
},
}
// Setup the environment variable required by the token verification
originalAppURL := common.EnvConfig.AppURL
common.EnvConfig.AppURL = "https://test.example.com"
defer func() {
common.EnvConfig.AppURL = originalAppURL
}()
t.Run("generates and verifies ID token with standard claims", func(t *testing.T) {
// Create a JWT service
service := &JwtService{}
err := service.init(mockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
// Create test claims
userClaims := map[string]interface{}{
"sub": "user123",
"name": "Test User",
"email": "user@example.com",
}
const clientID = "test-client-123"
// Generate a token
tokenString, err := service.GenerateIDToken(userClaims, clientID, "")
require.NoError(t, err, "Failed to generate ID token")
assert.NotEmpty(t, tokenString, "Token should not be empty")
// Verify the token
claims, err := service.VerifyIdToken(tokenString)
require.NoError(t, err, "Failed to verify generated ID token")
// Check the claims
assert.Equal(t, "user123", claims.Subject, "Token subject should match user ID")
assert.Contains(t, claims.Audience, clientID, "Audience should contain the client ID")
assert.Equal(t, common.EnvConfig.AppURL, claims.Issuer, "Issuer should match app URL")
// Check token expiration time is approximately 1 hour from now
expectedExp := time.Now().Add(1 * time.Hour)
tokenExp := claims.ExpiresAt.Time
timeDiff := expectedExp.Sub(tokenExp).Minutes()
assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 1 hour")
})
t.Run("generates and verifies ID token with nonce", func(t *testing.T) {
// Create a JWT service
service := &JwtService{}
err := service.init(mockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
// Create test claims with nonce
userClaims := map[string]interface{}{
"sub": "user456",
"name": "Another User",
}
const clientID = "test-client-456"
nonce := "random-nonce-value"
// Generate a token with nonce
tokenString, err := service.GenerateIDToken(userClaims, clientID, nonce)
require.NoError(t, err, "Failed to generate ID token with nonce")
// Parse the token manually to check nonce
publicKey, err := service.GetPublicJWK()
require.NoError(t, err, "Failed to get public key")
token, err := jwt.Parse([]byte(tokenString), jwt.WithKey(jwa.RS256(), publicKey))
require.NoError(t, err, "Failed to parse token")
var tokenNonce string
err = token.Get("nonce", &tokenNonce)
require.NoError(t, err, "Failed to get claims")
assert.Equal(t, nonce, tokenNonce, "Token should contain the correct nonce")
})
t.Run("fails verification with incorrect issuer", func(t *testing.T) {
// Create a JWT service
service := &JwtService{}
err := service.init(mockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
// Generate a token with standard claims
userClaims := map[string]interface{}{
"sub": "user789",
}
tokenString, err := service.GenerateIDToken(userClaims, "client-789", "")
require.NoError(t, err, "Failed to generate ID token")
// Temporarily change the app URL to simulate wrong issuer
common.EnvConfig.AppURL = "https://wrong-issuer.com"
// Verify should fail due to issuer mismatch
_, err = service.VerifyIdToken(tokenString)
assert.Error(t, err, "Verification should fail with incorrect issuer")
assert.Contains(t, err.Error(), "couldn't handle this token", "Error message should indicate token verification failure")
})
}
func TestGenerateVerifyOauthAccessToken(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Initialize the JWT service with a mock AppConfigService
mockConfig := &AppConfigService{
DbConfig: &model.AppConfig{
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
},
}
// Setup the environment variable required by the token verification
originalAppURL := common.EnvConfig.AppURL
common.EnvConfig.AppURL = "https://test.example.com"
defer func() {
common.EnvConfig.AppURL = originalAppURL
}()
t.Run("generates and verifies OAuth access token with standard claims", func(t *testing.T) {
// Create a JWT service
service := &JwtService{}
err := service.init(mockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
// Create a test user
user := model.User{
Base: model.Base{
ID: "user123",
},
Email: "user@example.com",
}
const clientID = "test-client-123"
// Generate a token
tokenString, err := service.GenerateOauthAccessToken(user, clientID)
require.NoError(t, err, "Failed to generate OAuth access token")
assert.NotEmpty(t, tokenString, "Token should not be empty")
// Verify the token
claims, err := service.VerifyOauthAccessToken(tokenString)
require.NoError(t, err, "Failed to verify generated OAuth access token")
// Check the claims
assert.Equal(t, user.ID, claims.Subject, "Token subject should match user ID")
assert.Contains(t, claims.Audience, clientID, "Audience should contain the client ID")
assert.Equal(t, common.EnvConfig.AppURL, claims.Issuer, "Issuer should match app URL")
// Check token expiration time is approximately 1 hour from now
expectedExp := time.Now().Add(1 * time.Hour)
tokenExp := claims.ExpiresAt.Time
timeDiff := expectedExp.Sub(tokenExp).Minutes()
assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 1 hour")
})
t.Run("fails verification for expired token", func(t *testing.T) {
// Create a JWT service with a mock function to generate an expired token
service := &JwtService{}
err := service.init(mockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
// Create a test user
user := model.User{
Base: model.Base{
ID: "user456",
},
}
const clientID = "test-client-456"
// Generate a token using JWT directly to create an expired token
token, err := jwt.NewBuilder().
Subject(user.ID).
Expiration(time.Now().Add(-1 * time.Hour)). // Expired 1 hour ago
IssuedAt(time.Now().Add(-2 * time.Hour)).
Audience([]string{clientID}).
Issuer(common.EnvConfig.AppURL).
Build()
require.NoError(t, err, "Failed to build token")
signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), service.privateKey))
require.NoError(t, err, "Failed to sign token")
// Verify should fail due to expiration
_, err = service.VerifyOauthAccessToken(string(signed))
assert.Error(t, err, "Verification should fail with expired token")
assert.Contains(t, err.Error(), "couldn't handle this token", "Error message should indicate token verification failure")
})
t.Run("fails verification with invalid signature", func(t *testing.T) {
// Create two JWT services with different keys
service1 := &JwtService{}
err := service1.init(mockConfig, t.TempDir()) // Use a different temp dir
require.NoError(t, err, "Failed to initialize first JWT service")
service2 := &JwtService{}
err = service2.init(mockConfig, t.TempDir()) // Use a different temp dir
require.NoError(t, err, "Failed to initialize second JWT service")
// Create a test user
user := model.User{
Base: model.Base{
ID: "user789",
},
}
const clientID = "test-client-789"
// Generate a token with the first service
tokenString, err := service1.GenerateOauthAccessToken(user, clientID)
require.NoError(t, err, "Failed to generate OAuth access token")
// Verify with the second service should fail due to different keys
_, err = service2.VerifyOauthAccessToken(tokenString)
assert.Error(t, err, "Verification should fail with invalid signature")
assert.Contains(t, err.Error(), "couldn't handle this token", "Error message should indicate token verification failure")
})
}
func createECKeyJWK(t *testing.T, path string) string {
t.Helper()
// Generate a new P-256 ECDSA key
privateKeyRaw, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err, "Failed to generate ECDSA key")
// Import as JWK and save to disk
privateKey, err := importRawKey(privateKeyRaw)
require.NoError(t, err, "Failed to import private key")
err = SaveKeyJWK(privateKey, filepath.Join(path, PrivateKeyFile))
require.NoError(t, err, "Failed to save key")
kid, _ := privateKey.KeyID()
require.NotEmpty(t, kid, "Key ID must be set")
return kid
}

View File

@@ -4,7 +4,6 @@ import (
"crypto/ecdsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"log"
"os"
@@ -12,14 +11,15 @@ import (
"time"
"github.com/fxamacker/cbor/v2"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/resources"
"github.com/go-webauthn/webauthn/protocol"
"github.com/lestrrat-go/jwx/v3/jwk"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/resources"
)
type TestService struct {
@@ -304,38 +304,9 @@ func (s *TestService) ResetAppConfig() error {
}
func (s *TestService) SetJWTKeys() {
privateKeyString := `-----BEGIN RSA PRIVATE KEY-----
MIIEpQIBAAKCAQEAyaeEL0VKoPBXIAaWXsUgmu05lAvEIIdJn0FX9lHh4JE5UY9B
83C5sCNdhs9iSWzpeP11EVjWp8i3Yv2CF7c7u50BXnVBGtxpZpFC+585UXacoJ0c
hUmarL9GRFJcM1nPHBTFu68aRrn1rIKNHUkNaaxFo0NFGl/4EDDTO8HwawTjwkPo
QlRzeByhlvGPVvwgB3Fn93B8QJ/cZhXKxJvjjrC/8Pk76heC/ntEMru71Ix77BoC
3j2TuyiN7m9RNBW8BU5q6lKoIdvIeZfTFLzi37iufyfvMrJTixp9zhNB1NxlLCeO
Zl2MXegtiGqd2H3cbAyqoOiv9ihUWTfXj7SxJwIDAQABAoIBAQCa8wNZJ08+9y6b
RzSIQcTaBuq1XY0oyYvCuX0ToruDyVNX3lJ48udb9vDIw9XsQans9CTeXXsjldGE
WPN7sapOcUg6ArMyJqc+zuO/YQu0EwYrTE48BOC7WIZvvTFnq9y+4R9HJjd0nTOv
iOlR1W5fAqbH2srgh1mfZ0UIp+9K6ymoinPXVGEXUAuuoMuTEZW/tnA2HT9WEllT
2FyMbmXrFzutAQqk9GRmnQh2OQZLxnQWyShVqJEhYBtm6JUUH1YJbyTVzMLgdBM8
ukgjTVtRDHaW51ubRSVdGBVT2m1RRtTsYAiZCpM5bwt88aSUS9yDOUiVH+irDg/3
IHEuL7IxAoGBAP2MpXPXtOwinajUQ9hKLDAtpq4axGvY+aGP5dNEMsuPo5ggOfUP
b4sqr73kaNFO3EbxQOQVoFjehhi4dQxt1/kAala9HZ5N7s26G2+eUWFF8jy7gWSN
qusNqGrG4g8D3WOyqZFb/x/m6SE0Jcg7zvIYbnAOq1Fexeik0Fc/DNzLAoGBAMua
d4XIfu4ydtU5AIaf1ZNXywgLg+LWxK8ELNqH/Y2vLAeIiTrOVp+hw9z+zHPD5cnu
6mix783PCOYNLTylrwtAz3fxSz14lsDFQM3ntzVF/6BniTTkKddctcPyqnTvamah
0hD2dzXBS/0mTBYIIMYTNbs0Yj87FTdJZw/+qa2VAoGBAKbzQkp54W6PCIMPabD0
fg4nMRZ5F5bv4seIKcunn068QPs9VQxQ4qCfNeLykDYqGA86cgD9YHzD4UZLxv6t
IUWbCWod0m/XXwPlpIUlmO5VEUD+MiAUzFNDxf6xAE7ku5UXImJNUjseX6l2Xd5v
yz9L6QQuFI5aujQKugiIwp5rAoGATtUVGCCkPNgfOLmkYXu7dxxUCV5kB01+xAEK
2OY0n0pG8vfDophH4/D/ZC7nvJ8J9uDhs/3JStexq1lIvaWtG99RNTChIEDzpdn6
GH9yaVcb/eB4uJjrNm64FhF8PGCCwxA+xMCZMaARKwhMB2/IOMkxUbWboL3gnhJ2
rDO/QO0CgYEA2Grt6uXHm61ji3xSdkBWNtUnj19vS1+7rFJp5SoYztVQVThf/W52
BAiXKBdYZDRVoItC/VS2NvAOjeJjhYO/xQ/q3hK7MdtuXfEPpLnyXKkmWo3lrJ26
wbeF6l05LexCkI7ShsOuSt+dsyaTJTszuKDIA6YOfWvfo3aVZmlWRaI=
-----END RSA PRIVATE KEY-----
`
block, _ := pem.Decode([]byte(privateKeyString))
privateKey, _ := x509.ParsePKCS1PrivateKey(block.Bytes)
const privateKeyString = `{"alg":"RS256","d":"mvMDWSdPPvcum0c0iEHE2gbqtV2NKMmLwrl9E6K7g8lTV95SePLnW_bwyMPV7EGp7PQk3l17I5XRhFjze7GqTnFIOgKzMianPs7jv2ELtBMGK0xOPATgu1iGb70xZ6vcvuEfRyY3dJ0zr4jpUdVuXwKmx9rK4IdZn2dFCKfvSuspqIpz11RhF1ALrqDLkxGVv7ZwNh0_VhJZU9hcjG5l6xc7rQEKpPRkZp0IdjkGS8Z0FskoVaiRIWAbZuiVFB9WCW8k1czC4HQTPLpII01bUQx2ludbm0UlXRgVU9ptUUbU7GAImQqTOW8LfPGklEvcgzlIlR_oqw4P9yBxLi-yMQ","dp":"pvNCSnnhbo8Igw9psPR-DicxFnkXlu_ix4gpy6efTrxA-z1VDFDioJ814vKQNioYDzpyAP1gfMPhRkvG_q0hRZsJah3Sb9dfA-WkhSWY7lURQP4yIBTMU0PF_rEATuS7lRciYk1SOx5fqXZd3m_LP0vpBC4Ujlq6NAq6CIjCnms","dq":"TtUVGCCkPNgfOLmkYXu7dxxUCV5kB01-xAEK2OY0n0pG8vfDophH4_D_ZC7nvJ8J9uDhs_3JStexq1lIvaWtG99RNTChIEDzpdn6GH9yaVcb_eB4uJjrNm64FhF8PGCCwxA-xMCZMaARKwhMB2_IOMkxUbWboL3gnhJ2rDO_QO0","e":"AQAB","kid":"8uHDw3M6rf8","kty":"RSA","n":"yaeEL0VKoPBXIAaWXsUgmu05lAvEIIdJn0FX9lHh4JE5UY9B83C5sCNdhs9iSWzpeP11EVjWp8i3Yv2CF7c7u50BXnVBGtxpZpFC-585UXacoJ0chUmarL9GRFJcM1nPHBTFu68aRrn1rIKNHUkNaaxFo0NFGl_4EDDTO8HwawTjwkPoQlRzeByhlvGPVvwgB3Fn93B8QJ_cZhXKxJvjjrC_8Pk76heC_ntEMru71Ix77BoC3j2TuyiN7m9RNBW8BU5q6lKoIdvIeZfTFLzi37iufyfvMrJTixp9zhNB1NxlLCeOZl2MXegtiGqd2H3cbAyqoOiv9ihUWTfXj7SxJw","p":"_Yylc9e07CKdqNRD2EosMC2mrhrEa9j5oY_l00Qyy4-jmCA59Q9viyqvveRo0U7cRvFA5BWgWN6GGLh1DG3X-QBqVr0dnk3uzbobb55RYUXyPLuBZI2q6w2oasbiDwPdY7KpkVv_H-bpITQlyDvO8hhucA6rUV7F6KTQVz8M3Ms","q":"y5p3hch-7jJ21TkAhp_Vk1fLCAuD4tbErwQs2of9ja8sB4iJOs5Wn6HD3P7Mc8Plye7qaLHvzc8I5g0tPKWvC0DPd_FLPXiWwMVAzee3NUX_oGeJNOQp11y1w_KqdO9qZqHSEPZ3NcFL_SZMFgggxhM1uzRiPzsVN0lnD_6prZU","qi":"2Grt6uXHm61ji3xSdkBWNtUnj19vS1-7rFJp5SoYztVQVThf_W52BAiXKBdYZDRVoItC_VS2NvAOjeJjhYO_xQ_q3hK7MdtuXfEPpLnyXKkmWo3lrJ26wbeF6l05LexCkI7ShsOuSt-dsyaTJTszuKDIA6YOfWvfo3aVZmlWRaI","use":"sig"}`
privateKey, _ := jwk.ParseKey([]byte(privateKeyString))
s.jwtService.SetKey(privateKey)
}

View File

@@ -153,6 +153,7 @@ func (s *UserService) CreateUser(input dto.UserCreateDto) (model.User, error) {
Email: input.Email,
Username: input.Username,
IsAdmin: input.IsAdmin,
Locale: input.Locale,
}
if input.LdapID != "" {
user.LdapID = &input.LdapID
@@ -182,6 +183,7 @@ func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, u
user.LastName = updatedUser.LastName
user.Email = updatedUser.Email
user.Username = updatedUser.Username
user.Locale = updatedUser.Locale
if !updateOwnUser {
user.IsAdmin = updatedUser.IsAdmin
}
@@ -365,3 +367,27 @@ func (s *UserService) checkDuplicatedFields(user model.User) error {
return nil
}
// ResetProfilePicture deletes a user's custom profile picture
func (s *UserService) ResetProfilePicture(userID string) error {
// Validate the user ID to prevent directory traversal
if err := uuid.Validate(userID); err != nil {
return &common.InvalidUUIDError{}
}
// Build path to profile picture
profilePicturePath := fmt.Sprintf("%s/profile-pictures/%s.png", common.EnvConfig.UploadPath, userID)
// Check if file exists and delete it
if _, err := os.Stat(profilePicturePath); err == nil {
if err := os.Remove(profilePicturePath); err != nil {
return fmt.Errorf("failed to delete profile picture: %w", err)
}
} else if !os.IsNotExist(err) {
// If any error other than "file not exists"
return fmt.Errorf("failed to check if profile picture exists: %w", err)
}
// It's okay if the file doesn't exist - just means there's no custom picture to delete
return nil
}

View File

@@ -95,8 +95,11 @@ func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.R
return model.WebauthnCredential{}, err
}
// Determine passkey name using AAGUID and User-Agent
passkeyName := s.determinePasskeyName(credential.Authenticator.AAGUID)
credentialToStore := model.WebauthnCredential{
Name: "New Passkey",
Name: passkeyName,
CredentialID: credential.ID,
AttestationType: credential.AttestationType,
PublicKey: credential.PublicKey,
@@ -112,6 +115,16 @@ func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.R
return credentialToStore, nil
}
func (s *WebAuthnService) determinePasskeyName(aaguid []byte) string {
// First try to identify by AAGUID using a combination of builtin + MDS
authenticatorName := utils.GetAuthenticatorName(aaguid)
if authenticatorName != "" {
return authenticatorName
}
return "New Passkey" // Default fallback
}
func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions, error) {
options, session, err := s.webAuthn.BeginDiscoverableLogin()
if err != nil {

View File

@@ -0,0 +1,64 @@
package utils
import (
"encoding/hex"
"encoding/json"
"fmt"
"log"
"sync"
"github.com/pocket-id/pocket-id/backend/resources"
)
var (
aaguidMap map[string]string
aaguidMapOnce sync.Once
)
// FormatAAGUID converts an AAGUID byte slice to UUID string format
func FormatAAGUID(aaguid []byte) string {
if len(aaguid) == 0 {
return ""
}
// If exactly 16 bytes, format as UUID
if len(aaguid) == 16 {
return fmt.Sprintf("%x-%x-%x-%x-%x",
aaguid[0:4], aaguid[4:6], aaguid[6:8], aaguid[8:10], aaguid[10:16])
}
// Otherwise just return as hex
return hex.EncodeToString(aaguid)
}
// GetAuthenticatorName returns the name of the authenticator for the given AAGUID
func GetAuthenticatorName(aaguid []byte) string {
aaguidStr := FormatAAGUID(aaguid)
if aaguidStr == "" {
return ""
}
// Then check JSON-sourced map
aaguidMapOnce.Do(loadAAGUIDsFromFile)
if name, ok := aaguidMap[aaguidStr]; ok {
return name + " Passkey"
}
return ""
}
// loadAAGUIDsFromFile loads AAGUID data from the embedded file system
func loadAAGUIDsFromFile() {
// Read from embedded file system
data, err := resources.FS.ReadFile("aaguids.json")
if err != nil {
log.Printf("Error reading embedded AAGUID file: %v", err)
return
}
if err := json.Unmarshal(data, &aaguidMap); err != nil {
log.Printf("Error unmarshalling AAGUID data: %v", err)
return
}
}

View File

@@ -0,0 +1,126 @@
package utils
import (
"encoding/hex"
"sync"
"testing"
)
func TestFormatAAGUID(t *testing.T) {
tests := []struct {
name string
aaguid []byte
want string
}{
{
name: "empty byte slice",
aaguid: []byte{},
want: "",
},
{
name: "16 byte slice - standard UUID",
aaguid: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10},
want: "01020304-0506-0708-090a-0b0c0d0e0f10",
},
{
name: "non-16 byte slice",
aaguid: []byte{0x01, 0x02, 0x03, 0x04, 0x05},
want: "0102030405",
},
{
name: "specific UUID example",
aaguid: mustDecodeHex("adce000235bcc60a648b0b25f1f05503"),
want: "adce0002-35bc-c60a-648b-0b25f1f05503",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FormatAAGUID(tt.aaguid)
if got != tt.want {
t.Errorf("FormatAAGUID() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetAuthenticatorName(t *testing.T) {
// Reset the aaguidMap for testing
originalMap := aaguidMap
originalOnce := aaguidMapOnce
defer func() {
aaguidMap = originalMap
aaguidMapOnce = originalOnce
}()
// Inject a test AAGUID map
aaguidMap = map[string]string{
"adce0002-35bc-c60a-648b-0b25f1f05503": "Test Authenticator",
"00000000-0000-0000-0000-000000000000": "Zero Authenticator",
}
aaguidMapOnce = sync.Once{}
aaguidMapOnce.Do(func() {}) // Mark as done to avoid loading from file
tests := []struct {
name string
aaguid []byte
want string
}{
{
name: "empty byte slice",
aaguid: []byte{},
want: "",
},
{
name: "known AAGUID",
aaguid: mustDecodeHex("adce000235bcc60a648b0b25f1f05503"),
want: "Test Authenticator Passkey",
},
{
name: "zero UUID",
aaguid: mustDecodeHex("00000000000000000000000000000000"),
want: "Zero Authenticator Passkey",
},
{
name: "unknown AAGUID",
aaguid: mustDecodeHex("ffffffffffffffffffffffffffffffff"),
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GetAuthenticatorName(tt.aaguid)
if got != tt.want {
t.Errorf("GetAuthenticatorName() = %v, want %v", got, tt.want)
}
})
}
}
func TestLoadAAGUIDsFromFile(t *testing.T) {
// Reset the map and once flag for clean testing
aaguidMap = nil
aaguidMapOnce = sync.Once{}
// Trigger loading of AAGUIDs by calling GetAuthenticatorName
GetAuthenticatorName([]byte{0x01, 0x02, 0x03, 0x04})
if len(aaguidMap) == 0 {
t.Error("loadAAGUIDsFromFile() failed to populate aaguidMap")
}
// Check for a few known entries that should be in the embedded file
// This test will be more brittle as it depends on the content of aaguids.json,
// but it helps verify that the loading actually worked
t.Log("AAGUID map loaded with", len(aaguidMap), "entries")
}
// Helper function to convert hex string to bytes
func mustDecodeHex(s string) []byte {
bytes, err := hex.DecodeString(s)
if err != nil {
panic("invalid hex in test: " + err.Error())
}
return bytes
}

View File

@@ -27,7 +27,7 @@ func GetTemplate[U any, V any](templateMap TemplateMap[U], template Template[V])
return templateMap[template.Path]
}
type clonable[V pareseable[V]] interface {
type cloneable[V pareseable[V]] interface {
Clone() (V, error)
}
@@ -35,7 +35,7 @@ type pareseable[V any] interface {
ParseFS(fs.FS, ...string) (V, error)
}
func prepareTemplate[V pareseable[V]](templateFS fs.FS, template string, rootTemplate clonable[V], suffix string) (V, error) {
func prepareTemplate[V pareseable[V]](templateFS fs.FS, template string, rootTemplate cloneable[V], suffix string) (V, error) {
tmpl, err := rootTemplate.Clone()
if err != nil {
return *new(V), fmt.Errorf("clone root template: %w", err)

View File

@@ -78,3 +78,15 @@ func SaveFile(file *multipart.FileHeader, dst string) error {
_, err = io.Copy(out, src)
return err
}
// FileExists returns true if a file exists on disk and is a regular file
func FileExists(path string) (bool, error) {
s, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
err = nil
}
return false, err
}
return !s.IsDir(), nil
}

File diff suppressed because one or more lines are too long

View File

@@ -4,5 +4,5 @@ import "embed"
// Embedded file systems for the project
//go:embed email-templates images migrations fonts
//go:embed email-templates images migrations fonts aaguids.json
var FS embed.FS

View File

@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN locale;

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN locale TEXT;

View File

@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN locale;

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN locale TEXT;

4
crowdin.yml Normal file
View File

@@ -0,0 +1,4 @@
files:
- source: /frontend/messages/en-US.json
translation: /%original_path%/%locale%.json
pull_request_title: 'chore(translations): update translations via Crowdin'

View File

@@ -0,0 +1,316 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"my_account": "My Account",
"logout": "Logout",
"confirm": "Confirm",
"key": "Key",
"value": "Value",
"remove_custom_claim": "Remove custom claim",
"add_custom_claim": "Add custom claim",
"add_another": "Add another",
"select_a_date": "Select a date",
"select_file": "Select File",
"profile_picture": "Profile Picture",
"profile_picture_is_managed_by_ldap_server": "The profile picture is managed by the LDAP server and cannot be changed here.",
"click_profile_picture_to_upload_custom": "Click on the profile picture to upload a custom one from your files.",
"image_should_be_in_format": "The image should be in PNG or JPEG format.",
"items_per_page": "Items per page",
"no_items_found": "No items found",
"search": "Search...",
"expand_card": "Expand card",
"copied": "Copied",
"click_to_copy": "Click to copy",
"something_went_wrong": "Something went wrong",
"go_back_to_home": "Go back to home",
"dont_have_access_to_your_passkey": "Don't have access to your passkey?",
"login_background": "Login background",
"logo": "Logo",
"login_code": "Login Code",
"create_a_login_code_to_sign_in_without_a_passkey_once": "Create a login code that the user can use to sign in without a passkey once.",
"one_hour": "1 hour",
"twelve_hours": "12 hours",
"one_day": "1 day",
"one_week": "1 week",
"one_month": "1 month",
"expiration": "Expiration",
"generate_code": "Generate Code",
"name": "Name",
"browser_unsupported": "Browser unsupported",
"this_browser_does_not_support_passkeys": "This browser doesn't support passkeys. Please or use a alternative sign in method.",
"an_unknown_error_occurred": "An unknown error occurred",
"authentication_process_was_aborted": "The authentication process was aborted",
"error_occurred_with_authenticator": "An error occurred with the authenticator",
"authenticator_does_not_support_discoverable_credentials": "The authenticator does not support discoverable credentials",
"authenticator_does_not_support_resident_keys": "The authenticator does not support resident keys",
"passkey_was_previously_registered": "This passkey was previously registered",
"authenticator_does_not_support_any_of_the_requested_algorithms": "The authenticator does not support any of the requested algorithms",
"authenticator_timed_out": "The authenticator timed out",
"critical_error_occurred_contact_administrator": "A critical error occurred. Please contact your administrator.",
"sign_in_to": "Sign in to {name}",
"client_not_found": "Client not found",
"client_wants_to_access_the_following_information": "<b>{client}</b> wants to access the following information:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Do you want to sign in to <b>{client}</b> with your <b>{appName}</b> account?",
"email": "Email",
"view_your_email_address": "View your email address",
"profile": "Profile",
"view_your_profile_information": "View your profile information",
"groups": "Groups",
"view_the_groups_you_are_a_member_of": "View the groups you are a member of",
"cancel": "Cancel",
"sign_in": "Sign in",
"try_again": "Try again",
"client_logo": "Client Logo",
"sign_out": "Sign out",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Do you want to sign out of Pocket ID with the account <b>{username}</b>?",
"sign_in_to_appname": "Sign in to {appName}",
"please_try_to_sign_in_again": "Please try to sign in again.",
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Authenticate yourself with your passkey to access the admin panel.",
"authenticate": "Authenticate",
"appname_setup": "{appName} Setup",
"please_try_again": "Please try again.",
"you_are_about_to_sign_in_to_the_initial_admin_account": "You're about to sign in to the initial admin account. Anyone with this link can access the account until a passkey is added. Please set up a passkey as soon as possible to prevent unauthorized access.",
"continue": "Continue",
"alternative_sign_in": "Alternative Sign In",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "If you dont't have access to your passkey, you can sign in using one of the following methods.",
"use_your_passkey_instead": "Use your passkey instead?",
"email_login": "Email Login",
"enter_a_login_code_to_sign_in": "Enter a login code to sign in.",
"request_a_login_code_via_email": "Request a login code via email.",
"go_back": "Go back",
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "An email has been sent to the provided email, if it exists in the system.",
"enter_code": "Enter code",
"enter_your_email_address_to_receive_an_email_with_a_login_code": "Enter your email address to receive an email with a login code.",
"your_email": "Your email",
"submit": "Submit",
"enter_the_code_you_received_to_sign_in": "Enter the code you received to sign in.",
"code": "Code",
"invalid_redirect_url": "Invalid redirect URL",
"audit_log": "Audit Log",
"users": "Users",
"user_groups": "User Groups",
"oidc_clients": "OIDC Clients",
"api_keys": "API Keys",
"application_configuration": "Application Configuration",
"settings": "Settings",
"update_pocket_id": "Update Pocket ID",
"powered_by": "Powered by",
"see_your_account_activities_from_the_last_3_months": "See your account activities from the last 3 months.",
"time": "Time",
"event": "Event",
"approximate_location": "Approximate Location",
"ip_address": "IP Address",
"device": "Device",
"client": "Client",
"unknown": "Unknown",
"account_details_updated_successfully": "Account details updated successfully",
"profile_picture_updated_successfully": "Profile picture updated successfully. It may take a few minutes to update.",
"account_settings": "Account Settings",
"passkey_missing": "Passkey missing",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Please add a passkey to prevent losing access to your account.",
"single_passkey_configured": "Single Passkey Configured",
"it_is_recommended_to_add_more_than_one_passkey": "It is recommended to add more than one passkey to avoid losing access to your account.",
"account_details": "Account Details",
"passkeys": "Passkeys",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Manage your passkeys that you can use to authenticate yourself.",
"add_passkey": "Add Passkey",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Create a one-time login code to sign in from a different device without a passkey.",
"create": "Create",
"first_name": "First name",
"last_name": "Last name",
"username": "Username",
"save": "Save",
"username_can_only_contain": "Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols",
"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",
"added_on": "Added on",
"rename": "Rename",
"delete": "Delete",
"are_you_sure_you_want_to_delete_this_passkey": "Are you sure you want to delete this passkey?",
"passkey_deleted_successfully": "Passkey deleted successfully",
"delete_passkey_name": "Delete {passkeyName}",
"passkey_name_updated_successfully": "Passkey name updated successfully",
"name_passkey": "Name Passkey",
"name_your_passkey_to_easily_identify_it_later": "Name your passkey to easily identify it later.",
"create_api_key": "Create API Key",
"add_a_new_api_key_for_programmatic_access": "Add a new API key for programmatic access.",
"add_api_key": "Add API Key",
"manage_api_keys": "Manage API Keys",
"api_key_created": "API Key Created",
"for_security_reasons_this_key_will_only_be_shown_once": "For security reasons, this key will only be shown once. Please store it securely.",
"description": "Description",
"api_key": "API Key",
"close": "Close",
"name_to_identify_this_api_key": "Name to identify this API key.",
"expires_at": "Expires At",
"when_this_api_key_will_expire": "When this API key will expire.",
"optional_description_to_help_identify_this_keys_purpose": "Optional description to help identify this key's purpose.",
"name_must_be_at_least_3_characters": "Name must be at least 3 characters",
"name_cannot_exceed_50_characters": "Name cannot exceed 50 characters",
"expiration_date_must_be_in_the_future": "Expiration date must be in the future",
"revoke_api_key": "Revoke API Key",
"never": "Never",
"revoke": "Revoke",
"api_key_revoked_successfully": "API key revoked successfully",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Are you sure you want to revoke the API key \"{apiKeyName}\"? This will break any integrations using this key.",
"last_used": "Last Used",
"actions": "Actions",
"images_updated_successfully": "Images updated successfully",
"general": "General",
"enable_email_notifications_to_alert_users_when_a_login_is_detected_from_a_new_device_or_location": "Enable email notifications to alert users when a login is detected from a new device or location.",
"ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configure LDAP settings to sync users and groups from an LDAP server.",
"images": "Images",
"update": "Update",
"email_configuration_updated_successfully": "Email configuration updated successfully",
"save_changes_question": "Save changes?",
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "You have to save the changes before sending a test email. Do you want to save now?",
"save_and_send": "Save and send",
"test_email_sent_successfully": "Test email sent successfully to your email address.",
"failed_to_send_test_email": "Failed to send test email. Check the server logs for more information.",
"smtp_configuration": "SMTP Configuration",
"smtp_host": "SMTP Host",
"smtp_port": "SMTP Port",
"smtp_user": "SMTP User",
"smtp_password": "SMTP Password",
"smtp_from": "SMTP From",
"smtp_tls_option": "SMTP TLS Option",
"email_tls_option": "Email TLS Option",
"skip_certificate_verification": "Skip Certificate Verification",
"this_can_be_useful_for_selfsigned_certificates": "This can be useful for self-signed certificates.",
"enabled_emails": "Enabled Emails",
"email_login_notification": "Email Login Notification",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Send an email to the user when they log in from a new device.",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to sign in with a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
"send_test_email": "Send test email",
"application_configuration_updated_successfully": "Application configuration updated successfully",
"application_name": "Application Name",
"session_duration": "Session Duration",
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "The duration of a session in minutes before the user has to sign in again.",
"enable_self_account_editing": "Enable Self-Account Editing",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Whether the users should be able to edit their own account details.",
"emails_verified": "Emails Verified",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Whether the user's email should be marked as verified for the OIDC clients.",
"ldap_configuration_updated_successfully": "LDAP configuration updated successfully",
"ldap_disabled_successfully": "LDAP disabled successfully",
"ldap_sync_finished": "LDAP sync finished",
"client_configuration": "Client Configuration",
"ldap_url": "LDAP URL",
"ldap_bind_dn": "LDAP Bind DN",
"ldap_bind_password": "LDAP Bind Password",
"ldap_base_dn": "LDAP Base DN",
"user_search_filter": "User Search Filter",
"the_search_filter_to_use_to_search_or_sync_users": "The Search filter to use to search/sync users.",
"groups_search_filter": "Groups Search Filter",
"the_search_filter_to_use_to_search_or_sync_groups": "The Search filter to use to search/sync groups.",
"attribute_mapping": "Attribute Mapping",
"user_unique_identifier_attribute": "User Unique Identifier Attribute",
"the_value_of_this_attribute_should_never_change": "The value of this attribute should never change.",
"username_attribute": "Username Attribute",
"user_mail_attribute": "User Mail Attribute",
"user_first_name_attribute": "User First Name Attribute",
"user_last_name_attribute": "User Last Name Attribute",
"user_profile_picture_attribute": "User Profile Picture Attribute",
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "The value of this attribute can either be a URL, a binary or a base64 encoded image.",
"group_members_attribute": "Group Members Attribute",
"the_attribute_to_use_for_querying_members_of_a_group": "The attribute to use for querying members of a group.",
"group_unique_identifier_attribute": "Group Unique Identifier Attribute",
"group_name_attribute": "Group Name Attribute",
"admin_group_name": "Admin Group Name",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Members of this group will have Admin Privileges in Pocket ID.",
"disable": "Disable",
"sync_now": "Sync now",
"enable": "Enable",
"user_created_successfully": "User created successfully",
"create_user": "Create User",
"add_a_new_user_to_appname": "Add a new user to {appName}",
"add_user": "Add User",
"manage_users": "Manage Users",
"admin_privileges": "Admin Privileges",
"admins_have_full_access_to_the_admin_panel": "Admins have full access to the admin panel.",
"delete_firstname_lastname": "Delete {firstName} {lastName}",
"are_you_sure_you_want_to_delete_this_user": "Are you sure you want to delete this user?",
"user_deleted_successfully": "User deleted successfully",
"role": "Role",
"source": "Source",
"admin": "Admin",
"user": "User",
"local": "Local",
"toggle_menu": "Toggle menu",
"edit": "Edit",
"user_groups_updated_successfully": "User groups updated successfully",
"user_updated_successfully": "User updated successfully",
"custom_claims_updated_successfully": "Custom claims updated successfully",
"back": "Back",
"user_details_firstname_lastname": "User Details {firstName} {lastName}",
"manage_which_groups_this_user_belongs_to": "Manage which groups this user belongs to.",
"custom_claims": "Custom Claims",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested.",
"user_group_created_successfully": "User group created successfully",
"create_user_group": "Create User Group",
"create_a_new_group_that_can_be_assigned_to_users": "Create a new group that can be assigned to users.",
"add_group": "Add Group",
"manage_user_groups": "Manage User Groups",
"friendly_name": "Friendly Name",
"name_that_will_be_displayed_in_the_ui": "Name that will be displayed in the UI",
"name_that_will_be_in_the_groups_claim": "Name that will be in the \"groups\" claim",
"delete_name": "Delete {name}",
"are_you_sure_you_want_to_delete_this_user_group": "Are you sure you want to delete this user group?",
"user_group_deleted_successfully": "User group deleted successfully",
"user_count": "User Count",
"user_group_updated_successfully": "User group updated successfully",
"users_updated_successfully": "Users updated successfully",
"user_group_details_name": "User Group Details {name}",
"assign_users_to_this_group": "Assign users to this group.",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested. Custom claims defined on the user will be prioritized if there are conflicts.",
"oidc_client_created_successfully": "OIDC client created successfully",
"create_oidc_client": "Create OIDC Client",
"add_a_new_oidc_client_to_appname": "Add a new OIDC client to {appName}.",
"add_oidc_client": "Add OIDC Client",
"manage_oidc_clients": "Manage OIDC Clients",
"one_time_link": "One Time Link",
"use_this_link_to_sign_in_once": "Use this link to sign in once. This is needed for users who haven't added a passkey yet or\n\t\t\t\thave lost it.",
"add": "Add",
"callback_urls": "Callback URLs",
"logout_callback_urls": "Logout Callback URLs",
"public_client": "Public Client",
"public_clients_do_not_have_a_client_secret_and_use_pkce_instead": "Public clients do not have a client secret and use PKCE instead. Enable this if your client is a SPA or mobile app.",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange is a security feature to prevent CSRF and authorization code interception attacks.",
"name_logo": "{name} logo",
"change_logo": "Change Logo",
"upload_logo": "Upload Logo",
"remove_logo": "Remove Logo",
"are_you_sure_you_want_to_delete_this_oidc_client": "Are you sure you want to delete this OIDC client?",
"oidc_client_deleted_successfully": "OIDC client deleted successfully",
"authorization_url": "Authorization URL",
"oidc_discovery_url": "OIDC Discovery URL",
"token_url": "Token URL",
"userinfo_url": "Userinfo URL",
"logout_url": "Logout URL",
"certificate_url": "Certificate URL",
"enabled": "Enabled",
"disabled": "Disabled",
"oidc_client_updated_successfully": "OIDC client updated successfully",
"create_new_client_secret": "Create new client secret",
"are_you_sure_you_want_to_create_a_new_client_secret": "Are you sure you want to create a new client secret? The old one will be invalidated.",
"generate": "Generate",
"new_client_secret_created_successfully": "New client secret created successfully",
"allowed_user_groups_updated_successfully": "Allowed user groups updated successfully",
"oidc_client_name": "OIDC Client {name}",
"client_id": "Client ID",
"client_secret": "Client secret",
"show_more_details": "Show more details",
"allowed_user_groups": "Allowed User Groups",
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Add user groups to this client to restrict access to users in these groups. If no user groups are selected, all users will have access to this client.",
"favicon": "Favicon",
"light_mode_logo": "Light Mode Logo",
"dark_mode_logo": "Dark Mode Logo",
"background_image": "Background Image",
"language": "Language",
"reset_profile_picture_question": "Reset profile picture?",
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "This will remove the uploaded image, and reset the profile picture to default. Do you want to continue?",
"reset": "Reset",
"reset_to_default": "Reset to default",
"profile_picture_has_been_reset": "Profile picture has been reset. It may take a few minutes to update.",
"select_the_language_you_want_to_use": "Select the language you want to use. Some languages may not be fully translated."
}

View File

@@ -0,0 +1,311 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"my_account": "Mijn Account",
"logout": "Uitloggen",
"confirm": "Bevestigen",
"key": "Sleutel",
"value": "Waarde",
"remove_custom_claim": "Aangepaste claim verwijderen",
"add_custom_claim": "Aangepaste claim toevoegen",
"add_another": "Voeg nog een toe",
"select_a_date": "Selecteer een datum",
"select_file": "Selecteer bestand",
"profile_picture": "Profielfoto",
"profile_picture_is_managed_by_ldap_server": "De profielfoto wordt beheerd door de LDAP-server en kan hier niet worden gewijzigd.",
"click_profile_picture_to_upload_custom": "Klik op de profielfoto om een aangepaste foto uit uw bestanden te uploaden.",
"image_should_be_in_format": "De afbeelding moet in PNG- of JPEG-formaat zijn.",
"items_per_page": "Aantal per pagina",
"no_items_found": "Geen items gevonden",
"search": "Zoekopdracht...",
"expand_card": "Kaart uitbreiden",
"copied": "Gekopieerd",
"click_to_copy": "Klik om te kopiëren",
"something_went_wrong": "Er is iets misgegaan",
"go_back_to_home": "Ga terug naar huis",
"dont_have_access_to_your_passkey": "Hebt u geen toegang tot uw toegangscode?",
"login_background": "Inlogachtergrond",
"logo": "Logo",
"login_code": "Inlogcode",
"create_a_login_code_to_sign_in_without_a_passkey_once": "Maak een inlogcode aan waarmee de gebruiker zich eenmalig kan aanmelden zonder passkey.",
"one_hour": "1 uur",
"twelve_hours": "12 uur",
"one_day": "1 dag",
"one_week": "1 week",
"one_month": "1 maand",
"expiration": "Vervaldatum",
"generate_code": "Genereer code",
"name": "Naam",
"browser_unsupported": "Browser niet ondersteund",
"this_browser_does_not_support_passkeys": "Deze browser ondersteunt geen passkeys. Gebruik een alternatieve aanmeldmethode.",
"an_unknown_error_occurred": "Er is een onbekende fout opgetreden",
"authentication_process_was_aborted": "Het authenticatieproces is afgebroken",
"error_occurred_with_authenticator": "Er is een fout opgetreden met de authenticator",
"authenticator_does_not_support_discoverable_credentials": "De authenticator ondersteunt geen vindbare referenties",
"authenticator_does_not_support_resident_keys": "De authenticator ondersteunt geen residente sleutels",
"passkey_was_previously_registered": "Deze toegangscode is eerder geregistreerd",
"authenticator_does_not_support_any_of_the_requested_algorithms": "De authenticator ondersteunt geen van de gevraagde algoritmen",
"authenticator_timed_out": "De authenticator is verlopen",
"critical_error_occurred_contact_administrator": "Er is een kritieke fout opgetreden. Neem contact op met uw beheerder.",
"sign_in_to": "Meld u aan bij {name}",
"client_not_found": "Client niet gevonden",
"client_wants_to_access_the_following_information": "<b>{client}</b> wil toegang tot de volgende informatie:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Wilt u zich aanmelden bij <b>{client}</b> met uw <b>{appName}</b> account?",
"email": "E-mail",
"view_your_email_address": "Bekijk uw e-mailadres",
"profile": "Profiel",
"view_your_profile_information": "Bekijk uw profielgegevens",
"groups": "Groepen",
"view_the_groups_you_are_a_member_of": "Bekijk de groepen waarvan u lid bent",
"cancel": "Annuleren",
"sign_in": "Aanmelden",
"try_again": "Probeer het opnieuw",
"client_logo": "Client logo",
"sign_out": "Afmelden",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Wilt u zich afmelden bij Pocket ID met het account <b>{username}</b> ?",
"sign_in_to_appname": "Meld u aan bij {appName}",
"please_try_to_sign_in_again": "Probeer opnieuw in te loggen.",
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Verifieer uzelf met uw toegangscode om toegang te krijgen tot het beheerderspaneel.",
"authenticate": "Authenticeren",
"appname_setup": "{appName} Instellen",
"please_try_again": "Probeer het opnieuw.",
"you_are_about_to_sign_in_to_the_initial_admin_account": "U staat op het punt om in te loggen op het oorspronkelijke beheerdersaccount. Iedereen met deze link heeft toegang tot het account totdat er een passkey is toegevoegd. Stel zo snel mogelijk een passkey in om ongeautoriseerde toegang te voorkomen.",
"continue": "Doorgaan",
"alternative_sign_in": "Alternatieve aanmelding",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Als u geen toegang hebt tot uw toegangscode, kunt u zich op een van de volgende manieren aanmelden.",
"use_your_passkey_instead": "Wilt u in plaats daarvan uw toegangscode gebruiken?",
"email_login": "E-mail inloggen",
"enter_a_login_code_to_sign_in": "Voer een inlogcode in om in te loggen.",
"request_a_login_code_via_email": "Vraag een inlogcode aan via e-mail.",
"go_back": "Ga terug",
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "Er is een e-mail verzonden naar het opgegeven e-mailadres, indien dit in het systeem voorkomt.",
"enter_code": "Voer code in",
"enter_your_email_address_to_receive_an_email_with_a_login_code": "Voer uw e-mailadres in om een e-mail met een inlogcode te ontvangen.",
"your_email": "Uw e-mail",
"submit": "Indienen",
"enter_the_code_you_received_to_sign_in": "Voer de code in die u hebt ontvangen om in te loggen.",
"code": "Code",
"invalid_redirect_url": "Ongeldige omleidings-URL",
"audit_log": "Audit logboek",
"users": "Gebruikers",
"user_groups": "Gebruikersgroepen",
"oidc_clients": "OIDC-clients",
"api_keys": "API-sleutels",
"application_configuration": "Toepassingsconfiguratie",
"settings": "Instellingen",
"update_pocket_id": "Pocket-ID bijwerken",
"powered_by": "Aangedreven door",
"see_your_account_activities_from_the_last_3_months": "Bekijk uw accountactiviteiten van de afgelopen 3 maanden.",
"time": "Tijd",
"event": "Evenement",
"approximate_location": "Geschatte locatie",
"ip_address": "IP-adres",
"device": "Apparaat",
"client": "Cliënt",
"unknown": "Onbekend",
"account_details_updated_successfully": "Accountgegevens succesvol bijgewerkt",
"profile_picture_updated_successfully": "Profielfoto succesvol bijgewerkt. Het kan enkele minuten duren voordat de wijzigingen zichtbaar zijn.",
"account_settings": "Accountinstellingen",
"passkey_missing": "Passkey ontbreekt",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Voeg een passkey toe om te voorkomen dat u de toegang tot uw account verliest.",
"single_passkey_configured": "Eén enkele toegangscode geconfigureerd",
"it_is_recommended_to_add_more_than_one_passkey": "Het is raadzaam om meer dan één toegangscode toe te voegen om te voorkomen dat u de toegang tot uw account verliest.",
"account_details": "Accountgegevens",
"passkeys": "Toegangscodes",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Beheer de toegangscodes waarmee u uzelf kunt verifiëren.",
"add_passkey": "Passkey toevoegen",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Maak een eenmalige inlogcode aan om in te loggen vanaf een ander apparaat zonder passkey.",
"create": "Creëren",
"first_name": "Voornaam",
"last_name": "Achternaam",
"username": "Gebruikersnaam",
"save": "Opslaan",
"username_can_only_contain": "Gebruikersnaam mag alleen kleine letters, cijfers, onderstrepingstekens, punten, koppeltekens en '@'-symbolen bevatten",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Meld u aan met de volgende code. De code verloopt over 15 minuten.",
"or_visit": "of bezoek",
"added_on": "Toegevoegd op",
"rename": "Hernoemen",
"delete": "Verwijderen",
"are_you_sure_you_want_to_delete_this_passkey": "Weet u zeker dat u deze toegangscode wilt verwijderen?",
"passkey_deleted_successfully": "Passkey succesvol verwijderd",
"delete_passkey_name": "Verwijder {passkeyName}",
"passkey_name_updated_successfully": "Passkey naam succesvol bijgewerkt",
"name_passkey": "Naam Passkey",
"name_your_passkey_to_easily_identify_it_later": "Geef uw toegangscode een naam, zodat u deze later gemakkelijk kunt terugvinden.",
"create_api_key": "API-sleutel aanmaken",
"add_a_new_api_key_for_programmatic_access": "Voeg een nieuwe API-sleutel toe voor programmatische toegang.",
"add_api_key": "API-sleutel toevoegen",
"manage_api_keys": "API-sleutels beheren",
"api_key_created": "API-sleutel gemaakt",
"for_security_reasons_this_key_will_only_be_shown_once": "Om veiligheidsredenen wordt deze sleutel slechts één keer getoond. Bewaar hem veilig.",
"description": "Beschrijving",
"api_key": "API-sleutel",
"close": "Dichtbij",
"name_to_identify_this_api_key": "Naam om deze API-sleutel te identificeren.",
"expires_at": "Verloopt op",
"when_this_api_key_will_expire": "Wanneer deze API-sleutel verloopt.",
"optional_description_to_help_identify_this_keys_purpose": "Optionele beschrijving om het doel van deze sleutel te helpen identificeren.",
"name_must_be_at_least_3_characters": "Naam moet minimaal 3 tekens lang zijn",
"name_cannot_exceed_50_characters": "Naam mag niet langer zijn dan 50 tekens",
"expiration_date_must_be_in_the_future": "Vervaldatum moet in de toekomst liggen",
"revoke_api_key": "API-sleutel intrekken",
"never": "Nooit",
"revoke": "Herroepen",
"api_key_revoked_successfully": "API-sleutel succesvol ingetrokken",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Weet u zeker dat u de API-sleutel \" {apiKeyName} \" wilt intrekken? Hiermee worden alle integraties die deze sleutel gebruiken, verbroken.",
"last_used": "Laatst gebruikt",
"actions": "Acties",
"images_updated_successfully": "Afbeeldingen succesvol bijgewerkt",
"general": "Algemeen",
"enable_email_notifications_to_alert_users_when_a_login_is_detected_from_a_new_device_or_location": "Schakel e-mailmeldingen in om gebruikers te waarschuwen wanneer er wordt ingelogd vanaf een nieuw apparaat of een nieuwe locatie.",
"ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configureer LDAP-instellingen om gebruikers en groepen vanaf een LDAP-server te synchroniseren.",
"images": "Afbeeldingen",
"update": "Update",
"email_configuration_updated_successfully": "E-mailconfiguratie succesvol bijgewerkt",
"save_changes_question": "Wijzigingen opslaan?",
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "U moet de wijzigingen opslaan voordat u een test-e-mail verzendt. Wilt u nu opslaan?",
"save_and_send": "Opslaan en verzenden",
"test_email_sent_successfully": "Test-e-mail succesvol verzonden naar uw e-mailadres.",
"failed_to_send_test_email": "Het is niet gelukt om een test-e-mail te versturen. Controleer de serverlogs voor meer informatie.",
"smtp_configuration": "SMTP-configuratie",
"smtp_host": "SMTP-host",
"smtp_port": "SMTP-poort",
"smtp_user": "SMTP-gebruiker",
"smtp_password": "SMTP-wachtwoord",
"smtp_from": "SMTP van",
"smtp_tls_option": "SMTP TLS-optie",
"email_tls_option": "E-mail TLS-optie",
"skip_certificate_verification": "Certificaatverificatie overslaan",
"this_can_be_useful_for_selfsigned_certificates": "Dit kan handig zijn voor zelfondertekende certificaten.",
"enabled_emails": "Ingeschakelde e-mails",
"email_login_notification": "E-mail-inlogmelding",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Stuur een e-mail naar de gebruiker wanneer deze zich aanmeldt vanaf een nieuw apparaat.",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Hiermee kunnen gebruikers inloggen met een inlogcode die naar hun e-mail is gestuurd. Dit vermindert de beveiliging aanzienlijk, omdat iedereen met toegang tot de e-mail van de gebruiker toegang kan krijgen.",
"send_test_email": "Test-e-mail verzenden",
"application_configuration_updated_successfully": "Toepassingsconfiguratie succesvol bijgewerkt",
"application_name": "Toepassingsnaam",
"session_duration": "Sessieduur",
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "De duur van een sessie in minuten voordat de gebruiker zich opnieuw moet aanmelden.",
"enable_self_account_editing": "Zelf-accountbewerking inschakelen",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Of gebruikers hun eigen accountgegevens moeten kunnen bewerken.",
"emails_verified": "E-mails geverifieerd",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Of het e-mailadres van de gebruiker als geverifieerd moet worden gemarkeerd voor de OIDC-clients.",
"ldap_configuration_updated_successfully": "LDAP-configuratie succesvol bijgewerkt",
"ldap_disabled_successfully": "LDAP succesvol uitgeschakeld",
"ldap_sync_finished": "LDAP-synchronisatie voltooid",
"client_configuration": "Clientconfiguratie",
"ldap_url": "LDAP-URL",
"ldap_bind_dn": "LDAP Bind-DN",
"ldap_bind_password": "LDAP Bind-wachtwoord",
"ldap_base_dn": "LDAP-basis-DN",
"user_search_filter": "Gebruikerszoekfilter",
"the_search_filter_to_use_to_search_or_sync_users": "Het zoekfilter waarmee u gebruikers kunt zoeken/synchroniseren.",
"groups_search_filter": "Groepen Zoekfilter",
"the_search_filter_to_use_to_search_or_sync_groups": "Het zoekfilter waarmee u groepen kunt zoeken/synchroniseren.",
"attribute_mapping": "Attribuuttoewijzing",
"user_unique_identifier_attribute": "Gebruiker uniek identificatiekenmerk",
"the_value_of_this_attribute_should_never_change": "De waarde van dit kenmerk mag nooit veranderen.",
"username_attribute": "Gebruikersnaam Attribuut",
"user_mail_attribute": "Gebruikersmailkenmerk",
"user_first_name_attribute": "Gebruikersvoornaam Attribuut",
"user_last_name_attribute": "Gebruikersnaam Achternaam Attribuut",
"user_profile_picture_attribute": "Gebruikersprofielfoto-attribuut",
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "De waarde van dit kenmerk kan een URL, een binair bestand of een base64-gecodeerde afbeelding zijn.",
"group_members_attribute": "Groepsleden Attribuut",
"the_attribute_to_use_for_querying_members_of_a_group": "Het kenmerk dat gebruikt moet worden om leden van een groep te bevragen.",
"group_unique_identifier_attribute": "Groeps uniek identificatiekenmerk",
"group_name_attribute": "Groepsnaam Attribuut",
"admin_group_name": "Naam van beheerdersgroep",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Leden van deze groep hebben beheerdersrechten in Pocket ID.",
"disable": "Uitzetten",
"sync_now": "Nu synchroniseren",
"enable": "Inschakelen",
"user_created_successfully": "Gebruiker succesvol aangemaakt",
"create_user": "Gebruiker aanmaken",
"add_a_new_user_to_appname": "Voeg een nieuwe gebruiker toe aan {appName}",
"add_user": "Gebruiker toevoegen",
"manage_users": "Gebruikers beheren",
"admin_privileges": "Beheerdersrechten",
"admins_have_full_access_to_the_admin_panel": "Beheerders hebben volledige toegang tot het beheerderspaneel.",
"delete_firstname_lastname": "Verwijderen {firstName} {lastName}",
"are_you_sure_you_want_to_delete_this_user": "Weet u zeker dat u deze gebruiker wilt verwijderen?",
"user_deleted_successfully": "Gebruiker succesvol verwijderd",
"role": "Rol",
"source": "Bron",
"admin": "Beheerder",
"user": "Gebruiker",
"local": "Lokaal",
"toggle_menu": "Menu wisselen",
"edit": "Bewerking",
"user_groups_updated_successfully": "Gebruikersgroepen succesvol bijgewerkt",
"user_updated_successfully": "Gebruiker succesvol bijgewerkt",
"custom_claims_updated_successfully": "Aangepaste claims succesvol bijgewerkt",
"back": "Terug",
"user_details_firstname_lastname": "Gebruikersgegevens {firstName} {lastName}",
"manage_which_groups_this_user_belongs_to": "Beheer tot welke groepen deze gebruiker behoort.",
"custom_claims": "Aangepaste claims",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Aangepaste claims zijn sleutel-waardeparen die kunnen worden gebruikt om aanvullende informatie over een gebruiker op te slaan. Deze claims worden opgenomen in het ID-token als de scope 'profile' wordt aangevraagd.",
"user_group_created_successfully": "Gebruikersgroep succesvol aangemaakt",
"create_user_group": "Gebruikersgroep aanmaken",
"create_a_new_group_that_can_be_assigned_to_users": "Maak een nieuwe groep aan die aan gebruikers kan worden toegewezen.",
"add_group": "Groep toevoegen",
"manage_user_groups": "Gebruikersgroepen beheren",
"friendly_name": "Vriendelijke naam",
"name_that_will_be_displayed_in_the_ui": "Naam die in de gebruikersinterface wordt weergegeven",
"name_that_will_be_in_the_groups_claim": "Naam die in de claim 'groepen' zal staan",
"delete_name": "Verwijder {name}",
"are_you_sure_you_want_to_delete_this_user_group": "Weet u zeker dat u deze gebruikersgroep wilt verwijderen?",
"user_group_deleted_successfully": "Gebruikersgroep succesvol verwijderd",
"user_count": "Gebruikersaantal",
"user_group_updated_successfully": "Gebruikersgroep succesvol bijgewerkt",
"users_updated_successfully": "Gebruikers succesvol bijgewerkt",
"user_group_details_name": "Gebruikersgroepdetails {name}",
"assign_users_to_this_group": "Gebruikers aan deze groep toewijzen.",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Aangepaste claims zijn sleutel-waardeparen die kunnen worden gebruikt om aanvullende informatie over een gebruiker op te slaan. Deze claims worden opgenomen in het ID-token als de scope 'profile' wordt opgevraagd. Aangepaste claims die zijn gedefinieerd voor de gebruiker, krijgen prioriteit als er conflicten zijn.",
"oidc_client_created_successfully": "OIDC-client succesvol aangemaakt",
"create_oidc_client": "Maak een OIDC-client",
"add_a_new_oidc_client_to_appname": "Voeg een nieuwe OIDC-client toe aan {appName} .",
"add_oidc_client": "OIDC-client toevoegen",
"manage_oidc_clients": "OIDC-clients beheren",
"one_time_link": "Eenmalige link",
"use_this_link_to_sign_in_once": "Gebruik deze link om u eenmalig aan te melden. Dit is nodig voor gebruikers die nog geen passkey hebben toegevoegd of\nben het kwijt.",
"add": "Toevoegen",
"callback_urls": "Callback-URL's",
"logout_callback_urls": "Callback-URL's voor afmelden",
"public_client": "Publieke client",
"public_clients_do_not_have_a_client_secret_and_use_pkce_instead": "Publieke clients hebben geen client secret en gebruiken in plaats daarvan PKCE. Schakel dit in als uw client een SPA of mobiele app is.",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange is een beveiligingsfunctie om CSRF- en autorisatiecode-onderscheppingsaanvallen te voorkomen.",
"name_logo": "{name} logo",
"change_logo": "Logo wijzigen",
"upload_logo": "Logo uploaden",
"remove_logo": "Logo verwijderen",
"are_you_sure_you_want_to_delete_this_oidc_client": "Weet u zeker dat u deze OIDC-client wilt verwijderen?",
"oidc_client_deleted_successfully": "OIDC-client succesvol verwijderd",
"authorization_url": "Autorisatie-URL",
"oidc_discovery_url": "OIDC-ontdekkings-URL",
"token_url": "Token-URL",
"userinfo_url": "Gebruikersinfo-URL",
"logout_url": "Uitlog-URL",
"certificate_url": "Certificaat-URL",
"enabled": "Ingeschakeld",
"disabled": "Uitgeschakeld",
"oidc_client_updated_successfully": "OIDC-client succesvol bijgewerkt",
"create_new_client_secret": "Nieuw clientgeheim aanmaken",
"are_you_sure_you_want_to_create_a_new_client_secret": "Weet u zeker dat u een nieuw client secret wilt aanmaken? De oude wordt ongeldig.",
"generate": "Genereren",
"new_client_secret_created_successfully": "Nieuw clientgeheim succesvol aangemaakt",
"allowed_user_groups_updated_successfully": "Toegestane gebruikersgroepen succesvol bijgewerkt",
"oidc_client_name": "OIDC-client {name}",
"client_id": "Client id",
"client_secret": "Client geheim",
"show_more_details": "Meer details weergeven",
"allowed_user_groups": "Toegestane gebruikersgroepen",
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Voeg gebruikersgroepen toe aan deze client om de toegang tot gebruikers in deze groepen te beperken. Als er geen gebruikersgroepen zijn geselecteerd, hebben alle gebruikers toegang tot deze client.",
"favicon": "Favicon",
"light_mode_logo": "Lichte modus logo",
"dark_mode_logo": "Donkere modus logo",
"background_image": "Achtergrondfoto",
"language": "Taal",
"profile_picture_has_been_reset": "Profielfoto is gereset. Het kan enkele minuten duren voordat de wijzigingen zichtbaar zijn."
}

View File

@@ -1,12 +1,12 @@
{
"name": "pocket-id-frontend",
"version": "0.39.0",
"version": "0.42.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pocket-id-frontend",
"version": "0.39.0",
"version": "0.42.1",
"dependencies": {
"@simplewebauthn/browser": "^13.1.0",
"@tailwindcss/vite": "^4.0.0",
@@ -25,6 +25,7 @@
"zod": "^3.24.1"
},
"devDependencies": {
"@inlang/paraglide-js": "^2.0.0",
"@internationalized/date": "^3.7.0",
"@playwright/test": "^1.50.0",
"@sveltejs/adapter-auto": "^4.0.0",
@@ -747,6 +748,78 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@inlang/paraglide-js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@inlang/paraglide-js/-/paraglide-js-2.0.0.tgz",
"integrity": "sha512-ufe/k4tfBIQrJf6X1L+KGtvHYRhvDPX53m7vVe+IOYs0DkyR7RkBgwPBQb3kbXKpr5atCD+D2BDh/I7EpK5Clg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@inlang/recommend-sherlock": "0.2.1",
"@inlang/sdk": "2.4.2",
"commander": "11.1.0",
"consola": "3.4.0",
"json5": "2.2.3",
"unplugin": "^2.1.2",
"urlpattern-polyfill": "^10.0.0"
},
"bin": {
"paraglide-js": "bin/run.js"
}
},
"node_modules/@inlang/paraglide-js/node_modules/@inlang/sdk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@inlang/sdk/-/sdk-2.4.2.tgz",
"integrity": "sha512-EqL32PcFHOlXWEg2o0nftSBZ376tSxuAhV8uTZoaq521AKSRMEvjTpVsJ9eS6ZJDCRiIXx7avtsdVNwkUntf8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lix-js/sdk": "0.4.2",
"@sinclair/typebox": "^0.31.17",
"kysely": "^0.27.4",
"sqlite-wasm-kysely": "0.3.0",
"uuid": "^10.0.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@inlang/paraglide-js/node_modules/@lix-js/sdk": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@lix-js/sdk/-/sdk-0.4.2.tgz",
"integrity": "sha512-wrQQMAZzOxQEAssxUnajn7Djua98MlIzs+V6GdX51VN6b7iA3qvZJY4L9xEEMh0nRFvpAO3wOt7uBth9580pog==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@lix-js/server-api-schema": "0.1.1",
"dedent": "1.5.1",
"human-id": "^4.1.1",
"js-sha256": "^0.11.0",
"kysely": "^0.27.4",
"sqlite-wasm-kysely": "0.3.0",
"uuid": "^10.0.0"
},
"engines": {
"node": ">=21"
}
},
"node_modules/@inlang/paraglide-js/node_modules/@sinclair/typebox": {
"version": "0.31.28",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.31.28.tgz",
"integrity": "sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@inlang/recommend-sherlock": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@inlang/recommend-sherlock/-/recommend-sherlock-0.2.1.tgz",
"integrity": "sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg==",
"dev": true,
"license": "MIT",
"dependencies": {
"comment-json": "^4.2.3"
}
},
"node_modules/@internationalized/date": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.7.0.tgz",
@@ -798,6 +871,13 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lix-js/server-api-schema": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@lix-js/server-api-schema/-/server-api-schema-0.1.1.tgz",
"integrity": "sha512-W1Z7KKOxAQ4Dag9V2wrDevHPh5rPk+icBUsxNfNCNB2tlPrKpba99562vcTCPoT03KXpihEbWutZNujCRtMA+g==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1214,6 +1294,16 @@
"integrity": "sha512-TJ7Al17j3+by5y2QkTLcF/oBVMbgXBhILVgi9PuwpxQVZZvGh5BFRzWbJPmZVNKpbRLjuMzFuRwR+tdFPqCkvA==",
"optional": true
},
"node_modules/@sqlite.org/sqlite-wasm": {
"version": "3.48.0-build4",
"resolved": "https://registry.npmjs.org/@sqlite.org/sqlite-wasm/-/sqlite-wasm-3.48.0-build4.tgz",
"integrity": "sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"sqlite-wasm": "bin/index.js"
}
},
"node_modules/@sveltejs/adapter-auto": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-4.0.0.tgz",
@@ -1909,6 +1999,13 @@
"@ark/util": "0.38.0"
}
},
"node_modules/array-timsort": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz",
"integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==",
"dev": true,
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -2099,6 +2196,33 @@
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
"integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/comment-json": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.2.5.tgz",
"integrity": "sha512-bKw/r35jR3HGt5PEPm1ljsQQGyCrR8sFGNiN5L+ykDHdpO8Smxkrkla9Yi6NkQyUrb8V54PGhfMs6NrIwtxtdw==",
"dev": true,
"license": "MIT",
"dependencies": {
"array-timsort": "^1.0.3",
"core-util-is": "^1.0.3",
"esprima": "^4.0.1",
"has-own-prop": "^2.0.0",
"repeat-string": "^1.6.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/commondir": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
@@ -2111,6 +2235,16 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true
},
"node_modules/consola": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.0.tgz",
"integrity": "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.10.0"
}
},
"node_modules/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
@@ -2119,6 +2253,13 @@
"node": ">= 0.6"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"dev": true,
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2173,6 +2314,21 @@
}
}
},
"node_modules/dedent": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz",
"integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"babel-plugin-macros": "^3.1.0"
},
"peerDependenciesMeta": {
"babel-plugin-macros": {
"optional": true
}
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2490,6 +2646,20 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"dev": true,
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/esquery": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
@@ -2814,6 +2984,16 @@
"node": ">=8"
}
},
"node_modules/has-own-prop": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/has-own-prop/-/has-own-prop-2.0.0.tgz",
"integrity": "sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -2826,6 +3006,16 @@
"node": ">= 0.4"
}
},
"node_modules/human-id": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/human-id/-/human-id-4.1.1.tgz",
"integrity": "sha512-3gKm/gCSUipeLsRYZbbdA1BD83lBoWUkZ7G9VFrhWPAU76KwYo5KR8V28bpoPm/ygy0x5/GCbpRQdY7VLYCoIg==",
"dev": true,
"license": "MIT",
"bin": {
"human-id": "dist/cli.js"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2964,6 +3154,13 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-sha256": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.0.tgz",
"integrity": "sha512-6xNlKayMZvds9h1Y1VWc0fQHQ82BxTXizWPEtEeGvmOUYpBRy4gbWroHLpzowe6xiQhHpelCQiE7HEdznyBL9Q==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
@@ -3007,6 +3204,19 @@
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
"dev": true
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true,
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -3030,6 +3240,16 @@
"integrity": "sha512-a/RAk2BfKk+WFGhhOCAYqSiFLc34k8Mt/6NWRI4joER0EYUzXIcFivjjnoD3+XU1DggLn/tZc3DOAgke7l8a4A==",
"dev": true
},
"node_modules/kysely": {
"version": "0.27.6",
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.27.6.tgz",
"integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -3906,6 +4126,16 @@
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"optional": true
},
"node_modules/repeat-string": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz",
"integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10"
}
},
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -4094,6 +4324,18 @@
"source-map": "^0.6.0"
}
},
"node_modules/sqlite-wasm-kysely": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/sqlite-wasm-kysely/-/sqlite-wasm-kysely-0.3.0.tgz",
"integrity": "sha512-TzjBNv7KwRw6E3pdKdlRyZiTmUIE0UttT/Sl56MVwVARl/u5gp978KepazCJZewFUnlWHz9i3NQd4kOtP/Afdg==",
"dev": true,
"dependencies": {
"@sqlite.org/sqlite-wasm": "^3.48.0-build2"
},
"peerDependencies": {
"kysely": "*"
}
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -4548,6 +4790,20 @@
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"devOptional": true
},
"node_modules/unplugin": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.2.0.tgz",
"integrity": "sha512-m1ekpSwuOT5hxkJeZGRxO7gXbXT3gF26NjQ7GdVHoLoF8/nopLcd/QfPigpCy7i51oFHiRJg/CyHhj4vs2+KGw==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.14.0",
"webpack-virtual-modules": "^0.6.2"
},
"engines": {
"node": ">=18.12.0"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -4557,12 +4813,33 @@
"punycode": "^2.1.0"
}
},
"node_modules/urlpattern-polyfill": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz",
"integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==",
"dev": true,
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true
},
"node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/valibot": {
"version": "1.0.0-beta.11",
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.0.0-beta.11.tgz",
@@ -4687,6 +4964,13 @@
}
}
},
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"dev": true,
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "pocket-id-frontend",
"version": "0.40.1",
"version": "0.43.1",
"private": true,
"type": "module",
"scripts": {
@@ -30,6 +30,7 @@
"zod": "^3.24.1"
},
"devDependencies": {
"@inlang/paraglide-js": "^2.0.0",
"@internationalized/date": "^3.7.0",
"@playwright/test": "^1.50.0",
"@sveltejs/adapter-auto": "^4.0.0",

1
frontend/project.inlang/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
cache

View File

@@ -0,0 +1 @@
O2jvFph6P4Jpehf2BT

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en-US",
"locales": [
"en-US",
"nl-NL"
],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
}
}

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="%lang%">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/api/application-configuration/favicon" />

View File

@@ -1,6 +1,8 @@
import { env } from '$env/dynamic/private';
import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants';
import { paraglideMiddleware } from '$lib/paraglide/server';
import type { Handle, HandleServerError } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import { AxiosError } from 'axios';
import { decodeJwt } from 'jose';
@@ -9,7 +11,16 @@ import { decodeJwt } from 'jose';
// this is still secure as process will just be undefined in the browser
process.env.INTERNAL_BACKEND_URL = env.INTERNAL_BACKEND_URL ?? 'http://localhost:8080';
export const handle: Handle = async ({ event, resolve }) => {
// Handle to use the paraglide middleware
const paraglideHandle: Handle = ({ event, resolve }) => {
return paraglideMiddleware(event.request, ({ locale }) => {
return resolve(event, {
transformPageChunk: ({ html }) => html.replace('%lang%', locale)
});
});
};
const authenticationHandle: Handle = async ({ event, resolve }) => {
const { isSignedIn, isAdmin } = verifyJwt(event.cookies.get(ACCESS_TOKEN_COOKIE_NAME));
const isUnauthenticatedOnlyPath = event.url.pathname.startsWith('/login') || event.url.pathname.startsWith('/lc')
@@ -43,6 +54,8 @@ export const handle: Handle = async ({ event, resolve }) => {
return response;
};
export const handle: Handle = sequence(paraglideHandle, authenticationHandle);
export const handleError: HandleServerError = async ({ error, message, status }) => {
if (error instanceof AxiosError) {
message = error.response?.data.error || message;

View File

@@ -11,6 +11,7 @@
import { ChevronDown } from 'lucide-svelte';
import type { Snippet } from 'svelte';
import Button from './ui/button/button.svelte';
import { m } from '$lib/paraglide/messages';
let {
items,
@@ -93,7 +94,7 @@
'relative z-50 mb-4 max-w-sm',
items.data.length == 0 && searchValue == '' && 'hidden'
)}
placeholder={'Search...'}
placeholder={m.search()}
type="text"
oninput={(e) => onSearch((e.target as HTMLInputElement).value)}
/>
@@ -102,7 +103,7 @@
{#if items.data.length === 0 && searchValue === ''}
<div class="my-5 flex flex-col items-center">
<Empty class="text-muted-foreground h-20" />
<p class="text-muted-foreground mt-3 text-sm">No items found</p>
<p class="text-muted-foreground mt-3 text-sm">{m.no_items_found()}</p>
</div>
{:else}
<Table.Root class="min-w-full table-auto overflow-x-auto">
@@ -166,7 +167,7 @@
<div class="mt-5 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
<div class="flex items-center space-x-2">
<p class="text-sm font-medium">Items per page</p>
<p class="text-sm font-medium">{m.items_per_page()}</p>
<Select.Root
selected={{
label: items.pagination.itemsPerPage.toString(),

View File

@@ -5,6 +5,7 @@
import { slide } from 'svelte/transition';
import { Button } from './ui/button';
import * as Card from './ui/card';
import { m } from '$lib/paraglide/messages';
let {
id,
@@ -55,7 +56,7 @@
<Card.Description>{description}</Card.Description>
{/if}
</div>
<Button class="ml-10 h-8 p-3" variant="ghost" aria-label="Expand card">
<Button class="ml-10 h-8 p-3" variant="ghost" aria-label={m.expand_card()}>
<LucideChevronDown
class={cn(
'h-5 w-5 transition-transform duration-200',

View File

@@ -1,12 +1,13 @@
import { writable } from 'svelte/store';
import ConfirmDialog from './confirm-dialog.svelte';
import { m } from '$lib/paraglide/messages';
export const confirmDialogStore = writable({
open: false,
title: '',
message: '',
confirm: {
label: 'Confirm',
label: m.confirm(),
destructive: false,
action: () => {}
}

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip';
import { m } from '$lib/paraglide/messages';
import { LucideCheck } from 'lucide-svelte';
import type { Snippet } from 'svelte';
@@ -31,9 +32,9 @@
<Tooltip.Trigger class="text-start" tabindex={-1} onclick={onClick}>{@render children()}</Tooltip.Trigger>
<Tooltip.Content onclick={copyToClipboard}>
{#if copied}
<span class="flex items-center"><LucideCheck class="mr-1 h-4 w-4" /> Copied</span>
<span class="flex items-center"><LucideCheck class="mr-1 h-4 w-4" /> {m.copied()}</span>
{:else}
<span>Click to copy</span>
<span>{m.click_to_copy()}</span>
{/if}
</Tooltip.Content>
</Tooltip.Root>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import { LucideXCircle } from 'lucide-svelte';
let { message, showButton = true }: { message: string; showButton?: boolean } = $props();
@@ -7,9 +8,9 @@
<div class="mt-[20%] flex flex-col items-center">
<LucideXCircle class="h-12 w-12 text-muted-foreground" />
<h1 class="mt-3 text-2xl font-semibold">Something went wrong</h1>
<h1 class="mt-3 text-2xl font-semibold">{m.something_went_wrong()}</h1>
<p class="text-muted-foreground">{message}</p>
{#if showButton}
<Button size="sm" class="mt-5" href="/">Go back to home</Button>
<Button size="sm" class="mt-5" href="/">{m.go_back_to_home()}</Button>
{/if}
</div>

View File

@@ -8,6 +8,7 @@
import { onMount, type Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import AutoCompleteInput from './auto-complete-input.svelte';
import { m } from '$lib/paraglide/messages';
let {
customClaims = $bindable(),
@@ -41,15 +42,15 @@
{#each customClaims as _, i}
<div class="flex gap-x-2">
<AutoCompleteInput
placeholder="Key"
placeholder={m.key()}
suggestions={filteredSuggestions}
bind:value={customClaims[i].key}
/>
<Input placeholder="Value" bind:value={customClaims[i].value} />
<Input placeholder={m.value()} bind:value={customClaims[i].value} />
<Button
variant="outline"
size="sm"
aria-label="Remove custom claim"
aria-label={m.remove_custom_claim()}
on:click={() => (customClaims = customClaims.filter((_, index) => index !== i))}
>
<LucideMinus class="h-4 w-4" />
@@ -69,7 +70,7 @@
on:click={() => (customClaims = [...customClaims, { key: '', value: '' }])}
>
<LucidePlus class="mr-1 h-4 w-4" />
{customClaims.length === 0 ? 'Add custom claim' : 'Add another'}
{customClaims.length === 0 ? m.add_custom_claim() : m.add_another()}
</Button>
{/if}
</div>

View File

@@ -2,6 +2,8 @@
import { Button } from '$lib/components/ui/button';
import { Calendar } from '$lib/components/ui/calendar';
import * as Popover from '$lib/components/ui/popover';
import { m } from '$lib/paraglide/messages';
import { getLocale } from '$lib/paraglide/runtime';
import { cn } from '$lib/utils/style';
import {
CalendarDate,
@@ -30,7 +32,7 @@
open = false;
}
const df = new DateFormatter('en-US', {
const df = new DateFormatter(getLocale(), {
dateStyle: 'long'
});
</script>
@@ -44,7 +46,7 @@
builders={[builder]}
>
<CalendarIcon class="mr-2 h-4 w-4" />
{date ? df.format(date.toDate(getLocalTimeZone())) : 'Select a date'}
{date ? df.format(date.toDate(getLocalTimeZone())) : m.select_a_date()}
</Button>
</Popover.Trigger>
<Popover.Content class="w-auto p-0" align="start">

View File

@@ -3,6 +3,7 @@
import type { HTMLInputAttributes } from 'svelte/elements';
import type { VariantProps } from 'tailwind-variants';
import type { buttonVariants } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
let {
id,
@@ -21,7 +22,7 @@
{#if restProps.children}
{@render restProps.children()}
{:else}
Select File
{m.select_file()}
{/if}
</button>
<input {id} {...restProps} type="file" class="hidden" />

View File

@@ -1,20 +1,24 @@
<script lang="ts">
import FileInput from '$lib/components/form/file-input.svelte';
import * as Avatar from '$lib/components/ui/avatar';
import { LucideLoader, LucideUpload } from 'lucide-svelte';
import Button from '$lib/components/ui/button/button.svelte';
import { LucideLoader, LucideRefreshCw, LucideUpload } from 'lucide-svelte';
import { openConfirmDialog } from '../confirm-dialog';
import { m } from '$lib/paraglide/messages';
let {
userId,
isLdapUser = false,
callback
resetCallback,
updateCallback
}: {
userId: string;
isLdapUser?: boolean;
callback: (image: File) => Promise<void>;
resetCallback: () => Promise<void>;
updateCallback: (image: File) => Promise<void>;
} = $props();
let isLoading = $state(false);
let imageDataURL = $state(`/api/users/${userId}/profile-picture.png`);
async function onImageChange(e: Event) {
@@ -29,55 +33,84 @@
};
reader.readAsDataURL(file);
await callback(file).catch(() => {
imageDataURL = `/api/users/${userId}/profile-picture.png`;
await updateCallback(file).catch(() => {
imageDataURL = `/api/users/${userId}/profile-picture.png}`;
});
isLoading = false;
}
function onReset() {
openConfirmDialog({
title: m.reset_profile_picture_question(),
message: m.this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default(),
confirm: {
label: m.reset(),
action: async () => {
isLoading = true;
await resetCallback().catch();
isLoading = false;
}
}
});
}
</script>
<div class="flex gap-5">
<div class="flex w-full flex-col justify-between gap-5 sm:flex-row">
<div>
<h3 class="text-xl font-semibold">Profile Picture</h3>
<h3 class="text-xl font-semibold">{m.profile_picture()}</h3>
{#if isLdapUser}
<p class="text-muted-foreground mt-1 text-sm">
The profile picture is managed by the LDAP server and cannot be changed here.
{m.profile_picture_is_managed_by_ldap_server()}
</p>
{:else}
<p class="text-muted-foreground mt-1 text-sm">
Click on the profile picture to upload a custom one from your files.
{m.click_profile_picture_to_upload_custom()}
</p>
<p class="text-muted-foreground mt-1 text-sm">The image should be in PNG or JPEG format.</p>
<p class="text-muted-foreground mt-1 text-sm">{m.image_should_be_in_format()}</p>
{/if}
<Button
variant="outline"
size="sm"
class="mt-5"
on:click={onReset}
disabled={isLoading || isLdapUser}
>
<LucideRefreshCw class="mr-2 h-4 w-4" />
{m.reset_to_default()}
</Button>
</div>
{#if isLdapUser}
<Avatar.Root class="h-24 w-24">
<Avatar.Image class="object-cover" src={imageDataURL} />
</Avatar.Root>
{:else}
<FileInput
id="profile-picture-input"
variant="secondary"
accept="image/png, image/jpeg"
onchange={onImageChange}
>
<div class="group relative h-28 w-28 rounded-full">
<Avatar.Root class="h-full w-full transition-opacity duration-200">
<Avatar.Image
class="object-cover group-hover:opacity-10 {isLoading ? 'opacity-10' : ''}"
src={imageDataURL}
/>
</Avatar.Root>
<div class="absolute inset-0 flex items-center justify-center">
{#if isLoading}
<LucideLoader class="h-5 w-5 animate-spin" />
{:else}
<LucideUpload class="h-5 w-5 opacity-0 transition-opacity group-hover:opacity-100" />
{/if}
<div class="flex flex-col items-center gap-2">
<FileInput
id="profile-picture-input"
variant="secondary"
accept="image/png, image/jpeg"
onchange={onImageChange}
>
<div class="group relative h-28 w-28 rounded-full">
<Avatar.Root class="h-full w-full transition-opacity duration-200">
<Avatar.Image
class="object-cover group-hover:opacity-10 {isLoading ? 'opacity-10' : ''}"
src={imageDataURL}
/>
</Avatar.Root>
<div class="absolute inset-0 flex items-center justify-center">
{#if isLoading}
<LucideLoader class="h-5 w-5 animate-spin" />
{:else}
<LucideUpload
class="h-5 w-5 opacity-0 transition-opacity group-hover:opacity-100"
/>
{/if}
</div>
</div>
</div>
</FileInput>
</FileInput>
</div>
{/if}
</div>
</div>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import * as Avatar from '$lib/components/ui/avatar';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { m } from '$lib/paraglide/messages';
import WebAuthnService from '$lib/services/webauthn-service';
import userStore from '$lib/stores/user-store';
import { LucideLogOut, LucideUser } from 'lucide-svelte';
@@ -16,7 +17,7 @@
<DropdownMenu.Root>
<DropdownMenu.Trigger
><Avatar.Root class="h-9 w-9">
<Avatar.Image src="/api/users/me/profile-picture.png" />
<Avatar.Image src="/api/users/{$userStore?.id}/profile-picture.png" />
</Avatar.Root></DropdownMenu.Trigger
>
<DropdownMenu.Content class="min-w-40" align="start">
@@ -32,10 +33,10 @@
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item href="/settings/account"
><LucideUser class="mr-2 h-4 w-4" /> My Account</DropdownMenu.Item
><LucideUser class="mr-2 h-4 w-4" /> {m.my_account()}</DropdownMenu.Item
>
<DropdownMenu.Item on:click={logout}
><LucideLogOut class="mr-2 h-4 w-4" /> Logout</DropdownMenu.Item
><LucideLogOut class="mr-2 h-4 w-4" /> {m.logout()}</DropdownMenu.Item
>
</DropdownMenu.Group>
</DropdownMenu.Content>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { page } from '$app/stores';
import { page } from '$app/state';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import Logo from '../logo.svelte';
@@ -8,7 +8,7 @@
const authUrls = [/^\/authorize$/, /^\/login(?:\/.*)?$/, /^\/logout$/];
let isAuthPage = $derived(
!$page.error && authUrls.some((pattern) => pattern.test($page.url.pathname))
!page.error && authUrls.some((pattern) => pattern.test(page.url.pathname))
);
</script>
@@ -26,8 +26,10 @@
</h1>
{/if}
</div>
{#if $userStore?.id}
<HeaderAvatar />
{/if}
<div class="flex items-center justify-between gap-4">
{#if $userStore?.id}
<HeaderAvatar />
{/if}
</div>
</div>
</div>

View File

@@ -2,6 +2,7 @@
import { page } from '$app/state';
import type { Snippet } from 'svelte';
import * as Card from './ui/card';
import { m } from '$lib/paraglide/messages';
let {
children,
@@ -29,7 +30,7 @@
)}`}
class="text-muted-foreground text-xs"
>
Don't have access to your passkey?
{m.dont_have_access_to_your_passkey()}
</a>
</div>
{/if}
@@ -38,7 +39,7 @@
<img
src="/api/application-configuration/background-image"
class="h-screen w-[calc(100vw-650px)] rounded-l-[60px] object-cover"
alt="Login background"
alt={m.login_background()}
/>
</div>
@@ -60,7 +61,7 @@
)}`}
class="text-muted-foreground mt-7 flex justify-center text-xs"
>
Don't have access to your passkey?
{m.dont_have_access_to_your_passkey()}
</a>
{/if}
</Card.CardContent>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages';
import { mode } from 'mode-watcher';
import type { HTMLAttributes } from 'svelte/elements';
@@ -7,4 +8,4 @@
const isDarkMode = $derived($mode === 'dark');
</script>
<img {...props} src="/api/application-configuration/logo?light={!isDarkMode}" alt="Logo" />
<img {...props} src="/api/application-configuration/logo?light={!isDarkMode}" alt={m.logo()} />

View File

@@ -5,6 +5,7 @@
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import * as Select from '$lib/components/ui/select/index.js';
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service';
import { axiosErrorToast } from '$lib/utils/error-util';
@@ -17,14 +18,14 @@
const userService = new UserService();
let oneTimeLink: string | null = $state(null);
let selectedExpiration: keyof typeof availableExpirations = $state('1 hour');
let selectedExpiration: keyof typeof availableExpirations = $state(m.one_hour());
let availableExpirations = {
'1 hour': 60 * 60,
'12 hours': 60 * 60 * 12,
'1 day': 60 * 60 * 24,
'1 week': 60 * 60 * 24 * 7,
'1 month': 60 * 60 * 24 * 30
[m.one_hour()]: 60 * 60,
[m.twelve_hours()]: 60 * 60 * 12,
[m.one_day()]: 60 * 60 * 24,
[m.one_week()]: 60 * 60 * 24 * 7,
[m.one_month()]: 60 * 60 * 24 * 30
};
async function createOneTimeAccessToken() {
@@ -48,14 +49,14 @@
<Dialog.Root open={!!userId} {onOpenChange}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>Login Code</Dialog.Title>
<Dialog.Title>{m.login_code()}</Dialog.Title>
<Dialog.Description
>Create a login code that the user can use to sign in without a passkey once.</Dialog.Description
>{m.create_a_login_code_to_sign_in_without_a_passkey_once()}</Dialog.Description
>
</Dialog.Header>
{#if oneTimeLink === null}
<div>
<Label for="expiration">Expiration</Label>
<Label for="expiration">{m.expiration()}</Label>
<Select.Root
selected={{
label: Object.keys(availableExpirations)[0],
@@ -75,10 +76,10 @@
</Select.Root>
</div>
<Button onclick={() => createOneTimeAccessToken()} disabled={!selectedExpiration}>
Generate Code
{m.generate_code()}
</Button>
{:else}
<Label for="login-code" class="sr-only">Login Code</Label>
<Label for="login-code" class="sr-only">{m.login_code()}</Label>
<Input id="login-code" value={oneTimeLink} readonly />
{/if}
</Dialog.Content>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import AdvancedTable from '$lib/components/advanced-table.svelte';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import UserGroupService from '$lib/services/user-group-service';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { UserGroup } from '$lib/types/user-group.type';
@@ -34,7 +35,7 @@
items={groups}
{requestOptions}
onRefresh={async (o) => (groups = await userGroupService.list(o))}
columns={[{ label: 'Name', sortColumn: 'friendlyName' }]}
columns={[{ label: m.name(), sortColumn: 'friendlyName' }]}
bind:selectedIds={selectedGroupIds}
{selectionDisabled}
>

View File

@@ -1,4 +1,5 @@
<script>
import { m } from '$lib/paraglide/messages';
import Logo from './logo.svelte';
</script>
@@ -6,8 +7,8 @@
<div class="bg-muted mx-auto rounded-2xl p-3">
<Logo class="h-10 w-10" />
</div>
<p class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Browser unsupported</p>
<p class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">{m.browser_unsupported()}</p>
<p class="text-muted-foreground mt-3">
This browser doesn't support passkeys. Please or use a alternative sign in method.
{m.this_browser_does_not_support_passkeys()}
</p>
</div>

View File

@@ -59,6 +59,14 @@ export default class UserService extends APIService {
await this.api.put('/users/me/profile-picture', formData);
}
async resetCurrentUserProfilePicture() {
await this.api.delete(`/users/me/profile-picture`);
}
async resetProfilePicture(userId: string) {
await this.api.delete(`/users/${userId}/profile-picture`);
}
async createOneTimeAccessToken(expiresAt: Date, userId: string) {
const res = await this.api.post(`/users/${userId}/one-time-access-token`, {
userId,

View File

@@ -1,9 +1,13 @@
import { setLocale } from '$lib/paraglide/runtime';
import type { User } from '$lib/types/user.type';
import { writable } from 'svelte/store';
const userStore = writable<User | null>(null);
const setUser = (user: User) => {
if (user.locale) {
setLocale(user.locale, { reload: false });
}
userStore.set(user);
};

View File

@@ -1,3 +1,4 @@
import type { Locale } from '$lib/paraglide/runtime';
import type { CustomClaim } from './custom-claim.type';
import type { UserGroup } from './user-group.type';
@@ -10,6 +11,7 @@ export type User = {
isAdmin: boolean;
userGroups: UserGroup[];
customClaims: CustomClaim[];
locale?: Locale;
ldapId?: string;
};

View File

@@ -1,10 +1,11 @@
import { m } from '$lib/paraglide/messages';
import { WebAuthnError } from '@simplewebauthn/browser';
import { AxiosError } from 'axios';
import { toast } from 'svelte-sonner';
export function getAxiosErrorMessage(
e: unknown,
defaultMessage: string = 'An unknown error occurred'
defaultMessage: string = m.an_unknown_error_occurred()
) {
let message = defaultMessage;
if (e instanceof AxiosError) {
@@ -13,29 +14,29 @@ export function getAxiosErrorMessage(
return message;
}
export function axiosErrorToast(e: unknown, defaultMessage: string = 'An unknown error occurred') {
export function axiosErrorToast(e: unknown, defaultMessage: string = m.an_unknown_error_occurred()) {
const message = getAxiosErrorMessage(e, defaultMessage);
toast.error(message);
}
export function getWebauthnErrorMessage(e: unknown) {
const errors = {
ERROR_CEREMONY_ABORTED: 'The authentication process was aborted',
ERROR_AUTHENTICATOR_GENERAL_ERROR: 'An error occurred with the authenticator',
ERROR_CEREMONY_ABORTED: m.authentication_process_was_aborted(),
ERROR_AUTHENTICATOR_GENERAL_ERROR: m.error_occurred_with_authenticator(),
ERROR_AUTHENTICATOR_MISSING_DISCOVERABLE_CREDENTIAL_SUPPORT:
'The authenticator does not support discoverable credentials',
m.authenticator_does_not_support_discoverable_credentials(),
ERROR_AUTHENTICATOR_MISSING_RESIDENT_KEY_SUPPORT:
'The authenticator does not support resident keys',
ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED: 'This passkey was previously registered',
m.authenticator_does_not_support_resident_keys(),
ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED: m.passkey_was_previously_registered(),
ERROR_AUTHENTICATOR_NO_SUPPORTED_PUBKEYCREDPARAMS_ALG:
'The authenticator does not support any of the requested algorithms'
m.authenticator_does_not_support_any_of_the_requested_algorithms()
};
let message = 'An unknown error occurred';
let message = m.an_unknown_error_occurred();
if (e instanceof WebAuthnError && e.code in errors) {
message = errors[e.code as keyof typeof errors];
} else if (e instanceof WebAuthnError && e?.message.includes('timed out')) {
message = 'The authenticator timed out';
message = m.authenticator_timed_out();
} else if (e instanceof AxiosError && e.response?.data.error) {
message = e.response?.data.error;
} else {

View File

@@ -4,6 +4,7 @@
import Error from '$lib/components/error.svelte';
import Header from '$lib/components/header/header.svelte';
import { Toaster } from '$lib/components/ui/sonner';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import { ModeWatcher } from 'mode-watcher';
@@ -30,10 +31,7 @@
</script>
{#if !appConfig}
<Error
message="A critical error occured. Please contact your administrator."
showButton={false}
/>
<Error message={m.critical_error_occurred_contact_administrator()} showButton={false} />
{:else}
<Header />
{@render children()}

View File

@@ -14,6 +14,7 @@
import type { PageData } from './$types';
import ClientProviderImages from './components/client-provider-images.svelte';
import ScopeItem from './components/scope-item.svelte';
import { m } from '$lib/paraglide/messages';
const webauthnService = new WebAuthnService();
const oidService = new OidcService();
@@ -77,15 +78,15 @@
</script>
<svelte:head>
<title>Sign in to {client.name}</title>
<title>{m.sign_in_to({name: client.name})}</title>
</svelte:head>
{#if client == null}
<p>Client not found</p>
<p>{m.client_not_found()}</p>
{:else}
<SignInWrapper showAlternativeSignInMethodButton>
<ClientProviderImages {client} {success} error={!!errorMessage} />
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Sign in to {client.name}</h1>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">{m.sign_in_to({name: client.name})}</h1>
{#if errorMessage}
<p class="text-muted-foreground mb-10 mt-2">
{errorMessage}.
@@ -93,34 +94,36 @@
{/if}
{#if !authorizationRequired && !errorMessage}
<p class="text-muted-foreground mb-10 mt-2">
Do you want to sign in to <b>{client.name}</b> with your
<b>{$appConfigStore.appName}</b> account?
{@html m.do_you_want_to_sign_in_to_client_with_your_app_name_account({
client: client.name,
appName: $appConfigStore.appName
})}
</p>
{:else if authorizationRequired}
<div transition:slide={{ duration: 300 }}>
<Card.Root class="mb-10 mt-6">
<Card.Header class="pb-5">
<p class="text-muted-foreground text-start">
<b>{client.name}</b> wants to access the following information:
{@html m.client_wants_to_access_the_following_information({ client: client.name })}
</p>
</Card.Header>
<Card.Content data-testid="scopes">
<div class="flex flex-col gap-3">
{#if scope!.includes('email')}
<ScopeItem icon={LucideMail} name="Email" description="View your email address" />
<ScopeItem icon={LucideMail} name={m.email()} description={m.view_your_email_address()} />
{/if}
{#if scope!.includes('profile')}
<ScopeItem
icon={LucideUser}
name="Profile"
description="View your profile information"
name={m.profile()}
description={m.view_your_profile_information()}
/>
{/if}
{#if scope!.includes('groups')}
<ScopeItem
icon={LucideUsers}
name="Groups"
description="View the groups you are a member of"
name={m.groups()}
description={m.view_the_groups_you_are_a_member_of()}
/>
{/if}
</div>
@@ -129,11 +132,11 @@
</div>
{/if}
<div class="flex w-full justify-stretch gap-2">
<Button onclick={() => history.back()} class="w-full" variant="secondary">Cancel</Button>
<Button onclick={() => history.back()} class="w-full" variant="secondary">{m.cancel()}</Button>
{#if !errorMessage}
<Button class="w-full" {isLoading} on:click={authorize}>Sign in</Button>
<Button class="w-full" {isLoading} on:click={authorize}>{m.sign_in()}</Button>
{:else}
<Button class="w-full" on:click={() => (errorMessage = null)}>Try again</Button>
<Button class="w-full" on:click={() => (errorMessage = null)}>{m.try_again()}</Button>
{/if}
</div>
</SignInWrapper>

View File

@@ -3,6 +3,7 @@
import CheckmarkAnimated from '$lib/icons/checkmark-animated.svelte';
import ConnectArrow from '$lib/icons/connect-arrow.svelte';
import CrossAnimated from '$lib/icons/cross-animated.svelte';
import { m } from '$lib/paraglide/messages';
import type { OidcClientMetaData } from '$lib/types/oidc.type';
const {
@@ -61,7 +62,7 @@
class="h-10 w-10"
src="/api/oidc/clients/{client.id}/logo"
draggable={false}
alt="Client Logo"
alt={m.client_logo()}
/>
{:else}
<div class="flex h-10 w-10 items-center justify-center text-3xl font-bold">

View File

@@ -9,6 +9,7 @@
import { startAuthentication } from '@simplewebauthn/browser';
import { fade } from 'svelte/transition';
import LoginLogoErrorSuccessIndicator from './components/login-logo-error-success-indicator.svelte';
import { m } from '$lib/paraglide/messages';
const webauthnService = new WebAuthnService();
let isLoading = $state(false);
@@ -32,7 +33,7 @@
</script>
<svelte:head>
<title>Sign In</title>
<title>{m.sign_in()}</title>
</svelte:head>
<SignInWrapper showAlternativeSignInMethodButton>
@@ -40,18 +41,18 @@
<LoginLogoErrorSuccessIndicator error={!!error} />
</div>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">
Sign in to {$appConfigStore.appName}
{m.sign_in_to_appname({ appName: $appConfigStore.appName})}
</h1>
{#if error}
<p class="text-muted-foreground mt-2" in:fade>
{error}. Please try to sign in again.
{error}. {m.please_try_to_sign_in_again()}
</p>
{:else}
<p class="text-muted-foreground mt-2" in:fade>
Authenticate yourself with your passkey to access the admin panel.
{m.authenticate_yourself_with_your_passkey_to_access_the_admin_panel()}
</p>
{/if}
<Button class="mt-10" {isLoading} on:click={authenticate}
>{error ? 'Try again' : 'Authenticate'}</Button
>{error ? m.try_again() : m.authenticate()}</Button
>
</SignInWrapper>

View File

@@ -4,14 +4,15 @@
import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import { LucideChevronRight, LucideMail, LucideRectangleEllipsis } from 'lucide-svelte';
const methods = [
{
icon: LucideRectangleEllipsis,
title: 'Login Code',
description: 'Enter a login code to sign in.',
title: m.login_code(),
description: m.enter_a_login_code_to_sign_in(),
href: '/login/alternative/code'
}
];
@@ -19,15 +20,15 @@
if ($appConfigStore.emailOneTimeAccessEnabled) {
methods.push({
icon: LucideMail,
title: 'Email Login',
description: 'Request a login code via email.',
title: m.email_login(),
description: m.request_a_login_code_via_email(),
href: '/login/alternative/email'
});
}
</script>
<svelte:head>
<title>Sign In</title>
<title>{m.sign_in()}</title>
</svelte:head>
<SignInWrapper>
@@ -35,9 +36,9 @@
<div class="bg-muted mx-auto rounded-2xl p-3">
<Logo class="h-10 w-10" />
</div>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Alternative Sign In</h1>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">{m.alternative_sign_in()}</h1>
<p class="text-muted-foreground mt-3">
If you dont't have access to your passkey, you can sign in using one of the following methods.
{m.if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods()}
</p>
<div class="mt-5 flex flex-col gap-3">
{#each methods as method}
@@ -59,7 +60,7 @@
</div>
<a class="text-muted-foreground mt-5 text-xs" href={'/login' + page.url.search}
>Use your passkey instead?</a
>{m.use_your_passkey_instead()}</a
>
</div>
</SignInWrapper>

View File

@@ -9,6 +9,7 @@
import { onMount } from 'svelte';
import LoginLogoErrorSuccessIndicator from '../../components/login-logo-error-success-indicator.svelte';
import { page } from '$app/state';
import { m } from '$lib/paraglide/messages';
let { data } = $props();
let code = $state(data.code ?? '');
@@ -26,7 +27,7 @@
try {
goto(data.redirect);
} catch (e) {
error = 'Invalid redirect URL';
error = m.invalid_redirect_url();
}
} catch (e) {
error = getAxiosErrorMessage(e);
@@ -43,20 +44,20 @@
</script>
<svelte:head>
<title>Login Code</title>
<title>{m.login_code()}</title>
</svelte:head>
<SignInWrapper>
<div class="flex justify-center">
<LoginLogoErrorSuccessIndicator error={!!error} />
</div>
<h1 class="font-playfair mt-5 text-4xl font-bold">Login Code</h1>
<h1 class="font-playfair mt-5 text-4xl font-bold">{m.login_code()}</h1>
{#if error}
<p class="text-muted-foreground mt-2">
{error}. Please try again.
{error}. {m.please_try_again()}
</p>
{:else}
<p class="text-muted-foreground mt-2">Enter the code you received to sign in.</p>
<p class="text-muted-foreground mt-2">{m.enter_the_code_you_received_to_sign_in()}</p>
{/if}
<form
onsubmit={(e) => {
@@ -65,10 +66,10 @@
}}
class="w-full max-w-[450px]"
>
<Input id="Email" class="mt-7" placeholder="Code" bind:value={code} type="text" />
<Input id="Email" class="mt-7" placeholder={m.code()} bind:value={code} type="text" />
<div class="mt-8 flex justify-stretch gap-2">
<Button variant="secondary" class="w-full" href={"/login/alternative" + page.url.search}>Go back</Button>
<Button class="w-full" type="submit" {isLoading}>Submit</Button>
<Button variant="secondary" class="w-full" href={"/login/alternative" + page.url.search}>{m.go_back()}</Button>
<Button class="w-full" type="submit" {isLoading}>{m.submit()}</Button>
</div>
</form>
</SignInWrapper>

View File

@@ -6,6 +6,7 @@
import UserService from '$lib/services/user-service';
import { fade } from 'svelte/transition';
import LoginLogoErrorSuccessIndicator from '../../components/login-logo-error-success-indicator.svelte';
import { m } from '$lib/paraglide/messages';
const { data } = $props();
@@ -21,38 +22,38 @@
await userService
.requestOneTimeAccessEmail(email, data.redirect)
.then(() => (success = true))
.catch((e) => (error = e.response?.data.error || 'An unknown error occured'));
.catch((e) => (error = e.response?.data.error || m.an_unknown_error_occurred()));
isLoading = false;
}
</script>
<svelte:head>
<title>Email Login</title>
<title>{m.email_login()}</title>
</svelte:head>
<SignInWrapper>
<div class="flex justify-center">
<LoginLogoErrorSuccessIndicator {success} error={!!error} />
</div>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Email Login</h1>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">{m.email_login()}</h1>
{#if error}
<p class="text-muted-foreground mt-2" in:fade>
{error}. Please try again.
{error}. {m.please_try_again()}
</p>
<div class="mt-10 flex w-full justify-stretch gap-2">
<Button variant="secondary" class="w-full" href="/">Go back</Button>
<Button class="w-full" onclick={() => (error = undefined)}>Try again</Button>
<Button variant="secondary" class="w-full" href="/">{m.go_back()}</Button>
<Button class="w-full" onclick={() => (error = undefined)}>{m.try_again()}</Button>
</div>
{:else if success}
<p class="text-muted-foreground mt-2" in:fade>
An email has been sent to the provided email, if it exists in the system.
{m.an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system()}
</p>
<div class="mt-8 flex w-full justify-stretch gap-2">
<Button variant="secondary" class="w-full" href={'/login/alternative' + page.url.search}
>Go back</Button
>{m.go_back()}</Button
>
<Button class="w-full" href={'/login/alternative/code' + page.url.search}>Enter code</Button>
<Button class="w-full" href={'/login/alternative/code' + page.url.search}>{m.enter_code()}</Button>
</div>
{:else}
<form
@@ -63,14 +64,14 @@
class="w-full max-w-[450px]"
>
<p class="text-muted-foreground mt-2" in:fade>
Enter your email address to receive an email with a login code.
{m.enter_your_email_address_to_receive_an_email_with_a_login_code()}
</p>
<Input id="Email" class="mt-7" placeholder="Your email" bind:value={email} />
<Input id="Email" class="mt-7" placeholder={m.your_email()} bind:value={email} />
<div class="mt-8 flex justify-stretch gap-2">
<Button variant="secondary" class="w-full" href={'/login/alternative' + page.url.search}
>Go back</Button
>{m.go_back()}</Button
>
<Button class="w-full" type="submit" {isLoading}>Submit</Button>
<Button class="w-full" type="submit" {isLoading}>{m.submit()}</Button>
</div>
</form>
{/if}

View File

@@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store.js';
import userStore from '$lib/stores/user-store.js';
@@ -33,18 +34,16 @@
<LoginLogoErrorSuccessIndicator error={!!error} />
</div>
<h1 class="font-playfair mt-5 text-4xl font-bold">
{`${$appConfigStore.appName} Setup`}
{m.appname_setup({ appName: $appConfigStore.appName })}
</h1>
{#if error}
<p class="text-muted-foreground mt-2">
{error}. Please try again.
{error}. {m.please_try_again()}
</p>
{:else}
<p class="text-muted-foreground mt-2">
You're about to sign in to the initial admin account. Anyone with this link can access the
account until a passkey is added. Please set up a passkey as soon as possible to prevent
unauthorized access.
{m.you_are_about_to_sign_in_to_the_initial_admin_account()}
</p>
<Button class="mt-5" {isLoading} on:click={authenticate}>Continue</Button>
<Button class="mt-5" {isLoading} on:click={authenticate}>{m.continue()}</Button>
{/if}
</SignInWrapper>

View File

@@ -3,6 +3,7 @@
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import WebAuthnService from '$lib/services/webauthn-service';
import userStore from '$lib/stores/user-store.js';
import { axiosErrorToast } from '$lib/utils/error-util.js';
@@ -22,7 +23,7 @@
</script>
<svelte:head>
<title>Logout</title>
<title>{m.logout()}</title>
</svelte:head>
<SignInWrapper>
@@ -31,13 +32,13 @@
<Logo class="h-10 w-10" />
</div>
</div>
<h1 class="font-playfair mt-5 text-4xl font-bold">Sign out</h1>
<h1 class="font-playfair mt-5 text-4xl font-bold">{m.sign_out()}</h1>
<p class="text-muted-foreground mt-2">
Do you want to sign out of Pocket ID with the account <b>{$userStore?.username}</b>?
{@html m.do_you_want_to_sign_out_of_pocketid_with_the_account({ username: $userStore?.username ?? '' })}
</p>
<div class="mt-10 flex w-full justify-stretch gap-2">
<Button class="w-full" variant="secondary" onclick={() => history.back()}>Cancel</Button>
<Button class="w-full" {isLoading} onclick={signOut}>Sign out</Button>
<Button class="w-full" variant="secondary" onclick={() => history.back()}>{m.cancel()}</Button>
<Button class="w-full" {isLoading} onclick={signOut}>{m.sign_out()}</Button>
</div>
</SignInWrapper>

View File

@@ -4,6 +4,7 @@
import { LucideExternalLink } from 'lucide-svelte';
import type { Snippet } from 'svelte';
import type { LayoutData } from './$types';
import { m } from '$lib/paraglide/messages';
let {
children,
@@ -16,19 +17,19 @@
const { versionInformation } = data;
let links = $state([
{ href: '/settings/account', label: 'My Account' },
{ href: '/settings/audit-log', label: 'Audit Log' }
{ href: '/settings/account', label: m.my_account() },
{ href: '/settings/audit-log', label: m.audit_log() }
]);
if ($userStore?.isAdmin) {
links = [
// svelte-ignore state_referenced_locally
...links,
{ href: '/settings/admin/users', label: 'Users' },
{ href: '/settings/admin/user-groups', label: 'User Groups' },
{ href: '/settings/admin/oidc-clients', label: 'OIDC Clients' },
{ href: '/settings/admin/api-keys', label: 'API Keys' },
{ href: '/settings/admin/application-configuration', label: 'Application Configuration' }
{ href: '/settings/admin/users', label: m.users() },
{ href: '/settings/admin/user-groups', label: m.user_groups() },
{ href: '/settings/admin/oidc-clients', label: m.oidc_clients() },
{ href: '/settings/admin/api-keys', label: m.api_keys() },
{ href: '/settings/admin/application-configuration', label: m.application_configuration() }
];
}
</script>
@@ -40,7 +41,7 @@
>
<div class="min-w-[200px] xl:min-w-[250px]">
<div class="mx-auto grid w-full gap-2">
<h1 class="mb-5 text-3xl font-semibold">Settings</h1>
<h1 class="mb-5 text-3xl font-semibold">{m.settings()}</h1>
</div>
<nav class="text-muted-foreground grid gap-4 text-sm">
{#each links as { href, label }}
@@ -54,7 +55,7 @@
target="_blank"
class="flex items-center gap-2"
>
Update Pocket ID <LucideExternalLink class="my-auto inline-block h-3 w-3" />
{m.update_pocket_id()} <LucideExternalLink class="my-auto inline-block h-3 w-3" />
</a>
{/if}
</nav>
@@ -65,7 +66,7 @@
</main>
<div class="flex flex-col items-center">
<p class="text-muted-foreground py-3 text-xs">
Powered by <a
{m.powered_by()} <a
class="text-foreground"
href="https://github.com/pocket-id/pocket-id"
target="_blank">Pocket ID</a

View File

@@ -2,6 +2,7 @@
import * as Alert from '$lib/components/ui/alert';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service';
import WebAuthnService from '$lib/services/webauthn-service';
import appConfigStore from '$lib/stores/application-configuration-store';
@@ -13,6 +14,7 @@
import { toast } from 'svelte-sonner';
import ProfilePictureSettings from '../../../lib/components/form/profile-picture-settings.svelte';
import AccountForm from './account-form.svelte';
import LocalePicker from './locale-picker.svelte';
import LoginCodeModal from './login-code-modal.svelte';
import PasskeyList from './passkey-list.svelte';
import RenamePasskeyModal from './rename-passkey-modal.svelte';
@@ -26,11 +28,20 @@
const userService = new UserService();
const webauthnService = new WebAuthnService();
async function resetProfilePicture() {
await userService
.resetCurrentUserProfilePicture()
.then(() =>
toast.success('Profile picture has been reset. It may take a few minutes to update.')
)
.catch(axiosErrorToast);
}
async function updateAccount(user: UserCreate) {
let success = true;
await userService
.updateCurrent(user)
.then(() => toast.success('Account details updated successfully'))
.then(() => toast.success(m.account_details_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
success = false;
@@ -42,7 +53,7 @@
async function updateProfilePicture(image: File) {
await userService
.updateCurrentUsersProfilePicture(image)
.then(() => toast.success('Profile picture updated successfully'))
.then(() => toast.success(m.profile_picture_updated_successfully()))
.catch(axiosErrorToast);
}
@@ -61,24 +72,22 @@
</script>
<svelte:head>
<title>Account Settings</title>
<title>{m.account_settings()}</title>
</svelte:head>
{#if passkeys.length == 0}
<Alert.Root variant="warning">
<LucideAlertTriangle class="size-4" />
<Alert.Title>Passkey missing</Alert.Title>
<Alert.Title>{m.passkey_missing()}</Alert.Title>
<Alert.Description
>Please add a passkey to prevent losing access to your account.</Alert.Description
>{m.please_provide_a_passkey_to_prevent_losing_access_to_your_account()}</Alert.Description
>
</Alert.Root>
{:else if passkeys.length == 1}
<Alert.Root variant="warning" dismissibleId="single-passkey">
<LucideAlertTriangle class="size-4" />
<Alert.Title>Single Passkey Configured</Alert.Title>
<Alert.Description
>It is recommended to add more than one passkey to avoid losing access to your account.</Alert.Description
>
<Alert.Title>{m.single_passkey_configured()}</Alert.Title>
<Alert.Description>{m.it_is_recommended_to_add_more_than_one_passkey()}</Alert.Description>
</Alert.Root>
{/if}
@@ -88,7 +97,7 @@
>
<Card.Root>
<Card.Header>
<Card.Title>Account Details</Card.Title>
<Card.Title>{m.account_details()}</Card.Title>
</Card.Header>
<Card.Content>
<AccountForm {account} callback={updateAccount} />
@@ -101,7 +110,8 @@
<ProfilePictureSettings
userId={account.id}
isLdapUser={!!account.ldapId}
callback={updateProfilePicture}
updateCallback={updateProfilePicture}
resetCallback={resetProfilePicture}
/>
</Card.Content>
</Card.Root>
@@ -110,12 +120,12 @@
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Passkeys</Card.Title>
<Card.Title>{m.passkeys()}</Card.Title>
<Card.Description class="mt-1">
Manage your passkeys that you can use to authenticate yourself.
{m.manage_your_passkeys_that_you_can_use_to_authenticate_yourself()}
</Card.Description>
</div>
<Button size="sm" class="ml-3" on:click={createPasskey}>Add Passkey</Button>
<Button size="sm" class="ml-3" on:click={createPasskey}>{m.add_passkey()}</Button>
</div>
</Card.Header>
{#if passkeys.length != 0}
@@ -129,12 +139,28 @@
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Login Code</Card.Title>
<Card.Title>{m.login_code()}</Card.Title>
<Card.Description class="mt-1">
Create a one-time login code to sign in from a different device without a passkey.
{m.create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey()}
</Card.Description>
</div>
<Button size="sm" class="ml-auto" on:click={() => (showLoginCodeModal = true)}>Create</Button>
<Button size="sm" class="ml-auto" on:click={() => (showLoginCodeModal = true)}
>{m.create()}</Button
>
</div>
</Card.Header>
</Card.Root>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>{m.language()}</Card.Title>
<Card.Description class="mt-1">
{m.select_the_language_you_want_to_use()}
</Card.Description>
</div>
<LocalePicker />
</div>
</Card.Header>
</Card.Root>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import type { UserCreate } from '$lib/types/user.type';
import { createForm } from '$lib/utils/form-util';
import { z } from 'zod';
@@ -24,7 +25,7 @@
.max(30)
.regex(
/^[a-z0-9_@.-]+$/,
"Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols"
m.username_can_only_contain()
),
email: z.string().email(),
isAdmin: z.boolean()
@@ -36,7 +37,7 @@
const data = form.validate();
if (!data) return;
isLoading = true;
const success = await callback(data);
await callback(data);
// Reset form if user was successfully created
isLoading = false;
}
@@ -45,21 +46,21 @@
<form onsubmit={onSubmit}>
<div class="flex flex-col gap-3 sm:flex-row">
<div class="w-full">
<FormInput label="First name" bind:input={$inputs.firstName} />
<FormInput label={m.first_name()} bind:input={$inputs.firstName} />
</div>
<div class="w-full">
<FormInput label="Last name" bind:input={$inputs.lastName} />
<FormInput label={m.last_name()} bind:input={$inputs.lastName} />
</div>
</div>
<div class="mt-3 flex flex-col gap-3 sm:flex-row">
<div class="w-full">
<FormInput label="Email" bind:input={$inputs.email} />
<FormInput label={m.email()} bind:input={$inputs.email} />
</div>
<div class="w-full">
<FormInput label="Username" bind:input={$inputs.username} />
<FormInput label={m.username()} bind:input={$inputs.username} />
</div>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button>
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
</form>

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import * as Select from '$lib/components/ui/select';
import { getLocale, setLocale, type Locale } from '$lib/paraglide/runtime';
import UserService from '$lib/services/user-service';
import userStore from '$lib/stores/user-store';
const userService = new UserService();
const currentLocale = getLocale();
const locales = {
'en-US': 'English',
'nl-NL': 'Nederlands'
};
function updateLocale(locale: Locale) {
setLocale(locale);
userService.updateCurrent({
...$userStore!,
locale
});
}
</script>
<Select.Root
selected={{
label: locales[currentLocale],
value: currentLocale
}}
onSelectedChange={(v) => updateLocale(v!.value)}
>
<Select.Trigger class="h-9 max-w-[200px]" aria-label="Select locale">
<Select.Value>{locales[currentLocale]}</Select.Value>
</Select.Trigger>
<Select.Content>
{#each Object.entries(locales) as [value, label]}
<Select.Item {value}>{label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>

View File

@@ -3,6 +3,7 @@
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import * as Dialog from '$lib/components/ui/dialog';
import { Separator } from '$lib/components/ui/separator';
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service';
import { axiosErrorToast } from '$lib/utils/error-util';
@@ -37,9 +38,9 @@
<Dialog.Root open={!!code} {onOpenChange}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>Login Code</Dialog.Title>
<Dialog.Title>{m.login_code()}</Dialog.Title>
<Dialog.Description
>Sign in using the following code. The code will expire in 15 minutes.
>{m.sign_in_using_the_following_code_the_code_will_expire_in_minutes()}
</Dialog.Description>
</Dialog.Header>
@@ -49,7 +50,7 @@
</CopyToClipboard>
<div class="text-muted-foreground flex items-center justify-center gap-3">
<Separator />
<p class="text-nowrap text-xs">or visit</p>
<p class="text-nowrap text-xs">{m.or_visit()}</p>
<Separator />
</div>
<div>

View File

@@ -8,6 +8,7 @@
import { LucideKeyRound, LucidePencil, LucideTrash } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import RenamePasskeyModal from './rename-passkey-modal.svelte';
import { m } from '$lib/paraglide/messages';
let { passkeys = $bindable() }: { passkeys: Passkey[] } = $props();
@@ -17,16 +18,16 @@
async function deletePasskey(passkey: Passkey) {
openConfirmDialog({
title: `Delete ${passkey.name}`,
message: 'Are you sure you want to delete this passkey?',
title: m.delete_passkey_name({ passkeyName: passkey.name }),
message: m.are_you_sure_you_want_to_delete_this_passkey(),
confirm: {
label: 'Delete',
label: m.delete(),
destructive: true,
action: async () => {
try {
await webauthnService.removeCredential(passkey.id);
passkeys = await webauthnService.listCredentials();
toast.success('Passkey deleted successfully');
toast.success(m.passkey_deleted_successfully());
} catch (e) {
axiosErrorToast(e);
}
@@ -44,7 +45,7 @@
<div>
<p>{passkey.name}</p>
<p class="text-xs text-muted-foreground">
Added on {new Date(passkey.createdAt).toLocaleDateString()}
{m.added_on()} {new Date(passkey.createdAt).toLocaleDateString()}
</p>
</div>
</div>
@@ -53,13 +54,13 @@
on:click={() => (passkeyToRename = passkey)}
size="sm"
variant="outline"
aria-label="Rename"><LucidePencil class="h-3 w-3" /></Button
aria-label={m.rename()}><LucidePencil class="h-3 w-3" /></Button
>
<Button
on:click={() => deletePasskey(passkey)}
size="sm"
variant="outline"
aria-label="Delete"><LucideTrash class="h-3 w-3 text-red-500" /></Button
aria-label={m.delete()}><LucideTrash class="h-3 w-3 text-red-500" /></Button
>
</div>
</div>

View File

@@ -3,6 +3,7 @@
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { m } from '$lib/paraglide/messages';
import WebAuthnService from '$lib/services/webauthn-service';
import type { Passkey } from '$lib/types/passkey.type';
import { axiosErrorToast } from '$lib/utils/error-util';
@@ -35,7 +36,7 @@
.updateCredentialName(passkey!.id, name)
.then(() => {
passkey = null;
toast.success('Passkey name updated successfully');
toast.success(m.passkey_name_updated_successfully());
callback?.();
})
.catch(axiosErrorToast);
@@ -45,16 +46,16 @@
<Dialog.Root open={!!passkey} {onOpenChange}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>Name Passkey</Dialog.Title>
<Dialog.Description>Name your passkey to easily identify it later.</Dialog.Description>
<Dialog.Title>{m.name_passkey()}</Dialog.Title>
<Dialog.Description>{m.name_your_passkey_to_easily_identify_it_later()}</Dialog.Description>
</Dialog.Header>
<form onsubmit={onSubmit}>
<div class="grid items-center gap-4 sm:grid-cols-4">
<Label for="name" class="sm:text-right">Name</Label>
<Label for="name" class="sm:text-right">{m.name()}</Label>
<Input id="name" bind:value={name} class="col-span-3" />
</div>
<Dialog.Footer class="mt-4">
<Button type="submit">Save</Button>
<Button type="submit">{m.save()}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>

View File

@@ -9,6 +9,7 @@
import ApiKeyDialog from './api-key-dialog.svelte';
import ApiKeyForm from './api-key-form.svelte';
import ApiKeyList from './api-key-list.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props();
let apiKeys = $state(data.apiKeys);
@@ -35,18 +36,18 @@
</script>
<svelte:head>
<title>API Keys</title>
<title>{m.api_keys()}</title>
</svelte:head>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Create API Key</Card.Title>
<Card.Description>Add a new API key for programmatic access.</Card.Description>
<Card.Title>{m.create_api_key()}</Card.Title>
<Card.Description>{m.add_a_new_api_key_for_programmatic_access()}</Card.Description>
</div>
{#if !expandAddApiKey}
<Button on:click={() => (expandAddApiKey = true)}>Add API Key</Button>
<Button on:click={() => (expandAddApiKey = true)}>{m.add_api_key()}</Button>
{:else}
<Button class="h-8 p-3" variant="ghost" on:click={() => (expandAddApiKey = false)}>
<LucideMinus class="h-5 w-5" />
@@ -65,7 +66,7 @@
<Card.Root class="mt-6">
<Card.Header>
<Card.Title>Manage API Keys</Card.Title>
<Card.Title>{m.manage_api_keys()}</Card.Title>
</Card.Header>
<Card.Content>
<ApiKeyList {apiKeys} requestOptions={apiKeysRequestOptions} />

View File

@@ -2,6 +2,7 @@
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { m } from '$lib/paraglide/messages';
import type { ApiKeyResponse } from '$lib/types/api-key.type';
let {
@@ -20,22 +21,22 @@
<Dialog.Root open={!!apiKeyResponse} {onOpenChange}>
<Dialog.Content class="max-w-md" closeButton={false}>
<Dialog.Header>
<Dialog.Title>API Key Created</Dialog.Title>
<Dialog.Title>{m.api_key_created()}</Dialog.Title>
<Dialog.Description>
For security reasons, this key will only be shown once. Please store it securely.
{m.for_security_reasons_this_key_will_only_be_shown_once()}
</Dialog.Description>
</Dialog.Header>
{#if apiKeyResponse}
<div>
<div class="mb-2 font-medium">Name</div>
<div class="mb-2 font-medium">{m.name()}</div>
<p class="text-muted-foreground">{apiKeyResponse.apiKey.name}</p>
{#if apiKeyResponse.apiKey.description}
<div class="mb-2 mt-4 font-medium">Description</div>
<div class="mb-2 mt-4 font-medium">{m.description()}</div>
<p class="text-muted-foreground">{apiKeyResponse.apiKey.description}</p>
{/if}
<div class="mb-2 mt-4 font-medium">API Key</div>
<div class="mb-2 mt-4 font-medium">{m.api_key()}</div>
<div class="bg-muted rounded-md p-2">
<CopyToClipboard value={apiKeyResponse.token}>
<span class="break-all font-mono text-sm">{apiKeyResponse.token}</span>
@@ -44,7 +45,7 @@
</div>
{/if}
<Dialog.Footer class="mt-3">
<Button variant="default" on:click={() => onOpenChange(false)}>Close</Button>
<Button variant="default" on:click={() => onOpenChange(false)}>{m.close()}</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import type { ApiKeyCreate } from '$lib/types/api-key.type';
import { createForm } from '$lib/utils/form-util';
import { z } from 'zod';
@@ -26,10 +27,10 @@
const formSchema = z.object({
name: z
.string()
.min(3, 'Name must be at least 3 characters')
.max(50, 'Name cannot exceed 50 characters'),
.min(3, m.name_must_be_at_least_3_characters())
.max(50, m.name_cannot_exceed_50_characters()),
description: z.string().default(''),
expiresAt: z.date().min(new Date(), 'Expiration date must be in the future')
expiresAt: z.date().min(new Date(), m.expiration_date_must_be_in_the_future())
});
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, apiKey);
@@ -54,25 +55,25 @@
<form onsubmit={onSubmit}>
<div class="grid grid-cols-1 items-start gap-5 md:grid-cols-2">
<FormInput
label="Name"
label={m.name()}
bind:input={$inputs.name}
description="Name to identify this API key."
description={m.name_to_identify_this_api_key()}
/>
<FormInput
label="Expires At"
label={m.expires_at()}
type="date"
description="When this API key will expire."
description={m.when_this_api_key_will_expire()}
bind:input={$inputs.expiresAt}
/>
<div class="col-span-1 md:col-span-2">
<FormInput
label="Description"
description="Optional description to help identify this key's purpose."
label={m.description()}
description={m.optional_description_to_help_identify_this_keys_purpose()}
bind:input={$inputs.description}
/>
</div>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button>
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
</form>

View File

@@ -3,6 +3,7 @@
import { openConfirmDialog } from '$lib/components/confirm-dialog';
import { Button } from '$lib/components/ui/button';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import ApiKeyService from '$lib/services/api-key-service';
import type { ApiKey } from '$lib/types/api-key.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
@@ -21,22 +22,22 @@
const apiKeyService = new ApiKeyService();
function formatDate(dateStr: string | undefined) {
if (!dateStr) return 'Never';
if (!dateStr) return m.never();
return new Date(dateStr).toLocaleString();
}
function revokeApiKey(apiKey: ApiKey) {
openConfirmDialog({
title: 'Revoke API Key',
message: `Are you sure you want to revoke the API key "${apiKey.name}"? This will break any integrations using this key.`,
title: m.revoke_api_key(),
message: m.are_you_sure_you_want_to_revoke_the_api_key_apikeyname({ apiKeyName: apiKey.name }),
confirm: {
label: 'Revoke',
label: m.revoke(),
destructive: true,
action: async () => {
try {
await apiKeyService.revoke(apiKey.id);
apiKeys = await apiKeyService.list(requestOptions);
toast.success('API key revoked successfully');
toast.success(m.api_key_revoked_successfully());
} catch (e) {
axiosErrorToast(e);
}
@@ -52,11 +53,11 @@
onRefresh={async (o) => (apiKeys = await apiKeyService.list(o))}
withoutSearch
columns={[
{ label: 'Name', sortColumn: 'name' },
{ label: 'Description' },
{ label: 'Expires At', sortColumn: 'expiresAt' },
{ label: 'Last Used', sortColumn: 'lastUsedAt' },
{ label: 'Actions', hidden: true }
{ label: m.name(), sortColumn: 'name' },
{ label: m.description() },
{ label: m.expires_at(), sortColumn: 'expiresAt' },
{ label: m.last_used(), sortColumn: 'lastUsedAt' },
{ label: m.actions(), hidden: true }
]}
>
{#snippet rows({ item })}
@@ -65,7 +66,7 @@
<Table.Cell>{formatDate(item.expiresAt)}</Table.Cell>
<Table.Cell>{formatDate(item.lastUsedAt)}</Table.Cell>
<Table.Cell class="flex justify-end">
<Button on:click={() => revokeApiKey(item)} size="sm" variant="outline" aria-label="Revoke"
<Button on:click={() => revokeApiKey(item)} size="sm" variant="outline" aria-label={m.revoke()}
><LucideBan class="h-3 w-3 text-red-500" /></Button
>
</Table.Cell>

View File

@@ -9,6 +9,7 @@
import AppConfigGeneralForm from './forms/app-config-general-form.svelte';
import AppConfigLdapForm from './forms/app-config-ldap-form.svelte';
import UpdateApplicationImages from './update-application-images.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props();
let appConfig = $state(data.appConfig);
@@ -46,36 +47,35 @@
: Promise.resolve();
await Promise.all([lightLogoPromise, darkLogoPromise, backgroundImagePromise, faviconPromise])
.then(() => toast.success('Images updated successfully'))
.then(() => toast.success(m.images_updated_successfully()))
.catch(axiosErrorToast);
}
</script>
<svelte:head>
<title>Application Configuration</title>
<title>{m.application_configuration()}</title>
</svelte:head>
<CollapsibleCard id="application-configuration-general" title="General" defaultExpanded>
<CollapsibleCard id="application-configuration-general" title={m.general()} defaultExpanded>
<AppConfigGeneralForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
<CollapsibleCard
id="application-configuration-email"
title="Email"
description="Enable email notifications to alert users when a login is detected from a new device or
location."
title={m.email()}
description={m.enable_email_notifications_to_alert_users_when_a_login_is_detected_from_a_new_device_or_location()}
>
<AppConfigEmailForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
<CollapsibleCard
id="application-configuration-ldap"
title="LDAP"
description="Configure LDAP settings to sync users and groups from an LDAP server."
title={m.ldap()}
description={m.configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server()}
>
<AppConfigLdapForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
<CollapsibleCard id="application-configuration-images" title="Images">
<CollapsibleCard id="application-configuration-images" title={m.images()}>
<UpdateApplicationImages callback={updateImages} />
</CollapsibleCard>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import FileInput from '$lib/components/form/file-input.svelte';
import { Label } from '$lib/components/ui/label';
import { m } from '$lib/paraglide/messages';
import { cn } from '$lib/utils/style';
import type { HTMLAttributes } from 'svelte/elements';
@@ -60,7 +61,7 @@
<span
class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform font-medium opacity-0 transition-opacity group-hover:opacity-100"
>
Update
{m.update()}
</span>
</div>
</FileInput>

View File

@@ -6,6 +6,7 @@
import { Button } from '$lib/components/ui/button';
import Label from '$lib/components/ui/label/label.svelte';
import * as Select from '$lib/components/ui/select';
import { m } from '$lib/paraglide/messages';
import AppConfigService from '$lib/services/app-config-service';
import type { AllAppConfig } from '$lib/types/application-configuration';
import { createForm } from '$lib/utils/form-util';
@@ -55,7 +56,7 @@
appConfig[key] = value;
});
toast.success('Email configuration updated successfully');
toast.success(m.email_configuration_updated_successfully());
return true;
}
async function onTestEmail() {
@@ -64,11 +65,11 @@
if (hasChanges) {
openConfirmDialog({
title: 'Save changes?',
title: m.save_changes_question(),
message:
'You have to save the changes before sending a test email. Do you want to save now?',
m.you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now(),
confirm: {
label: 'Save and send',
label: m.save_and_send(),
action: async () => {
const saved = await onSubmit();
if (saved) {
@@ -86,9 +87,9 @@
isSendingTestEmail = true;
await appConfigService
.sendTestEmail()
.then(() => toast.success('Test email sent successfully to your email address.'))
.then(() => toast.success(m.test_email_sent_successfully()))
.catch(() =>
toast.error('Failed to send test email. Check the server logs for more information.')
toast.error(m.failed_to_send_test_email())
)
.finally(() => (isSendingTestEmail = false));
}
@@ -96,21 +97,21 @@
<form onsubmit={onSubmit}>
<fieldset disabled={uiConfigDisabled}>
<h4 class="text-lg font-semibold">SMTP Configuration</h4>
<h4 class="text-lg font-semibold">{m.smtp_configuration()}</h4>
<div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2">
<FormInput label="SMTP Host" bind:input={$inputs.smtpHost} />
<FormInput label="SMTP Port" type="number" bind:input={$inputs.smtpPort} />
<FormInput label="SMTP User" bind:input={$inputs.smtpUser} />
<FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} />
<FormInput label="SMTP From" bind:input={$inputs.smtpFrom} />
<FormInput label={m.smtp_host()} bind:input={$inputs.smtpHost} />
<FormInput label={m.smtp_port()} type="number" bind:input={$inputs.smtpPort} />
<FormInput label={m.smtp_user()} bind:input={$inputs.smtpUser} />
<FormInput label={m.smtp_password()} type="password" bind:input={$inputs.smtpPassword} />
<FormInput label={m.smtp_from()} bind:input={$inputs.smtpFrom} />
<div class="grid gap-2">
<Label class="mb-0" for="smtp-tls">SMTP TLS Option</Label>
<Label class="mb-0" for="smtp-tls">{m.smtp_tls_option()}</Label>
<Select.Root
selected={{ value: $inputs.smtpTls.value, label: tlsOptions[$inputs.smtpTls.value] }}
onSelectedChange={(v) => ($inputs.smtpTls.value = v!.value)}
>
<Select.Trigger>
<Select.Value placeholder="Email TLS Option" />
<Select.Value placeholder={m.email_tls_option()} />
</Select.Trigger>
<Select.Content>
<Select.Item value="none" label="None" />
@@ -121,31 +122,31 @@
</div>
<CheckboxWithLabel
id="skip-cert-verify"
label="Skip Certificate Verification"
description="This can be useful for self-signed certificates."
label={m.skip_certificate_verification()}
description={m.this_can_be_useful_for_selfsigned_certificates()}
bind:checked={$inputs.smtpSkipCertVerify.value}
/>
</div>
<h4 class="mt-10 text-lg font-semibold">Enabled Emails</h4>
<h4 class="mt-10 text-lg font-semibold">{m.enabled_emails()}</h4>
<div class="mt-4 flex flex-col gap-5">
<CheckboxWithLabel
id="email-login-notification"
label="Email Login Notification"
description="Send an email to the user when they log in from a new device."
label={m.email_login_notification()}
description={m.send_an_email_to_the_user_when_they_log_in_from_a_new_device()}
bind:checked={$inputs.emailLoginNotificationEnabled.value}
/>
<CheckboxWithLabel
id="email-login"
label="Email Login"
description="Allows users to sign in with a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry."
label={m.email_login()}
description={m.allow_users_to_sign_in_with_a_login_code_sent_to_their_email()}
bind:checked={$inputs.emailOneTimeAccessEnabled.value}
/>
</div>
</fieldset>
<div class="mt-8 flex flex-wrap justify-end gap-3">
<Button isLoading={isSendingTestEmail} variant="secondary" onclick={onTestEmail}
>Send test email</Button
>{m.send_test_email()}</Button
>
<Button type="submit" disabled={uiConfigDisabled}>Save</Button>
<Button type="submit" disabled={uiConfigDisabled}>{m.save()}</Button>
</div>
</form>

View File

@@ -3,6 +3,7 @@
import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import type { AllAppConfig } from '$lib/types/application-configuration';
import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner';
@@ -39,35 +40,35 @@
if (!data) return;
isLoading = true;
await callback(data).finally(() => (isLoading = false));
toast.success('Application configuration updated successfully');
toast.success(m.application_configuration_updated_successfully());
}
</script>
<form onsubmit={onSubmit}>
<fieldset class="flex flex-col gap-5" disabled={uiConfigDisabled}>
<div class="flex flex-col gap-5">
<FormInput label="Application Name" bind:input={$inputs.appName} />
<FormInput label={m.application_name()} bind:input={$inputs.appName} />
<FormInput
label="Session Duration"
label={m.session_duration()}
type="number"
description="The duration of a session in minutes before the user has to sign in again."
description={m.the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again()}
bind:input={$inputs.sessionDuration}
/>
<CheckboxWithLabel
id="self-account-editing"
label="Enable Self-Account Editing"
description="Whether the users should be able to edit their own account details."
label={m.enable_self_account_editing()}
description={m.whether_the_users_should_be_able_to_edit_their_own_account_details()}
bind:checked={$inputs.allowOwnAccountEdit.value}
/>
<CheckboxWithLabel
id="emails-verified"
label="Emails Verified"
description="Whether the user's email should be marked as verified for the OIDC clients."
label={m.emails_verified()}
description={m.whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients()}
bind:checked={$inputs.emailsVerified.value}
/>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button>
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
</fieldset>
</form>

View File

@@ -3,6 +3,7 @@
import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import AppConfigService from '$lib/services/app-config-service';
import type { AllAppConfig } from '$lib/types/application-configuration';
import { axiosErrorToast } from '$lib/utils/error-util';
@@ -74,14 +75,14 @@
...data,
ldapEnabled: true
});
toast.success('LDAP configuration updated successfully');
toast.success(m.ldap_configuration_updated_successfully());
return true;
}
async function onDisable() {
ldapEnabled = false;
await callback({ ldapEnabled });
toast.success('LDAP disabled successfully');
toast.success(m.ldap_disabled_successfully());
}
async function onEnable() {
@@ -94,7 +95,7 @@
ldapSyncing = true;
await appConfigService
.syncLdap()
.then(() => toast.success('LDAP sync finished'))
.then(() => toast.success(m.ldap_sync_finished()))
.catch(axiosErrorToast);
ldapSyncing = false;
@@ -102,98 +103,98 @@
</script>
<form onsubmit={onSubmit}>
<h4 class="text-lg font-semibold">Client Configuration</h4>
<h4 class="text-lg font-semibold">{m.client_configuration()}</h4>
<fieldset disabled={uiConfigDisabled}>
<div class="mt-4 grid grid-cols-1 items-start gap-5 md:grid-cols-2">
<FormInput
label="LDAP URL"
label={m.ldap_url()}
placeholder="ldap://example.com:389"
bind:input={$inputs.ldapUrl}
/>
<FormInput
label="LDAP Bind DN"
label={m.ldap_bind_dn()}
placeholder="cn=people,dc=example,dc=com"
bind:input={$inputs.ldapBindDn}
/>
<FormInput label="LDAP Bind Password" type="password" bind:input={$inputs.ldapBindPassword} />
<FormInput label={m.ldap_bind_password()} type="password" bind:input={$inputs.ldapBindPassword} />
<FormInput
label="LDAP Base DN"
label={m.ldap_base_dn()}
placeholder="dc=example,dc=com"
bind:input={$inputs.ldapBase}
/>
<FormInput
label="User Search Filter"
description="The Search filter to use to search/sync users."
label={m.user_search_filter()}
description={m.the_search_filter_to_use_to_search_or_sync_users()}
placeholder="(objectClass=person)"
bind:input={$inputs.ldapUserSearchFilter}
/>
<FormInput
label="Groups Search Filter"
description="The Search filter to use to search/sync groups."
label={m.groups_search_filter()}
description={m.the_search_filter_to_use_to_search_or_sync_groups()}
placeholder="(objectClass=groupOfNames)"
bind:input={$inputs.ldapUserGroupSearchFilter}
/>
<CheckboxWithLabel
id="skip-cert-verify"
label="Skip Certificate Verification"
description="This can be useful for self-signed certificates."
label={m.skip_certificate_verification()}
description={m.this_can_be_useful_for_selfsigned_certificates()}
bind:checked={$inputs.ldapSkipCertVerify.value}
/>
</div>
<h4 class="mt-10 text-lg font-semibold">Attribute Mapping</h4>
<h4 class="mt-10 text-lg font-semibold">{m.attribute_mapping()}</h4>
<div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2">
<FormInput
label="User Unique Identifier Attribute"
description="The value of this attribute should never change."
label={m.user_unique_identifier_attribute()}
description={m.the_value_of_this_attribute_should_never_change()}
placeholder="uuid"
bind:input={$inputs.ldapAttributeUserUniqueIdentifier}
/>
<FormInput
label="Username Attribute"
label={m.username_attribute()}
placeholder="uid"
bind:input={$inputs.ldapAttributeUserUsername}
/>
<FormInput
label="User Mail Attribute"
label={m.user_mail_attribute()}
placeholder="mail"
bind:input={$inputs.ldapAttributeUserEmail}
/>
<FormInput
label="User First Name Attribute"
label={m.user_first_name_attribute()}
placeholder="givenName"
bind:input={$inputs.ldapAttributeUserFirstName}
/>
<FormInput
label="User Last Name Attribute"
label={m.user_last_name_attribute()}
placeholder="sn"
bind:input={$inputs.ldapAttributeUserLastName}
/>
<FormInput
label="User Profile Picture Attribute"
description="The value of this attribute can either be a URL, a binary or a base64 encoded image."
label={m.user_profile_picture_attribute()}
description={m.the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image()}
placeholder="jpegPhoto"
bind:input={$inputs.ldapAttributeUserProfilePicture}
/>
<FormInput
label="Group Members Attribute"
description="The attribute to use for querying members of a group."
label={m.group_members_attribute()}
description={m.the_attribute_to_use_for_querying_members_of_a_group()}
placeholder="member"
bind:input={$inputs.ldapAttributeGroupMember}
/>
<FormInput
label="Group Unique Identifier Attribute"
description="The value of this attribute should never change."
label={m.group_unique_identifier_attribute()}
description={m.the_value_of_this_attribute_should_never_change()}
placeholder="uuid"
bind:input={$inputs.ldapAttributeGroupUniqueIdentifier}
/>
<FormInput
label="Group Name Attribute"
label={m.group_name_attribute()}
placeholder="cn"
bind:input={$inputs.ldapAttributeGroupName}
/>
<FormInput
label="Admin Group Name"
description="Members of this group will have Admin Privileges in Pocket ID."
label={m.admin_group_name()}
description={m.members_of_this_group_will_have_admin_privileges_in_pocketid()}
placeholder="_admin_group_name"
bind:input={$inputs.ldapAttributeAdminGroup}
/>
@@ -202,11 +203,11 @@
<div class="mt-8 flex flex-wrap justify-end gap-3">
{#if ldapEnabled}
<Button variant="secondary" onclick={onDisable} disabled={uiConfigDisabled}>Disable</Button>
<Button variant="secondary" onclick={syncLdap} isLoading={ldapSyncing}>Sync now</Button>
<Button type="submit" disabled={uiConfigDisabled}>Save</Button>
<Button variant="secondary" onclick={onDisable} disabled={uiConfigDisabled}>{m.disable()}</Button>
<Button variant="secondary" onclick={syncLdap} isLoading={ldapSyncing}>{m.sync_now()}</Button>
<Button type="submit" disabled={uiConfigDisabled}>{m.save()}</Button>
{:else}
<Button onclick={onEnable} disabled={uiConfigDisabled}>Enable</Button>
<Button onclick={onEnable} disabled={uiConfigDisabled}>{m.enable()}</Button>
{/if}
</div>
</form>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import Button from '$lib/components/ui/button/button.svelte';
import { m } from '$lib/paraglide/messages';
import ApplicationImage from './application-image.svelte';
let {
@@ -23,7 +24,7 @@
<ApplicationImage
id="favicon"
imageClass="h-14 w-14 p-2"
label="Favicon"
label={m.favicon()}
bind:image={favicon}
imageURL="/api/application-configuration/favicon"
accept="image/x-icon"
@@ -31,7 +32,7 @@
<ApplicationImage
id="logo-light"
imageClass="h-32 w-32"
label="Light Mode Logo"
label={m.light_mode_logo()}
bind:image={logoLight}
imageURL="/api/application-configuration/logo?light=true"
forceColorScheme="light"
@@ -39,7 +40,7 @@
<ApplicationImage
id="logo-dark"
imageClass="h-32 w-32"
label="Dark Mode Logo"
label={m.dark_mode_logo()}
bind:image={logoDark}
imageURL="/api/application-configuration/logo?light=false"
forceColorScheme="dark"
@@ -47,13 +48,13 @@
<ApplicationImage
id="background-image"
imageClass="h-[350px] max-w-[500px]"
label="Background Image"
label={m.background_image()}
bind:image={backgroundImage}
imageURL="/api/application-configuration/background-image"
/>
</div>
<div class="flex justify-end">
<Button class="mt-5" onclick={() => callback(logoLight, logoDark, backgroundImage, favicon)}
>Save</Button
>{m.save()}</Button
>
</div>

View File

@@ -12,6 +12,7 @@
import { slide } from 'svelte/transition';
import OIDCClientForm from './oidc-client-form.svelte';
import OIDCClientList from './oidc-client-list.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props();
let clients = $state(data.clients);
@@ -29,7 +30,7 @@
const clientSecret = await oidcService.createClientSecret(createdClient.id);
clientSecretStore.set(clientSecret);
goto(`/settings/admin/oidc-clients/${createdClient.id}`);
toast.success('OIDC client created successfully');
toast.success(m.oidc_client_created_successfully());
return true;
} catch (e) {
axiosErrorToast(e);
@@ -39,18 +40,18 @@
</script>
<svelte:head>
<title>OIDC Clients</title>
<title>{m.oidc_clients()}</title>
</svelte:head>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Create OIDC Client</Card.Title>
<Card.Description>Add a new OIDC client to {$appConfigStore.appName}.</Card.Description>
<Card.Title>{m.create_oidc_client()}</Card.Title>
<Card.Description>{m.add_a_new_oidc_client_to_appname({ appName: $appConfigStore.appName})}</Card.Description>
</div>
{#if !expandAddClient}
<Button on:click={() => (expandAddClient = true)}>Add OIDC Client</Button>
<Button on:click={() => (expandAddClient = true)}>{m.add_oidc_client()}</Button>
{:else}
<Button class="h-8 p-3" variant="ghost" on:click={() => (expandAddClient = false)}>
<LucideMinus class="h-5 w-5" />
@@ -69,7 +70,7 @@
<Card.Root>
<Card.Header>
<Card.Title>Manage OIDC Clients</Card.Title>
<Card.Title>{m.manage_oidc_clients()}</Card.Title>
</Card.Header>
<Card.Content>
<OIDCClientList {clients} requestOptions={clientsRequestOptions} />

View File

@@ -16,6 +16,7 @@
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import OidcForm from '../oidc-client-form.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props();
let client = $state({
@@ -27,13 +28,13 @@
const oidcService = new OidcService();
const setupDetails = $state({
'Authorization URL': `https://${$page.url.hostname}/authorize`,
'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`,
'Token URL': `https://${$page.url.hostname}/api/oidc/token`,
'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`,
'Logout URL': `https://${$page.url.hostname}/api/oidc/end-session`,
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`,
PKCE: client.pkceEnabled ? 'Enabled' : 'Disabled'
[m.authorization_url()]: `https://${$page.url.hostname}/authorize`,
[m.oidc_discovery_url()]: `https://${$page.url.hostname}/.well-known/openid-configuration`,
[m.token_url()]: `https://${$page.url.hostname}/api/oidc/token`,
[m.userinfo_url()]: `https://${$page.url.hostname}/api/oidc/userinfo`,
[m.logout_url()]: `https://${$page.url.hostname}/api/oidc/end-session`,
[m.certificate_url()]: `https://${$page.url.hostname}/.well-known/jwks.json`,
[m.pkce()]: client.pkceEnabled ? m.enabled() : m.disabled()
});
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
@@ -45,11 +46,11 @@
: Promise.resolve();
client.isPublic = updatedClient.isPublic;
setupDetails.PKCE = updatedClient.pkceEnabled ? 'Enabled' : 'Disabled';
setupDetails[m.pkce()] = updatedClient.pkceEnabled ? m.enabled() : m.disabled();
await Promise.all([dataPromise, imagePromise])
.then(() => {
toast.success('OIDC client updated successfully');
toast.success(m.oidc_client_updated_successfully());
})
.catch((e) => {
axiosErrorToast(e);
@@ -61,17 +62,17 @@
async function createClientSecret() {
openConfirmDialog({
title: 'Create new client secret',
title: m.create_new_client_secret(),
message:
'Are you sure you want to create a new client secret? The old one will be invalidated.',
m.are_you_sure_you_want_to_create_a_new_client_secret(),
confirm: {
label: 'Generate',
label: m.generate(),
destructive: true,
action: async () => {
try {
const clientSecret = await oidcService.createClientSecret(client.id);
clientSecretStore.set(clientSecret);
toast.success('New client secret created successfully');
toast.success(m.new_client_secret_created_successfully());
} catch (e) {
axiosErrorToast(e);
}
@@ -84,7 +85,7 @@
await oidcService
.updateAllowedUserGroups(client.id, allowedGroups)
.then(() => {
toast.success('Allowed user groups updated successfully');
toast.success(m.allowed_user_groups_updated_successfully());
})
.catch((e) => {
axiosErrorToast(e);
@@ -97,12 +98,12 @@
</script>
<svelte:head>
<title>OIDC Client {client.name}</title>
<title>{m.oidc_client_name({ name: client.name })}</title>
</svelte:head>
<div>
<a class="text-muted-foreground flex text-sm" href="/settings/admin/oidc-clients"
><LucideChevronLeft class="h-5 w-5" /> Back</a
><LucideChevronLeft class="h-5 w-5" /> {m.back()}</a
>
</div>
<Card.Root>
@@ -112,14 +113,14 @@
<Card.Content>
<div class="flex flex-col">
<div class="mb-2 flex flex-col sm:flex-row sm:items-center">
<Label class="mb-0 w-44">Client ID</Label>
<Label class="mb-0 w-44">{m.client_id()}</Label>
<CopyToClipboard value={client.id}>
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
</CopyToClipboard>
</div>
{#if !client.isPublic}
<div class="mb-2 mt-1 flex flex-col sm:flex-row sm:items-center">
<Label class="mb-0 w-44">Client secret</Label>
<Label class="mb-0 w-44">{m.client_secret()}</Label>
{#if $clientSecretStore}
<CopyToClipboard value={$clientSecretStore}>
<span class="text-muted-foreground text-sm" data-testid="client-secret">
@@ -158,7 +159,7 @@
{#if !showAllDetails}
<div class="mt-4 flex justify-center">
<Button on:click={() => (showAllDetails = true)} size="sm" variant="ghost"
>Show more details</Button
>{m.show_more_details()}</Button
>
</div>
{/if}
@@ -172,11 +173,11 @@
</Card.Root>
<CollapsibleCard
id="allowed-user-groups"
title="Allowed User Groups"
description="Add user groups to this client to restrict access to users in these groups. If no user groups are selected, all users will have access to this client."
title={m.allowed_user_groups()}
description={m.add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups()}
>
<UserGroupSelection bind:selectedGroupIds={client.allowedUserGroupIds} />
<div class="mt-5 flex justify-end">
<Button on:click={() => updateUserGroupClients(client.allowedUserGroupIds)}>Save</Button>
<Button on:click={() => updateUserGroupClients(client.allowedUserGroupIds)}>{m.save()}</Button>
</div>
</CollapsibleCard>

View File

@@ -2,6 +2,7 @@
import * as Dialog from '$lib/components/ui/dialog';
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte';
import { m } from '$lib/paraglide/messages';
let {
oneTimeLink = $bindable()
@@ -19,13 +20,12 @@
<Dialog.Root open={!!oneTimeLink} {onOpenChange}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>One Time Link</Dialog.Title>
<Dialog.Title>{m.one_time_link()}</Dialog.Title>
<Dialog.Description
>Use this link to sign in once. This is needed for users who haven't added a passkey yet or
have lost it.</Dialog.Description
>{m.use_this_link_to_sign_in_once()}</Dialog.Description
>
</Dialog.Header>
<Label for="one-time-link">One Time Link</Label>
<Label for="one-time-link">{m.one_time_link()}</Label>
<Input id="one-time-link" value={oneTimeLink} readonly />
</Dialog.Content>
</Dialog.Root>

View File

@@ -2,6 +2,7 @@
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { m } from '$lib/paraglide/messages';
import { LucideMinus, LucidePlus } from 'lucide-svelte';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
@@ -53,7 +54,7 @@
on:click={() => (callbackURLs = [...callbackURLs, ''])}
>
<LucidePlus class="mr-1 h-4 w-4" />
{callbackURLs.length === 0 ? 'Add' : 'Add another'}
{callbackURLs.length === 0 ? m.add() : m.add_another()}
</Button>
{/if}
</div>

View File

@@ -12,6 +12,7 @@
import { createForm } from '$lib/utils/form-util';
import { z } from 'zod';
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
import { m } from '$lib/paraglide/messages';
let {
callback,
@@ -79,16 +80,16 @@
<form onsubmit={onSubmit}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-7 sm:flex-row">
<FormInput label="Name" class="w-full" bind:input={$inputs.name} />
<FormInput label={m.name()} class="w-full" bind:input={$inputs.name} />
<div></div>
<OidcCallbackUrlInput
label="Callback URLs"
label={m.callback_urls()}
class="w-full"
bind:callbackURLs={$inputs.callbackURLs.value}
bind:error={$inputs.callbackURLs.error}
/>
<OidcCallbackUrlInput
label="Logout Callback URLs"
label={m.logout_callback_urls()}
class="w-full"
allowEmpty
bind:callbackURLs={$inputs.logoutCallbackURLs.value}
@@ -96,8 +97,8 @@
/>
<CheckboxWithLabel
id="public-client"
label="Public Client"
description="Public clients do not have a client secret and use PKCE instead. Enable this if your client is a SPA or mobile app."
label={m.public_client()}
description={m.public_clients_do_not_have_a_client_secret_and_use_pkce_instead()}
onCheckedChange={(v) => {
if (v == true) form.setValue('pkceEnabled', true);
}}
@@ -105,21 +106,21 @@
/>
<CheckboxWithLabel
id="pkce"
label="PKCE"
description="Public Key Code Exchange is a security feature to prevent CSRF and authorization code interception attacks."
label={m.pkce()}
description={m.public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks()}
disabled={$inputs.isPublic.value}
bind:checked={$inputs.pkceEnabled.value}
/>
</div>
<div class="mt-8">
<Label for="logo">Logo</Label>
<Label for="logo">{m.logo()}</Label>
<div class="mt-2 flex items-end gap-3">
{#if logoDataURL}
<div class="bg-muted h-32 w-32 rounded-2xl p-3">
<img
class="m-auto max-h-full max-w-full object-contain"
src={logoDataURL}
alt={`${$inputs.name.value} logo`}
alt={m.name_logo({name: $inputs.name.value})}
/>
</div>
{/if}
@@ -131,17 +132,17 @@
onchange={onLogoChange}
>
<Button variant="secondary">
{logoDataURL ? 'Change Logo' : 'Upload Logo'}
{logoDataURL ? m.change_logo() : m.upload_logo()}
</Button>
</FileInput>
{#if logoDataURL}
<Button variant="outline" on:click={resetLogo}>Remove Logo</Button>
<Button variant="outline" on:click={resetLogo}>{m.remove_logo()}</Button>
{/if}
</div>
</div>
</div>
<div class="w-full"></div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button>
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
</form>

View File

@@ -10,6 +10,7 @@
import { LucidePencil, LucideTrash } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import OneTimeLinkModal from './client-secret.svelte';
import { m } from '$lib/paraglide/messages';
let {
clients = $bindable(),
@@ -25,16 +26,16 @@
async function deleteClient(client: OidcClient) {
openConfirmDialog({
title: `Delete ${client.name}`,
message: 'Are you sure you want to delete this OIDC client?',
title: m.delete_name({name: client.name}),
message: m.are_you_sure_you_want_to_delete_this_oidc_client(),
confirm: {
label: 'Delete',
label: m.delete(),
destructive: true,
action: async () => {
try {
await oidcService.removeClient(client.id);
clients = await oidcService.listClients(requestOptions!);
toast.success('OIDC client deleted successfully');
toast.success(m.oidc_client_deleted_successfully());
} catch (e) {
axiosErrorToast(e);
}
@@ -49,9 +50,9 @@
{requestOptions}
onRefresh={async (o) => (clients = await oidcService.listClients(o))}
columns={[
{ label: 'Logo' },
{ label: 'Name', sortColumn: 'name' },
{ label: 'Actions', hidden: true }
{ label: m.logo() },
{ label: m.name(), sortColumn: 'name' },
{ label: m.actions(), hidden: true }
]}
>
{#snippet rows({ item })}
@@ -61,7 +62,7 @@
<img
class="m-auto max-h-full max-w-full object-contain"
src="/api/oidc/clients/{item.id}/logo"
alt="{item.name} logo"
alt={m.name_logo({name: item.name})}
/>
</div>
{/if}
@@ -72,9 +73,9 @@
href="/settings/admin/oidc-clients/{item.id}"
size="sm"
variant="outline"
aria-label="Edit"><LucidePencil class="h-3 w-3 " /></Button
aria-label={m.edit()}><LucidePencil class="h-3 w-3 " /></Button
>
<Button on:click={() => deleteClient(item)} size="sm" variant="outline" aria-label="Delete"
<Button on:click={() => deleteClient(item)} size="sm" variant="outline" aria-label={m.delete()}
><LucideTrash class="h-3 w-3 text-red-500" /></Button
>
</Table.Cell>

View File

@@ -11,6 +11,7 @@
import { slide } from 'svelte/transition';
import UserGroupForm from './user-group-form.svelte';
import UserGroupList from './user-group-list.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props();
let userGroups = $state(data.userGroups);
@@ -24,7 +25,7 @@
await userGroupService
.create(userGroup)
.then((createdUserGroup) => {
toast.success('User group created successfully');
toast.success(m.user_group_created_successfully());
goto(`/settings/admin/user-groups/${createdUserGroup.id}`);
})
.catch((e) => {
@@ -36,18 +37,18 @@
</script>
<svelte:head>
<title>User Groups</title>
<title>{m.user_groups()}</title>
</svelte:head>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Create User Group</Card.Title>
<Card.Description>Create a new group that can be assigned to users.</Card.Description>
<Card.Title>{m.create_user_group()}</Card.Title>
<Card.Description>{m.create_a_new_group_that_can_be_assigned_to_users()}</Card.Description>
</div>
{#if !expandAddUserGroup}
<Button on:click={() => (expandAddUserGroup = true)}>Add Group</Button>
<Button on:click={() => (expandAddUserGroup = true)}>{m.add_group()}</Button>
{:else}
<Button class="h-8 p-3" variant="ghost" on:click={() => (expandAddUserGroup = false)}>
<LucideMinus class="h-5 w-5" />
@@ -66,7 +67,7 @@
<Card.Root>
<Card.Header>
<Card.Title>Manage User Groups</Card.Title>
<Card.Title>{m.manage_user_groups()}</Card.Title>
</Card.Header>
<Card.Content>
<UserGroupList {userGroups} requestOptions={userGroupsRequestOptions} />

View File

@@ -13,6 +13,7 @@
import { toast } from 'svelte-sonner';
import UserGroupForm from '../user-group-form.svelte';
import UserSelection from '../user-selection.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props();
let userGroup = $state({
@@ -27,7 +28,7 @@
let success = true;
await userGroupService
.update(userGroup.id, updatedUserGroup)
.then(() => toast.success('User group updated successfully'))
.then(() => toast.success(m.user_group_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
success = false;
@@ -39,7 +40,7 @@
async function updateUserGroupUsers(userIds: string[]) {
await userGroupService
.updateUsers(userGroup.id, userIds)
.then(() => toast.success('Users updated successfully'))
.then(() => toast.success(m.users_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
});
@@ -48,7 +49,7 @@
async function updateCustomClaims() {
await customClaimService
.updateUserGroupCustomClaims(userGroup.id, userGroup.customClaims)
.then(() => toast.success('Custom claims updated successfully'))
.then(() => toast.success(m.custom_claims_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
});
@@ -56,20 +57,20 @@
</script>
<svelte:head>
<title>User Group Details {userGroup.name}</title>
<title>{m.user_group_details_name({ name: userGroup.name })}</title>
</svelte:head>
<div class="flex items-center justify-between">
<a class="text-muted-foreground flex text-sm" href="/settings/admin/user-groups"
><LucideChevronLeft class="h-5 w-5" /> Back</a
><LucideChevronLeft class="h-5 w-5" /> {m.back()}</a
>
{#if !!userGroup.ldapId}
<Badge variant="default" class="">LDAP</Badge>
<Badge variant="default" class="">{m.ldap()}</Badge>
{/if}
</div>
<Card.Root>
<Card.Header>
<Card.Title>General</Card.Title>
<Card.Title>{m.general()}</Card.Title>
</Card.Header>
<Card.Content>
@@ -79,8 +80,8 @@
<Card.Root>
<Card.Header>
<Card.Title>Users</Card.Title>
<Card.Description>Assign users to this group.</Card.Description>
<Card.Title>{m.users()}</Card.Title>
<Card.Description>{m.assign_users_to_this_group()}</Card.Description>
</Card.Header>
<Card.Content>
@@ -91,7 +92,7 @@
<div class="mt-5 flex justify-end">
<Button
disabled={!!userGroup.ldapId && $appConfigStore.ldapEnabled}
on:click={() => updateUserGroupUsers(userGroup.userIds)}>Save</Button
on:click={() => updateUserGroupUsers(userGroup.userIds)}>{m.save()}</Button
>
</div>
</Card.Content>
@@ -99,11 +100,11 @@
<CollapsibleCard
id="user-group-custom-claims"
title="Custom Claims"
description="Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested. Custom claims defined on the user will be prioritized if there are conflicts."
title={m.custom_claims()}
description={m.custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized()}
>
<CustomClaimsInput bind:customClaims={userGroup.customClaims} />
<div class="mt-5 flex justify-end">
<Button onclick={updateCustomClaims} type="submit">Save</Button>
<Button onclick={updateCustomClaims} type="submit">{m.save()}</Button>
</div>
</CollapsibleCard>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { UserGroupCreate } from '$lib/types/user-group.type';
import { createForm } from '$lib/utils/form-util';
@@ -60,23 +61,23 @@
<div class="flex flex-col gap-3 sm:flex-row">
<div class="w-full">
<FormInput
label="Friendly Name"
description="Name that will be displayed in the UI"
label={m.friendly_name()}
description={m.name_that_will_be_displayed_in_the_ui()}
bind:input={$inputs.friendlyName}
onInput={onFriendlyNameInput}
/>
</div>
<div class="w-full">
<FormInput
label="Name"
description={`Name that will be in the "groups" claim`}
label={m.name()}
description={m.name_that_will_be_in_the_groups_claim()}
bind:input={$inputs.name}
onInput={onNameInput}
/>
</div>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button>
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
</fieldset>
</form>

View File

@@ -5,6 +5,7 @@
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import UserGroupService from '$lib/services/user-group-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
@@ -26,16 +27,16 @@
async function deleteUserGroup(userGroup: UserGroup) {
openConfirmDialog({
title: `Delete ${userGroup.name}`,
message: 'Are you sure you want to delete this user group?',
title: m.delete_name({ name: userGroup.name }),
message: m.are_you_sure_you_want_to_delete_this_user_group(),
confirm: {
label: 'Delete',
label: m.delete(),
destructive: true,
action: async () => {
try {
await userGroupService.remove(userGroup.id);
userGroups = await userGroupService.list(requestOptions!);
toast.success('User group deleted successfully');
toast.success(m.user_group_deleted_successfully());
} catch (e) {
axiosErrorToast(e);
}
@@ -50,11 +51,11 @@
onRefresh={async (o) => (userGroups = await userGroupService.list(o))}
{requestOptions}
columns={[
{ label: 'Friendly Name', sortColumn: 'friendlyName' },
{ label: 'Name', sortColumn: 'name' },
{ label: 'User Count', sortColumn: 'userCount' },
...($appConfigStore.ldapEnabled ? [{ label: 'Source' }] : []),
{ label: 'Actions', hidden: true }
{ label: m.friendly_name(), sortColumn: 'friendlyName' },
{ label: m.name(), sortColumn: 'name' },
{ label: m.user_count(), sortColumn: 'userCount' },
...($appConfigStore.ldapEnabled ? [{ label: m.source() }] : []),
{ label: m.actions(), hidden: true }
]}
>
{#snippet rows({ item })}
@@ -63,7 +64,7 @@
<Table.Cell>{item.userCount}</Table.Cell>
{#if $appConfigStore.ldapEnabled}
<Table.Cell>
<Badge variant={item.ldapId ? 'default' : 'outline'}>{item.ldapId ? 'LDAP' : 'Local'}</Badge
<Badge variant={item.ldapId ? 'default' : 'outline'}>{item.ldapId ? m.ldap() : m.local()}</Badge
>
</Table.Cell>
{/if}
@@ -72,18 +73,18 @@
<DropdownMenu.Trigger asChild let:builder>
<Button aria-haspopup="true" size="icon" variant="ghost" builders={[builder]}>
<Ellipsis class="h-4 w-4" />
<span class="sr-only">Toggle menu</span>
<span class="sr-only">{m.toggle_menu()}</span>
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item href="/settings/admin/user-groups/{item.id}"
><LucidePencil class="mr-2 h-4 w-4" /> Edit</DropdownMenu.Item
><LucidePencil class="mr-2 h-4 w-4" /> {m.edit()}</DropdownMenu.Item
>
{#if !item.ldapId || !$appConfigStore.ldapEnabled}
<DropdownMenu.Item
class="text-red-500 focus:!text-red-700"
on:click={() => deleteUserGroup(item)}
><LucideTrash class="mr-2 h-4 w-4" />Delete</DropdownMenu.Item
><LucideTrash class="mr-2 h-4 w-4" />{m.delete()}</DropdownMenu.Item
>
{/if}
</DropdownMenu.Content>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import AdvancedTable from '$lib/components/advanced-table.svelte';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { User } from '$lib/types/user.type';
@@ -35,8 +36,8 @@
onRefresh={async (o) => (users = await userService.list(o))}
{requestOptions}
columns={[
{ label: 'Name', sortColumn: 'firstName' },
{ label: 'Email', sortColumn: 'email' }
{ label: m.name(), sortColumn: 'firstName' },
{ label: m.email(), sortColumn: 'email' }
]}
bind:selectedIds={selectedUserIds}
{selectionDisabled}

View File

@@ -10,6 +10,7 @@
import { slide } from 'svelte/transition';
import UserForm from './user-form.svelte';
import UserList from './user-list.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props();
let users = $state(data.users);
@@ -23,7 +24,7 @@
let success = true;
await userService
.create(user)
.then(() => toast.success('User created successfully'))
.then(() => toast.success(m.user_created_successfully()))
.catch((e) => {
axiosErrorToast(e);
success = false;
@@ -35,18 +36,18 @@
</script>
<svelte:head>
<title>Users</title>
<title>{m.users()}</title>
</svelte:head>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Create User</Card.Title>
<Card.Description>Add a new user to {$appConfigStore.appName}.</Card.Description>
<Card.Title>{m.create_user()}</Card.Title>
<Card.Description>{m.add_a_new_user_to_appname({ appName: $appConfigStore.appName })}.</Card.Description>
</div>
{#if !expandAddUser}
<Button on:click={() => (expandAddUser = true)}>Add User</Button>
<Button on:click={() => (expandAddUser = true)}>{m.add_user()}</Button>
{:else}
<Button class="h-8 p-3" variant="ghost" on:click={() => (expandAddUser = false)}>
<LucideMinus class="h-5 w-5" />
@@ -65,7 +66,7 @@
<Card.Root>
<Card.Header>
<Card.Title>Manage Users</Card.Title>
<Card.Title>{m.manage_users()}</Card.Title>
</Card.Header>
<Card.Content>
<UserList {users} requestOptions={usersRequestOptions} />

View File

@@ -14,6 +14,7 @@
import { LucideChevronLeft } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import UserForm from '../user-form.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props();
let user = $state({
@@ -27,7 +28,7 @@
async function updateUserGroups(userIds: string[]) {
await userService
.updateUserGroups(user.id, userIds)
.then(() => toast.success('User groups updated successfully'))
.then(() => toast.success(m.user_groups_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
});
@@ -37,7 +38,7 @@
let success = true;
await userService
.update(user.id, updatedUser)
.then(() => toast.success('User updated successfully'))
.then(() => toast.success(m.user_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
success = false;
@@ -49,7 +50,7 @@
async function updateCustomClaims() {
await customClaimService
.updateUserCustomClaims(user.id, user.customClaims)
.then(() => toast.success('Custom claims updated successfully'))
.then(() => toast.success(m.custom_claims_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
});
@@ -58,26 +59,38 @@
async function updateProfilePicture(image: File) {
await userService
.updateProfilePicture(user.id, image)
.then(() => toast.success('Profile picture updated successfully'))
.then(() => toast.success(m.profile_picture_updated_successfully()))
.catch(axiosErrorToast);
}
async function resetProfilePicture() {
await userService
.resetProfilePicture(user.id)
.then(() => toast.success(m.profile_picture_has_been_reset()))
.catch(axiosErrorToast);
}
</script>
<svelte:head>
<title>User Details {user.firstName} {user.lastName}</title>
<title
>{m.user_details_firstname_lastname({
firstName: user.firstName,
lastName: user.lastName
})}</title
>
</svelte:head>
<div class="flex items-center justify-between">
<a class="text-muted-foreground flex text-sm" href="/settings/admin/users"
><LucideChevronLeft class="h-5 w-5" /> Back</a
><LucideChevronLeft class="h-5 w-5" /> {m.back()}</a
>
{#if !!user.ldapId}
<Badge variant="default" class="">LDAP</Badge>
<Badge variant="default" class="">{m.ldap()}</Badge>
{/if}
</div>
<Card.Root>
<Card.Header>
<Card.Title>General</Card.Title>
<Card.Title>{m.general()}</Card.Title>
</Card.Header>
<Card.Content>
<UserForm existingUser={user} callback={updateUser} />
@@ -89,15 +102,16 @@
<ProfilePictureSettings
userId={user.id}
isLdapUser={!!user.ldapId}
callback={updateProfilePicture}
updateCallback={updateProfilePicture}
resetCallback={resetProfilePicture}
/>
</Card.Content>
</Card.Root>
<CollapsibleCard
id="user-groups"
title="User Groups"
description="Manage which groups this user belongs to."
title={m.user_groups()}
description={m.manage_which_groups_this_user_belongs_to()}
>
<UserGroupSelection
bind:selectedGroupIds={user.userGroupIds}
@@ -107,18 +121,18 @@
<Button
on:click={() => updateUserGroups(user.userGroupIds)}
disabled={!!user.ldapId && $appConfigStore.ldapEnabled}
type="submit">Save</Button
type="submit">{m.save()}</Button
>
</div>
</CollapsibleCard>
<CollapsibleCard
id="user-custom-claims"
title="Custom Claims"
description="Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested."
title={m.custom_claims()}
description={m.custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user()}
>
<CustomClaimsInput bind:customClaims={user.customClaims} />
<div class="mt-5 flex justify-end">
<Button on:click={updateCustomClaims} type="submit">Save</Button>
<Button on:click={updateCustomClaims} type="submit">{m.save()}</Button>
</div>
</CollapsibleCard>

Some files were not shown because too many files have changed in this diff Show More