Compare commits

..

100 Commits

Author SHA1 Message Date
Elias Schneider
dd9b1d26ea release: 1.5.0 2025-06-27 23:56:16 +02:00
Elias Schneider
4b829757b2 tests: fix e2e tests 2025-06-27 23:52:43 +02:00
Elias Schneider
b5b01cb6dd chore(translations): update translations via Crowdin (#688) 2025-06-27 23:42:32 +02:00
Elias Schneider
287314f016 feat: improve initial admin creation workflow 2025-06-27 23:41:05 +02:00
Elias Schneider
73e7e0b1c5 refactor: add formatter to Playwright tests 2025-06-27 23:33:26 +02:00
Elias Schneider
d070b9a778 fix: double double full stops for certain error messages 2025-06-27 22:43:31 +02:00
Elias Schneider
d976bf5965 fix: improve accent color picker disabled state 2025-06-27 22:38:21 +02:00
Elias Schneider
052ac008c3 fix: margin of user sign up description 2025-06-27 22:31:55 +02:00
Elias Schneider
57a2b2bc83 chore(translations): update translations via Crowdin (#687) 2025-06-27 22:24:36 +02:00
ElevenNotes
043f82ad79 fix: less noisy logging for certain GET requests (#681)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-06-27 22:24:22 +02:00
Elias Schneider
ba61cdba4e feat: redact sensitive app config variables if set with env variable 2025-06-27 22:22:28 +02:00
Kyle Mendell
dcd1ae96e0 feat: self-service user signup (#672)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-06-27 15:01:10 -05:00
Elias Schneider
1fdb058386 docs: clarify confusing user update logic 2025-06-27 17:20:51 +02:00
Elias Schneider
29cb5513a0 fix: users can't be updated by admin if self account editing is disabled 2025-06-27 17:15:26 +02:00
Elias Schneider
6db57d9f27 chore(translations): update translations via Crowdin (#683) 2025-06-26 19:01:16 +02:00
Elias Schneider
1a77bd9914 fix: error page flickering after sign out 2025-06-24 21:56:40 +02:00
Elias Schneider
350335711b chore(translations): update translations via Crowdin (#677) 2025-06-24 09:00:57 -05:00
Ryan Kaskel
988c425150 fix: remove duplicate request logging (#678) 2025-06-24 13:48:11 +00:00
Elias Schneider
23827ba1d1 release: 1.4.1 2025-06-22 21:30:07 +02:00
Elias Schneider
7d36bda769 fix: app not starting if UI config is disabled and Postgres is used 2025-06-22 21:21:14 +02:00
Manuel Rais
8c559ea067 chore(translations) : typo in french language (#669) 2025-06-22 18:58:59 +00:00
Elias Schneider
88832d4bc9 chore(translations): update translations via Crowdin (#663) 2025-06-20 11:11:42 +02:00
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
208 changed files with 14986 additions and 8575 deletions

View File

@@ -1,5 +1,5 @@
# See the documentation for more information: https://pocket-id.org/docs/configuration/environment-variables # 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 TRUST_PROXY=false
MAXMIND_LICENSE_KEY= MAXMIND_LICENSE_KEY=
PUID=1000 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

1
.gitignore vendored
View File

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

View File

@@ -1 +1 @@
1.1.0 1.5.0

View File

@@ -1,3 +1,97 @@
## [](https://github.com/pocket-id/pocket-id/compare/v1.4.1...v) (2025-06-27)
### Features
* improve initial admin creation workflow ([287314f](https://github.com/pocket-id/pocket-id/commit/287314f01644e42ddb2ce1b1115bd14f2f0c1768))
* redact sensitive app config variables if set with env variable ([ba61cdb](https://github.com/pocket-id/pocket-id/commit/ba61cdba4eb3d5659f3ae6b6c21249985c0aa630))
* self-service user signup ([#672](https://github.com/pocket-id/pocket-id/issues/672)) ([dcd1ae9](https://github.com/pocket-id/pocket-id/commit/dcd1ae96e048115be34b0cce275054e990462ebf))
### Bug Fixes
* double double full stops for certain error messages ([d070b9a](https://github.com/pocket-id/pocket-id/commit/d070b9a778d7d1a51f2fa62d003f2331a96d6c91))
* error page flickering after sign out ([1a77bd9](https://github.com/pocket-id/pocket-id/commit/1a77bd9914ea01e445ff3d6e116c9ed3bcfbf153))
* improve accent color picker disabled state ([d976bf5](https://github.com/pocket-id/pocket-id/commit/d976bf5965eda10e3ecb71821c23e93e5d712a02))
* less noisy logging for certain GET requests ([#681](https://github.com/pocket-id/pocket-id/issues/681)) ([043f82a](https://github.com/pocket-id/pocket-id/commit/043f82ad794eb64a5550d8b80703114a055701d9))
* margin of user sign up description ([052ac00](https://github.com/pocket-id/pocket-id/commit/052ac008c3a8c910d1ce79ee99b2b2f75e4090f4))
* remove duplicate request logging ([#678](https://github.com/pocket-id/pocket-id/issues/678)) ([988c425](https://github.com/pocket-id/pocket-id/commit/988c425150556b32cff1d341a21fcc9c69d9aaf8))
* users can't be updated by admin if self account editing is disabled ([29cb551](https://github.com/pocket-id/pocket-id/commit/29cb5513a03d1a9571969c8a42deec9b2bdee037))
## [](https://github.com/pocket-id/pocket-id/compare/v1.4.0...v) (2025-06-22)
### Bug Fixes
* app not starting if UI config is disabled and Postgres is used ([7d36bda](https://github.com/pocket-id/pocket-id/commit/7d36bda769e25497dec6b76206a4f7e151b0bd72))
## [](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) ## [](https://github.com/pocket-id/pocket-id/compare/v1.0.0...v) (2025-05-28)

View File

@@ -1,5 +1,5 @@
# This Dockerfile embeds a pre-built binary for the given Linux architecture # 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 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 ( require (
github.com/caarlos0/env/v11 v11.3.1 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/imageorient v0.0.0-20180920195336-8147d86e83ec
github.com/disintegration/imaging v1.6.2 github.com/disintegration/imaging v1.6.2
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
@@ -19,7 +20,8 @@ require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/hashicorp/go-uuid v1.0.3 github.com/hashicorp/go-uuid v1.0.3
github.com/joho/godotenv v1.5.1 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/mileusna/useragent v1.3.5
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2 github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
@@ -31,7 +33,7 @@ require (
go.opentelemetry.io/otel/sdk v1.35.0 go.opentelemetry.io/otel/sdk v1.35.0
go.opentelemetry.io/otel/sdk/metric v1.35.0 go.opentelemetry.io/otel/sdk/metric v1.35.0
go.opentelemetry.io/otel/trace v1.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/image v0.24.0
golang.org/x/time v0.9.0 golang.org/x/time v0.9.0
gorm.io/driver/postgres v1.5.11 gorm.io/driver/postgres v1.5.11
@@ -76,9 +78,8 @@ require (
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.4.0 // 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/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/lestrrat-go/option v1.0.1 // indirect
github.com/lib/pq v1.10.9 // indirect github.com/lib/pq v1.10.9 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
@@ -122,7 +123,7 @@ require (
golang.org/x/net v0.38.0 // indirect golang.org/x/net v0.38.0 // indirect
golang.org/x/sync v0.14.0 // indirect golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.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/api v0.0.0-20250218202821-56aae31c358a // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect
google.golang.org/grpc v1.71.0 // indirect google.golang.org/grpc v1.71.0 // indirect

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/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 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= 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/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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 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.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= 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 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 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-beta2 h1:SDxjGoH7qj0nBXVrcrxX8eD94wEnjR+EEuqqmeqQYlY=
github.com/lestrrat-go/httprc/v3 v3.0.0-beta1/go.mod h1:wdsgouffPvWPEYh8t7PRH/PidR5sfVqt0na4Nhj60Ms= github.com/lestrrat-go/httprc/v3 v3.0.0-beta2/go.mod h1:Nwo81sMxE0DcvTB+rJyynNhv/DUu2yZErV7sscw9pHE=
github.com/lestrrat-go/jwx/v3 v3.0.0-beta1 h1:Iqjb8JvWjh34Jv8DeM2wQ1aG5fzFBzwQu7rlqwuJB0I= github.com/lestrrat-go/jwx/v3 v3.0.1 h1:fH3T748FCMbXoF9UXXNS9i0q6PpYyJZK/rKSbkt2guY=
github.com/lestrrat-go/jwx/v3 v3.0.0-beta1/go.mod h1:ak32WoNtHE0aLowVWBcCvXngcAnW4tuC0YhFwOr/kwc= 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 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 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.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 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 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= 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= 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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 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 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 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= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@@ -3,6 +3,8 @@
package bootstrap package bootstrap
import ( import (
"log"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
@@ -14,7 +16,12 @@ import (
func init() { func init() {
registerTestControllers = []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services){ registerTestControllers = []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services){
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) controller.NewTestController(apiGroup, testService)
}, },
} }

View File

@@ -7,6 +7,9 @@ import (
"log" "log"
"net" "net"
"net/http" "net/http"
"os"
"strconv"
"strings"
"time" "time"
"github.com/pocket-id/pocket-id/backend/frontend" "github.com/pocket-id/pocket-id/backend/frontend"
@@ -45,8 +48,26 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
} }
r := gin.Default() // do not log these URLs
r.Use(gin.Logger()) loggerSkipPathsPrefix := []string{
"GET /application-configuration/logo",
"GET /application-configuration/background-image",
"GET /application-configuration/favicon",
"GET /_app",
"GET /fonts",
"GET /healthz",
"HEAD /healthz",
}
r := gin.New()
r.Use(gin.LoggerWithConfig(gin.LoggerConfig{Skip: func(c *gin.Context) bool {
for _, prefix := range loggerSkipPathsPrefix {
if strings.HasPrefix(c.Request.Method+" "+c.Request.URL.String(), prefix) {
return true
}
}
return false
}}))
if !common.EnvConfig.TrustProxy { if !common.EnvConfig.TrustProxy {
_ = r.SetTrustedProxies(nil) _ = r.SetTrustedProxies(nil)
@@ -101,21 +122,39 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
// Set up the server // Set up the server
srv := &http.Server{ srv := &http.Server{
Addr: net.JoinHostPort(common.EnvConfig.Host, common.EnvConfig.Port),
MaxHeaderBytes: 1 << 20, MaxHeaderBytes: 1 << 20,
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
Handler: r, Handler: r,
} }
// Set up the listener // 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 { 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 // Service runner function
runFn := func(ctx context.Context) error { 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 // Start the server in a background goroutine
go func() { go func() {

View File

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

View File

@@ -32,9 +32,12 @@ type EnvConfigSchema struct {
KeysPath string `env:"KEYS_PATH"` KeysPath string `env:"KEYS_PATH"`
Port string `env:"PORT"` Port string `env:"PORT"`
Host string `env:"HOST"` Host string `env:"HOST"`
UnixSocket string `env:"UNIX_SOCKET"`
UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"` MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"` GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"` GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"`
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"` UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
MetricsEnabled bool `env:"METRICS_ENABLED"` MetricsEnabled bool `env:"METRICS_ENABLED"`
TracingEnabled bool `env:"TRACING_ENABLED"` TracingEnabled bool `env:"TRACING_ENABLED"`
@@ -51,9 +54,12 @@ var EnvConfig = &EnvConfigSchema{
AppURL: "http://localhost:1411", AppURL: "http://localhost:1411",
Port: "1411", Port: "1411",
Host: "0.0.0.0", Host: "0.0.0.0",
UnixSocket: "",
UnixSocketMode: "",
MaxMindLicenseKey: "", MaxMindLicenseKey: "",
GeoLiteDBPath: "data/GeoLite2-City.mmdb", GeoLiteDBPath: "data/GeoLite2-City.mmdb",
GeoLiteDBUrl: MaxMindGeoLiteCityUrl, GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
LocalIPv6Ranges: "",
UiConfigDisabled: false, UiConfigDisabled: false,
MetricsEnabled: false, MetricsEnabled: false,
TracingEnabled: false, TracingEnabled: false,

View File

@@ -65,11 +65,23 @@ type OidcClientSecretInvalidError struct{}
func (e *OidcClientSecretInvalidError) Error() string { return "invalid client secret" } func (e *OidcClientSecretInvalidError) Error() string { return "invalid client secret" }
func (e *OidcClientSecretInvalidError) HttpStatusCode() int { return 400 } 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{} type OidcInvalidAuthorizationCodeError struct{}
func (e *OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" } func (e *OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" }
func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return 400 } 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{} type OidcInvalidCallbackURLError struct{}
func (e *OidcInvalidCallbackURLError) Error() string { func (e *OidcInvalidCallbackURLError) Error() string {
@@ -156,13 +168,6 @@ func (e *DuplicateClaimError) Error() string {
} }
func (e *DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest } 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{} type OidcInvalidCodeVerifierError struct{}
func (e *OidcInvalidCodeVerifierError) Error() string { func (e *OidcInvalidCodeVerifierError) Error() string {
@@ -344,3 +349,13 @@ func (e *OidcAuthorizationPendingError) Error() string {
func (e *OidcAuthorizationPendingError) HttpStatusCode() int { func (e *OidcAuthorizationPendingError) HttpStatusCode() int {
return http.StatusBadRequest return http.StatusBadRequest
} }
type OpenSignupDisabledError struct{}
func (e *OpenSignupDisabledError) Error() string {
return "Open user signup is not enabled"
}
func (e *OpenSignupDisabledError) HttpStatusCode() int {
return http.StatusForbidden
}

View File

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

View File

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

View File

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

View File

@@ -35,10 +35,6 @@ type CustomClaimController struct {
// @Tags Custom Claims // @Tags Custom Claims
// @Produce json // @Produce json
// @Success 200 {array} string "List of suggested custom claim names" // @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] // @Router /api/custom-claims/suggestions [get]
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) { func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
claims, err := ccc.customClaimService.GetSuggestions(c.Request.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 userGroupId path string true "User Group ID"
// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user group" // @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user group"
// @Success 200 {array} dto.CustomClaimDto "Updated custom claims" // @Success 200 {array} dto.CustomClaimDto "Updated custom claims"
// @Security BearerAuth
// @Router /api/custom-claims/user-group/{userGroupId} [put] // @Router /api/custom-claims/user-group/{userGroupId} [put]
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) { func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
var input []dto.CustomClaimCreateDto var input []dto.CustomClaimCreateDto

View File

@@ -14,6 +14,10 @@ func NewTestController(group *gin.RouterGroup, testService *service.TestService)
testController := &TestController{TestService: testService} testController := &TestController{TestService: testService}
group.POST("/test/reset", testController.resetAndSeedHandler) 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 { type TestController struct {
@@ -21,6 +25,16 @@ type TestController struct {
} }
func (tc *TestController) resetAndSeedHandler(c *gin.Context) { 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"
skipSeed := c.Query("skip-seed") == "true"
if err := tc.TestService.ResetDatabase(); err != nil { if err := tc.TestService.ResetDatabase(); err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
@@ -31,9 +45,11 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
return return
} }
if err := tc.TestService.SeedDatabase(); err != nil { if !skipSeed {
_ = c.Error(err) if err := tc.TestService.SeedDatabase(baseURL); err != nil {
return _ = c.Error(err)
return
}
} }
if err := tc.TestService.ResetAppConfig(c.Request.Context()); err != nil { if err := tc.TestService.ResetAppConfig(c.Request.Context()); err != nil {
@@ -41,17 +57,71 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
return return
} }
if err := tc.TestService.SetLdapTestConfig(c.Request.Context()); err != nil { if !skipLdap {
_ = c.Error(err) if err := tc.TestService.SetLdapTestConfig(c.Request.Context()); err != nil {
return _ = c.Error(err)
} return
}
if err := tc.TestService.SyncLdap(c.Request.Context()); err != nil { if err := tc.TestService.SyncLdap(c.Request.Context()); err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
}
} }
tc.TestService.SetJWTKeys() tc.TestService.SetJWTKeys()
c.Status(http.StatusNoContent) 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/http"
"net/url" "net/url"
"strings" "strings"
"time"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware" "github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils" "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 // 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.DELETE("/oidc/clients/:id/logo", oc.deleteClientLogoHandler)
group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler) 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/authorize", oc.deviceAuthorizationHandler)
group.POST("/oidc/device/verify", authMiddleware.WithAdminNotRequired().Add(), oc.verifyDeviceCodeHandler) group.POST("/oidc/device/verify", authMiddleware.WithAdminNotRequired().Add(), oc.verifyDeviceCodeHandler)
group.GET("/oidc/device/info", authMiddleware.WithAdminNotRequired().Add(), oc.getDeviceCodeInfoHandler) 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 { type OidcController struct {
@@ -66,7 +72,6 @@ type OidcController struct {
// @Produce json // @Produce json
// @Param request body dto.AuthorizeOidcClientRequestDto true "Authorization request parameters" // @Param request body dto.AuthorizeOidcClientRequestDto true "Authorization request parameters"
// @Success 200 {object} dto.AuthorizeOidcClientResponseDto "Authorization code and callback URL" // @Success 200 {object} dto.AuthorizeOidcClientResponseDto "Authorization code and callback URL"
// @Security BearerAuth
// @Router /api/oidc/authorize [post] // @Router /api/oidc/authorize [post]
func (oc *OidcController) authorizeHandler(c *gin.Context) { func (oc *OidcController) authorizeHandler(c *gin.Context) {
var input dto.AuthorizeOidcClientRequestDto var input dto.AuthorizeOidcClientRequestDto
@@ -97,7 +102,6 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
// @Produce json // @Produce json
// @Param request body dto.AuthorizationRequiredDto true "Authorization check parameters" // @Param request body dto.AuthorizationRequiredDto true "Authorization check parameters"
// @Success 200 {object} object "{ \"authorizationRequired\": true/false }" // @Success 200 {object} object "{ \"authorizationRequired\": true/false }"
// @Security BearerAuth
// @Router /api/oidc/authorization-required [post] // @Router /api/oidc/authorization-required [post]
func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Context) { func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Context) {
var input dto.AuthorizationRequiredDto var input dto.AuthorizationRequiredDto
@@ -121,11 +125,13 @@ func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Contex
// @Tags OIDC // @Tags OIDC
// @Produce json // @Produce json
// @Param client_id formData string false "Client ID (if not using Basic Auth)" // @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 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 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 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 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" // @Success 200 {object} dto.OidcTokenResponseDto "Token response with access_token and optional id_token and refresh_token"
// @Router /api/oidc/token [post] // @Router /api/oidc/token [post]
func (oc *OidcController) createTokensHandler(c *gin.Context) { func (oc *OidcController) createTokensHandler(c *gin.Context) {
@@ -195,7 +201,7 @@ func (oc *OidcController) userInfoHandler(c *gin.Context) {
return return
} }
token, err := oc.jwtService.VerifyOauthAccessToken(authToken) token, err := oc.jwtService.VerifyOAuthAccessToken(authToken)
if err != nil { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
@@ -224,7 +230,6 @@ func (oc *OidcController) userInfoHandler(c *gin.Context) {
// @Description End user session and handle OIDC logout // @Description End user session and handle OIDC logout
// @Tags OIDC // @Tags OIDC
// @Accept application/x-www-form-urlencoded // @Accept application/x-www-form-urlencoded
// @Produce html
// @Param id_token_hint query string false "ID token" // @Param id_token_hint query string false "ID token"
// @Param post_logout_redirect_uri query string false "URL to redirect to after logout" // @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" // @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 // 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 // supposed to interact with our IdP (since that needs to have a client_id
// and client_secret anyway). // 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 { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
@@ -348,7 +365,6 @@ func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
// @Produce json // @Produce json
// @Param id path string true "Client ID" // @Param id path string true "Client ID"
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Client information" // @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Client information"
// @Security BearerAuth
// @Router /api/oidc/clients/{id} [get] // @Router /api/oidc/clients/{id} [get]
func (oc *OidcController) getClientHandler(c *gin.Context) { func (oc *OidcController) getClientHandler(c *gin.Context) {
clientId := c.Param("id") clientId := c.Param("id")
@@ -360,12 +376,12 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
clientDto := dto.OidcClientWithAllowedUserGroupsDto{} clientDto := dto.OidcClientWithAllowedUserGroupsDto{}
err = dto.MapStruct(client, &clientDto) err = dto.MapStruct(client, &clientDto)
if err == nil { if err != nil {
c.JSON(http.StatusOK, clientDto) _ = c.Error(err)
return return
} }
_ = c.Error(err) c.JSON(http.StatusOK, clientDto)
} }
// listClientsHandler godoc // 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 // @Description Get a paginated list of OIDC clients with optional search and sorting
// @Tags OIDC // @Tags OIDC
// @Param search query string false "Search term to filter clients by name" // @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 pagination[page] query int false "Page number for pagination" default(1)
// @Param limit query int false "Number of items per page" default(10) // @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort_column query string false "Column to sort by" default("name") // @Param sort[column] query string false "Column to sort by"
// @Param sort_direction query string false "Sort direction (asc or desc)" default("asc") // @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto] // @Success 200 {object} dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]
// @Security BearerAuth
// @Router /api/oidc/clients [get] // @Router /api/oidc/clients [get]
func (oc *OidcController) listClientsHandler(c *gin.Context) { func (oc *OidcController) listClientsHandler(c *gin.Context) {
searchTerm := c.Query("search") searchTerm := c.Query("search")
@@ -424,7 +439,6 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
// @Produce json // @Produce json
// @Param client body dto.OidcClientCreateDto true "Client information" // @Param client body dto.OidcClientCreateDto true "Client information"
// @Success 201 {object} dto.OidcClientWithAllowedUserGroupsDto "Created client" // @Success 201 {object} dto.OidcClientWithAllowedUserGroupsDto "Created client"
// @Security BearerAuth
// @Router /api/oidc/clients [post] // @Router /api/oidc/clients [post]
func (oc *OidcController) createClientHandler(c *gin.Context) { func (oc *OidcController) createClientHandler(c *gin.Context) {
var input dto.OidcClientCreateDto var input dto.OidcClientCreateDto
@@ -454,7 +468,6 @@ func (oc *OidcController) createClientHandler(c *gin.Context) {
// @Tags OIDC // @Tags OIDC
// @Param id path string true "Client ID" // @Param id path string true "Client ID"
// @Success 204 "No Content" // @Success 204 "No Content"
// @Security BearerAuth
// @Router /api/oidc/clients/{id} [delete] // @Router /api/oidc/clients/{id} [delete]
func (oc *OidcController) deleteClientHandler(c *gin.Context) { func (oc *OidcController) deleteClientHandler(c *gin.Context) {
err := oc.oidcService.DeleteClient(c.Request.Context(), c.Param("id")) err := oc.oidcService.DeleteClient(c.Request.Context(), c.Param("id"))
@@ -475,7 +488,6 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) {
// @Param id path string true "Client ID" // @Param id path string true "Client ID"
// @Param client body dto.OidcClientCreateDto true "Client information" // @Param client body dto.OidcClientCreateDto true "Client information"
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Updated client" // @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Updated client"
// @Security BearerAuth
// @Router /api/oidc/clients/{id} [put] // @Router /api/oidc/clients/{id} [put]
func (oc *OidcController) updateClientHandler(c *gin.Context) { func (oc *OidcController) updateClientHandler(c *gin.Context) {
var input dto.OidcClientCreateDto var input dto.OidcClientCreateDto
@@ -506,7 +518,6 @@ func (oc *OidcController) updateClientHandler(c *gin.Context) {
// @Produce json // @Produce json
// @Param id path string true "Client ID" // @Param id path string true "Client ID"
// @Success 200 {object} object "{ \"secret\": \"string\" }" // @Success 200 {object} object "{ \"secret\": \"string\" }"
// @Security BearerAuth
// @Router /api/oidc/clients/{id}/secret [post] // @Router /api/oidc/clients/{id}/secret [post]
func (oc *OidcController) createClientSecretHandler(c *gin.Context) { func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
secret, err := oc.oidcService.CreateClientSecret(c.Request.Context(), c.Param("id")) secret, err := oc.oidcService.CreateClientSecret(c.Request.Context(), c.Param("id"))
@@ -535,6 +546,8 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
return return
} }
utils.SetCacheControlHeader(c, 15*time.Minute, 12*time.Hour)
c.Header("Content-Type", mimeType) c.Header("Content-Type", mimeType)
c.File(imagePath) c.File(imagePath)
} }
@@ -545,9 +558,8 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
// @Tags OIDC // @Tags OIDC
// @Accept multipart/form-data // @Accept multipart/form-data
// @Param id path string true "Client ID" // @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" // @Success 204 "No Content"
// @Security BearerAuth
// @Router /api/oidc/clients/{id}/logo [post] // @Router /api/oidc/clients/{id}/logo [post]
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) { func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
file, err := c.FormFile("file") file, err := c.FormFile("file")
@@ -571,7 +583,6 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
// @Tags OIDC // @Tags OIDC
// @Param id path string true "Client ID" // @Param id path string true "Client ID"
// @Success 204 "No Content" // @Success 204 "No Content"
// @Security BearerAuth
// @Router /api/oidc/clients/{id}/logo [delete] // @Router /api/oidc/clients/{id}/logo [delete]
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) { func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
err := oc.oidcService.DeleteClientLogo(c.Request.Context(), c.Param("id")) err := oc.oidcService.DeleteClientLogo(c.Request.Context(), c.Param("id"))
@@ -592,7 +603,6 @@ func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
// @Param id path string true "Client ID" // @Param id path string true "Client ID"
// @Param groups body dto.OidcUpdateAllowedUserGroupsDto true "User group IDs" // @Param groups body dto.OidcUpdateAllowedUserGroupsDto true "User group IDs"
// @Success 200 {object} dto.OidcClientDto "Updated client" // @Success 200 {object} dto.OidcClientDto "Updated client"
// @Security BearerAuth
// @Router /api/oidc/clients/{id}/allowed-user-groups [put] // @Router /api/oidc/clients/{id}/allowed-user-groups [put]
func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) { func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
var input dto.OidcUpdateAllowedUserGroupsDto var input dto.OidcUpdateAllowedUserGroupsDto
@@ -637,6 +647,62 @@ func (oc *OidcController) deviceAuthorizationHandler(c *gin.Context) {
c.JSON(http.StatusOK, response) 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) { func (oc *OidcController) verifyDeviceCodeHandler(c *gin.Context) {
userCode := c.Query("code") userCode := c.Query("code")
if userCode == "" { if userCode == "" {
@@ -672,3 +738,43 @@ func (oc *OidcController) getDeviceCodeInfoHandler(c *gin.Context) {
c.JSON(http.StatusOK, deviceCodeInfo) 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/pocket-id/pocket-id/backend/internal/utils/cookie"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware" "github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/service"
@@ -45,11 +44,17 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.POST("/users/:id/one-time-access-token", authMiddleware.Add(), uc.createAdminOneTimeAccessTokenHandler) group.POST("/users/:id/one-time-access-token", authMiddleware.Add(), uc.createAdminOneTimeAccessTokenHandler)
group.POST("/users/:id/one-time-access-email", authMiddleware.Add(), uc.RequestOneTimeAccessEmailAsAdminHandler) group.POST("/users/:id/one-time-access-email", authMiddleware.Add(), uc.RequestOneTimeAccessEmailAsAdminHandler)
group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler) group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler)
group.POST("/one-time-access-token/setup", uc.getSetupAccessTokenHandler)
group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.RequestOneTimeAccessEmailAsUnauthenticatedUserHandler) group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.RequestOneTimeAccessEmailAsUnauthenticatedUserHandler)
group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler) group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler)
group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler) group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler)
group.POST("/signup-tokens", authMiddleware.Add(), uc.createSignupTokenHandler)
group.GET("/signup-tokens", authMiddleware.Add(), uc.listSignupTokensHandler)
group.DELETE("/signup-tokens/:id", authMiddleware.Add(), uc.deleteSignupTokenHandler)
group.POST("/signup", rateLimitMiddleware.Add(rate.Every(1*time.Minute), 10), uc.signupHandler)
group.POST("/signup/setup", uc.signUpInitialAdmin)
} }
type UserController struct { type UserController struct {
@@ -86,10 +91,10 @@ func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
// @Description Get a paginated list of users with optional search and sorting // @Description Get a paginated list of users with optional search and sorting
// @Tags Users // @Tags Users
// @Param search query string false "Search term to filter users" // @Param search query string false "Search term to filter users"
// @Param page query int false "Page number, starting from 1" default(1) // @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param limit query int false "Number of items per page" default(10) // @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort_column query string false "Column to sort by" default("created_at") // @Param sort[column] query string false "Column to sort by"
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc") // @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.UserDto] // @Success 200 {object} dto.Paginated[dto.UserDto]
// @Router /api/users [get] // @Router /api/users [get]
func (uc *UserController) listUsersHandler(c *gin.Context) { func (uc *UserController) listUsersHandler(c *gin.Context) {
@@ -228,10 +233,6 @@ func (uc *UserController) updateUserHandler(c *gin.Context) {
// @Success 200 {object} dto.UserDto // @Success 200 {object} dto.UserDto
// @Router /api/users/me [put] // @Router /api/users/me [put]
func (uc *UserController) updateCurrentUserHandler(c *gin.Context) { func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
if !uc.appConfigService.GetDbConfig().AllowOwnAccountEdit.IsTrue() {
_ = c.Error(&common.AccountEditNotAllowedError{})
return
}
uc.updateUser(c, true) uc.updateUser(c, true)
} }
@@ -255,10 +256,7 @@ func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) {
defer picture.Close() defer picture.Close()
} }
_, ok := c.GetQuery("skipCache") utils.SetCacheControlHeader(c, 15*time.Minute, 1*time.Hour)
if !ok {
c.Header("Cache-Control", "public, max-age=900")
}
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil) c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
} }
@@ -448,14 +446,23 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
c.JSON(http.StatusOK, userDto) c.JSON(http.StatusOK, userDto)
} }
// getSetupAccessTokenHandler godoc // signUpInitialAdmin godoc
// @Summary Setup initial admin // @Summary Sign up initial admin user
// @Description Generate setup access token for initial admin user configuration // @Description Sign up and generate setup access token for initial admin user
// @Tags Users // @Tags Users
// @Accept json
// @Produce json
// @Param body body dto.SignUpDto true "User information"
// @Success 200 {object} dto.UserDto // @Success 200 {object} dto.UserDto
// @Router /api/one-time-access-token/setup [post] // @Router /api/signup/setup [post]
func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) { func (uc *UserController) signUpInitialAdmin(c *gin.Context) {
user, token, err := uc.userService.SetupInitialAdmin(c.Request.Context()) var input dto.SignUpDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
user, token, err := uc.userService.SignUpInitialAdmin(c.Request.Context(), input)
if err != nil { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
@@ -503,6 +510,128 @@ func (uc *UserController) updateUserGroups(c *gin.Context) {
c.JSON(http.StatusOK, userDto) c.JSON(http.StatusOK, userDto)
} }
// createSignupTokenHandler godoc
// @Summary Create signup token
// @Description Create a new signup token that allows user registration
// @Tags Users
// @Accept json
// @Produce json
// @Param token body dto.SignupTokenCreateDto true "Signup token information"
// @Success 201 {object} dto.SignupTokenDto
// @Router /api/signup-tokens [post]
func (uc *UserController) createSignupTokenHandler(c *gin.Context) {
var input dto.SignupTokenCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
signupToken, err := uc.userService.CreateSignupToken(c.Request.Context(), input.ExpiresAt, input.UsageLimit)
if err != nil {
_ = c.Error(err)
return
}
var tokenDto dto.SignupTokenDto
if err := dto.MapStruct(signupToken, &tokenDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, tokenDto)
}
// listSignupTokensHandler godoc
// @Summary List signup tokens
// @Description Get a paginated list of signup tokens
// @Tags Users
// @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.SignupTokenDto]
// @Router /api/signup-tokens [get]
func (uc *UserController) listSignupTokensHandler(c *gin.Context) {
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
tokens, pagination, err := uc.userService.ListSignupTokens(c.Request.Context(), sortedPaginationRequest)
if err != nil {
_ = c.Error(err)
return
}
var tokensDto []dto.SignupTokenDto
if err := dto.MapStructList(tokens, &tokensDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, dto.Paginated[dto.SignupTokenDto]{
Data: tokensDto,
Pagination: pagination,
})
}
// deleteSignupTokenHandler godoc
// @Summary Delete signup token
// @Description Delete a signup token by ID
// @Tags Users
// @Param id path string true "Token ID"
// @Success 204 "No Content"
// @Router /api/signup-tokens/{id} [delete]
func (uc *UserController) deleteSignupTokenHandler(c *gin.Context) {
tokenID := c.Param("id")
err := uc.userService.DeleteSignupToken(c.Request.Context(), tokenID)
if err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// signupWithTokenHandler godoc
// @Summary Sign up
// @Description Create a new user account
// @Tags Users
// @Accept json
// @Produce json
// @Param user body dto.SignUpDto true "User information"
// @Success 201 {object} dto.SignUpDto
// @Router /api/signup [post]
func (uc *UserController) signupHandler(c *gin.Context) {
var input dto.SignUpDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
ipAddress := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
user, accessToken, err := uc.userService.SignUp(c.Request.Context(), input, ipAddress, userAgent)
if err != nil {
_ = c.Error(err)
return
}
maxAge := int(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, accessToken)
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, userDto)
}
// updateUser is an internal helper method, not exposed as an API endpoint // updateUser is an internal helper method, not exposed as an API endpoint
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) { func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
var input dto.UserCreateDto var input dto.UserCreateDto

View File

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

View File

@@ -17,6 +17,8 @@ type AppConfigUpdateDto struct {
EmailsVerified string `json:"emailsVerified" binding:"required"` EmailsVerified string `json:"emailsVerified" binding:"required"`
DisableAnimations string `json:"disableAnimations" binding:"required"` DisableAnimations string `json:"disableAnimations" binding:"required"`
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"` AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
AllowUserSignups string `json:"allowUserSignups" binding:"required,oneof=disabled withToken open"`
AccentColor string `json:"accentColor"`
SmtpHost string `json:"smtpHost"` SmtpHost string `json:"smtpHost"`
SmtpPort string `json:"smtpPort"` SmtpPort string `json:"smtpPort"`
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"` SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`

View File

@@ -23,4 +23,5 @@ type AuditLogFilterDto struct {
UserID string `form:"filters[userId]"` UserID string `form:"filters[userId]"`
Event string `form:"filters[event]"` Event string `form:"filters[event]"`
ClientName string `form:"filters[clientName]"` 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 return nil
} }
//nolint:gocognit
func mapField(sourceField reflect.Value, destField reflect.Value) error { 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 { switch {
case sourceField.Type() == destField.Type(): case sourceField.Type() == destField.Type():
destField.Set(sourceField) destField.Set(sourceField)

View File

@@ -8,10 +8,11 @@ type OidcClientMetaDataDto struct {
type OidcClientDto struct { type OidcClientDto struct {
OidcClientMetaDataDto OidcClientMetaDataDto
CallbackURLs []string `json:"callbackURLs"` CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"` LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"` IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"` PkceEnabled bool `json:"pkceEnabled"`
Credentials OidcClientCredentialsDto `json:"credentials"`
} }
type OidcClientWithAllowedUserGroupsDto struct { type OidcClientWithAllowedUserGroupsDto struct {
@@ -25,11 +26,23 @@ type OidcClientWithAllowedGroupsCountDto struct {
} }
type OidcClientCreateDto struct { type OidcClientCreateDto struct {
Name string `json:"name" binding:"required,max=50"` Name string `json:"name" binding:"required,max=50"`
CallbackURLs []string `json:"callbackURLs" binding:"required"` CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"` LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"` IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"` 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 { type AuthorizeOidcClientRequestDto struct {
@@ -52,13 +65,15 @@ type AuthorizationRequiredDto struct {
} }
type OidcCreateTokensDto struct { type OidcCreateTokensDto struct {
GrantType string `form:"grant_type" binding:"required"` GrantType string `form:"grant_type" binding:"required"`
Code string `form:"code"` Code string `form:"code"`
DeviceCode string `form:"device_code"` DeviceCode string `form:"device_code"`
ClientID string `form:"client_id"` ClientID string `form:"client_id"`
ClientSecret string `form:"client_secret"` ClientSecret string `form:"client_secret"`
CodeVerifier string `form:"code_verifier"` CodeVerifier string `form:"code_verifier"`
RefreshToken string `form:"refresh_token"` RefreshToken string `form:"refresh_token"`
ClientAssertion string `form:"client_assertion"`
ClientAssertionType string `form:"client_assertion_type"`
} }
type OidcIntrospectDto struct { type OidcIntrospectDto struct {
@@ -98,9 +113,11 @@ type OidcIntrospectionResponseDto struct {
} }
type OidcDeviceAuthorizationRequestDto struct { type OidcDeviceAuthorizationRequestDto struct {
ClientID string `form:"client_id" binding:"required"` ClientID string `form:"client_id" binding:"required"`
Scope string `form:"scope" binding:"required"` Scope string `form:"scope" binding:"required"`
ClientSecret string `form:"client_secret"` ClientSecret string `form:"client_secret"`
ClientAssertion string `form:"client_assertion"`
ClientAssertionType string `form:"client_assertion_type"`
} }
type OidcDeviceAuthorizationResponseDto struct { type OidcDeviceAuthorizationResponseDto struct {
@@ -125,3 +142,14 @@ type DeviceCodeInfoDto struct {
AuthorizationRequired bool `json:"authorizationRequired"` AuthorizationRequired bool `json:"authorizationRequired"`
Client OidcClientMetaDataDto `json:"client"` 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,21 @@
package dto
import (
"time"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
type SignupTokenCreateDto struct {
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"`
}
type SignupTokenDto struct {
ID string `json:"id"`
Token string `json:"token"`
ExpiresAt datatype.DateTime `json:"expiresAt"`
UsageLimit int `json:"usageLimit"`
UsageCount int `json:"usageCount"`
CreatedAt datatype.DateTime `json:"createdAt"`
}

View File

@@ -44,3 +44,11 @@ type OneTimeAccessEmailAsAdminDto struct {
type UserUpdateUserGroupDto struct { type UserUpdateUserGroupDto struct {
UserGroupIds []string `json:"userGroupIds" binding:"required"` UserGroupIds []string `json:"userGroupIds" binding:"required"`
} }
type SignUpDto struct {
Username string `json:"username" binding:"required,username,min=2,max=50"`
Email string `json:"email" binding:"required,email"`
FirstName string `json:"firstName" binding:"required,min=1,max=50"`
LastName string `json:"lastName" binding:"max=50"`
Token string `json:"token"`
}

View File

@@ -6,6 +6,10 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "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/common"
"github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/service"
@@ -14,8 +18,17 @@ import (
const heartbeatUrl = "https://analytics.pocket-id.org/heartbeat" const heartbeatUrl = "https://analytics.pocket-id.org/heartbeat"
func (s *Scheduler) RegisterAnalyticsJob(ctx context.Context, appConfig *service.AppConfigService, httpClient *http.Client) error { func (s *Scheduler) RegisterAnalyticsJob(ctx context.Context, appConfig *service.AppConfigService, httpClient *http.Client) error {
jobs := &AnalyticsJob{appConfig: appConfig, httpClient: httpClient} // Skip if analytics are disabled or not in production environment
return s.registerJob(ctx, "SendHeartbeat", "0 0 * * *", jobs.sendHeartbeat, true) 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 { type AnalyticsJob struct {
@@ -24,38 +37,50 @@ type AnalyticsJob struct {
} }
// sendHeartbeat sends a heartbeat to the analytics service // sendHeartbeat sends a heartbeat to the analytics service
func (j *AnalyticsJob) sendHeartbeat(ctx context.Context) error { func (j *AnalyticsJob) sendHeartbeat(parentCtx context.Context) error {
// Skip if analytics are disabled or not in production environment // Skip if analytics are disabled or not in production environment
if common.EnvConfig.AnalyticsDisabled || common.EnvConfig.AppEnv != "production" { if common.EnvConfig.AnalyticsDisabled || common.EnvConfig.AppEnv != "production" {
return nil return nil
} }
body := struct { body, err := json.Marshal(struct {
Version string `json:"version"` Version string `json:"version"`
InstanceID string `json:"instance_id"` InstanceID string `json:"instance_id"`
}{ }{
Version: common.Version, Version: common.Version,
InstanceID: j.appConfig.GetDbConfig().InstanceID.Value, InstanceID: j.appConfig.GetDbConfig().InstanceID.Value,
} })
bodyBytes, err := json.Marshal(body)
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal heartbeat body: %w", err) return fmt.Errorf("failed to marshal heartbeat body: %w", err)
} }
req, err := http.NewRequestWithContext(ctx, http.MethodPost, heartbeatUrl, bytes.NewBuffer(bodyBytes)) _, 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 { if err != nil {
return fmt.Errorf("failed to create heartbeat request: %w", err) return fmt.Errorf("heartbeat request failed: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := j.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to send heartbeat request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("heartbeat request failed with status code: %d", resp.StatusCode)
} }
return nil return nil
} }

View File

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

View File

@@ -3,8 +3,11 @@ package job
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"log/slog"
"time" "time"
"github.com/go-co-op/gocron/v2"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/model"
@@ -14,12 +17,15 @@ import (
func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) error { func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) error {
jobs := &DbCleanupJobs{db: db} 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( return errors.Join(
s.registerJob(ctx, "ClearWebauthnSessions", "0 3 * * *", jobs.clearWebauthnSessions, false), s.registerJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
s.registerJob(ctx, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens, false), s.registerJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true),
s.registerJob(ctx, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes, false), s.registerJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true),
s.registerJob(ctx, "ClearOidcRefreshTokens", "0 3 * * *", jobs.clearOidcRefreshTokens, false), s.registerJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
s.registerJob(ctx, "ClearAuditLogs", "0 3 * * *", jobs.clearAuditLogs, false), s.registerJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
s.registerJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
) )
} }
@@ -29,40 +35,85 @@ type DbCleanupJobs struct {
// ClearWebauthnSessions deletes WebAuthn sessions that have expired // ClearWebauthnSessions deletes WebAuthn sessions that have expired
func (j *DbCleanupJobs) clearWebauthnSessions(ctx context.Context) error { func (j *DbCleanupJobs) clearWebauthnSessions(ctx context.Context) error {
return j.db. st := j.db.
WithContext(ctx). WithContext(ctx).
Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now())). Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now()))
Error 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 // ClearOneTimeAccessTokens deletes one-time access tokens that have expired
func (j *DbCleanupJobs) clearOneTimeAccessTokens(ctx context.Context) error { func (j *DbCleanupJobs) clearOneTimeAccessTokens(ctx context.Context) error {
return j.db. st := j.db.
WithContext(ctx). WithContext(ctx).
Delete(&model.OneTimeAccessToken{}, "expires_at < ?", datatype.DateTime(time.Now())). Delete(&model.OneTimeAccessToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
Error 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
}
// ClearSignupTokens deletes signup tokens that have expired
func (j *DbCleanupJobs) clearSignupTokens(ctx context.Context) error {
// Delete tokens that are expired OR have reached their usage limit
st := j.db.
WithContext(ctx).
Delete(&model.SignupToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired tokens: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired tokens", slog.Int64("count", st.RowsAffected))
return nil
} }
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired // ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
func (j *DbCleanupJobs) clearOidcAuthorizationCodes(ctx context.Context) error { func (j *DbCleanupJobs) clearOidcAuthorizationCodes(ctx context.Context) error {
return j.db. st := j.db.
WithContext(ctx). WithContext(ctx).
Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now())). Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now()))
Error 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 // ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
func (j *DbCleanupJobs) clearOidcRefreshTokens(ctx context.Context) error { func (j *DbCleanupJobs) clearOidcRefreshTokens(ctx context.Context) error {
return j.db. st := j.db.
WithContext(ctx). WithContext(ctx).
Delete(&model.OidcRefreshToken{}, "expires_at < ?", datatype.DateTime(time.Now())). Delete(&model.OidcRefreshToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
Error 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 // ClearAuditLogs deletes audit logs older than 90 days
func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error { func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
return j.db. st := j.db.
WithContext(ctx). WithContext(ctx).
Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90))). Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90)))
Error 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 ( import (
"context" "context"
"fmt" "fmt"
"log" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"github.com/go-co-op/gocron/v2"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common" "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 { func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB) error {
jobs := &FileCleanupJobs{db: db} jobs := &FileCleanupJobs{db: db}
return s.registerJob(ctx, "ClearUnusedDefaultProfilePictures", "0 2 * * 0", jobs.clearUnusedDefaultProfilePictures, false) // Run every 24 hours
return s.registerJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, false)
} }
type FileCleanupJobs struct { type FileCleanupJobs struct {
@@ -64,13 +67,13 @@ func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context)
if _, ok := initialsInUse[initials]; !ok { if _, ok := initialsInUse[initials]; !ok {
filePath := filepath.Join(defaultPicturesDir, filename) filePath := filepath.Join(defaultPicturesDir, filename)
if err := os.Remove(filePath); err != nil { 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 { } else {
filesDeleted++ filesDeleted++
} }
} }
} }
log.Printf("Deleted %d unused default profile pictures", filesDeleted) slog.Info("Done deleting unused default profile pictures", slog.Int("count", filesDeleted))
return nil return nil
} }

View File

@@ -2,6 +2,9 @@ package job
import ( import (
"context" "context"
"time"
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/service"
) )
@@ -19,8 +22,8 @@ func (s *Scheduler) RegisterGeoLiteUpdateJobs(ctx context.Context, geoLiteServic
jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService} jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService}
// Register the job to run every day, at 5 minutes past midnight // Run every 24 hours (and right away)
return s.registerJob(ctx, "UpdateGeoLiteDB", "5 * */1 * *", jobs.updateGoeLiteDB, true) return s.registerJob(ctx, "UpdateGeoLiteDB", gocron.DurationJob(24*time.Hour), jobs.updateGoeLiteDB, true)
} }
func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error { func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error {

View File

@@ -2,6 +2,9 @@ package job
import ( import (
"context" "context"
"time"
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/service"
) )
@@ -15,7 +18,7 @@ func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.L
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService} jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
// Register the job to run every hour // Register the job to run every hour
return s.registerJob(ctx, "SyncLdap", "0 * * * *", jobs.syncLdap, true) return s.registerJob(ctx, "SyncLdap", gocron.DurationJob(time.Hour), jobs.syncLdap, true)
} }
func (j *LdapJobs) syncLdap(ctx context.Context) error { func (j *LdapJobs) syncLdap(ctx context.Context) error {

View File

@@ -3,7 +3,7 @@ package job
import ( import (
"context" "context"
"fmt" "fmt"
"log" "log/slog"
"github.com/go-co-op/gocron/v2" "github.com/go-co-op/gocron/v2"
"github.com/google/uuid" "github.com/google/uuid"
@@ -27,7 +27,7 @@ func NewScheduler() (*Scheduler, error) {
// Run the scheduler. // Run the scheduler.
// This function blocks until the context is canceled. // This function blocks until the context is canceled.
func (s *Scheduler) Run(ctx context.Context) error { func (s *Scheduler) Run(ctx context.Context) error {
log.Println("Starting job scheduler") slog.Info("Starting job scheduler")
s.scheduler.Start() s.scheduler.Start()
// Block until context is canceled // Block until context is canceled
@@ -35,23 +35,36 @@ func (s *Scheduler) Run(ctx context.Context) error {
err := s.scheduler.Shutdown() err := s.scheduler.Shutdown()
if err != nil { 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 { } else {
log.Println("Job scheduler shut down") slog.Info("Job scheduler shut down")
} }
return nil return nil
} }
func (s *Scheduler) registerJob(ctx context.Context, name string, interval string, job func(ctx context.Context) error, runImmediately bool) error { func (s *Scheduler) registerJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, runImmediately bool) error {
jobOptions := []gocron.JobOption{ jobOptions := []gocron.JobOption{
gocron.WithContext(ctx), gocron.WithContext(ctx),
gocron.WithEventListeners( 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) { 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) { 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),
)
}), }),
), ),
} }
@@ -60,11 +73,7 @@ func (s *Scheduler) registerJob(ctx context.Context, name string, interval strin
jobOptions = append(jobOptions, gocron.JobOption(gocron.WithStartImmediately())) jobOptions = append(jobOptions, gocron.JobOption(gocron.WithStartImmediately()))
} }
_, err := s.scheduler.NewJob( _, err := s.scheduler.NewJob(def, gocron.NewTask(job), jobOptions...)
gocron.CronJob(interval, false),
gocron.NewTask(job),
jobOptions...,
)
if err != nil { if err != nil {
return fmt.Errorf("failed to register job %q: %w", name, err) 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-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Authorization")
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST") c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST")
// Preflight request // Preflight request

View File

@@ -8,6 +8,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/pocket-id/pocket-id/backend/internal/common"
) )
type AppConfigVariable struct { type AppConfigVariable struct {
@@ -35,8 +37,10 @@ type AppConfig struct {
AppName AppConfigVariable `key:"appName,public"` // Public AppName AppConfigVariable `key:"appName,public"` // Public
SessionDuration AppConfigVariable `key:"sessionDuration"` SessionDuration AppConfigVariable `key:"sessionDuration"`
EmailsVerified AppConfigVariable `key:"emailsVerified"` EmailsVerified AppConfigVariable `key:"emailsVerified"`
AccentColor AppConfigVariable `key:"accentColor,public"` // Public
DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public
AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public
AllowUserSignups AppConfigVariable `key:"allowUserSignups,public"` // Public
// Internal // Internal
BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal
LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal
@@ -47,7 +51,7 @@ type AppConfig struct {
SmtpPort AppConfigVariable `key:"smtpPort"` SmtpPort AppConfigVariable `key:"smtpPort"`
SmtpFrom AppConfigVariable `key:"smtpFrom"` SmtpFrom AppConfigVariable `key:"smtpFrom"`
SmtpUser AppConfigVariable `key:"smtpUser"` SmtpUser AppConfigVariable `key:"smtpUser"`
SmtpPassword AppConfigVariable `key:"smtpPassword"` SmtpPassword AppConfigVariable `key:"smtpPassword,sensitive"`
SmtpTls AppConfigVariable `key:"smtpTls"` SmtpTls AppConfigVariable `key:"smtpTls"`
SmtpSkipCertVerify AppConfigVariable `key:"smtpSkipCertVerify"` SmtpSkipCertVerify AppConfigVariable `key:"smtpSkipCertVerify"`
EmailLoginNotificationEnabled AppConfigVariable `key:"emailLoginNotificationEnabled"` EmailLoginNotificationEnabled AppConfigVariable `key:"emailLoginNotificationEnabled"`
@@ -58,7 +62,7 @@ type AppConfig struct {
LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public
LdapUrl AppConfigVariable `key:"ldapUrl"` LdapUrl AppConfigVariable `key:"ldapUrl"`
LdapBindDn AppConfigVariable `key:"ldapBindDn"` LdapBindDn AppConfigVariable `key:"ldapBindDn"`
LdapBindPassword AppConfigVariable `key:"ldapBindPassword"` LdapBindPassword AppConfigVariable `key:"ldapBindPassword,sensitive"`
LdapBase AppConfigVariable `key:"ldapBase"` LdapBase AppConfigVariable `key:"ldapBase"`
LdapUserSearchFilter AppConfigVariable `key:"ldapUserSearchFilter"` LdapUserSearchFilter AppConfigVariable `key:"ldapUserSearchFilter"`
LdapUserGroupSearchFilter AppConfigVariable `key:"ldapUserGroupSearchFilter"` LdapUserGroupSearchFilter AppConfigVariable `key:"ldapUserGroupSearchFilter"`
@@ -76,7 +80,7 @@ type AppConfig struct {
LdapSoftDeleteUsers AppConfigVariable `key:"ldapSoftDeleteUsers"` LdapSoftDeleteUsers AppConfigVariable `key:"ldapSoftDeleteUsers"`
} }
func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable { func (c *AppConfig) ToAppConfigVariableSlice(showAll bool, redactSensitiveValues bool) []AppConfigVariable {
// Use reflection to iterate through all fields // Use reflection to iterate through all fields
cfgValue := reflect.ValueOf(c).Elem() cfgValue := reflect.ValueOf(c).Elem()
cfgType := cfgValue.Type() cfgType := cfgValue.Type()
@@ -96,11 +100,16 @@ func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable {
continue continue
} }
fieldValue := cfgValue.Field(i) value := cfgValue.Field(i).FieldByName("Value").String()
// Redact sensitive values if the value isn't empty, the UI config is disabled, and redactSensitiveValues is true
if value != "" && common.EnvConfig.UiConfigDisabled && redactSensitiveValues && attrs == "sensitive" {
value = "XXXXXXXXXX"
}
appConfigVariable := AppConfigVariable{ appConfigVariable := AppConfigVariable{
Key: key, Key: key,
Value: fieldValue.FieldByName("Value").String(), Value: value,
} }
res = append(res, appConfigVariable) res = append(res, appConfigVariable)

View File

@@ -28,6 +28,7 @@ type AuditLogEvent string //nolint:recvcheck
const ( const (
AuditLogEventSignIn AuditLogEvent = "SIGN_IN" AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN" AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
AuditLogEventAccountCreated AuditLogEvent = "ACCOUNT_CREATED"
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION" AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION" AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
AuditLogEventDeviceCodeAuthorization AuditLogEvent = "DEVICE_CODE_AUTHORIZATION" AuditLogEventDeviceCodeAuthorization AuditLogEvent = "DEVICE_CODE_AUTHORIZATION"

View File

@@ -5,8 +5,9 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"gorm.io/gorm" "gorm.io/gorm"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
) )
type UserAuthorizedOidcClient struct { type UserAuthorizedOidcClient struct {
@@ -45,6 +46,7 @@ type OidcClient struct {
HasLogo bool `gorm:"-"` HasLogo bool `gorm:"-"`
IsPublic bool IsPublic bool
PkceEnabled bool PkceEnabled bool
Credentials OidcClientCredentials
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"` AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
CreatedByID string CreatedByID string
@@ -71,9 +73,49 @@ func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
return nil 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 type UrlList []string //nolint:recvcheck
func (cu *UrlList) Scan(value interface{}) error { func (cu *UrlList) Scan(value any) error {
switch v := value.(type) { switch v := value.(type) {
case []byte: case []byte:
return json.Unmarshal(v, cu) return json.Unmarshal(v, cu)

View File

@@ -0,0 +1,28 @@
package model
import (
"time"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
type SignupToken struct {
Base
Token string `json:"token"`
ExpiresAt datatype.DateTime `json:"expiresAt" sortable:"true"`
UsageLimit int `json:"usageLimit" sortable:"true"`
UsageCount int `json:"usageCount" sortable:"true"`
}
func (st *SignupToken) IsExpired() bool {
return time.Time(st.ExpiresAt).Before(time.Now())
}
func (st *SignupToken) IsUsageLimitReached() bool {
return st.UsageCount >= st.UsageLimit
}
func (st *SignupToken) IsValid() bool {
return !st.IsExpired() && !st.IsUsageLimitReached()
}

View File

@@ -29,17 +29,17 @@ type AppConfigService struct {
db *gorm.DB db *gorm.DB
} }
func NewAppConfigService(initCtx context.Context, db *gorm.DB) *AppConfigService { func NewAppConfigService(ctx context.Context, db *gorm.DB) *AppConfigService {
service := &AppConfigService{ service := &AppConfigService{
db: db, db: db,
} }
err := service.LoadDbConfig(initCtx) err := service.LoadDbConfig(ctx)
if err != nil { if err != nil {
log.Fatalf("Failed to initialize app config service: %v", err) log.Fatalf("Failed to initialize app config service: %v", err)
} }
err = service.initInstanceID(initCtx) err = service.initInstanceID(ctx)
if err != nil { if err != nil {
log.Fatalf("Failed to initialize instance ID: %v", err) log.Fatalf("Failed to initialize instance ID: %v", err)
} }
@@ -68,6 +68,8 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
EmailsVerified: model.AppConfigVariable{Value: "false"}, EmailsVerified: model.AppConfigVariable{Value: "false"},
DisableAnimations: model.AppConfigVariable{Value: "false"}, DisableAnimations: model.AppConfigVariable{Value: "false"},
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"}, AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
AllowUserSignups: model.AppConfigVariable{Value: "disabled"},
AccentColor: model.AppConfigVariable{Value: "default"},
// Internal // Internal
BackgroundImageType: model.AppConfigVariable{Value: "jpg"}, BackgroundImageType: model.AppConfigVariable{Value: "jpg"},
LogoLightImageType: model.AppConfigVariable{Value: "svg"}, LogoLightImageType: model.AppConfigVariable{Value: "svg"},
@@ -232,11 +234,11 @@ func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppCon
s.dbConfig.Store(cfg) s.dbConfig.Store(cfg)
// Return the updated config // Return the updated config
res := cfg.ToAppConfigVariableSlice(true) res := cfg.ToAppConfigVariableSlice(true, false)
return res, nil return res, nil
} }
// UpdateAppConfigValues // UpdateAppConfigValues updates the application configuration values in the database.
func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndValues ...string) error { func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndValues ...string) error {
// Count of keysAndValues must be even // Count of keysAndValues must be even
if len(keysAndValues)%2 != 0 { if len(keysAndValues)%2 != 0 {
@@ -317,11 +319,11 @@ func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndVal
} }
func (s *AppConfigService) ListAppConfig(showAll bool) []model.AppConfigVariable { func (s *AppConfigService) ListAppConfig(showAll bool) []model.AppConfigVariable {
return s.GetDbConfig().ToAppConfigVariableSlice(showAll) return s.GetDbConfig().ToAppConfigVariableSlice(showAll, true)
} }
func (s *AppConfigService) UpdateImage(ctx context.Context, uploadedFile *multipart.FileHeader, imageName string, oldImageType string) (err error) { 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) mimeType := utils.GetImageMimeType(fileType)
if mimeType == "" { if mimeType == "" {
return &common.FileTypeNotSupportedError{} return &common.FileTypeNotSupportedError{}
@@ -355,24 +357,52 @@ func (s *AppConfigService) UpdateImage(ctx context.Context, uploadedFile *multip
// LoadDbConfig loads the configuration values from the database into the DbConfig struct. // LoadDbConfig loads the configuration values from the database into the DbConfig struct.
func (s *AppConfigService) LoadDbConfig(ctx context.Context) (err error) { func (s *AppConfigService) LoadDbConfig(ctx context.Context) (err error) {
var dest *model.AppConfig dest, err := s.loadDbConfigInternal(ctx, s.db)
// 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)
}
if err != nil { if err != nil {
return err return err
} }
// Update the value in the object
s.dbConfig.Store(dest) s.dbConfig.Store(dest)
return nil 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, tx)
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) { func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB) (*model.AppConfig, error) {
// First, start from the default configuration // First, start from the default configuration
dest := s.getDefaultDbConfig() dest := s.getDefaultDbConfig()
@@ -414,41 +444,6 @@ func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB)
return dest, nil 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()
// 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 {
// 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)
}
}
return dest, nil
}
func (s *AppConfigService) initInstanceID(ctx context.Context) error { func (s *AppConfigService) initInstanceID(ctx context.Context) error {
// Check if the instance ID is already set // Check if the instance ID is already set
instanceID := s.GetDbConfig().InstanceID.Value instanceID := s.GetDbConfig().InstanceID.Value

View File

@@ -3,16 +3,10 @@ package service
import ( import (
"sync/atomic" "sync/atomic"
"testing" "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/common"
"github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -28,7 +22,7 @@ func NewTestAppConfigService(config *model.AppConfig) *AppConfigService {
func TestLoadDbConfig(t *testing.T) { func TestLoadDbConfig(t *testing.T) {
t.Run("empty config table", func(t *testing.T) { t.Run("empty config table", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t) db := newDatabaseForTest(t)
service := &AppConfigService{ service := &AppConfigService{
db: db, db: db,
} }
@@ -42,14 +36,13 @@ func TestLoadDbConfig(t *testing.T) {
}) })
t.Run("loads value from config table", func(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 // Populate the config table with some initial values
err := db. err := db.
Create([]model.AppConfigVariable{ Create([]model.AppConfigVariable{
// Should be set to the default value because it's an empty string
{Key: "appName", Value: ""},
// Overrides default value // Overrides default value
{Key: "appName", Value: "Test App"},
{Key: "sessionDuration", Value: "5"}, {Key: "sessionDuration", Value: "5"},
// Does not have a default value // Does not have a default value
{Key: "smtpHost", Value: "example"}, {Key: "smtpHost", Value: "example"},
@@ -66,13 +59,14 @@ func TestLoadDbConfig(t *testing.T) {
// Values should match expected ones // Values should match expected ones
expect := service.getDefaultDbConfig() expect := service.getDefaultDbConfig()
expect.AppName.Value = "Test App"
expect.SessionDuration.Value = "5" expect.SessionDuration.Value = "5"
expect.SmtpHost.Value = "example" expect.SmtpHost.Value = "example"
require.Equal(t, service.GetDbConfig(), expect) require.Equal(t, service.GetDbConfig(), expect)
}) })
t.Run("ignores unknown config keys", func(t *testing.T) { 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 // Add an entry with a key that doesn't exist in the config struct
err := db.Create([]model.AppConfigVariable{ err := db.Create([]model.AppConfigVariable{
@@ -93,7 +87,7 @@ func TestLoadDbConfig(t *testing.T) {
}) })
t.Run("loading config multiple times", func(t *testing.T) { t.Run("loading config multiple times", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t) db := newDatabaseForTest(t)
// Initial state // Initial state
err := db.Create([]model.AppConfigVariable{ err := db.Create([]model.AppConfigVariable{
@@ -135,7 +129,7 @@ func TestLoadDbConfig(t *testing.T) {
common.EnvConfig.UiConfigDisabled = true common.EnvConfig.UiConfigDisabled = true
// Create database with config that should be ignored // Create database with config that should be ignored
db := newAppConfigTestDatabaseForTest(t) db := newDatabaseForTest(t)
err := db.Create([]model.AppConfigVariable{ err := db.Create([]model.AppConfigVariable{
{Key: "appName", Value: "DB App"}, {Key: "appName", Value: "DB App"},
{Key: "sessionDuration", Value: "120"}, {Key: "sessionDuration", Value: "120"},
@@ -171,7 +165,7 @@ func TestLoadDbConfig(t *testing.T) {
common.EnvConfig.UiConfigDisabled = false common.EnvConfig.UiConfigDisabled = false
// Create database with config values that should take precedence // Create database with config values that should take precedence
db := newAppConfigTestDatabaseForTest(t) db := newDatabaseForTest(t)
err := db.Create([]model.AppConfigVariable{ err := db.Create([]model.AppConfigVariable{
{Key: "appName", Value: "DB App"}, {Key: "appName", Value: "DB App"},
{Key: "sessionDuration", Value: "120"}, {Key: "sessionDuration", Value: "120"},
@@ -195,7 +189,7 @@ func TestLoadDbConfig(t *testing.T) {
func TestUpdateAppConfigValues(t *testing.T) { func TestUpdateAppConfigValues(t *testing.T) {
t.Run("update single value", func(t *testing.T) { t.Run("update single value", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t) db := newDatabaseForTest(t)
// Create a service with default config // Create a service with default config
service := &AppConfigService{ service := &AppConfigService{
@@ -220,7 +214,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
}) })
t.Run("update multiple values", func(t *testing.T) { t.Run("update multiple values", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t) db := newDatabaseForTest(t)
// Create a service with default config // Create a service with default config
service := &AppConfigService{ service := &AppConfigService{
@@ -264,7 +258,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
}) })
t.Run("empty value resets to default", func(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 // Create a service with default config
service := &AppConfigService{ service := &AppConfigService{
@@ -285,7 +279,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
}) })
t.Run("error with odd number of arguments", func(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 // Create a service with default config
service := &AppConfigService{ service := &AppConfigService{
@@ -301,7 +295,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
}) })
t.Run("error with invalid key", func(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 // Create a service with default config
service := &AppConfigService{ service := &AppConfigService{
@@ -319,7 +313,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
func TestUpdateAppConfig(t *testing.T) { func TestUpdateAppConfig(t *testing.T) {
t.Run("updates configuration values from DTO", func(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 // Create a service with default config
service := &AppConfigService{ service := &AppConfigService{
@@ -392,7 +386,7 @@ func TestUpdateAppConfig(t *testing.T) {
}) })
t.Run("empty values reset to defaults", func(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 // Create a service with default config and modify some values
service := &AppConfigService{ service := &AppConfigService{
@@ -457,7 +451,7 @@ func TestUpdateAppConfig(t *testing.T) {
// Disable UI config // Disable UI config
common.EnvConfig.UiConfigDisabled = true common.EnvConfig.UiConfigDisabled = true
db := newAppConfigTestDatabaseForTest(t) db := newDatabaseForTest(t)
service := &AppConfigService{ service := &AppConfigService{
db: db, db: db,
} }
@@ -475,49 +469,3 @@ func TestUpdateAppConfig(t *testing.T) {
require.ErrorAs(t, err, &uiConfigDisabledErr) 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) 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) pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &logs)
if err != nil { if err != nil {

View File

@@ -5,6 +5,8 @@ package service
import ( import (
"context" "context"
"crypto/ecdsa" "crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509" "crypto/x509"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
@@ -16,6 +18,7 @@ import (
"github.com/fxamacker/cbor/v2" "github.com/fxamacker/cbor/v2"
"github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
"github.com/lestrrat-go/jwx/v3/jwk" "github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/common"
@@ -30,14 +33,43 @@ type TestService struct {
jwtService *JwtService jwtService *JwtService
appConfigService *AppConfigService appConfigService *AppConfigService
ldapService *LdapService ldapService *LdapService
externalIdPKey jwk.Key
} }
func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService, ldapService *LdapService) *TestService { func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService, ldapService *LdapService) (*TestService, error) {
return &TestService{db: db, appConfigService: appConfigService, jwtService: jwtService, ldapService: ldapService} 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 //nolint:gocognit
func (s *TestService) SeedDatabase() error { func (s *TestService) SeedDatabase(baseURL string) error {
err := s.db.Transaction(func(tx *gorm.DB) error { err := s.db.Transaction(func(tx *gorm.DB) error {
users := []model.User{ users := []model.User{
{ {
@@ -138,6 +170,26 @@ func (s *TestService) SeedDatabase() error {
userGroups[1], 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 { for _, client := range oidcClients {
if err := tx.Create(&client).Error; err != nil { if err := tx.Create(&client).Error; err != nil {
@@ -145,16 +197,28 @@ func (s *TestService) SeedDatabase() error {
} }
} }
authCode := model.OidcAuthorizationCode{ authCodes := []model.OidcAuthorizationCode{
Code: "auth-code", {
Scope: "openid profile", Code: "auth-code",
Nonce: "nonce", Scope: "openid profile",
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)), Nonce: "nonce",
UserID: users[0].ID, ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
ClientID: oidcClients[0].ID, 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 { for _, authCode := range authCodes {
return err if err := tx.Create(&authCode).Error; err != nil {
return err
}
} }
refreshToken := model.OidcRefreshToken{ refreshToken := model.OidcRefreshToken{
@@ -177,13 +241,22 @@ func (s *TestService) SeedDatabase() error {
return err return err
} }
userAuthorizedClient := model.UserAuthorizedOidcClient{ userAuthorizedClients := []model.UserAuthorizedOidcClient{
Scope: "openid profile email", {
UserID: users[0].ID, Scope: "openid profile email",
ClientID: oidcClients[0].ID, 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 { for _, userAuthorizedClient := range userAuthorizedClients {
return err if err := tx.Create(&userAuthorizedClient).Error; err != nil {
return err
}
} }
// To generate a new key pair, run the following command: // To generate a new key pair, run the following command:
@@ -237,6 +310,50 @@ func (s *TestService) SeedDatabase() error {
return err return err
} }
signupTokens := []model.SignupToken{
{
Base: model.Base{
ID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
},
Token: "VALID1234567890A",
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
UsageLimit: 1,
UsageCount: 0,
},
{
Base: model.Base{
ID: "b2c3d4e5-f6g7-8901-bcde-f12345678901",
},
Token: "PARTIAL567890ABC",
ExpiresAt: datatype.DateTime(time.Now().Add(7 * 24 * time.Hour)),
UsageLimit: 5,
UsageCount: 2,
},
{
Base: model.Base{
ID: "c3d4e5f6-g7h8-9012-cdef-123456789012",
},
Token: "EXPIRED34567890B",
ExpiresAt: datatype.DateTime(time.Now().Add(-24 * time.Hour)), // Expired
UsageLimit: 3,
UsageCount: 1,
},
{
Base: model.Base{
ID: "d4e5f6g7-h8i9-0123-def0-234567890123",
},
Token: "FULLYUSED567890C",
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
UsageLimit: 1,
UsageCount: 1, // Usage limit reached
},
}
for _, token := range signupTokens {
if err := tx.Create(&token).Error; err != nil {
return err
}
}
return nil return nil
}) })
@@ -405,3 +522,45 @@ func (s *TestService) SetLdapTestConfig(ctx context.Context) error {
return nil 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" "net/netip"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"time" "time"
@@ -22,9 +23,10 @@ import (
) )
type GeoLiteService struct { type GeoLiteService struct {
httpClient *http.Client httpClient *http.Client
disableUpdater bool disableUpdater bool
mutex sync.RWMutex mutex sync.RWMutex
localIPv6Ranges []*net.IPNet
} }
var localhostIPNets = []*net.IPNet{ var localhostIPNets = []*net.IPNet{
@@ -54,9 +56,66 @@ func NewGeoLiteService(httpClient *http.Client) *GeoLiteService {
service.disableUpdater = true 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 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 { func (s *GeoLiteService) DisableUpdater() bool {
return s.disableUpdater return s.disableUpdater
} }
@@ -65,6 +124,12 @@ func (s *GeoLiteService) DisableUpdater() bool {
func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string, err error) { func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string, err error) {
// Check the IP address against known private IP ranges // Check the IP address against known private IP ranges
if ip := net.ParseIP(ipAddress); ip != nil { 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 { for _, ipNet := range tailscaleIPNets {
if ipNet.Contains(ip) { if ipNet.Contains(ip) {
return "Internal Network", "Tailscale", nil return "Internal Network", "Tailscale", nil
@@ -72,7 +137,7 @@ func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string
} }
for _, ipNet := range privateLanIPNets { for _, ipNet := range privateLanIPNets {
if ipNet.Contains(ip) { if ipNet.Contains(ip) {
return "Internal Network", "LAN/Docker/k8s", nil return "Internal Network", "LAN", nil
} }
} }
for _, ipNet := range localhostIPNets { 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" "context"
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
@@ -41,9 +39,15 @@ const (
// TokenTypeClaim is the claim used to identify the type of token // TokenTypeClaim is the claim used to identify the type of token
TokenTypeClaim = "type" 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 identifies a JWT as an OAuth access token
OAuthAccessTokenJWTType = "oauth-access-token" //nolint:gosec 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 identifies a JWT as an access token used by Pocket ID
AccessTokenJWTType = "access-token" AccessTokenJWTType = "access-token"
@@ -236,7 +240,8 @@ func (s *JwtService) VerifyAccessToken(tokenString string) (jwt.Token, error) {
return token, nil 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() now := time.Now()
token, err := jwt.NewBuilder(). token, err := jwt.NewBuilder().
Expiration(now.Add(1 * time.Hour)). Expiration(now.Add(1 * time.Hour)).
@@ -244,33 +249,43 @@ func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string,
Issuer(common.EnvConfig.AppURL). Issuer(common.EnvConfig.AppURL).
Build() Build()
if err != nil { 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) err = SetAudienceString(token, clientID)
if err != nil { 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) err = SetTokenType(token, IDTokenJWTType)
if err != nil { 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 { for k, v := range userClaims {
err = token.Set(k, v) err = token.Set(k, v)
if err != nil { 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 != "" { if nonce != "" {
err = token.Set("nonce", nonce) err = token.Set("nonce", nonce)
if err != nil { 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() alg, _ := s.privateKey.Algorithm()
signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey)) signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey))
if err != nil { if err != nil {
@@ -313,7 +328,8 @@ func (s *JwtService) VerifyIdToken(tokenString string, acceptExpiredTokens bool)
return token, nil 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() now := time.Now()
token, err := jwt.NewBuilder(). token, err := jwt.NewBuilder().
Subject(user.ID). Subject(user.ID).
@@ -322,17 +338,27 @@ func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string)
Issuer(common.EnvConfig.AppURL). Issuer(common.EnvConfig.AppURL).
Build() Build()
if err != nil { 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) err = SetAudienceString(token, clientID)
if err != nil { 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) err = SetTokenType(token, OAuthAccessTokenJWTType)
if err != nil { 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() alg, _ := s.privateKey.Algorithm()
@@ -344,7 +370,7 @@ func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string)
return string(signed), nil 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() alg, _ := s.privateKey.Algorithm()
token, err := jwt.ParseString( token, err := jwt.ParseString(
tokenString, tokenString,
@@ -361,6 +387,96 @@ func (s *JwtService) VerifyOauthAccessToken(tokenString string) (jwt.Token, erro
return token, nil 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. // GetPublicJWK returns the JSON Web Key (JWK) for the public key.
func (s *JwtService) GetPublicJWK() (jwk.Key, error) { func (s *JwtService) GetPublicJWK() (jwk.Key, error) {
if s.privateKey == nil { 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) return nil, fmt.Errorf("failed to get public key: %w", err)
} }
EnsureAlgInKey(pubKey) utils.EnsureAlgInKey(pubKey)
return pubKey, nil return pubKey, nil
} }
@@ -415,27 +531,6 @@ func (s *JwtService) loadKeyJWK(path string) (jwk.Key, error) {
return key, nil 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) { func (s *JwtService) generateNewRSAKey() (jwk.Key, error) {
// We generate RSA keys only // We generate RSA keys only
rawKey, err := rsa.GenerateKey(rand.Reader, RsaKeySize) rawKey, err := rsa.GenerateKey(rand.Reader, RsaKeySize)
@@ -444,27 +539,7 @@ func (s *JwtService) generateNewRSAKey() (jwk.Key, error) {
} }
// Import the raw key // Import the raw key
return importRawKey(rawKey) return utils.ImportRawKey(rawKey)
}
func importRawKey(rawKey any) (jwk.Key, error) {
key, err := jwk.Import(rawKey)
if err != nil {
return nil, fmt.Errorf("failed to import generated private key: %w", err)
}
// Generate the key ID
kid, err := generateRandomKeyID()
if err != nil {
return nil, fmt.Errorf("failed to generate key ID: %w", err)
}
_ = key.Set(jwk.KeyIDKey, kid)
// Set other required fields
_ = key.Set(jwk.KeyUsageKey, KeyUsageSigning)
EnsureAlgInKey(key)
return key, err
} }
// SaveKeyJWK saves a JWK to a file // SaveKeyJWK saves a JWK to a file
@@ -492,16 +567,6 @@ func SaveKeyJWK(key jwk.Key, path string) error {
return nil 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 // GetIsAdmin returns the value of the "isAdmin" claim in the token
func GetIsAdmin(token jwt.Token) (bool, error) { func GetIsAdmin(token jwt.Token) (bool, error) {
if !token.Has(IsAdminClaim) { if !token.Has(IsAdminClaim) {
@@ -509,7 +574,10 @@ func GetIsAdmin(token jwt.Token) (bool, error) {
} }
var isAdmin bool var isAdmin bool
err := token.Get(IsAdminClaim, &isAdmin) 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 // 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/common"
"github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
) )
func TestJwtService_Init(t *testing.T) { 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 // Create a temporary directory for the test
tempDir := t.TempDir() tempDir := t.TempDir()
@@ -913,12 +914,12 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
const clientID = "test-client-123" const clientID = "test-client-123"
// Generate a token // 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") require.NoError(t, err, "Failed to generate OAuth access token")
assert.NotEmpty(t, tokenString, "Token should not be empty") assert.NotEmpty(t, tokenString, "Token should not be empty")
// Verify the token // Verify the token
claims, err := service.VerifyOauthAccessToken(tokenString) claims, err := service.VerifyOAuthAccessToken(tokenString)
require.NoError(t, err, "Failed to verify generated OAuth access token") require.NoError(t, err, "Failed to verify generated OAuth access token")
// Check the claims // Check the claims
@@ -971,7 +972,7 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
require.NoError(t, err, "Failed to sign token") require.NoError(t, err, "Failed to sign token")
// Verify should fail due to expiration // 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") 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") 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" const clientID = "test-client-789"
// Generate a token with the first service // 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") require.NoError(t, err, "Failed to generate OAuth access token")
// Verify with the second service should fail due to different keys // 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") require.Error(t, err, "Verification should fail with invalid signature")
assert.Contains(t, err.Error(), "verification error", "Error message should indicate token verification failure") 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" const clientID = "eddsa-oauth-client"
// Generate a token // 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") require.NoError(t, err, "Failed to generate OAuth access token with key")
assert.NotEmpty(t, tokenString, "Token should not be empty") assert.NotEmpty(t, tokenString, "Token should not be empty")
// Verify the token // 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") require.NoError(t, err, "Failed to verify generated OAuth access token with key")
// Check the claims // Check the claims
@@ -1085,12 +1086,12 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
const clientID = "ecdsa-oauth-client" const clientID = "ecdsa-oauth-client"
// Generate a token // 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") require.NoError(t, err, "Failed to generate OAuth access token with key")
assert.NotEmpty(t, tokenString, "Token should not be empty") assert.NotEmpty(t, tokenString, "Token should not be empty")
// Verify the token // 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") require.NoError(t, err, "Failed to verify generated OAuth access token with key")
// Check the claims // Check the claims
@@ -1139,12 +1140,12 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
const clientID = "rsa-oauth-client" const clientID = "rsa-oauth-client"
// Generate a token // 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") require.NoError(t, err, "Failed to generate OAuth access token with key")
assert.NotEmpty(t, tokenString, "Token should not be empty") assert.NotEmpty(t, tokenString, "Token should not be empty")
// Verify the token // 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") require.NoError(t, err, "Failed to verify generated OAuth access token with key")
// Check the claims // 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) { func TestTokenTypeValidator(t *testing.T) {
// Create a context for the validator function // Create a context for the validator function
ctx := context.Background() ctx := context.Background()
@@ -1212,13 +1299,110 @@ func TestTokenTypeValidator(t *testing.T) {
require.Error(t, err, "Validator should reject token without type claim") require.Error(t, err, "Validator should reject token without type claim")
assert.Contains(t, err.Error(), "failed to get token 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 { func importKey(t *testing.T, privateKeyRaw any, path string) string {
t.Helper() t.Helper()
privateKey, err := importRawKey(privateKeyRaw) privateKey, err := utils.ImportRawKey(privateKeyRaw)
require.NoError(t, err, "Failed to import private key") require.NoError(t, err, "Failed to import private key")
err = SaveKeyJWK(privateKey, filepath.Join(path, PrivateKeyFile)) err = SaveKeyJWK(privateKey, filepath.Join(path, PrivateKeyFile))

View File

@@ -3,18 +3,25 @@ package service
import ( import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"crypto/tls"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log" "log"
"log/slog"
"mime/multipart" "mime/multipart"
"net/http"
"os" "os"
"regexp" "regexp"
"slices" "slices"
"strings" "strings"
"time" "time"
"github.com/lestrrat-go/httprc/v3"
"github.com/lestrrat-go/httprc/v3/errsink"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jws"
"github.com/lestrrat-go/jwx/v3/jwt" "github.com/lestrrat-go/jwx/v3/jwt"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"gorm.io/gorm" "gorm.io/gorm"
@@ -31,6 +38,11 @@ const (
GrantTypeAuthorizationCode = "authorization_code" GrantTypeAuthorizationCode = "authorization_code"
GrantTypeRefreshToken = "refresh_token" GrantTypeRefreshToken = "refresh_token"
GrantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code" GrantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code"
ClientAssertionTypeJWTBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" //nolint:gosec
RefreshTokenDuration = 30 * 24 * time.Hour // 30 days
DeviceCodeDuration = 15 * time.Minute
) )
type OidcService struct { type OidcService struct {
@@ -39,16 +51,61 @@ type OidcService struct {
appConfigService *AppConfigService appConfigService *AppConfigService
auditLogService *AuditLogService auditLogService *AuditLogService
customClaimService *CustomClaimService customClaimService *CustomClaimService
httpClient *http.Client
jwkCache *jwk.Cache
} }
func NewOidcService(db *gorm.DB, jwtService *JwtService, appConfigService *AppConfigService, auditLogService *AuditLogService, customClaimService *CustomClaimService) *OidcService { func NewOidcService(
return &OidcService{ ctx context.Context,
db *gorm.DB,
jwtService *JwtService,
appConfigService *AppConfigService,
auditLogService *AuditLogService,
customClaimService *CustomClaimService,
) (s *OidcService, err error) {
s = &OidcService{
db: db, db: db,
jwtService: jwtService, jwtService: jwtService,
appConfigService: appConfigService, appConfigService: appConfigService,
auditLogService: auditLogService, auditLogService: auditLogService,
customClaimService: customClaimService, customClaimService: customClaimService,
} }
// Note: we don't pass the HTTP Client with OTel instrumented to this because requests are always made in background and not tied to a specific trace
s.jwkCache, err = s.getJWKCache(ctx)
if err != nil {
return nil, err
}
return s, nil
}
func (s *OidcService) getJWKCache(ctx context.Context) (*jwk.Cache, error) {
// We need to create a custom HTTP client to set a timeout.
client := s.httpClient
if client == nil {
client = &http.Client{
Timeout: 20 * time.Second,
}
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
// Indicates a development-time error
panic("Default transport is not of type *http.Transport")
}
transport := defaultTransport.Clone()
transport.TLSClientConfig.MinVersion = tls.VersionTLS12
client.Transport = transport
}
// Create the JWKS cache
return jwk.NewCache(ctx,
httprc.NewClient(
httprc.WithErrorSink(errsink.NewSlog(slog.Default())),
httprc.WithHTTPClient(client),
),
)
} }
func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClientRequestDto, userID, ipAddress, userAgent string) (string, string, error) { func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClientRequestDto, userID, ipAddress, userAgent string) (string, string, error) {
@@ -73,7 +130,7 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
} }
// Get the callback URL of the client. Return an error if the provided callback URL is not allowed // Get the callback URL of the client. Return an error if the provided callback URL is not allowed
callbackURL, err := s.getCallbackURL(client.CallbackURLs, input.CallbackURL) callbackURL, err := s.getCallbackURL(&client, input.CallbackURL, tx, ctx)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
@@ -198,7 +255,7 @@ func (s *OidcService) createTokenFromDeviceCode(ctx context.Context, input dto.O
tx.Rollback() tx.Rollback()
}() }()
_, err := s.verifyClientCredentialsInternal(ctx, input.ClientID, input.ClientSecret, tx) _, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input))
if err != nil { if err != nil {
return CreatedTokens{}, err return CreatedTokens{}, err
} }
@@ -249,7 +306,7 @@ func (s *OidcService) createTokenFromDeviceCode(ctx context.Context, input dto.O
return CreatedTokens{}, err return CreatedTokens{}, err
} }
accessToken, err := s.jwtService.GenerateOauthAccessToken(deviceAuth.User, input.ClientID) accessToken, err := s.jwtService.GenerateOAuthAccessToken(deviceAuth.User, input.ClientID)
if err != nil { if err != nil {
return CreatedTokens{}, err return CreatedTokens{}, err
} }
@@ -279,7 +336,7 @@ func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, inpu
tx.Rollback() tx.Rollback()
}() }()
client, err := s.verifyClientCredentialsInternal(ctx, input.ClientID, input.ClientSecret, tx) client, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input))
if err != nil { if err != nil {
return CreatedTokens{}, err return CreatedTokens{}, err
} }
@@ -321,7 +378,7 @@ func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, inpu
return CreatedTokens{}, err return CreatedTokens{}, err
} }
accessToken, err := s.jwtService.GenerateOauthAccessToken(authorizationCodeMetaData.User, input.ClientID) accessToken, err := s.jwtService.GenerateOAuthAccessToken(authorizationCodeMetaData.User, input.ClientID)
if err != nil { if err != nil {
return CreatedTokens{}, err return CreatedTokens{}, err
} }
@@ -352,22 +409,39 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
return CreatedTokens{}, &common.OidcMissingRefreshTokenError{} return CreatedTokens{}, &common.OidcMissingRefreshTokenError{}
} }
// Validate the signed refresh token and extract the actual token (which is a claim in the signed one)
userID, clientID, rt, err := s.jwtService.VerifyOAuthRefreshToken(input.RefreshToken)
if err != nil {
return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{}
}
tx := s.db.Begin() tx := s.db.Begin()
defer func() { defer func() {
tx.Rollback() tx.Rollback()
}() }()
_, err := s.verifyClientCredentialsInternal(ctx, input.ClientID, input.ClientSecret, tx) client, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input))
if err != nil { if err != nil {
return CreatedTokens{}, err return CreatedTokens{}, err
} }
// The ID of the client that made the call must match the client ID in the token
if client.ID != clientID {
return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{}
}
// Verify refresh token // Verify refresh token
var storedRefreshToken model.OidcRefreshToken var storedRefreshToken model.OidcRefreshToken
err = tx. err = tx.
WithContext(ctx). WithContext(ctx).
Preload("User"). Preload("User").
Where("token = ? AND expires_at > ?", utils.CreateSha256Hash(input.RefreshToken), datatype.DateTime(time.Now())). Where(
"token = ? AND expires_at > ? AND user_id = ? AND client_id = ?",
utils.CreateSha256Hash(rt),
datatype.DateTime(time.Now()),
userID,
input.ClientID,
).
First(&storedRefreshToken). First(&storedRefreshToken).
Error Error
if err != nil { if err != nil {
@@ -383,7 +457,7 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
} }
// Generate a new access token // Generate a new access token
accessToken, err := s.jwtService.GenerateOauthAccessToken(storedRefreshToken.User, input.ClientID) accessToken, err := s.jwtService.GenerateOAuthAccessToken(storedRefreshToken.User, input.ClientID)
if err != nil { if err != nil {
return CreatedTokens{}, err return CreatedTokens{}, err
} }
@@ -415,70 +489,125 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
}, nil }, nil
} }
func (s *OidcService) IntrospectToken(ctx context.Context, clientID, clientSecret, tokenString string) (introspectDto dto.OidcIntrospectionResponseDto, err error) { func (s *OidcService) IntrospectToken(ctx context.Context, creds ClientAuthCredentials, tokenString string) (introspectDto dto.OidcIntrospectionResponseDto, err error) {
if clientID == "" || clientSecret == "" { // Get the type of the token and the client ID
tokenType, token, err := s.jwtService.GetTokenType(tokenString)
if err != nil {
// We just treat the token as invalid
introspectDto.Active = false
return introspectDto, nil //nolint:nilerr
}
// If we don't have a client ID, get it from the token
// Otherwise, we need to make sure that the client ID passed as credential matches
tokenAudiences, _ := token.Audience()
if len(tokenAudiences) != 1 || tokenAudiences[0] == "" {
// We just treat the token as invalid
introspectDto.Active = false
return introspectDto, nil
}
if creds.ClientID == "" {
creds.ClientID = tokenAudiences[0]
} else if creds.ClientID != tokenAudiences[0] {
return introspectDto, &common.OidcMissingClientCredentialsError{} return introspectDto, &common.OidcMissingClientCredentialsError{}
} }
_, err = s.verifyClientCredentialsInternal(ctx, clientID, clientSecret, s.db) // Verify the credentials for the call
client, err := s.verifyClientCredentialsInternal(ctx, s.db, creds)
if err != nil { if err != nil {
return introspectDto, err return introspectDto, err
} }
token, err := s.jwtService.VerifyOauthAccessToken(tokenString) // Introspect the token
if err != nil { switch tokenType {
if errors.Is(err, jwt.ParseError()) { case OAuthAccessTokenJWTType:
// It's apparently not a valid JWT token, so we check if it's a valid refresh_token. return s.introspectAccessToken(client.ID, tokenString)
return s.introspectRefreshToken(ctx, tokenString) case OAuthRefreshTokenJWTType:
} return s.introspectRefreshToken(ctx, client.ID, tokenString)
default:
// Every failure we get means the token is invalid. Nothing more to do with the error. // We just treat the token as invalid
introspectDto.Active = false introspectDto.Active = false
return introspectDto, nil return introspectDto, nil
} }
}
func (s *OidcService) introspectAccessToken(clientID string, tokenString string) (introspectDto dto.OidcIntrospectionResponseDto, err error) {
token, err := s.jwtService.VerifyOAuthAccessToken(tokenString)
if err != nil {
// Every failure we get means the token is invalid. Nothing more to do with the error.
introspectDto.Active = false
return introspectDto, nil //nolint:nilerr
}
// The ID of the client that made the request must match the client ID in the token
audience, ok := token.Audience()
if !ok || len(audience) != 1 || audience[0] == "" {
introspectDto.Active = false
return introspectDto, nil
}
if audience[0] != clientID {
return introspectDto, &common.OidcMissingClientCredentialsError{}
}
introspectDto.Active = true introspectDto.Active = true
introspectDto.TokenType = "access_token" introspectDto.TokenType = "access_token"
introspectDto.Audience = audience
if token.Has("scope") { if token.Has("scope") {
var asString string var (
var asStrings []string asString string
asStrings []string
)
if err := token.Get("scope", &asString); err == nil { if err := token.Get("scope", &asString); err == nil {
introspectDto.Scope = asString introspectDto.Scope = asString
} else if err := token.Get("scope", &asStrings); err == nil { } else if err := token.Get("scope", &asStrings); err == nil {
introspectDto.Scope = strings.Join(asStrings, " ") introspectDto.Scope = strings.Join(asStrings, " ")
} }
} }
if expiration, hasExpiration := token.Expiration(); hasExpiration { if expiration, ok := token.Expiration(); ok {
introspectDto.Expiration = expiration.Unix() introspectDto.Expiration = expiration.Unix()
} }
if issuedAt, hasIssuedAt := token.IssuedAt(); hasIssuedAt { if issuedAt, ok := token.IssuedAt(); ok {
introspectDto.IssuedAt = issuedAt.Unix() introspectDto.IssuedAt = issuedAt.Unix()
} }
if notBefore, hasNotBefore := token.NotBefore(); hasNotBefore { if notBefore, ok := token.NotBefore(); ok {
introspectDto.NotBefore = notBefore.Unix() introspectDto.NotBefore = notBefore.Unix()
} }
if subject, hasSubject := token.Subject(); hasSubject { if subject, ok := token.Subject(); ok {
introspectDto.Subject = subject introspectDto.Subject = subject
} }
if audience, hasAudience := token.Audience(); hasAudience { if issuer, ok := token.Issuer(); ok {
introspectDto.Audience = audience
}
if issuer, hasIssuer := token.Issuer(); hasIssuer {
introspectDto.Issuer = issuer introspectDto.Issuer = issuer
} }
if identifier, hasIdentifier := token.JwtID(); hasIdentifier { if identifier, ok := token.JwtID(); ok {
introspectDto.Identifier = identifier introspectDto.Identifier = identifier
} }
return introspectDto, nil return introspectDto, nil
} }
func (s *OidcService) introspectRefreshToken(ctx context.Context, refreshToken string) (introspectDto dto.OidcIntrospectionResponseDto, err error) { func (s *OidcService) introspectRefreshToken(ctx context.Context, clientID string, refreshToken string) (introspectDto dto.OidcIntrospectionResponseDto, err error) {
// Validate the signed refresh token and extract the actual token (which is a claim in the signed one)
tokenUserID, tokenClientID, tokenRT, err := s.jwtService.VerifyOAuthRefreshToken(refreshToken)
if err != nil {
return introspectDto, fmt.Errorf("invalid refresh token: %w", err)
}
// The ID of the client that made the call must match the client ID in the token
if tokenClientID != clientID {
return introspectDto, errors.New("invalid refresh token: client ID does not match")
}
var storedRefreshToken model.OidcRefreshToken var storedRefreshToken model.OidcRefreshToken
err = s.db. err = s.db.
WithContext(ctx). WithContext(ctx).
Preload("User"). Preload("User").
Where("token = ? AND expires_at > ?", utils.CreateSha256Hash(refreshToken), datatype.DateTime(time.Now())). Where(
"token = ? AND expires_at > ? AND user_id = ? AND client_id = ?",
utils.CreateSha256Hash(tokenRT),
datatype.DateTime(time.Now()),
tokenUserID,
tokenClientID,
).
First(&storedRefreshToken). First(&storedRefreshToken).
Error Error
if err != nil { if err != nil {
@@ -542,13 +671,9 @@ func (s *OidcService) ListClients(ctx context.Context, name string, sortedPagina
func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCreateDto, userID string) (model.OidcClient, error) { func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCreateDto, userID string) (model.OidcClient, error) {
client := model.OidcClient{ client := model.OidcClient{
Name: input.Name, CreatedByID: userID,
CallbackURLs: input.CallbackURLs,
LogoutCallbackURLs: input.LogoutCallbackURLs,
CreatedByID: userID,
IsPublic: input.IsPublic,
PkceEnabled: input.PkceEnabled,
} }
updateOIDCClientModelFromDto(&client, &input)
err := s.db. err := s.db.
WithContext(ctx). WithContext(ctx).
@@ -577,11 +702,7 @@ func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input d
return model.OidcClient{}, err return model.OidcClient{}, err
} }
client.Name = input.Name updateOIDCClientModelFromDto(&client, &input)
client.CallbackURLs = input.CallbackURLs
client.LogoutCallbackURLs = input.LogoutCallbackURLs
client.IsPublic = input.IsPublic
client.PkceEnabled = input.IsPublic || input.PkceEnabled
err = tx. err = tx.
WithContext(ctx). WithContext(ctx).
@@ -599,6 +720,29 @@ func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input d
return client, nil return client, nil
} }
func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClientCreateDto) {
// Base fields
client.Name = input.Name
client.CallbackURLs = input.CallbackURLs
client.LogoutCallbackURLs = input.LogoutCallbackURLs
client.IsPublic = input.IsPublic
// PKCE is required for public clients
client.PkceEnabled = input.IsPublic || input.PkceEnabled
// Credentials
if len(input.Credentials.FederatedIdentities) > 0 {
client.Credentials.FederatedIdentities = make([]model.OidcClientFederatedIdentity, len(input.Credentials.FederatedIdentities))
for i, fi := range input.Credentials.FederatedIdentities {
client.Credentials.FederatedIdentities[i] = model.OidcClientFederatedIdentity{
Issuer: fi.Issuer,
Audience: fi.Audience,
Subject: fi.Subject,
JWKS: fi.JWKS,
}
}
}
}
func (s *OidcService) DeleteClient(ctx context.Context, clientID string) error { func (s *OidcService) DeleteClient(ctx context.Context, clientID string) error {
var client model.OidcClient var client model.OidcClient
err := s.db. err := s.db.
@@ -676,7 +820,7 @@ func (s *OidcService) GetClientLogo(ctx context.Context, clientID string) (strin
} }
func (s *OidcService) UpdateClientLogo(ctx context.Context, clientID string, file *multipart.FileHeader) error { func (s *OidcService) UpdateClientLogo(ctx context.Context, clientID string, file *multipart.FileHeader) error {
fileType := utils.GetFileExtension(file.Filename) fileType := strings.ToLower(utils.GetFileExtension(file.Filename))
if mimeType := utils.GetImageMimeType(fileType); mimeType == "" { if mimeType := utils.GetImageMimeType(fileType); mimeType == "" {
return &common.FileTypeNotSupportedError{} return &common.FileTypeNotSupportedError{}
} }
@@ -744,6 +888,7 @@ func (s *OidcService) DeleteClientLogo(ctx context.Context, clientID string) err
return errors.New("image not found") return errors.New("image not found")
} }
oldImageType := *client.ImageType
client.ImageType = nil client.ImageType = nil
err = tx. err = tx.
WithContext(ctx). WithContext(ctx).
@@ -753,7 +898,7 @@ func (s *OidcService) DeleteClientLogo(ctx context.Context, clientID string) err
return err return err
} }
imagePath := common.EnvConfig.UploadPath + "/oidc-client-images/" + client.ID + "." + *client.ImageType imagePath := common.EnvConfig.UploadPath + "/oidc-client-images/" + client.ID + "." + oldImageType
if err := os.Remove(imagePath); err != nil { if err := os.Remove(imagePath); err != nil {
return err return err
} }
@@ -766,97 +911,6 @@ func (s *OidcService) DeleteClientLogo(ctx context.Context, clientID string) err
return nil return nil
} }
func (s *OidcService) GetUserClaimsForClient(ctx context.Context, userID string, clientID string) (map[string]interface{}, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
claims, err := s.getUserClaimsForClientInternal(ctx, userID, clientID, s.db)
if err != nil {
return nil, err
}
err = tx.Commit().Error
if err != nil {
return nil, err
}
return claims, nil
}
func (s *OidcService) getUserClaimsForClientInternal(ctx context.Context, userID string, clientID string, tx *gorm.DB) (map[string]interface{}, error) {
var authorizedOidcClient model.UserAuthorizedOidcClient
err := tx.
WithContext(ctx).
Preload("User.UserGroups").
First(&authorizedOidcClient, "user_id = ? AND client_id = ?", userID, clientID).
Error
if err != nil {
return nil, err
}
user := authorizedOidcClient.User
scopes := strings.Split(authorizedOidcClient.Scope, " ")
claims := map[string]interface{}{
"sub": user.ID,
}
if slices.Contains(scopes, "email") {
claims["email"] = user.Email
claims["email_verified"] = s.appConfigService.GetDbConfig().EmailsVerified.IsTrue()
}
if slices.Contains(scopes, "groups") {
userGroups := make([]string, len(user.UserGroups))
for i, group := range user.UserGroups {
userGroups[i] = group.Name
}
claims["groups"] = userGroups
}
profileClaims := map[string]interface{}{
"given_name": user.FirstName,
"family_name": user.LastName,
"name": user.FullName(),
"preferred_username": user.Username,
"picture": common.EnvConfig.AppURL + "/api/users/" + user.ID + "/profile-picture.png",
}
if slices.Contains(scopes, "profile") {
// Add profile claims
for k, v := range profileClaims {
claims[k] = v
}
// Add custom claims
customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(ctx, userID, tx)
if err != nil {
return nil, err
}
for _, customClaim := range customClaims {
// The value of the custom claim can be a JSON object or a string
var jsonValue interface{}
err := json.Unmarshal([]byte(customClaim.Value), &jsonValue)
if err == nil {
// It's JSON so we store it as an object
claims[customClaim.Key] = jsonValue
} else {
// Marshalling failed, so we store it as a string
claims[customClaim.Key] = customClaim.Value
}
}
}
if slices.Contains(scopes, "email") {
claims["email"] = user.Email
}
return claims, nil
}
func (s *OidcService) UpdateAllowedUserGroups(ctx context.Context, id string, input dto.OidcUpdateAllowedUserGroupsDto) (client model.OidcClient, err error) { func (s *OidcService) UpdateAllowedUserGroups(ctx context.Context, id string, input dto.OidcUpdateAllowedUserGroupsDto) (client model.OidcClient, err error) {
tx := s.db.Begin() tx := s.db.Begin()
defer func() { defer func() {
@@ -947,13 +1001,12 @@ func (s *OidcService) ValidateEndSession(ctx context.Context, input dto.OidcLogo
return "", &common.OidcNoCallbackURLError{} return "", &common.OidcNoCallbackURLError{}
} }
callbackURL, err := s.getCallbackURL(userAuthorizedOIDCClient.Client.LogoutCallbackURLs, input.PostLogoutRedirectUri) callbackURL, err := s.getLogoutCallbackURL(&userAuthorizedOIDCClient.Client, input.PostLogoutRedirectUri)
if err != nil { if err != nil {
return "", err return "", err
} }
return callbackURL, nil return callbackURL, nil
} }
func (s *OidcService) createAuthorizationCode(ctx context.Context, clientID string, userID string, scope string, nonce string, codeChallenge string, codeChallengeMethod string, tx *gorm.DB) (string, error) { func (s *OidcService) createAuthorizationCode(ctx context.Context, clientID string, userID string, scope string, nonce string, codeChallenge string, codeChallengeMethod string, tx *gorm.DB) (string, error) {
@@ -1006,11 +1059,52 @@ func (s *OidcService) validateCodeVerifier(codeVerifier, codeChallenge string, c
return encodedVerifierHash == codeChallenge return encodedVerifierHash == codeChallenge
} }
func (s *OidcService) getCallbackURL(urls []string, inputCallbackURL string) (callbackURL string, err error) { func (s *OidcService) getCallbackURL(client *model.OidcClient, inputCallbackURL string, tx *gorm.DB, ctx context.Context) (callbackURL string, err error) {
// If no input callback URL provided, use the first configured URL
if inputCallbackURL == "" { if inputCallbackURL == "" {
return urls[0], nil if len(client.CallbackURLs) > 0 {
return client.CallbackURLs[0], nil
}
// If no URLs are configured and no input URL, this is an error
return "", &common.OidcMissingCallbackURLError{}
} }
// If URLs are already configured, validate against them
if len(client.CallbackURLs) > 0 {
matched, err := s.getCallbackURLFromList(client.CallbackURLs, inputCallbackURL)
if err != nil {
return "", err
} else if matched == "" {
return "", &common.OidcInvalidCallbackURLError{}
}
return matched, nil
}
// If no URLs are configured, trust and store the first URL (TOFU)
err = s.addCallbackURLToClient(ctx, client, inputCallbackURL, tx)
if err != nil {
return "", err
}
return inputCallbackURL, nil
}
func (s *OidcService) getLogoutCallbackURL(client *model.OidcClient, inputLogoutCallbackURL string) (callbackURL string, err error) {
if inputLogoutCallbackURL == "" {
return client.LogoutCallbackURLs[0], nil
}
matched, err := s.getCallbackURLFromList(client.LogoutCallbackURLs, inputLogoutCallbackURL)
if err != nil {
return "", err
} else if matched == "" {
return "", &common.OidcInvalidCallbackURLError{}
}
return matched, nil
}
func (s *OidcService) getCallbackURLFromList(urls []string, inputCallbackURL string) (callbackURL string, err error) {
for _, callbackPattern := range urls { for _, callbackPattern := range urls {
regexPattern := "^" + strings.ReplaceAll(regexp.QuoteMeta(callbackPattern), `\*`, ".*") + "$" regexPattern := "^" + strings.ReplaceAll(regexp.QuoteMeta(callbackPattern), `\*`, ".*") + "$"
matched, err := regexp.MatchString(regexPattern, inputCallbackURL) matched, err := regexp.MatchString(regexPattern, inputCallbackURL)
@@ -1022,11 +1116,28 @@ func (s *OidcService) getCallbackURL(urls []string, inputCallbackURL string) (ca
} }
} }
return "", &common.OidcInvalidCallbackURLError{} return "", nil
}
func (s *OidcService) addCallbackURLToClient(ctx context.Context, client *model.OidcClient, callbackURL string, tx *gorm.DB) error {
// Add the new callback URL to the existing list
client.CallbackURLs = append(client.CallbackURLs, callbackURL)
err := tx.WithContext(ctx).Save(client).Error
if err != nil {
return err
}
return nil
} }
func (s *OidcService) CreateDeviceAuthorization(ctx context.Context, input dto.OidcDeviceAuthorizationRequestDto) (*dto.OidcDeviceAuthorizationResponseDto, error) { func (s *OidcService) CreateDeviceAuthorization(ctx context.Context, input dto.OidcDeviceAuthorizationRequestDto) (*dto.OidcDeviceAuthorizationResponseDto, error) {
client, err := s.verifyClientCredentialsInternal(ctx, input.ClientID, input.ClientSecret, s.db) client, err := s.verifyClientCredentialsInternal(ctx, s.db, ClientAuthCredentials{
ClientID: input.ClientID,
ClientSecret: input.ClientSecret,
ClientAssertionType: input.ClientAssertionType,
ClientAssertion: input.ClientAssertion,
})
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1046,7 +1157,7 @@ func (s *OidcService) CreateDeviceAuthorization(ctx context.Context, input dto.O
DeviceCode: deviceCode, DeviceCode: deviceCode,
UserCode: userCode, UserCode: userCode,
Scope: input.Scope, Scope: input.Scope,
ExpiresAt: datatype.DateTime(time.Now().Add(15 * time.Minute)), ExpiresAt: datatype.DateTime(time.Now().Add(DeviceCodeDuration)),
IsAuthorized: false, IsAuthorized: false,
ClientID: client.ID, ClientID: client.ID,
} }
@@ -1060,7 +1171,7 @@ func (s *OidcService) CreateDeviceAuthorization(ctx context.Context, input dto.O
UserCode: userCode, UserCode: userCode,
VerificationURI: common.EnvConfig.AppURL + "/device", VerificationURI: common.EnvConfig.AppURL + "/device",
VerificationURIComplete: common.EnvConfig.AppURL + "/device?code=" + userCode, VerificationURIComplete: common.EnvConfig.AppURL + "/device?code=" + userCode,
ExpiresIn: 900, // 15 minutes ExpiresIn: int(DeviceCodeDuration.Seconds()),
Interval: 5, Interval: 5,
}, nil }, nil
} }
@@ -1191,6 +1302,20 @@ func (s *OidcService) GetAllowedGroupsCountOfClient(ctx context.Context, id stri
return count, nil return count, nil
} }
func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.UserAuthorizedOidcClient, utils.PaginationResponse, error) {
query := s.db.
WithContext(ctx).
Model(&model.UserAuthorizedOidcClient{}).
Preload("Client").
Where("user_id = ?", userID)
var authorizedClients []model.UserAuthorizedOidcClient
response, err := utils.PaginateAndSort(sortedPaginationRequest, query, &authorizedClients)
return authorizedClients, response, err
}
func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, userID string, scope string, tx *gorm.DB) (string, error) { func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, userID string, scope string, tx *gorm.DB) (string, error) {
refreshToken, err := utils.GenerateRandomAlphanumericString(40) refreshToken, err := utils.GenerateRandomAlphanumericString(40)
if err != nil { if err != nil {
@@ -1202,7 +1327,7 @@ func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, u
refreshTokenHash := utils.CreateSha256Hash(refreshToken) refreshTokenHash := utils.CreateSha256Hash(refreshToken)
m := model.OidcRefreshToken{ m := model.OidcRefreshToken{
ExpiresAt: datatype.DateTime(time.Now().Add(30 * 24 * time.Hour)), // 30 days ExpiresAt: datatype.DateTime(time.Now().Add(RefreshTokenDuration)),
Token: refreshTokenHash, Token: refreshTokenHash,
ClientID: clientID, ClientID: clientID,
UserID: userID, UserID: userID,
@@ -1217,7 +1342,13 @@ func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, u
return "", err return "", err
} }
return refreshToken, nil // Sign the refresh token
signed, err := s.jwtService.GenerateOAuthRefreshToken(userID, clientID, refreshToken)
if err != nil {
return "", fmt.Errorf("failed to sign refresh token: %w", err)
}
return signed, nil
} }
func (s *OidcService) createAuthorizedClientInternal(ctx context.Context, userID string, clientID string, scope string, tx *gorm.DB) error { func (s *OidcService) createAuthorizedClientInternal(ctx context.Context, userID string, clientID string, scope string, tx *gorm.DB) error {
@@ -1238,33 +1369,300 @@ func (s *OidcService) createAuthorizedClientInternal(ctx context.Context, userID
return err return err
} }
func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, clientID, clientSecret string, tx *gorm.DB) (model.OidcClient, error) { type ClientAuthCredentials struct {
ClientID string
ClientSecret string
ClientAssertion string
ClientAssertionType string
}
func clientAuthCredentialsFromCreateTokensDto(d *dto.OidcCreateTokensDto) ClientAuthCredentials {
return ClientAuthCredentials{
ClientID: d.ClientID,
ClientSecret: d.ClientSecret,
ClientAssertion: d.ClientAssertion,
ClientAssertionType: d.ClientAssertionType,
}
}
func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, tx *gorm.DB, input ClientAuthCredentials) (*model.OidcClient, error) {
// First, ensure we have a valid client ID // First, ensure we have a valid client ID
if clientID == "" { if input.ClientID == "" {
return model.OidcClient{}, &common.OidcMissingClientCredentialsError{} return nil, &common.OidcMissingClientCredentialsError{}
} }
// Load the OIDC client's configuration // Load the OIDC client's configuration
var client model.OidcClient var client model.OidcClient
err := tx. err := tx.
WithContext(ctx). WithContext(ctx).
First(&client, "id = ?", clientID). First(&client, "id = ?", input.ClientID).
Error Error
if err != nil { if err != nil {
return model.OidcClient{}, err return nil, err
} }
// If we have a client secret, we validate it // We have 3 options
// Otherwise, we require the client to be public // If credentials are provided, we validate them; otherwise, we can continue without credentials for public clients only
if clientSecret != "" { switch {
err = bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret)) // First, if we have a client secret, we validate it
case input.ClientSecret != "":
err = bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(input.ClientSecret))
if err != nil { if err != nil {
return model.OidcClient{}, &common.OidcClientSecretInvalidError{} return nil, &common.OidcClientSecretInvalidError{}
}
return &client, nil
// Next, check if we want to use client assertions from federated identities
case input.ClientAssertionType == ClientAssertionTypeJWTBearer && input.ClientAssertion != "":
err = s.verifyClientAssertionFromFederatedIdentities(ctx, &client, input)
if err != nil {
log.Printf("Invalid assertion for client '%s': %v", client.ID, err)
return nil, &common.OidcClientAssertionInvalidError{}
}
return &client, nil
// There's no credentials
// This is allowed only if the client is public
case client.IsPublic:
return &client, nil
// If we're here, we have no credentials AND the client is not public, so credentials are required
default:
return nil, &common.OidcMissingClientCredentialsError{}
}
}
func (s *OidcService) jwkSetForURL(ctx context.Context, url string) (set jwk.Set, err error) {
// Check if we have already registered the URL
if !s.jwkCache.IsRegistered(ctx, url) {
// We set a timeout because otherwise Register will keep trying in case of errors
registerCtx, registerCancel := context.WithTimeout(ctx, 15*time.Second)
defer registerCancel()
// We need to register the URL
err = s.jwkCache.Register(
registerCtx,
url,
jwk.WithMaxInterval(24*time.Hour),
jwk.WithMinInterval(15*time.Minute),
jwk.WithWaitReady(true),
)
// In case of race conditions (two goroutines calling jwkCache.Register at the same time), it's possible we can get a conflict anyways, so we ignore that error
if err != nil && !errors.Is(err, httprc.ErrResourceAlreadyExists()) {
return nil, fmt.Errorf("failed to register JWK set: %w", err)
} }
return client, nil
} else if !client.IsPublic {
return model.OidcClient{}, &common.OidcMissingClientCredentialsError{}
} }
return client, nil jwks, err := s.jwkCache.CachedSet(url)
if err != nil {
return nil, fmt.Errorf("failed to get cached JWK set: %w", err)
}
return jwks, nil
}
func (s *OidcService) verifyClientAssertionFromFederatedIdentities(ctx context.Context, client *model.OidcClient, input ClientAuthCredentials) error {
// First, parse the assertion JWT, without validating it, to check the issuer
assertion := []byte(input.ClientAssertion)
insecureToken, err := jwt.ParseInsecure(assertion)
if err != nil {
return fmt.Errorf("failed to parse client assertion JWT: %w", err)
}
issuer, _ := insecureToken.Issuer()
if issuer == "" {
return errors.New("client assertion does not contain an issuer claim")
}
// Ensure that this client is federated with the one that issued the token
ocfi, ok := client.Credentials.FederatedIdentityForIssuer(issuer)
if !ok {
return fmt.Errorf("client assertion is not from an allowed issuer: %s", issuer)
}
// Get the JWK set for the issuer
jwksURL := ocfi.JWKS
if jwksURL == "" {
// Default URL is from the issuer
if strings.HasSuffix(issuer, "/") {
jwksURL = issuer + ".well-known/jwks.json"
} else {
jwksURL = issuer + "/.well-known/jwks.json"
}
}
jwks, err := s.jwkSetForURL(ctx, jwksURL)
if err != nil {
return fmt.Errorf("failed to get JWK set for issuer '%s': %w", issuer, err)
}
// Set default audience and subject if missing
audience := ocfi.Audience
if audience == "" {
// Default to the Pocket ID's URL
audience = common.EnvConfig.AppURL
}
subject := ocfi.Subject
if subject == "" {
// Default to the client ID, per RFC 7523
subject = client.ID
}
// Now re-parse the token with proper validation
// (Note: we don't use jwt.WithIssuer() because that would be redundant)
_, err = jwt.Parse(assertion,
jwt.WithValidate(true),
jwt.WithAcceptableSkew(clockSkew),
jwt.WithKeySet(jwks, jws.WithInferAlgorithmFromKey(true), jws.WithUseDefault(true)),
jwt.WithAudience(audience),
jwt.WithSubject(subject),
)
if err != nil {
return fmt.Errorf("client assertion is not valid: %w", err)
}
// If we're here, the assertion is valid
return nil
}
func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, userID string, scopes string) (*dto.OidcClientPreviewDto, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
client, err := s.getClientInternal(ctx, clientID, tx)
if err != nil {
return nil, err
}
var user model.User
err = tx.
WithContext(ctx).
Preload("UserGroups").
First(&user, "id = ?", userID).
Error
if err != nil {
return nil, err
}
if !s.IsUserGroupAllowedToAuthorize(user, client) {
return nil, &common.OidcAccessDeniedError{}
}
dummyAuthorizedClient := model.UserAuthorizedOidcClient{
UserID: userID,
ClientID: clientID,
Scope: scopes,
User: user,
}
userClaims, err := s.getUserClaimsFromAuthorizedClient(ctx, &dummyAuthorizedClient, tx)
if err != nil {
return nil, err
}
// Commit the transaction before signing tokens to avoid locking the database for longer
err = tx.Commit().Error
if err != nil {
return nil, err
}
idToken, err := s.jwtService.BuildIDToken(userClaims, clientID, "")
if err != nil {
return nil, err
}
accessToken, err := s.jwtService.BuildOAuthAccessToken(user, clientID)
if err != nil {
return nil, err
}
idTokenPayload, err := utils.GetClaimsFromToken(idToken)
if err != nil {
return nil, err
}
accessTokenPayload, err := utils.GetClaimsFromToken(accessToken)
if err != nil {
return nil, err
}
return &dto.OidcClientPreviewDto{
IdToken: idTokenPayload,
AccessToken: accessTokenPayload,
UserInfo: userClaims,
}, nil
}
func (s *OidcService) GetUserClaimsForClient(ctx context.Context, userID string, clientID string) (map[string]any, error) {
return s.getUserClaimsForClientInternal(ctx, userID, clientID, s.db)
}
func (s *OidcService) getUserClaimsForClientInternal(ctx context.Context, userID string, clientID string, tx *gorm.DB) (map[string]any, error) {
var authorizedOidcClient model.UserAuthorizedOidcClient
err := tx.
WithContext(ctx).
Preload("User.UserGroups").
First(&authorizedOidcClient, "user_id = ? AND client_id = ?", userID, clientID).
Error
if err != nil {
return nil, err
}
return s.getUserClaimsFromAuthorizedClient(ctx, &authorizedOidcClient, tx)
}
func (s *OidcService) getUserClaimsFromAuthorizedClient(ctx context.Context, authorizedClient *model.UserAuthorizedOidcClient, tx *gorm.DB) (map[string]any, error) {
user := authorizedClient.User
scopes := strings.Split(authorizedClient.Scope, " ")
claims := make(map[string]any, 10)
claims["sub"] = user.ID
if slices.Contains(scopes, "email") {
claims["email"] = user.Email
claims["email_verified"] = s.appConfigService.GetDbConfig().EmailsVerified.IsTrue()
}
if slices.Contains(scopes, "groups") {
userGroups := make([]string, len(user.UserGroups))
for i, group := range user.UserGroups {
userGroups[i] = group.Name
}
claims["groups"] = userGroups
}
if slices.Contains(scopes, "profile") {
// Add profile claims
claims["given_name"] = user.FirstName
claims["family_name"] = user.LastName
claims["name"] = user.FullName()
claims["preferred_username"] = user.Username
claims["picture"] = common.EnvConfig.AppURL + "/api/users/" + user.ID + "/profile-picture.png"
// Add custom claims
customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(ctx, user.ID, tx)
if err != nil {
return nil, err
}
for _, customClaim := range customClaims {
// The value of the custom claim can be a JSON object or a string
var jsonValue any
err := json.Unmarshal([]byte(customClaim.Value), &jsonValue)
if err == nil {
// It's JSON, so we store it as an object
claims[customClaim.Key] = jsonValue
} else {
// Marshaling failed, so we store it as a string
claims[customClaim.Key] = customClaim.Value
}
}
}
if slices.Contains(scopes, "email") {
claims["email"] = user.Email
}
return claims, nil
} }

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,17 +294,23 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
// Check if this is an LDAP user and LDAP is enabled // Check if this is an LDAP user and LDAP is enabled
isLdapUser := user.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() 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 || (!allowOwnAccountEdit && updateOwnUser)) {
if !isLdapSync && isLdapUser { // Restricted update: Only locale can be changed when:
// Only update the locale for LDAP users // - User is from LDAP, OR
// - User is editing their own account but global setting disallows self-editing
// (Exception: LDAP sync operations can update everything)
user.Locale = updatedUser.Locale user.Locale = updatedUser.Locale
} else { } else {
// Full update: Allow updating all personal fields
user.FirstName = updatedUser.FirstName user.FirstName = updatedUser.FirstName
user.LastName = updatedUser.LastName user.LastName = updatedUser.LastName
user.Email = updatedUser.Email user.Email = updatedUser.Email
user.Username = updatedUser.Username user.Username = updatedUser.Username
user.Locale = updatedUser.Locale user.Locale = updatedUser.Locale
// Admin-only fields: Only allow updates when not updating own account
if !updateOwnUser { if !updateOwnUser {
user.IsAdmin = updatedUser.IsAdmin user.IsAdmin = updatedUser.IsAdmin
user.Disabled = updatedUser.Disabled user.Disabled = updatedUser.Disabled
@@ -523,7 +529,7 @@ func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroup
return user, nil return user, nil
} }
func (s *UserService) SetupInitialAdmin(ctx context.Context) (model.User, string, error) { func (s *UserService) SignUpInitialAdmin(ctx context.Context, signUpData dto.SignUpDto) (model.User, string, error) {
tx := s.db.Begin() tx := s.db.Begin()
defer func() { defer func() {
tx.Rollback() tx.Rollback()
@@ -533,25 +539,19 @@ func (s *UserService) SetupInitialAdmin(ctx context.Context) (model.User, string
if err := tx.WithContext(ctx).Model(&model.User{}).Count(&userCount).Error; err != nil { if err := tx.WithContext(ctx).Model(&model.User{}).Count(&userCount).Error; err != nil {
return model.User{}, "", err return model.User{}, "", err
} }
if userCount > 1 { if userCount != 0 {
return model.User{}, "", &common.SetupAlreadyCompletedError{} return model.User{}, "", &common.SetupAlreadyCompletedError{}
} }
user := model.User{ userToCreate := dto.UserCreateDto{
FirstName: "Admin", FirstName: signUpData.FirstName,
LastName: "Admin", LastName: signUpData.LastName,
Username: "admin", Username: signUpData.Username,
Email: "admin@admin.com", Email: signUpData.Email,
IsAdmin: true, IsAdmin: true,
} }
if err := tx.WithContext(ctx).Model(&model.User{}).Preload("Credentials").FirstOrCreate(&user).Error; err != nil { user, err := s.createUserInternal(ctx, userToCreate, false, tx)
return model.User{}, "", err
}
if len(user.Credentials) > 0 {
return model.User{}, "", &common.SetupAlreadyCompletedError{}
}
token, err := s.jwtService.GenerateAccessToken(user) token, err := s.jwtService.GenerateAccessToken(user)
if err != nil { if err != nil {
@@ -630,6 +630,110 @@ func (s *UserService) disableUserInternal(ctx context.Context, userID string, tx
Error Error
} }
func (s *UserService) CreateSignupToken(ctx context.Context, expiresAt time.Time, usageLimit int) (model.SignupToken, error) {
return s.createSignupTokenInternal(ctx, expiresAt, usageLimit, s.db)
}
func (s *UserService) createSignupTokenInternal(ctx context.Context, expiresAt time.Time, usageLimit int, tx *gorm.DB) (model.SignupToken, error) {
signupToken, err := NewSignupToken(expiresAt, usageLimit)
if err != nil {
return model.SignupToken{}, err
}
if err := tx.WithContext(ctx).Create(signupToken).Error; err != nil {
return model.SignupToken{}, err
}
return *signupToken, nil
}
func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAddress, userAgent string) (model.User, string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
tokenProvided := signupData.Token != ""
config := s.appConfigService.GetDbConfig()
if config.AllowUserSignups.Value != "open" && !tokenProvided {
return model.User{}, "", &common.OpenSignupDisabledError{}
}
var signupToken model.SignupToken
if tokenProvided {
err := tx.
WithContext(ctx).
Where("token = ?", signupData.Token).
First(&signupToken).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
}
return model.User{}, "", err
}
if !signupToken.IsValid() {
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
}
}
userToCreate := dto.UserCreateDto{
Username: signupData.Username,
Email: signupData.Email,
FirstName: signupData.FirstName,
LastName: signupData.LastName,
}
user, err := s.createUserInternal(ctx, userToCreate, false, tx)
if err != nil {
return model.User{}, "", err
}
accessToken, err := s.jwtService.GenerateAccessToken(user)
if err != nil {
return model.User{}, "", err
}
if tokenProvided {
s.auditLogService.Create(ctx, model.AuditLogEventAccountCreated, ipAddress, userAgent, user.ID, model.AuditLogData{
"signupToken": signupToken.Token,
}, tx)
signupToken.UsageCount++
err = tx.WithContext(ctx).Save(&signupToken).Error
if err != nil {
return model.User{}, "", err
}
} else {
s.auditLogService.Create(ctx, model.AuditLogEventAccountCreated, ipAddress, userAgent, user.ID, model.AuditLogData{
"method": "open_signup",
}, tx)
}
err = tx.Commit().Error
if err != nil {
return model.User{}, "", err
}
return user, accessToken, nil
}
func (s *UserService) ListSignupTokens(ctx context.Context, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.SignupToken, utils.PaginationResponse, error) {
var tokens []model.SignupToken
query := s.db.WithContext(ctx).Model(&model.SignupToken{})
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &tokens)
return tokens, pagination, err
}
func (s *UserService) DeleteSignupToken(ctx context.Context, tokenID string) error {
return s.db.WithContext(ctx).Delete(&model.SignupToken{}, "id = ?", tokenID).Error
}
func NewOneTimeAccessToken(userID string, expiresAt time.Time) (*model.OneTimeAccessToken, error) { func NewOneTimeAccessToken(userID string, expiresAt time.Time) (*model.OneTimeAccessToken, error) {
// If expires at is less than 15 minutes, use a 6-character token instead of 16 // If expires at is less than 15 minutes, use a 6-character token instead of 16
tokenLength := 16 tokenLength := 16
@@ -650,3 +754,20 @@ func NewOneTimeAccessToken(userID string, expiresAt time.Time) (*model.OneTimeAc
return o, nil return o, nil
} }
func NewSignupToken(expiresAt time.Time, usageLimit int) (*model.SignupToken, error) {
// Generate a random token
randomString, err := utils.GenerateRandomAlphanumericString(16)
if err != nil {
return nil, err
}
token := &model.SignupToken{
Token: randomString,
ExpiresAt: datatype.DateTime(expiresAt),
UsageLimit: usageLimit,
UsageCount: 0,
}
return token, nil
}

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) sortField, sortFieldFound := reflect.TypeOf(result).Elem().Elem().FieldByName(capitalizedSortColumn)
isSortable, _ := strconv.ParseBool(sortField.Tag.Get("sortable")) 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) columnName := CamelCaseToSnakeCase(sort.Column)
query = query.Clauses(clause.OrderBy{ query = query.Clauses(clause.OrderBy{
Columns: []clause.OrderByColumn{ 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,3 @@
DROP INDEX IF EXISTS idx_signup_tokens_expires_at;
DROP INDEX IF EXISTS idx_signup_tokens_token;
DROP TABLE IF EXISTS signup_tokens;

View File

@@ -0,0 +1,11 @@
CREATE TABLE signup_tokens (
id UUID NOT NULL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL,
token VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
usage_limit INTEGER NOT NULL DEFAULT 1,
usage_count INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_signup_tokens_token ON signup_tokens(token);
CREATE INDEX idx_signup_tokens_expires_at ON signup_tokens(expires_at);

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

@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_signup_tokens_expires_at;
DROP INDEX IF EXISTS idx_signup_tokens_token;
DROP TABLE IF EXISTS signup_tokens;

View File

@@ -0,0 +1,11 @@
CREATE TABLE signup_tokens (
id TEXT NOT NULL PRIMARY KEY,
created_at DATETIME NOT NULL,
token TEXT NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
usage_limit INTEGER NOT NULL DEFAULT 1,
usage_count INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_signup_tokens_token ON signup_tokens(token);
CREATE INDEX idx_signup_tokens_expires_at ON signup_tokens(expires_at);

View File

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

View File

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

View File

@@ -1,10 +1,9 @@
{ {
"$schema": "https://next.shadcn-svelte.com/schema.json", "$schema": "https://shadcn-svelte.com/schema.json",
"style": "default", "style": "default",
"tailwind": { "tailwind": {
"config": "tailwind.config.ts",
"css": "src/app.css", "css": "src/app.css",
"baseColor": "zinc" "baseColor": "neutral"
}, },
"aliases": { "aliases": {
"components": "$lib/components", "components": "$lib/components",
@@ -14,5 +13,5 @@
"lib": "$lib" "lib": "$lib"
}, },
"typescript": true, "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", "my_account": "Můj Účet",
"logout": "Odhlásit se", "logout": "Odhlásit se",
"confirm": "Potvrdit", "confirm": "Potvrdit",
"docs": "Dokumentace",
"key": "Klíč", "key": "Klíč",
"value": "Hodnota", "value": "Hodnota",
"remove_custom_claim": "Odstranit vlastní nárok", "remove_custom_claim": "Odstranit vlastní nárok",
@@ -40,8 +41,8 @@
"an_unknown_error_occurred": "Došlo k neznámé chybě", "an_unknown_error_occurred": "Došlo k neznámé chybě",
"authentication_process_was_aborted": "Proces přihlášení byl přerušen", "authentication_process_was_aborted": "Proces přihlášení byl přerušen",
"error_occurred_with_authenticator": "Došlo k chybě s autentifikátorem", "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_discoverable_credentials": "Autentifikátor nepodporuje vyhledatelné přihlašovací údaje",
"authenticator_does_not_support_resident_keys": "Autentikátor nepodporuje rezidentní klíče.", "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", "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_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", "authenticator_timed_out": "Vypršel časový limit autentifikátoru",
@@ -64,11 +65,9 @@
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Chcete se s účtem <b>{username}</b> odhlásit z Pocket ID?", "do_you_want_to_sign_out_of_pocketid_with_the_account": "Chcete se s účtem <b>{username}</b> odhlásit z Pocket ID?",
"sign_in_to_appname": "Přihlásit se k {appName}", "sign_in_to_appname": "Přihlásit se k {appName}",
"please_try_to_sign_in_again": "Zkuste se prosím znovu přihlásit.", "please_try_to_sign_in_again": "Zkuste se prosím znovu přihlásit.",
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Autentizujte se pomocí Vašeho přístupového klíče pro přístup k administrátorskému panelu.", "authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
"authenticate": "Autentizovat", "authenticate": "Autentizovat",
"appname_setup": "{appName} konfigurace",
"please_try_again": "Prosím, zkuste znovu.", "please_try_again": "Prosím, zkuste znovu.",
"you_are_about_to_sign_in_to_the_initial_admin_account": "Chystáte se přihlásit k počátečnímu účtu správce. Kdokoli s tímto odkazem může přistupovat k účtu, dokud nebude přidán přístupový účet. Prosím nastavte přístupový klíč co nejdříve, abyste zabránili neoprávněnému přístupu.",
"continue": "Pokračovat", "continue": "Pokračovat",
"alternative_sign_in": "Alternativní přihlášení", "alternative_sign_in": "Alternativní přihlášení",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Pokud nemáte přístup k Vašemu přístupovému klíči, můžete se přihlášit pomocí jedné z následujících metod.", "if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Pokud nemáte přístup k Vašemu přístupovému klíči, můžete se přihlášit pomocí jedné z následujících metod.",
@@ -144,8 +143,6 @@
"expires_at": "Vyprší", "expires_at": "Vyprší",
"when_this_api_key_will_expire": "Až vyprší platnost tohoto API klíče.", "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.", "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", "expiration_date_must_be_in_the_future": "Datum vypršení musí být v budoucnu",
"revoke_api_key": "Zrušit API klíč", "revoke_api_key": "Zrušit API klíč",
"never": "Nikdy", "never": "Nikdy",
@@ -168,7 +165,7 @@
"test_email_sent_successfully": "Testovací e-mail byl úspěšně odeslán na vaši e-mailovou adresu.", "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.", "failed_to_send_test_email": "Nepodařilo se odeslat testovací e-mail. Pro více informací zkontrolujte protokoly serveru.",
"smtp_configuration": "Nastavení SMTP", "smtp_configuration": "Nastavení SMTP",
"smtp_host": "SMTP Host", "smtp_host": "SMTP Hostitel",
"smtp_port": "SMTP Port", "smtp_port": "SMTP Port",
"smtp_user": "SMTP Uživatel", "smtp_user": "SMTP Uživatel",
"smtp_password": "SMTP Heslo", "smtp_password": "SMTP Heslo",
@@ -181,7 +178,7 @@
"email_login_notification": "E-mailovová oznámení o přihlášení", "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í.", "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", "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", "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.", "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", "send_test_email": "Odeslat testovací e-mail",
@@ -198,8 +195,8 @@
"ldap_sync_finished": "LDAP synchronizace dokončena", "ldap_sync_finished": "LDAP synchronizace dokončena",
"client_configuration": "Nastavení klienta", "client_configuration": "Nastavení klienta",
"ldap_url": "LDAP URL", "ldap_url": "LDAP URL",
"ldap_bind_dn": "LDAP Bind DN", "ldap_bind_dn": "LDAP Uživatel",
"ldap_bind_password": "LDAP Bind Password", "ldap_bind_password": "LDAP Heslo",
"ldap_base_dn": "LDAP Base DN", "ldap_base_dn": "LDAP Base DN",
"user_search_filter": "Filtr vyhledávání uživatelů", "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ů.", "the_search_filter_to_use_to_search_or_sync_users": "Hledaný filtr pro vyhledávání/synchronizaci uživatelů.",
@@ -289,8 +286,8 @@
"oidc_discovery_url": "OIDC Discovery URL", "oidc_discovery_url": "OIDC Discovery URL",
"token_url": "Token URL", "token_url": "Token URL",
"userinfo_url": "Userinfo URL", "userinfo_url": "Userinfo URL",
"logout_url": "Logout URL", "logout_url": "URL pro odhlášení",
"certificate_url": "Certificate URL", "certificate_url": "URL certifikátu",
"enabled": "Povoleno", "enabled": "Povoleno",
"disabled": "Zakázáno", "disabled": "Zakázáno",
"oidc_client_updated_successfully": "OIDC klient úspěšně aktualizován", "oidc_client_updated_successfully": "OIDC klient úspěšně aktualizován",
@@ -301,17 +298,17 @@
"allowed_user_groups_updated_successfully": "Povolené skupiny uživatelů byly úspěšně aktualizovány", "allowed_user_groups_updated_successfully": "Povolené skupiny uživatelů byly úspěšně aktualizovány",
"oidc_client_name": "OIDC Klient {name}", "oidc_client_name": "OIDC Klient {name}",
"client_id": "ID klienta", "client_id": "ID klienta",
"client_secret": "Client secret", "client_secret": "Tajný klíč",
"show_more_details": "Zobrazit další podrobnosti", "show_more_details": "Zobrazit další podrobnosti",
"allowed_user_groups": "Povolené skupiny uživatelů", "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.", "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", "light_mode_logo": "Logo světlého režimu",
"dark_mode_logo": "Logo tmavého režimu", "dark_mode_logo": "Logo tmavého režimu",
"background_image": "Obrázek na pozadí", "background_image": "Obrázek na pozadí",
"language": "Jazyk", "language": "Jazyk",
"reset_profile_picture_question": "Resetovat profilový obrázek?", "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": "Obnovit",
"reset_to_default": "Obnovit výchozí", "reset_to_default": "Obnovit výchozí",
"profile_picture_has_been_reset": "Profilový obrázek byl obnoven. Aktualizace může trvat několik minut.", "profile_picture_has_been_reset": "Profilový obrázek byl obnoven. Aktualizace může trvat několik minut.",
@@ -321,13 +318,14 @@
"all_users": "Všichni uživatelé", "all_users": "Všichni uživatelé",
"all_events": "Všechny události", "all_events": "Všechny události",
"all_clients": "Všichni klienti", "all_clients": "Všichni klienti",
"all_locations": "All Locations",
"global_audit_log": "Globální protokol auditu", "global_audit_log": "Globální protokol auditu",
"see_all_account_activities_from_the_last_3_months": "Zobrazit veškerou aktivitu uživatele za poslední 3 měsíce.", "see_all_account_activities_from_the_last_3_months": "Zobrazit veškerou aktivitu uživatele za poslední 3 měsíce.",
"token_sign_in": "Přihlášení tokenem", "token_sign_in": "Přihlášení tokenem",
"client_authorization": "Autorizace klienta", "client_authorization": "Autorizace klienta",
"new_client_authorization": "Nová autorizace klienta", "new_client_authorization": "Nová autorizace klienta",
"disable_animations": "Zakázat animace", "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", "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.", "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.", "user_disabled_successfully": "Uživatel byl úspěšně deaktivován.",
@@ -340,13 +338,85 @@
"login_code_email_success": "Přihlašovací kód byl odeslán uživateli.", "login_code_email_success": "Přihlašovací kód byl odeslán uživateli.",
"send_email": "Odeslat e-mail", "send_email": "Odeslat e-mail",
"show_code": "Zobrazit kód", "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", "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ší.", "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í", "authorize_device": "Autorizovat zařízení",
"the_device_has_been_authorized": "Zařízení bylo autorizováno.", "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.", "enter_code_displayed_in_previous_step": "Zadejte kód, který byl zobrazen v předchozím kroku.",
"authorize": "Autorizovat", "authorize": "Autorizovat",
"oidc_allowed_group_count": "Allowed Group Count", "federated_client_credentials": "Údaje o klientovi ve federaci",
"unrestricted": "Unrestricted" "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",
"signup_token": "Signup Token",
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
"usage_limit": "Usage Limit",
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
"expires": "Expires",
"signup": "Sign Up",
"signup_requires_valid_token": "A valid signup token is required to create an account",
"validating_signup_token": "Validating signup token",
"go_to_login": "Go to login",
"signup_to_appname": "Sign Up to {appName}",
"create_your_account_to_get_started": "Create your account to get started.",
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
"setup_your_passkey": "Set up your passkey",
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
"skip_for_now": "Skip for now",
"account_created": "Account Created",
"enable_user_signups": "Enable User Signups",
"enable_user_signups_description": "Whether the User Signup functionality should be enabled.",
"user_signups_are_disabled": "User signups are currently disabled",
"create_signup_token": "Create Signup Token",
"view_active_signup_tokens": "View Active Signup Tokens",
"manage_signup_tokens": "Manage Signup Tokens",
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
"signup_token_deleted_successfully": "Signup token deleted successfully.",
"expired": "Expired",
"used_up": "Used Up",
"active": "Active",
"usage": "Usage",
"created": "Created",
"token": "Token",
"loading": "Loading",
"delete_signup_token": "Delete Signup Token",
"are_you_sure_you_want_to_delete_this_signup_token": "Are you sure you want to delete this signup token? This action cannot be undone.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.",
"signup_with_token": "Signup with token",
"signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.",
"signup_open": "Open Signup",
"signup_open_description": "Anyone can create a new account without restrictions.",
"of": "of",
"skip_passkey_setup": "Skip Passkey Setup",
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
} }

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

@@ -0,0 +1,422 @@
{
"$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_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
"authenticate": "Bekræft identitet",
"please_try_again": "Prøv venligst igen.",
"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",
"all_locations": "All Locations",
"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",
"signup_token": "Signup Token",
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
"usage_limit": "Usage Limit",
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
"expires": "Expires",
"signup": "Sign Up",
"signup_requires_valid_token": "A valid signup token is required to create an account",
"validating_signup_token": "Validating signup token",
"go_to_login": "Go to login",
"signup_to_appname": "Sign Up to {appName}",
"create_your_account_to_get_started": "Create your account to get started.",
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
"setup_your_passkey": "Set up your passkey",
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
"skip_for_now": "Skip for now",
"account_created": "Account Created",
"enable_user_signups": "Enable User Signups",
"enable_user_signups_description": "Whether the User Signup functionality should be enabled.",
"user_signups_are_disabled": "User signups are currently disabled",
"create_signup_token": "Create Signup Token",
"view_active_signup_tokens": "View Active Signup Tokens",
"manage_signup_tokens": "Manage Signup Tokens",
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
"signup_token_deleted_successfully": "Signup token deleted successfully.",
"expired": "Expired",
"used_up": "Used Up",
"active": "Active",
"usage": "Usage",
"created": "Created",
"token": "Token",
"loading": "Loading",
"delete_signup_token": "Delete Signup Token",
"are_you_sure_you_want_to_delete_this_signup_token": "Are you sure you want to delete this signup token? This action cannot be undone.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.",
"signup_with_token": "Signup with token",
"signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.",
"signup_open": "Open Signup",
"signup_open_description": "Anyone can create a new account without restrictions.",
"of": "of",
"skip_passkey_setup": "Skip Passkey Setup",
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
}

View File

@@ -3,6 +3,7 @@
"my_account": "Mein Konto", "my_account": "Mein Konto",
"logout": "Abmelden", "logout": "Abmelden",
"confirm": "Bestätigen", "confirm": "Bestätigen",
"docs": "Dokumentation",
"key": "Schlüssel", "key": "Schlüssel",
"value": "Wert", "value": "Wert",
"remove_custom_claim": "Benutzerdefinierten Claim entfernen", "remove_custom_claim": "Benutzerdefinierten Claim entfernen",
@@ -35,7 +36,7 @@
"expiration": "Ablaufdatum", "expiration": "Ablaufdatum",
"generate_code": "Code erzeugen", "generate_code": "Code erzeugen",
"name": "Name", "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.", "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", "an_unknown_error_occurred": "Ein unbekannter Fehler ist aufgetreten",
"authentication_process_was_aborted": "Der Authentifizierungsprozess wurde abgebrochen", "authentication_process_was_aborted": "Der Authentifizierungsprozess wurde abgebrochen",
@@ -64,11 +65,9 @@
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Möchtest du dich mit deinem Konto <b>{username}</b> von Pocket ID abmelden?", "do_you_want_to_sign_out_of_pocketid_with_the_account": "Möchtest du dich mit deinem Konto <b>{username}</b> von Pocket ID abmelden?",
"sign_in_to_appname": "Bei {appName} anmelden", "sign_in_to_appname": "Bei {appName} anmelden",
"please_try_to_sign_in_again": "Bitte versuche dich erneut anzumelden.", "please_try_to_sign_in_again": "Bitte versuche dich erneut anzumelden.",
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Authentifiziere dich mit deinem Passkey, um auf das Admin Panel zugreifen zu können.", "authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
"authenticate": "Authentifizieren", "authenticate": "Authentifizieren",
"appname_setup": "{appName} Einrichtung",
"please_try_again": "Bitte versuche es noch einmal.", "please_try_again": "Bitte versuche es noch einmal.",
"you_are_about_to_sign_in_to_the_initial_admin_account": "Du bist dabei, dich beim initialen Administratorkonto anzumelden. Jeder, der diesen Link hat, kann auf das Konto zugreifen, bis ein Passkey hinzugefügt wird. Bitte richte so schnell wie möglich einen Passkey ein, um unbefugten Zugriff zu verhindern.",
"continue": "Fortsetzen", "continue": "Fortsetzen",
"alternative_sign_in": "Alternative Anmeldemethoden", "alternative_sign_in": "Alternative Anmeldemethoden",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Wenn du keinen Zugang zu deinem Passkey hast, kannst du dich mit einer der folgenden Methoden anmelden.", "if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Wenn du keinen Zugang zu deinem Passkey hast, kannst du dich mit einer der folgenden Methoden anmelden.",
@@ -144,8 +143,6 @@
"expires_at": "Ablaufdatum", "expires_at": "Ablaufdatum",
"when_this_api_key_will_expire": "Wann der API Key ablaufen wird.", "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.", "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", "expiration_date_must_be_in_the_future": "Ablaufdatum muss in der Zukunft liegen",
"revoke_api_key": "API Key widerrufen", "revoke_api_key": "API Key widerrufen",
"never": "Nie", "never": "Nie",
@@ -181,7 +178,7 @@
"email_login_notification": "E-Mail Benachrichtigung bei Login", "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.", "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", "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", "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.", "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", "send_test_email": "Test-E-Mail senden",
@@ -235,7 +232,7 @@
"user_deleted_successfully": "Benutzer erfolgreich gelöscht", "user_deleted_successfully": "Benutzer erfolgreich gelöscht",
"role": "Rolle", "role": "Rolle",
"source": "Quelle", "source": "Quelle",
"admin": "Admin", "admin": "Administrator",
"user": "Benutzer", "user": "Benutzer",
"local": "Lokal", "local": "Lokal",
"toggle_menu": "Menü umschalten", "toggle_menu": "Menü umschalten",
@@ -321,13 +318,14 @@
"all_users": "Alle Benutzer", "all_users": "Alle Benutzer",
"all_events": "Alle Ereignisse", "all_events": "Alle Ereignisse",
"all_clients": "Alle Clients", "all_clients": "Alle Clients",
"all_locations": "Alle Orte",
"global_audit_log": "Globaler Aktivitäts-Log", "global_audit_log": "Globaler Aktivitäts-Log",
"see_all_account_activities_from_the_last_3_months": "Sieh dir alle Benutzeraktivitäten der letzten 3 Monate an.", "see_all_account_activities_from_the_last_3_months": "Sieh dir alle Benutzeraktivitäten der letzten 3 Monate an.",
"token_sign_in": "Token-Anmeldung", "token_sign_in": "Token-Anmeldung",
"client_authorization": "Client-Autorisierung", "client_authorization": "Client-Autorisierung",
"new_client_authorization": "Neue Client-Autorisierung", "new_client_authorization": "Neue Client-Autorisierung",
"disable_animations": "Animationen deaktivieren", "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", "user_disabled": "Account deaktiviert",
"disabled_users_cannot_log_in_or_use_services": "Deaktivierte Benutzer können sich nicht anmelden oder Dienste nutzen.", "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.", "user_disabled_successfully": "Der Benutzer wurde erfolgreich deaktiviert.",
@@ -340,13 +338,85 @@
"login_code_email_success": "Der Login-Code wurde an den Benutzer gesendet.", "login_code_email_success": "Der Login-Code wurde an den Benutzer gesendet.",
"send_email": "E-Mail senden", "send_email": "E-Mail senden",
"show_code": "Code anzeigen", "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", "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.", "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", "authorize_device": "Gerät autorisieren",
"the_device_has_been_authorized": "Das Gerät wurde autorisiert.", "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.", "enter_code_displayed_in_previous_step": "Gib den Code ein, der im vorherigen Schritt angezeigt wurde.",
"authorize": "Autorisieren", "authorize": "Autorisieren",
"oidc_allowed_group_count": "Allowed Group Count", "federated_client_credentials": "Federated Client Credentials",
"unrestricted": "Unrestricted" "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": "Vorschau der OIDC-Daten, die für verschiedene Nutzer gesendet werden sollen",
"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": "Akzentfarbe",
"custom_accent_color": "Benutzerdefinierte Akzentfarbe",
"custom_accent_color_description": "Geben Sie eine benutzerdefinierte Farbe mit gültigen CSS-Farbformaten ein (z.B. hex, rgb, hsl).",
"color_value": "Farbwert",
"apply": "Übernehmen",
"signup_token": "Signup Token",
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
"usage_limit": "Usage Limit",
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
"expires": "Expires",
"signup": "Sign Up",
"signup_requires_valid_token": "A valid signup token is required to create an account",
"validating_signup_token": "Validating signup token",
"go_to_login": "Go to login",
"signup_to_appname": "Sign Up to {appName}",
"create_your_account_to_get_started": "Create your account to get started.",
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
"setup_your_passkey": "Set up your passkey",
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
"skip_for_now": "Skip for now",
"account_created": "Account Created",
"enable_user_signups": "Enable User Signups",
"enable_user_signups_description": "Whether the User Signup functionality should be enabled.",
"user_signups_are_disabled": "User signups are currently disabled",
"create_signup_token": "Create Signup Token",
"view_active_signup_tokens": "View Active Signup Tokens",
"manage_signup_tokens": "Manage Signup Tokens",
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
"signup_token_deleted_successfully": "Signup token deleted successfully.",
"expired": "Expired",
"used_up": "Used Up",
"active": "Active",
"usage": "Usage",
"created": "Created",
"token": "Token",
"loading": "Loading",
"delete_signup_token": "Delete Signup Token",
"are_you_sure_you_want_to_delete_this_signup_token": "Are you sure you want to delete this signup token? This action cannot be undone.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.",
"signup_with_token": "Signup with token",
"signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.",
"signup_open": "Open Signup",
"signup_open_description": "Anyone can create a new account without restrictions.",
"of": "of",
"skip_passkey_setup": "Skip Passkey Setup",
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
} }

View File

@@ -3,6 +3,7 @@
"my_account": "My Account", "my_account": "My Account",
"logout": "Logout", "logout": "Logout",
"confirm": "Confirm", "confirm": "Confirm",
"docs": "Docs",
"key": "Key", "key": "Key",
"value": "Value", "value": "Value",
"remove_custom_claim": "Remove custom claim", "remove_custom_claim": "Remove custom claim",
@@ -64,11 +65,9 @@
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Do you want to sign out of {appName} with the account <b>{username}</b>?", "do_you_want_to_sign_out_of_pocketid_with_the_account": "Do you want to sign out of {appName} with the account <b>{username}</b>?",
"sign_in_to_appname": "Sign in to {appName}", "sign_in_to_appname": "Sign in to {appName}",
"please_try_to_sign_in_again": "Please try to sign in again.", "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_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
"authenticate": "Authenticate", "authenticate": "Authenticate",
"appname_setup": "{appName} Setup",
"please_try_again": "Please try again.", "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", "continue": "Continue",
"alternative_sign_in": "Alternative Sign In", "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.", "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.",
@@ -144,8 +143,6 @@
"expires_at": "Expires At", "expires_at": "Expires At",
"when_this_api_key_will_expire": "When this API key will expire.", "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.", "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", "expiration_date_must_be_in_the_future": "Expiration date must be in the future",
"revoke_api_key": "Revoke API Key", "revoke_api_key": "Revoke API Key",
"never": "Never", "never": "Never",
@@ -181,7 +178,7 @@
"email_login_notification": "Email Login Notification", "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.", "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", "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", "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.", "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": "Send test email",
@@ -311,7 +308,7 @@
"background_image": "Background Image", "background_image": "Background Image",
"language": "Language", "language": "Language",
"reset_profile_picture_question": "Reset profile picture?", "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": "Reset",
"reset_to_default": "Reset to default", "reset_to_default": "Reset to default",
"profile_picture_has_been_reset": "Profile picture has been reset. It may take a few minutes to update.", "profile_picture_has_been_reset": "Profile picture has been reset. It may take a few minutes to update.",
@@ -321,13 +318,14 @@
"all_users": "All Users", "all_users": "All Users",
"all_events": "All Events", "all_events": "All Events",
"all_clients": "All Clients", "all_clients": "All Clients",
"all_locations": "All Locations",
"global_audit_log": "Global Audit Log", "global_audit_log": "Global Audit Log",
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.", "see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
"token_sign_in": "Token Sign In", "token_sign_in": "Token Sign In",
"client_authorization": "Client Authorization", "client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization", "new_client_authorization": "New Client Authorization",
"disable_animations": "Disable Animations", "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", "user_disabled": "Account Disabled",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.", "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_disabled_successfully": "User has been disabled successfully.",
@@ -340,13 +338,85 @@
"login_code_email_success": "The login code has been sent to the user.", "login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email", "send_email": "Send Email",
"show_code": "Show Code", "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", "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.", "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", "authorize_device": "Authorize Device",
"the_device_has_been_authorized": "The device has been authorized.", "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.", "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", "oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted" "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",
"signup_token": "Signup Token",
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
"usage_limit": "Usage Limit",
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
"expires": "Expires",
"signup": "Sign Up",
"signup_requires_valid_token": "A valid signup token is required to create an account",
"validating_signup_token": "Validating signup token",
"go_to_login": "Go to login",
"signup_to_appname": "Sign Up to {appName}",
"create_your_account_to_get_started": "Create your account to get started.",
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
"setup_your_passkey": "Set up your passkey",
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
"skip_for_now": "Skip for now",
"account_created": "Account Created",
"enable_user_signups": "Enable User Signups",
"enable_user_signups_description": "Whether the User Signup functionality should be enabled.",
"user_signups_are_disabled": "User signups are currently disabled",
"create_signup_token": "Create Signup Token",
"view_active_signup_tokens": "View Active Signup Tokens",
"manage_signup_tokens": "Manage Signup Tokens",
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
"signup_token_deleted_successfully": "Signup token deleted successfully.",
"expired": "Expired",
"used_up": "Used Up",
"active": "Active",
"usage": "Usage",
"created": "Created",
"token": "Token",
"loading": "Loading",
"delete_signup_token": "Delete Signup Token",
"are_you_sure_you_want_to_delete_this_signup_token": "Are you sure you want to delete this signup token? This action cannot be undone.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.",
"signup_with_token": "Signup with token",
"signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.",
"signup_open": "Open Signup",
"signup_open_description": "Anyone can create a new account without restrictions.",
"of": "of",
"skip_passkey_setup": "Skip Passkey Setup",
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
} }

View File

@@ -3,6 +3,7 @@
"my_account": "Mi Cuenta", "my_account": "Mi Cuenta",
"logout": "Cerrar sesión", "logout": "Cerrar sesión",
"confirm": "Confirmar", "confirm": "Confirmar",
"docs": "Docs",
"key": "Clave", "key": "Clave",
"value": "Valor", "value": "Valor",
"remove_custom_claim": "Eliminar reclamo personalizado", "remove_custom_claim": "Eliminar reclamo personalizado",
@@ -64,11 +65,9 @@
"do_you_want_to_sign_out_of_pocketid_with_the_account": "¿Quieres cerrar sesión de Pocket ID con la cuenta <b>{username}</b>?", "do_you_want_to_sign_out_of_pocketid_with_the_account": "¿Quieres cerrar sesión de Pocket ID con la cuenta <b>{username}</b>?",
"sign_in_to_appname": "Iniciar sesión en {appName}", "sign_in_to_appname": "Iniciar sesión en {appName}",
"please_try_to_sign_in_again": "Por favor, intente iniciar sesión de nuevo.", "please_try_to_sign_in_again": "Por favor, intente iniciar sesión de nuevo.",
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Autenticar con tu Passkey para acceder al panel de administración.", "authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
"authenticate": "Autenticar", "authenticate": "Autenticar",
"appname_setup": "Configuración de {appName}",
"please_try_again": "Por favor intente nuevamente.", "please_try_again": "Por favor intente nuevamente.",
"you_are_about_to_sign_in_to_the_initial_admin_account": "Estás a punto de iniciar sesión en la cuenta de administrador inicial. Cualquiera con este enlace puede acceder a la cuenta hasta que se agregue un Passkey. Por favor, configure un Passkey lo antes posible para evitar acceso no autorizado.",
"continue": "Continuar", "continue": "Continuar",
"alternative_sign_in": "Inicio de sesión alternativa", "alternative_sign_in": "Inicio de sesión alternativa",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Si no tiene acceso a su Passkey, puede iniciar sesión usando uno de los siguientes métodos.", "if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Si no tiene acceso a su Passkey, puede iniciar sesión usando uno de los siguientes métodos.",
@@ -103,75 +102,73 @@
"client": "Cliente", "client": "Cliente",
"unknown": "Desconocido", "unknown": "Desconocido",
"account_details_updated_successfully": "Detalles de la cuenta actualizados exitosamente", "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.", "profile_picture_updated_successfully": "Imagen de perfil actualizada correctamente. Puede tardar unos minutos en actualizarse.",
"account_settings": "Account Settings", "account_settings": "Configuración de la cuenta",
"passkey_missing": "Passkey missing", "passkey_missing": "Passkey no encontrada",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Please add a passkey to prevent losing access to your account.", "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": "Single Passkey Configured", "single_passkey_configured": "Clave única configurada",
"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.", "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": "Account Details", "account_details": "Detalles de la cuenta",
"passkeys": "Passkeys", "passkeys": "Claves de acceso",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Manage your passkeys that you can use to authenticate yourself.", "manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Administra las claves de acceso que te permiten autenticarte.",
"add_passkey": "Add Passkey", "add_passkey": "Añade una clave de acceso",
"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_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": "Create", "create": "Crear",
"first_name": "First name", "first_name": "Nombre",
"last_name": "Last name", "last_name": "Apellido",
"username": "Username", "username": "Nombre de usuario",
"save": "Save", "save": "Guardar",
"username_can_only_contain": "Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols", "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": "Sign in using the following code. The code will expire in 15 minutes.", "sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Inicia sesión usando el siguiente código. El código caducará en 15 minutos.",
"or_visit": "or visit", "or_visit": "o visita",
"added_on": "Añadido el", "added_on": "Añadido el",
"rename": "Renombrar", "rename": "Renombrar",
"delete": "Borrar", "delete": "Borrar",
"are_you_sure_you_want_to_delete_this_passkey": "¿Está seguro de que desea eliminar esta passkey?", "are_you_sure_you_want_to_delete_this_passkey": "¿Está seguro de que desea eliminar esta passkey?",
"passkey_deleted_successfully": "Passkey eliminada con éxito", "passkey_deleted_successfully": "Passkey eliminada con éxito",
"delete_passkey_name": "Borrar {passkeyName}", "delete_passkey_name": "Borrar {passkeyName}",
"passkey_name_updated_successfully": "Passkey name updated successfully", "passkey_name_updated_successfully": "Nombre de la clave de acceso actualizado correctamente",
"name_passkey": "Name Passkey", "name_passkey": "Nombre para la clave de acceso",
"name_your_passkey_to_easily_identify_it_later": "Name your passkey to easily identify it later.", "name_your_passkey_to_easily_identify_it_later": "Nombra tu clave de acceso para poder identificarla fácilmente más tarde.",
"create_api_key": "Create API Key", "create_api_key": "Crear API Key",
"add_a_new_api_key_for_programmatic_access": "Add a new API key for programmatic access.", "add_a_new_api_key_for_programmatic_access": "Añade una nueva API key para el acceso programático.",
"add_api_key": "Add API Key", "add_api_key": "Añade una API Key",
"manage_api_keys": "Manage API Keys", "manage_api_keys": "Gestiona las API Keys",
"api_key_created": "API Key Created", "api_key_created": "API Key creada",
"for_security_reasons_this_key_will_only_be_shown_once": "For security reasons, this key will only be shown once. Please store it securely.", "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": "Description", "description": "Descripción",
"api_key": "API Key", "api_key": "API Key",
"close": "Cerrar", "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", "expires_at": "Expira el",
"when_this_api_key_will_expire": "Cuando esta clave de API caducará.", "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.", "optional_description_to_help_identify_this_keys_purpose": "Descripción opcional para ayudar a identificar el propósito de esta clave.",
"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",
"expiration_date_must_be_in_the_future": "La fecha de caducidad debe ser en el futuro", "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", "never": "Nunca",
"revoke": "Revoke", "revoke": "Invalidar",
"api_key_revoked_successfully": "La clave API se ha revocado con éxito", "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", "last_used": "Utilizado por última vez",
"actions": "Acciones", "actions": "Acciones",
"images_updated_successfully": "Images updated successfully", "images_updated_successfully": "Imágenes actualizadas correctamente",
"general": "General", "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", "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.", "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": "Images", "images": "Imágenes",
"update": "Update", "update": "Actualización",
"email_configuration_updated_successfully": "Email configuration updated successfully", "email_configuration_updated_successfully": "Configuración de correo electrónico actualizada correctamente",
"save_changes_question": "Save changes?", "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?", "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", "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.", "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.", "failed_to_send_test_email": "Error al enviar el email de prueba. Revisa los registros del servidor para más información.",
"smtp_configuration": "SMTP Configuration", "smtp_configuration": "Configuración SMTP",
"smtp_host": "SMTP Host", "smtp_host": "Servidor SMTP",
"smtp_port": "SMTP Port", "smtp_port": "Puerto SMTP",
"smtp_user": "SMTP User", "smtp_user": "Usuario SMTP",
"smtp_password": "SMTP Password", "smtp_password": "Contraseña SMTP",
"smtp_from": "SMTP From", "smtp_from": "SMTP From",
"smtp_tls_option": "SMTP TLS Option", "smtp_tls_option": "SMTP TLS Option",
"email_tls_option": "Email TLS Option", "email_tls_option": "Email TLS Option",
@@ -181,7 +178,7 @@
"email_login_notification": "Email Login Notification", "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.", "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", "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", "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.", "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", "send_test_email": "Enviar correo de prueba",
@@ -281,7 +278,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.", "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", "name_logo": "{name} logo",
"change_logo": "Change Logo", "change_logo": "Change Logo",
"upload_logo": "Upload Logo", "upload_logo": "Subir Logo",
"remove_logo": "Remove 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?", "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", "oidc_client_deleted_successfully": "OIDC client deleted successfully",
@@ -306,12 +303,12 @@
"allowed_user_groups": "Allowed User Groups", "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.", "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", "favicon": "Favicon",
"light_mode_logo": "Light Mode Logo", "light_mode_logo": "Logo del modo Claro",
"dark_mode_logo": "Dark Mode Logo", "dark_mode_logo": "Dark Mode Logo",
"background_image": "Background Image", "background_image": "Background Image",
"language": "Language", "language": "Language",
"reset_profile_picture_question": "Reset profile picture?", "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": "Reset",
"reset_to_default": "Reset to default", "reset_to_default": "Reset to default",
"profile_picture_has_been_reset": "Profile picture has been reset. It may take a few minutes to update.", "profile_picture_has_been_reset": "Profile picture has been reset. It may take a few minutes to update.",
@@ -321,13 +318,14 @@
"all_users": "All Users", "all_users": "All Users",
"all_events": "All Events", "all_events": "All Events",
"all_clients": "All Clients", "all_clients": "All Clients",
"all_locations": "All Locations",
"global_audit_log": "Global Audit Log", "global_audit_log": "Global Audit Log",
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.", "see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
"token_sign_in": "Token Sign In", "token_sign_in": "Token Sign In",
"client_authorization": "Client Authorization", "client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization", "new_client_authorization": "New Client Authorization",
"disable_animations": "Disable Animations", "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", "user_disabled": "Account Disabled",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.", "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_disabled_successfully": "User has been disabled successfully.",
@@ -340,13 +338,85 @@
"login_code_email_success": "The login code has been sent to the user.", "login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email", "send_email": "Send Email",
"show_code": "Show Code", "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", "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.", "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", "authorize_device": "Authorize Device",
"the_device_has_been_authorized": "The device has been authorized.", "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.", "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", "oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted" "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",
"signup_token": "Signup Token",
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
"usage_limit": "Usage Limit",
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
"expires": "Expires",
"signup": "Sign Up",
"signup_requires_valid_token": "A valid signup token is required to create an account",
"validating_signup_token": "Validating signup token",
"go_to_login": "Go to login",
"signup_to_appname": "Sign Up to {appName}",
"create_your_account_to_get_started": "Create your account to get started.",
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
"setup_your_passkey": "Set up your passkey",
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
"skip_for_now": "Skip for now",
"account_created": "Account Created",
"enable_user_signups": "Enable User Signups",
"enable_user_signups_description": "Whether the User Signup functionality should be enabled.",
"user_signups_are_disabled": "User signups are currently disabled",
"create_signup_token": "Create Signup Token",
"view_active_signup_tokens": "View Active Signup Tokens",
"manage_signup_tokens": "Manage Signup Tokens",
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
"signup_token_deleted_successfully": "Signup token deleted successfully.",
"expired": "Expired",
"used_up": "Used Up",
"active": "Active",
"usage": "Usage",
"created": "Created",
"token": "Token",
"loading": "Loading",
"delete_signup_token": "Delete Signup Token",
"are_you_sure_you_want_to_delete_this_signup_token": "Are you sure you want to delete this signup token? This action cannot be undone.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.",
"signup_with_token": "Signup with token",
"signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.",
"signup_open": "Open Signup",
"signup_open_description": "Anyone can create a new account without restrictions.",
"of": "of",
"skip_passkey_setup": "Skip Passkey Setup",
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
} }

View File

@@ -3,6 +3,7 @@
"my_account": "Mon compte", "my_account": "Mon compte",
"logout": "Déconnexion", "logout": "Déconnexion",
"confirm": "Confirmer", "confirm": "Confirmer",
"docs": "Documentation",
"key": "Clé", "key": "Clé",
"value": "Valeur", "value": "Valeur",
"remove_custom_claim": "Remove custom claim", "remove_custom_claim": "Remove custom claim",
@@ -36,7 +37,7 @@
"generate_code": "Générer un code", "generate_code": "Générer un code",
"name": "Nom", "name": "Nom",
"browser_unsupported": "Navigateur non pris en charge", "browser_unsupported": "Navigateur non pris en charge",
"this_browser_does_not_support_passkeys": "This browser doesn't support passkeys. Please use an alternative sign in method.", "this_browser_does_not_support_passkeys": "Ce navigateur ne supporte pas les clés d'accès. Veuillez utiliser une autre méthode d'authentification.",
"an_unknown_error_occurred": "Une erreur inconnue est survenue", "an_unknown_error_occurred": "Une erreur inconnue est survenue",
"authentication_process_was_aborted": "Le processus d'authentification a été interrompu", "authentication_process_was_aborted": "Le processus d'authentification a été interrompu",
"error_occurred_with_authenticator": "Une erreur est survenue pendant l'authentification", "error_occurred_with_authenticator": "Une erreur est survenue pendant l'authentification",
@@ -64,14 +65,12 @@
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Voulez-vous vous déconnecter de Pocket ID avec le compte <b>{username}</b>?", "do_you_want_to_sign_out_of_pocketid_with_the_account": "Voulez-vous vous déconnecter de Pocket ID avec le compte <b>{username}</b>?",
"sign_in_to_appname": "Se connecter à {appName}", "sign_in_to_appname": "Se connecter à {appName}",
"please_try_to_sign_in_again": "Veuillez essayer de vous connecter à nouveau.", "please_try_to_sign_in_again": "Veuillez essayer de vous connecter à nouveau.",
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Authentifiez-vous avec votre clé d'accès pour accéder au panneau d'administration.", "authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
"authenticate": "S'authentifier", "authenticate": "S'authentifier",
"appname_setup": "Configuration {appName}",
"please_try_again": "Veuillez réessayer.", "please_try_again": "Veuillez réessayer.",
"you_are_about_to_sign_in_to_the_initial_admin_account": "Vous êtes sur le point de vous connecter au compte administrateur initial. N'importe qui avec ce lien peut accéder au compte jusqu'à ce qu'une clé d'accès soit ajouté. Veuillez configurer une clé d'accès dès que possible pour éviter tout accès non autorisé.",
"continue": "Continuer", "continue": "Continuer",
"alternative_sign_in": "Connexion alternative", "alternative_sign_in": "Connexion alternative",
"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.", "if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Si vous n'avez pas accès à votre clé d'accès, vous pouvez vous authentifier en utilisant une des méthodes suivantes.",
"use_your_passkey_instead": "Utiliser votre clé d'accès à la place ?", "use_your_passkey_instead": "Utiliser votre clé d'accès à la place ?",
"email_login": "Connexion par e-mail", "email_login": "Connexion par e-mail",
"enter_a_login_code_to_sign_in": "Entrez un code de connexion pour vous connecter.", "enter_a_login_code_to_sign_in": "Entrez un code de connexion pour vous connecter.",
@@ -107,7 +106,7 @@
"account_settings": "Paramètres du compte", "account_settings": "Paramètres du compte",
"passkey_missing": "Clé d'accès manquante", "passkey_missing": "Clé d'accès manquante",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Veuillez ajouter une clé d'accès pour éviter de perdre l'accès à votre compte.", "please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Veuillez ajouter une clé d'accès pour éviter de perdre l'accès à votre compte.",
"single_passkey_configured": "Une seul clé d'accès configuré", "single_passkey_configured": "Une seule clé d'accès configurée",
"it_is_recommended_to_add_more_than_one_passkey": "Il est recommandé d'ajouter plus d'une clé d'accès pour éviter de perdre l'accès à votre compte.", "it_is_recommended_to_add_more_than_one_passkey": "Il est recommandé d'ajouter plus d'une clé d'accès pour éviter de perdre l'accès à votre compte.",
"account_details": "Paramètres du compte", "account_details": "Paramètres du compte",
"passkeys": "Clés d'accès", "passkeys": "Clés d'accès",
@@ -144,8 +143,6 @@
"expires_at": "Date d'expiration", "expires_at": "Date d'expiration",
"when_this_api_key_will_expire": "Date d'expiration de la clé API.", "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é.", "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", "expiration_date_must_be_in_the_future": "La date d'expiration doit être dans le futur",
"revoke_api_key": "Révoquer la clé API", "revoke_api_key": "Révoquer la clé API",
"never": "Jamais", "never": "Jamais",
@@ -156,7 +153,7 @@
"actions": "Actions", "actions": "Actions",
"images_updated_successfully": "Image mise à jour avec succès", "images_updated_successfully": "Image mise à jour avec succès",
"general": "Général", "general": "Général",
"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": "Activer les notifications par e-mail pour alerter les utilisateurs lorsqu'une connexion est détectée à partir d'un nouvel appareil ou d'un nouvel emplacement.",
"ldap": "LDAP", "ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configurer les paramètres LDAP pour synchroniser les utilisateurs et les groupes à partir d'un serveur LDAP.", "configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configurer les paramètres LDAP pour synchroniser les utilisateurs et les groupes à partir d'un serveur LDAP.",
"images": "Images", "images": "Images",
@@ -180,11 +177,11 @@
"enabled_emails": "Emails activés", "enabled_emails": "Emails activés",
"email_login_notification": "Notification de connexion par e-mail", "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.", "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", "emai_login_code_requested_by_user": "Code de connexion reçu par e-mail à la demande de l'utilisateur.",
"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": "Permet aux utilisateurs de contourner les clés d'accès en demandant un code de connexion envoyé à leur adresse e-mail. Cela réduit considérablement la sécurité car toute personne ayant accès à l'e-mail de l'utilisateur peut récupérer la clé d'accès.",
"email_login_code_from_admin": "Email Login Code from Admin", "email_login_code_from_admin": "Code de connexion reçu par e-mail envoyé par l'administrateur.",
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.", "allows_an_admin_to_send_a_login_code_to_the_user": "Permet à un administrateur d'envoyer un code de connexion à l'utilisateur par e-mail.",
"send_test_email": "", "send_test_email": "Envoyer un e-mail de test",
"application_configuration_updated_successfully": "Mise à jour de l'application avec succès", "application_configuration_updated_successfully": "Mise à jour de l'application avec succès",
"application_name": "Nom de l'application", "application_name": "Nom de l'application",
"session_duration": "Durée de la session", "session_duration": "Durée de la session",
@@ -271,7 +268,7 @@
"add_oidc_client": "Ajouter un client OIDC", "add_oidc_client": "Ajouter un client OIDC",
"manage_oidc_clients": "Gérer les clients OIDC", "manage_oidc_clients": "Gérer les clients OIDC",
"one_time_link": "Lien de connexion unique", "one_time_link": "Lien de connexion unique",
"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.", "use_this_link_to_sign_in_once": "Utilisez ce lien pour vous connecter. Ceci est nécessaire pour les utilisateurs qui n'ont pas encore ajouté de clé d'accès ou l'ont perdu.",
"add": "Ajouter", "add": "Ajouter",
"callback_urls": "URL de callback", "callback_urls": "URL de callback",
"logout_callback_urls": "URL de callback de déconnexion", "logout_callback_urls": "URL de callback de déconnexion",
@@ -311,40 +308,115 @@
"background_image": "Image d'arrière-plan", "background_image": "Image d'arrière-plan",
"language": "Langue", "language": "Langue",
"reset_profile_picture_question": "Réinitialiser la photo de profil ?", "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": "Cela supprimera limage téléchargée et réinitialisera la photo de profil par défaut. Voulez-vous continuer ?",
"reset": "Réinitialiser", "reset": "Réinitialiser",
"reset_to_default": "Valeurs par défaut", "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.", "profile_picture_has_been_reset": "La photo de profil a été réinitialisée. La mise à jour peut prendre quelques minutes.",
"select_the_language_you_want_to_use": "Sélectionnez la langue que vous souhaitez utiliser. Certaines langues peuvent ne pas être entièrement traduites.", "select_the_language_you_want_to_use": "Sélectionnez la langue que vous souhaitez utiliser. Certaines langues peuvent ne pas être entièrement traduites.",
"personal": "Personal", "personal": "Personnel",
"global": "Global", "global": "Global",
"all_users": "All Users", "all_users": "Tous les utilisateurs",
"all_events": "All Events", "all_events": "Tous les événements",
"all_clients": "All Clients", "all_clients": "Tous les clients",
"global_audit_log": "Global Audit Log", "all_locations": "Tous les emplacements",
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.", "global_audit_log": "Journal d'audit global",
"token_sign_in": "Token Sign In", "see_all_account_activities_from_the_last_3_months": "Voir toutes les activités des utilisateurs des 3 derniers mois.",
"client_authorization": "Client Authorization", "token_sign_in": "Connexion par jeton",
"new_client_authorization": "New Client Authorization", "client_authorization": "Autorisation client",
"disable_animations": "Disable Animations", "new_client_authorization": "Nouvelle autorisation client",
"turn_off_all_animations_throughout_the_admin_ui": "Turn off all animations throughout the Admin UI.", "disable_animations": "Désactiver les animations",
"user_disabled": "Account Disabled", "turn_off_ui_animations": "Désactiver les animations dans toute l'interface.",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.", "user_disabled": "Compte désactivé",
"user_disabled_successfully": "User has been disabled successfully.", "disabled_users_cannot_log_in_or_use_services": "Les utilisateurs désactivés ne peuvent pas se connecter ni utiliser les services.",
"user_enabled_successfully": "User has been enabled successfully.", "user_disabled_successfully": "L'utilisateur a été désactivé avec succès.",
"status": "Status", "user_enabled_successfully": "L'utilisateur a été activé avec succès.",
"disable_firstname_lastname": "Disable {firstName} {lastName}", "status": "Statut",
"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.", "disable_firstname_lastname": "Désactiver {firstName} {lastName}",
"ldap_soft_delete_users": "Keep disabled users from LDAP.", "are_you_sure_you_want_to_disable_this_user": "Êtes-vous sûr de vouloir désactiver cet utilisateur ? Il ne pourra plus se connecter ni accéder aux services.",
"ldap_soft_delete_users_description": "When enabled, users removed from LDAP will be disabled rather than deleted from the system.", "ldap_soft_delete_users": "Conserver les utilisateurs désactivés de LDAP.",
"login_code_email_success": "The login code has been sent to the user.", "ldap_soft_delete_users_description": "Quand activé, les utilisateurs retirés de LDAP seront désactivés plutôt que supprimés du système.",
"send_email": "Send Email", "login_code_email_success": "Le code de connexion a été envoyé à l'utilisateur.",
"show_code": "Show Code", "send_email": "Envoyer un email",
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.", "show_code": "Afficher le code",
"api_key_expiration": "API Key Expiration", "callback_url_description": "URL(s) fournies par votre client. Sera automatiquement ajoutée si laissée vide. Les jokers (*) sont supportés, mais il est préférable de les éviter pour plus de sécurité.",
"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.", "logout_callback_url_description": "URL(s) fournies par votre client pour la déconnexion. Les jokers (*) sont supportés, mais il est préférable de les éviter pour plus de sécurité.",
"authorize_device": "Authorize Device", "api_key_expiration": "Expiration de la clé API",
"the_device_has_been_authorized": "The device has been authorized.", "send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Envoyer un email à l'utilisateur lorsque sa clé API est sur le point d'expirer.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.", "authorize_device": "Autoriser l'appareil",
"authorize": "Authorize" "the_device_has_been_authorized": "L'appareil a été autorisé.",
"enter_code_displayed_in_previous_step": "Entrez le code affiché à l'étape précédente.",
"authorize": "Autoriser",
"federated_client_credentials": "Identifiants client fédérés",
"federated_client_credentials_description": "Avec des identifiants clients fédérés, vous pouvez authentifier des clients OIDC avec des tokens JWT émis par des autorités tierces.",
"add_federated_client_credential": "Ajouter un identifiant client fédéré",
"add_another_federated_client_credential": "Ajouter un autre identifiant client fédéré",
"oidc_allowed_group_count": "Nombre de groupes autorisés",
"unrestricted": "Illimité",
"show_advanced_options": "Afficher les options avancées",
"hide_advanced_options": "Masquer les options avancées",
"oidc_data_preview": "Aperçu des données OIDC",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Aperçu des données OIDC qui seraient envoyées pour différents utilisateurs",
"id_token": "Jeton ID",
"access_token": "Jeton d'accès",
"userinfo": "Informations utilisateur",
"id_token_payload": "Charge utile du jeton ID",
"access_token_payload": "Charge utile du jeton d'accès",
"userinfo_endpoint_response": "Réponse du point d'accès Userinfo",
"copy": "Copier",
"no_preview_data_available": "Aucune donnée d'aperçu disponible",
"copy_all": "Tout copier",
"preview": "Aperçu",
"preview_for_user": "Aperçu pour {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Aperçu des données OIDC qui seraient envoyées pour cet utilisateur",
"show": "Afficher",
"select_an_option": "Sélectionner une option",
"select_user": "Sélectionner un utilisateur",
"error": "Erreur",
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Sélectionnez une couleur d'accent pour personnaliser l'apparence de Pocket ID.",
"accent_color": "Couleur d'accent",
"custom_accent_color": "Couleur d'accent personnalisée",
"custom_accent_color_description": "Entrez une couleur personnalisée en utilisant un format CSS valide (par ex. hex, rgb, hsl).",
"color_value": "Valeur de la couleur",
"apply": "Appliquer",
"signup_token": "Signup Token",
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
"usage_limit": "Usage Limit",
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
"expires": "Expires",
"signup": "Sign Up",
"signup_requires_valid_token": "A valid signup token is required to create an account",
"validating_signup_token": "Validating signup token",
"go_to_login": "Go to login",
"signup_to_appname": "Sign Up to {appName}",
"create_your_account_to_get_started": "Create your account to get started.",
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
"setup_your_passkey": "Set up your passkey",
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
"skip_for_now": "Skip for now",
"account_created": "Account Created",
"enable_user_signups": "Enable User Signups",
"enable_user_signups_description": "Whether the User Signup functionality should be enabled.",
"user_signups_are_disabled": "User signups are currently disabled",
"create_signup_token": "Create Signup Token",
"view_active_signup_tokens": "View Active Signup Tokens",
"manage_signup_tokens": "Manage Signup Tokens",
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
"signup_token_deleted_successfully": "Signup token deleted successfully.",
"expired": "Expired",
"used_up": "Used Up",
"active": "Active",
"usage": "Usage",
"created": "Created",
"token": "Token",
"loading": "Loading",
"delete_signup_token": "Delete Signup Token",
"are_you_sure_you_want_to_delete_this_signup_token": "Are you sure you want to delete this signup token? This action cannot be undone.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.",
"signup_with_token": "Signup with token",
"signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.",
"signup_open": "Open Signup",
"signup_open_description": "Anyone can create a new account without restrictions.",
"of": "of",
"skip_passkey_setup": "Skip Passkey Setup",
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
} }

View File

@@ -3,6 +3,7 @@
"my_account": "Il mio account", "my_account": "Il mio account",
"logout": "Disconnetti", "logout": "Disconnetti",
"confirm": "Conferma", "confirm": "Conferma",
"docs": "Documentazione",
"key": "Chiave", "key": "Chiave",
"value": "Valore", "value": "Valore",
"remove_custom_claim": "Rimuovi attributo personalizzato", "remove_custom_claim": "Rimuovi attributo personalizzato",
@@ -64,11 +65,9 @@
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Vuoi disconnetterti da Pocket ID con l'account <b>{username}</b>?", "do_you_want_to_sign_out_of_pocketid_with_the_account": "Vuoi disconnetterti da Pocket ID con l'account <b>{username}</b>?",
"sign_in_to_appname": "Accedi a {appName}", "sign_in_to_appname": "Accedi a {appName}",
"please_try_to_sign_in_again": "Per favore, prova ad accedere di nuovo.", "please_try_to_sign_in_again": "Per favore, prova ad accedere di nuovo.",
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Autenticati con la tua passkey per accedere al pannello di amministrazione.", "authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
"authenticate": "Autentica", "authenticate": "Autentica",
"appname_setup": "Configurazione di {appName}",
"please_try_again": "Per favore, riprova.", "please_try_again": "Per favore, riprova.",
"you_are_about_to_sign_in_to_the_initial_admin_account": "Stai per accedere all'account amministratore iniziale. Chiunque abbia questo link può accedere all'account finché non viene aggiunta una passkey. Configura una passkey il prima possibile per prevenire accessi non autorizzati.",
"continue": "Continua", "continue": "Continua",
"alternative_sign_in": "Accesso Alternativo", "alternative_sign_in": "Accesso Alternativo",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Se non hai accesso alla tua passkey, puoi accedere utilizzando uno dei seguenti metodi.", "if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Se non hai accesso alla tua passkey, puoi accedere utilizzando uno dei seguenti metodi.",
@@ -144,8 +143,6 @@
"expires_at": "Scade il", "expires_at": "Scade il",
"when_this_api_key_will_expire": "Quando scadrà questa chiave API.", "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.", "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", "expiration_date_must_be_in_the_future": "La data di scadenza deve essere nel futuro",
"revoke_api_key": "Revoca Chiave API", "revoke_api_key": "Revoca Chiave API",
"never": "Mai", "never": "Mai",
@@ -321,13 +318,14 @@
"all_users": "Tutti gli utenti", "all_users": "Tutti gli utenti",
"all_events": "Tutti gli eventi", "all_events": "Tutti gli eventi",
"all_clients": "Tutti i client", "all_clients": "Tutti i client",
"all_locations": "Tutte le posizioni",
"global_audit_log": "Registro attività globale", "global_audit_log": "Registro attività globale",
"see_all_account_activities_from_the_last_3_months": "Visualizza tutte le attività degli utenti degli ultimi 3 mesi.", "see_all_account_activities_from_the_last_3_months": "Visualizza tutte le attività degli utenti degli ultimi 3 mesi.",
"token_sign_in": "Accesso con token", "token_sign_in": "Accesso con token",
"client_authorization": "Autorizzazione client", "client_authorization": "Autorizzazione client",
"new_client_authorization": "Nuova autorizzazione client", "new_client_authorization": "Nuova autorizzazione client",
"disable_animations": "Disabilita animazioni", "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", "user_disabled": "Account disabilitato",
"disabled_users_cannot_log_in_or_use_services": "Gli utenti disabilitati non possono accedere o utilizzare i servizi.", "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.", "user_disabled_successfully": "Utente disabilitato con successo.",
@@ -340,13 +338,85 @@
"login_code_email_success": "Il codice di accesso è stato inviato all'utente.", "login_code_email_success": "Il codice di accesso è stato inviato all'utente.",
"send_email": "Invia email", "send_email": "Invia email",
"show_code": "Mostra codice", "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", "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.", "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", "authorize_device": "Autorizza Dispositivo",
"the_device_has_been_authorized": "Il dispositivo è stato autorizzato.", "the_device_has_been_authorized": "Il dispositivo è stato autorizzato.",
"enter_code_displayed_in_previous_step": "Inserisci il codice visualizzato nel passaggio precedente.", "enter_code_displayed_in_previous_step": "Inserisci il codice visualizzato nel passaggio precedente.",
"authorize": "Autorizza", "authorize": "Autorizza",
"oidc_allowed_group_count": "Allowed Group Count", "federated_client_credentials": "Identità Federate",
"unrestricted": "Unrestricted" "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",
"signup_token": "Signup Token",
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
"usage_limit": "Usage Limit",
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
"expires": "Expires",
"signup": "Sign Up",
"signup_requires_valid_token": "A valid signup token is required to create an account",
"validating_signup_token": "Validating signup token",
"go_to_login": "Go to login",
"signup_to_appname": "Sign Up to {appName}",
"create_your_account_to_get_started": "Create your account to get started.",
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
"setup_your_passkey": "Set up your passkey",
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
"skip_for_now": "Skip for now",
"account_created": "Account Created",
"enable_user_signups": "Enable User Signups",
"enable_user_signups_description": "Whether the User Signup functionality should be enabled.",
"user_signups_are_disabled": "User signups are currently disabled",
"create_signup_token": "Create Signup Token",
"view_active_signup_tokens": "View Active Signup Tokens",
"manage_signup_tokens": "Manage Signup Tokens",
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
"signup_token_deleted_successfully": "Signup token deleted successfully.",
"expired": "Expired",
"used_up": "Used Up",
"active": "Active",
"usage": "Usage",
"created": "Created",
"token": "Token",
"loading": "Loading",
"delete_signup_token": "Delete Signup Token",
"are_you_sure_you_want_to_delete_this_signup_token": "Are you sure you want to delete this signup token? This action cannot be undone.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.",
"signup_with_token": "Signup with token",
"signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.",
"signup_open": "Open Signup",
"signup_open_description": "Anyone can create a new account without restrictions.",
"of": "of",
"skip_passkey_setup": "Skip Passkey Setup",
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
} }

View File

@@ -3,6 +3,7 @@
"my_account": "Mijn account", "my_account": "Mijn account",
"logout": "Uitloggen", "logout": "Uitloggen",
"confirm": "Bevestigen", "confirm": "Bevestigen",
"docs": "Docs",
"key": "Sleutel", "key": "Sleutel",
"value": "Waarde", "value": "Waarde",
"remove_custom_claim": "Aangepaste claim verwijderen", "remove_custom_claim": "Aangepaste claim verwijderen",
@@ -64,11 +65,9 @@
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Wilt u zich afmelden bij Pocket ID met het account <b>{username}</b> ?", "do_you_want_to_sign_out_of_pocketid_with_the_account": "Wilt u zich afmelden bij Pocket ID met het account <b>{username}</b> ?",
"sign_in_to_appname": "Meld u aan bij {appName}", "sign_in_to_appname": "Meld u aan bij {appName}",
"please_try_to_sign_in_again": "Probeer opnieuw in te loggen.", "please_try_to_sign_in_again": "Probeer opnieuw in te loggen.",
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Verifieer uzelf met uw toegangscode om toegang te krijgen tot het beheerderspaneel.", "authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
"authenticate": "Authenticeren", "authenticate": "Authenticeren",
"appname_setup": "{appName} Instellen",
"please_try_again": "Probeer het opnieuw.", "please_try_again": "Probeer het opnieuw.",
"you_are_about_to_sign_in_to_the_initial_admin_account": "U staat op het punt om in te loggen op het oorspronkelijke beheerdersaccount. Iedereen met deze link heeft toegang tot het account totdat er een passkey is toegevoegd. Stel zo snel mogelijk een passkey in om ongeautoriseerde toegang te voorkomen.",
"continue": "Doorgaan", "continue": "Doorgaan",
"alternative_sign_in": "Alternatieve aanmelding", "alternative_sign_in": "Alternatieve aanmelding",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Als u geen toegang hebt tot uw passkeys, kunt u zich op een van de volgende manieren aanmelden.", "if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Als u geen toegang hebt tot uw passkeys, kunt u zich op een van de volgende manieren aanmelden.",
@@ -144,8 +143,6 @@
"expires_at": "Verloopt op", "expires_at": "Verloopt op",
"when_this_api_key_will_expire": "Wanneer deze API-sleutel verloopt.", "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.", "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", "expiration_date_must_be_in_the_future": "Vervaldatum moet in de toekomst liggen",
"revoke_api_key": "API-sleutel intrekken", "revoke_api_key": "API-sleutel intrekken",
"never": "Nooit", "never": "Nooit",
@@ -181,7 +178,7 @@
"email_login_notification": "E-mail-inlogmelding", "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.", "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", "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", "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.", "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", "send_test_email": "Test-e-mail verzenden",
@@ -311,7 +308,7 @@
"background_image": "Achtergrondfoto", "background_image": "Achtergrondfoto",
"language": "Taal", "language": "Taal",
"reset_profile_picture_question": "Profielfoto opnieuw instellen?", "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": "Reset",
"reset_to_default": "Standaardinstellingen herstellen", "reset_to_default": "Standaardinstellingen herstellen",
"profile_picture_has_been_reset": "Profielfoto is gereset. Het kan enkele minuten duren voordat de wijzigingen zichtbaar zijn.", "profile_picture_has_been_reset": "Profielfoto is gereset. Het kan enkele minuten duren voordat de wijzigingen zichtbaar zijn.",
@@ -321,13 +318,14 @@
"all_users": "Alle gebruikers", "all_users": "Alle gebruikers",
"all_events": "Alle activiteiten", "all_events": "Alle activiteiten",
"all_clients": "Alle clients", "all_clients": "Alle clients",
"all_locations": "All Locations",
"global_audit_log": "Algemeen audit logboek", "global_audit_log": "Algemeen audit logboek",
"see_all_account_activities_from_the_last_3_months": "Bekijk alle gebruikersactiviteit van de afgelopen 3 maanden.", "see_all_account_activities_from_the_last_3_months": "Bekijk alle gebruikersactiviteit van de afgelopen 3 maanden.",
"token_sign_in": "Token Sign In", "token_sign_in": "Token Sign In",
"client_authorization": "Client autorisatie", "client_authorization": "Client autorisatie",
"new_client_authorization": "Nieuwe clientautorisatie", "new_client_authorization": "Nieuwe clientautorisatie",
"disable_animations": "Disable Animations", "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", "user_disabled": "Account Disabled",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.", "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_disabled_successfully": "User has been disabled successfully.",
@@ -340,13 +338,85 @@
"login_code_email_success": "The login code has been sent to the user.", "login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email", "send_email": "Send Email",
"show_code": "Show Code", "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", "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.", "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", "authorize_device": "Authorize Device",
"the_device_has_been_authorized": "The device has been authorized.", "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.", "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", "oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted" "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",
"signup_token": "Signup Token",
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
"usage_limit": "Usage Limit",
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
"expires": "Expires",
"signup": "Sign Up",
"signup_requires_valid_token": "A valid signup token is required to create an account",
"validating_signup_token": "Validating signup token",
"go_to_login": "Go to login",
"signup_to_appname": "Sign Up to {appName}",
"create_your_account_to_get_started": "Create your account to get started.",
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
"setup_your_passkey": "Set up your passkey",
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
"skip_for_now": "Skip for now",
"account_created": "Account Created",
"enable_user_signups": "Enable User Signups",
"enable_user_signups_description": "Whether the User Signup functionality should be enabled.",
"user_signups_are_disabled": "User signups are currently disabled",
"create_signup_token": "Create Signup Token",
"view_active_signup_tokens": "View Active Signup Tokens",
"manage_signup_tokens": "Manage Signup Tokens",
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
"signup_token_deleted_successfully": "Signup token deleted successfully.",
"expired": "Expired",
"used_up": "Used Up",
"active": "Active",
"usage": "Usage",
"created": "Created",
"token": "Token",
"loading": "Loading",
"delete_signup_token": "Delete Signup Token",
"are_you_sure_you_want_to_delete_this_signup_token": "Are you sure you want to delete this signup token? This action cannot be undone.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.",
"signup_with_token": "Signup with token",
"signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.",
"signup_open": "Open Signup",
"signup_open_description": "Anyone can create a new account without restrictions.",
"of": "of",
"skip_passkey_setup": "Skip Passkey Setup",
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
} }

View File

@@ -3,6 +3,7 @@
"my_account": "Moje konto", "my_account": "Moje konto",
"logout": "Wyloguj się", "logout": "Wyloguj się",
"confirm": "Potwierdź", "confirm": "Potwierdź",
"docs": "Docs",
"key": "Klucz", "key": "Klucz",
"value": "Wartość", "value": "Wartość",
"remove_custom_claim": "Usuń niestandardowy atrybut", "remove_custom_claim": "Usuń niestandardowy atrybut",
@@ -64,11 +65,9 @@
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Czy chcesz się wylogować z Pocket ID z konta <b>{username}</b>?", "do_you_want_to_sign_out_of_pocketid_with_the_account": "Czy chcesz się wylogować z Pocket ID z konta <b>{username}</b>?",
"sign_in_to_appname": "Zaloguj się do {appName}", "sign_in_to_appname": "Zaloguj się do {appName}",
"please_try_to_sign_in_again": "Spróbuj zalogować się ponownie.", "please_try_to_sign_in_again": "Spróbuj zalogować się ponownie.",
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Uwierzytelnij się swoim kluczem, aby uzyskać dostęp do panelu administracyjnego.", "authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
"authenticate": "Uwierzytelnij", "authenticate": "Uwierzytelnij",
"appname_setup": "Konfiguracja {appName}",
"please_try_again": "Spróbuj ponownie.", "please_try_again": "Spróbuj ponownie.",
"you_are_about_to_sign_in_to_the_initial_admin_account": "Zaraz zalogujesz się na początkowe konto administratora. Każdy z tym linkiem ma dostęp do konta, dopóki nie zostanie dodany klucz. Dodaj klucz jak najszybciej, aby zapobiec nieautoryzowanemu dostępowi.",
"continue": "Kontynuuj", "continue": "Kontynuuj",
"alternative_sign_in": "Alternatywne logowanie", "alternative_sign_in": "Alternatywne logowanie",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Jeśli nie masz dostępu do swojego klucza, możesz zalogować się, używając jednej z następujących metod.", "if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Jeśli nie masz dostępu do swojego klucza, możesz zalogować się, używając jednej z następujących metod.",
@@ -144,8 +143,6 @@
"expires_at": "Wygasa o", "expires_at": "Wygasa o",
"when_this_api_key_will_expire": "Kiedy ten klucz API wygaśnie.", "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.", "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", "expiration_date_must_be_in_the_future": "Data wygaśnięcia musi być w przyszłości",
"revoke_api_key": "Unieważnij klucz API", "revoke_api_key": "Unieważnij klucz API",
"never": "Nigdy", "never": "Nigdy",
@@ -181,7 +178,7 @@
"email_login_notification": "Powiadomienie o logowaniu przez e-mail", "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.", "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", "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", "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.", "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", "send_test_email": "Wyślij testowy e-mail",
@@ -311,7 +308,7 @@
"background_image": "Obraz tła", "background_image": "Obraz tła",
"language": "Język", "language": "Język",
"reset_profile_picture_question": "Zresetować zdjęcie profilowe?", "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": "Zresetuj",
"reset_to_default": "Zresetuj do domyślnych", "reset_to_default": "Zresetuj do domyślnych",
"profile_picture_has_been_reset": "Zdjęcie profilowe zostało zresetowane. Może to potrwać kilka minut.", "profile_picture_has_been_reset": "Zdjęcie profilowe zostało zresetowane. Może to potrwać kilka minut.",
@@ -321,13 +318,14 @@
"all_users": "Wszyscy użytkownicy", "all_users": "Wszyscy użytkownicy",
"all_events": "Wszystkie wydarzenia", "all_events": "Wszystkie wydarzenia",
"all_clients": "Wszyscy klienci", "all_clients": "Wszyscy klienci",
"all_locations": "All Locations",
"global_audit_log": "Globalny dziennik audytu", "global_audit_log": "Globalny dziennik audytu",
"see_all_account_activities_from_the_last_3_months": "Zobacz wszystkie działania użytkowników z ostatnich 3 miesięcy.", "see_all_account_activities_from_the_last_3_months": "Zobacz wszystkie działania użytkowników z ostatnich 3 miesięcy.",
"token_sign_in": "Logowanie za pomocą tokena", "token_sign_in": "Logowanie za pomocą tokena",
"client_authorization": "Autoryzacja klienta", "client_authorization": "Autoryzacja klienta",
"new_client_authorization": "Nowa autoryzacja klienta", "new_client_authorization": "Nowa autoryzacja klienta",
"disable_animations": "Wyłącz animacje", "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", "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.", "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.", "user_disabled_successfully": "Sukces! Konto zostało wyłączone.",
@@ -340,13 +338,85 @@
"login_code_email_success": "Kod logowania został wysłany do użytkownika.", "login_code_email_success": "Kod logowania został wysłany do użytkownika.",
"send_email": "Wyślij e-mail", "send_email": "Wyślij e-mail",
"show_code": "Pokaż kod", "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", "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ąć.", "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", "authorize_device": "Autoryzuj urządzenie",
"the_device_has_been_authorized": "Urządzenie zostało autoryzowane.", "the_device_has_been_authorized": "Urządzenie zostało autoryzowane.",
"enter_code_displayed_in_previous_step": "Wprowadź kod wyświetlony w poprzednim kroku.", "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", "oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted" "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",
"signup_token": "Signup Token",
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
"usage_limit": "Usage Limit",
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
"expires": "Expires",
"signup": "Sign Up",
"signup_requires_valid_token": "A valid signup token is required to create an account",
"validating_signup_token": "Validating signup token",
"go_to_login": "Go to login",
"signup_to_appname": "Sign Up to {appName}",
"create_your_account_to_get_started": "Create your account to get started.",
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
"setup_your_passkey": "Set up your passkey",
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
"skip_for_now": "Skip for now",
"account_created": "Account Created",
"enable_user_signups": "Enable User Signups",
"enable_user_signups_description": "Whether the User Signup functionality should be enabled.",
"user_signups_are_disabled": "User signups are currently disabled",
"create_signup_token": "Create Signup Token",
"view_active_signup_tokens": "View Active Signup Tokens",
"manage_signup_tokens": "Manage Signup Tokens",
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
"signup_token_deleted_successfully": "Signup token deleted successfully.",
"expired": "Expired",
"used_up": "Used Up",
"active": "Active",
"usage": "Usage",
"created": "Created",
"token": "Token",
"loading": "Loading",
"delete_signup_token": "Delete Signup Token",
"are_you_sure_you_want_to_delete_this_signup_token": "Are you sure you want to delete this signup token? This action cannot be undone.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.",
"signup_with_token": "Signup with token",
"signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.",
"signup_open": "Open Signup",
"signup_open_description": "Anyone can create a new account without restrictions.",
"of": "of",
"skip_passkey_setup": "Skip Passkey Setup",
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
} }

View File

@@ -3,6 +3,7 @@
"my_account": "Minha Conta", "my_account": "Minha Conta",
"logout": "Sair", "logout": "Sair",
"confirm": "Confirmar", "confirm": "Confirmar",
"docs": "Docs",
"key": "Chave", "key": "Chave",
"value": "Valor", "value": "Valor",
"remove_custom_claim": "Remove custom claim", "remove_custom_claim": "Remove custom claim",
@@ -64,11 +65,9 @@
"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>?", "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": "Entrar em {appName}", "sign_in_to_appname": "Entrar em {appName}",
"please_try_to_sign_in_again": "Please try to sign in again.", "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_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
"authenticate": "Autenticar", "authenticate": "Autenticar",
"appname_setup": "{appName} Setup",
"please_try_again": "Please try again.", "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": "Continuar", "continue": "Continuar",
"alternative_sign_in": "Alternative Sign In", "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.", "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.",
@@ -144,8 +143,6 @@
"expires_at": "Expires At", "expires_at": "Expires At",
"when_this_api_key_will_expire": "When this API key will expire.", "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.", "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", "expiration_date_must_be_in_the_future": "Expiration date must be in the future",
"revoke_api_key": "Revoke API Key", "revoke_api_key": "Revoke API Key",
"never": "Nunca", "never": "Nunca",
@@ -181,7 +178,7 @@
"email_login_notification": "Email Login Notification", "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.", "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", "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", "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.", "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": "Send test email",
@@ -311,7 +308,7 @@
"background_image": "Background Image", "background_image": "Background Image",
"language": "Idioma", "language": "Idioma",
"reset_profile_picture_question": "Reset profile picture?", "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": "Redefinir",
"reset_to_default": "Redefinir para o padrão", "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.", "profile_picture_has_been_reset": "Profile picture has been reset. It may take a few minutes to update.",
@@ -321,13 +318,14 @@
"all_users": "All Users", "all_users": "All Users",
"all_events": "All Events", "all_events": "All Events",
"all_clients": "All Clients", "all_clients": "All Clients",
"all_locations": "All Locations",
"global_audit_log": "Global Audit Log", "global_audit_log": "Global Audit Log",
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.", "see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
"token_sign_in": "Token Sign In", "token_sign_in": "Token Sign In",
"client_authorization": "Client Authorization", "client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization", "new_client_authorization": "New Client Authorization",
"disable_animations": "Disable Animations", "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", "user_disabled": "Account Disabled",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.", "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_disabled_successfully": "User has been disabled successfully.",
@@ -340,13 +338,85 @@
"login_code_email_success": "The login code has been sent to the user.", "login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email", "send_email": "Send Email",
"show_code": "Show Code", "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", "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.", "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", "authorize_device": "Authorize Device",
"the_device_has_been_authorized": "The device has been authorized.", "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.", "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", "oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted" "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",
"signup_token": "Signup Token",
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
"usage_limit": "Usage Limit",
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
"expires": "Expires",
"signup": "Sign Up",
"signup_requires_valid_token": "A valid signup token is required to create an account",
"validating_signup_token": "Validating signup token",
"go_to_login": "Go to login",
"signup_to_appname": "Sign Up to {appName}",
"create_your_account_to_get_started": "Create your account to get started.",
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
"setup_your_passkey": "Set up your passkey",
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
"skip_for_now": "Skip for now",
"account_created": "Account Created",
"enable_user_signups": "Enable User Signups",
"enable_user_signups_description": "Whether the User Signup functionality should be enabled.",
"user_signups_are_disabled": "User signups are currently disabled",
"create_signup_token": "Create Signup Token",
"view_active_signup_tokens": "View Active Signup Tokens",
"manage_signup_tokens": "Manage Signup Tokens",
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
"signup_token_deleted_successfully": "Signup token deleted successfully.",
"expired": "Expired",
"used_up": "Used Up",
"active": "Active",
"usage": "Usage",
"created": "Created",
"token": "Token",
"loading": "Loading",
"delete_signup_token": "Delete Signup Token",
"are_you_sure_you_want_to_delete_this_signup_token": "Are you sure you want to delete this signup token? This action cannot be undone.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.",
"signup_with_token": "Signup with token",
"signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.",
"signup_open": "Open Signup",
"signup_open_description": "Anyone can create a new account without restrictions.",
"of": "of",
"skip_passkey_setup": "Skip Passkey Setup",
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
} }

View File

@@ -1,352 +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",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted"
}

View File

@@ -3,6 +3,7 @@
"my_account": "Моя учетная запись", "my_account": "Моя учетная запись",
"logout": "Выйти", "logout": "Выйти",
"confirm": "Подтвердить", "confirm": "Подтвердить",
"docs": "Документация",
"key": "Ключ", "key": "Ключ",
"value": "Значение", "value": "Значение",
"remove_custom_claim": "Удалить пользовательский claim", "remove_custom_claim": "Удалить пользовательский claim",
@@ -64,11 +65,9 @@
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Вы хотите выйти из Pocket ID с учетной записью <b>{username}</b>?", "do_you_want_to_sign_out_of_pocketid_with_the_account": "Вы хотите выйти из Pocket ID с учетной записью <b>{username}</b>?",
"sign_in_to_appname": "Вход в {appName}", "sign_in_to_appname": "Вход в {appName}",
"please_try_to_sign_in_again": "Пожалуйста, попробуйте войти снова.", "please_try_to_sign_in_again": "Пожалуйста, попробуйте войти снова.",
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Авторизуйтесь с использованием passkey для доступа к панели администратора.", "authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
"authenticate": "Авторизоваться", "authenticate": "Авторизоваться",
"appname_setup": "Настройка {appName}",
"please_try_again": "Пожалуйста, повторите попытку.", "please_try_again": "Пожалуйста, повторите попытку.",
"you_are_about_to_sign_in_to_the_initial_admin_account": "Вы собираетесь впервые войти в учетную запись администратора. Любой пользователь с этой ссылкой может получить доступ к учетной записи до тех пор, пока не будет добавлен passkey. Пожалуйста, настройте passkey как можно скорее для предотвращения несанкционированного доступа.",
"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": "Если у вас нет доступа к вашему passkey, вы можете войти одним из следующих способов.", "if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Если у вас нет доступа к вашему passkey, вы можете войти одним из следующих способов.",
@@ -144,8 +143,6 @@
"expires_at": "Действителен до", "expires_at": "Действителен до",
"when_this_api_key_will_expire": "Когда срок действия этого API ключа истечет.", "when_this_api_key_will_expire": "Когда срок действия этого API ключа истечет.",
"optional_description_to_help_identify_this_keys_purpose": "Опциональное описание, чтобы помочь определить цель этого ключа.", "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": "Дата истечения должна быть определена в будущем", "expiration_date_must_be_in_the_future": "Дата истечения должна быть определена в будущем",
"revoke_api_key": "Отозвать API ключ", "revoke_api_key": "Отозвать API ключ",
"never": "Никогда", "never": "Никогда",
@@ -321,13 +318,14 @@
"all_users": "Все пользователи", "all_users": "Все пользователи",
"all_events": "Все события", "all_events": "Все события",
"all_clients": "Все клиенты", "all_clients": "Все клиенты",
"all_locations": "Все местоположения",
"global_audit_log": "Глобальный журнал аудита", "global_audit_log": "Глобальный журнал аудита",
"see_all_account_activities_from_the_last_3_months": "Смотрите всю активность пользователей за последние 3 месяца.", "see_all_account_activities_from_the_last_3_months": "Смотрите всю активность пользователей за последние 3 месяца.",
"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_ui_animations": "Выключить анимации по всему интерфейсу.",
"user_disabled": "Аккаунт отключен", "user_disabled": "Аккаунт отключен",
"disabled_users_cannot_log_in_or_use_services": "Отключенные пользователи не могут войти или использовать сервисы.", "disabled_users_cannot_log_in_or_use_services": "Отключенные пользователи не могут войти или использовать сервисы.",
"user_disabled_successfully": "Пользователь успешно отключен.", "user_disabled_successfully": "Пользователь успешно отключен.",
@@ -340,13 +338,85 @@
"login_code_email_success": "Код входа был отправлен пользователю.", "login_code_email_success": "Код входа был отправлен пользователю.",
"send_email": "Отправить письмо", "send_email": "Отправить письмо",
"show_code": "Показать код", "show_code": "Показать код",
"callback_url_description": "URL-адреса, предоставленные клиентом. Поддерживаются wildcard-адреса (*), но лучше всего избегать их для лучшей безопасности.", "callback_url_description": "URL-адрес(а) предоставленные вашим клиентом. Будет автоматически добавлен если оставить пустым. Маски (*) поддерживаются, но лучше избегайте их для повышения безопасности.",
"logout_callback_url_description": "URL-адрес(а), предоставленный вашим клиентом для выхода. Маски (*) поддерживаются, но лучше избегайте их для повышения безопасности.",
"api_key_expiration": "Истечение срока действия API ключа", "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": "Авторизовать устройство", "authorize_device": "Авторизовать устройство",
"the_device_has_been_authorized": "Устройство авторизовано.", "the_device_has_been_authorized": "Устройство авторизовано.",
"enter_code_displayed_in_previous_step": "Введите код, который был отображен на предыдущем шаге.", "enter_code_displayed_in_previous_step": "Введите код, который был отображен на предыдущем шаге.",
"authorize": "Авторизируйте", "authorize": "Авторизируйте",
"oidc_allowed_group_count": "Allowed Group Count", "federated_client_credentials": "Федеративные учетные данные клиента",
"unrestricted": "Unrestricted" "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": "Применить",
"signup_token": "Signup Token",
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
"usage_limit": "Usage Limit",
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
"expires": "Expires",
"signup": "Sign Up",
"signup_requires_valid_token": "A valid signup token is required to create an account",
"validating_signup_token": "Validating signup token",
"go_to_login": "Go to login",
"signup_to_appname": "Sign Up to {appName}",
"create_your_account_to_get_started": "Create your account to get started.",
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
"setup_your_passkey": "Set up your passkey",
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
"skip_for_now": "Skip for now",
"account_created": "Account Created",
"enable_user_signups": "Enable User Signups",
"enable_user_signups_description": "Whether the User Signup functionality should be enabled.",
"user_signups_are_disabled": "User signups are currently disabled",
"create_signup_token": "Create Signup Token",
"view_active_signup_tokens": "View Active Signup Tokens",
"manage_signup_tokens": "Manage Signup Tokens",
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
"signup_token_deleted_successfully": "Signup token deleted successfully.",
"expired": "Expired",
"used_up": "Used Up",
"active": "Active",
"usage": "Usage",
"created": "Created",
"token": "Token",
"loading": "Loading",
"delete_signup_token": "Delete Signup Token",
"are_you_sure_you_want_to_delete_this_signup_token": "Are you sure you want to delete this signup token? This action cannot be undone.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.",
"signup_with_token": "Signup with token",
"signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.",
"signup_open": "Open Signup",
"signup_open_description": "Anyone can create a new account without restrictions.",
"of": "of",
"skip_passkey_setup": "Skip Passkey Setup",
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
} }

View File

@@ -1,22 +1,23 @@
{ {
"$schema": "https://inlang.com/schema/inlang-message-format", "$schema": "",
"my_account": "账户", "my_account": "账户",
"logout": "登出", "logout": "登出",
"confirm": "确认", "confirm": "确认",
"docs": "文档",
"key": "Key", "key": "Key",
"value": "Value", "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": "头像由 LDAP 服务器管理,无法在此处更改。", "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 格式。", "image_should_be_in_format": "图片应为 PNG 或 JPEG 格式。",
"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": "点击复制",
@@ -33,7 +34,7 @@
"one_week": "1 周", "one_week": "1 周",
"one_month": "1 个月", "one_month": "1 个月",
"expiration": "到期时间", "expiration": "到期时间",
"generate_code": "生成码", "generate_code": "生成登录码",
"name": "名称", "name": "名称",
"browser_unsupported": "浏览器不支持", "browser_unsupported": "浏览器不支持",
"this_browser_does_not_support_passkeys": "此浏览器不支持通行密钥。请使用其他登录方式。", "this_browser_does_not_support_passkeys": "此浏览器不支持通行密钥。请使用其他登录方式。",
@@ -59,19 +60,17 @@
"cancel": "取消", "cancel": "取消",
"sign_in": "登录", "sign_in": "登录",
"try_again": "重试", "try_again": "重试",
"client_logo": "客户端标志", "client_logo": "客户端 Logo",
"sign_out": "登出", "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}", "sign_in_to_appname": "登录到 {appName}",
"please_try_to_sign_in_again": "请尝试重新登录。", "please_try_to_sign_in_again": "请尝试重新登录。",
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "使用通行密钥或通过临时登录码进行登录", "authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
"authenticate": "登录", "authenticate": "登录",
"appname_setup": "{appName} 设置", "please_try_again": "请再试一次。",
"please_try_again": "请重试。",
"you_are_about_to_sign_in_to_the_initial_admin_account": "您即将登录到初始管理员账户。在此添加通行密钥之前,任何拥有此链接的人都可以访问该账户。请尽快设置通行密钥以防止未经授权的访问。",
"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_do_not_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": "输入一次性登录码以登录。",
@@ -102,8 +101,8 @@
"device": "设备", "device": "设备",
"client": "客户端", "client": "客户端",
"unknown": "未知", "unknown": "未知",
"account_details_updated_successfully": "账户详细信息更新成功", "account_details_updated_successfully": "账户信息已成功更新",
"profile_picture_updated_successfully": "头像更新成功。可能需要几分钟才能新。", "profile_picture_updated_successfully": "头像更新成功。可能需要几分钟才能完成刷新。",
"account_settings": "账户设置", "account_settings": "账户设置",
"passkey_missing": "尚未绑定通行密钥", "passkey_missing": "尚未绑定通行密钥",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "请添加通行密钥以防止失去对账户的访问。", "please_provide_a_passkey_to_prevent_losing_access_to_your_account": "请添加通行密钥以防止失去对账户的访问。",
@@ -111,7 +110,7 @@
"it_is_recommended_to_add_more_than_one_passkey": "建议添加多个通行密钥以避免失去对账户的访问。", "it_is_recommended_to_add_more_than_one_passkey": "建议添加多个通行密钥以避免失去对账户的访问。",
"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": "创建",
@@ -120,15 +119,15 @@
"username": "用户名", "username": "用户名",
"save": "保存", "save": "保存",
"username_can_only_contain": "用户名只能包含小写字母、数字、下划线、点、连字符和 '@' 符号", "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": "或访问", "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": "删除 {passkeyName}", "delete_passkey_name": "删除 {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": "创建 API 密钥", "create_api_key": "创建 API 密钥",
@@ -136,27 +135,25 @@
"add_api_key": "添加 API 密钥", "add_api_key": "添加 API 密钥",
"manage_api_keys": "管理 API 密钥", "manage_api_keys": "管理 API 密钥",
"api_key_created": "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": "描述", "description": "描述",
"api_key": "API 密钥", "api_key": "API 密钥",
"close": "关闭", "close": "关闭",
"name_to_identify_this_api_key": "用于识别此 API 密钥的名称。", "name_to_identify_this_api_key": "用于识别此 API 密钥的名称。",
"expires_at": "期时间", "expires_at": "期时间",
"when_this_api_key_will_expire": "此 API 密钥的期时间。", "when_this_api_key_will_expire": "此 API 密钥的期时间。",
"optional_description_to_help_identify_this_keys_purpose": "可选描述,帮助识别此密钥的用途。", "optional_description_to_help_identify_this_keys_purpose": "可选描述,用于帮助识别此密钥的用途。",
"name_must_be_at_least_3_characters": "名称必须至少为 3 个字符", "expiration_date_must_be_in_the_future": "到期日期必须设定为未来的日期",
"name_cannot_exceed_50_characters": "名称不能超过 50 个字符",
"expiration_date_must_be_in_the_future": "过期日期必须是未来的日期",
"revoke_api_key": "撤销 API 密钥", "revoke_api_key": "撤销 API 密钥",
"never": "永不", "never": "永不",
"revoke": "撤销", "revoke": "撤销",
"api_key_revoked_successfully": "API 密钥撤销成功", "api_key_revoked_successfully": "API 密钥撤销成功",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "您确定要撤销 API 密钥 \"{apiKeyName}\" 吗?这将中断使用此密钥的任何集成。", "are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "您确定要撤销 API 密钥 \"{apiKeyName}\" 吗?这将中断使用此密钥的任何集成。",
"last_used": "最后使用", "last_used": "上次使用时间",
"actions": "操作", "actions": "操作",
"images_updated_successfully": "图片更新成功", "images_updated_successfully": "已成功更新图片",
"general": "常规", "general": "常规",
"configure_smtp_to_send_emails": "启用电子邮件通知,以便在新设备或位置检测到登录时提醒用户。", "configure_smtp_to_send_emails": "启用电子邮件通知,当检测到来自新设备或位置登录时提醒用户。",
"ldap": "LDAP", "ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "配置 LDAP 设置以从 LDAP 服务器同步用户和群组。", "configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "配置 LDAP 设置以从 LDAP 服务器同步用户和群组。",
"images": "图片", "images": "图片",
@@ -166,7 +163,7 @@
"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": "测试电子邮件已成功发送到您的电子邮件地址。",
"failed_to_send_test_email": "发送测试电子邮件失败。请检查服务器日志以获取更多信息。", "failed_to_send_test_email": "发送测试电子邮件失败。请检查服务器日志以获取详细信息。",
"smtp_configuration": "SMTP 配置", "smtp_configuration": "SMTP 配置",
"smtp_host": "SMTP 主机", "smtp_host": "SMTP 主机",
"smtp_port": "SMTP 端口", "smtp_port": "SMTP 端口",
@@ -178,23 +175,23 @@
"skip_certificate_verification": "跳过证书验证", "skip_certificate_verification": "跳过证书验证",
"this_can_be_useful_for_selfsigned_certificates": "这对于自签名证书很有用。", "this_can_be_useful_for_selfsigned_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": "用户请求的电子邮件登录代码", "emai_login_code_requested_by_user": "用户请求的电子邮件登录代码",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "允许用户通过发送到其电子邮件的登录代码登录。这会显著降低安全性,因为任何有权访问用户电子邮件的人都可以进入。", "allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "允许用户通过发送到其电子邮件的登录代码登录。这会显著降低安全性,因为任何有权访问用户电子邮件的人都可以进入。",
"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": "允许管理员通过电子邮件向用户发送登录代码。",
"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": "用户的电子邮件是否应标记为已验证,适用于 OIDC 客户端。", "whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "用户的电子邮件是否应标记为已验证,适用于 OIDC 客户端。",
"ldap_configuration_updated_successfully": "LDAP 配置更新成功", "ldap_configuration_updated_successfully": "LDAP 配置更新成功",
"ldap_disabled_successfully": "LDAP 禁用成功", "ldap_disabled_successfully": "LDAP 已成功禁用",
"ldap_sync_finished": "LDAP 同步完成", "ldap_sync_finished": "LDAP 同步完成",
"client_configuration": "客户端配置", "client_configuration": "客户端配置",
"ldap_url": "LDAP URL", "ldap_url": "LDAP URL",
@@ -202,9 +199,9 @@
"ldap_bind_password": "LDAP Bind Password", "ldap_bind_password": "LDAP Bind Password",
"ldap_base_dn": "LDAP Base DN", "ldap_base_dn": "LDAP Base DN",
"user_search_filter": "User Search Filter", "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", "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": "属性映射", "attribute_mapping": "属性映射",
"user_unique_identifier_attribute": "User Unique Identifier Attribute", "user_unique_identifier_attribute": "User Unique Identifier Attribute",
"the_value_of_this_attribute_should_never_change": "此属性的值不应更改。", "the_value_of_this_attribute_should_never_change": "此属性的值不应更改。",
@@ -213,26 +210,26 @@
"user_first_name_attribute": "User First Name Attribute", "user_first_name_attribute": "User First Name Attribute",
"user_last_name_attribute": "User Last Name Attribute", "user_last_name_attribute": "User Last Name Attribute",
"user_profile_picture_attribute": "User Profile Picture 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", "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_unique_identifier_attribute": "Group Unique Identifier Attribute",
"group_name_attribute": "Group Name 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 中拥有管理员权限。", "members_of_this_group_will_have_admin_privileges_in_pocketid": "此群组的成员将在 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": " {appName} 添加新用户", "add_a_new_user_to_appname": "将新用户添加到 {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": "删除 {firstName} {lastName}", "delete_firstname_lastname": "删除 {lastName} {firstName}",
"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": "管理员",
@@ -244,7 +241,7 @@
"user_updated_successfully": "用户更新成功", "user_updated_successfully": "用户更新成功",
"custom_claims_updated_successfully": "自定义声明更新成功", "custom_claims_updated_successfully": "自定义声明更新成功",
"back": "返回", "back": "返回",
"user_details_firstname_lastname": "用户详情 {firstName} {lastName}", "user_details_firstname_lastname": "用户详情 {lastName} {firstName}",
"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": "自定义声明是键值对,可用于存储有关用户的额外信息。如果请求了 \"profile\" 范围,这些声明将包含在 ID Token 中。", "custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "自定义声明是键值对,可用于存储有关用户的额外信息。如果请求了 \"profile\" 范围,这些声明将包含在 ID Token 中。",
@@ -258,25 +255,25 @@
"name_that_will_be_in_the_groups_claim": "将在 \"groups\" 声明中显示的名称", "name_that_will_be_in_the_groups_claim": "将在 \"groups\" 声明中显示的名称",
"delete_name": "删除 {name}", "delete_name": "删除 {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": "用户组详情 {name}", "user_group_details_name": "用户组详情 {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": "自定义声明是键值对,可用于存储有关用户的额外信息。如果请求了 'profile' 范围,这些声明将包含在 ID 令牌中。如果存在冲突,用户上定义的自定义声明将优先。", "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 客户端", "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 客户端", "add_oidc_client": "添加 OIDC 客户端",
"manage_oidc_clients": "管理 OIDC 客户端", "manage_oidc_clients": "管理 OIDC 客户端",
"one_time_link": "一次性链接", "one_time_link": "一次性链接",
"use_this_link_to_sign_in_once": "使用此链接一次性登录。这对尚未添加通行密钥或丢失通行密钥的用户必要。", "use_this_link_to_sign_in_once": "使用此链接进行一次性登录。这对尚未添加或丢失通行密钥的用户来说非常必要。",
"add": "添加", "add": "添加",
"callback_urls": "Callback URL", "callback_urls": "Callback URL",
"logout_callback_urls": "Logout Callback URL", "logout_callback_urls": "Logout Callback URL",
"public_client": "公共客户端", "public_client": "公共客户端",
"public_clients_description": "公共客户端没有客户端密钥,而是使用 PKCE。如果您的客户端是 SPA 或移动应用,请启用此选项。", "public_clients_description": "公共客户端没有客户端密钥。它们用于无法安全存储密钥的移动端、Web端和原生应用程序。",
"pkce": "PKCE", "pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "公钥代码交换是一种安全功能,可防止 CSRF 和授权代码拦截攻击。", "public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "公钥代码交换是一种安全功能,可防止 CSRF 和授权代码拦截攻击。",
"name_logo": "{name} Logo", "name_logo": "{name} Logo",
@@ -298,7 +295,7 @@
"are_you_sure_you_want_to_create_a_new_client_secret": "您确定要创建新的客户端密钥吗?旧的密钥将被失效。", "are_you_sure_you_want_to_create_a_new_client_secret": "您确定要创建新的客户端密钥吗?旧的密钥将被失效。",
"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 客户端 {name}", "oidc_client_name": "OIDC 客户端 {name}",
"client_id": "客户端 ID", "client_id": "客户端 ID",
"client_secret": "客户端密钥", "client_secret": "客户端密钥",
@@ -311,9 +308,9 @@
"background_image": "背景图片", "background_image": "背景图片",
"language": "语言", "language": "语言",
"reset_profile_picture_question": "重置头像?", "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": "重置",
"reset_to_default": "重置为默认", "reset_to_default": "恢复默认设置",
"profile_picture_has_been_reset": "头像已重置。可能需要几分钟才能更新。", "profile_picture_has_been_reset": "头像已重置。可能需要几分钟才能更新。",
"select_the_language_you_want_to_use": "选择您要使用的语言。某些语言可能未完全翻译。", "select_the_language_you_want_to_use": "选择您要使用的语言。某些语言可能未完全翻译。",
"personal": "个人", "personal": "个人",
@@ -321,26 +318,105 @@
"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": "查看过去 3 个月的所有用户活动。", "see_all_account_activities_from_the_last_3_months": "查看过去 3 个月的所有用户活动。",
"token_sign_in": "Token 登录", "token_sign_in": "Token 登录",
"client_authorization": "客户端授权", "client_authorization": "客户端授权",
"new_client_authorization": "首次客户端授权", "new_client_authorization": "首次客户端授权",
"disable_animations": "禁用动画", "disable_animations": "关闭动画",
"turn_off_all_animations_throughout_the_admin_ui": "关闭管理用户界面中的所有动画。", "turn_off_ui_animations": "关闭界面中的所有动画效果。",
"user_disabled": "账户已禁用", "user_disabled": "账户已禁用",
"disabled_users_cannot_log_in_or_use_services": "禁用的用户无法登录或使用服务。", "disabled_users_cannot_log_in_or_use_services": "禁用的用户无法登录或使用服务。",
"user_disabled_successfully": "用户已成功禁用。", "user_disabled_successfully": "用户已成功禁用。",
"user_enabled_successfully": "用户已成功启用。", "user_enabled_successfully": "用户已成功启用。",
"status": "状态", "status": "状态",
"disable_firstname_lastname": "禁用 {firstName} {lastName}", "disable_firstname_lastname": "禁用 {lastName} {firstName}",
"are_you_sure_you_want_to_disable_this_user": "您确定要禁用此用户吗?他们将无法登录或访问任何服务。", "are_you_sure_you_want_to_disable_this_user": "您确定要禁用此用户吗?他们将无法登录或访问任何服务。",
"ldap_soft_delete_users": "保留来自 LDAP 禁用用户。", "ldap_soft_delete_users": "保留 LDAP 中被禁用用户。",
"ldap_soft_delete_users_description": "启用后,从 LDAP 中移除的用户将禁用,而不从系统中删除。", "ldap_soft_delete_users_description": "启用后,从 LDAP 中移除的用户将会被标记为禁用,而不从系统中删除。",
"login_code_email_success": "登录代码已发送给用户。", "login_code_email_success": "登录代码已发送给用户。",
"send_email": "发送电子邮件", "send_email": "发送电子邮件",
"show_code": "显示登录码", "show_code": "显示登录码",
"callback_url_description": "由您的客户端提供的 URL。支持通配符 (*),但为了更好的安全性最好避免使用。", "callback_url_description": "由您的客户端提供的 URL。支持通配符 (*),但为了更好的安全性最好避免使用。",
"logout_callback_url_description": "注销功能中由您客户端配置的URL地址。支持通配符 (*),但出于安全考虑,强烈建议避免使用通配符。",
"api_key_expiration": "API 密钥过期", "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",
"signup_token": "Signup Token",
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
"usage_limit": "Usage Limit",
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
"expires": "Expires",
"signup": "Sign Up",
"signup_requires_valid_token": "A valid signup token is required to create an account",
"validating_signup_token": "Validating signup token",
"go_to_login": "Go to login",
"signup_to_appname": "Sign Up to {appName}",
"create_your_account_to_get_started": "Create your account to get started.",
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
"setup_your_passkey": "Set up your passkey",
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
"skip_for_now": "Skip for now",
"account_created": "Account Created",
"enable_user_signups": "Enable User Signups",
"enable_user_signups_description": "Whether the User Signup functionality should be enabled.",
"user_signups_are_disabled": "User signups are currently disabled",
"create_signup_token": "Create Signup Token",
"view_active_signup_tokens": "View Active Signup Tokens",
"manage_signup_tokens": "Manage Signup Tokens",
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
"signup_token_deleted_successfully": "Signup token deleted successfully.",
"expired": "Expired",
"used_up": "Used Up",
"active": "Active",
"usage": "Usage",
"created": "Created",
"token": "Token",
"loading": "Loading",
"delete_signup_token": "Delete Signup Token",
"are_you_sure_you_want_to_delete_this_signup_token": "Are you sure you want to delete this signup token? This action cannot be undone.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.",
"signup_with_token": "Signup with token",
"signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.",
"signup_open": "Open Signup",
"signup_open_description": "Anyone can create a new account without restrictions.",
"of": "of",
"skip_passkey_setup": "Skip Passkey Setup",
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
} }

View File

@@ -0,0 +1,422 @@
{
"$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_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
"authenticate": "驗證",
"please_try_again": "請再試一次。",
"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": "所有客戶端",
"all_locations": "All Locations",
"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",
"signup_token": "Signup Token",
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
"usage_limit": "Usage Limit",
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
"expires": "Expires",
"signup": "Sign Up",
"signup_requires_valid_token": "A valid signup token is required to create an account",
"validating_signup_token": "Validating signup token",
"go_to_login": "Go to login",
"signup_to_appname": "Sign Up to {appName}",
"create_your_account_to_get_started": "Create your account to get started.",
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
"setup_your_passkey": "Set up your passkey",
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
"skip_for_now": "Skip for now",
"account_created": "Account Created",
"enable_user_signups": "Enable User Signups",
"enable_user_signups_description": "Whether the User Signup functionality should be enabled.",
"user_signups_are_disabled": "User signups are currently disabled",
"create_signup_token": "Create Signup Token",
"view_active_signup_tokens": "View Active Signup Tokens",
"manage_signup_tokens": "Manage Signup Tokens",
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
"signup_token_deleted_successfully": "Signup token deleted successfully.",
"expired": "Expired",
"used_up": "Used Up",
"active": "Active",
"usage": "Usage",
"created": "Created",
"token": "Token",
"loading": "Loading",
"delete_signup_token": "Delete Signup Token",
"are_you_sure_you_want_to_delete_this_signup_token": "Are you sure you want to delete this signup token? This action cannot be undone.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.",
"signup_with_token": "Signup with token",
"signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.",
"signup_open": "Open Signup",
"signup_open_description": "Anyone can create a new account without restrictions.",
"of": "of",
"skip_passkey_setup": "Skip Passkey Setup",
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
}

11022
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,9 @@
import DatePicker from '$lib/components/form/date-picker.svelte'; import DatePicker from '$lib/components/form/date-picker.svelte';
import { Input, type FormInputEvent } from '$lib/components/ui/input'; import { Input, type FormInputEvent } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import { m } from '$lib/paraglide/messages';
import type { FormInput } from '$lib/utils/form-util'; import type { FormInput } from '$lib/utils/form-util';
import { LucideExternalLink } from '@lucide/svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
@@ -10,6 +12,7 @@
input = $bindable(), input = $bindable(),
label, label,
description, description,
docsLink,
placeholder, placeholder,
disabled = false, disabled = false,
type = 'text', type = 'text',
@@ -20,6 +23,7 @@
input?: FormInput<string | boolean | number | Date | undefined>; input?: FormInput<string | boolean | number | Date | undefined>;
label?: string; label?: string;
description?: string; description?: string;
docsLink?: string;
placeholder?: string; placeholder?: string;
disabled?: boolean; disabled?: boolean;
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox' | 'date'; type?: 'text' | 'password' | 'email' | 'number' | 'checkbox' | 'date';
@@ -35,7 +39,19 @@
<Label class="mb-0" for={id}>{label}</Label> <Label class="mb-0" for={id}>{label}</Label>
{/if} {/if}
{#if description} {#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} {/if}
<div class={label || description ? 'mt-2' : ''}> <div class={label || description ? 'mt-2' : ''}>
{#if children} {#if children}
@@ -56,7 +72,7 @@
{/if} {/if}
{/if} {/if}
{#if input?.error} {#if input?.error}
<p class="text-destructive mt-1 text-xs">{input.error}</p> <p class="text-destructive mt-1 text-xs text-start">{input.error}</p>
{/if} {/if}
</div> </div>
</div> </div>

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>

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