Compare commits

...

88 Commits

Author SHA1 Message Date
Kyle Mendell
f5cece3b0e release: 1.4.0 2025-06-19 13:21:49 -05:00
Kyle Mendell
d5485238b8 feat: configurable local ipv6 ranges for audit log (#657) 2025-06-19 19:56:27 +02:00
Kyle Mendell
ac5a121f66 feat: location filter for global audit log (#662) 2025-06-19 17:12:53 +00:00
Elias Schneider
481df3bcb9 chore: add configuration for backend hot reloading 2025-06-19 18:45:01 +02:00
Mr Snake
7677a3de2c feat: allow setting unix socket mode (#661) 2025-06-18 18:41:57 +02:00
Elias Schneider
1f65c01b04 chore(translations): update translations via Crowdin (#659) 2025-06-18 09:11:36 -05:00
Elias Schneider
d5928f6fea chore: remove unused crypto util 2025-06-17 17:55:52 +02:00
Elias Schneider
bef77ac8dc fix: use inline style for dynamic background image URL instead of Tailwind class 2025-06-17 13:10:02 +02:00
Elias Schneider
c8eb034c49 chore: use v1 tag in example docker-compose.yml 2025-06-16 23:31:16 +02:00
Elias Schneider
c77167df46 ci/cd: cancel build-next action if new one starts 2025-06-16 16:12:33 +02:00
Elias Schneider
3717a663d9 ci/cd: only build required binaries for next image 2025-06-16 16:09:09 +02:00
Elias Schneider
5814549cbe refactor: run formatter 2025-06-16 16:06:11 +02:00
Elias Schneider
2e5d268798 fix: explicitly cache images to prevent unexpected behavior 2025-06-16 15:59:14 +02:00
Elias Schneider
4ed312251e chore(translations): update translations via Crowdin (#652) 2025-06-15 09:57:08 -05:00
Elias Schneider
946c534b08 fix: center oidc client images if they are smaller than the box 2025-06-14 19:33:40 +02:00
Kyle Mendell
883877adec feat: ui accent colors (#643)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-06-13 07:06:54 -05:00
Elias Schneider
215531d65c feat: use icon instead of text on application image update hover state 2025-06-13 12:07:14 +02:00
Elias Schneider
c0f055c3c0 chore(translations): update translations via Crowdin (#649) 2025-06-10 22:01:50 -05:00
Alessandro (Ale) Segala
d77044882d fix: reduce duration of animations on login and signin page (#648) 2025-06-10 21:14:55 +02:00
Alessandro (Ale) Segala
d6795300b1 feat: auto-focus on the login buttons (#647) 2025-06-10 21:13:36 +02:00
Elias Schneider
fd3c76ffa3 refactor: run formatter 2025-06-10 14:43:56 +02:00
Amazingca
698bc3a35a chore(translations): Update spelling and grammar in en.json (#650) 2025-06-10 07:34:49 -05:00
Elias Schneider
1bcb50edc3 fix: allow images with uppercase file extension 2025-06-10 11:11:03 +02:00
Elias Schneider
9700afb9cb chore(translations): update translations via Crowdin (#644) 2025-06-10 10:36:52 +02:00
Elias Schneider
9ce82fb205 release: 1.3.1 2025-06-09 22:59:13 +02:00
Elias Schneider
2935236ace fix: change timestamp of client_credentials.sql migration 2025-06-09 22:58:56 +02:00
Elias Schneider
c821b675b8 release: 1.3.0 2025-06-09 21:37:27 +02:00
Elias Schneider
a09d529027 chore: add branch check to release script 2025-06-09 21:37:00 +02:00
Alessandro (Ale) Segala
b62b61fb01 feat: allow introspection and device code endpoints to use Federated Client Credentials (#640) 2025-06-09 21:17:55 +02:00
Alessandro (Ale) Segala
df5c1ed1f8 chore: add docs link and rename to Federated Client Credentials (#636)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-06-09 21:15:37 +02:00
Elias Schneider
f4af35f86b chore(translations): update translations via Crowdin (#642) 2025-06-09 18:36:37 +02:00
Elias Schneider
657a51f7ed fix: misleading text for disable animations option 2025-06-09 18:22:55 +02:00
Elias Schneider
575b2f71e9 fix: use full width for audit log filters 2025-06-09 18:16:53 +02:00
Elias Schneider
97f7326da4 feat: new color theme for the UI 2025-06-09 18:09:13 +02:00
Elias Schneider
242d87a54b chore: upgrade to Shadcn v1.0.0 2025-06-09 18:08:39 +02:00
Kyle Mendell
c111b79147 feat: oidc client data preview (#624)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-06-09 15:46:03 +00:00
Elias Schneider
61bf14225b chore(translations): update translations via Crowdin (#637) 2025-06-09 11:58:14 +02:00
github-actions[bot]
c1e98411b6 chore: update AAGUIDs (#639)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-06-09 11:48:21 +02:00
Elias Schneider
b25e95fc4a ci/cd: add missing attestions permission 2025-06-08 16:15:23 +02:00
Elias Schneider
3cc82d8522 docs: remove difficult to maintain OpenAPI properties 2025-06-08 16:10:42 +02:00
Elias Schneider
ea4e48680c docs: fix pagination API docs 2025-06-08 16:04:58 +02:00
Elias Schneider
f403eed12c ci/cd: add missing permission 2025-06-08 16:03:40 +02:00
Elias Schneider
388a874922 refactor: upgrade to Zod v4 (#623) 2025-06-08 15:44:59 +02:00
Elias Schneider
9a4aab465a chore(translations): update translations via Crowdin (#632) 2025-06-08 15:44:22 +02:00
Kyle Mendell
a052cd6619 ci/cd: add workflow for building 'next' docker image (#633)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-06-08 15:42:41 +02:00
Elias Schneider
31a803b243 chore(translations): add Traditional Chinese files 2025-06-07 21:04:48 +02:00
Elias Schneider
1d2e41c04e chore(translations): update translations via Crowdin (#629) 2025-06-07 21:01:49 +02:00
Elias Schneider
b650d6d423 chore(translations): add Danish language files 2025-06-06 16:27:33 +02:00
Elias Schneider
156aad3057 chore(translations): update translations via Crowdin (#620) 2025-06-06 16:25:37 +02:00
Alessandro (Ale) Segala
05bfe00924 feat: JWT bearer assertions for client authentication (#566)
Co-authored-by: Kyle Mendell <ksm@ofkm.us>
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-06-06 12:23:51 +02:00
Mr Snake
035b2c022b feat: add unix socket support (#615) 2025-06-06 07:01:19 +00:00
Elias Schneider
61b62d4612 fix: OIDC client image can't be deleted 2025-06-06 08:50:33 +02:00
Elias Schneider
dc5d7bb2f3 refactor: run fomratter 2025-06-05 22:43:24 +02:00
Elias Schneider
5e9096e328 fix: UI config overridden by env variables don't apply on first start 2025-06-05 22:36:55 +02:00
Elias Schneider
34b4ba514f chore(translations): update translations via Crowdin (#614) 2025-06-05 15:42:28 +02:00
Elias Schneider
d217083059 feat: add API endpoint for user authorized clients 2025-06-04 09:23:44 +02:00
Elias Schneider
bdcef60cab fix: don't load app config and user on every route change 2025-06-04 08:52:34 +02:00
Elias Schneider
14f59ce3f3 release: 1.2.0 2025-06-03 22:33:40 +02:00
Elias Schneider
31ad904367 fix: page scrolls up on form submisssion 2025-06-03 21:12:21 +02:00
Elias Schneider
04fcf1110e fix: improve spacing on auth screens 2025-06-03 21:09:32 +02:00
Elias Schneider
eb9b6433ae chore(translations): update translations via Crowdin (#606) 2025-06-02 15:58:52 +02:00
Elias Schneider
b9489b5e9a fix: whitelist authorization header for CORS 2025-06-02 15:55:29 +02:00
Elias Schneider
bd1c69b7b7 Update Crowdin configuration file 2025-06-02 14:17:21 +02:00
Elias Schneider
23dc235bac Update Crowdin configuration file 2025-06-02 14:13:16 +02:00
Elias Schneider
2440379cd1 fix: fallback to primary language if no translation available for specific country 2025-06-02 14:08:32 +02:00
Elias Schneider
6c00aaa3ef fix: allow users to update their locale even when own account update disabled 2025-06-02 11:35:13 +02:00
Elias Schneider
00259f8819 tests: adapt unit test for new app config default value behavior 2025-06-01 20:54:53 +02:00
Elias Schneider
decf8ec70b fix: clear default app config variables from database 2025-06-01 20:46:44 +02:00
Elias Schneider
c24a5546a5 docs: use https in .env.example 2025-05-31 20:51:55 +02:00
Elias Schneider
312421d777 chore(translations): update translations via Crowdin (#599) 2025-05-31 18:44:34 +02:00
Elias Schneider
c42a29a66c chore(translations): update translations via Crowdin (#593) 2025-05-30 21:56:28 -05:00
Elias Schneider
afc317adf7 chore(translations): update translations via Crowdin (#590) 2025-05-29 23:47:58 +02:00
Alessandro (Ale) Segala
256f74d0a3 fix: don't use TOFU for logout callback URLs (#588) 2025-05-29 22:01:23 +02:00
Kyle Mendell
20d3f780a2 feat: auto detect callback url (#583)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-05-29 17:16:10 +02:00
Alessandro (Ale) Segala
6d6dc6646a fix: run jobs at interval instead of specific time (#585) 2025-05-29 17:15:35 +02:00
Alessandro (Ale) Segala
3d402fc0ca fix: small fixes in analytics_job (#582) 2025-05-28 11:12:44 -05:00
Kyle Mendell
b874681824 fix: show LAN for auditlog location for internal networks 2025-05-28 10:52:40 -05:00
Elias Schneider
97cbdfb1ef chore(translations): update translations via Crowdin (#579) 2025-05-28 10:21:03 -05:00
Elias Schneider
24889f9ebc release: 1.1.0 2025-05-28 11:30:09 +02:00
Elias Schneider
e0ec607198 feat: add daily heartbeat request for counting Pocket ID instances (#578) 2025-05-28 11:19:45 +02:00
Maxim Baz
d29fca155e ci/cd: tag container images with v{major} (#577) 2025-05-27 14:01:49 +02:00
Elias Schneider
e2e26b53b3 chore(translations): update translations via Crowdin (#575) 2025-05-26 11:07:40 +02:00
github-actions[bot]
948efbd9c1 chore: update AAGUIDs (#576)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-05-26 11:06:41 +02:00
Elias Schneider
f03b80f9d7 fix: run user group count inside a transaction 2025-05-25 22:24:28 +02:00
Kyle Mendell
38d7ee4432 feat: show allowed group count on oidc client list (#567)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-05-25 19:22:25 +00:00
Kyle Mendell
f66e8e8b44 fix: use ldapAttributeUserUsername for finding group members (#565)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-05-25 20:37:17 +02:00
Elias Schneider
ee133dbceb chore(translations): update translations via Crowdin (#573) 2025-05-25 20:14:46 +02:00
Elias Schneider
68e4b67bd2 feat: require user verification for passkey sign in 2025-05-25 17:09:05 +02:00
172 changed files with 6406 additions and 1892 deletions

View File

@@ -1,5 +1,5 @@
# See the documentation for more information: https://pocket-id.org/docs/configuration/environment-variables
APP_URL=http://localhost:1411
APP_URL=https://your-pocket-id-domain.com
TRUST_PROXY=false
MAXMIND_LICENSE_KEY=
PUID=1000

82
.github/workflows/build-next.yml vendored Normal file
View File

@@ -0,0 +1,82 @@
name: Build Next Image
on:
push:
branches:
- main
concurrency:
group: build-next-image
cancel-in-progress: true
jobs:
build-next:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
attestations: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: "backend/go.mod"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set DOCKER_IMAGE_NAME
run: |
# Lowercase REPO_OWNER which is required for containers
REPO_OWNER=${{ github.repository_owner }}
DOCKER_IMAGE_NAME="ghcr.io/${REPO_OWNER,,}/pocket-id"
echo "DOCKER_IMAGE_NAME=${DOCKER_IMAGE_NAME}" >>${GITHUB_ENV}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Build frontend
working-directory: frontend
run: npm run build
- name: Build binaries
run: sh scripts/development/build-binaries.sh --docker-only
- name: Build and push container image
id: build-push-image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ env.DOCKER_IMAGE_NAME }}:next
file: Dockerfile-prebuilt
- name: Container image attestation
uses: actions/attest-build-provenance@v2
with:
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
subject-digest: ${{ steps.build-push-image.outputs.digest }}
push-to-registry: true

View File

@@ -52,6 +52,7 @@ jobs:
tags: |
type=semver,pattern={{version}},prefix=v
type=semver,pattern={{major}}.{{minor}},prefix=v
type=semver,pattern={{major}},prefix=v
- name: Install frontend dependencies
working-directory: frontend

1
.gitignore vendored
View File

@@ -10,6 +10,7 @@ node_modules
/frontend/build
/backend/bin
pocket-id
/tests/test-results/*.json
# OS
.DS_Store

View File

@@ -1 +1 @@
1.0.0
1.4.0

View File

@@ -1,3 +1,85 @@
## [](https://github.com/pocket-id/pocket-id/compare/v1.3.1...v) (2025-06-19)
### Features
* allow setting unix socket mode ([#661](https://github.com/pocket-id/pocket-id/issues/661)) ([7677a3d](https://github.com/pocket-id/pocket-id/commit/7677a3de2c923c11a58bc8c4d1b2121d403a1504))
* auto-focus on the login buttons ([#647](https://github.com/pocket-id/pocket-id/issues/647)) ([d679530](https://github.com/pocket-id/pocket-id/commit/d6795300b158b85dd9feadd561b6ecd891f5db0d))
* configurable local ipv6 ranges for audit log ([#657](https://github.com/pocket-id/pocket-id/issues/657)) ([d548523](https://github.com/pocket-id/pocket-id/commit/d5485238b8fd4cc566af00eae2b17d69a119f991))
* location filter for global audit log ([#662](https://github.com/pocket-id/pocket-id/issues/662)) ([ac5a121](https://github.com/pocket-id/pocket-id/commit/ac5a121f664b8127d0faf30c0f93432f30e7f33a))
* ui accent colors ([#643](https://github.com/pocket-id/pocket-id/issues/643)) ([883877a](https://github.com/pocket-id/pocket-id/commit/883877adec6fc3e65bd5a705499449959b894fb5))
* use icon instead of text on application image update hover state ([215531d](https://github.com/pocket-id/pocket-id/commit/215531d65c6683609b0b4a5505fdb72696fdb93e))
### Bug Fixes
* allow images with uppercase file extension ([1bcb50e](https://github.com/pocket-id/pocket-id/commit/1bcb50edc335886dd722a4c69960c48cc3cd1687))
* center oidc client images if they are smaller than the box ([946c534](https://github.com/pocket-id/pocket-id/commit/946c534b0877a074a6b658060f9af27e4061397c))
* explicitly cache images to prevent unexpected behavior ([2e5d268](https://github.com/pocket-id/pocket-id/commit/2e5d2687982186c12e530492292d49895cb6043a))
* reduce duration of animations on login and signin page ([#648](https://github.com/pocket-id/pocket-id/issues/648)) ([d770448](https://github.com/pocket-id/pocket-id/commit/d77044882d5a41da22df1c0099c1eb1f20bcbc5b))
* use inline style for dynamic background image URL instead of Tailwind class ([bef77ac](https://github.com/pocket-id/pocket-id/commit/bef77ac8dca2b98b6732677aaafbc28f79d00487))
## [](https://github.com/pocket-id/pocket-id/compare/v1.3.0...v) (2025-06-09)
### Bug Fixes
* change timestamp of `client_credentials.sql` migration ([2935236](https://github.com/pocket-id/pocket-id/commit/2935236acee9c78c2fe6787ec8b5f53ae0eca047))
## [](https://github.com/pocket-id/pocket-id/compare/v1.2.0...v) (2025-06-09)
### Features
* add API endpoint for user authorized clients ([d217083](https://github.com/pocket-id/pocket-id/commit/d217083059120171d5c555b09eefe6ba3c8a8d42))
* add unix socket support ([#615](https://github.com/pocket-id/pocket-id/issues/615)) ([035b2c0](https://github.com/pocket-id/pocket-id/commit/035b2c022bfd2b98f13355ec7a126e0f1ab3ebd8))
* allow introspection and device code endpoints to use Federated Client Credentials ([#640](https://github.com/pocket-id/pocket-id/issues/640)) ([b62b61f](https://github.com/pocket-id/pocket-id/commit/b62b61fb017dba31a6fc612c138bebf370d3956c))
* JWT bearer assertions for client authentication ([#566](https://github.com/pocket-id/pocket-id/issues/566)) ([05bfe00](https://github.com/pocket-id/pocket-id/commit/05bfe0092450c9bc26d03c6a54c21050eef8f63a))
* new color theme for the UI ([97f7326](https://github.com/pocket-id/pocket-id/commit/97f7326da40265a954340d519661969530f097a0))
* oidc client data preview ([#624](https://github.com/pocket-id/pocket-id/issues/624)) ([c111b79](https://github.com/pocket-id/pocket-id/commit/c111b7914731a3cafeaa55102b515f84a1ad74dc))
### Bug Fixes
* don't load app config and user on every route change ([bdcef60](https://github.com/pocket-id/pocket-id/commit/bdcef60cab6a61e1717661e918c42e3650d23fee))
* misleading text for disable animations option ([657a51f](https://github.com/pocket-id/pocket-id/commit/657a51f7ed8a77e8a937971032091058aacfded6))
* OIDC client image can't be deleted ([61b62d4](https://github.com/pocket-id/pocket-id/commit/61b62d461200c1359a16c92c9c62530362a4785c))
* UI config overridden by env variables don't apply on first start ([5e9096e](https://github.com/pocket-id/pocket-id/commit/5e9096e328741ba2a0e03835927fe62e6aea2a89))
* use full width for audit log filters ([575b2f7](https://github.com/pocket-id/pocket-id/commit/575b2f71e9f1ff9c4f6fd411b136676c213b7201))
## [](https://github.com/pocket-id/pocket-id/compare/v1.1.0...v) (2025-06-03)
### Features
* auto detect callback url ([#583](https://github.com/pocket-id/pocket-id/issues/583)) ([20d3f78](https://github.com/pocket-id/pocket-id/commit/20d3f780a2a431d0a48cece0f0764b6e4d53c1b9))
### Bug Fixes
* allow users to update their locale even when own account update disabled ([6c00aaa](https://github.com/pocket-id/pocket-id/commit/6c00aaa3efa75c76d340718698a0f4556e8de268))
* clear default app config variables from database ([decf8ec](https://github.com/pocket-id/pocket-id/commit/decf8ec70b5f6a69fe201d6e4ad60ee62e374ad0))
* don't use TOFU for logout callback URLs ([#588](https://github.com/pocket-id/pocket-id/issues/588)) ([256f74d](https://github.com/pocket-id/pocket-id/commit/256f74d0a348a835107fd5b17b9d57b1e845029e))
* fallback to primary language if no translation available for specific country ([2440379](https://github.com/pocket-id/pocket-id/commit/2440379cd11b4a6da7c52b122ba8f49d7c72ce1d))
* improve spacing on auth screens ([04fcf11](https://github.com/pocket-id/pocket-id/commit/04fcf1110e97b42dc5f0c20e169c569075d1e797))
* page scrolls up on form submisssion ([31ad904](https://github.com/pocket-id/pocket-id/commit/31ad904367e53dd47a15abcce5402dfe84828a14))
* run jobs at interval instead of specific time ([#585](https://github.com/pocket-id/pocket-id/issues/585)) ([6d6dc66](https://github.com/pocket-id/pocket-id/commit/6d6dc6646a39921a604b6c825d3e7e76af6c693b))
* show LAN for auditlog location for internal networks ([b874681](https://github.com/pocket-id/pocket-id/commit/b8746818240fde052e6f3b5db5c3355d7bbfcbda))
* small fixes in analytics_job ([#582](https://github.com/pocket-id/pocket-id/issues/582)) ([3d402fc](https://github.com/pocket-id/pocket-id/commit/3d402fc0ca30626c95b8f7accc274b9f2ab228b9))
* whitelist authorization header for CORS ([b9489b5](https://github.com/pocket-id/pocket-id/commit/b9489b5e9a32a2a3f54d48705e731a7bcf188d20))
## [](https://github.com/pocket-id/pocket-id/compare/v1.0.0...v) (2025-05-28)
### Features
* add daily heartbeat request for counting Pocket ID instances ([#578](https://github.com/pocket-id/pocket-id/issues/578)) ([e0ec607](https://github.com/pocket-id/pocket-id/commit/e0ec60719883c0230f1a16611b943d6f6f637157))
* require user verification for passkey sign in ([68e4b67](https://github.com/pocket-id/pocket-id/commit/68e4b67bd212e31ecc20277bfd293c94bf7f3642))
* show allowed group count on oidc client list ([#567](https://github.com/pocket-id/pocket-id/issues/567)) ([38d7ee4](https://github.com/pocket-id/pocket-id/commit/38d7ee4432e0dacc2cfbabad4bfd9336b8b84079))
### Bug Fixes
* run user group count inside a transaction ([f03b80f](https://github.com/pocket-id/pocket-id/commit/f03b80f9d7f2529d8cef23ca6a742a914a4ec883))
* use ldapAttributeUserUsername for finding group members ([#565](https://github.com/pocket-id/pocket-id/issues/565)) ([f66e8e8](https://github.com/pocket-id/pocket-id/commit/f66e8e8b4478c66ed1ada9168a272b33dbf494d0))
## [](https://github.com/pocket-id/pocket-id/compare/v0.53.0...v) (2025-05-24)

View File

@@ -1,5 +1,5 @@
# This Dockerfile embeds a pre-built binary for the given Linux architecture
# Binaries must be built using ./scripts/development/build-binaries.sh first
# Binaries must be built using ""./scripts/development/build-binaries.sh --docker-only"
FROM alpine

12
backend/.air.toml Normal file
View File

@@ -0,0 +1,12 @@
root = "."
tmp_dir = ".bin"
[build]
bin = "./.bin/pocket-id"
cmd = "CGO_ENABLED=0 go build -o ./.bin/pocket-id ./cmd"
exclude_dir = ["resources", ".bin", "data"]
exclude_regex = [".*_test\\.go"]
stop_on_error = true
[misc]
clean_on_exit = true

View File

@@ -4,6 +4,7 @@ go 1.24.0
require (
github.com/caarlos0/env/v11 v11.3.1
github.com/cenkalti/backoff/v5 v5.0.2
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
github.com/disintegration/imaging v1.6.2
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
@@ -17,8 +18,10 @@ require (
github.com/go-webauthn/webauthn v0.11.2
github.com/golang-migrate/migrate/v4 v4.18.2
github.com/google/uuid v1.6.0
github.com/hashicorp/go-uuid v1.0.3
github.com/joho/godotenv v1.5.1
github.com/lestrrat-go/jwx/v3 v3.0.0-beta1
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2
github.com/lestrrat-go/jwx/v3 v3.0.1
github.com/mileusna/useragent v1.3.5
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2
github.com/stretchr/testify v1.10.0
@@ -30,7 +33,7 @@ require (
go.opentelemetry.io/otel/sdk v1.35.0
go.opentelemetry.io/otel/sdk/metric v1.35.0
go.opentelemetry.io/otel/trace v1.35.0
golang.org/x/crypto v0.36.0
golang.org/x/crypto v0.37.0
golang.org/x/image v0.24.0
golang.org/x/time v0.9.0
gorm.io/driver/postgres v1.5.11
@@ -75,9 +78,8 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
github.com/lestrrat-go/blackmagic v1.0.3 // 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
@@ -121,7 +123,7 @@ require (
golang.org/x/net v0.38.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.23.0 // indirect
golang.org/x/text v0.24.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect
google.golang.org/grpc v1.71.0 // indirect

View File

@@ -17,6 +17,8 @@ github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5m
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=
github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
@@ -162,14 +164,14 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
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/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
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-beta1 h1:Iqjb8JvWjh34Jv8DeM2wQ1aG5fzFBzwQu7rlqwuJB0I=
github.com/lestrrat-go/jwx/v3 v3.0.0-beta1/go.mod h1:ak32WoNtHE0aLowVWBcCvXngcAnW4tuC0YhFwOr/kwc=
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2 h1:SDxjGoH7qj0nBXVrcrxX8eD94wEnjR+EEuqqmeqQYlY=
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2/go.mod h1:Nwo81sMxE0DcvTB+rJyynNhv/DUu2yZErV7sscw9pHE=
github.com/lestrrat-go/jwx/v3 v3.0.1 h1:fH3T748FCMbXoF9UXXNS9i0q6PpYyJZK/rKSbkt2guY=
github.com/lestrrat-go/jwx/v3 v3.0.1/go.mod h1:XP2WqxMOSzHSyf3pfibCcfsLqbomxakAnNqiuaH8nwo=
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=
@@ -307,8 +309,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.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
@@ -375,8 +377,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.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
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

@@ -40,7 +40,7 @@ func Bootstrap() error {
if err != nil {
return fmt.Errorf("failed to create job scheduler: %w", err)
}
err = registerScheduledJobs(ctx, db, svc, scheduler)
err = registerScheduledJobs(ctx, db, svc, httpClient, scheduler)
if err != nil {
return fmt.Errorf("failed to register scheduled jobs: %w", err)
}
@@ -48,7 +48,7 @@ func Bootstrap() error {
// Init the router
router := initRouter(db, svc)
// Run all background serivces
// Run all background services
// This call blocks until the context is canceled
err = utils.
NewServiceRunner(router, scheduler.Run).

View File

@@ -3,6 +3,8 @@
package bootstrap
import (
"log"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -14,7 +16,12 @@ import (
func init() {
registerTestControllers = []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services){
func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) {
testService := service.NewTestService(db, svc.appConfigService, svc.jwtService, svc.ldapService)
testService, err := service.NewTestService(db, svc.appConfigService, svc.jwtService, svc.ldapService)
if err != nil {
log.Fatalf("failed to initialize test service: %v", err)
return
}
controller.NewTestController(apiGroup, testService)
},
}

View File

@@ -7,6 +7,8 @@ import (
"log"
"net"
"net/http"
"os"
"strconv"
"time"
"github.com/pocket-id/pocket-id/backend/frontend"
@@ -101,21 +103,39 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
// Set up the server
srv := &http.Server{
Addr: net.JoinHostPort(common.EnvConfig.Host, common.EnvConfig.Port),
MaxHeaderBytes: 1 << 20,
ReadHeaderTimeout: 10 * time.Second,
Handler: r,
}
// Set up the listener
listener, err := net.Listen("tcp", srv.Addr)
network := "tcp"
addr := net.JoinHostPort(common.EnvConfig.Host, common.EnvConfig.Port)
if common.EnvConfig.UnixSocket != "" {
network = "unix"
addr = common.EnvConfig.UnixSocket
}
listener, err := net.Listen(network, addr)
if err != nil {
return nil, fmt.Errorf("failed to create TCP listener: %w", err)
return nil, fmt.Errorf("failed to create %s listener: %w", network, err)
}
// Set the socket mode if using a Unix socket
if network == "unix" && common.EnvConfig.UnixSocketMode != "" {
mode, err := strconv.ParseUint(common.EnvConfig.UnixSocketMode, 8, 32)
if err != nil {
return nil, fmt.Errorf("failed to parse UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
}
if err := os.Chmod(addr, os.FileMode(mode)); err != nil {
return nil, fmt.Errorf("failed to set UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
}
}
// Service runner function
runFn := func(ctx context.Context) error {
log.Printf("Server listening on %s", srv.Addr)
log.Printf("Server listening on %s", addr)
// Start the server in a background goroutine
go func() {

View File

@@ -3,13 +3,14 @@ package bootstrap
import (
"context"
"fmt"
"net/http"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/job"
)
func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, scheduler *job.Scheduler) error {
func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, httpClient *http.Client, scheduler *job.Scheduler) error {
err := scheduler.RegisterLdapJobs(ctx, svc.ldapService, svc.appConfigService)
if err != nil {
return fmt.Errorf("failed to register LDAP jobs in scheduler: %w", err)
@@ -30,6 +31,10 @@ func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, sche
if err != nil {
return fmt.Errorf("failed to register API key expiration jobs in scheduler: %w", err)
}
err = scheduler.RegisterAnalyticsJob(ctx, svc.appConfigService, httpClient)
if err != nil {
return fmt.Errorf("failed to register analytics job in scheduler: %w", err)
}
return nil
}

View File

@@ -26,15 +26,14 @@ type services struct {
}
// Initializes all services
// The context should be used by services only for initialization, and not for running
func initServices(initCtx context.Context, db *gorm.DB, httpClient *http.Client) (svc *services, err error) {
func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (svc *services, err error) {
svc = &services{}
svc.appConfigService = service.NewAppConfigService(initCtx, db)
svc.appConfigService = service.NewAppConfigService(ctx, db)
svc.emailService, err = service.NewEmailService(db, svc.appConfigService)
if err != nil {
return nil, fmt.Errorf("unable to create email service: %w", err)
return nil, fmt.Errorf("failed to create email service: %w", err)
}
svc.geoLiteService = service.NewGeoLiteService(httpClient)
@@ -42,7 +41,12 @@ func initServices(initCtx context.Context, db *gorm.DB, httpClient *http.Client)
svc.jwtService = service.NewJwtService(svc.appConfigService)
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService)
svc.customClaimService = service.NewCustomClaimService(db)
svc.oidcService = service.NewOidcService(db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService)
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService)
if err != nil {
return nil, fmt.Errorf("failed to create OIDC service: %w", err)
}
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService)
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService)
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)

View File

@@ -32,13 +32,17 @@ type EnvConfigSchema struct {
KeysPath string `env:"KEYS_PATH"`
Port string `env:"PORT"`
Host string `env:"HOST"`
UnixSocket string `env:"UNIX_SOCKET"`
UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"`
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
MetricsEnabled bool `env:"METRICS_ENABLED"`
TracingEnabled bool `env:"TRACING_ENABLED"`
TrustProxy bool `env:"TRUST_PROXY"`
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
}
var EnvConfig = &EnvConfigSchema{
@@ -50,13 +54,17 @@ var EnvConfig = &EnvConfigSchema{
AppURL: "http://localhost:1411",
Port: "1411",
Host: "0.0.0.0",
UnixSocket: "",
UnixSocketMode: "",
MaxMindLicenseKey: "",
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
LocalIPv6Ranges: "",
UiConfigDisabled: false,
MetricsEnabled: false,
TracingEnabled: false,
TrustProxy: false,
AnalyticsDisabled: false,
}
func init() {

View File

@@ -65,11 +65,23 @@ type OidcClientSecretInvalidError struct{}
func (e *OidcClientSecretInvalidError) Error() string { return "invalid client secret" }
func (e *OidcClientSecretInvalidError) HttpStatusCode() int { return 400 }
type OidcClientAssertionInvalidError struct{}
func (e *OidcClientAssertionInvalidError) Error() string { return "invalid client assertion" }
func (e *OidcClientAssertionInvalidError) HttpStatusCode() int { return 400 }
type OidcInvalidAuthorizationCodeError struct{}
func (e *OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" }
func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return 400 }
type OidcMissingCallbackURLError struct{}
func (e *OidcMissingCallbackURLError) Error() string {
return "unable to detect callback url, it might be necessary for an admin to fix this"
}
func (e *OidcMissingCallbackURLError) HttpStatusCode() int { return 400 }
type OidcInvalidCallbackURLError struct{}
func (e *OidcInvalidCallbackURLError) Error() string {
@@ -156,13 +168,6 @@ func (e *DuplicateClaimError) Error() string {
}
func (e *DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest }
type AccountEditNotAllowedError struct{}
func (e *AccountEditNotAllowedError) Error() string {
return "You are not allowed to edit your account"
}
func (e *AccountEditNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
type OidcInvalidCodeVerifierError struct{}
func (e *OidcInvalidCodeVerifierError) Error() string {

View File

@@ -38,10 +38,10 @@ func NewApiKeyController(group *gin.RouterGroup, authMiddleware *middleware.Auth
// @Summary List API keys
// @Description Get a paginated list of API keys belonging to the current user
// @Tags API Keys
// @Param page query int false "Page number, starting from 1" default(1)
// @Param limit query int false "Number of items per page" default(10)
// @Param sort_column query string false "Column to sort by" default("created_at")
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.ApiKeyDto]
// @Router /api/api-keys [get]
func (c *ApiKeyController) listApiKeysHandler(ctx *gin.Context) {

View File

@@ -3,6 +3,7 @@ package controller
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
@@ -57,7 +58,6 @@ type AppConfigController struct {
// @Accept json
// @Produce json
// @Success 200 {array} dto.PublicAppConfigVariableDto
// @Failure 500 {object} object "{"error": "error message"}"
// @Router /application-configuration [get]
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
configuration := acc.appConfigService.ListAppConfig(false)
@@ -85,7 +85,6 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
// @Accept json
// @Produce json
// @Success 200 {array} dto.AppConfigVariableDto
// @Security BearerAuth
// @Router /application-configuration/all [get]
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
configuration := acc.appConfigService.ListAppConfig(true)
@@ -107,7 +106,6 @@ func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
// @Produce json
// @Param body body dto.AppConfigUpdateDto true "Application Configuration"
// @Success 200 {array} dto.AppConfigVariableDto
// @Security BearerAuth
// @Router /api/application-configuration [put]
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
var input dto.AppConfigUpdateDto
@@ -164,7 +162,6 @@ func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
// @Tags Application Configuration
// @Produce image/x-icon
// @Success 200 {file} binary "Favicon image"
// @Failure 404 {object} object "{"error": "File not found"}"
// @Router /api/application-configuration/favicon [get]
func (acc *AppConfigController) getFaviconHandler(c *gin.Context) {
acc.getImage(c, "favicon", "ico")
@@ -177,7 +174,6 @@ func (acc *AppConfigController) getFaviconHandler(c *gin.Context) {
// @Produce image/png
// @Produce image/jpeg
// @Success 200 {file} binary "Background image"
// @Failure 404 {object} object "{"error": "File not found"}"
// @Router /api/application-configuration/background-image [get]
func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) {
imageType := acc.appConfigService.GetDbConfig().BackgroundImageType.Value
@@ -192,7 +188,6 @@ func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) {
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Param file formData file true "Logo image file"
// @Success 204 "No Content"
// @Security BearerAuth
// @Router /api/application-configuration/logo [put]
func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
dbConfig := acc.appConfigService.GetDbConfig()
@@ -218,7 +213,6 @@ func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
// @Accept multipart/form-data
// @Param file formData file true "Favicon file (.ico)"
// @Success 204 "No Content"
// @Security BearerAuth
// @Router /api/application-configuration/favicon [put]
func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
file, err := c.FormFile("file")
@@ -242,7 +236,6 @@ func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
// @Accept multipart/form-data
// @Param file formData file true "Background image file"
// @Success 204 "No Content"
// @Security BearerAuth
// @Router /api/application-configuration/background-image [put]
func (acc *AppConfigController) updateBackgroundImageHandler(c *gin.Context) {
imageType := acc.appConfigService.GetDbConfig().BackgroundImageType.Value
@@ -255,6 +248,8 @@ func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType
mimeType := utils.GetImageMimeType(imageType)
c.Header("Content-Type", mimeType)
utils.SetCacheControlHeader(c, 15*time.Minute, 24*time.Hour)
c.File(imagePath)
}
@@ -280,7 +275,6 @@ func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, ol
// @Description Manually trigger LDAP synchronization
// @Tags Application Configuration
// @Success 204 "No Content"
// @Security BearerAuth
// @Router /api/application-configuration/sync-ldap [post]
func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
err := acc.ldapService.SyncAll(c.Request.Context())
@@ -297,7 +291,6 @@ func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
// @Description Send a test email to verify email configuration
// @Tags Application Configuration
// @Success 204 "No Content"
// @Security BearerAuth
// @Router /api/application-configuration/test-email [post]
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
userID := c.GetString("userID")

View File

@@ -34,10 +34,10 @@ type AuditLogController struct {
// @Summary List audit logs
// @Description Get a paginated list of audit logs for the current user
// @Tags Audit Logs
// @Param page query int false "Page number, starting from 1" default(1)
// @Param limit query int false "Number of items per page" default(10)
// @Param sort_column query string false "Column to sort by" default("created_at")
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
// @Router /api/audit-logs [get]
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
@@ -82,13 +82,14 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
// @Summary List all audit logs
// @Description Get a paginated list of all audit logs (admin only)
// @Tags Audit Logs
// @Param page query int false "Page number, starting from 1" default(1)
// @Param limit query int false "Number of items per page" default(10)
// @Param sort_column query string false "Column to sort by" default("created_at")
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
// @Param user_id query string false "Filter by user ID"
// @Param event query string false "Filter by event type"
// @Param client_name query string false "Filter by client name"
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Param filters[userId] query string false "Filter by user ID"
// @Param filters[event] query string false "Filter by event type"
// @Param filters[clientName] query string false "Filter by client name"
// @Param filters[location] query string false "Filter by location type (external or internal)"
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
// @Router /api/audit-logs/all [get]
func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) {

View File

@@ -35,10 +35,6 @@ type CustomClaimController struct {
// @Tags Custom Claims
// @Produce json
// @Success 200 {array} string "List of suggested custom claim names"
// @Failure 401 {object} object "Unauthorized"
// @Failure 403 {object} object "Forbidden"
// @Failure 500 {object} object "Internal server error"
// @Security BearerAuth
// @Router /api/custom-claims/suggestions [get]
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
claims, err := ccc.customClaimService.GetSuggestions(c.Request.Context())
@@ -93,7 +89,6 @@ func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Contex
// @Param userGroupId path string true "User Group ID"
// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user group"
// @Success 200 {array} dto.CustomClaimDto "Updated custom claims"
// @Security BearerAuth
// @Router /api/custom-claims/user-group/{userGroupId} [put]
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
var input []dto.CustomClaimCreateDto

View File

@@ -14,6 +14,10 @@ func NewTestController(group *gin.RouterGroup, testService *service.TestService)
testController := &TestController{TestService: testService}
group.POST("/test/reset", testController.resetAndSeedHandler)
group.POST("/test/refreshtoken", testController.signRefreshToken)
group.GET("/externalidp/jwks.json", testController.externalIdPJWKS)
group.POST("/externalidp/sign", testController.externalIdPSignToken)
}
type TestController struct {
@@ -21,6 +25,15 @@ type TestController struct {
}
func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
var baseURL string
if c.Request.TLS != nil {
baseURL = "https://" + c.Request.Host
} else {
baseURL = "http://" + c.Request.Host
}
skipLdap := c.Query("skip-ldap") == "true"
if err := tc.TestService.ResetDatabase(); err != nil {
_ = c.Error(err)
return
@@ -31,7 +44,7 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
return
}
if err := tc.TestService.SeedDatabase(); err != nil {
if err := tc.TestService.SeedDatabase(baseURL); err != nil {
_ = c.Error(err)
return
}
@@ -41,17 +54,71 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
return
}
if err := tc.TestService.SetLdapTestConfig(c.Request.Context()); err != nil {
_ = c.Error(err)
return
}
if !skipLdap {
if err := tc.TestService.SetLdapTestConfig(c.Request.Context()); err != nil {
_ = c.Error(err)
return
}
if err := tc.TestService.SyncLdap(c.Request.Context()); err != nil {
_ = c.Error(err)
return
if err := tc.TestService.SyncLdap(c.Request.Context()); err != nil {
_ = c.Error(err)
return
}
}
tc.TestService.SetJWTKeys()
c.Status(http.StatusNoContent)
}
func (tc *TestController) externalIdPJWKS(c *gin.Context) {
jwks, err := tc.TestService.GetExternalIdPJWKS()
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, jwks)
}
func (tc *TestController) externalIdPSignToken(c *gin.Context) {
var input struct {
Aud string `json:"aud"`
Iss string `json:"iss"`
Sub string `json:"sub"`
}
err := c.ShouldBindJSON(&input)
if err != nil {
_ = c.Error(err)
return
}
token, err := tc.TestService.SignExternalIdPToken(input.Iss, input.Sub, input.Aud)
if err != nil {
_ = c.Error(err)
return
}
c.Writer.WriteString(token)
}
func (tc *TestController) signRefreshToken(c *gin.Context) {
var input struct {
UserID string `json:"user"`
ClientID string `json:"client"`
RefreshToken string `json:"rt"`
}
err := c.ShouldBindJSON(&input)
if err != nil {
_ = c.Error(err)
return
}
token, err := tc.TestService.SignRefreshToken(input.UserID, input.ClientID, input.RefreshToken)
if err != nil {
_ = c.Error(err)
return
}
c.Writer.WriteString(token)
}

View File

@@ -6,15 +6,16 @@ import (
"net/http"
"net/url"
"strings"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
"time"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
)
// NewOidcController creates a new controller for OIDC related endpoints
@@ -48,9 +49,14 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.DELETE("/oidc/clients/:id/logo", oc.deleteClientLogoHandler)
group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler)
group.GET("/oidc/clients/:id/preview/:userId", authMiddleware.Add(), oc.getClientPreviewHandler)
group.POST("/oidc/device/authorize", oc.deviceAuthorizationHandler)
group.POST("/oidc/device/verify", authMiddleware.WithAdminNotRequired().Add(), oc.verifyDeviceCodeHandler)
group.GET("/oidc/device/info", authMiddleware.WithAdminNotRequired().Add(), oc.getDeviceCodeInfoHandler)
group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAuthorizedClientsHandler)
group.GET("/oidc/users/:id/clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler)
}
type OidcController struct {
@@ -66,7 +72,6 @@ type OidcController struct {
// @Produce json
// @Param request body dto.AuthorizeOidcClientRequestDto true "Authorization request parameters"
// @Success 200 {object} dto.AuthorizeOidcClientResponseDto "Authorization code and callback URL"
// @Security BearerAuth
// @Router /api/oidc/authorize [post]
func (oc *OidcController) authorizeHandler(c *gin.Context) {
var input dto.AuthorizeOidcClientRequestDto
@@ -97,7 +102,6 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
// @Produce json
// @Param request body dto.AuthorizationRequiredDto true "Authorization check parameters"
// @Success 200 {object} object "{ \"authorizationRequired\": true/false }"
// @Security BearerAuth
// @Router /api/oidc/authorization-required [post]
func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Context) {
var input dto.AuthorizationRequiredDto
@@ -121,11 +125,13 @@ func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Contex
// @Tags OIDC
// @Produce json
// @Param client_id formData string false "Client ID (if not using Basic Auth)"
// @Param client_secret formData string false "Client secret (if not using Basic Auth)"
// @Param client_secret formData string false "Client secret (if not using Basic Auth or client assertions)"
// @Param code formData string false "Authorization code (required for 'authorization_code' grant)"
// @Param grant_type formData string true "Grant type ('authorization_code' or 'refresh_token')"
// @Param code_verifier formData string false "PKCE code verifier (for authorization_code with PKCE)"
// @Param refresh_token formData string false "Refresh token (required for 'refresh_token' grant)"
// @Param client_assertion formData string false "Client assertion type (for 'authorization_code' grant when using client assertions)"
// @Param client_assertion_type formData string false "Client assertion type (for 'authorization_code' grant when using client assertions)"
// @Success 200 {object} dto.OidcTokenResponseDto "Token response with access_token and optional id_token and refresh_token"
// @Router /api/oidc/token [post]
func (oc *OidcController) createTokensHandler(c *gin.Context) {
@@ -195,7 +201,7 @@ func (oc *OidcController) userInfoHandler(c *gin.Context) {
return
}
token, err := oc.jwtService.VerifyOauthAccessToken(authToken)
token, err := oc.jwtService.VerifyOAuthAccessToken(authToken)
if err != nil {
_ = c.Error(err)
return
@@ -224,7 +230,6 @@ func (oc *OidcController) userInfoHandler(c *gin.Context) {
// @Description End user session and handle OIDC logout
// @Tags OIDC
// @Accept application/x-www-form-urlencoded
// @Produce html
// @Param id_token_hint query string false "ID token"
// @Param post_logout_redirect_uri query string false "URL to redirect to after logout"
// @Param state query string false "State parameter to include in the redirect"
@@ -304,9 +309,21 @@ func (oc *OidcController) introspectTokenHandler(c *gin.Context) {
// find valid tokens) while still allowing it to be used by an application that is
// supposed to interact with our IdP (since that needs to have a client_id
// and client_secret anyway).
clientID, clientSecret, _ := c.Request.BasicAuth()
var (
creds service.ClientAuthCredentials
ok bool
)
creds.ClientID, creds.ClientSecret, ok = c.Request.BasicAuth()
if !ok {
// If there's no basic auth, check if we have a bearer token
bearer, ok := utils.BearerAuth(c.Request)
if ok {
creds.ClientAssertionType = service.ClientAssertionTypeJWTBearer
creds.ClientAssertion = bearer
}
}
response, err := oc.oidcService.IntrospectToken(c.Request.Context(), clientID, clientSecret, input.Token)
response, err := oc.oidcService.IntrospectToken(c.Request.Context(), creds, input.Token)
if err != nil {
_ = c.Error(err)
return
@@ -348,7 +365,6 @@ func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
// @Produce json
// @Param id path string true "Client ID"
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Client information"
// @Security BearerAuth
// @Router /api/oidc/clients/{id} [get]
func (oc *OidcController) getClientHandler(c *gin.Context) {
clientId := c.Param("id")
@@ -360,12 +376,12 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
clientDto := dto.OidcClientWithAllowedUserGroupsDto{}
err = dto.MapStruct(client, &clientDto)
if err == nil {
c.JSON(http.StatusOK, clientDto)
if err != nil {
_ = c.Error(err)
return
}
_ = c.Error(err)
c.JSON(http.StatusOK, clientDto)
}
// listClientsHandler godoc
@@ -373,12 +389,11 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
// @Description Get a paginated list of OIDC clients with optional search and sorting
// @Tags OIDC
// @Param search query string false "Search term to filter clients by name"
// @Param page query int false "Page number, starting from 1" default(1)
// @Param limit query int false "Number of items per page" default(10)
// @Param sort_column query string false "Column to sort by" default("name")
// @Param sort_direction query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.OidcClientDto]
// @Security BearerAuth
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]
// @Router /api/oidc/clients [get]
func (oc *OidcController) listClientsHandler(c *gin.Context) {
searchTerm := c.Query("search")
@@ -394,13 +409,23 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
return
}
var clientsDto []dto.OidcClientDto
if err := dto.MapStructList(clients, &clientsDto); err != nil {
_ = c.Error(err)
return
// Map the user groups to DTOs
var clientsDto = make([]dto.OidcClientWithAllowedGroupsCountDto, len(clients))
for i, client := range clients {
var clientDto dto.OidcClientWithAllowedGroupsCountDto
if err := dto.MapStruct(client, &clientDto); err != nil {
_ = c.Error(err)
return
}
clientDto.AllowedUserGroupsCount, err = oc.oidcService.GetAllowedGroupsCountOfClient(c, client.ID)
if err != nil {
_ = c.Error(err)
return
}
clientsDto[i] = clientDto
}
c.JSON(http.StatusOK, dto.Paginated[dto.OidcClientDto]{
c.JSON(http.StatusOK, dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]{
Data: clientsDto,
Pagination: pagination,
})
@@ -414,7 +439,6 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
// @Produce json
// @Param client body dto.OidcClientCreateDto true "Client information"
// @Success 201 {object} dto.OidcClientWithAllowedUserGroupsDto "Created client"
// @Security BearerAuth
// @Router /api/oidc/clients [post]
func (oc *OidcController) createClientHandler(c *gin.Context) {
var input dto.OidcClientCreateDto
@@ -444,7 +468,6 @@ func (oc *OidcController) createClientHandler(c *gin.Context) {
// @Tags OIDC
// @Param id path string true "Client ID"
// @Success 204 "No Content"
// @Security BearerAuth
// @Router /api/oidc/clients/{id} [delete]
func (oc *OidcController) deleteClientHandler(c *gin.Context) {
err := oc.oidcService.DeleteClient(c.Request.Context(), c.Param("id"))
@@ -465,7 +488,6 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) {
// @Param id path string true "Client ID"
// @Param client body dto.OidcClientCreateDto true "Client information"
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Updated client"
// @Security BearerAuth
// @Router /api/oidc/clients/{id} [put]
func (oc *OidcController) updateClientHandler(c *gin.Context) {
var input dto.OidcClientCreateDto
@@ -496,7 +518,6 @@ func (oc *OidcController) updateClientHandler(c *gin.Context) {
// @Produce json
// @Param id path string true "Client ID"
// @Success 200 {object} object "{ \"secret\": \"string\" }"
// @Security BearerAuth
// @Router /api/oidc/clients/{id}/secret [post]
func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
secret, err := oc.oidcService.CreateClientSecret(c.Request.Context(), c.Param("id"))
@@ -525,6 +546,8 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
return
}
utils.SetCacheControlHeader(c, 15*time.Minute, 12*time.Hour)
c.Header("Content-Type", mimeType)
c.File(imagePath)
}
@@ -535,9 +558,8 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
// @Tags OIDC
// @Accept multipart/form-data
// @Param id path string true "Client ID"
// @Param file formData file true "Logo image file (PNG, JPG, or SVG, max 2MB)"
// @Param file formData file true "Logo image file (PNG, JPG, or SVG)"
// @Success 204 "No Content"
// @Security BearerAuth
// @Router /api/oidc/clients/{id}/logo [post]
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
file, err := c.FormFile("file")
@@ -561,7 +583,6 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
// @Tags OIDC
// @Param id path string true "Client ID"
// @Success 204 "No Content"
// @Security BearerAuth
// @Router /api/oidc/clients/{id}/logo [delete]
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
err := oc.oidcService.DeleteClientLogo(c.Request.Context(), c.Param("id"))
@@ -582,7 +603,6 @@ func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
// @Param id path string true "Client ID"
// @Param groups body dto.OidcUpdateAllowedUserGroupsDto true "User group IDs"
// @Success 200 {object} dto.OidcClientDto "Updated client"
// @Security BearerAuth
// @Router /api/oidc/clients/{id}/allowed-user-groups [put]
func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
var input dto.OidcUpdateAllowedUserGroupsDto
@@ -627,6 +647,62 @@ func (oc *OidcController) deviceAuthorizationHandler(c *gin.Context) {
c.JSON(http.StatusOK, response)
}
// listOwnAuthorizedClientsHandler godoc
// @Summary List authorized clients for current user
// @Description Get a paginated list of OIDC clients that the current user has authorized
// @Tags OIDC
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
// @Router /api/oidc/users/me/clients [get]
func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) {
userID := c.GetString("userID")
oc.listAuthorizedClients(c, userID)
}
// listAuthorizedClientsHandler godoc
// @Summary List authorized clients for a user
// @Description Get a paginated list of OIDC clients that a specific user has authorized
// @Tags OIDC
// @Param id path string true "User ID"
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
// @Router /api/oidc/users/{id}/clients [get]
func (oc *OidcController) listAuthorizedClientsHandler(c *gin.Context) {
userID := c.Param("id")
oc.listAuthorizedClients(c, userID)
}
func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) {
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
authorizedClients, pagination, err := oc.oidcService.ListAuthorizedClients(c.Request.Context(), userID, sortedPaginationRequest)
if err != nil {
_ = c.Error(err)
return
}
// Map the clients to DTOs
var authorizedClientsDto []dto.AuthorizedOidcClientDto
if err := dto.MapStructList(authorizedClients, &authorizedClientsDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, dto.Paginated[dto.AuthorizedOidcClientDto]{
Data: authorizedClientsDto,
Pagination: pagination,
})
}
func (oc *OidcController) verifyDeviceCodeHandler(c *gin.Context) {
userCode := c.Query("code")
if userCode == "" {
@@ -662,3 +738,43 @@ func (oc *OidcController) getDeviceCodeInfoHandler(c *gin.Context) {
c.JSON(http.StatusOK, deviceCodeInfo)
}
// getClientPreviewHandler godoc
// @Summary Preview OIDC client data for user
// @Description Get a preview of the OIDC data (ID token, access token, userinfo) that would be sent to the client for a specific user
// @Tags OIDC
// @Produce json
// @Param id path string true "Client ID"
// @Param userId path string true "User ID to preview data for"
// @Param scopes query string false "Scopes to include in the preview (comma-separated)"
// @Success 200 {object} dto.OidcClientPreviewDto "Preview data including ID token, access token, and userinfo payloads"
// @Security BearerAuth
// @Router /api/oidc/clients/{id}/preview/{userId} [get]
func (oc *OidcController) getClientPreviewHandler(c *gin.Context) {
clientID := c.Param("id")
userID := c.Param("userId")
scopes := c.Query("scopes")
if clientID == "" {
_ = c.Error(&common.ValidationError{Message: "client ID is required"})
return
}
if userID == "" {
_ = c.Error(&common.ValidationError{Message: "user ID is required"})
return
}
if scopes == "" {
_ = c.Error(&common.ValidationError{Message: "scopes are required"})
return
}
preview, err := oc.oidcService.GetClientPreview(c.Request.Context(), clientID, userID, scopes)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, preview)
}

View File

@@ -7,7 +7,6 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
@@ -86,10 +85,10 @@ func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
// @Description Get a paginated list of users with optional search and sorting
// @Tags Users
// @Param search query string false "Search term to filter users"
// @Param page query int false "Page number, starting from 1" default(1)
// @Param limit query int false "Number of items per page" default(10)
// @Param sort_column query string false "Column to sort by" default("created_at")
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.UserDto]
// @Router /api/users [get]
func (uc *UserController) listUsersHandler(c *gin.Context) {
@@ -228,10 +227,6 @@ func (uc *UserController) updateUserHandler(c *gin.Context) {
// @Success 200 {object} dto.UserDto
// @Router /api/users/me [put]
func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
if !uc.appConfigService.GetDbConfig().AllowOwnAccountEdit.IsTrue() {
_ = c.Error(&common.AccountEditNotAllowedError{})
return
}
uc.updateUser(c, true)
}
@@ -255,10 +250,7 @@ func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) {
defer picture.Close()
}
_, ok := c.GetQuery("skipCache")
if !ok {
c.Header("Cache-Control", "public, max-age=900")
}
utils.SetCacheControlHeader(c, 15*time.Minute, 1*time.Hour)
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
}

View File

@@ -40,10 +40,10 @@ type UserGroupController struct {
// @Description Get a paginated list of user groups with optional search and sorting
// @Tags User Groups
// @Param search query string false "Search term to filter user groups by name"
// @Param page query int false "Page number, starting from 1" default(1)
// @Param limit query int false "Number of items per page" default(10)
// @Param sort_column query string false "Column to sort by" default("name")
// @Param sort_direction query string false "Sort direction (asc or desc)" default("asc")
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.UserGroupDtoWithUserCount]
// @Router /api/user-groups [get]
func (ugc *UserGroupController) list(c *gin.Context) {
@@ -92,7 +92,6 @@ func (ugc *UserGroupController) list(c *gin.Context) {
// @Produce json
// @Param id path string true "User Group ID"
// @Success 200 {object} dto.UserGroupDtoWithUsers
// @Security BearerAuth
// @Router /api/user-groups/{id} [get]
func (ugc *UserGroupController) get(c *gin.Context) {
group, err := ugc.UserGroupService.Get(c.Request.Context(), c.Param("id"))
@@ -118,7 +117,6 @@ func (ugc *UserGroupController) get(c *gin.Context) {
// @Produce json
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
// @Success 201 {object} dto.UserGroupDtoWithUsers "Created user group"
// @Security BearerAuth
// @Router /api/user-groups [post]
func (ugc *UserGroupController) create(c *gin.Context) {
var input dto.UserGroupCreateDto
@@ -151,7 +149,6 @@ func (ugc *UserGroupController) create(c *gin.Context) {
// @Param id path string true "User Group ID"
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
// @Success 200 {object} dto.UserGroupDtoWithUsers "Updated user group"
// @Security BearerAuth
// @Router /api/user-groups/{id} [put]
func (ugc *UserGroupController) update(c *gin.Context) {
var input dto.UserGroupCreateDto
@@ -183,7 +180,6 @@ func (ugc *UserGroupController) update(c *gin.Context) {
// @Produce json
// @Param id path string true "User Group ID"
// @Success 204 "No Content"
// @Security BearerAuth
// @Router /api/user-groups/{id} [delete]
func (ugc *UserGroupController) delete(c *gin.Context) {
if err := ugc.UserGroupService.Delete(c.Request.Context(), c.Param("id")); err != nil {
@@ -203,7 +199,6 @@ func (ugc *UserGroupController) delete(c *gin.Context) {
// @Param id path string true "User Group ID"
// @Param users body dto.UserGroupUpdateUsersDto true "List of user IDs to assign to this group"
// @Success 200 {object} dto.UserGroupDtoWithUsers
// @Security BearerAuth
// @Router /api/user-groups/{id}/users [put]
func (ugc *UserGroupController) updateUsers(c *gin.Context) {
var input dto.UserGroupUpdateUsersDto

View File

@@ -17,6 +17,7 @@ type AppConfigUpdateDto struct {
EmailsVerified string `json:"emailsVerified" binding:"required"`
DisableAnimations string `json:"disableAnimations" binding:"required"`
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
AccentColor string `json:"accentColor"`
SmtpHost string `json:"smtpHost"`
SmtpPort string `json:"smtpPort"`
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`

View File

@@ -23,4 +23,5 @@ type AuditLogFilterDto struct {
UserID string `form:"filters[userId]"`
Event string `form:"filters[event]"`
ClientName string `form:"filters[clientName]"`
Location string `form:"filters[location]"`
}

View File

@@ -62,7 +62,60 @@ func mapStructInternal(sourceVal reflect.Value, destVal reflect.Value) error {
return nil
}
//nolint:gocognit
func mapField(sourceField reflect.Value, destField reflect.Value) error {
// Handle pointer to struct in source
if sourceField.Kind() == reflect.Ptr && !sourceField.IsNil() {
switch {
case sourceField.Elem().Kind() == reflect.Struct:
switch {
case destField.Kind() == reflect.Struct:
// Map from pointer to struct -> struct
return mapStructInternal(sourceField.Elem(), destField)
case destField.Kind() == reflect.Ptr && destField.CanSet():
// Map from pointer to struct -> pointer to struct
if destField.IsNil() {
destField.Set(reflect.New(destField.Type().Elem()))
}
return mapStructInternal(sourceField.Elem(), destField.Elem())
}
case destField.Kind() == reflect.Ptr &&
destField.CanSet() &&
sourceField.Elem().Type().AssignableTo(destField.Type().Elem()):
// Handle primitive pointer types (e.g., *string to *string)
if destField.IsNil() {
destField.Set(reflect.New(destField.Type().Elem()))
}
destField.Elem().Set(sourceField.Elem())
return nil
case destField.Kind() != reflect.Ptr &&
destField.CanSet() &&
sourceField.Elem().Type().AssignableTo(destField.Type()):
// Handle *T to T conversion for primitive types
destField.Set(sourceField.Elem())
return nil
}
}
// Handle pointer to struct in destination
if destField.Kind() == reflect.Ptr && destField.CanSet() {
switch {
case sourceField.Kind() == reflect.Struct:
// Map from struct -> pointer to struct
if destField.IsNil() {
destField.Set(reflect.New(destField.Type().Elem()))
}
return mapStructInternal(sourceField, destField.Elem())
case !sourceField.IsZero() && sourceField.Type().AssignableTo(destField.Type().Elem()):
// Handle T to *T conversion for primitive types
if destField.IsNil() {
destField.Set(reflect.New(destField.Type().Elem()))
}
destField.Elem().Set(sourceField)
return nil
}
}
switch {
case sourceField.Type() == destField.Type():
destField.Set(sourceField)

View File

@@ -8,10 +8,11 @@ type OidcClientMetaDataDto struct {
type OidcClientDto struct {
OidcClientMetaDataDto
CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
Credentials OidcClientCredentialsDto `json:"credentials"`
}
type OidcClientWithAllowedUserGroupsDto struct {
@@ -19,12 +20,29 @@ type OidcClientWithAllowedUserGroupsDto struct {
AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"`
}
type OidcClientWithAllowedGroupsCountDto struct {
OidcClientDto
AllowedUserGroupsCount int64 `json:"allowedUserGroupsCount"`
}
type OidcClientCreateDto struct {
Name string `json:"name" binding:"required,max=50"`
CallbackURLs []string `json:"callbackURLs" binding:"required"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
Name string `json:"name" binding:"required,max=50"`
CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
Credentials OidcClientCredentialsDto `json:"credentials"`
}
type OidcClientCredentialsDto struct {
FederatedIdentities []OidcClientFederatedIdentityDto `json:"federatedIdentities,omitempty"`
}
type OidcClientFederatedIdentityDto struct {
Issuer string `json:"issuer"`
Subject string `json:"subject,omitempty"`
Audience string `json:"audience,omitempty"`
JWKS string `json:"jwks,omitempty"`
}
type AuthorizeOidcClientRequestDto struct {
@@ -47,13 +65,15 @@ type AuthorizationRequiredDto struct {
}
type OidcCreateTokensDto struct {
GrantType string `form:"grant_type" binding:"required"`
Code string `form:"code"`
DeviceCode string `form:"device_code"`
ClientID string `form:"client_id"`
ClientSecret string `form:"client_secret"`
CodeVerifier string `form:"code_verifier"`
RefreshToken string `form:"refresh_token"`
GrantType string `form:"grant_type" binding:"required"`
Code string `form:"code"`
DeviceCode string `form:"device_code"`
ClientID string `form:"client_id"`
ClientSecret string `form:"client_secret"`
CodeVerifier string `form:"code_verifier"`
RefreshToken string `form:"refresh_token"`
ClientAssertion string `form:"client_assertion"`
ClientAssertionType string `form:"client_assertion_type"`
}
type OidcIntrospectDto struct {
@@ -93,9 +113,11 @@ type OidcIntrospectionResponseDto struct {
}
type OidcDeviceAuthorizationRequestDto struct {
ClientID string `form:"client_id" binding:"required"`
Scope string `form:"scope" binding:"required"`
ClientSecret string `form:"client_secret"`
ClientID string `form:"client_id" binding:"required"`
Scope string `form:"scope" binding:"required"`
ClientSecret string `form:"client_secret"`
ClientAssertion string `form:"client_assertion"`
ClientAssertionType string `form:"client_assertion_type"`
}
type OidcDeviceAuthorizationResponseDto struct {
@@ -120,3 +142,14 @@ type DeviceCodeInfoDto struct {
AuthorizationRequired bool `json:"authorizationRequired"`
Client OidcClientMetaDataDto `json:"client"`
}
type AuthorizedOidcClientDto struct {
Scope string `json:"scope"`
Client OidcClientMetaDataDto `json:"client"`
}
type OidcClientPreviewDto struct {
IdToken map[string]interface{} `json:"idToken"`
AccessToken map[string]interface{} `json:"accessToken"`
UserInfo map[string]interface{} `json:"userInfo"`
}

View File

@@ -0,0 +1,86 @@
package job
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
backoff "github.com/cenkalti/backoff/v5"
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
const heartbeatUrl = "https://analytics.pocket-id.org/heartbeat"
func (s *Scheduler) RegisterAnalyticsJob(ctx context.Context, appConfig *service.AppConfigService, httpClient *http.Client) error {
// Skip if analytics are disabled or not in production environment
if common.EnvConfig.AnalyticsDisabled || common.EnvConfig.AppEnv != "production" {
return nil
}
// Send every 24 hours
jobs := &AnalyticsJob{
appConfig: appConfig,
httpClient: httpClient,
}
return s.registerJob(ctx, "SendHeartbeat", gocron.DurationJob(24*time.Hour), jobs.sendHeartbeat, true)
}
type AnalyticsJob struct {
appConfig *service.AppConfigService
httpClient *http.Client
}
// sendHeartbeat sends a heartbeat to the analytics service
func (j *AnalyticsJob) sendHeartbeat(parentCtx context.Context) error {
// Skip if analytics are disabled or not in production environment
if common.EnvConfig.AnalyticsDisabled || common.EnvConfig.AppEnv != "production" {
return nil
}
body, err := json.Marshal(struct {
Version string `json:"version"`
InstanceID string `json:"instance_id"`
}{
Version: common.Version,
InstanceID: j.appConfig.GetDbConfig().InstanceID.Value,
})
if err != nil {
return fmt.Errorf("failed to marshal heartbeat body: %w", err)
}
_, err = backoff.Retry(
parentCtx,
func() (struct{}, error) {
ctx, cancel := context.WithTimeout(parentCtx, 20*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, heartbeatUrl, bytes.NewReader(body))
if err != nil {
return struct{}{}, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := j.httpClient.Do(req)
if err != nil {
return struct{}{}, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return struct{}{}, fmt.Errorf("request failed with status code: %d", resp.StatusCode)
}
return struct{}{}, nil
},
backoff.WithBackOff(backoff.NewExponentialBackOff()),
backoff.WithMaxTries(3),
)
if err != nil {
return fmt.Errorf("heartbeat request failed: %w", err)
}
return nil
}

View File

@@ -2,7 +2,10 @@ package job
import (
"context"
"log"
"fmt"
"log/slog"
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
@@ -18,7 +21,8 @@ func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyService *
appConfigService: appConfigService,
}
return s.registerJob(ctx, "ExpiredApiKeyEmailJob", "0 0 * * *", jobs.checkAndNotifyExpiringApiKeys)
// Send every day at midnight
return s.registerJob(ctx, "ExpiredApiKeyEmailJob", gocron.CronJob("0 0 * * *", false), jobs.checkAndNotifyExpiringApiKeys, false)
}
func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error {
@@ -29,16 +33,16 @@ func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) err
apiKeys, err := j.apiKeyService.ListExpiringApiKeys(ctx, 7)
if err != nil {
log.Printf("Failed to list expiring API keys: %v", err)
return err
return fmt.Errorf("failed to list expiring API keys: %w", err)
}
for _, key := range apiKeys {
if key.User.Email == "" {
continue
}
if err := j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key); err != nil {
log.Printf("Failed to send email for key %s: %v", key.ID, err)
err = j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key)
if err != nil {
slog.ErrorContext(ctx, "Failed to send expiring API key notification email", slog.String("key", key.ID), slog.Any("error", err))
}
}
return nil

View File

@@ -3,8 +3,11 @@ package job
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/go-co-op/gocron/v2"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model"
@@ -14,12 +17,14 @@ import (
func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) error {
jobs := &DbCleanupJobs{db: db}
// Run every 24 hours (but with some jitter so they don't run at the exact same time), and now
def := gocron.DurationRandomJob(24*time.Hour-2*time.Minute, 24*time.Hour+2*time.Minute)
return errors.Join(
s.registerJob(ctx, "ClearWebauthnSessions", "0 3 * * *", jobs.clearWebauthnSessions),
s.registerJob(ctx, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens),
s.registerJob(ctx, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes),
s.registerJob(ctx, "ClearOidcRefreshTokens", "0 3 * * *", jobs.clearOidcRefreshTokens),
s.registerJob(ctx, "ClearAuditLogs", "0 3 * * *", jobs.clearAuditLogs),
s.registerJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
s.registerJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true),
s.registerJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
s.registerJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
s.registerJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
)
}
@@ -29,40 +34,70 @@ type DbCleanupJobs struct {
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
func (j *DbCleanupJobs) clearWebauthnSessions(ctx context.Context) error {
return j.db.
st := j.db.
WithContext(ctx).
Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now())).
Error
Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired WebAuthn sessions: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired WebAuthn sessions", slog.Int64("count", st.RowsAffected))
return nil
}
// ClearOneTimeAccessTokens deletes one-time access tokens that have expired
func (j *DbCleanupJobs) clearOneTimeAccessTokens(ctx context.Context) error {
return j.db.
st := j.db.
WithContext(ctx).
Delete(&model.OneTimeAccessToken{}, "expires_at < ?", datatype.DateTime(time.Now())).
Error
Delete(&model.OneTimeAccessToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired one-time access tokens: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired one-time access tokens", slog.Int64("count", st.RowsAffected))
return nil
}
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
func (j *DbCleanupJobs) clearOidcAuthorizationCodes(ctx context.Context) error {
return j.db.
st := j.db.
WithContext(ctx).
Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now())).
Error
Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired OIDC authorization codes: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired OIDC authorization codes", slog.Int64("count", st.RowsAffected))
return nil
}
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
func (j *DbCleanupJobs) clearOidcRefreshTokens(ctx context.Context) error {
return j.db.
st := j.db.
WithContext(ctx).
Delete(&model.OidcRefreshToken{}, "expires_at < ?", datatype.DateTime(time.Now())).
Error
Delete(&model.OidcRefreshToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired OIDC refresh tokens: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired OIDC refresh tokens", slog.Int64("count", st.RowsAffected))
return nil
}
// ClearAuditLogs deletes audit logs older than 90 days
func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
return j.db.
st := j.db.
WithContext(ctx).
Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90))).
Error
Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90)))
if st.Error != nil {
return fmt.Errorf("failed to delete old audit logs: %w", st.Error)
}
slog.InfoContext(ctx, "Deleted old audit logs", slog.Int64("count", st.RowsAffected))
return nil
}

View File

@@ -3,11 +3,13 @@ package job
import (
"context"
"fmt"
"log"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-co-op/gocron/v2"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
@@ -17,7 +19,8 @@ import (
func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB) error {
jobs := &FileCleanupJobs{db: db}
return s.registerJob(ctx, "ClearUnusedDefaultProfilePictures", "0 2 * * 0", jobs.clearUnusedDefaultProfilePictures)
// Run every 24 hours
return s.registerJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, false)
}
type FileCleanupJobs struct {
@@ -64,13 +67,13 @@ func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context)
if _, ok := initialsInUse[initials]; !ok {
filePath := filepath.Join(defaultPicturesDir, filename)
if err := os.Remove(filePath); err != nil {
log.Printf("Failed to delete unused default profile picture %s: %v", filePath, err)
slog.ErrorContext(ctx, "Failed to delete unused default profile picture", slog.String("path", filePath), slog.Any("error", err))
} else {
filesDeleted++
}
}
}
log.Printf("Deleted %d unused default profile pictures", filesDeleted)
slog.Info("Done deleting unused default profile pictures", slog.Int("count", filesDeleted))
return nil
}

View File

@@ -2,9 +2,10 @@ package job
import (
"context"
"log"
"time"
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
@@ -21,23 +22,8 @@ func (s *Scheduler) RegisterGeoLiteUpdateJobs(ctx context.Context, geoLiteServic
jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService}
// Register the job to run every day, at 5 minutes past midnight
err := s.registerJob(ctx, "UpdateGeoLiteDB", "5 * */1 * *", jobs.updateGoeLiteDB)
if err != nil {
return err
}
// Run the job immediately on startup, with a 1s delay
go func() {
time.Sleep(time.Second)
err = jobs.updateGoeLiteDB(ctx)
if err != nil {
// Log the error only, but don't return it
log.Printf("Failed to Update GeoLite database: %v", err)
}
}()
return nil
// Run every 24 hours (and right away)
return s.registerJob(ctx, "UpdateGeoLiteDB", gocron.DurationJob(24*time.Hour), jobs.updateGoeLiteDB, true)
}
func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error {

View File

@@ -2,7 +2,9 @@ package job
import (
"context"
"log"
"time"
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
@@ -16,19 +18,7 @@ func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.L
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
// Register the job to run every hour
err := s.registerJob(ctx, "SyncLdap", "0 * * * *", jobs.syncLdap)
if err != nil {
return err
}
// Run the job immediately on startup
err = jobs.syncLdap(ctx)
if err != nil {
// Log the error only, but don't return it
log.Printf("Failed to sync LDAP: %v", err)
}
return nil
return s.registerJob(ctx, "SyncLdap", gocron.DurationJob(time.Hour), jobs.syncLdap, true)
}
func (j *LdapJobs) syncLdap(ctx context.Context) error {

View File

@@ -3,7 +3,7 @@ package job
import (
"context"
"fmt"
"log"
"log/slog"
"github.com/go-co-op/gocron/v2"
"github.com/google/uuid"
@@ -27,7 +27,7 @@ func NewScheduler() (*Scheduler, error) {
// Run the scheduler.
// This function blocks until the context is canceled.
func (s *Scheduler) Run(ctx context.Context) error {
log.Println("Starting job scheduler")
slog.Info("Starting job scheduler")
s.scheduler.Start()
// Block until context is canceled
@@ -35,28 +35,45 @@ func (s *Scheduler) Run(ctx context.Context) error {
err := s.scheduler.Shutdown()
if err != nil {
log.Printf("[WARN] Error shutting down job scheduler: %v", err)
slog.Error("Error shutting down job scheduler", slog.Any("error", err))
} else {
log.Println("Job scheduler shut down")
slog.Info("Job scheduler shut down")
}
return nil
}
func (s *Scheduler) registerJob(ctx context.Context, name string, interval string, job func(ctx context.Context) error) error {
_, err := s.scheduler.NewJob(
gocron.CronJob(interval, false),
gocron.NewTask(job),
func (s *Scheduler) registerJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, runImmediately bool) error {
jobOptions := []gocron.JobOption{
gocron.WithContext(ctx),
gocron.WithEventListeners(
gocron.BeforeJobRuns(func(jobID uuid.UUID, jobName string) {
slog.Info("Starting job",
slog.String("name", name),
slog.String("id", jobID.String()),
)
}),
gocron.AfterJobRuns(func(jobID uuid.UUID, jobName string) {
log.Printf("Job %q run successfully", name)
slog.Info("Job run successfully",
slog.String("name", name),
slog.String("id", jobID.String()),
)
}),
gocron.AfterJobRunsWithError(func(jobID uuid.UUID, jobName string, err error) {
log.Printf("Job %q failed with error: %v", name, err)
slog.Error("Job failed with error",
slog.String("name", name),
slog.String("id", jobID.String()),
slog.Any("error", err),
)
}),
),
)
}
if runImmediately {
jobOptions = append(jobOptions, gocron.JobOption(gocron.WithStartImmediately()))
}
_, err := s.scheduler.NewJob(def, gocron.NewTask(job), jobOptions...)
if err != nil {
return fmt.Errorf("failed to register job %q: %w", name, err)

View File

@@ -26,6 +26,7 @@ func (m *CorsMiddleware) Add() gin.HandlerFunc {
}
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Authorization")
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST")
// Preflight request

View File

@@ -35,12 +35,14 @@ type AppConfig struct {
AppName AppConfigVariable `key:"appName,public"` // Public
SessionDuration AppConfigVariable `key:"sessionDuration"`
EmailsVerified AppConfigVariable `key:"emailsVerified"`
AccentColor AppConfigVariable `key:"accentColor,public"` // Public
DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public
AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public
// Internal
BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal
LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal
LogoDarkImageType AppConfigVariable `key:"logoDarkImageType,internal"` // Internal
InstanceID AppConfigVariable `key:"instanceId,internal"` // Internal
// Email
SmtpHost AppConfigVariable `key:"smtpHost"`
SmtpPort AppConfigVariable `key:"smtpPort"`

View File

@@ -103,7 +103,7 @@ func TestAppConfigStructMatchesUpdateDto(t *testing.T) {
// Verify every AppConfig field has a matching DTO field with the same name
for fieldName, keyName := range appConfigFields {
if strings.HasSuffix(fieldName, "ImageType") {
if strings.HasSuffix(fieldName, "ImageType") || keyName == "instanceId" {
// Skip internal fields that shouldn't be in the DTO
continue
}

View File

@@ -5,8 +5,9 @@ import (
"encoding/json"
"fmt"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"gorm.io/gorm"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
type UserAuthorizedOidcClient struct {
@@ -45,6 +46,7 @@ type OidcClient struct {
HasLogo bool `gorm:"-"`
IsPublic bool
PkceEnabled bool
Credentials OidcClientCredentials
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
CreatedByID string
@@ -71,9 +73,49 @@ func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
return nil
}
type OidcClientCredentials struct { //nolint:recvcheck
FederatedIdentities []OidcClientFederatedIdentity `json:"federatedIdentities,omitempty"`
}
type OidcClientFederatedIdentity struct {
Issuer string `json:"issuer"`
Subject string `json:"subject,omitempty"`
Audience string `json:"audience,omitempty"`
JWKS string `json:"jwks,omitempty"` // URL of the JWKS
}
func (occ OidcClientCredentials) FederatedIdentityForIssuer(issuer string) (OidcClientFederatedIdentity, bool) {
if issuer == "" {
return OidcClientFederatedIdentity{}, false
}
for _, fi := range occ.FederatedIdentities {
if fi.Issuer == issuer {
return fi, true
}
}
return OidcClientFederatedIdentity{}, false
}
func (occ *OidcClientCredentials) Scan(value any) error {
switch v := value.(type) {
case []byte:
return json.Unmarshal(v, occ)
case string:
return json.Unmarshal([]byte(v), occ)
default:
return fmt.Errorf("unsupported type: %T", value)
}
}
func (occ OidcClientCredentials) Value() (driver.Value, error) {
return json.Marshal(occ)
}
type UrlList []string //nolint:recvcheck
func (cu *UrlList) Scan(value interface{}) error {
func (cu *UrlList) Scan(value any) error {
switch v := value.(type) {
case []byte:
return json.Unmarshal(v, cu)

View File

@@ -13,6 +13,8 @@ import (
"sync/atomic"
"time"
"github.com/hashicorp/go-uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -27,16 +29,21 @@ type AppConfigService struct {
db *gorm.DB
}
func NewAppConfigService(initCtx context.Context, db *gorm.DB) *AppConfigService {
func NewAppConfigService(ctx context.Context, db *gorm.DB) *AppConfigService {
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(initCtx)
err := service.LoadDbConfig(ctx)
if err != nil {
log.Fatalf("Failed to initialize app config service: %v", err)
}
err = service.initInstanceID(ctx)
if err != nil {
log.Fatalf("Failed to initialize instance ID: %v", err)
}
return service
}
@@ -61,10 +68,12 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
EmailsVerified: model.AppConfigVariable{Value: "false"},
DisableAnimations: model.AppConfigVariable{Value: "false"},
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
AccentColor: model.AppConfigVariable{Value: "default"},
// Internal
BackgroundImageType: model.AppConfigVariable{Value: "jpg"},
LogoLightImageType: model.AppConfigVariable{Value: "svg"},
LogoDarkImageType: model.AppConfigVariable{Value: "svg"},
InstanceID: model.AppConfigVariable{Value: ""},
// Email
SmtpHost: model.AppConfigVariable{},
SmtpPort: model.AppConfigVariable{},
@@ -228,7 +237,7 @@ func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppCon
return res, nil
}
// UpdateAppConfigValues
// UpdateAppConfigValues updates the application configuration values in the database.
func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndValues ...string) error {
// Count of keysAndValues must be even
if len(keysAndValues)%2 != 0 {
@@ -313,7 +322,7 @@ func (s *AppConfigService) ListAppConfig(showAll bool) []model.AppConfigVariable
}
func (s *AppConfigService) UpdateImage(ctx context.Context, uploadedFile *multipart.FileHeader, imageName string, oldImageType string) (err error) {
fileType := utils.GetFileExtension(uploadedFile.Filename)
fileType := strings.ToLower(utils.GetFileExtension(uploadedFile.Filename))
mimeType := utils.GetImageMimeType(fileType)
if mimeType == "" {
return &common.FileTypeNotSupportedError{}
@@ -347,24 +356,52 @@ func (s *AppConfigService) UpdateImage(ctx context.Context, uploadedFile *multip
// LoadDbConfig loads the configuration values from the database into the DbConfig struct.
func (s *AppConfigService) LoadDbConfig(ctx context.Context) (err error) {
var dest *model.AppConfig
// If the UI config is disabled, only load from the env
if common.EnvConfig.UiConfigDisabled {
dest, err = s.loadDbConfigFromEnv(ctx, s.db)
} else {
dest, err = s.loadDbConfigInternal(ctx, s.db)
}
dest, err := s.loadDbConfigInternal(ctx, s.db)
if err != nil {
return err
}
// Update the value in the object
s.dbConfig.Store(dest)
return nil
}
func (s *AppConfigService) loadDbConfigInternal(ctx context.Context, tx *gorm.DB) (*model.AppConfig, error) {
// If the UI config is disabled, only load from the env
if common.EnvConfig.UiConfigDisabled {
dest, err := s.loadDbConfigFromEnv(ctx, s.db)
return dest, err
}
// First, start from the default configuration
dest := s.getDefaultDbConfig()
// Load all configuration values from the database
// This loads all values in a single shot
var loaded []model.AppConfigVariable
queryCtx, queryCancel := context.WithTimeout(ctx, 10*time.Second)
defer queryCancel()
err := tx.
WithContext(queryCtx).
Find(&loaded).Error
if err != nil {
return nil, fmt.Errorf("failed to load configuration from the database: %w", err)
}
// Iterate through all values loaded from the database
for _, v := range loaded {
// Find the field in the struct whose "key" tag matches, then update that
err = dest.UpdateField(v.Key, v.Value, false)
// We ignore the case of fields that don't exist, as there may be leftover data in the database
if err != nil && !errors.Is(err, model.AppConfigKeyNotFoundError{}) {
return nil, fmt.Errorf("failed to process config for key '%s': %w", v.Key, err)
}
}
return dest, nil
}
func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB) (*model.AppConfig, error) {
// First, start from the default configuration
dest := s.getDefaultDbConfig()
@@ -406,37 +443,22 @@ func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB)
return dest, nil
}
func (s *AppConfigService) loadDbConfigInternal(ctx context.Context, tx *gorm.DB) (*model.AppConfig, error) {
// First, start from the default configuration
dest := s.getDefaultDbConfig()
func (s *AppConfigService) initInstanceID(ctx context.Context) error {
// Check if the instance ID is already set
instanceID := s.GetDbConfig().InstanceID.Value
if instanceID != "" {
return nil
}
// Load all configuration values from the database
// This loads all values in a single shot
var loaded []model.AppConfigVariable
queryCtx, queryCancel := context.WithTimeout(ctx, 10*time.Second)
defer queryCancel()
err := tx.
WithContext(queryCtx).
Find(&loaded).Error
newInstanceID, err := uuid.GenerateUUID()
if err != nil {
return nil, fmt.Errorf("failed to load configuration from the database: %w", err)
return fmt.Errorf("failed to generate new instance ID: %w", err)
}
// Iterate through all values loaded from the database
for _, v := range loaded {
// If the value is empty, it means we are using the default value
if v.Value == "" {
continue
}
// Find the field in the struct whose "key" tag matches, then update that
err = dest.UpdateField(v.Key, v.Value, false)
// We ignore the case of fields that don't exist, as there may be leftover data in the database
if err != nil && !errors.Is(err, model.AppConfigKeyNotFoundError{}) {
return nil, fmt.Errorf("failed to process config for key '%s': %w", v.Key, err)
}
err = s.UpdateAppConfigValues(ctx, "instanceId", newInstanceID)
if err != nil {
return fmt.Errorf("failed to update instance ID in the database: %w", err)
}
return dest, nil
return nil
}

View File

@@ -3,16 +3,10 @@ package service
import (
"sync/atomic"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/stretchr/testify/require"
)
@@ -28,7 +22,7 @@ func NewTestAppConfigService(config *model.AppConfig) *AppConfigService {
func TestLoadDbConfig(t *testing.T) {
t.Run("empty config table", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
db := newDatabaseForTest(t)
service := &AppConfigService{
db: db,
}
@@ -42,14 +36,13 @@ func TestLoadDbConfig(t *testing.T) {
})
t.Run("loads value from config table", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
db := newDatabaseForTest(t)
// Populate the config table with some initial values
err := db.
Create([]model.AppConfigVariable{
// Should be set to the default value because it's an empty string
{Key: "appName", Value: ""},
// Overrides default value
{Key: "appName", Value: "Test App"},
{Key: "sessionDuration", Value: "5"},
// Does not have a default value
{Key: "smtpHost", Value: "example"},
@@ -66,13 +59,14 @@ func TestLoadDbConfig(t *testing.T) {
// Values should match expected ones
expect := service.getDefaultDbConfig()
expect.AppName.Value = "Test App"
expect.SessionDuration.Value = "5"
expect.SmtpHost.Value = "example"
require.Equal(t, service.GetDbConfig(), expect)
})
t.Run("ignores unknown config keys", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
db := newDatabaseForTest(t)
// Add an entry with a key that doesn't exist in the config struct
err := db.Create([]model.AppConfigVariable{
@@ -93,7 +87,7 @@ func TestLoadDbConfig(t *testing.T) {
})
t.Run("loading config multiple times", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
db := newDatabaseForTest(t)
// Initial state
err := db.Create([]model.AppConfigVariable{
@@ -135,7 +129,7 @@ func TestLoadDbConfig(t *testing.T) {
common.EnvConfig.UiConfigDisabled = true
// Create database with config that should be ignored
db := newAppConfigTestDatabaseForTest(t)
db := newDatabaseForTest(t)
err := db.Create([]model.AppConfigVariable{
{Key: "appName", Value: "DB App"},
{Key: "sessionDuration", Value: "120"},
@@ -171,7 +165,7 @@ func TestLoadDbConfig(t *testing.T) {
common.EnvConfig.UiConfigDisabled = false
// Create database with config values that should take precedence
db := newAppConfigTestDatabaseForTest(t)
db := newDatabaseForTest(t)
err := db.Create([]model.AppConfigVariable{
{Key: "appName", Value: "DB App"},
{Key: "sessionDuration", Value: "120"},
@@ -195,7 +189,7 @@ func TestLoadDbConfig(t *testing.T) {
func TestUpdateAppConfigValues(t *testing.T) {
t.Run("update single value", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
db := newDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -220,7 +214,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
})
t.Run("update multiple values", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
db := newDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -264,7 +258,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
})
t.Run("empty value resets to default", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
db := newDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -285,7 +279,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
})
t.Run("error with odd number of arguments", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
db := newDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -301,7 +295,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
})
t.Run("error with invalid key", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
db := newDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -319,7 +313,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
func TestUpdateAppConfig(t *testing.T) {
t.Run("updates configuration values from DTO", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
db := newDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -392,7 +386,7 @@ func TestUpdateAppConfig(t *testing.T) {
})
t.Run("empty values reset to defaults", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
db := newDatabaseForTest(t)
// Create a service with default config and modify some values
service := &AppConfigService{
@@ -457,7 +451,7 @@ func TestUpdateAppConfig(t *testing.T) {
// Disable UI config
common.EnvConfig.UiConfigDisabled = true
db := newAppConfigTestDatabaseForTest(t)
db := newDatabaseForTest(t)
service := &AppConfigService{
db: db,
}
@@ -475,49 +469,3 @@ func TestUpdateAppConfig(t *testing.T) {
require.ErrorAs(t, err, &uiConfigDisabledErr)
})
}
// Implements gorm's logger.Writer interface
type testLoggerAdapter struct {
t *testing.T
}
func (l testLoggerAdapter) Printf(format string, args ...any) {
l.t.Logf(format, args...)
}
func newAppConfigTestDatabaseForTest(t *testing.T) *gorm.DB {
t.Helper()
// Get a name for this in-memory database that is specific to the test
dbName := utils.CreateSha256Hash(t.Name())
// Connect to a new in-memory SQL database
db, err := gorm.Open(
sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"),
&gorm.Config{
TranslateError: true,
Logger: logger.New(
testLoggerAdapter{t: t},
logger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: logger.Info,
IgnoreRecordNotFoundError: false,
ParameterizedQueries: false,
Colorful: false,
},
),
})
require.NoError(t, err, "Failed to connect to test database")
// Create the app_config_variables table
err = db.Exec(`
CREATE TABLE app_config_variables
(
key VARCHAR(100) NOT NULL PRIMARY KEY,
value TEXT NOT NULL
)
`).Error
require.NoError(t, err, "Failed to create test config table")
return db
}

View File

@@ -150,6 +150,14 @@ func (s *AuditLogService) ListAllAuditLogs(ctx context.Context, sortedPagination
return nil, utils.PaginationResponse{}, fmt.Errorf("unsupported database dialect: %s", dialect)
}
}
if filters.Location != "" {
switch filters.Location {
case "external":
query = query.Where("country != 'Internal Network'")
case "internal":
query = query.Where("country = 'Internal Network'")
}
}
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &logs)
if err != nil {

View File

@@ -5,6 +5,8 @@ package service
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"fmt"
@@ -16,6 +18,7 @@ import (
"github.com/fxamacker/cbor/v2"
"github.com/go-webauthn/webauthn/protocol"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
@@ -30,14 +33,43 @@ type TestService struct {
jwtService *JwtService
appConfigService *AppConfigService
ldapService *LdapService
externalIdPKey jwk.Key
}
func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService, ldapService *LdapService) *TestService {
return &TestService{db: db, appConfigService: appConfigService, jwtService: jwtService, ldapService: ldapService}
func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService, ldapService *LdapService) (*TestService, error) {
s := &TestService{
db: db,
appConfigService: appConfigService,
jwtService: jwtService,
ldapService: ldapService,
}
err := s.initExternalIdP()
if err != nil {
return nil, fmt.Errorf("failed to initialize external IdP: %w", err)
}
return s, nil
}
// Initializes the "external IdP"
// This creates a new "issuing authority" containing a public JWKS
// It also stores the private key internally that will be used to issue JWTs
func (s *TestService) initExternalIdP() error {
// Generate a new ECDSA key
rawKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return fmt.Errorf("failed to generate private key: %w", err)
}
s.externalIdPKey, err = utils.ImportRawKey(rawKey)
if err != nil {
return fmt.Errorf("failed to import private key: %w", err)
}
return nil
}
//nolint:gocognit
func (s *TestService) SeedDatabase() error {
func (s *TestService) SeedDatabase(baseURL string) error {
err := s.db.Transaction(func(tx *gorm.DB) error {
users := []model.User{
{
@@ -138,6 +170,26 @@ func (s *TestService) SeedDatabase() error {
userGroups[1],
},
},
{
Base: model.Base{
ID: "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
},
Name: "Federated",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
CallbackURLs: model.UrlList{"http://federated/auth/callback"},
CreatedByID: users[1].ID,
AllowedUserGroups: []model.UserGroup{},
Credentials: model.OidcClientCredentials{
FederatedIdentities: []model.OidcClientFederatedIdentity{
{
Issuer: "https://external-idp.local",
Audience: "api://PocketID",
Subject: "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
JWKS: baseURL + "/api/externalidp/jwks.json",
},
},
},
},
}
for _, client := range oidcClients {
if err := tx.Create(&client).Error; err != nil {
@@ -145,16 +197,28 @@ func (s *TestService) SeedDatabase() error {
}
}
authCode := model.OidcAuthorizationCode{
Code: "auth-code",
Scope: "openid profile",
Nonce: "nonce",
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[0].ID,
ClientID: oidcClients[0].ID,
authCodes := []model.OidcAuthorizationCode{
{
Code: "auth-code",
Scope: "openid profile",
Nonce: "nonce",
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[0].ID,
ClientID: oidcClients[0].ID,
},
{
Code: "federated",
Scope: "openid profile",
Nonce: "nonce",
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[1].ID,
ClientID: oidcClients[2].ID,
},
}
if err := tx.Create(&authCode).Error; err != nil {
return err
for _, authCode := range authCodes {
if err := tx.Create(&authCode).Error; err != nil {
return err
}
}
refreshToken := model.OidcRefreshToken{
@@ -177,13 +241,22 @@ func (s *TestService) SeedDatabase() error {
return err
}
userAuthorizedClient := model.UserAuthorizedOidcClient{
Scope: "openid profile email",
UserID: users[0].ID,
ClientID: oidcClients[0].ID,
userAuthorizedClients := []model.UserAuthorizedOidcClient{
{
Scope: "openid profile email",
UserID: users[0].ID,
ClientID: oidcClients[0].ID,
},
{
Scope: "openid profile email",
UserID: users[1].ID,
ClientID: oidcClients[2].ID,
},
}
if err := tx.Create(&userAuthorizedClient).Error; err != nil {
return err
for _, userAuthorizedClient := range userAuthorizedClients {
if err := tx.Create(&userAuthorizedClient).Error; err != nil {
return err
}
}
// To generate a new key pair, run the following command:
@@ -405,3 +478,45 @@ func (s *TestService) SetLdapTestConfig(ctx context.Context) error {
return nil
}
func (s *TestService) SignRefreshToken(userID, clientID, refreshToken string) (string, error) {
return s.jwtService.GenerateOAuthRefreshToken(userID, clientID, refreshToken)
}
// GetExternalIdPJWKS returns the JWKS for the "external IdP".
func (s *TestService) GetExternalIdPJWKS() (jwk.Set, error) {
pubKey, err := s.externalIdPKey.PublicKey()
if err != nil {
return nil, fmt.Errorf("failed to get public key: %w", err)
}
set := jwk.NewSet()
err = set.AddKey(pubKey)
if err != nil {
return nil, fmt.Errorf("failed to add public key to set: %w", err)
}
return set, nil
}
func (s *TestService) SignExternalIdPToken(iss, sub, aud string) (string, error) {
now := time.Now()
token, err := jwt.NewBuilder().
Subject(sub).
Expiration(now.Add(time.Hour)).
IssuedAt(now).
Issuer(iss).
Audience([]string{aud}).
Build()
if err != nil {
return "", fmt.Errorf("failed to build token: %w", err)
}
alg, _ := s.externalIdPKey.Algorithm()
signed, err := jwt.Sign(token, jwt.WithKey(alg, s.externalIdPKey))
if err != nil {
return "", fmt.Errorf("failed to sign token: %w", err)
}
return string(signed), nil
}

View File

@@ -13,6 +13,7 @@ import (
"net/netip"
"os"
"path/filepath"
"strings"
"sync"
"time"
@@ -22,9 +23,10 @@ import (
)
type GeoLiteService struct {
httpClient *http.Client
disableUpdater bool
mutex sync.RWMutex
httpClient *http.Client
disableUpdater bool
mutex sync.RWMutex
localIPv6Ranges []*net.IPNet
}
var localhostIPNets = []*net.IPNet{
@@ -54,9 +56,66 @@ func NewGeoLiteService(httpClient *http.Client) *GeoLiteService {
service.disableUpdater = true
}
// Initialize IPv6 local ranges
if err := service.initializeIPv6LocalRanges(); err != nil {
log.Printf("Warning: Failed to initialize IPv6 local ranges: %v", err)
}
return service
}
// initializeIPv6LocalRanges parses the LOCAL_IPV6_RANGES environment variable
func (s *GeoLiteService) initializeIPv6LocalRanges() error {
rangesEnv := common.EnvConfig.LocalIPv6Ranges
if rangesEnv == "" {
return nil // No local IPv6 ranges configured
}
ranges := strings.Split(rangesEnv, ",")
localRanges := make([]*net.IPNet, 0, len(ranges))
for _, rangeStr := range ranges {
rangeStr = strings.TrimSpace(rangeStr)
if rangeStr == "" {
continue
}
_, ipNet, err := net.ParseCIDR(rangeStr)
if err != nil {
return fmt.Errorf("invalid IPv6 range '%s': %w", rangeStr, err)
}
// Ensure it's an IPv6 range
if ipNet.IP.To4() != nil {
return fmt.Errorf("range '%s' is not a valid IPv6 range", rangeStr)
}
localRanges = append(localRanges, ipNet)
}
s.localIPv6Ranges = localRanges
if len(localRanges) > 0 {
log.Printf("Initialized %d IPv6 local ranges", len(localRanges))
}
return nil
}
// isLocalIPv6 checks if the given IPv6 address is within any of the configured local ranges
func (s *GeoLiteService) isLocalIPv6(ip net.IP) bool {
if ip.To4() != nil {
return false // Not an IPv6 address
}
for _, localRange := range s.localIPv6Ranges {
if localRange.Contains(ip) {
return true
}
}
return false
}
func (s *GeoLiteService) DisableUpdater() bool {
return s.disableUpdater
}
@@ -65,6 +124,12 @@ func (s *GeoLiteService) DisableUpdater() bool {
func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string, err error) {
// Check the IP address against known private IP ranges
if ip := net.ParseIP(ipAddress); ip != nil {
// Check IPv6 local ranges first
if s.isLocalIPv6(ip) {
return "Internal Network", "LAN", nil
}
// Check existing IPv4 ranges
for _, ipNet := range tailscaleIPNets {
if ipNet.Contains(ip) {
return "Internal Network", "Tailscale", nil
@@ -72,7 +137,7 @@ func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string
}
for _, ipNet := range privateLanIPNets {
if ipNet.Contains(ip) {
return "Internal Network", "LAN/Docker/k8s", nil
return "Internal Network", "LAN", nil
}
}
for _, ipNet := range localhostIPNets {

View File

@@ -0,0 +1,231 @@
package service
import (
"net"
"net/http"
"testing"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
func TestGeoLiteService_IPv6LocalRanges(t *testing.T) {
tests := []struct {
name string
localRanges string
testIP string
expectedCountry string
expectedCity string
expectError bool
}{
{
name: "IPv6 in local range",
localRanges: "2001:0db8:abcd:000::/56,2001:0db8:abcd:001::/56",
testIP: "2001:0db8:abcd:000::1",
expectedCountry: "Internal Network",
expectedCity: "LAN",
expectError: false,
},
{
name: "IPv6 not in local range",
localRanges: "2001:0db8:abcd:000::/56",
testIP: "2001:0db8:ffff:000::1",
expectError: true,
},
{
name: "Multiple ranges - second range match",
localRanges: "2001:0db8:abcd:000::/56,2001:0db8:abcd:001::/56",
testIP: "2001:0db8:abcd:001::1",
expectedCountry: "Internal Network",
expectedCity: "LAN",
expectError: false,
},
{
name: "Empty local ranges",
localRanges: "",
testIP: "2001:0db8:abcd:000::1",
expectError: true,
},
{
name: "IPv4 private address still works",
localRanges: "2001:0db8:abcd:000::/56",
testIP: "192.168.1.1",
expectedCountry: "Internal Network",
expectedCity: "LAN",
expectError: false,
},
{
name: "IPv6 loopback",
localRanges: "2001:0db8:abcd:000::/56",
testIP: "::1",
expectedCountry: "Internal Network",
expectedCity: "localhost",
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
originalConfig := common.EnvConfig.LocalIPv6Ranges
common.EnvConfig.LocalIPv6Ranges = tt.localRanges
defer func() {
common.EnvConfig.LocalIPv6Ranges = originalConfig
}()
service := NewGeoLiteService(&http.Client{})
country, city, err := service.GetLocationByIP(tt.testIP)
if tt.expectError {
if err == nil && country != "Internal Network" {
t.Errorf("Expected error or internal network classification for external IP")
}
} else {
if err != nil {
t.Errorf("Expected no error for local IP, got: %v", err)
}
if country != tt.expectedCountry {
t.Errorf("Expected country %s, got %s", tt.expectedCountry, country)
}
if city != tt.expectedCity {
t.Errorf("Expected city %s, got %s", tt.expectedCity, city)
}
}
})
}
}
func TestGeoLiteService_isLocalIPv6(t *testing.T) {
tests := []struct {
name string
localRanges string
testIP string
expected bool
}{
{
name: "Valid IPv6 in range",
localRanges: "2001:0db8:abcd:000::/56",
testIP: "2001:0db8:abcd:000::1",
expected: true,
},
{
name: "Valid IPv6 not in range",
localRanges: "2001:0db8:abcd:000::/56",
testIP: "2001:0db8:ffff:000::1",
expected: false,
},
{
name: "IPv4 address should return false",
localRanges: "2001:0db8:abcd:000::/56",
testIP: "192.168.1.1",
expected: false,
},
{
name: "No ranges configured",
localRanges: "",
testIP: "2001:0db8:abcd:000::1",
expected: false,
},
{
name: "Edge of range",
localRanges: "2001:0db8:abcd:000::/56",
testIP: "2001:0db8:abcd:00ff:ffff:ffff:ffff:ffff",
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
originalConfig := common.EnvConfig.LocalIPv6Ranges
common.EnvConfig.LocalIPv6Ranges = tt.localRanges
defer func() {
common.EnvConfig.LocalIPv6Ranges = originalConfig
}()
service := NewGeoLiteService(&http.Client{})
ip := net.ParseIP(tt.testIP)
if ip == nil {
t.Fatalf("Invalid test IP: %s", tt.testIP)
}
result := service.isLocalIPv6(ip)
if result != tt.expected {
t.Errorf("Expected %v, got %v for IP %s", tt.expected, result, tt.testIP)
}
})
}
}
func TestGeoLiteService_initializeIPv6LocalRanges(t *testing.T) {
tests := []struct {
name string
envValue string
expectError bool
expectCount int
}{
{
name: "Valid IPv6 ranges",
envValue: "2001:0db8:abcd:000::/56,2001:0db8:abcd:001::/56",
expectError: false,
expectCount: 2,
},
{
name: "Empty environment variable",
envValue: "",
expectError: false,
expectCount: 0,
},
{
name: "Invalid CIDR notation",
envValue: "2001:0db8:abcd:000::/999",
expectError: true,
expectCount: 0,
},
{
name: "IPv4 range in IPv6 env var",
envValue: "192.168.1.0/24",
expectError: true,
expectCount: 0,
},
{
name: "Mixed valid and invalid ranges",
envValue: "2001:0db8:abcd:000::/56,invalid-range",
expectError: true,
expectCount: 0,
},
{
name: "Whitespace handling",
envValue: " 2001:0db8:abcd:000::/56 , 2001:0db8:abcd:001::/56 ",
expectError: false,
expectCount: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
originalConfig := common.EnvConfig.LocalIPv6Ranges
common.EnvConfig.LocalIPv6Ranges = tt.envValue
defer func() {
common.EnvConfig.LocalIPv6Ranges = originalConfig
}()
service := &GeoLiteService{
httpClient: &http.Client{},
}
err := service.initializeIPv6LocalRanges()
if tt.expectError && err == nil {
t.Errorf("Expected error but got none")
}
if !tt.expectError && err != nil {
t.Errorf("Expected no error but got: %v", err)
}
rangeCount := len(service.localIPv6Ranges)
if rangeCount != tt.expectCount {
t.Errorf("Expected %d ranges, got %d", tt.expectCount, rangeCount)
}
})
}
}

View File

@@ -4,11 +4,9 @@ import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"os"
"path/filepath"
@@ -41,9 +39,15 @@ const (
// TokenTypeClaim is the claim used to identify the type of token
TokenTypeClaim = "type"
// RefreshTokenClaim is the claim used for the refresh token's value
RefreshTokenClaim = "rt"
// OAuthAccessTokenJWTType identifies a JWT as an OAuth access token
OAuthAccessTokenJWTType = "oauth-access-token" //nolint:gosec
// OAuthRefreshTokenJWTType identifies a JWT as an OAuth refresh token
OAuthRefreshTokenJWTType = "refresh-token"
// AccessTokenJWTType identifies a JWT as an access token used by Pocket ID
AccessTokenJWTType = "access-token"
@@ -236,7 +240,8 @@ func (s *JwtService) VerifyAccessToken(tokenString string) (jwt.Token, error) {
return token, nil
}
func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string, nonce string) (string, error) {
// BuildIDToken creates an ID token with all claims
func (s *JwtService) BuildIDToken(userClaims map[string]any, clientID string, nonce string) (jwt.Token, error) {
now := time.Now()
token, err := jwt.NewBuilder().
Expiration(now.Add(1 * time.Hour)).
@@ -244,33 +249,43 @@ func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string,
Issuer(common.EnvConfig.AppURL).
Build()
if err != nil {
return "", fmt.Errorf("failed to build token: %w", err)
return nil, fmt.Errorf("failed to build token: %w", err)
}
err = SetAudienceString(token, clientID)
if err != nil {
return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err)
return nil, fmt.Errorf("failed to set 'aud' claim in token: %w", err)
}
err = SetTokenType(token, IDTokenJWTType)
if err != nil {
return "", fmt.Errorf("failed to set 'type' claim in token: %w", err)
return nil, fmt.Errorf("failed to set 'type' claim in token: %w", err)
}
for k, v := range userClaims {
err = token.Set(k, v)
if err != nil {
return "", fmt.Errorf("failed to set claim '%s': %w", k, err)
return nil, fmt.Errorf("failed to set claim '%s': %w", k, err)
}
}
if nonce != "" {
err = token.Set("nonce", nonce)
if err != nil {
return "", fmt.Errorf("failed to set claim 'nonce': %w", err)
return nil, fmt.Errorf("failed to set claim 'nonce': %w", err)
}
}
return token, nil
}
// GenerateIDToken creates and signs an ID token
func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string, nonce string) (string, error) {
token, err := s.BuildIDToken(userClaims, clientID, nonce)
if err != nil {
return "", err
}
alg, _ := s.privateKey.Algorithm()
signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey))
if err != nil {
@@ -313,7 +328,8 @@ func (s *JwtService) VerifyIdToken(tokenString string, acceptExpiredTokens bool)
return token, nil
}
func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) {
// BuildOAuthAccessToken creates an OAuth access token with all claims
func (s *JwtService) BuildOAuthAccessToken(user model.User, clientID string) (jwt.Token, error) {
now := time.Now()
token, err := jwt.NewBuilder().
Subject(user.ID).
@@ -322,17 +338,27 @@ func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string)
Issuer(common.EnvConfig.AppURL).
Build()
if err != nil {
return "", fmt.Errorf("failed to build token: %w", err)
return nil, fmt.Errorf("failed to build token: %w", err)
}
err = SetAudienceString(token, clientID)
if err != nil {
return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err)
return nil, fmt.Errorf("failed to set 'aud' claim in token: %w", err)
}
err = SetTokenType(token, OAuthAccessTokenJWTType)
if err != nil {
return "", fmt.Errorf("failed to set 'type' claim in token: %w", err)
return nil, fmt.Errorf("failed to set 'type' claim in token: %w", err)
}
return token, nil
}
// GenerateOAuthAccessToken creates and signs an OAuth access token
func (s *JwtService) GenerateOAuthAccessToken(user model.User, clientID string) (string, error) {
token, err := s.BuildOAuthAccessToken(user, clientID)
if err != nil {
return "", err
}
alg, _ := s.privateKey.Algorithm()
@@ -344,7 +370,7 @@ func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string)
return string(signed), nil
}
func (s *JwtService) VerifyOauthAccessToken(tokenString string) (jwt.Token, error) {
func (s *JwtService) VerifyOAuthAccessToken(tokenString string) (jwt.Token, error) {
alg, _ := s.privateKey.Algorithm()
token, err := jwt.ParseString(
tokenString,
@@ -361,6 +387,96 @@ func (s *JwtService) VerifyOauthAccessToken(tokenString string) (jwt.Token, erro
return token, nil
}
func (s *JwtService) GenerateOAuthRefreshToken(userID string, clientID string, refreshToken string) (string, error) {
now := time.Now()
token, err := jwt.NewBuilder().
Subject(userID).
Expiration(now.Add(RefreshTokenDuration)).
IssuedAt(now).
Issuer(common.EnvConfig.AppURL).
Build()
if err != nil {
return "", fmt.Errorf("failed to build token: %w", err)
}
err = token.Set(RefreshTokenClaim, refreshToken)
if err != nil {
return "", fmt.Errorf("failed to set 'rt' claim in token: %w", err)
}
err = SetAudienceString(token, clientID)
if err != nil {
return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err)
}
err = SetTokenType(token, OAuthRefreshTokenJWTType)
if err != nil {
return "", fmt.Errorf("failed to set 'type' claim in token: %w", err)
}
alg, _ := s.privateKey.Algorithm()
signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey))
if err != nil {
return "", fmt.Errorf("failed to sign token: %w", err)
}
return string(signed), nil
}
func (s *JwtService) VerifyOAuthRefreshToken(tokenString string) (userID, clientID, rt string, err error) {
alg, _ := s.privateKey.Algorithm()
token, err := jwt.ParseString(
tokenString,
jwt.WithValidate(true),
jwt.WithKey(alg, s.privateKey),
jwt.WithAcceptableSkew(clockSkew),
jwt.WithIssuer(common.EnvConfig.AppURL),
jwt.WithValidator(TokenTypeValidator(OAuthRefreshTokenJWTType)),
)
if err != nil {
return "", "", "", fmt.Errorf("failed to parse token: %w", err)
}
err = token.Get(RefreshTokenClaim, &rt)
if err != nil {
return "", "", "", fmt.Errorf("failed to get '%s' claim from token: %w", RefreshTokenClaim, err)
}
audiences, ok := token.Audience()
if !ok || len(audiences) != 1 || audiences[0] == "" {
return "", "", "", errors.New("failed to get 'aud' claim from token")
}
clientID = audiences[0]
userID, ok = token.Subject()
if !ok {
return "", "", "", errors.New("failed to get 'sub' claim from token")
}
return userID, clientID, rt, nil
}
// GetTokenType returns the type of the JWT token issued by Pocket ID, but **does not validate it**.
func (s *JwtService) GetTokenType(tokenString string) (string, jwt.Token, error) {
// Disable validation and verification to parse the token without checking it
token, err := jwt.ParseString(
tokenString,
jwt.WithValidate(false),
jwt.WithVerify(false),
)
if err != nil {
return "", nil, fmt.Errorf("failed to parse token: %w", err)
}
var tokenType string
err = token.Get(TokenTypeClaim, &tokenType)
if err != nil {
return "", nil, fmt.Errorf("failed to get token type claim: %w", err)
}
return tokenType, token, nil
}
// GetPublicJWK returns the JSON Web Key (JWK) for the public key.
func (s *JwtService) GetPublicJWK() (jwk.Key, error) {
if s.privateKey == nil {
@@ -372,7 +488,7 @@ func (s *JwtService) GetPublicJWK() (jwk.Key, error) {
return nil, fmt.Errorf("failed to get public key: %w", err)
}
EnsureAlgInKey(pubKey)
utils.EnsureAlgInKey(pubKey)
return pubKey, nil
}
@@ -415,27 +531,6 @@ func (s *JwtService) loadKeyJWK(path string) (jwk.Key, error) {
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)
@@ -444,27 +539,7 @@ func (s *JwtService) generateNewRSAKey() (jwk.Key, error) {
}
// 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
return utils.ImportRawKey(rawKey)
}
// SaveKeyJWK saves a JWK to a file
@@ -492,16 +567,6 @@ func SaveKeyJWK(key jwk.Key, path string) error {
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
}
// GetIsAdmin returns the value of the "isAdmin" claim in the token
func GetIsAdmin(token jwt.Token) (bool, error) {
if !token.Has(IsAdminClaim) {
@@ -509,7 +574,10 @@ func GetIsAdmin(token jwt.Token) (bool, error) {
}
var isAdmin bool
err := token.Get(IsAdminClaim, &isAdmin)
return isAdmin, err
if err != nil {
return false, fmt.Errorf("failed to get 'isAdmin' claim from token: %w", err)
}
return isAdmin, nil
}
// SetTokenType sets the "type" claim in the token

View File

@@ -21,6 +21,7 @@ import (
"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"
)
func TestJwtService_Init(t *testing.T) {
@@ -881,7 +882,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
})
}
func TestGenerateVerifyOauthAccessToken(t *testing.T) {
func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
@@ -913,12 +914,12 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
const clientID = "test-client-123"
// Generate a token
tokenString, err := service.GenerateOauthAccessToken(user, clientID)
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)
claims, err := service.VerifyOAuthAccessToken(tokenString)
require.NoError(t, err, "Failed to verify generated OAuth access token")
// Check the claims
@@ -971,7 +972,7 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
require.NoError(t, err, "Failed to sign token")
// Verify should fail due to expiration
_, err = service.VerifyOauthAccessToken(string(signed))
_, err = service.VerifyOAuthAccessToken(string(signed))
require.Error(t, err, "Verification should fail with expired token")
assert.Contains(t, err.Error(), `"exp" not satisfied`, "Error message should indicate token verification failure")
})
@@ -995,11 +996,11 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
const clientID = "test-client-789"
// Generate a token with the first service
tokenString, err := service1.GenerateOauthAccessToken(user, clientID)
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)
_, err = service2.VerifyOAuthAccessToken(tokenString)
require.Error(t, err, "Verification should fail with invalid signature")
assert.Contains(t, err.Error(), "verification error", "Error message should indicate token verification failure")
})
@@ -1031,12 +1032,12 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
const clientID = "eddsa-oauth-client"
// Generate a token
tokenString, err := service.GenerateOauthAccessToken(user, clientID)
tokenString, err := service.GenerateOAuthAccessToken(user, clientID)
require.NoError(t, err, "Failed to generate OAuth access token with key")
assert.NotEmpty(t, tokenString, "Token should not be empty")
// Verify the token
claims, err := service.VerifyOauthAccessToken(tokenString)
claims, err := service.VerifyOAuthAccessToken(tokenString)
require.NoError(t, err, "Failed to verify generated OAuth access token with key")
// Check the claims
@@ -1085,12 +1086,12 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
const clientID = "ecdsa-oauth-client"
// Generate a token
tokenString, err := service.GenerateOauthAccessToken(user, clientID)
tokenString, err := service.GenerateOAuthAccessToken(user, clientID)
require.NoError(t, err, "Failed to generate OAuth access token with key")
assert.NotEmpty(t, tokenString, "Token should not be empty")
// Verify the token
claims, err := service.VerifyOauthAccessToken(tokenString)
claims, err := service.VerifyOAuthAccessToken(tokenString)
require.NoError(t, err, "Failed to verify generated OAuth access token with key")
// Check the claims
@@ -1139,12 +1140,12 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
const clientID = "rsa-oauth-client"
// Generate a token
tokenString, err := service.GenerateOauthAccessToken(user, clientID)
tokenString, err := service.GenerateOAuthAccessToken(user, clientID)
require.NoError(t, err, "Failed to generate OAuth access token with key")
assert.NotEmpty(t, tokenString, "Token should not be empty")
// Verify the token
claims, err := service.VerifyOauthAccessToken(tokenString)
claims, err := service.VerifyOAuthAccessToken(tokenString)
require.NoError(t, err, "Failed to verify generated OAuth access token with key")
// Check the claims
@@ -1167,6 +1168,92 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
})
}
func TestGenerateVerifyOAuthRefreshToken(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Initialize the JWT service with a mock AppConfigService
mockConfig := NewTestAppConfigService(&model.AppConfig{})
// 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 refresh token", 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
const (
userID = "user123"
clientID = "client123"
refreshToken = "rt-123"
)
// Generate a token
tokenString, err := service.GenerateOAuthRefreshToken(userID, clientID, refreshToken)
require.NoError(t, err, "Failed to generate refresh token")
assert.NotEmpty(t, tokenString, "Token should not be empty")
// Verify the token
resUser, resClient, resRT, err := service.VerifyOAuthRefreshToken(tokenString)
require.NoError(t, err, "Failed to verify generated token")
assert.Equal(t, userID, resUser, "Should return correct user ID")
assert.Equal(t, clientID, resClient, "Should return correct client ID")
assert.Equal(t, refreshToken, resRT, "Should return correct refresh token")
})
t.Run("fails verification for expired token", 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 using JWT directly to create an expired token
token, err := jwt.NewBuilder().
Subject("user789").
Expiration(time.Now().Add(-1 * time.Hour)). // Expired 1 hour ago
IssuedAt(time.Now().Add(-2 * time.Hour)).
Audience([]string{"client123"}).
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.VerifyOAuthRefreshToken(string(signed))
require.Error(t, err, "Verification should fail with expired token")
assert.Contains(t, err.Error(), `"exp" not satisfied`, "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())
require.NoError(t, err, "Failed to initialize first JWT service")
service2 := &JwtService{}
err = service2.init(mockConfig, t.TempDir())
require.NoError(t, err, "Failed to initialize second JWT service")
// Generate a token with the first service
tokenString, err := service1.GenerateOAuthRefreshToken("user789", "client123", "my-rt-123")
require.NoError(t, err, "Failed to generate refresh token")
// Verify with the second service should fail due to different keys
_, _, _, err = service2.VerifyOAuthRefreshToken(tokenString)
require.Error(t, err, "Verification should fail with invalid signature")
assert.Contains(t, err.Error(), "verification error", "Error message should indicate token verification failure")
})
}
func TestTokenTypeValidator(t *testing.T) {
// Create a context for the validator function
ctx := context.Background()
@@ -1212,13 +1299,110 @@ func TestTokenTypeValidator(t *testing.T) {
require.Error(t, err, "Validator should reject token without type claim")
assert.Contains(t, err.Error(), "failed to get token type claim")
})
}
func TestGetTokenType(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Initialize the JWT service
mockConfig := NewTestAppConfigService(&model.AppConfig{})
service := &JwtService{}
err := service.init(mockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
buildTokenForType := func(t *testing.T, typ string, setClaimsFn func(b *jwt.Builder)) string {
t.Helper()
b := jwt.NewBuilder()
b.Subject("user123")
if setClaimsFn != nil {
setClaimsFn(b)
}
token, err := b.Build()
require.NoError(t, err, "Failed to build token")
err = SetTokenType(token, typ)
require.NoError(t, err, "Failed to set token type")
alg, _ := service.privateKey.Algorithm()
signed, err := jwt.Sign(token, jwt.WithKey(alg, service.privateKey))
require.NoError(t, err, "Failed to sign token")
return string(signed)
}
t.Run("correctly identifies access tokens", func(t *testing.T) {
tokenString := buildTokenForType(t, AccessTokenJWTType, nil)
// Get the token type without validating
tokenType, _, err := service.GetTokenType(tokenString)
require.NoError(t, err, "GetTokenType should not return an error")
assert.Equal(t, AccessTokenJWTType, tokenType, "Token type should be correctly identified as access token")
})
t.Run("correctly identifies ID tokens", func(t *testing.T) {
tokenString := buildTokenForType(t, IDTokenJWTType, nil)
// Get the token type without validating
tokenType, _, err := service.GetTokenType(tokenString)
require.NoError(t, err, "GetTokenType should not return an error")
assert.Equal(t, IDTokenJWTType, tokenType, "Token type should be correctly identified as ID token")
})
t.Run("correctly identifies OAuth access tokens", func(t *testing.T) {
tokenString := buildTokenForType(t, OAuthAccessTokenJWTType, nil)
// Get the token type without validating
tokenType, _, err := service.GetTokenType(tokenString)
require.NoError(t, err, "GetTokenType should not return an error")
assert.Equal(t, OAuthAccessTokenJWTType, tokenType, "Token type should be correctly identified as OAuth access token")
})
t.Run("correctly identifies refresh tokens", func(t *testing.T) {
tokenString := buildTokenForType(t, OAuthRefreshTokenJWTType, nil)
// Get the token type without validating
tokenType, _, err := service.GetTokenType(tokenString)
require.NoError(t, err, "GetTokenType should not return an error")
assert.Equal(t, OAuthRefreshTokenJWTType, tokenType, "Token type should be correctly identified as refresh token")
})
t.Run("works with expired tokens", func(t *testing.T) {
tokenString := buildTokenForType(t, AccessTokenJWTType, func(b *jwt.Builder) {
b.Expiration(time.Now().Add(-1 * time.Hour)) // Expired 1 hour ago
})
// Get the token type without validating
tokenType, _, err := service.GetTokenType(tokenString)
require.NoError(t, err, "GetTokenType should not return an error for expired tokens")
assert.Equal(t, AccessTokenJWTType, tokenType, "Token type should be correctly identified even for expired tokens")
})
t.Run("returns error for malformed tokens", func(t *testing.T) {
// Try to get the token type of a malformed token
tokenType, _, err := service.GetTokenType("not.a.valid.jwt.token")
require.Error(t, err, "GetTokenType should return an error for malformed tokens")
assert.Empty(t, tokenType, "Token type should be empty for malformed tokens")
})
t.Run("returns error for tokens without type claim", func(t *testing.T) {
// Create a token without type claim
tokenString := buildTokenForType(t, "", nil)
// Get the token type without validating
tokenType, _, err := service.GetTokenType(tokenString)
require.Error(t, err, "GetTokenType should return an error for tokens without type claim")
assert.Empty(t, tokenType, "Token type should be empty when type claim is missing")
assert.Contains(t, err.Error(), "failed to get token type claim", "Error message should indicate missing token type claim")
})
}
func importKey(t *testing.T, privateKeyRaw any, path string) string {
t.Helper()
privateKey, err := importRawKey(privateKeyRaw)
privateKey, err := utils.ImportRawKey(privateKeyRaw)
require.NoError(t, err, "Failed to import private key")
err = SaveKeyJWK(privateKey, filepath.Join(path, PrivateKeyFile))

View File

@@ -148,22 +148,44 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
groupMembers := value.GetAttributeValues(dbConfig.LdapAttributeGroupMember.Value)
membersUserId := make([]string, 0, len(groupMembers))
for _, member := range groupMembers {
ldapId := getDNProperty("uid", member)
if ldapId == "" {
continue
username := getDNProperty(dbConfig.LdapAttributeUserUsername.Value, member)
// If username extraction fails, try to query LDAP directly for the user
if username == "" {
// Query LDAP to get the user by their DN
userSearchReq := ldap.NewSearchRequest(
member,
ldap.ScopeBaseObject,
0, 0, 0, false,
"(objectClass=*)",
[]string{dbConfig.LdapAttributeUserUsername.Value, dbConfig.LdapAttributeUserUniqueIdentifier.Value},
[]ldap.Control{},
)
userResult, err := client.Search(userSearchReq)
if err != nil || len(userResult.Entries) == 0 {
log.Printf("Could not resolve group member DN '%s': %v", member, err)
continue
}
username = userResult.Entries[0].GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value)
if username == "" {
log.Printf("Could not extract username from group member DN '%s'", member)
continue
}
}
var databaseUser model.User
err = tx.
WithContext(ctx).
Where("username = ? AND ldap_id IS NOT NULL", ldapId).
Where("username = ? AND ldap_id IS NOT NULL", username).
First(&databaseUser).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// The user collides with a non-LDAP user, so we skip it
continue
} else if err != nil {
return fmt.Errorf("failed to query for existing user '%s': %w", ldapId, err)
return fmt.Errorf("failed to query for existing user '%s': %w", username, err)
}
membersUserId = append(membersUserId, databaseUser.ID)
@@ -305,7 +327,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
// Check if user is admin by checking if they are in the admin group
isAdmin := false
for _, group := range value.GetAttributeValues("memberOf") {
if getDNProperty("cn", group) == dbConfig.LdapAttributeAdminGroup.Value {
if getDNProperty(dbConfig.LdapAttributeGroupName.Value, group) == dbConfig.LdapAttributeAdminGroup.Value {
isAdmin = true
break
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,365 @@
package service
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/json"
"net/http"
"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/dto"
)
// generateTestECDSAKey creates an ECDSA key for testing
func generateTestECDSAKey(t *testing.T) (jwk.Key, []byte) {
t.Helper()
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
privateJwk, err := jwk.Import(privateKey)
require.NoError(t, err)
err = privateJwk.Set(jwk.KeyIDKey, "test-key-1")
require.NoError(t, err)
err = privateJwk.Set(jwk.AlgorithmKey, "ES256")
require.NoError(t, err)
err = privateJwk.Set("use", "sig")
require.NoError(t, err)
publicJwk, err := jwk.PublicKeyOf(privateJwk)
require.NoError(t, err)
// Create a JWK Set with the public key
jwkSet := jwk.NewSet()
err = jwkSet.AddKey(publicJwk)
require.NoError(t, err)
jwkSetJSON, err := json.Marshal(jwkSet)
require.NoError(t, err)
return privateJwk, jwkSetJSON
}
func TestOidcService_jwkSetForURL(t *testing.T) {
// Generate a test key for JWKS
_, jwkSetJSON1 := generateTestECDSAKey(t)
_, jwkSetJSON2 := generateTestECDSAKey(t)
// Create a mock HTTP client with responses for different URLs
const (
url1 = "https://example.com/.well-known/jwks.json"
url2 = "https://other-issuer.com/jwks"
)
mockResponses := map[string]*http.Response{
//nolint:bodyclose
url1: NewMockResponse(http.StatusOK, string(jwkSetJSON1)),
//nolint:bodyclose
url2: NewMockResponse(http.StatusOK, string(jwkSetJSON2)),
}
httpClient := &http.Client{
Transport: &MockRoundTripper{
Responses: mockResponses,
},
}
// Create the OidcService with our mock client
s := &OidcService{
httpClient: httpClient,
}
var err error
s.jwkCache, err = s.getJWKCache(t.Context())
require.NoError(t, err)
t.Run("Fetches and caches JWK set", func(t *testing.T) {
jwks, err := s.jwkSetForURL(t.Context(), url1)
require.NoError(t, err)
require.NotNil(t, jwks)
// Verify the JWK set contains our key
require.Equal(t, 1, jwks.Len())
})
t.Run("Fails with invalid URL", func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
defer cancel()
_, err := s.jwkSetForURL(ctx, "https://bad-url.com")
require.Error(t, err)
require.ErrorIs(t, err, context.DeadlineExceeded)
})
t.Run("Safe for concurrent use", func(t *testing.T) {
const concurrency = 20
// Channel to collect errors
errChan := make(chan error, concurrency)
// Start concurrent requests
for range concurrency {
go func() {
jwks, err := s.jwkSetForURL(t.Context(), url2)
if err != nil {
errChan <- err
return
}
// Verify the JWK set is valid
if jwks == nil || jwks.Len() != 1 {
errChan <- assert.AnError
return
}
errChan <- nil
}()
}
// Check for errors
for range concurrency {
assert.NoError(t, <-errChan, "Concurrent JWK set fetching should not produce errors")
}
})
}
func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
const (
federatedClientIssuer = "https://external-idp.com"
federatedClientAudience = "https://pocket-id.com"
federatedClientSubject = "123456abcdef"
federatedClientIssuerDefaults = "https://external-idp-defaults.com/"
)
var err error
// Create a test database
db := newDatabaseForTest(t)
// Create two JWKs for testing
privateJWK, jwkSetJSON := generateTestECDSAKey(t)
require.NoError(t, err)
privateJWKDefaults, jwkSetJSONDefaults := generateTestECDSAKey(t)
require.NoError(t, err)
// Create a mock HTTP client with custom transport to return the JWKS
httpClient := &http.Client{
Transport: &MockRoundTripper{
Responses: map[string]*http.Response{
//nolint:bodyclose
federatedClientIssuer + "/jwks.json": NewMockResponse(http.StatusOK, string(jwkSetJSON)),
//nolint:bodyclose
federatedClientIssuerDefaults + ".well-known/jwks.json": NewMockResponse(http.StatusOK, string(jwkSetJSONDefaults)),
},
},
}
// Init the OidcService
s := &OidcService{
db: db,
httpClient: httpClient,
}
s.jwkCache, err = s.getJWKCache(t.Context())
require.NoError(t, err)
// Create the test clients
// 1. Confidential client
confidentialClient, err := s.CreateClient(t.Context(), dto.OidcClientCreateDto{
Name: "Confidential Client",
CallbackURLs: []string{"https://example.com/callback"},
}, "test-user-id")
require.NoError(t, err)
// Create a client secret for the confidential client
confidentialSecret, err := s.CreateClientSecret(t.Context(), confidentialClient.ID)
require.NoError(t, err)
// 2. Public client
publicClient, err := s.CreateClient(t.Context(), dto.OidcClientCreateDto{
Name: "Public Client",
CallbackURLs: []string{"https://example.com/callback"},
IsPublic: true,
}, "test-user-id")
require.NoError(t, err)
// 3. Confidential client with federated identity
federatedClient, err := s.CreateClient(t.Context(), dto.OidcClientCreateDto{
Name: "Federated Client",
CallbackURLs: []string{"https://example.com/callback"},
Credentials: dto.OidcClientCredentialsDto{
FederatedIdentities: []dto.OidcClientFederatedIdentityDto{
{
Issuer: federatedClientIssuer,
Audience: federatedClientAudience,
Subject: federatedClientSubject,
JWKS: federatedClientIssuer + "/jwks.json",
},
{Issuer: federatedClientIssuerDefaults},
},
},
}, "test-user-id")
require.NoError(t, err)
// Test cases for confidential client (using client secret)
t.Run("Confidential client", func(t *testing.T) {
t.Run("Succeeds with valid secret", func(t *testing.T) {
// Test with valid client credentials
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
ClientID: confidentialClient.ID,
ClientSecret: confidentialSecret,
})
require.NoError(t, err)
require.NotNil(t, client)
assert.Equal(t, confidentialClient.ID, client.ID)
})
t.Run("Fails with invalid secret", func(t *testing.T) {
// Test with invalid client secret
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
ClientID: confidentialClient.ID,
ClientSecret: "invalid-secret",
})
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcClientSecretInvalidError{})
assert.Nil(t, client)
})
t.Run("Fails with missing secret", func(t *testing.T) {
// Test with missing client secret
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
ClientID: confidentialClient.ID,
})
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcMissingClientCredentialsError{})
assert.Nil(t, client)
})
})
// Test cases for public client
t.Run("Public client", func(t *testing.T) {
t.Run("Succeeds with no credentials", func(t *testing.T) {
// Public clients don't require client secret
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
ClientID: publicClient.ID,
})
require.NoError(t, err)
require.NotNil(t, client)
assert.Equal(t, publicClient.ID, client.ID)
})
})
// Test cases for federated client using JWT assertion
t.Run("Federated client", func(t *testing.T) {
t.Run("Succeeds with valid JWT", func(t *testing.T) {
// Create JWT for federated identity
token, err := jwt.NewBuilder().
Issuer(federatedClientIssuer).
Audience([]string{federatedClientAudience}).
Subject(federatedClientSubject).
IssuedAt(time.Now()).
Expiration(time.Now().Add(10 * time.Minute)).
Build()
require.NoError(t, err)
signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), privateJWK))
require.NoError(t, err)
// Test with valid JWT assertion
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
ClientID: federatedClient.ID,
ClientAssertionType: ClientAssertionTypeJWTBearer,
ClientAssertion: string(signedToken),
})
require.NoError(t, err)
require.NotNil(t, client)
assert.Equal(t, federatedClient.ID, client.ID)
})
t.Run("Fails with malformed JWT", func(t *testing.T) {
// Test with invalid JWT assertion (just a random string)
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
ClientID: federatedClient.ID,
ClientAssertionType: ClientAssertionTypeJWTBearer,
ClientAssertion: "invalid.jwt.token",
})
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcClientAssertionInvalidError{})
assert.Nil(t, client)
})
testBadJWT := func(builderFn func(builder *jwt.Builder)) func(t *testing.T) {
return func(t *testing.T) {
// Populate all claims with valid values
builder := jwt.NewBuilder().
Issuer(federatedClientIssuer).
Audience([]string{federatedClientAudience}).
Subject(federatedClientSubject).
IssuedAt(time.Now()).
Expiration(time.Now().Add(10 * time.Minute))
// Call builderFn to override the claims
builderFn(builder)
token, err := builder.Build()
require.NoError(t, err)
signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), privateJWK))
require.NoError(t, err)
// Test with invalid JWT assertion
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
ClientID: federatedClient.ID,
ClientAssertionType: ClientAssertionTypeJWTBearer,
ClientAssertion: string(signedToken),
})
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcClientAssertionInvalidError{})
require.Nil(t, client)
}
}
t.Run("Fails with expired JWT", testBadJWT(func(builder *jwt.Builder) {
builder.Expiration(time.Now().Add(-30 * time.Minute))
}))
t.Run("Fails with wrong issuer in JWT", testBadJWT(func(builder *jwt.Builder) {
builder.Issuer("https://bad-issuer.com")
}))
t.Run("Fails with wrong audience in JWT", testBadJWT(func(builder *jwt.Builder) {
builder.Audience([]string{"bad-audience"})
}))
t.Run("Fails with wrong subject in JWT", testBadJWT(func(builder *jwt.Builder) {
builder.Subject("bad-subject")
}))
t.Run("Uses default values for audience and subject", func(t *testing.T) {
// Create JWT for federated identity
token, err := jwt.NewBuilder().
Issuer(federatedClientIssuerDefaults).
Audience([]string{common.EnvConfig.AppURL}).
Subject(federatedClient.ID).
IssuedAt(time.Now()).
Expiration(time.Now().Add(10 * time.Minute)).
Build()
require.NoError(t, err)
signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), privateJWKDefaults))
require.NoError(t, err)
// Test with valid JWT assertion
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
ClientID: federatedClient.ID,
ClientAssertionType: ClientAssertionTypeJWTBearer,
ClientAssertion: string(signedToken),
})
require.NoError(t, err)
require.NotNil(t, client)
assert.Equal(t, federatedClient.ID, client.ID)
})
})
}

View File

@@ -0,0 +1,97 @@
package service
import (
"io"
"net/http"
"strings"
"testing"
"time"
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/glebarez/sqlite"
"github.com/golang-migrate/migrate/v4"
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/resources"
)
func newDatabaseForTest(t *testing.T) *gorm.DB {
t.Helper()
// Get a name for this in-memory database that is specific to the test
dbName := utils.CreateSha256Hash(t.Name())
// Connect to a new in-memory SQL database
db, err := gorm.Open(
sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"),
&gorm.Config{
TranslateError: true,
Logger: logger.New(
testLoggerAdapter{t: t},
logger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: logger.Info,
IgnoreRecordNotFoundError: false,
ParameterizedQueries: false,
Colorful: false,
},
),
})
require.NoError(t, err, "Failed to connect to test database")
// Perform migrations with the embedded migrations
sqlDB, err := db.DB()
require.NoError(t, err, "Failed to get sql.DB")
driver, err := sqliteMigrate.WithInstance(sqlDB, &sqliteMigrate.Config{})
require.NoError(t, err, "Failed to create migration driver")
source, err := iofs.New(resources.FS, "migrations/sqlite")
require.NoError(t, err, "Failed to create embedded migration source")
m, err := migrate.NewWithInstance("iofs", source, "pocket-id", driver)
require.NoError(t, err, "Failed to create migration instance")
err = m.Up()
require.NoError(t, err, "Failed to perform migrations")
return db
}
// Implements gorm's logger.Writer interface
type testLoggerAdapter struct {
t *testing.T
}
func (l testLoggerAdapter) Printf(format string, args ...any) {
l.t.Logf(format, args...)
}
// MockRoundTripper is a custom http.RoundTripper that returns responses based on the URL
type MockRoundTripper struct {
Err error
Responses map[string]*http.Response
}
// RoundTrip implements the http.RoundTripper interface
func (m *MockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
// Check if we have a specific response for this URL
for url, resp := range m.Responses {
if req.URL.String() == url {
return resp, nil
}
}
return NewMockResponse(http.StatusNotFound, ""), nil
}
// NewMockResponse creates an http.Response with the given status code and body
func NewMockResponse(statusCode int, body string) *http.Response {
return &http.Response{
StatusCode: statusCode,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}
}

View File

@@ -294,10 +294,10 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
// Check if this is an LDAP user and LDAP is enabled
isLdapUser := user.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue()
allowOwnAccountEdit := s.appConfigService.GetDbConfig().AllowOwnAccountEdit.IsTrue()
// For LDAP users, only allow updating the locale unless it's an LDAP sync
if !isLdapSync && isLdapUser {
// Only update the locale for LDAP users
// For LDAP users or if own account editing is not allowed, only allow updating the locale unless it's an LDAP sync
if !isLdapSync && (isLdapUser || (!allowOwnAccountEdit && !updateOwnUser)) {
user.Locale = updatedUser.Locale
} else {
user.FirstName = updatedUser.FirstName

View File

@@ -29,6 +29,9 @@ func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *Au
RPDisplayName: appConfigService.GetDbConfig().AppName.Value,
RPID: utils.GetHostnameFromURL(common.EnvConfig.AppURL),
RPOrigins: []string{common.EnvConfig.AppURL},
AuthenticatorSelection: protocol.AuthenticatorSelection{
UserVerification: protocol.VerificationRequired,
},
Timeouts: webauthn.TimeoutsConfig{
Login: webauthn.TimeoutConfig{
Enforce: true,

View File

@@ -0,0 +1,33 @@
package utils
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// BearerAuth returns the value of the bearer token in the Authorization header if present
func BearerAuth(r *http.Request) (string, bool) {
const prefix = "bearer "
authHeader := r.Header.Get("Authorization")
if len(authHeader) >= len(prefix) && strings.ToLower(authHeader[:len(prefix)]) == prefix {
return authHeader[len(prefix):], true
}
return "", false
}
// SetCacheControlHeader sets the Cache-Control header for the response.
func SetCacheControlHeader(ctx *gin.Context, maxAge, staleWhileRevalidate time.Duration) {
_, ok := ctx.GetQuery("skipCache")
if !ok {
maxAgeSeconds := strconv.Itoa(int(maxAge.Seconds()))
staleWhileRevalidateSeconds := strconv.Itoa(int(staleWhileRevalidate.Seconds()))
ctx.Header("Cache-Control", "public, max-age="+maxAgeSeconds+", stale-while-revalidate="+staleWhileRevalidateSeconds)
}
}

View File

@@ -0,0 +1,65 @@
package utils
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBearerAuth(t *testing.T) {
tests := []struct {
name string
authHeader string
expectedToken string
expectedFound bool
}{
{
name: "Valid bearer token",
authHeader: "Bearer token123",
expectedToken: "token123",
expectedFound: true,
},
{
name: "Valid bearer token with mixed case",
authHeader: "beARer token456",
expectedToken: "token456",
expectedFound: true,
},
{
name: "No bearer prefix",
authHeader: "Basic dXNlcjpwYXNz",
expectedToken: "",
expectedFound: false,
},
{
name: "Empty auth header",
authHeader: "",
expectedToken: "",
expectedFound: false,
},
{
name: "Bearer prefix only",
authHeader: "Bearer ",
expectedToken: "",
expectedFound: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://example.com", nil)
require.NoError(t, err, "Failed to create request")
if tt.authHeader != "" {
req.Header.Set("Authorization", tt.authHeader)
}
token, found := BearerAuth(req)
assert.Equal(t, tt.expectedFound, found)
assert.Equal(t, tt.expectedToken, token)
})
}
}

View File

@@ -0,0 +1,69 @@
package utils
import (
"crypto/rand"
"encoding/base64"
"fmt"
"io"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
)
const (
// KeyUsageSigning is the usage for the private keys, for the "use" property
KeyUsageSigning = "sig"
)
// ImportRawKey imports a crypto key in "raw" format (e.g. crypto.PrivateKey) into a jwk.Key.
// It also populates additional fields such as the key ID, usage, and alg.
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, 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
}
// 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())
}
}

View File

@@ -0,0 +1,20 @@
package utils
import (
"fmt"
"github.com/lestrrat-go/jwx/v3/jwt"
)
func GetClaimsFromToken(token jwt.Token) (map[string]any, error) {
keys := token.Keys()
claims := make(map[string]any, len(keys))
for _, key := range keys {
var value any
if err := token.Get(key, &value); err != nil {
return nil, fmt.Errorf("failed to get claim %s: %w", key, err)
}
claims[key] = value
}
return claims, nil
}

View File

@@ -34,9 +34,12 @@ func PaginateAndSort(sortedPaginationRequest SortedPaginationRequest, query *gor
sortField, sortFieldFound := reflect.TypeOf(result).Elem().Elem().FieldByName(capitalizedSortColumn)
isSortable, _ := strconv.ParseBool(sortField.Tag.Get("sortable"))
isValidSortOrder := sort.Direction == "asc" || sort.Direction == "desc"
if sortFieldFound && isSortable && isValidSortOrder {
if sort.Direction == "" || (sort.Direction != "asc" && sort.Direction != "desc") {
sort.Direction = "asc"
}
if sortFieldFound && isSortable {
columnName := CamelCaseToSnakeCase(sort.Column)
query = query.Clauses(clause.OrderBy{
Columns: []clause.OrderByColumn{

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
-- No rollback is needed for this migration.

View File

@@ -0,0 +1 @@
DELETE FROM app_config_variables WHERE value = '';

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients DROP COLUMN credentials;

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients ADD COLUMN credentials JSONB NULL;

View File

@@ -0,0 +1 @@
DROP INDEX idx_audit_logs_country;

View File

@@ -0,0 +1 @@
CREATE INDEX idx_audit_logs_country ON audit_logs(country);

View File

@@ -0,0 +1 @@
-- No rollback is needed for this migration.

View File

@@ -0,0 +1 @@
DELETE FROM app_config_variables WHERE value = '';

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients DROP COLUMN credentials;

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients ADD COLUMN credentials TEXT NULL;

View File

@@ -0,0 +1 @@
DROP INDEX idx_audit_logs_country;

View File

@@ -0,0 +1 @@
CREATE INDEX idx_audit_logs_country ON audit_logs(country);

View File

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

View File

@@ -1,6 +1,6 @@
services:
pocket-id:
image: ghcr.io/pocket-id/pocket-id
image: ghcr.io/pocket-id/pocket-id:v1
restart: unless-stopped
env_file: .env
ports:

View File

@@ -1,10 +1,9 @@
{
"$schema": "https://next.shadcn-svelte.com/schema.json",
"$schema": "https://shadcn-svelte.com/schema.json",
"style": "default",
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app.css",
"baseColor": "zinc"
"baseColor": "neutral"
},
"aliases": {
"components": "$lib/components",
@@ -14,5 +13,5 @@
"lib": "$lib"
},
"typescript": true,
"registry": "https://next.shadcn-svelte.com/registry"
"registry": "https://shadcn-svelte.com/registry"
}

View File

@@ -3,6 +3,7 @@
"my_account": "Můj Účet",
"logout": "Odhlásit se",
"confirm": "Potvrdit",
"docs": "Dokumentace",
"key": "Klíč",
"value": "Hodnota",
"remove_custom_claim": "Odstranit vlastní nárok",
@@ -40,8 +41,8 @@
"an_unknown_error_occurred": "Došlo k neznámé chybě",
"authentication_process_was_aborted": "Proces přihlášení byl přerušen",
"error_occurred_with_authenticator": "Došlo k chybě s autentifikátorem",
"authenticator_does_not_support_discoverable_credentials": "Autentifikátor nepodporuje zobrazitelné přihlašovací údaje",
"authenticator_does_not_support_resident_keys": "Autentikátor nepodporuje rezidentní klíče.",
"authenticator_does_not_support_discoverable_credentials": "Autentifikátor nepodporuje vyhledatelné přihlašovací údaje",
"authenticator_does_not_support_resident_keys": "Autentikátor nepodporuje rezidentní klíče",
"passkey_was_previously_registered": "Tento přístupový klíč byl již dříve zaregistrován",
"authenticator_does_not_support_any_of_the_requested_algorithms": "Autentikátor nepodporuje žádný z požadovaných algoritmů",
"authenticator_timed_out": "Vypršel časový limit autentifikátoru",
@@ -144,8 +145,6 @@
"expires_at": "Vyprší",
"when_this_api_key_will_expire": "Až vyprší platnost tohoto API klíče.",
"optional_description_to_help_identify_this_keys_purpose": "Volitelný popis, který pomůže identifikovat účel tohoto klíče.",
"name_must_be_at_least_3_characters": "Název musí obsahovat alespoň 3 znaky",
"name_cannot_exceed_50_characters": "Název nesmí překročit 50 znaků",
"expiration_date_must_be_in_the_future": "Datum vypršení musí být v budoucnu",
"revoke_api_key": "Zrušit API klíč",
"never": "Nikdy",
@@ -168,7 +167,7 @@
"test_email_sent_successfully": "Testovací e-mail byl úspěšně odeslán na vaši e-mailovou adresu.",
"failed_to_send_test_email": "Nepodařilo se odeslat testovací e-mail. Pro více informací zkontrolujte protokoly serveru.",
"smtp_configuration": "Nastavení SMTP",
"smtp_host": "SMTP Host",
"smtp_host": "SMTP Hostitel",
"smtp_port": "SMTP Port",
"smtp_user": "SMTP Uživatel",
"smtp_password": "SMTP Heslo",
@@ -181,7 +180,7 @@
"email_login_notification": "E-mailovová oznámení o přihlášení",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Poslat uživateli e-mail, když se přihlásí z nového zařízení.",
"emai_login_code_requested_by_user": "Přihlašovací kód e-mailu vyžádaný uživatelem",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Umožňuje uživatelům přihlásit se pomocí přihlašovacího kódu, který je odeslán na jejich e-mail. To výrazně snižuje bezpečnost, protože každý, kdo má přístup k e-mailu uživatele, může vstoupit.",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This significantly reduces security as anyone with access to the user's email can gain entry.",
"email_login_code_from_admin": "Poslat e-mail přihlašovacímu kódu od administrátora",
"allows_an_admin_to_send_a_login_code_to_the_user": "Umožňuje administrátorovi odeslat přihlašovací kód uživateli e-mailem.",
"send_test_email": "Odeslat testovací e-mail",
@@ -198,8 +197,8 @@
"ldap_sync_finished": "LDAP synchronizace dokončena",
"client_configuration": "Nastavení klienta",
"ldap_url": "LDAP URL",
"ldap_bind_dn": "LDAP Bind DN",
"ldap_bind_password": "LDAP Bind Password",
"ldap_bind_dn": "LDAP Uživatel",
"ldap_bind_password": "LDAP Heslo",
"ldap_base_dn": "LDAP Base DN",
"user_search_filter": "Filtr vyhledávání uživatelů",
"the_search_filter_to_use_to_search_or_sync_users": "Hledaný filtr pro vyhledávání/synchronizaci uživatelů.",
@@ -289,8 +288,8 @@
"oidc_discovery_url": "OIDC Discovery URL",
"token_url": "Token URL",
"userinfo_url": "Userinfo URL",
"logout_url": "Logout URL",
"certificate_url": "Certificate URL",
"logout_url": "URL pro odhlášení",
"certificate_url": "URL certifikátu",
"enabled": "Povoleno",
"disabled": "Zakázáno",
"oidc_client_updated_successfully": "OIDC klient úspěšně aktualizován",
@@ -301,17 +300,17 @@
"allowed_user_groups_updated_successfully": "Povolené skupiny uživatelů byly úspěšně aktualizovány",
"oidc_client_name": "OIDC Klient {name}",
"client_id": "ID klienta",
"client_secret": "Client secret",
"client_secret": "Tajný klíč",
"show_more_details": "Zobrazit další podrobnosti",
"allowed_user_groups": "Povolené skupiny uživatelů",
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Přidejte do tohoto klienta uživatelské skupiny, abyste omezili přístup pouze pro uživatele v těchto skupinách. Pokud nejsou vybrány žádné skupiny uživatelů, všichni uživatelé budou mít přístup k tomuto klientovi.",
"favicon": "Favicon",
"favicon": "Favicona",
"light_mode_logo": "Logo světlého režimu",
"dark_mode_logo": "Logo tmavého režimu",
"background_image": "Obrázek na pozadí",
"language": "Jazyk",
"reset_profile_picture_question": "Resetovat profilový obrázek?",
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "Tímto odstraníte nahraný obrázek a obnovíte výchozí. Chcete pokračovat?",
"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": "Obnovit",
"reset_to_default": "Obnovit výchozí",
"profile_picture_has_been_reset": "Profilový obrázek byl obnoven. Aktualizace může trvat několik minut.",
@@ -327,7 +326,7 @@
"client_authorization": "Autorizace klienta",
"new_client_authorization": "Nová autorizace klienta",
"disable_animations": "Zakázat animace",
"turn_off_all_animations_throughout_the_admin_ui": "Vypnout všechny animace v celém administrátorském rozhraní.",
"turn_off_ui_animations": "Turn off animations throughout the UI.",
"user_disabled": "Účet deaktivován",
"disabled_users_cannot_log_in_or_use_services": "Zakázaní uživatelé se nemohou přihlásit nebo používat služby.",
"user_disabled_successfully": "Uživatel byl úspěšně deaktivován.",
@@ -340,11 +339,44 @@
"login_code_email_success": "Přihlašovací kód byl odeslán uživateli.",
"send_email": "Odeslat e-mail",
"show_code": "Zobrazit kód",
"callback_url_description": "URL poskytnuté klientem. Klientské zástupné znaky (*) jsou podporovány, ale raději se jim vyhýbejte, pro lepší bezpečnost.",
"callback_url_description": "URL poskytnuté vaším klientem. Bude automaticky přidáno, pokud necháte prázdné. Zástupné znaky (*) jsou podporovány, ale raději se jim vyhýbejte, pro lepší bezpečnost.",
"logout_callback_url_description": "URL poskytnuté klientem pro odhlášení. Klientské zástupné znaky (*) jsou podporovány, ale raději se jim vyhýbejte, pro lepší bezpečnost.",
"api_key_expiration": "Vypršení platnosti API klíče",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Pošlete uživateli e-mail, jakmile jejich API klíč brzy vyprší.",
"authorize_device": "Autorizovat zařízení",
"the_device_has_been_authorized": "Zařízení bylo autorizováno.",
"enter_code_displayed_in_previous_step": "Zadejte kód, který byl zobrazen v předchozím kroku.",
"authorize": "Autorizovat"
"authorize": "Autorizovat",
"federated_client_credentials": "Údaje o klientovi ve federaci",
"federated_client_credentials_description": "Pomocí federovaných přihlašovacích údajů klienta můžete ověřit klienty OIDC pomocí JWT tokenů vydaných třetí stranou.",
"add_federated_client_credential": "Přidat údaje federovaného klienta",
"add_another_federated_client_credential": "Přidat dalšího federovaného klienta",
"oidc_allowed_group_count": "Počet povolených skupin",
"unrestricted": "Bez omezení",
"show_advanced_options": "Zobrazit rozšířené možnosti",
"hide_advanced_options": "Skrýt rozšířené rožnosti",
"oidc_data_preview": "Náhled OIDC dat",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Náhled údajů OIDC, které by měly být odeslány pro různé uživatele",
"id_token": "ID Token",
"access_token": "Access Token",
"userinfo": "Userinfo",
"id_token_payload": "ID Token Payload",
"access_token_payload": "Access Token Payload",
"userinfo_endpoint_response": "Userinfo Endpoint Response",
"copy": "Kopírovat",
"no_preview_data_available": "Nejsou k dispozici žádná náhledová data",
"copy_all": "Kopírovat vše",
"preview": "Náhled",
"preview_for_user": "Náhled pro {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Náhled OIDC dat, která by byla odeslána pro uživatele",
"show": "Zobrazit",
"select_an_option": "Vyberte možnost",
"select_user": "Vyberte uživatele",
"error": "Chyba",
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Select an accent color to customize the appearance of Pocket ID.",
"accent_color": "Accent Color",
"custom_accent_color": "Custom Accent Color",
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).",
"color_value": "Color Value",
"apply": "Apply"
}

382
frontend/messages/da.json Normal file
View File

@@ -0,0 +1,382 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"my_account": "Min konto",
"logout": "Log ud",
"confirm": "Bekræft",
"docs": "Dokumentation",
"key": "Nøgle",
"value": "Værdi",
"remove_custom_claim": "Fjern brugerdefineret claim",
"add_custom_claim": "Tilføj brugerdefineret claim",
"add_another": "Tilføj endnu en",
"select_a_date": "Vælg en dato",
"select_file": "Vælg en fil",
"profile_picture": "Profilbillede",
"profile_picture_is_managed_by_ldap_server": "Profilbilledet administreres af LDAP-serveren og kan ikke ændres her.",
"click_profile_picture_to_upload_custom": "Klik på profilbilledet for at uploade et brugerdefineret billede fra dine filer.",
"image_should_be_in_format": "Billedet skal være i PNG eller JPEG-format.",
"items_per_page": "Emner pr. side",
"no_items_found": "Ingen emner fundet",
"search": "Søg...",
"expand_card": "Udvid kortet",
"copied": "Kopieret",
"click_to_copy": "Klik for at kopiere",
"something_went_wrong": "Noget gik galt",
"go_back_to_home": "Gå tilbage til hjem",
"dont_have_access_to_your_passkey": "Har du ikke adgang til din adgangsnøgle?",
"login_background": "Log ind baggrund",
"logo": "Logo",
"login_code": "Loginkode",
"create_a_login_code_to_sign_in_without_a_passkey_once": "Opret en loginkode, som brugeren kan bruge til at logge ind uden en adgangsnøgle én gang.",
"one_hour": "1 time",
"twelve_hours": "12 timer",
"one_day": "1 dag",
"one_week": "1 uge",
"one_month": "1 måned",
"expiration": "Udløbstid",
"generate_code": "Generer kode",
"name": "Navn",
"browser_unsupported": "Browseren understøttes ikke",
"this_browser_does_not_support_passkeys": "Denne browser understøtter ikke adgangsnøgler. Benyt venligst en alternativ login metode.",
"an_unknown_error_occurred": "En ukendt fejl opstod",
"authentication_process_was_aborted": "Godkendelsesprocessen blev afbrudt",
"error_occurred_with_authenticator": "Der opstod en fejl med godkendelsesenheden",
"authenticator_does_not_support_discoverable_credentials": "Godkenderen understøtter ikke synlige legitimationsoplysninger",
"authenticator_does_not_support_resident_keys": "Godkenderen understøtter ikke gemte nøgler",
"passkey_was_previously_registered": "Denne adgangsnøgle er allerede registreret",
"authenticator_does_not_support_any_of_the_requested_algorithms": "Godkenderen understøtter ikke nogen af de algoritmer, der anmodes om",
"authenticator_timed_out": "Godkenderen overskred tidsgrænsen",
"critical_error_occurred_contact_administrator": "En kritisk fejl opstod. Kontakt venligst din administrator.",
"sign_in_to": "Log ind på {name}",
"client_not_found": "Klient ikke fundet",
"client_wants_to_access_the_following_information": "{client} ønsker at få adgang til følgende oplysninger:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Vil du logge ind på {client} med din {appName}-konto?",
"email": "E-mail",
"view_your_email_address": "Se din e-mailadresse",
"profile": "Profil",
"view_your_profile_information": "Se dine profiloplysninger",
"groups": "Grupper",
"view_the_groups_you_are_a_member_of": "Se de grupper, du er medlem af",
"cancel": "Annuller",
"sign_in": "Log ind",
"try_again": "Prøv igen",
"client_logo": "Klientlogo",
"sign_out": "Log ud",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Vil du logge ud af {appName} med kontoen <b>{username}</b>?",
"sign_in_to_appname": "Log ind på {appName}",
"please_try_to_sign_in_again": "Prøv at logge ind igen.",
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Bekræft din identitet med din adgangsnøgle for at få adgang til administrationspanelet.",
"authenticate": "Bekræft identitet",
"appname_setup": "Opsætning af {appName}",
"please_try_again": "Prøv venligst igen.",
"you_are_about_to_sign_in_to_the_initial_admin_account": "Du er ved at logge ind på den oprindelige administrator-konto. Enhver med dette link kan få adgang, indtil en adgangsnøgle tilføjes. Opsæt en adgangsnøgle hurtigst muligt for at forhindre uautoriseret adgang.",
"continue": "Fortsæt",
"alternative_sign_in": "Andre loginmetoder",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Hvis du ikke har adgang til din adgangsnøgle, kan du logge ind med en af følgende metoder.",
"use_your_passkey_instead": "Vil du i stedet bruge din adgangsnøgle?",
"email_login": "E-mail Login",
"enter_a_login_code_to_sign_in": "Indtast en loginkode for at logge ind.",
"request_a_login_code_via_email": "Anmod om en loginkode via e-mail.",
"go_back": "Gå tilbage",
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "En e-mail er blevet sendt til den angivne e-mailadresse, hvis den findes i systemet.",
"enter_code": "Indtast kode",
"enter_your_email_address_to_receive_an_email_with_a_login_code": "Indtast din e-mailadresse for at modtage en login-kode.\n",
"your_email": "Din e-mail",
"submit": "Indsend",
"enter_the_code_you_received_to_sign_in": "Indtast den kode, du har modtaget, for at logge ind.",
"code": "Kode",
"invalid_redirect_url": "Ugyldig redirect-URL",
"audit_log": "Aktivitetslog",
"users": "Brugere",
"user_groups": "Brugergrupper",
"oidc_clients": "OIDC-klienter",
"api_keys": "API-nøgler",
"application_configuration": "Applikationsindstillinger",
"settings": "Indstillinger",
"update_pocket_id": "Opdater Pocket ID",
"powered_by": "Drevet af",
"see_your_account_activities_from_the_last_3_months": "Se dine kontoaktiviteter fra de sidste 3 måneder.",
"time": "Tid",
"event": "Hændelse",
"approximate_location": "Omtrentlig placering",
"ip_address": "IP-adresse",
"device": "Enhed",
"client": "Klient",
"unknown": "Ukendt",
"account_details_updated_successfully": "Kontodetaljer blev opdateret",
"profile_picture_updated_successfully": "Profilbillede opdateret. Det kan tage et par minutter før ændringen vises.",
"account_settings": "Kontoindstillinger",
"passkey_missing": "Adgangsnøgle mangler",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Tilføj en adgangsnøgle for at undgå at miste adgangen til din konto.",
"single_passkey_configured": "Én adgangsnøgle er konfigureret",
"it_is_recommended_to_add_more_than_one_passkey": "Det anbefales at tilføje mere end én adgangsnøgle for at undgå at miste adgangen til din konto.",
"account_details": "Kontooplysninger",
"passkeys": "Adgangsnøgler",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Administrér dine adgangsnøgler, som du kan bruge til at godkende dig selv.",
"add_passkey": "Tilføj adgangsnøgle",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Opret en engangskode for at logge ind fra en anden enhed uden en adgangsnøgle.",
"create": "Opret",
"first_name": "Fornavn",
"last_name": "Efternavn",
"username": "Brugernavn",
"save": "Gem",
"username_can_only_contain": "Brugernavn må kun indeholde små bogstaver, tal, understregninger (_), punktummer (.), bindestreger (-) og @-tegn",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Log ind med nedenstående kode. Koden udløber om 15 minutter.",
"or_visit": "eller besøg",
"added_on": "Tilføjet den",
"rename": "Omdøb",
"delete": "Slet",
"are_you_sure_you_want_to_delete_this_passkey": "Er du sikker på, at du vil slette denne adgangsnøgle?",
"passkey_deleted_successfully": "Adgangsnøgle blev slettet",
"delete_passkey_name": "Slet {passkeyName}",
"passkey_name_updated_successfully": "Navnet på adgangsnøglen blev opdateret",
"name_passkey": "Navngiv adgangsnøgle",
"name_your_passkey_to_easily_identify_it_later": "Giv din adgangsnøgle et navn, så du nemt kan genkende den senere.",
"create_api_key": "Opret API-nøgle",
"add_a_new_api_key_for_programmatic_access": "Tilføj en ny API-nøgle til programmatisk adgang.",
"add_api_key": "Tilføj API-nøgle",
"manage_api_keys": "Administrér API-nøgler",
"api_key_created": "API-nøgle oprettet",
"for_security_reasons_this_key_will_only_be_shown_once": "Af sikkerhedshensyn vises denne nøgle kun én gang. Gem den et sikkert sted.",
"description": "Beskrivelse",
"api_key": "API-nøgle",
"close": "Luk",
"name_to_identify_this_api_key": "Navn til at identificere denne API-nøgle.",
"expires_at": "Udløber den",
"when_this_api_key_will_expire": "Hvornår denne API-nøgle udløber.",
"optional_description_to_help_identify_this_keys_purpose": "Valgfri beskrivelse for at identificere nøglens formål.",
"expiration_date_must_be_in_the_future": "Udløbsdatoen skal ligge i fremtiden",
"revoke_api_key": "Tilbagekald API-nøgle",
"never": "Aldrig",
"revoke": "Tilbagekald",
"api_key_revoked_successfully": "API-nøgle blev tilbagekaldt",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Er du sikker på, at du vil tilbagekalde API-nøglen \"{apiKeyName}\"? Dette vil afbryde alle integrationer, der bruger nøglen.",
"last_used": "Sidst brugt",
"actions": "Handlinger",
"images_updated_successfully": "Billeder blev opdateret",
"general": "Generelt",
"configure_smtp_to_send_emails": "Aktivér e-mailnotifikationer for at advare brugere, når et login registreres fra en ny enhed eller placering.",
"ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Konfigurer LDAP-indstillinger for at synkronisere brugere og grupper fra en LDAP-server",
"images": "Billeder",
"update": "Opdater",
"email_configuration_updated_successfully": "E-mailkonfiguration blev opdateret",
"save_changes_question": "Gem ændringer?",
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "Du skal gemme ændringerne, før du kan sende en test-e-mail. Vil du gemme nu?",
"save_and_send": "Gem og send",
"test_email_sent_successfully": "Test-e-mail blev sendt til din e-mailadresse.",
"failed_to_send_test_email": "Kunne ikke sende test-e-mail. Tjek serverloggen for flere oplysninger.",
"smtp_configuration": "SMTP-konfiguration",
"smtp_host": "SMTP-vært",
"smtp_port": "SMTP-port",
"smtp_user": "SMTP-bruger",
"smtp_password": "SMTP-adgangskode",
"smtp_from": "SMTP-afsender",
"smtp_tls_option": "SMTP TLS-indstilling",
"email_tls_option": "E-mail TLS-indstilling",
"skip_certificate_verification": "Spring certifikatverificering over",
"this_can_be_useful_for_selfsigned_certificates": "Dette kan være nyttigt ved selvsignerede certifikater.",
"enabled_emails": "Aktiverede e-mails",
"email_login_notification": "Notifikation om login via e-mail",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Send en e-mail til brugeren, når de logger ind fra en ny enhed.",
"emai_login_code_requested_by_user": "Tillad brugere at anmode om login-koder via e-mail",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Tillader brugere at omgå adgangsnøgler ved at anmode om en login-kode sendt til deres e-mail. Dette reducerer sikkerheden væsentligt, da enhver med adgang til brugerens e-mail kan få adgang.\n",
"email_login_code_from_admin": "Tillad administratorer at sende login-koder via e-mail",
"allows_an_admin_to_send_a_login_code_to_the_user": "Giver en administrator mulighed for at sende en login-kode til brugeren via e-mail.",
"send_test_email": "Send test-e-mail",
"application_configuration_updated_successfully": "Applikationsindstillinger blev opdateret",
"application_name": "Applikationsnavn",
"session_duration": "Sessionsvarighed",
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Varighed i minutter før brugeren skal logge ind igen.",
"enable_self_account_editing": "Aktivér redigering af egen konto",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Om brugere må redigere deres egne kontooplysninger.",
"emails_verified": "E-mailadresser verificeret",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Om brugerens e-mail skal markeres som verificeret for OIDC-klienter.",
"ldap_configuration_updated_successfully": "LDAP-konfiguration blev opdateret",
"ldap_disabled_successfully": "LDAP blev deaktiveret",
"ldap_sync_finished": "LDAP-synkronisering fuldført",
"client_configuration": "Klientkonfiguration",
"ldap_url": "LDAP-URL",
"ldap_bind_dn": "LDAP Bind DN",
"ldap_bind_password": "LDAP-bindingsadgangskode",
"ldap_base_dn": "LDAP Base DN",
"user_search_filter": "Brugersøgningsfilter",
"the_search_filter_to_use_to_search_or_sync_users": "Søgefilteret der bruges til at finde eller synkronisere brugere.",
"groups_search_filter": "Gruppesøgningsfilter",
"the_search_filter_to_use_to_search_or_sync_groups": "Søgefilteret der bruges til at finde eller synkronisere grupper.",
"attribute_mapping": "Attributtilknytning",
"user_unique_identifier_attribute": "Unik brugeridentifikator-attribut",
"the_value_of_this_attribute_should_never_change": "Værdien af denne attribut bør aldrig ændres.",
"username_attribute": "Brugernavn-attribut",
"user_mail_attribute": "E-mail-attribut",
"user_first_name_attribute": "Fornavns-attribut",
"user_last_name_attribute": "Efternavns-attribut",
"user_profile_picture_attribute": "Profilbilled-attribut",
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "Værdien af denne attribut kan være en URL, en binær fil eller et base64-kodet billede.",
"group_members_attribute": "Gruppemedlems-attribut",
"the_attribute_to_use_for_querying_members_of_a_group": "Attributten der bruges til at hente gruppemedlemmer.",
"group_unique_identifier_attribute": "Unik gruppe-ID-attribut",
"group_name_attribute": "Gruppenavns-attribut",
"admin_group_name": "Administratorgruppe-navn",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Medlemmer af denne gruppe får administratorrettigheder i Pocket ID.",
"disable": "Deaktivér",
"sync_now": "Synkronisér nu",
"enable": "Aktivér",
"user_created_successfully": "Bruger blev oprettet",
"create_user": "Opret bruger",
"add_a_new_user_to_appname": "Tilføj en ny bruger til {appName}",
"add_user": "Tilføj bruger",
"manage_users": "Administrér brugere",
"admin_privileges": "Administratorrettigheder",
"admins_have_full_access_to_the_admin_panel": "Administratorer har fuld adgang til administratorpanelet.",
"delete_firstname_lastname": "Slet {firstName} {lastName}",
"are_you_sure_you_want_to_delete_this_user": "Er du sikker på, at du vil slette denne bruger?",
"user_deleted_successfully": "Brugeren blev slettet",
"role": "Rolle",
"source": "Kilde",
"admin": "Administrator",
"user": "Bruger",
"local": "Lokal",
"toggle_menu": "Åbn/luk menu",
"edit": "Redigér",
"user_groups_updated_successfully": "Brugergrupper blev opdateret",
"user_updated_successfully": "Bruger blev opdateret",
"custom_claims_updated_successfully": "Brugerdefinerede claims blev opdateret",
"back": "Tilbage",
"user_details_firstname_lastname": "Brugeroplysninger for {firstName} {lastName}",
"manage_which_groups_this_user_belongs_to": "Administrér hvilke grupper denne bruger tilhører.",
"custom_claims": "Brugerdefinerede claims",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Brugerdefinerede claims er nøgle-værdi-par, der kan bruges til at gemme yderligere information om en bruger. Disse claims vil blive inkluderet i ID-tokenet, hvis scopen profile er anmodet.",
"user_group_created_successfully": "Brugergruppe blev oprettet",
"create_user_group": "Opret brugergruppe",
"create_a_new_group_that_can_be_assigned_to_users": "Opret en ny gruppe, der kan tildeles brugere.",
"add_group": "Tilføj gruppe",
"manage_user_groups": "Administrér brugergrupper",
"friendly_name": "Kaldenavn",
"name_that_will_be_displayed_in_the_ui": "Navn der vises i brugerfladen",
"name_that_will_be_in_the_groups_claim": "Navn der vises i “groups”-claimet",
"delete_name": "Slet {name}",
"are_you_sure_you_want_to_delete_this_user_group": "Er du sikker på, at du vil slette denne brugergruppe?",
"user_group_deleted_successfully": "Brugergruppe blev slettet",
"user_count": "Antal brugere",
"user_group_updated_successfully": "Brugergruppe blev opdateret",
"users_updated_successfully": "Brugere blev opdateret",
"user_group_details_name": "Detaljer for brugergruppe {name}",
"assign_users_to_this_group": "Tildel brugere til denne gruppe.",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Brugerdefinerede claims er nøgle-værdi-par, der bruges til at gemme yderligere information om en bruger. Disse claims vil blive inkluderet i ID-tokenet, hvis scopen profile anmodes. Brugerdefinerede claims defineret direkte på brugeren har prioritet, hvis der opstår konflikter.",
"oidc_client_created_successfully": "OIDC-klient blev oprettet",
"create_oidc_client": "Opret OIDC-klient",
"add_a_new_oidc_client_to_appname": "Tilføj en ny OIDC-klient til {appName}",
"add_oidc_client": "Tilføj OIDC-klient",
"manage_oidc_clients": "Administrér OIDC-klienter",
"one_time_link": "Engangslink",
"use_this_link_to_sign_in_once": "Brug dette link til at logge ind én gang. Det er nødvendigt for brugere, som endnu ikke har tilføjet en adgangsnøgle eller har mistet den.",
"add": "Tilføj",
"callback_urls": "Callback-URLer",
"logout_callback_urls": "Logout Callback-URLer",
"public_client": "Public-klient",
"public_clients_description": "Public-klienter har ikke en klienthemmelighed. De er designet til mobil-, web- og native-apps, hvor hemmeligheder ikke kan opbevares sikkert.",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange er en sikkerhedsfunktion, der beskytter mod CSRF- og authorization code-angreb.",
"name_logo": "Logo for {name}",
"change_logo": "Skift logo",
"upload_logo": "Upload logo",
"remove_logo": "Fjern logo",
"are_you_sure_you_want_to_delete_this_oidc_client": "Er du sikker på, at du vil slette denne OIDC-klient?",
"oidc_client_deleted_successfully": "OIDC-klient blev slettet",
"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": "Aktiveret",
"disabled": "Deaktiveret",
"oidc_client_updated_successfully": "OIDC-klient blev opdateret",
"create_new_client_secret": "Opret ny client secret",
"are_you_sure_you_want_to_create_a_new_client_secret": "Vil du oprette en ny client secret? Den gamle bliver ugyldig og kan ikke længere bruges.",
"generate": "Generér",
"new_client_secret_created_successfully": "Ny client secret blev oprettet",
"allowed_user_groups_updated_successfully": "Tilladte brugergrupper blev opdateret",
"oidc_client_name": "OIDC-klient {name}",
"client_id": "Client ID",
"client_secret": "Client secret",
"show_more_details": "Vis flere detaljer",
"allowed_user_groups": "Tilladte brugergrupper",
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Tilføj brugergrupper til denne klient for at begrænse adgangen til brugere i disse grupper. Hvis ingen brugergrupper er valgt, vil alle brugere have adgang til klienten.",
"favicon": "Favicon",
"light_mode_logo": "Logo til lys tilstand",
"dark_mode_logo": "Logo til mørk tilstand",
"background_image": "Baggrundsbillede",
"language": "Sprog",
"reset_profile_picture_question": "Nulstil profilbillede?",
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "Dette vil fjerne det uploadede billede og nulstille profilbilledet til standard. Vil du fortsætte?",
"reset": "Nulstil",
"reset_to_default": "Nulstil til standard",
"profile_picture_has_been_reset": "Profilbilledet er blevet nulstillet. Det kan tage et par minutter at opdatere.",
"select_the_language_you_want_to_use": "Vælg det sprog, du vil bruge. Nogle sprog er muligvis ikke fuldt oversat.",
"personal": "Personlig",
"global": "Global",
"all_users": "Alle brugere",
"all_events": "Alle hændelser",
"all_clients": "Alle klienter",
"global_audit_log": "Global aktivitetslog",
"see_all_account_activities_from_the_last_3_months": "Se al brugeraktivitet for de seneste 3 måneder.",
"token_sign_in": "Token-login",
"client_authorization": "Godkendelse af klient",
"new_client_authorization": "Ny klientgodkendelse",
"disable_animations": "Deaktiver animationer",
"turn_off_ui_animations": "Slå animationer fra for hele brugergrænsefladen.",
"user_disabled": "Konto deaktiveret",
"disabled_users_cannot_log_in_or_use_services": "Deaktiverede brugere kan ikke logge ind eller bruge tjenester.",
"user_disabled_successfully": "Brugeren blev deaktiveret.",
"user_enabled_successfully": "Brugeren blev aktiveret.",
"status": "Status",
"disable_firstname_lastname": "Deaktiver {firstName} {lastName}",
"are_you_sure_you_want_to_disable_this_user": "Er du sikker på, at du vil deaktivere denne bruger? Brugeren vil ikke kunne logge ind eller få adgang til tjenester.",
"ldap_soft_delete_users": "Behold deaktiverede brugere i LDAP.",
"ldap_soft_delete_users_description": "Når aktiveret, vil brugere fjernet fra LDAP blive deaktiveret i stedet for at blive slettet fra systemet.",
"login_code_email_success": "Loginkoden er sendt til brugeren.",
"send_email": "Send e-mail",
"show_code": "Vis kode",
"callback_url_description": "En eller flere URLer angivet af din klient. Tilføjes automatisk, hvis feltet er tomt. Wildcards (*) understøttes, men bør undgås af hensyn til sikkerheden.",
"logout_callback_url_description": "En eller flere URLer angivet af din klient til logout. Wildcards (*) understøttes, men bør undgås af hensyn til sikkerheden.",
"api_key_expiration": "Udløb af API-nøgle",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send en e-mail til brugeren, når deres API-nøgle er ved at udløbe.",
"authorize_device": "Godkend enhed",
"the_device_has_been_authorized": "Enheden er godkendt.",
"enter_code_displayed_in_previous_step": "Indtast koden, der blev vist i det forrige trin.",
"authorize": "Godkend",
"federated_client_credentials": "Federated klientlegitimationsoplysninger",
"federated_client_credentials_description": "Ved hjælp af federated klientlegitimationsoplysninger kan du godkende OIDC-klienter med JWT-tokens udstedt af tredjepartsudbydere.",
"add_federated_client_credential": "Tilføj federated klientlegitimation",
"add_another_federated_client_credential": "Tilføj endnu en federated klientlegitimation",
"oidc_allowed_group_count": "Tilladt antal grupper",
"unrestricted": "Ubegrænset",
"show_advanced_options": "Vis avancerede indstillinger",
"hide_advanced_options": "Skjul avancerede indstillinger",
"oidc_data_preview": "Forhåndsvisning af OIDC-data",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Forhåndsvis OIDC-data, der ville blive sendt for forskellige brugere",
"id_token": "ID-token",
"access_token": "Adgangstoken",
"userinfo": "Brugerinfo",
"id_token_payload": "ID-token-payload",
"access_token_payload": "Adgangstoken-payload",
"userinfo_endpoint_response": "Svar fra brugerinfo-endpoint",
"copy": "Kopiér",
"no_preview_data_available": "Ingen forhåndsvisningsdata tilgængelig",
"copy_all": "Kopiér alt",
"preview": "Forhåndsvisning",
"preview_for_user": "Forhåndsvisning for {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Forhåndsvis OIDC-data, der ville blive sendt for denne bruger",
"show": "Vis",
"select_an_option": "Vælg en indstilling",
"select_user": "Vælg en bruger",
"error": "Fejl",
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Vælg en accentfarve for at tilpasse udseendet af Pocket ID.",
"accent_color": "Accentfarve",
"custom_accent_color": "Brugerdefineret accentfarve",
"custom_accent_color_description": "Indtast en brugerdefineret farve i et gyldigt CSS-format (f.eks. hex, rgb, hsl).",
"color_value": "Farveværdi",
"apply": "Anvend"
}

View File

@@ -3,6 +3,7 @@
"my_account": "Mein Konto",
"logout": "Abmelden",
"confirm": "Bestätigen",
"docs": "Dokumentation",
"key": "Schlüssel",
"value": "Wert",
"remove_custom_claim": "Benutzerdefinierten Claim entfernen",
@@ -35,7 +36,7 @@
"expiration": "Ablaufdatum",
"generate_code": "Code erzeugen",
"name": "Name",
"browser_unsupported": "Browser nicht unterstützt",
"browser_unsupported": "Browser wird nicht unterstützt",
"this_browser_does_not_support_passkeys": "Dieser Browser unterstützt keine Passkeys. Bitte verwende eine alternative Anmeldemethode.",
"an_unknown_error_occurred": "Ein unbekannter Fehler ist aufgetreten",
"authentication_process_was_aborted": "Der Authentifizierungsprozess wurde abgebrochen",
@@ -144,8 +145,6 @@
"expires_at": "Ablaufdatum",
"when_this_api_key_will_expire": "Wann der API Key ablaufen wird.",
"optional_description_to_help_identify_this_keys_purpose": "Optionale Beschreibung, um den Zweck dieses Schlüssels zu identifizieren.",
"name_must_be_at_least_3_characters": "Der Name muss mindestens 3 Zeichen lang sein",
"name_cannot_exceed_50_characters": "Der Name darf nicht länger als 50 Zeichen sein",
"expiration_date_must_be_in_the_future": "Ablaufdatum muss in der Zukunft liegen",
"revoke_api_key": "API Key widerrufen",
"never": "Nie",
@@ -181,7 +180,7 @@
"email_login_notification": "E-Mail Benachrichtigung bei Login",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Sende dem Benutzer eine E-Mail, wenn er sich von einem neuen Gerät aus anmeldet.",
"emai_login_code_requested_by_user": "E-Mail-Logincode angefordert vom Benutzer",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Ermöglicht Benutzern, den Passkey zu umgehen, indem sie das Senden eines Logincodes an ihre E-Mail-Adresse anfordern. Dies reduziert die Sicherheit erheblich, da jeder, der Zugriff auf die E-Mail des Benutzers hat, Zugang bekommen kann.",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Ermöglicht Benutzern Passkeys zu umgehen, indem sie einen Login-Code anfordern, der an ihre E-Mail gesendet wurde. Dies verringert die Sicherheit erheblich, da jeder, der Zugriff auf die E-Mail des Benutzers hat, Zugang erhalten kann.",
"email_login_code_from_admin": "E-Mail-Logincode von Administratoren",
"allows_an_admin_to_send_a_login_code_to_the_user": "Erlaube Administratoren das Senden von Logincodes an den Nutzer via E-Mail.",
"send_test_email": "Test-E-Mail senden",
@@ -235,7 +234,7 @@
"user_deleted_successfully": "Benutzer erfolgreich gelöscht",
"role": "Rolle",
"source": "Quelle",
"admin": "Admin",
"admin": "Administrator",
"user": "Benutzer",
"local": "Lokal",
"toggle_menu": "Menü umschalten",
@@ -327,7 +326,7 @@
"client_authorization": "Client-Autorisierung",
"new_client_authorization": "Neue Client-Autorisierung",
"disable_animations": "Animationen deaktivieren",
"turn_off_all_animations_throughout_the_admin_ui": "Deaktiviert alle Animationen in der Benutzeroberfläche.",
"turn_off_ui_animations": "Deaktiviert alle Animationen in der Benutzeroberfläche.",
"user_disabled": "Account deaktiviert",
"disabled_users_cannot_log_in_or_use_services": "Deaktivierte Benutzer können sich nicht anmelden oder Dienste nutzen.",
"user_disabled_successfully": "Der Benutzer wurde erfolgreich deaktiviert.",
@@ -340,11 +339,44 @@
"login_code_email_success": "Der Login-Code wurde an den Benutzer gesendet.",
"send_email": "E-Mail senden",
"show_code": "Code anzeigen",
"callback_url_description": "URL(s) die von deinem Client bereitgestellt werden. Wildcards (*) werden unterstützt, sollten für bessere Sicherheit jedoch lieber vermieden werden.",
"callback_url_description": "URL(s) die von deinem Client bereitgestellt werden. Automatische Ergänzung bei leerem Feld. Wildcards (*) werden unterstützt, sollten für bessere Sicherheit jedoch vermieden werden.",
"logout_callback_url_description": "URL(s) die von deinem Client für die Abmeldung bereitgestellt werden. Wildcards (*) werden unterstützt, sollten für bessere Sicherheit jedoch vermieden werden.",
"api_key_expiration": "API Key Ablauf",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Sende eine E-Mail an den Benutzer, wenn sein API Key ablaufen wird.",
"authorize_device": "Gerät autorisieren",
"the_device_has_been_authorized": "Das Gerät wurde autorisiert.",
"enter_code_displayed_in_previous_step": "Gib den Code ein, der im vorherigen Schritt angezeigt wurde.",
"authorize": "Autorisieren"
"authorize": "Autorisieren",
"federated_client_credentials": "Federated Client Credentials",
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
"add_federated_client_credential": "Add Federated Client Credential",
"add_another_federated_client_credential": "Add another federated client credential",
"oidc_allowed_group_count": "Erlaubte Gruppenanzahl",
"unrestricted": "Uneingeschränkt",
"show_advanced_options": "Erweiterte Optionen anzeigen",
"hide_advanced_options": "Erweiterte Optionen ausblenden",
"oidc_data_preview": "OIDC Daten-Vorschau",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
"id_token": "ID Token",
"access_token": "Access Token",
"userinfo": "Userinfo",
"id_token_payload": "ID Token Payload",
"access_token_payload": "Access Token Payload",
"userinfo_endpoint_response": "Userinfo Endpoint Antwort",
"copy": "Kopieren",
"no_preview_data_available": "Keine Vorschaudaten verfügbar",
"copy_all": "Alles kopieren",
"preview": "Vorschau",
"preview_for_user": "Vorschau für {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Vorschau der OIDC-Daten, für diesen Benutzer",
"show": "Anzeigen",
"select_an_option": "Wähle eine Option",
"select_user": "Benutzer auswählen",
"error": "Fehler",
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Select an accent color to customize the appearance of Pocket ID.",
"accent_color": "Accent Color",
"custom_accent_color": "Custom Accent Color",
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).",
"color_value": "Color Value",
"apply": "Apply"
}

View File

@@ -3,6 +3,7 @@
"my_account": "My Account",
"logout": "Logout",
"confirm": "Confirm",
"docs": "Docs",
"key": "Key",
"value": "Value",
"remove_custom_claim": "Remove custom claim",
@@ -144,8 +145,6 @@
"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",
@@ -181,7 +180,7 @@
"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.",
"emai_login_code_requested_by_user": "Email Login Code Requested by User",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This significantly reduces security as anyone with access to the user's email can gain entry.",
"email_login_code_from_admin": "Email Login Code from Admin",
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
"send_test_email": "Send test email",
@@ -311,7 +310,7 @@
"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?",
"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.",
@@ -321,13 +320,14 @@
"all_users": "All Users",
"all_events": "All Events",
"all_clients": "All Clients",
"all_locations": "All Locations",
"global_audit_log": "Global Audit Log",
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
"token_sign_in": "Token Sign In",
"client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization",
"disable_animations": "Disable Animations",
"turn_off_all_animations_throughout_the_admin_ui": "Turn off all animations throughout the Admin UI.",
"turn_off_ui_animations": "Turn off animations throughout the UI.",
"user_disabled": "Account Disabled",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
"user_disabled_successfully": "User has been disabled successfully.",
@@ -340,11 +340,44 @@
"login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email",
"show_code": "Show Code",
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.",
"callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API Key Expiration",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
"authorize_device": "Authorize Device",
"the_device_has_been_authorized": "The device has been authorized.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
"authorize": "Authorize"
"authorize": "Authorize",
"federated_client_credentials": "Federated Client Credentials",
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
"add_federated_client_credential": "Add Federated Client Credential",
"add_another_federated_client_credential": "Add another federated client credential",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted",
"show_advanced_options": "Show Advanced Options",
"hide_advanced_options": "Hide Advanced Options",
"oidc_data_preview": "OIDC Data Preview",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
"id_token": "ID Token",
"access_token": "Access Token",
"userinfo": "Userinfo",
"id_token_payload": "ID Token Payload",
"access_token_payload": "Access Token Payload",
"userinfo_endpoint_response": "Userinfo Endpoint Response",
"copy": "Copy",
"no_preview_data_available": "No preview data available",
"copy_all": "Copy All",
"preview": "Preview",
"preview_for_user": "Preview for {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
"show": "Show",
"select_an_option": "Select an option",
"select_user": "Select User",
"error": "Error",
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Select an accent color to customize the appearance of Pocket ID.",
"accent_color": "Accent Color",
"custom_accent_color": "Custom Accent Color",
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).",
"color_value": "Color Value",
"apply": "Apply"
}

View File

@@ -3,6 +3,7 @@
"my_account": "Mi Cuenta",
"logout": "Cerrar sesión",
"confirm": "Confirmar",
"docs": "Docs",
"key": "Clave",
"value": "Valor",
"remove_custom_claim": "Eliminar reclamo personalizado",
@@ -103,75 +104,73 @@
"client": "Cliente",
"unknown": "Desconocido",
"account_details_updated_successfully": "Detalles de la cuenta actualizados exitosamente",
"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",
"profile_picture_updated_successfully": "Imagen de perfil actualizada correctamente. Puede tardar unos minutos en actualizarse.",
"account_settings": "Configuración de la cuenta",
"passkey_missing": "Passkey no encontrada",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Por favor, añade una clave de acceso o passkey para evitar que pierdas el acceso a tu cuenta.",
"single_passkey_configured": "Clave única configurada",
"it_is_recommended_to_add_more_than_one_passkey": "Se recomienda añadir más de una clave de acceso para evitar perder el acceso a tu cuenta.",
"account_details": "Detalles de la cuenta",
"passkeys": "Claves de acceso",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Administra las claves de acceso que te permiten autenticarte.",
"add_passkey": "Añade una clave de acceso",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Crea un código de inicio de sesión único para iniciar sesión desde un dispositivo diferente sin una clave.",
"create": "Crear",
"first_name": "Nombre",
"last_name": "Apellido",
"username": "Nombre de usuario",
"save": "Guardar",
"username_can_only_contain": "El nombre de usuario solo puede contener letras minúsculas, números, guiones bajos, puntos, guiones y símbolos '@'",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Inicia sesión usando el siguiente código. El código caducará en 15 minutos.",
"or_visit": "o visita",
"added_on": "Añadido el",
"rename": "Renombrar",
"delete": "Borrar",
"are_you_sure_you_want_to_delete_this_passkey": "¿Está seguro de que desea eliminar esta passkey?",
"passkey_deleted_successfully": "Passkey eliminada con éxito",
"delete_passkey_name": "Borrar {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",
"passkey_name_updated_successfully": "Nombre de la clave de acceso actualizado correctamente",
"name_passkey": "Nombre para la clave de acceso",
"name_your_passkey_to_easily_identify_it_later": "Nombra tu clave de acceso para poder identificarla fácilmente más tarde.",
"create_api_key": "Crear API Key",
"add_a_new_api_key_for_programmatic_access": "Añade una nueva API key para el acceso programático.",
"add_api_key": "Añade una API Key",
"manage_api_keys": "Gestiona las API Keys",
"api_key_created": "API Key creada",
"for_security_reasons_this_key_will_only_be_shown_once": "Por razones de seguridad, esta clave sólo se mostrará una vez. Por favor, guárdala de forma segura.",
"description": "Descripción",
"api_key": "API Key",
"close": "Cerrar",
"name_to_identify_this_api_key": "Name to identify this API key.",
"name_to_identify_this_api_key": "Nombra esta API Key para identificarla.",
"expires_at": "Expira el",
"when_this_api_key_will_expire": "Cuando esta clave de API caducará.",
"optional_description_to_help_identify_this_keys_purpose": "Optional description to help identify this key's purpose.",
"name_must_be_at_least_3_characters": "El nombre debe tener al menos 3 caracteres",
"name_cannot_exceed_50_characters": "El nombre no puede exceder los 50 caracteres",
"optional_description_to_help_identify_this_keys_purpose": "Descripción opcional para ayudar a identificar el propósito de esta clave.",
"expiration_date_must_be_in_the_future": "La fecha de caducidad debe ser en el futuro",
"revoke_api_key": "Revoke API Key",
"revoke_api_key": "Invalidar API Key",
"never": "Nunca",
"revoke": "Revoke",
"revoke": "Invalidar",
"api_key_revoked_successfully": "La clave API se ha revocado con éxito",
"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.",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "¿Estás seguro de que deseas invalidar la API Key \"{apiKeyName}\"? Esto romperá cualquier integración que esté usando esta clave.",
"last_used": "Utilizado por última vez",
"actions": "Acciones",
"images_updated_successfully": "Images updated successfully",
"images_updated_successfully": "Imágenes actualizadas correctamente",
"general": "General",
"configure_smtp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.",
"configure_smtp_to_send_emails": "Habilita las notificaciones por correo electrónico para alertar a los usuarios cuando se detecta un inicio de sesión desde un nuevo dispositivo o ubicación.",
"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?",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configura los ajustes LDAP para sincronizar usuarios y grupos desde un servidor LDAP.",
"images": "Imágenes",
"update": "Actualización",
"email_configuration_updated_successfully": "Configuración de correo electrónico actualizada correctamente",
"save_changes_question": "¿Guardar los cambios?",
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "Tienes que guardar los cambios antes de enviar un correo electrónico de prueba. ¿Quieres guardar ahora?",
"save_and_send": "Guardar y enviar",
"test_email_sent_successfully": "Correo electrónico de prueba enviado con éxito a tu dirección de correo electrónico.",
"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",
"failed_to_send_test_email": "Error al enviar el email de prueba. Revisa los registros del servidor para más información.",
"smtp_configuration": "Configuración SMTP",
"smtp_host": "Servidor SMTP",
"smtp_port": "Puerto SMTP",
"smtp_user": "Usuario SMTP",
"smtp_password": "Contraseña SMTP",
"smtp_from": "SMTP From",
"smtp_tls_option": "SMTP TLS Option",
"email_tls_option": "Email TLS Option",
@@ -181,7 +180,7 @@
"email_login_notification": "Email Login Notification",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Enviar un correo electrónico al usuario cuando inicie sesión desde un dispositivo nuevo.",
"emai_login_code_requested_by_user": "Código de acceso solicitado por el usuario",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Permite a los usuarios saltarse las claves de acceso solicitando un código de acceso enviado a su correo electrónico. Esto reduce la seguridad significativamente, ya que cualquiera con acceso al correo electrónico del usuario puede obtener acceso.",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This significantly reduces security as anyone with access to the user's email can gain entry.",
"email_login_code_from_admin": "Email Login Code from Admin",
"allows_an_admin_to_send_a_login_code_to_the_user": "Permite a un administrador enviar un código de acceso al usuario por correo electrónico.",
"send_test_email": "Enviar correo de prueba",
@@ -281,7 +280,7 @@
"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",
"upload_logo": "Subir 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",
@@ -306,12 +305,12 @@
"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",
"light_mode_logo": "Logo del modo Claro",
"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?",
"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.",
@@ -327,7 +326,7 @@
"client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization",
"disable_animations": "Disable Animations",
"turn_off_all_animations_throughout_the_admin_ui": "Turn off all animations throughout the Admin UI.",
"turn_off_ui_animations": "Turn off animations throughout the UI.",
"user_disabled": "Account Disabled",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
"user_disabled_successfully": "User has been disabled successfully.",
@@ -340,11 +339,44 @@
"login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email",
"show_code": "Show Code",
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.",
"callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API Key Expiration",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
"authorize_device": "Authorize Device",
"the_device_has_been_authorized": "The device has been authorized.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
"authorize": "Authorize"
"authorize": "Authorize",
"federated_client_credentials": "Federated Client Credentials",
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
"add_federated_client_credential": "Add Federated Client Credential",
"add_another_federated_client_credential": "Add another federated client credential",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted",
"show_advanced_options": "Show Advanced Options",
"hide_advanced_options": "Hide Advanced Options",
"oidc_data_preview": "OIDC Data Preview",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
"id_token": "ID Token",
"access_token": "Access Token",
"userinfo": "Userinfo",
"id_token_payload": "ID Token Payload",
"access_token_payload": "Access Token Payload",
"userinfo_endpoint_response": "Userinfo Endpoint Response",
"copy": "Copy",
"no_preview_data_available": "No preview data available",
"copy_all": "Copy All",
"preview": "Preview",
"preview_for_user": "Preview for {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
"show": "Show",
"select_an_option": "Select an option",
"select_user": "Select User",
"error": "Error",
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Select an accent color to customize the appearance of Pocket ID.",
"accent_color": "Accent Color",
"custom_accent_color": "Custom Accent Color",
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).",
"color_value": "Color Value",
"apply": "Apply"
}

View File

@@ -3,6 +3,7 @@
"my_account": "Mon compte",
"logout": "Déconnexion",
"confirm": "Confirmer",
"docs": "Docs",
"key": "Clé",
"value": "Valeur",
"remove_custom_claim": "Remove custom claim",
@@ -144,8 +145,6 @@
"expires_at": "Date d'expiration",
"when_this_api_key_will_expire": "Date d'expiration de la clé API.",
"optional_description_to_help_identify_this_keys_purpose": "Description facultative pour aider à identifier le but de cette clé.",
"name_must_be_at_least_3_characters": "Le nom doit contenir au moins 3 caractères",
"name_cannot_exceed_50_characters": "Le nom ne doit pas dépasser un maximum de 50 caractères",
"expiration_date_must_be_in_the_future": "La date d'expiration doit être dans le futur",
"revoke_api_key": "Révoquer la clé API",
"never": "Jamais",
@@ -181,10 +180,10 @@
"email_login_notification": "Notification de connexion par e-mail",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Envoyer un email à l'utilisateur lorsqu'il se connecte à partir d'un nouvel appareil.",
"emai_login_code_requested_by_user": "Email Login Code Requested by User",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This significantly reduces security as anyone with access to the user's email can gain entry.",
"email_login_code_from_admin": "Email Login Code from Admin",
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
"send_test_email": "",
"send_test_email": "Send test email",
"application_configuration_updated_successfully": "Mise à jour de l'application avec succès",
"application_name": "Nom de l'application",
"session_duration": "Durée de la session",
@@ -311,7 +310,7 @@
"background_image": "Image d'arrière-plan",
"language": "Langue",
"reset_profile_picture_question": "Réinitialiser la photo de profil ?",
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "Cela réinitialisera l'image de profil par défaut. Voulez-vous continuer ?",
"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": "Réinitialiser",
"reset_to_default": "Valeurs par défaut",
"profile_picture_has_been_reset": "La photo de profil a été réinitialisée. La mise à jour peut prendre quelques minutes.",
@@ -327,7 +326,7 @@
"client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization",
"disable_animations": "Disable Animations",
"turn_off_all_animations_throughout_the_admin_ui": "Turn off all animations throughout the Admin UI.",
"turn_off_ui_animations": "Turn off animations throughout the UI.",
"user_disabled": "Account Disabled",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
"user_disabled_successfully": "User has been disabled successfully.",
@@ -340,11 +339,44 @@
"login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email",
"show_code": "Show Code",
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.",
"callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API Key Expiration",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
"authorize_device": "Authorize Device",
"the_device_has_been_authorized": "The device has been authorized.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
"authorize": "Authorize"
"authorize": "Authorize",
"federated_client_credentials": "Federated Client Credentials",
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
"add_federated_client_credential": "Add Federated Client Credential",
"add_another_federated_client_credential": "Add another federated client credential",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted",
"show_advanced_options": "Show Advanced Options",
"hide_advanced_options": "Hide Advanced Options",
"oidc_data_preview": "OIDC Data Preview",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
"id_token": "ID Token",
"access_token": "Access Token",
"userinfo": "Userinfo",
"id_token_payload": "ID Token Payload",
"access_token_payload": "Access Token Payload",
"userinfo_endpoint_response": "Userinfo Endpoint Response",
"copy": "Copy",
"no_preview_data_available": "No preview data available",
"copy_all": "Copy All",
"preview": "Preview",
"preview_for_user": "Preview for {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
"show": "Show",
"select_an_option": "Select an option",
"select_user": "Select User",
"error": "Error",
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Select an accent color to customize the appearance of Pocket ID.",
"accent_color": "Accent Color",
"custom_accent_color": "Custom Accent Color",
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).",
"color_value": "Color Value",
"apply": "Apply"
}

View File

@@ -3,6 +3,7 @@
"my_account": "Il mio account",
"logout": "Disconnetti",
"confirm": "Conferma",
"docs": "Documentazione",
"key": "Chiave",
"value": "Valore",
"remove_custom_claim": "Rimuovi attributo personalizzato",
@@ -144,8 +145,6 @@
"expires_at": "Scade il",
"when_this_api_key_will_expire": "Quando scadrà questa chiave API.",
"optional_description_to_help_identify_this_keys_purpose": "Descrizione opzionale per aiutare a identificare lo scopo di questa chiave.",
"name_must_be_at_least_3_characters": "Il nome deve essere di almeno 3 caratteri",
"name_cannot_exceed_50_characters": "Il nome non può superare i 50 caratteri",
"expiration_date_must_be_in_the_future": "La data di scadenza deve essere nel futuro",
"revoke_api_key": "Revoca Chiave API",
"never": "Mai",
@@ -327,7 +326,7 @@
"client_authorization": "Autorizzazione client",
"new_client_authorization": "Nuova autorizzazione client",
"disable_animations": "Disabilita animazioni",
"turn_off_all_animations_throughout_the_admin_ui": "Disattiva tutte le animazioni nell'interfaccia di amministrazione.",
"turn_off_ui_animations": "Disattiva tutte le animazioni della UI.",
"user_disabled": "Account disabilitato",
"disabled_users_cannot_log_in_or_use_services": "Gli utenti disabilitati non possono accedere o utilizzare i servizi.",
"user_disabled_successfully": "Utente disabilitato con successo.",
@@ -340,11 +339,44 @@
"login_code_email_success": "Il codice di accesso è stato inviato all'utente.",
"send_email": "Invia email",
"show_code": "Mostra codice",
"callback_url_description": "URL forniti dal tuo client. Wildcard (*) sono supportati, ma meglio evitarli per una migliore sicurezza.",
"callback_url_description": "URL forniti dal client. Verrà automaticamente aggiunto se lasciato vuoto. I caratteri jolly (*) sono supportati, ma è meglio evitarli per maggiore sicurezza.",
"logout_callback_url_description": "URL forniti dal client per il logout. I caratteri jolly (*) sono supportati, ma meglio evitarli per una migliore sicurezza.",
"api_key_expiration": "Scadenza Chiave API",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Invia un'email all'utente quando la sua chiave API sta per scadere.",
"authorize_device": "Autorizza Dispositivo",
"the_device_has_been_authorized": "Il dispositivo è stato autorizzato.",
"enter_code_displayed_in_previous_step": "Inserisci il codice visualizzato nel passaggio precedente.",
"authorize": "Autorizza"
"authorize": "Autorizza",
"federated_client_credentials": "Identità Federate",
"federated_client_credentials_description": "Utilizzando identità federate, è possibile autenticare i client OIDC utilizzando i token JWT emessi da autorità di terze parti.",
"add_federated_client_credential": "Aggiungi Identità Federata",
"add_another_federated_client_credential": "Aggiungi un'altra identità federata",
"oidc_allowed_group_count": "Numero Gruppi Consentiti",
"unrestricted": "Illimitati",
"show_advanced_options": "Mostra Opzioni Avanzate",
"hide_advanced_options": "Nascondi Opzioni Avanzate",
"oidc_data_preview": "Anteprima Dati OIDC",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Anteprima dei dati OIDC che saranno inviati agli utenti",
"id_token": "ID Token",
"access_token": "Access Token",
"userinfo": "Userinfo",
"id_token_payload": "ID Token Payload",
"access_token_payload": "Access Token Payload",
"userinfo_endpoint_response": "Risposta Endpoint Userinfo",
"copy": "Copia",
"no_preview_data_available": "Dati di anteprima non disponibili",
"copy_all": "Copia tutto",
"preview": "Anteprima",
"preview_for_user": "Anteprima per {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Anteprima dei dati OIDC che saranno inviati per l'utente",
"show": "Mostra",
"select_an_option": "Seleziona un'opzione",
"select_user": "Seleziona utente",
"error": "Errore",
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Seleziona un colore in risalto per personalizzare l'aspetto di Pocket ID.",
"accent_color": "Colore in Risalto",
"custom_accent_color": "Colore in Risalto Personalizzato",
"custom_accent_color_description": "Inserisci un colore personalizzato usando formati di colore CSS validi (es: hex, rgb, hsl).",
"color_value": "Valore Colore",
"apply": "Applica"
}

View File

@@ -3,6 +3,7 @@
"my_account": "Mijn account",
"logout": "Uitloggen",
"confirm": "Bevestigen",
"docs": "Docs",
"key": "Sleutel",
"value": "Waarde",
"remove_custom_claim": "Aangepaste claim verwijderen",
@@ -144,8 +145,6 @@
"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",
@@ -181,7 +180,7 @@
"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.",
"emai_login_code_requested_by_user": "Email Login Code Requested by User",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This significantly reduces security as anyone with access to the user's email can gain entry.",
"email_login_code_from_admin": "Email Login Code from Admin",
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
"send_test_email": "Test-e-mail verzenden",
@@ -311,7 +310,7 @@
"background_image": "Achtergrondfoto",
"language": "Taal",
"reset_profile_picture_question": "Profielfoto opnieuw instellen?",
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "Dit verwijdert de geüploade afbeelding en de zet de profielfoto terug naar de standaard-profielfoto. Wilt u doorgaan?",
"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": "Standaardinstellingen herstellen",
"profile_picture_has_been_reset": "Profielfoto is gereset. Het kan enkele minuten duren voordat de wijzigingen zichtbaar zijn.",
@@ -327,7 +326,7 @@
"client_authorization": "Client autorisatie",
"new_client_authorization": "Nieuwe clientautorisatie",
"disable_animations": "Disable Animations",
"turn_off_all_animations_throughout_the_admin_ui": "Turn off all animations throughout the Admin UI.",
"turn_off_ui_animations": "Turn off animations throughout the UI.",
"user_disabled": "Account Disabled",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
"user_disabled_successfully": "User has been disabled successfully.",
@@ -340,11 +339,44 @@
"login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email",
"show_code": "Show Code",
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.",
"callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API Key Expiration",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
"authorize_device": "Authorize Device",
"the_device_has_been_authorized": "The device has been authorized.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
"authorize": "Authorize"
"authorize": "Authorize",
"federated_client_credentials": "Federated Client Credentials",
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
"add_federated_client_credential": "Add Federated Client Credential",
"add_another_federated_client_credential": "Add another federated client credential",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted",
"show_advanced_options": "Show Advanced Options",
"hide_advanced_options": "Hide Advanced Options",
"oidc_data_preview": "OIDC Data Preview",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
"id_token": "ID Token",
"access_token": "Access Token",
"userinfo": "Userinfo",
"id_token_payload": "ID Token Payload",
"access_token_payload": "Access Token Payload",
"userinfo_endpoint_response": "Userinfo Endpoint Response",
"copy": "Copy",
"no_preview_data_available": "No preview data available",
"copy_all": "Copy All",
"preview": "Preview",
"preview_for_user": "Preview for {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
"show": "Show",
"select_an_option": "Select an option",
"select_user": "Select User",
"error": "Error",
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Select an accent color to customize the appearance of Pocket ID.",
"accent_color": "Accent Color",
"custom_accent_color": "Custom Accent Color",
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).",
"color_value": "Color Value",
"apply": "Apply"
}

View File

@@ -3,6 +3,7 @@
"my_account": "Moje konto",
"logout": "Wyloguj się",
"confirm": "Potwierdź",
"docs": "Docs",
"key": "Klucz",
"value": "Wartość",
"remove_custom_claim": "Usuń niestandardowy atrybut",
@@ -144,8 +145,6 @@
"expires_at": "Wygasa o",
"when_this_api_key_will_expire": "Kiedy ten klucz API wygaśnie.",
"optional_description_to_help_identify_this_keys_purpose": "Opcjonalny opis, aby pomóc zidentyfikować cel tego klucza.",
"name_must_be_at_least_3_characters": "Nazwa musi mieć co najmniej 3 znaki",
"name_cannot_exceed_50_characters": "Nazwa nie może przekraczać 50 znaków",
"expiration_date_must_be_in_the_future": "Data wygaśnięcia musi być w przyszłości",
"revoke_api_key": "Unieważnij klucz API",
"never": "Nigdy",
@@ -181,7 +180,7 @@
"email_login_notification": "Powiadomienie o logowaniu przez e-mail",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Wyślij e-mail do użytkownika, gdy zaloguje się z nowego urządzenia.",
"emai_login_code_requested_by_user": "Kod logowania e-mailem zażądany przez użytkownika",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Pozwól użytkownikom zalogować się za pomocą kodu logowania wysłanego na ich e-mail. Znacząco obniża to bezpieczeństwo, ponieważ każdy, kto ma dostęp do e-maila użytkownika, może uzyskać dostęp.",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This significantly reduces security as anyone with access to the user's email can gain entry.",
"email_login_code_from_admin": "Kod logowania e-mailem od administratora",
"allows_an_admin_to_send_a_login_code_to_the_user": "Pozwala administratorowi wysłać kod logowania do użytkownika za pomocą e-maila.",
"send_test_email": "Wyślij testowy e-mail",
@@ -279,7 +278,7 @@
"public_clients_description": "Klienci publiczni nie mają tajnego klucza. Są zaprojektowane dla aplikacji mobilnych, webowych i natywnych, gdzie tajne klucze nie mogą być bezpiecznie przechowywane.",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Wymiana kodu publicznego klucza to funkcja zabezpieczająca, która zapobiega atakom CSRF i przechwytywaniu kodu autoryzacyjnego.",
"name_logo": "{name} logo",
"name_logo": "Logo {name}",
"change_logo": "Zmień logo",
"upload_logo": "Prześlij logo",
"remove_logo": "Usuń logo",
@@ -305,13 +304,13 @@
"show_more_details": "Pokaż więcej szczegółów",
"allowed_user_groups": "Dozwolone grupy użytkowników",
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Dodaj grupy użytkowników do tego klienta, aby ograniczyć dostęp do użytkowników w tych grupach. Jeśli nie wybrano żadnych grup użytkowników, wszyscy użytkownicy będą mieli dostęp do tego klienta.",
"favicon": "Favicon",
"favicon": "Ikona ulubionych",
"light_mode_logo": "Logo w trybie jasnym",
"dark_mode_logo": "Logo w trybie ciemnym",
"background_image": "Obraz tła",
"language": "Język",
"reset_profile_picture_question": "Zresetować zdjęcie profilowe?",
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "To usunie przesłany obraz i zresetuje zdjęcie profilowe do domyślnego. Czy chcesz kontynuować?",
"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": "Zresetuj",
"reset_to_default": "Zresetuj do domyślnych",
"profile_picture_has_been_reset": "Zdjęcie profilowe zostało zresetowane. Może to potrwać kilka minut.",
@@ -327,7 +326,7 @@
"client_authorization": "Autoryzacja klienta",
"new_client_authorization": "Nowa autoryzacja klienta",
"disable_animations": "Wyłącz animacje",
"turn_off_all_animations_throughout_the_admin_ui": "Wyłącz wszystkie animacje w całym interfejsie administracyjnym.",
"turn_off_ui_animations": "Turn off animations throughout the UI.",
"user_disabled": "Konto wyłączone",
"disabled_users_cannot_log_in_or_use_services": "Wyłączone konta użytkowników nie mogą się logować ani korzystać z usług.",
"user_disabled_successfully": "Sukces! Konto zostało wyłączone.",
@@ -340,11 +339,44 @@
"login_code_email_success": "Kod logowania został wysłany do użytkownika.",
"send_email": "Wyślij e-mail",
"show_code": "Pokaż kod",
"callback_url_description": "URL-e podane przez twojego klienta. Wildcardy (*) są obsługiwane, ale najlepiej ich unikać dla lepszej bezpieczeństwa.",
"callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "Wygaszenie klucza API",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Wyślij e-mail do użytkownika, gdy jego klucz API ma wygasnąć.",
"authorize_device": "Autoryzuj urządzenie",
"the_device_has_been_authorized": "Urządzenie zostało autoryzowane.",
"enter_code_displayed_in_previous_step": "Wprowadź kod wyświetlony w poprzednim kroku.",
"authorize": "Autoryzuj"
"authorize": "Autoryzuj",
"federated_client_credentials": "Federated Client Credentials",
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
"add_federated_client_credential": "Add Federated Client Credential",
"add_another_federated_client_credential": "Add another federated client credential",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted",
"show_advanced_options": "Show Advanced Options",
"hide_advanced_options": "Hide Advanced Options",
"oidc_data_preview": "OIDC Data Preview",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
"id_token": "ID Token",
"access_token": "Access Token",
"userinfo": "Userinfo",
"id_token_payload": "ID Token Payload",
"access_token_payload": "Access Token Payload",
"userinfo_endpoint_response": "Userinfo Endpoint Response",
"copy": "Copy",
"no_preview_data_available": "No preview data available",
"copy_all": "Copy All",
"preview": "Preview",
"preview_for_user": "Preview for {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
"show": "Show",
"select_an_option": "Select an option",
"select_user": "Select User",
"error": "Error",
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Select an accent color to customize the appearance of Pocket ID.",
"accent_color": "Accent Color",
"custom_accent_color": "Custom Accent Color",
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).",
"color_value": "Color Value",
"apply": "Apply"
}

View File

@@ -3,6 +3,7 @@
"my_account": "Minha Conta",
"logout": "Sair",
"confirm": "Confirmar",
"docs": "Docs",
"key": "Chave",
"value": "Valor",
"remove_custom_claim": "Remove custom claim",
@@ -144,8 +145,6 @@
"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": "Nunca",
@@ -181,7 +180,7 @@
"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.",
"emai_login_code_requested_by_user": "Email Login Code Requested by User",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This significantly reduces security as anyone with access to the user's email can gain entry.",
"email_login_code_from_admin": "Email Login Code from Admin",
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
"send_test_email": "Send test email",
@@ -311,7 +310,7 @@
"background_image": "Background Image",
"language": "Idioma",
"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?",
"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": "Redefinir",
"reset_to_default": "Redefinir para o padrão",
"profile_picture_has_been_reset": "Profile picture has been reset. It may take a few minutes to update.",
@@ -327,7 +326,7 @@
"client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization",
"disable_animations": "Disable Animations",
"turn_off_all_animations_throughout_the_admin_ui": "Turn off all animations throughout the Admin UI.",
"turn_off_ui_animations": "Turn off animations throughout the UI.",
"user_disabled": "Account Disabled",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
"user_disabled_successfully": "User has been disabled successfully.",
@@ -340,11 +339,44 @@
"login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email",
"show_code": "Show Code",
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.",
"callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API Key Expiration",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
"authorize_device": "Authorize Device",
"the_device_has_been_authorized": "The device has been authorized.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
"authorize": "Authorize"
"authorize": "Authorize",
"federated_client_credentials": "Federated Client Credentials",
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
"add_federated_client_credential": "Add Federated Client Credential",
"add_another_federated_client_credential": "Add another federated client credential",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted",
"show_advanced_options": "Show Advanced Options",
"hide_advanced_options": "Hide Advanced Options",
"oidc_data_preview": "OIDC Data Preview",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
"id_token": "ID Token",
"access_token": "Access Token",
"userinfo": "Userinfo",
"id_token_payload": "ID Token Payload",
"access_token_payload": "Access Token Payload",
"userinfo_endpoint_response": "Userinfo Endpoint Response",
"copy": "Copy",
"no_preview_data_available": "No preview data available",
"copy_all": "Copy All",
"preview": "Preview",
"preview_for_user": "Preview for {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
"show": "Show",
"select_an_option": "Select an option",
"select_user": "Select User",
"error": "Error",
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Select an accent color to customize the appearance of Pocket ID.",
"accent_color": "Accent Color",
"custom_accent_color": "Custom Accent Color",
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).",
"color_value": "Color Value",
"apply": "Apply"
}

View File

@@ -1,350 +0,0 @@
{
"$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 use an 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 {appName} 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 don'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",
"configure_smtp_to_send_emails": "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.",
"emai_login_code_requested_by_user": "Email Login Code Requested by User",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
"email_login_code_from_admin": "Email Login Code from Admin",
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
"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 have lost it.",
"add": "Add",
"callback_urls": "Callback URLs",
"logout_callback_urls": "Logout Callback URLs",
"public_client": "Public Client",
"public_clients_description": "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.",
"personal": "Personal",
"global": "Global",
"all_users": "All Users",
"all_events": "All Events",
"all_clients": "All Clients",
"global_audit_log": "Global Audit Log",
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
"token_sign_in": "Token Sign In",
"client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization",
"disable_animations": "Disable Animations",
"turn_off_all_animations_throughout_the_admin_ui": "Turn off all animations throughout the Admin UI.",
"user_disabled": "Account Disabled",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
"user_disabled_successfully": "User has been disabled successfully.",
"user_enabled_successfully": "User has been enabled successfully.",
"status": "Status",
"disable_firstname_lastname": "Disable {firstName} {lastName}",
"are_you_sure_you_want_to_disable_this_user": "Are you sure you want to disable this user? They will not be able to log in or access any services.",
"ldap_soft_delete_users": "Keep disabled users from LDAP.",
"ldap_soft_delete_users_description": "When enabled, users removed from LDAP will be disabled rather than deleted from the system.",
"login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email",
"show_code": "Show Code",
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API Key Expiration",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
"authorize_device": "Authorize Device",
"the_device_has_been_authorized": "The device has been authorized.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
"authorize": "Authorize"
}

View File

@@ -3,6 +3,7 @@
"my_account": "Моя учетная запись",
"logout": "Выйти",
"confirm": "Подтвердить",
"docs": "Документация",
"key": "Ключ",
"value": "Значение",
"remove_custom_claim": "Удалить пользовательский claim",
@@ -144,8 +145,6 @@
"expires_at": "Действителен до",
"when_this_api_key_will_expire": "Когда срок действия этого API ключа истечет.",
"optional_description_to_help_identify_this_keys_purpose": "Опциональное описание, чтобы помочь определить цель этого ключа.",
"name_must_be_at_least_3_characters": "Имя должно содержать не менее 3 символов",
"name_cannot_exceed_50_characters": "Длина имени не может превышать 50 символов",
"expiration_date_must_be_in_the_future": "Дата истечения должна быть определена в будущем",
"revoke_api_key": "Отозвать API ключ",
"never": "Никогда",
@@ -327,7 +326,7 @@
"client_authorization": "Авторизация в клиенте",
"new_client_authorization": "Новая авторизация в клиенте",
"disable_animations": "Отключить анимации",
"turn_off_all_animations_throughout_the_admin_ui": "Выключить все анимации в интерфейсе администратора.",
"turn_off_ui_animations": "Выключить анимации по всему интерфейсу.",
"user_disabled": "Аккаунт отключен",
"disabled_users_cannot_log_in_or_use_services": "Отключенные пользователи не могут войти или использовать сервисы.",
"user_disabled_successfully": "Пользователь успешно отключен.",
@@ -340,11 +339,44 @@
"login_code_email_success": "Код входа был отправлен пользователю.",
"send_email": "Отправить письмо",
"show_code": "Показать код",
"callback_url_description": "URL-адреса, предоставленные клиентом. Поддерживаются wildcard-адреса (*), но лучше всего избегать их для лучшей безопасности.",
"callback_url_description": "URL-адрес(а) предоставленные вашим клиентом. Будет автоматически добавлен если оставить пустым. Маски (*) поддерживаются, но лучше избегайте их для повышения безопасности.",
"logout_callback_url_description": "URL-адрес(а), предоставленный вашим клиентом для выхода. Маски (*) поддерживаются, но лучше избегайте их для повышения безопасности.",
"api_key_expiration": "Истечение срока действия API ключа",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Отправлять пользователю письмо, когда истечет срок действия API ключа.",
"authorize_device": "Авторизовать устройство",
"the_device_has_been_authorized": "Устройство авторизовано.",
"enter_code_displayed_in_previous_step": "Введите код, который был отображен на предыдущем шаге.",
"authorize": "Авторизируйте"
"authorize": "Авторизируйте",
"federated_client_credentials": "Федеративные учетные данные клиента",
"federated_client_credentials_description": "Используя федеративные учетные данные клиента, вы можете авторизовывать OIDC клиентов, используя JWT токены, выпущенные третьими сторонами.",
"add_federated_client_credential": "Добавить федеративные учетные данные клиента",
"add_another_federated_client_credential": "Добавить другие федеративные учетные данные клиента",
"oidc_allowed_group_count": "Кол-во разрешенных групп",
"unrestricted": "Не ограничено",
"show_advanced_options": "Показать дополнительные опции",
"hide_advanced_options": "Скрыть дополнительные опции",
"oidc_data_preview": "Предпросмотр данных OIDC",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Предпросмотр данных OIDC, которые будут отправлены разным пользователям",
"id_token": "ID Token",
"access_token": "Access Token",
"userinfo": "Userinfo",
"id_token_payload": "Содержимое ID Token",
"access_token_payload": "Содержимое Access Token",
"userinfo_endpoint_response": "Ответ Userinfo эндпоинта",
"copy": "Копировать",
"no_preview_data_available": "Предварительный просмотр данных не доступен",
"copy_all": "Копировать все",
"preview": "Предпросмотр",
"preview_for_user": "Предпросмотр для {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Предпросмотр данных OIDC, которые будут отправлены для этого пользователя",
"show": "Показать",
"select_an_option": "Выберите опцию",
"select_user": "Выбрать пользователя",
"error": "Ошибка",
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Выберите цвет акцента, чтобы настроить внешний вид Pocket ID.",
"accent_color": "Цвет акцента",
"custom_accent_color": "Пользовательский цвет акцента",
"custom_accent_color_description": "Введите пользовательский цвет, используя правильные цветовые форматы CSS (например, hex, rgb, hsl).",
"color_value": "Значение цвета",
"apply": "Применить"
}

View File

@@ -1,22 +1,23 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"$schema": "",
"my_account": "账户",
"logout": "登出",
"confirm": "确认",
"docs": "文档",
"key": "Key",
"value": "Value",
"remove_custom_claim": "除自定义声明",
"remove_custom_claim": "除自定义声明",
"add_custom_claim": "添加自定义声明",
"add_another": "添加另一个",
"select_a_date": "选择日期",
"select_file": "选择文件",
"select_file": "选择上传文件",
"profile_picture": "头像",
"profile_picture_is_managed_by_ldap_server": "头像由 LDAP 服务器管理,无法在此处更改。",
"click_profile_picture_to_upload_custom": "点击头像从文件中上传您的自定义头像。",
"click_profile_picture_to_upload_custom": "点击头像从文件中上传自定义头像。",
"image_should_be_in_format": "图片应为 PNG 或 JPEG 格式。",
"items_per_page": "每页条数",
"no_items_found": "🌱 这里暂时空空如也",
"search": "搜索...",
"no_items_found": "🌱 这里暂时空空如也",
"search": "搜索",
"expand_card": "展开卡片",
"copied": "已复制",
"click_to_copy": "点击复制",
@@ -33,7 +34,7 @@
"one_week": "1 周",
"one_month": "1 个月",
"expiration": "到期时间",
"generate_code": "生成码",
"generate_code": "生成登录码",
"name": "名称",
"browser_unsupported": "浏览器不支持",
"this_browser_does_not_support_passkeys": "此浏览器不支持通行密钥。请使用其他登录方式。",
@@ -59,19 +60,19 @@
"cancel": "取消",
"sign_in": "登录",
"try_again": "重试",
"client_logo": "客户端标志",
"client_logo": "客户端 Logo",
"sign_out": "登出",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "您是否希望使用账户 <b>{username}</b> 登出 Pocket ID",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "您确定要退出 {appName} 应用中的帐号 <b>{username}</b> ",
"sign_in_to_appname": "登录到 {appName}",
"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": "登录",
"appname_setup": "{appName} 设置",
"please_try_again": "请重试。",
"please_try_again": "请再试一次。",
"you_are_about_to_sign_in_to_the_initial_admin_account": "您即将登录到初始管理员账户。在此添加通行密钥之前,任何拥有此链接的人都可以访问该账户。请尽快设置通行密钥以防止未经授权的访问。",
"continue": "继续",
"alternative_sign_in": "替代登录方式",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "如果您无法访问您的通行密钥,可以使用以下方之一登录。",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "如果您无法使用通行密钥,可以通过以下方之一登录。",
"use_your_passkey_instead": "改用您的通行密钥?",
"email_login": "电子邮件登录",
"enter_a_login_code_to_sign_in": "输入一次性登录码以登录。",
@@ -102,8 +103,8 @@
"device": "设备",
"client": "客户端",
"unknown": "未知",
"account_details_updated_successfully": "账户详细信息更新成功",
"profile_picture_updated_successfully": "头像更新成功。可能需要几分钟才能新。",
"account_details_updated_successfully": "账户信息已成功更新",
"profile_picture_updated_successfully": "头像更新成功。可能需要几分钟才能完成刷新。",
"account_settings": "账户设置",
"passkey_missing": "尚未绑定通行密钥",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "请添加通行密钥以防止失去对账户的访问。",
@@ -111,7 +112,7 @@
"it_is_recommended_to_add_more_than_one_passkey": "建议添加多个通行密钥以避免失去对账户的访问。",
"account_details": "账户详情",
"passkeys": "通行密钥",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "管理您可以用来进行身份验证的通行密钥。",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "管理您的通行密钥,以用于身份验证。",
"add_passkey": "添加通行密钥",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "创建一次性登录码,以便从不同设备登录而无需通行密钥。",
"create": "创建",
@@ -120,15 +121,15 @@
"username": "用户名",
"save": "保存",
"username_can_only_contain": "用户名只能包含小写字母、数字、下划线、点、连字符和 '@' 符号",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "使用以下代码登录。代码将在 15 分钟后过期。",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "使用以下代码登录。代码将在 15 分钟后失效。",
"or_visit": "或访问",
"added_on": "添加于",
"rename": "重命名",
"delete": "删除",
"are_you_sure_you_want_to_delete_this_passkey": "您确定要删除此通行密钥吗?",
"passkey_deleted_successfully": "通行密钥删除成功",
"passkey_deleted_successfully": "已成功删除通行密钥",
"delete_passkey_name": "删除 {passkeyName}",
"passkey_name_updated_successfully": "通行密钥名称更新成功",
"passkey_name_updated_successfully": "已成功更新通行密钥名称",
"name_passkey": "重命名通行密钥",
"name_your_passkey_to_easily_identify_it_later": "为您的通行密钥命名,以便以后轻松识别。",
"create_api_key": "创建 API 密钥",
@@ -136,27 +137,25 @@
"add_api_key": "添加 API 密钥",
"manage_api_keys": "管理 API 密钥",
"api_key_created": "API 密钥已创建",
"for_security_reasons_this_key_will_only_be_shown_once": "出于安全原因,此密钥会显示一次请妥善保存。",
"for_security_reasons_this_key_will_only_be_shown_once": "出于安全原因,此密钥会显示一次请妥善保存。",
"description": "描述",
"api_key": "API 密钥",
"close": "关闭",
"name_to_identify_this_api_key": "用于识别此 API 密钥的名称。",
"expires_at": "期时间",
"when_this_api_key_will_expire": "此 API 密钥的期时间。",
"optional_description_to_help_identify_this_keys_purpose": "可选描述,帮助识别此密钥的用途。",
"name_must_be_at_least_3_characters": "名称必须至少为 3 个字符",
"name_cannot_exceed_50_characters": "名称不能超过 50 个字符",
"expiration_date_must_be_in_the_future": "过期日期必须是未来的日期",
"expires_at": "期时间",
"when_this_api_key_will_expire": "此 API 密钥的期时间。",
"optional_description_to_help_identify_this_keys_purpose": "可选描述,用于帮助识别此密钥的用途。",
"expiration_date_must_be_in_the_future": "到期日期必须设定为未来的日期",
"revoke_api_key": "撤销 API 密钥",
"never": "永不",
"revoke": "撤销",
"api_key_revoked_successfully": "API 密钥撤销成功",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "您确定要撤销 API 密钥 \"{apiKeyName}\" 吗?这将中断使用此密钥的任何集成。",
"last_used": "最后使用",
"last_used": "上次使用时间",
"actions": "操作",
"images_updated_successfully": "图片更新成功",
"images_updated_successfully": "已成功更新图片",
"general": "常规",
"configure_smtp_to_send_emails": "启用电子邮件通知,以便在新设备或位置检测到登录时提醒用户。",
"configure_smtp_to_send_emails": "启用电子邮件通知,当检测到来自新设备或位置登录时提醒用户。",
"ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "配置 LDAP 设置以从 LDAP 服务器同步用户和群组。",
"images": "图片",
@@ -166,7 +165,7 @@
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "在发送测试电子邮件之前,您必须保存更改。是否现在保存?",
"save_and_send": "保存并发送",
"test_email_sent_successfully": "测试电子邮件已成功发送到您的电子邮件地址。",
"failed_to_send_test_email": "发送测试电子邮件失败。请检查服务器日志以获取更多信息。",
"failed_to_send_test_email": "发送测试电子邮件失败。请检查服务器日志以获取详细信息。",
"smtp_configuration": "SMTP 配置",
"smtp_host": "SMTP 主机",
"smtp_port": "SMTP 端口",
@@ -178,23 +177,23 @@
"skip_certificate_verification": "跳过证书验证",
"this_can_be_useful_for_selfsigned_certificates": "这对于自签名证书很有用。",
"enabled_emails": "启用的电子邮件",
"email_login_notification": "电子邮件登录通知",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "用户新设备登录时,向其发送电子邮件。",
"email_login_notification": "登录时的电子邮件通知",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "用户通过新设备登录时发送一封电子邮件通知。",
"emai_login_code_requested_by_user": "用户请求的电子邮件登录代码",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "允许用户通过发送到其电子邮件的登录代码登录。这会显著降低安全性,因为任何有权访问用户电子邮件的人都可以进入。",
"email_login_code_from_admin": "管理员发送的电子邮件登录代码",
"allows_an_admin_to_send_a_login_code_to_the_user": "允许管理员通过电子邮件向用户发送登录代码。",
"send_test_email": "发送测试电子邮件",
"application_configuration_updated_successfully": "应用配置更新成功",
"application_configuration_updated_successfully": "已成功更新应用配置",
"application_name": "应用名称",
"session_duration": "会话持续时间",
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "用户需再次登录之前的会话持续时间(分钟)。",
"enable_self_account_editing": "启用自助账户编辑",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "用户是否能够编辑自己的账户详细信息。",
"session_duration": "会话时长",
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "用户需再次登录之前的会话时长(以分钟为单位)。",
"enable_self_account_editing": "启用用户自行编辑账户功能",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "用户是否能够编辑自己的账户详细信息。",
"emails_verified": "已验证的邮箱地址",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "用户的电子邮件是否应标记为已验证,适用于 OIDC 客户端。",
"ldap_configuration_updated_successfully": "LDAP 配置更新成功",
"ldap_disabled_successfully": "LDAP 禁用成功",
"ldap_disabled_successfully": "LDAP 已成功禁用",
"ldap_sync_finished": "LDAP 同步完成",
"client_configuration": "客户端配置",
"ldap_url": "LDAP URL",
@@ -202,9 +201,9 @@
"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_or_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_or_sync_groups": "用于搜索同步群组的筛选器。",
"attribute_mapping": "属性映射",
"user_unique_identifier_attribute": "User Unique Identifier Attribute",
"the_value_of_this_attribute_should_never_change": "此属性的值不应更改。",
@@ -213,26 +212,26 @@
"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": "此属性的值可以是 URL、二进制或 base64 编码的图像。",
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "此属性的值可以是 URL、二进制数据Base64 编码的图像。",
"group_members_attribute": "Group Members Attribute",
"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",
"admin_group_name": "管理员组名称",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "此群组的成员将在 Pocket ID 中拥有管理员权限。",
"disable": "禁用",
"sync_now": "立即同步",
"enable": "启用",
"user_created_successfully": "用户创建成功",
"user_created_successfully": "已成功创建用户",
"create_user": "创建用户",
"add_a_new_user_to_appname": " {appName} 添加新用户",
"add_a_new_user_to_appname": "将新用户添加到 {appName} ",
"add_user": "添加用户",
"manage_users": "管理用户",
"admin_privileges": "管理员权限",
"admins_have_full_access_to_the_admin_panel": "管理员拥有管理面板的完全访问权限。",
"delete_firstname_lastname": "删除 {firstName} {lastName}",
"delete_firstname_lastname": "删除 {lastName} {firstName}",
"are_you_sure_you_want_to_delete_this_user": "您确定要删除此用户吗?",
"user_deleted_successfully": "用户删除成功",
"user_deleted_successfully": "已成功删除用户",
"role": "角色",
"source": "来源",
"admin": "管理员",
@@ -244,7 +243,7 @@
"user_updated_successfully": "用户更新成功",
"custom_claims_updated_successfully": "自定义声明更新成功",
"back": "返回",
"user_details_firstname_lastname": "用户详情 {firstName} {lastName}",
"user_details_firstname_lastname": "用户详情 {lastName} {firstName}",
"manage_which_groups_this_user_belongs_to": "管理此用户所属的群组。",
"custom_claims": "自定义声明",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "自定义声明是键值对,可用于存储有关用户的额外信息。如果请求了 \"profile\" 范围,这些声明将包含在 ID Token 中。",
@@ -258,25 +257,25 @@
"name_that_will_be_in_the_groups_claim": "将在 \"groups\" 声明中显示的名称",
"delete_name": "删除 {name}",
"are_you_sure_you_want_to_delete_this_user_group": "您确定要删除此用户组吗?",
"user_group_deleted_successfully": "用户组删除成功",
"user_group_deleted_successfully": "已成功删除用户组",
"user_count": "用户数",
"user_group_updated_successfully": "用户组更新成功",
"users_updated_successfully": "用户更新成功",
"user_group_updated_successfully": "已成功更新用户组",
"users_updated_successfully": "已成功更新用户信息",
"user_group_details_name": "用户组详情 {name}",
"assign_users_to_this_group": "将用户分配到此群组。",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "自定义声明是键值对,可用于存储有关用户的额外信息。如果请求了 'profile' 范围,这些声明将包含在 ID 令牌中。如果存在冲突,用户上定义的自定义声明将优先。",
"oidc_client_created_successfully": "OIDC 客户端创建成功",
"oidc_client_created_successfully": "已成功创建 OIDC 客户端",
"create_oidc_client": "创建 OIDC 客户端",
"add_a_new_oidc_client_to_appname": "向 {appName} 添加新的 OIDC 客户端。",
"add_a_new_oidc_client_to_appname": "将新的 OIDC 客户端添加到 {appName}。",
"add_oidc_client": "添加 OIDC 客户端",
"manage_oidc_clients": "管理 OIDC 客户端",
"one_time_link": "一次性链接",
"use_this_link_to_sign_in_once": "使用此链接一次性登录。这对尚未添加通行密钥或丢失通行密钥的用户必要。",
"use_this_link_to_sign_in_once": "使用此链接进行一次性登录。这对尚未添加或丢失通行密钥的用户来说非常必要。",
"add": "添加",
"callback_urls": "Callback URL",
"logout_callback_urls": "Logout Callback URL",
"public_client": "公共客户端",
"public_clients_description": "公共客户端没有客户端密钥,而是使用 PKCE。如果您的客户端是 SPA 或移动应用,请启用此选项。",
"public_clients_description": "公共客户端没有客户端密钥。它们用于无法安全存储密钥的移动端、Web端和原生应用程序。",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "公钥代码交换是一种安全功能,可防止 CSRF 和授权代码拦截攻击。",
"name_logo": "{name} Logo",
@@ -298,7 +297,7 @@
"are_you_sure_you_want_to_create_a_new_client_secret": "您确定要创建新的客户端密钥吗?旧的密钥将被失效。",
"generate": "生成",
"new_client_secret_created_successfully": "新客户端密钥创建成功",
"allowed_user_groups_updated_successfully": "允许的用户组更新成功",
"allowed_user_groups_updated_successfully": "已成功更新允许的用户组",
"oidc_client_name": "OIDC 客户端 {name}",
"client_id": "客户端 ID",
"client_secret": "客户端密钥",
@@ -311,9 +310,9 @@
"background_image": "背景图片",
"language": "语言",
"reset_profile_picture_question": "重置头像?",
"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": "这将除已上传的图片,并将头像重置为默认。您确定要继续",
"reset": "重置",
"reset_to_default": "重置为默认",
"reset_to_default": "恢复默认设置",
"profile_picture_has_been_reset": "头像已重置。可能需要几分钟才能更新。",
"select_the_language_you_want_to_use": "选择您要使用的语言。某些语言可能未完全翻译。",
"personal": "个人",
@@ -326,21 +325,58 @@
"token_sign_in": "Token 登录",
"client_authorization": "客户端授权",
"new_client_authorization": "首次客户端授权",
"disable_animations": "禁用动画",
"turn_off_all_animations_throughout_the_admin_ui": "关闭管理用户界面中的所有动画。",
"disable_animations": "关闭动画",
"turn_off_ui_animations": "关闭界面中的所有动画效果。",
"user_disabled": "账户已禁用",
"disabled_users_cannot_log_in_or_use_services": "禁用的用户无法登录或使用服务。",
"user_disabled_successfully": "用户已成功禁用。",
"user_enabled_successfully": "用户已成功启用。",
"status": "状态",
"disable_firstname_lastname": "禁用 {firstName} {lastName}",
"disable_firstname_lastname": "禁用 {lastName} {firstName}",
"are_you_sure_you_want_to_disable_this_user": "您确定要禁用此用户吗?他们将无法登录或访问任何服务。",
"ldap_soft_delete_users": "保留来自 LDAP 禁用用户。",
"ldap_soft_delete_users_description": "启用后,从 LDAP 中移除的用户将禁用,而不从系统中删除。",
"ldap_soft_delete_users": "保留 LDAP 中被禁用用户。",
"ldap_soft_delete_users_description": "启用后,从 LDAP 中移除的用户将会被标记为禁用,而不从系统中删除。",
"login_code_email_success": "登录代码已发送给用户。",
"send_email": "发送电子邮件",
"show_code": "显示登录码",
"callback_url_description": "由您的客户端提供的 URL。支持通配符 (*),但为了更好的安全性最好避免使用。",
"logout_callback_url_description": "注销功能中由您客户端配置的URL地址。支持通配符 (*),但出于安全考虑,强烈建议避免使用通配符。",
"api_key_expiration": "API 密钥过期",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "当用户的 API 密钥即将过期时,向其发送电子邮件。"
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "当用户的 API 密钥即将过期时,发送电子邮件通知用户。",
"authorize_device": "授权设备",
"the_device_has_been_authorized": "设备已授权",
"enter_code_displayed_in_previous_step": "输入在上一步中显示的代码",
"authorize": "授权",
"federated_client_credentials": "联合身份",
"federated_client_credentials_description": "您可以使用联合身份,通过第三方授权机构签发的 JWT 令牌,对 OIDC 客户端进行认证。",
"add_federated_client_credential": "添加联合身份",
"add_another_federated_client_credential": "添加另一个联合身份",
"oidc_allowed_group_count": "允许的群组数量",
"unrestricted": "不受限制",
"show_advanced_options": "显示高级选项",
"hide_advanced_options": "隐藏高级选项",
"oidc_data_preview": "OIDC 数据预览",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "预览将发送给不同用户的 OIDC 数据",
"id_token": "ID Token",
"access_token": "Access Token",
"userinfo": "Userinfo",
"id_token_payload": "ID Token 有效载载",
"access_token_payload": "Access Token 有效载载",
"userinfo_endpoint_response": "Userinfo 端点响应",
"copy": "复制",
"no_preview_data_available": "暂无可用的预览数据",
"copy_all": "全部复制",
"preview": "预览",
"preview_for_user": "为 {name} ({email}) 预览",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "预览将为此用户发送的 OIDC 数据",
"show": "显示",
"select_an_option": "请选择",
"select_user": "选择用户",
"error": "错误",
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Select an accent color to customize the appearance of Pocket ID.",
"accent_color": "Accent Color",
"custom_accent_color": "Custom Accent Color",
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).",
"color_value": "Color Value",
"apply": "Apply"
}

View File

@@ -0,0 +1,382 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"my_account": "我的帳號",
"logout": "登出",
"confirm": "確認",
"docs": "Docs",
"key": "Key",
"value": "Value",
"remove_custom_claim": "移除自定義 claim",
"add_custom_claim": "添加自定義 claim",
"add_another": "新增另一個",
"select_a_date": "選擇日期",
"select_file": "選擇檔案",
"profile_picture": "個人資料圖片",
"profile_picture_is_managed_by_ldap_server": "這張個人資料圖片是由 LDAP 伺服器管理,無法在此變更。",
"click_profile_picture_to_upload_custom": "點擊個人資料圖片,從您的檔案中上傳自訂圖片。",
"image_should_be_in_format": "圖片應為 PNG 或 JPEG 格式。",
"items_per_page": "每頁項目數",
"no_items_found": "找不到任何項目",
"search": "搜尋...",
"expand_card": "展開卡片",
"copied": "已複製",
"click_to_copy": "點擊以複製",
"something_went_wrong": "出了點問題",
"go_back_to_home": "返回首頁",
"dont_have_access_to_your_passkey": "無法存取您的密碼金鑰嗎?",
"login_background": "登入背景",
"logo": "標誌",
"login_code": "登入代碼",
"create_a_login_code_to_sign_in_without_a_passkey_once": "建立一個登入代碼,讓使用者可以在不使用密碼金鑰的情況下登入一次。",
"one_hour": "1 小時",
"twelve_hours": "12 小時",
"one_day": "1 天",
"one_week": "1 周",
"one_month": "1 個月",
"expiration": "到期時間",
"generate_code": "產生代碼",
"name": "名稱",
"browser_unsupported": "瀏覽器不支援",
"this_browser_does_not_support_passkeys": "此瀏覽器不支援密碼金鑰。請使用其他登入方式。",
"an_unknown_error_occurred": "發生未知的錯誤",
"authentication_process_was_aborted": "驗證程序已中止",
"error_occurred_with_authenticator": "驗證器發生錯誤",
"authenticator_does_not_support_discoverable_credentials": "此驗證器不支援可被發現的憑證",
"authenticator_does_not_support_resident_keys": "此驗證器不支援常駐金鑰",
"passkey_was_previously_registered": "這個密碼金鑰先前已註冊",
"authenticator_does_not_support_any_of_the_requested_algorithms": "驗證器不支援任何一種所要求的演算法",
"authenticator_timed_out": "驗證器逾時",
"critical_error_occurred_contact_administrator": "發生嚴重錯誤,請聯絡您的管理員。",
"sign_in_to": "登入 {name}",
"client_not_found": "找不到客戶端",
"client_wants_to_access_the_following_information": "<b>{client}</b> 想要存取下列資訊:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "您想要使用您的 {appName} 帳號登入至 <b>{client}</b> 嗎?",
"email": "電子郵件",
"view_your_email_address": "檢視您的電子郵件地址",
"profile": "個人資料",
"view_your_profile_information": "檢視您的個人資料",
"groups": "群組",
"view_the_groups_you_are_a_member_of": "檢視您所屬的群組",
"cancel": "取消",
"sign_in": "登入",
"try_again": "再試一次",
"client_logo": "客戶端圖標",
"sign_out": "登出",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "您確定要使用帳號 <b>{username}</b> 登出 {appName} 嗎?",
"sign_in_to_appname": "登入 {appName}",
"please_try_to_sign_in_again": "請嘗試重新登入。",
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "請使用您的密碼金鑰進行驗證以存取管理面板。",
"authenticate": "驗證",
"appname_setup": "{appName} 設定",
"please_try_again": "請再試一次。",
"you_are_about_to_sign_in_to_the_initial_admin_account": "您即將登入初始管理員帳號。在新增密碼金鑰之前,任何擁有此連結的人都可以存取該帳號。為避免未經授權的存取,請儘快設定密碼金鑰。",
"continue": "繼續",
"alternative_sign_in": "替代登入方式",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "如果您無法使用您的密碼金鑰,可以改用下列其中一種方式登入。",
"use_your_passkey_instead": "改為使用您的密碼金鑰?",
"email_login": "電子郵件登入",
"enter_a_login_code_to_sign_in": "輸入登入代碼以登入。",
"request_a_login_code_via_email": "透過電子郵件取得登入代碼。",
"go_back": "返回",
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "如果該電子郵件地址存在於系統中,我們會發送信件至您所提供的電子信箱。",
"enter_code": "輸入代碼",
"enter_your_email_address_to_receive_an_email_with_a_login_code": "請輸入您的電子郵件地址以接收登入代碼。",
"your_email": "您的電子信箱",
"submit": "送出",
"enter_the_code_you_received_to_sign_in": "輸入您收到的代碼以登入。",
"code": "代碼",
"invalid_redirect_url": "無效的重新導向 URL",
"audit_log": "稽核日誌",
"users": "使用者",
"user_groups": "使用者群組",
"oidc_clients": "OIDC 客戶端",
"api_keys": "API 金鑰",
"application_configuration": "應用程式設定",
"settings": "設定",
"update_pocket_id": "更新 Pocket ID",
"powered_by": "技術支援",
"see_your_account_activities_from_the_last_3_months": "查看您過去 3 個月的帳戶活動。",
"time": "時間",
"event": "事件",
"approximate_location": "概略位置",
"ip_address": "IP 地址",
"device": "裝置",
"client": "客戶端",
"unknown": "未知",
"account_details_updated_successfully": "帳號資訊更新成功",
"profile_picture_updated_successfully": "個人資料圖片更新成功。 這可能會花幾分鐘更新。",
"account_settings": "帳戶設定",
"passkey_missing": "沒有密碼金鑰",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "請新增密碼金鑰以避免日後無法存取您的帳戶。",
"single_passkey_configured": "已設定一組密碼金鑰",
"it_is_recommended_to_add_more_than_one_passkey": "建議您新增多組密碼金鑰,以避免日後無法存取帳戶。",
"account_details": "帳戶詳細資料",
"passkeys": "密碼金鑰",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "管理可用於驗證身分的密碼金鑰。",
"add_passkey": "新增密碼金鑰",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "建立一次性登入代碼,以便從其他裝置登入而不需使用密碼金鑰。",
"create": "建立",
"first_name": "名字",
"last_name": "姓氏",
"username": "使用者名稱",
"save": "儲存",
"username_can_only_contain": "使用者名稱僅能包含小寫英文字母、數字、底線_、句點.)、連字號(-)與 @ 符號",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "使用以下代碼登入。 這個代碼將於 15 分鐘後到期。",
"or_visit": "或造訪",
"added_on": "新增於",
"rename": "重新命名",
"delete": "刪除",
"are_you_sure_you_want_to_delete_this_passkey": "您確定您要刪除此密碼金鑰嗎?",
"passkey_deleted_successfully": "密碼金鑰刪除成功",
"delete_passkey_name": "刪除 {passkeyName}",
"passkey_name_updated_successfully": "密碼金鑰名稱更新成功",
"name_passkey": "命名密碼金鑰",
"name_your_passkey_to_easily_identify_it_later": "命名您的密碼金鑰以便日後辨識。",
"create_api_key": "建立 API 金鑰",
"add_a_new_api_key_for_programmatic_access": "新增 API 金鑰以供程式化存取。",
"add_api_key": "新增 API 金鑰",
"manage_api_keys": "管理 API 金鑰",
"api_key_created": "已建立 API 金鑰",
"for_security_reasons_this_key_will_only_be_shown_once": "為了安全起見,此金鑰僅會顯示一次。請妥善保存。",
"description": "描述",
"api_key": "API 金鑰",
"close": "關閉",
"name_to_identify_this_api_key": "辨識此 API 金鑰的名稱。",
"expires_at": "到期於",
"when_this_api_key_will_expire": "此 API 金鑰何時到期。",
"optional_description_to_help_identify_this_keys_purpose": "可選填描述以說明此金鑰用途。",
"expiration_date_must_be_in_the_future": "到期日期必須位於未來",
"revoke_api_key": "撤銷 API 金鑰",
"never": "從未",
"revoke": "撤銷",
"api_key_revoked_successfully": "API 金鑰撤銷成功",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "您確定要撤銷 API 金鑰「{apiKeyName}」嗎?這將會中斷所有使用此金鑰的整合。",
"last_used": "上次使用",
"actions": "操作",
"images_updated_successfully": "圖片更新成功",
"general": "一般",
"configure_smtp_to_send_emails": "啟用電子郵件通知以提醒使用者有來自新裝置或位置的登入。",
"ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "設定 LDAP 以從 LDAP 伺服器同步使用者與群組。",
"images": "圖像",
"update": "更新",
"email_configuration_updated_successfully": "電子郵件設定更新完成",
"save_changes_question": "是否儲存更改?",
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "您必須先儲存變更才能傳送測試郵件,是否立即儲存?",
"save_and_send": "儲存並傳送",
"test_email_sent_successfully": "測試信件已成功發送至您的電子郵件。",
"failed_to_send_test_email": "測試信件傳送失敗,請查看伺服器日誌以了解詳情。",
"smtp_configuration": "SMTP 設定",
"smtp_host": "SMTP 主機",
"smtp_port": "SMTP 連接埠",
"smtp_user": "SMTP 使用者",
"smtp_password": "SMTP 密碼",
"smtp_from": "SMTP 發送者",
"smtp_tls_option": "SMTP TLS 選項",
"email_tls_option": "Email TLS 選項",
"skip_certificate_verification": "略過憑證檢查",
"this_can_be_useful_for_selfsigned_certificates": "這在使用自簽憑證時可能會很有用。",
"enabled_emails": "啟用電子郵件",
"email_login_notification": "電子郵件登入通知",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "使用者從新裝置登入時寄送電子郵件通知。",
"emai_login_code_requested_by_user": "使用者請求電子郵件登入代碼",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This significantly reduces security as anyone with access to the user's email can gain entry.",
"email_login_code_from_admin": "來自管理員的使用者登入代碼",
"allows_an_admin_to_send_a_login_code_to_the_user": "允許管理員透過電子郵件向使用者發送登入代碼。",
"send_test_email": "發送測試郵件",
"application_configuration_updated_successfully": "應用程式設定更新成功",
"application_name": "應用程式名稱",
"session_duration": "登入階段有效時長",
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "使用者需重新登入前的階段時長(以分鐘為單位)。",
"enable_self_account_editing": "允許使用者自行編輯帳戶資訊",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "是否允許使用者編輯自己的帳號資料。",
"emails_verified": "已驗證的電子郵件",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "是否應將使用者的電子郵件標記為已驗證,以供 OIDC 客戶端使用。",
"ldap_configuration_updated_successfully": "LDAP 設定更新成功",
"ldap_disabled_successfully": "LDAP 已成功停用",
"ldap_sync_finished": "LDAP 同步完成",
"client_configuration": "客戶端設定",
"ldap_url": "LDAP 網址",
"ldap_bind_dn": "LDAP Bind DN",
"ldap_bind_password": "LDAP Bind 密碼",
"ldap_base_dn": "LDAP Base DN",
"user_search_filter": "使用者搜尋篩選器",
"the_search_filter_to_use_to_search_or_sync_users": "使用搜尋篩選器以搜尋/同步使用者。",
"groups_search_filter": "群組搜尋篩選器",
"the_search_filter_to_use_to_search_or_sync_groups": "使用搜尋篩選器以搜尋/同步群組。",
"attribute_mapping": "屬性對應",
"user_unique_identifier_attribute": "使用者唯一識別屬性",
"the_value_of_this_attribute_should_never_change": "此屬性的值不應被變更。",
"username_attribute": "使用者名稱屬性",
"user_mail_attribute": "使用者電子郵件屬性",
"user_first_name_attribute": "使用者名字屬性",
"user_last_name_attribute": "使用者姓氏屬性",
"user_profile_picture_attribute": "使用者個人資料圖片屬性",
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "此屬性的值可以是 URL、二進位資料或 base64 編碼的圖片。",
"group_members_attribute": "群組成員屬性",
"the_attribute_to_use_for_querying_members_of_a_group": "用於查詢群組成員的屬性。",
"group_unique_identifier_attribute": "群組唯一識別屬性",
"group_name_attribute": "群組名稱屬性",
"admin_group_name": "管理員群組名稱",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "此群組的成員將擁有 Pocket ID 的管理權限。",
"disable": "停用",
"sync_now": "立即同步",
"enable": "啟用",
"user_created_successfully": "使用者建立成功",
"create_user": "建立使用者",
"add_a_new_user_to_appname": "新增新使用者至 {appName}",
"add_user": "新增使用者",
"manage_users": "管理使用者",
"admin_privileges": "管理員權限",
"admins_have_full_access_to_the_admin_panel": "管理員擁有存取管理面板的權限。",
"delete_firstname_lastname": "刪除 {lastName} {firstName}",
"are_you_sure_you_want_to_delete_this_user": "您確定要刪除該使用者嗎?",
"user_deleted_successfully": "使用者刪除成功",
"role": "角色",
"source": "來源",
"admin": "管理員",
"user": "使用者",
"local": "本地",
"toggle_menu": "切換選單",
"edit": "編輯",
"user_groups_updated_successfully": "使用者群組更新成功",
"user_updated_successfully": "使用者更新成功",
"custom_claims_updated_successfully": "自定義 claims 更新成功",
"back": "返回",
"user_details_firstname_lastname": "使用者詳細資料 {lastName} {firstName}",
"manage_which_groups_this_user_belongs_to": "管理此使用者所屬的群組。",
"custom_claims": "自定義 Claims",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "自定義宣告 (claim) 是可用來儲存使用者額外資訊的鍵值對。若請求的範圍中包含 'profile',這些宣告將會被加入至 ID token 中。",
"user_group_created_successfully": "使用者群組建立成功",
"create_user_group": "建立使用者群組",
"create_a_new_group_that_can_be_assigned_to_users": "建立可指派給使用者的新群組。",
"add_group": "新增群組",
"manage_user_groups": "管理使用者群組",
"friendly_name": "易記名稱",
"name_that_will_be_displayed_in_the_ui": "會顯示在 UI 的名稱",
"name_that_will_be_in_the_groups_claim": "會顯示在 \"groups\" claim 的名稱",
"delete_name": "刪除 {name}",
"are_you_sure_you_want_to_delete_this_user_group": "您確定要刪除該使用者群組嗎?",
"user_group_deleted_successfully": "使用者群組刪除成功",
"user_count": "使用者數量",
"user_group_updated_successfully": "使用者群組更新成功",
"users_updated_successfully": "使用者更新成功",
"user_group_details_name": "使用者群組詳細資料 {name}",
"assign_users_to_this_group": "指派使用者至此群組。",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "自定義宣告 (claim) 是可用來儲存使用者額外資訊的鍵值對。若請求的範圍中包含 'profile',這些宣告將會被加入至 ID token 中。若宣告有衝突,將優先使用定義於使用者上的自定義宣告。",
"oidc_client_created_successfully": "OIDC 客戶端建立成功",
"create_oidc_client": "建立 OIDC 客戶端",
"add_a_new_oidc_client_to_appname": "建立新 OIDC 客戶端至 {appName}。",
"add_oidc_client": "新增 OIDC 客戶端",
"manage_oidc_clients": "管理 OIDC 客戶端",
"one_time_link": "一次性連結",
"use_this_link_to_sign_in_once": "使用此連結可進行一次性登入。適用於尚未新增密碼金鑰或已遺失密碼金鑰的使用者。",
"add": "新增",
"callback_urls": "Callback URLs",
"logout_callback_urls": "登出 Callback URLs",
"public_client": "公開客戶端",
"public_clients_description": "公開客戶端 (Public Client) 不包含 client secret。這類客戶端是為了行動裝置、網頁以及無法安全儲存 secret 的原生應用程式所設計。",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "PKCE公開金鑰碼交換是一項安全機制用於防止 CSRF 與授權碼攔截攻擊。",
"name_logo": "{name} 標誌",
"change_logo": "更改標誌",
"upload_logo": "上傳標誌",
"remove_logo": "移除標誌",
"are_you_sure_you_want_to_delete_this_oidc_client": "您確定要刪除這個 OIDC 客戶端嗎?",
"oidc_client_deleted_successfully": "OIDC 客戶端刪除成功",
"authorization_url": "Authorization URL",
"oidc_discovery_url": "OIDC Discovery URL",
"token_url": "Token URL",
"userinfo_url": "Userinfo URL",
"logout_url": "登出 URL",
"certificate_url": "Certificate URL",
"enabled": "啟用",
"disabled": "停用",
"oidc_client_updated_successfully": "OIDC 客戶端更新成功",
"create_new_client_secret": "建立新 client secret",
"are_you_sure_you_want_to_create_a_new_client_secret": "確定要建立新的 client secret 嗎?舊的將會失效。",
"generate": "產生",
"new_client_secret_created_successfully": "新的 client secret 建立成功",
"allowed_user_groups_updated_successfully": "允許的使用者群組已成功更新",
"oidc_client_name": "OIDC 客戶端 {name}",
"client_id": "Client ID",
"client_secret": "Client secret",
"show_more_details": "顯示更多資訊",
"allowed_user_groups": "允許的使用者群組",
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "將使用者群組新增至此客戶端,以限制只有這些群組中的使用者可以存取。若未選擇任何群組,所有使用者都將能存取此客戶端。",
"favicon": "Favicon",
"light_mode_logo": "亮色模式標誌",
"dark_mode_logo": "暗色模式標誌",
"background_image": "背景圖片",
"language": "語言",
"reset_profile_picture_question": "重設個人資料圖片?",
"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_to_default": "重設至預設值",
"profile_picture_has_been_reset": "個人資料圖片已經重設。 這可能會花幾分鐘更新。",
"select_the_language_you_want_to_use": "請選擇您想使用的語言,部分語言可能尚未完整翻譯。",
"personal": "個人",
"global": "全域",
"all_users": "所有使用者",
"all_events": "所有事件",
"all_clients": "所有客戶端",
"global_audit_log": "全域稽核日誌",
"see_all_account_activities_from_the_last_3_months": "查看過去 3 個月的所有使用者活動。",
"token_sign_in": "Token 登入",
"client_authorization": "客戶端授權",
"new_client_authorization": "新客戶端授權",
"disable_animations": "停用動畫",
"turn_off_ui_animations": "Turn off animations throughout the UI.",
"user_disabled": "帳戶已停用",
"disabled_users_cannot_log_in_or_use_services": "已停用的使用者不能登入或使用服務。",
"user_disabled_successfully": "使用者已成功停用。",
"user_enabled_successfully": "使用者已成功啟用。",
"status": "狀態",
"disable_firstname_lastname": "停用 {lastName} {firstName}",
"are_you_sure_you_want_to_disable_this_user": "您確定要停用此使用者嗎?他們將無法登入或使用任何服務。",
"ldap_soft_delete_users": "保留來自 LDAP 的停用使用者。",
"ldap_soft_delete_users_description": "啟用此選項後,從 LDAP 移除的使用者將會被停用,而不是從系統中刪除。",
"login_code_email_success": "登入代碼已傳送給使用者。",
"send_email": "發送郵件",
"show_code": "顯示代碼",
"callback_url_description": "由客戶端提供的 URL。如留空系統將自動填入。支援萬用字元 (*),但為了安全性建議避免使用。",
"logout_callback_url_description": "由客戶端提供的 URL。支援萬用字元 (*),但為了安全性建議避免使用。",
"api_key_expiration": "API 金鑰到期通知",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "當使用者的 API 金鑰即將到期時,發送電子郵件通知。",
"authorize_device": "授權裝置",
"the_device_has_been_authorized": "裝置已獲授權。",
"enter_code_displayed_in_previous_step": "請輸入上一步顯示的代碼。",
"authorize": "授權",
"federated_client_credentials": "聯邦身分",
"federated_client_credentials_description": "使用聯邦身分,您可以透過由第三方授權機構簽發的 JWT 權杖來驗證 OIDC 客戶端。",
"add_federated_client_credential": "增加聯邦身分",
"add_another_federated_client_credential": "新增另一組聯邦身分",
"oidc_allowed_group_count": "允許的群組數量",
"unrestricted": "未受限制",
"show_advanced_options": "顯示進階選項",
"hide_advanced_options": "隱藏進階選項",
"oidc_data_preview": "OIDC Data Preview",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
"id_token": "ID Token",
"access_token": "Access Token",
"userinfo": "Userinfo",
"id_token_payload": "ID Token Payload",
"access_token_payload": "Access Token Payload",
"userinfo_endpoint_response": "Userinfo Endpoint Response",
"copy": "Copy",
"no_preview_data_available": "No preview data available",
"copy_all": "Copy All",
"preview": "Preview",
"preview_for_user": "Preview for {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
"show": "Show",
"select_an_option": "Select an option",
"select_user": "Select User",
"error": "Error",
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Select an accent color to customize the appearance of Pocket ID.",
"accent_color": "Accent Color",
"custom_accent_color": "Custom Accent Color",
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).",
"color_value": "Color Value",
"apply": "Apply"
}

View File

@@ -1,30 +1,29 @@
{
"name": "pocket-id-frontend",
"version": "0.53.0",
"version": "1.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pocket-id-frontend",
"version": "0.53.0",
"version": "1.3.1",
"dependencies": {
"@lucide/svelte": "^0.511.0",
"@simplewebauthn/browser": "^13.1.0",
"@tailwindcss/vite": "^4.1.7",
"axios": "^1.8.2",
"clsx": "^2.1.1",
"crypto": "^1.0.1",
"jose": "^5.9.6",
"qrcode": "^1.5.4",
"sveltekit-superforms": "^2.23.1",
"tailwind-merge": "^3.3.0",
"zod": "^3.24.1"
"zod": "^3.25.55"
},
"devDependencies": {
"@inlang/paraglide-js": "^2.0.0",
"@inlang/plugin-m-function-matcher": "^2.0.7",
"@inlang/paraglide-js": "^2.0.13",
"@inlang/plugin-m-function-matcher": "^2.0.10",
"@inlang/plugin-message-format": "^4.0.0",
"@internationalized/date": "^3.7.0",
"@internationalized/date": "^3.8.2",
"@lucide/svelte": "^0.513.0",
"@playwright/test": "^1.50.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.20.7",
@@ -32,7 +31,7 @@
"@types/eslint": "^9.6.1",
"@types/node": "^22.10.10",
"@types/qrcode": "^1.5.5",
"bits-ui": "^1.5.3",
"bits-ui": "^2.5.0",
"eslint": "^9.19.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^2.46.1",
@@ -632,21 +631,23 @@
"optional": true
},
"node_modules/@floating-ui/core": {
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz",
"integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==",
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.1.tgz",
"integrity": "sha512-azI0DrjMMfIug/ExbBaeDVJXcY0a7EPvPjb2xAJPa4HeimBX+Z18HK8QQR3jb6356SnDDdxx+hinMLcJEDdOjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.6.13",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz",
"integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==",
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.1.tgz",
"integrity": "sha512-cwsmW/zyw5ltYTUeeYJ60CnQuPqmGwuGVhG9w0PRaRKkAyi38BT5CKrpIbb+jtahSwUl04cWzSx9ZOIxeS6RsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/core": "^1.7.1",
"@floating-ui/utils": "^0.2.9"
}
},
@@ -654,7 +655,8 @@
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/@gcornut/valibot-json-schema": {
"version": "0.31.0",
@@ -756,14 +758,14 @@
}
},
"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==",
"version": "2.0.13",
"resolved": "https://registry.npmjs.org/@inlang/paraglide-js/-/paraglide-js-2.0.13.tgz",
"integrity": "sha512-8tccsLzGa9uw0rufFqbHSM6GDF8+X1BgfBOyjG7PweBF2zGhN5fMu/nVNbsZiVKpXyR7lcfMxajIBwKhZ/zGKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@inlang/recommend-sherlock": "0.2.1",
"@inlang/sdk": "2.4.2",
"@inlang/sdk": "2.4.8",
"commander": "11.1.0",
"consola": "3.4.0",
"json5": "2.2.3",
@@ -774,56 +776,13 @@
"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/plugin-m-function-matcher": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@inlang/plugin-m-function-matcher/-/plugin-m-function-matcher-2.0.7.tgz",
"integrity": "sha512-o3xGL4BTWOcM/j2WvBcLNHqkHWKWOKdwQED5x3j6+NeFmbkaEioOTPo5FFWZUeWpNnUMn6aJnmfnLLUomO1Jug==",
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@inlang/plugin-m-function-matcher/-/plugin-m-function-matcher-2.0.10.tgz",
"integrity": "sha512-9k/zIebaDNkay4P3cktgliIJ6SOMvLDJFIgwcEIMo6Z2CxE71fqiDPCvTZ7Mm0iXBzPtXYU8R1TlE9eR/ri5Hw==",
"dev": true,
"dependencies": {
"@inlang/sdk": "2.4.5"
"@inlang/sdk": "2.4.8"
}
},
"node_modules/@inlang/plugin-message-format": {
@@ -846,13 +805,13 @@
}
},
"node_modules/@inlang/sdk": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/@inlang/sdk/-/sdk-2.4.5.tgz",
"integrity": "sha512-3zlc2llEQGeQALSEz5sZ9MdqDpFiZCxwgqNtt5QA46KD7DIp2bh7VD5kmUKifyNzDxiIk1r4liAxIgCvgC2m5w==",
"version": "2.4.8",
"resolved": "https://registry.npmjs.org/@inlang/sdk/-/sdk-2.4.8.tgz",
"integrity": "sha512-tyXNe/5+1Vn/eDt3mVklVjZh5qxFwqdF9+hdB6wRUCexVRw6w/w854TIRFrHuaAwFq/0N/ij/yXzll9oScAB+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lix-js/sdk": "0.4.5",
"@lix-js/sdk": "0.4.7",
"@sinclair/typebox": "^0.31.17",
"kysely": "^0.27.4",
"sqlite-wasm-kysely": "0.3.0",
@@ -870,10 +829,11 @@
"license": "MIT"
},
"node_modules/@internationalized/date": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.7.0.tgz",
"integrity": "sha512-VJ5WS3fcVx0bejE/YHfbDKR/yawZgKqn/if+oEeLqNwBtPzVB06olkfcnojTmEMX+gTpH+FlQ69SHNitJ8/erQ==",
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.8.2.tgz",
"integrity": "sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
@@ -934,9 +894,9 @@
}
},
"node_modules/@lix-js/sdk": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/@lix-js/sdk/-/sdk-0.4.5.tgz",
"integrity": "sha512-H0bu99QlzYArFtyV+5aKHGfgjAvtUYMxatQVXFddG0q+I3GtjR4PyNAjQdh0zeTnMJkSXWo2giSsQpXpFBz4Dw==",
"version": "0.4.7",
"resolved": "https://registry.npmjs.org/@lix-js/sdk/-/sdk-0.4.7.tgz",
"integrity": "sha512-pRbW+joG12L0ULfMiWYosIW0plmW4AsUdiPCp+Z8rAsElJ+wJ6in58zhD3UwUcd4BNcpldEGjg6PdA7e0RgsDQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -952,13 +912,6 @@
"node": ">=18"
}
},
"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/@lix-js/server-protocol-schema": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@lix-js/server-protocol-schema/-/server-protocol-schema-0.1.1.tgz",
@@ -967,9 +920,10 @@
"license": "Apache-2.0"
},
"node_modules/@lucide/svelte": {
"version": "0.511.0",
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-0.511.0.tgz",
"integrity": "sha512-aLCSPMUJmHlCuLXzXENXa4Z1NV2mN1iAZAFKk4bEbey+/MdsNlu+/DqwVkgW3Yvj6p8y8Vn5xZ2v9CLmPlA6Vw==",
"version": "0.513.0",
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-0.513.0.tgz",
"integrity": "sha512-XwBQMQkMlr9qp9yVg+epx5MzhBBrqul8atO00y/ZfhlKRJuQZVmq3ELibApqyBtj9ys0Ai4FH/SZcODTUFYXig==",
"dev": true,
"license": "ISC",
"peerDependencies": {
"svelte": "^5"
@@ -2124,40 +2078,42 @@
"dev": true
},
"node_modules/bits-ui": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-1.5.3.tgz",
"integrity": "sha512-BTZ9/GU11DaEGyQp+AY+sXCMLZO0gbDC5J8l7+Ngj4Vf6hNOwrpMmoh5iuKktA6cphXYolVkUDgBWmkh415I+w==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.5.0.tgz",
"integrity": "sha512-PbjylA1UWd4A/c5AYqie/EVxQ1/8uugmJKLg9whLoBBHbfPEBGhK09dCPrahK9kA6DRHhMmij0XXIUGIfrmNow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.6.4",
"@floating-ui/dom": "^1.6.7",
"@internationalized/date": "^3.5.6",
"@floating-ui/core": "^1.7.0",
"@floating-ui/dom": "^1.7.0",
"css.escape": "^1.5.1",
"esm-env": "^1.1.2",
"runed": "^0.23.2",
"svelte-toolbelt": "^0.7.1",
"runed": "^0.28.0",
"svelte-toolbelt": "^0.9.1",
"tabbable": "^6.2.0"
},
"engines": {
"node": ">=18",
"node": ">=20",
"pnpm": ">=8.7.0"
},
"funding": {
"url": "https://github.com/sponsors/huntabyte"
},
"peerDependencies": {
"svelte": "^5.11.0"
"@internationalized/date": "^3.8.1",
"svelte": "^5.33.0"
}
},
"node_modules/bits-ui/node_modules/runed": {
"version": "0.23.4",
"resolved": "https://registry.npmjs.org/runed/-/runed-0.23.4.tgz",
"integrity": "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/runed/-/runed-0.28.0.tgz",
"integrity": "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/huntabyte",
"https://github.com/sponsors/tglide"
],
"license": "MIT",
"dependencies": {
"esm-env": "^1.0.0"
},
@@ -2165,6 +2121,27 @@
"svelte": "^5.7.0"
}
},
"node_modules/bits-ui/node_modules/svelte-toolbelt": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.9.1.tgz",
"integrity": "sha512-wBX6MtYw/kpht80j5zLpxJyR9soLizXPIAIWEVd9llAi17SR44ZdG291bldjB7r/K5duC0opDFcuhk2cA1hb8g==",
"dev": true,
"funding": [
"https://github.com/sponsors/huntabyte"
],
"dependencies": {
"clsx": "^2.1.1",
"runed": "^0.28.0",
"style-to-object": "^1.0.8"
},
"engines": {
"node": ">=18",
"pnpm": ">=8.7.0"
},
"peerDependencies": {
"svelte": "^5.30.2"
}
},
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -2384,11 +2361,12 @@
"node": ">= 8"
}
},
"node_modules/crypto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz",
"integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==",
"deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in."
"node_modules/css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
"dev": true,
"license": "MIT"
},
"node_modules/cssesc": {
"version": "3.0.0",
@@ -2795,9 +2773,9 @@
}
},
"node_modules/esrap": {
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-1.4.6.tgz",
"integrity": "sha512-F/D2mADJ9SHY3IwksD4DAXjTt7qt7GWUf3/8RhCNWmC/67tyb55dpimHmy7EplakFaflV0R/PC+fdSPqrRHAQw==",
"version": "1.4.9",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-1.4.9.tgz",
"integrity": "sha512-3OMlcd0a03UGuZpPeUC1HxR3nA23l+HEyCiZw3b3FumJIN9KphoGzDJKMXI1S72jVS1dsenDyQC0kJlO1U9E1g==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
@@ -3281,9 +3259,9 @@
}
},
"node_modules/js-sha256": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.0.tgz",
"integrity": "sha512-6xNlKayMZvds9h1Y1VWc0fQHQ82BxTXizWPEtEeGvmOUYpBRy4gbWroHLpzowe6xiQhHpelCQiE7HEdznyBL9Q==",
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.1.tgz",
"integrity": "sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==",
"dev": true,
"license": "MIT"
},
@@ -4623,9 +4601,9 @@
}
},
"node_modules/svelte": {
"version": "5.31.1",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.31.1.tgz",
"integrity": "sha512-09fup3U7NQobUCUJnLhed6pxG6MzUS8rPsALB5Jr8m8u3pVKITs0ejYiKS/wsVjfkXHvKc2g260KA8o7dWypHA==",
"version": "5.33.18",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.33.18.tgz",
"integrity": "sha512-GVAhi8vi8pGne/wlEdnfWIJvSR9eKvEknxjfL5Sr8gQALiyk8Ey+H0lhUYLpjW+MrqgH9h4dgh2NF6/BTFprRg==",
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.3.0",
@@ -4637,7 +4615,7 @@
"axobject-query": "^4.1.0",
"clsx": "^2.1.1",
"esm-env": "^1.2.1",
"esrap": "^1.4.6",
"esrap": "^1.4.8",
"is-reference": "^3.0.3",
"locate-character": "^3.0.0",
"magic-string": "^0.30.11",
@@ -5508,9 +5486,10 @@
"integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w=="
},
"node_modules/zod": {
"version": "3.24.1",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz",
"integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==",
"version": "3.25.55",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.55.tgz",
"integrity": "sha512-219huNnkSLQnLsQ3uaRjXsxMrVm5C9W3OOpEVt2k5tvMKuA8nBSu38e0B//a+he9Iq2dvmk2VyYVlHqiHa4YBA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -1,6 +1,6 @@
{
"name": "pocket-id-frontend",
"version": "1.0.0",
"version": "1.4.0",
"private": true,
"type": "module",
"scripts": {
@@ -13,23 +13,22 @@
"format": "prettier --write ."
},
"dependencies": {
"@lucide/svelte": "^0.511.0",
"@simplewebauthn/browser": "^13.1.0",
"@tailwindcss/vite": "^4.1.7",
"axios": "^1.8.2",
"clsx": "^2.1.1",
"crypto": "^1.0.1",
"jose": "^5.9.6",
"qrcode": "^1.5.4",
"sveltekit-superforms": "^2.23.1",
"tailwind-merge": "^3.3.0",
"zod": "^3.24.1"
"zod": "^3.25.55"
},
"devDependencies": {
"@inlang/paraglide-js": "^2.0.0",
"@inlang/plugin-m-function-matcher": "^2.0.7",
"@inlang/paraglide-js": "^2.0.13",
"@inlang/plugin-m-function-matcher": "^2.0.10",
"@inlang/plugin-message-format": "^4.0.0",
"@internationalized/date": "^3.7.0",
"@internationalized/date": "^3.8.2",
"@lucide/svelte": "^0.513.0",
"@playwright/test": "^1.50.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.20.7",
@@ -37,7 +36,7 @@
"@types/eslint": "^9.6.1",
"@types/node": "^22.10.10",
"@types/qrcode": "^1.5.5",
"bits-ui": "^1.5.3",
"bits-ui": "^2.5.0",
"eslint": "^9.19.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^2.46.1",

View File

@@ -1,17 +1,20 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en-US",
"baseLocale": "en",
"locales": [
"en-US",
"nl-NL",
"ru-RU",
"de-DE",
"fr-FR",
"cs-CZ",
"cs",
"da",
"de",
"en",
"es",
"fr",
"it",
"nl",
"pl",
"pt-BR",
"it-IT",
"ru",
"zh-CN",
"pl-PL"
"zh-TW"
],
"modules": [
"./node_modules/@inlang/plugin-message-format/dist/index.js",

View File

@@ -20,67 +20,72 @@
}
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(240 10% 3.9%);
--muted: hsl(240 4.8% 95.9%);
--muted-foreground: hsl(240 3.8% 46.1%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(240 10% 3.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(240 10% 3.9%);
--border: hsl(240 5.9% 90%);
--input: hsl(240 5.9% 90%);
--primary: hsl(240 5.9% 10%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(240 4.8% 95.9%);
--secondary-foreground: hsl(240 5.9% 10%);
--accent: hsl(240 4.8% 95.9%);
--accent-foreground: hsl(240 5.9% 10%);
--destructive: hsl(0 72.2% 50.6%);
--destructive-foreground: hsl(0 0% 98%);
--ring: hsl(240 10% 3.9%);
--radius: 0.5rem;
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: hsl(240 10% 3.9%);
--foreground: hsl(0 0% 98%);
--muted: hsl(240 3.7% 15.9%);
--muted-foreground: hsl(240 5% 64.9%);
--popover: hsl(240 10% 3.9%);
--popover-foreground: hsl(0 0% 98%);
--card: hsl(240 10% 3.9%);
--card-foreground: hsl(0 0% 98%);
--border: hsl(240 3.7% 15.9%);
--input: hsl(240 3.7% 15.9%);
--primary: hsl(0 0% 98%);
--primary-foreground: hsl(240 5.9% 10%);
--secondary: hsl(240 3.7% 15.9%);
--secondary-foreground: hsl(0 0% 98%);
--accent: hsl(240 3.7% 15.9%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(0 0% 98%);
--ring: hsl(240 4.9% 83.9%);
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@theme inline {
@@ -198,7 +203,7 @@
}
.animate-fade-in {
animation: fadeIn 0.8s ease-out forwards;
animation: fadeIn 0.3s ease-out forwards;
opacity: 0;
}
@@ -228,7 +233,7 @@
bottom: 0;
left: 0;
right: 0;
animation: slide-bg-container 1.2s cubic-bezier(0.33, 1, 0.68, 1) forwards;
animation: slide-bg-container 0.6s cubic-bezier(0.33, 1, 0.68, 1) forwards;
}
@keyframes delayed-fade {
@@ -242,5 +247,5 @@
}
.animate-delayed-fade {
animation: delayed-fade 1.5s ease-out forwards;
animation: delayed-fade 0.5s ease-out forwards;
}

View File

@@ -0,0 +1,19 @@
import type { HandleClientError } from '@sveltejs/kit';
import { AxiosError } from 'axios';
export const handleError: HandleClientError = async ({ error, message, status }) => {
if (error instanceof AxiosError) {
message = error.response?.data.error || message;
status = error.response?.status || status;
console.error(
`Axios error: ${error.request.path} - ${error.response?.data.error ?? error.message}`
);
} else {
console.error(error);
}
return {
message,
status
};
};

View File

@@ -1,31 +0,0 @@
import { paraglideMiddleware } from '$lib/paraglide/server';
import type { Handle, HandleServerError } from '@sveltejs/kit';
import { AxiosError } from 'axios';
// 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)
});
});
};
export const handle: Handle = paraglideHandle;
export const handleError: HandleServerError = async ({ error, message, status }) => {
if (error instanceof AxiosError) {
message = error.response?.data.error || message;
status = error.response?.status || status;
console.error(
`Axios error: ${error.request.path} - ${error.response?.data.error ?? error.message}`
);
} else {
console.error(error);
}
return {
message,
status
};
};

View File

@@ -68,7 +68,7 @@
<div class="w-full" {...restProps}>
<Popover.Root bind:open>
<Popover.Trigger {id} class="w-full" >
<Popover.Trigger {id} class="w-full">
{#snippet child({ props })}
<Button
{...props}

View File

@@ -2,7 +2,9 @@
import DatePicker from '$lib/components/form/date-picker.svelte';
import { Input, type FormInputEvent } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { m } from '$lib/paraglide/messages';
import type { FormInput } from '$lib/utils/form-util';
import { LucideExternalLink } from '@lucide/svelte';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
@@ -10,6 +12,7 @@
input = $bindable(),
label,
description,
docsLink,
placeholder,
disabled = false,
type = 'text',
@@ -20,6 +23,7 @@
input?: FormInput<string | boolean | number | Date | undefined>;
label?: string;
description?: string;
docsLink?: string;
placeholder?: string;
disabled?: boolean;
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox' | 'date';
@@ -35,7 +39,19 @@
<Label class="mb-0" for={id}>{label}</Label>
{/if}
{#if description}
<p class="text-muted-foreground mt-1 text-xs">{description}</p>
<p class="text-muted-foreground mt-1 text-xs">
{description}
{#if docsLink}
<a
class="relative text-white after:absolute after:bottom-0 after:left-0 after:h-px after:w-full after:translate-y-[-1px] after:bg-white"
href={docsLink}
target="_blank"
>
{m.docs()}
<LucideExternalLink class="inline size-3 align-text-top" />
</a>
{/if}
</p>
{/if}
<div class={label || description ? 'mt-2' : ''}>
{#if children}

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { LucideChevronDown } from '@lucide/svelte';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
let {
items,
selectedItems = $bindable(),
onSelect,
autoClose = false
}: {
items: {
value: string;
label: string;
}[];
selectedItems: string[];
onSelect?: (value: string) => void;
autoClose?: boolean;
} = $props();
function handleItemSelect(value: string) {
if (selectedItems.includes(value)) {
selectedItems = selectedItems.filter((item) => item !== value);
} else {
selectedItems = [...selectedItems, value];
}
onSelect?.(value);
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline">
{#each items.filter((item) => selectedItems.includes(item.value)) as item}
<Badge variant="secondary">
{item.label}
</Badge>
{/each}
<LucideChevronDown class="text-muted-foreground ml-2 size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" class="w-[var(--bits-dropdown-menu-anchor-width)]">
{#each items as item}
<DropdownMenu.CheckboxItem
checked={selectedItems.includes(item.value)}
onCheckedChange={() => handleItemSelect(item.value)}
closeOnSelect={autoClose}
>
{item.label}
</DropdownMenu.CheckboxItem>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>

View File

@@ -3,7 +3,7 @@
import * as Avatar from '$lib/components/ui/avatar';
import Button from '$lib/components/ui/button/button.svelte';
import { m } from '$lib/paraglide/messages';
import { getProfilePictureUrl } from '$lib/utils/profile-picture-util';
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
import { LucideLoader, LucideRefreshCw, LucideUpload } from '@lucide/svelte';
import { onMount } from 'svelte';
import { openConfirmDialog } from '../confirm-dialog';
@@ -25,7 +25,7 @@
onMount(() => {
// The "skipCache" query will only be added to the profile picture url on client-side
// because of that we need to set the imageDataURL after the component is mounted
imageDataURL = getProfilePictureUrl(userId);
imageDataURL = cachedProfilePicture.getUrl(userId);
});
async function onImageChange(e: Event) {
@@ -41,7 +41,7 @@
reader.readAsDataURL(file);
await updateCallback(file).catch(() => {
imageDataURL = getProfilePictureUrl(userId);
imageDataURL = cachedProfilePicture.getUrl(userId);
});
isLoading = false;
}

View File

@@ -2,15 +2,20 @@
import { Button } from '$lib/components/ui/button';
import * as Command from '$lib/components/ui/command';
import * as Popover from '$lib/components/ui/popover';
import { m } from '$lib/paraglide/messages';
import { cn } from '$lib/utils/style';
import { LucideCheck, LucideChevronDown } from '@lucide/svelte';
import { LoaderCircle, LucideCheck, LucideChevronDown } from '@lucide/svelte';
import { tick } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { FormEventHandler, HTMLAttributes } from 'svelte/elements';
let {
items,
value = $bindable(),
onSelect,
oninput,
isLoading,
disableSearch = false,
selectText = m.select_an_option(),
...restProps
}: HTMLAttributes<HTMLButtonElement> & {
items: {
@@ -18,7 +23,11 @@
label: string;
}[];
value: string;
oninput?: FormEventHandler<HTMLInputElement>;
onSelect?: (value: string) => void;
isLoading?: boolean;
disableSearch?: boolean;
selectText?: string;
} = $props();
let open = $state(false);
@@ -53,21 +62,40 @@
</script>
<Popover.Root bind:open {...restProps}>
<Popover.Trigger class="w-full">
<Button
variant="outline"
role="combobox"
aria-expanded={open}
class={cn('justify-between', restProps.class)}
>
{items.find((item) => item.value === value)?.label || 'Select an option'}
<LucideChevronDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
<Popover.Trigger>
{#snippet child({ props })}
<Button
variant="outline"
role="combobox"
aria-expanded={open}
{...props}
class={cn('justify-between', restProps.class)}
>
{items.find((item) => item.value === value)?.label || selectText}
<LucideChevronDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="p-0">
<Popover.Content class="p-0" sameWidth>
<Command.Root shouldFilter={false}>
<Command.Input placeholder="Search..." oninput={(e: any) => filterItems(e.target.value)} />
<Command.Empty>No results found.</Command.Empty>
{#if !disableSearch}
<Command.Input
placeholder={m.search()}
oninput={(e) => {
filterItems(e.currentTarget.value);
oninput?.(e);
}}
/>
{/if}
<Command.Empty>
{#if isLoading}
<div class="flex w-full justify-center">
<LoaderCircle class="size-4 animate-spin" />
</div>
{:else}
{m.no_items_found()}
{/if}
</Command.Empty>
<Command.Group>
{#each filteredItems as item}
<Command.Item

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