Compare commits

...

38 Commits

Author SHA1 Message Date
Kyle Mendell
505bdcb8ba release: 1.6.2 2025-07-09 16:56:34 -05:00
Kyle Mendell
f103a54790 fix: ensure confirmation dialog shows on top of other components 2025-07-09 16:50:01 -05:00
Alessandro (Ale) Segala
e1de593dcd fix: login failures on Postgres when IP is null (#737) 2025-07-09 08:45:07 -05:00
Elias Schneider
45f42772b1 chore(translations): update translations via Crowdin (#730) 2025-07-07 20:06:52 -05:00
XLion
98152640b1 chore(translations): Fix inconsistent punctuation marks for the language name of zh-TW (#731) 2025-07-07 12:54:45 +00:00
github-actions[bot]
04e235e805 chore: update AAGUIDs (#729)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-07-06 21:04:32 -05:00
Elias Schneider
ae737dddaa release: 1.6.1 2025-07-06 22:50:33 +02:00
Elias Schneider
f565c702e5 ci/cd: use latest-distroless tag for latest distroless images 2025-07-06 22:48:55 +02:00
Elias Schneider
f945b44bc9 release: 1.6.0 2025-07-06 20:19:45 +02:00
Elias Schneider
857b9cc864 refactor: run formatter 2025-07-06 15:32:19 +02:00
Elias Schneider
bf042563e9 feat: add support for OAuth 2.0 Authorization Server Issuer Identification 2025-07-06 15:29:26 +02:00
Elias Schneider
49f1ab2f75 fix: custom claims input suggestions flickering 2025-07-06 00:23:06 +02:00
Elias Schneider
e46f60ac8d fix: keep sidebar in settings sticky 2025-07-05 21:59:13 +02:00
Elias Schneider
5c9e504291 fix: show friendly name in user group selection 2025-07-05 21:58:56 +02:00
Alessandro (Ale) Segala
7fe83f8087 fix: actually fix linter issues (#720) 2025-07-04 21:14:44 -05:00
Alessandro (Ale) Segala
43f0114c57 fix: linter issues (#719) 2025-07-04 18:29:28 -05:00
Alessandro (Ale) Segala
1a41b05f60 feat: distroless container additional variant + healthcheck command (#716) 2025-07-04 12:26:01 -07:00
Elias Schneider
81315790a8 fix: support non UTF-8 LDAP IDs (#714) 2025-07-04 08:42:11 +02:00
Alessandro (Ale) Segala
8c8fc2304d feat: add "key-rotate" command (#709) 2025-07-03 22:23:24 +02:00
Elias Schneider
15ece0ab30 chore(translations): update translations via Crowdin (#712) 2025-07-03 13:47:27 -05:00
Alessandro (Ale) Segala
5550729120 feat: encrypt private keys saved on disk and in database (#682)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-07-03 13:34:34 -05:00
Elias Schneider
9872608d61 fix: allow profile picture update even if "allow own account edit" enabled 2025-07-03 10:57:56 +02:00
Elias Schneider
be52660227 feat: enhance language selection message and add translation contribution link 2025-07-03 09:20:39 +02:00
Elias Schneider
237342e876 chore(translations): update translations via Crowdin (#707) 2025-07-02 13:45:10 +02:00
Elias Schneider
cfbfbc9753 chore(translations): update translations via Crowdin (#705) 2025-07-01 17:11:42 -05:00
Elias Schneider
aefb308536 fix: token introspection authentication not handled correctly (#704)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
2025-07-01 21:14:07 +00:00
Alessandro (Ale) Segala
031181ad2a fix: auth fails when client IP is empty on Postgres (#695) 2025-06-30 14:04:30 +02:00
Elias Schneider
dbf3da41f3 chore(translations): update translations via Crowdin (#699)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-06-29 13:04:15 -05:00
Kyle Mendell
3a2902789e chore: use correct team name for codeowners 2025-06-29 09:31:32 -05:00
Kyle Mendell
459a4fd727 chore: update CODEOWNERS to be global 2025-06-29 09:30:00 -05:00
Kyle Mendell
2ecc1abbad chore: add CODEOWNERS file 2025-06-29 09:28:08 -05:00
Kyle Mendell
92c57ada1a fix: app config forms not updating with latest values (#696) 2025-06-29 15:13:06 +02:00
Elias Schneider
fceb6fa7b4 fix: add missing error check in initial user setup 2025-06-29 15:10:39 +02:00
Alessandro (Ale) Segala
c290c027fb refactor: use github.com/jinzhu/copier for MapStruct (#698) 2025-06-29 15:01:10 +02:00
Elias Schneider
ca205a8c73 chore(translations): update translations via Crowdin (#697) 2025-06-29 01:01:39 -05:00
Elias Schneider
968cf0b307 chore(translations): update translations via Crowdin (#694) 2025-06-28 21:25:58 -05:00
Elias Schneider
fd8bee94a4 chore(translations): update translations via Crowdin (#692) 2025-06-28 15:26:17 +02:00
Manuel Rais
41ac1be082 chore(translations) : translate missing french values (#691) 2025-06-28 15:26:05 +02:00
97 changed files with 4837 additions and 1936 deletions

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @pocket-id/maintainers

View File

@@ -73,10 +73,24 @@ jobs:
push: true push: true
tags: ${{ env.DOCKER_IMAGE_NAME }}:next tags: ${{ env.DOCKER_IMAGE_NAME }}:next
file: Dockerfile-prebuilt file: Dockerfile-prebuilt
- name: Build and push container image (distroless)
uses: docker/build-push-action@v6
id: container-build-push-distroless
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ env.DOCKER_IMAGE_NAME }}:next-distroless
file: Dockerfile-distroless
- name: Container image attestation - name: Container image attestation
uses: actions/attest-build-provenance@v2 uses: actions/attest-build-provenance@v2
with: with:
subject-name: "${{ env.DOCKER_IMAGE_NAME }}" subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
subject-digest: ${{ steps.build-push-image.outputs.digest }} subject-digest: ${{ steps.build-push-image.outputs.digest }}
push-to-registry: true push-to-registry: true
- name: Container image attestation (distroless)
uses: actions/attest-build-provenance@v2
with:
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
subject-digest: ${{ steps.container-build-push-distroless.outputs.digest }}
push-to-registry: true

View File

@@ -29,14 +29,12 @@ jobs:
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Set DOCKER_IMAGE_NAME - name: Set DOCKER_IMAGE_NAME
run: | run: |
# Lowercase REPO_OWNER which is required for containers # Lowercase REPO_OWNER which is required for containers
REPO_OWNER=${{ github.repository_owner }} REPO_OWNER=${{ github.repository_owner }}
DOCKER_IMAGE_NAME="ghcr.io/${REPO_OWNER,,}/pocket-id" DOCKER_IMAGE_NAME="ghcr.io/${REPO_OWNER,,}/pocket-id"
echo "DOCKER_IMAGE_NAME=${DOCKER_IMAGE_NAME}" >>${GITHUB_ENV} echo "DOCKER_IMAGE_NAME=${DOCKER_IMAGE_NAME}" >>${GITHUB_ENV}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
@@ -53,17 +51,26 @@ jobs:
type=semver,pattern={{version}},prefix=v type=semver,pattern={{version}},prefix=v
type=semver,pattern={{major}}.{{minor}},prefix=v type=semver,pattern={{major}}.{{minor}},prefix=v
type=semver,pattern={{major}},prefix=v type=semver,pattern={{major}},prefix=v
- name: Docker metadata (distroless)
id: meta-distroless
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKER_IMAGE_NAME }}
flavor: |
suffix=-distroless,onlatest=true
tags: |
type=semver,pattern={{version}},prefix=v
type=semver,pattern={{major}}.{{minor}},prefix=v
type=semver,pattern={{major}},prefix=v
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: frontend working-directory: frontend
run: npm ci run: npm ci
- name: Build frontend - name: Build frontend
working-directory: frontend working-directory: frontend
run: npm run build run: npm run build
- name: Build binaries - name: Build binaries
run: sh scripts/development/build-binaries.sh run: sh scripts/development/build-binaries.sh
- name: Build and push container image - name: Build and push container image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
id: container-build-push id: container-build-push
@@ -74,19 +81,32 @@ jobs:
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
file: Dockerfile-prebuilt file: Dockerfile-prebuilt
- name: Build and push container image (distroless)
uses: docker/build-push-action@v6
id: container-build-push-distroless
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta-distroless.outputs.tags }}
labels: ${{ steps.meta-distroless.outputs.labels }}
file: Dockerfile-distroless
- name: Binary attestation - name: Binary attestation
uses: actions/attest-build-provenance@v2 uses: actions/attest-build-provenance@v2
with: with:
subject-path: "backend/.bin/pocket-id-**" subject-path: "backend/.bin/pocket-id-**"
- name: Container image attestation - name: Container image attestation
uses: actions/attest-build-provenance@v2 uses: actions/attest-build-provenance@v2
with: with:
subject-name: "${{ env.DOCKER_IMAGE_NAME }}" subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
subject-digest: ${{ steps.container-build-push.outputs.digest }} subject-digest: ${{ steps.container-build-push.outputs.digest }}
push-to-registry: true push-to-registry: true
- name: Container image attestation (distroless)
uses: actions/attest-build-provenance@v2
with:
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
subject-digest: ${{ steps.container-build-push-distroless.outputs.digest }}
push-to-registry: true
- name: Upload binaries to release - name: Upload binaries to release
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1 +1 @@
1.5.0 1.6.2

View File

@@ -1,3 +1,35 @@
## [](https://github.com/pocket-id/pocket-id/compare/v1.6.1...v) (2025-07-09)
### Bug Fixes
* ensure confirmation dialog shows on top of other components ([f103a54](https://github.com/pocket-id/pocket-id/commit/f103a547904070c5b192e519c8b5a8fed9d80e96))
* login failures on Postgres when IP is null ([#737](https://github.com/pocket-id/pocket-id/issues/737)) ([e1de593](https://github.com/pocket-id/pocket-id/commit/e1de593dcd30b7b04da3b003455134992b702595))
## [](https://github.com/pocket-id/pocket-id/compare/v1.5.0...v) (2025-07-06)
### Features
* add "key-rotate" command ([#709](https://github.com/pocket-id/pocket-id/issues/709)) ([8c8fc23](https://github.com/pocket-id/pocket-id/commit/8c8fc2304d8f33c1fea54b1138b109f282e78b8b))
* add support for OAuth 2.0 Authorization Server Issuer Identification ([bf04256](https://github.com/pocket-id/pocket-id/commit/bf042563e997d57bb087705a5789fd72ffbed467))
* distroless container additional variant + healthcheck command ([#716](https://github.com/pocket-id/pocket-id/issues/716)) ([1a41b05](https://github.com/pocket-id/pocket-id/commit/1a41b05f60d487fff78703bec1d4e832f96fd071))
* encrypt private keys saved on disk and in database ([#682](https://github.com/pocket-id/pocket-id/issues/682)) ([5550729](https://github.com/pocket-id/pocket-id/commit/5550729120ac9f5e9361c7f9cf25b9075a33a94a))
* enhance language selection message and add translation contribution link ([be52660](https://github.com/pocket-id/pocket-id/commit/be526602273c1689cb4057ca96d4214e7f817d1d))
### Bug Fixes
* actually fix linter issues ([#720](https://github.com/pocket-id/pocket-id/issues/720)) ([7fe83f8](https://github.com/pocket-id/pocket-id/commit/7fe83f8087f033f957bb6e0eee5e0c159417e1cd))
* add missing error check in initial user setup ([fceb6fa](https://github.com/pocket-id/pocket-id/commit/fceb6fa7b4701a3645c4c2353bcd108b15d69ded))
* allow profile picture update even if "allow own account edit" enabled ([9872608](https://github.com/pocket-id/pocket-id/commit/9872608d61a486f7b775f314d9392e0620bcd891))
* app config forms not updating with latest values ([#696](https://github.com/pocket-id/pocket-id/issues/696)) ([92c57ad](https://github.com/pocket-id/pocket-id/commit/92c57ada1a11f76963e36ca0a81bca8f52dbc84e))
* auth fails when client IP is empty on Postgres ([#695](https://github.com/pocket-id/pocket-id/issues/695)) ([031181a](https://github.com/pocket-id/pocket-id/commit/031181ad2ae8fae94cc5793dd1c614e79476a766))
* custom claims input suggestions flickering ([49f1ab2](https://github.com/pocket-id/pocket-id/commit/49f1ab2f75df97d551fff5acbadcd55df74af617))
* keep sidebar in settings sticky ([e46f60a](https://github.com/pocket-id/pocket-id/commit/e46f60ac8d6944bcea54d0708af1950d98f66c3c))
* linter issues ([#719](https://github.com/pocket-id/pocket-id/issues/719)) ([43f0114](https://github.com/pocket-id/pocket-id/commit/43f0114c579f7b5b32b372e09f46bcb2a9d7796e))
* show friendly name in user group selection ([5c9e504](https://github.com/pocket-id/pocket-id/commit/5c9e504291b3bffe947bcbe907701806e301d1fe))
* support non UTF-8 LDAP IDs ([#714](https://github.com/pocket-id/pocket-id/issues/714)) ([8131579](https://github.com/pocket-id/pocket-id/commit/81315790a8aa601a2565a1b54807df1e68f06dc5))
* token introspection authentication not handled correctly ([#704](https://github.com/pocket-id/pocket-id/issues/704)) ([aefb308](https://github.com/pocket-id/pocket-id/commit/aefb30853677baf7ed29ac8b539e1aadf56e14a4))
## [](https://github.com/pocket-id/pocket-id/compare/v1.4.1...v) (2025-06-27) ## [](https://github.com/pocket-id/pocket-id/compare/v1.4.1...v) (2025-06-27)

View File

@@ -48,5 +48,7 @@ RUN chmod +x /app/pocket-id && \
EXPOSE 1411 EXPOSE 1411
ENV APP_ENV=production ENV APP_ENV=production
HEALTHCHECK --interval=90s --timeout=5s --start-period=10s --retries=3 CMD [ "/app/pocket-id", "healthcheck" ]
ENTRYPOINT ["sh", "/app/docker/entrypoint.sh"] ENTRYPOINT ["sh", "/app/docker/entrypoint.sh"]
CMD ["/app/pocket-id"] CMD ["/app/pocket-id"]

18
Dockerfile-distroless Normal file
View File

@@ -0,0 +1,18 @@
# This Dockerfile embeds a pre-built binary for the given Linux architecture
# Binaries must be built using "./scripts/development/build-binaries.sh --docker-only"
FROM gcr.io/distroless/static-debian12:nonroot
# TARGETARCH can be "amd64" or "arm64"
ARG TARGETARCH
WORKDIR /app
COPY ./backend/.bin/pocket-id-linux-${TARGETARCH} /app/pocket-id
EXPOSE 1411
ENV APP_ENV=production
HEALTHCHECK --interval=90s --timeout=5s --start-period=10s --retries=3 CMD [ "/app/pocket-id", "healthcheck" ]
CMD ["/app/pocket-id"]

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 --docker-only" # Binaries must be built using "./scripts/development/build-binaries.sh --docker-only"
FROM alpine FROM alpine
@@ -16,5 +16,7 @@ COPY ./scripts/docker /app/docker
EXPOSE 1411 EXPOSE 1411
ENV APP_ENV=production ENV APP_ENV=production
HEALTHCHECK --interval=90s --timeout=5s --start-period=10s --retries=3 CMD [ "/app/pocket-id", "healthcheck" ]
ENTRYPOINT ["/app/docker/entrypoint.sh"] ENTRYPOINT ["/app/docker/entrypoint.sh"]
CMD ["/app/pocket-id"] CMD ["/app/pocket-id"]

View File

@@ -1,15 +1,9 @@
package main package main
import ( import (
"flag"
"fmt"
"log"
_ "time/tzdata" _ "time/tzdata"
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
"github.com/pocket-id/pocket-id/backend/internal/cmds" "github.com/pocket-id/pocket-id/backend/internal/cmds"
"github.com/pocket-id/pocket-id/backend/internal/common"
) )
// @title Pocket ID API // @title Pocket ID API
@@ -17,27 +11,5 @@ import (
// @description.markdown // @description.markdown
func main() { func main() {
// Get the command cmds.Execute()
// By default, this starts the server
var cmd string
flag.Parse()
args := flag.Args()
if len(args) > 0 {
cmd = args[0]
}
var err error
switch cmd {
case "version":
fmt.Println("pocket-id " + common.Version)
case "one-time-access-token":
err = cmds.OneTimeAccessToken(args)
default:
// Start the server
err = bootstrap.Bootstrap()
}
if err != nil {
log.Fatal(err.Error())
}
} }

View File

@@ -19,11 +19,13 @@ require (
github.com/golang-migrate/migrate/v4 v4.18.2 github.com/golang-migrate/migrate/v4 v4.18.2
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/jinzhu/copier v0.4.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2 github.com/lestrrat-go/httprc/v3 v3.0.0-beta2
github.com/lestrrat-go/jwx/v3 v3.0.1 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/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
go.opentelemetry.io/contrib/exporters/autoexport v0.59.0 go.opentelemetry.io/contrib/exporters/autoexport v0.59.0
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0 go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0
@@ -68,6 +70,7 @@ require (
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.2 // indirect github.com/jackc/pgx/v5 v5.7.2 // indirect
@@ -98,6 +101,7 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/segmentio/asm v1.2.0 // indirect github.com/segmentio/asm v1.2.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect

View File

@@ -24,6 +24,7 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -120,6 +121,8 @@ github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -140,6 +143,8 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -225,8 +230,13 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=

View File

@@ -11,13 +11,9 @@ 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/job" "github.com/pocket-id/pocket-id/backend/internal/job"
"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/signals"
) )
func Bootstrap() error { func Bootstrap(ctx context.Context) error {
// Get a context that is canceled when the application is stopping
ctx := signals.SignalContext(context.Background())
initApplicationImages() initApplicationImages()
// Initialize the tracer and metrics exporter // Initialize the tracer and metrics exporter
@@ -59,11 +55,12 @@ func Bootstrap() error {
// Invoke all shutdown functions // Invoke all shutdown functions
// We give these a timeout of 5s // We give these a timeout of 5s
// Note: we use a background context because the run context has been canceled already
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel() defer shutdownCancel()
err = utils. err = utils.
NewServiceRunner(shutdownFns...). NewServiceRunner(shutdownFns...).
Run(shutdownCtx) Run(shutdownCtx) //nolint:contextcheck
if err != nil { if err != nil {
log.Printf("Error shutting down services: %v", err) log.Printf("Error shutting down services: %v", err)
} }

View File

@@ -38,7 +38,7 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (sv
svc.geoLiteService = service.NewGeoLiteService(httpClient) svc.geoLiteService = service.NewGeoLiteService(httpClient)
svc.auditLogService = service.NewAuditLogService(db, svc.appConfigService, svc.emailService, svc.geoLiteService) svc.auditLogService = service.NewAuditLogService(db, svc.appConfigService, svc.emailService, svc.geoLiteService)
svc.jwtService = service.NewJwtService(svc.appConfigService) svc.jwtService = service.NewJwtService(db, 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)

View File

@@ -0,0 +1,83 @@
package cmds
import (
"context"
"log/slog"
"net/http"
"os"
"time"
"github.com/spf13/cobra"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
type healthcheckFlags struct {
Endpoint string
Verbose bool
}
func init() {
var flags healthcheckFlags
healthcheckCmd := &cobra.Command{
Use: "healthcheck",
Short: "Performs a healthcheck of a running Pocket ID instance",
Run: func(cmd *cobra.Command, args []string) {
start := time.Now()
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
defer cancel()
url := flags.Endpoint + "/healthz"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
slog.ErrorContext(ctx,
"Failed to create request object",
"error", err,
"url", url,
"ms", time.Since(start).Milliseconds(),
)
os.Exit(1)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
slog.ErrorContext(ctx,
"Failed to perform request",
"error", err,
"url", url,
"ms", time.Since(start).Milliseconds(),
)
os.Exit(1)
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
if err != nil {
slog.ErrorContext(ctx,
"Healthcheck failed",
"status", res.StatusCode,
"url", url,
"ms", time.Since(start).Milliseconds(),
)
os.Exit(1)
}
}
if flags.Verbose {
slog.InfoContext(ctx,
"Healthcheck succeeded",
"status", res.StatusCode,
"url", url,
"ms", time.Since(start).Milliseconds(),
)
}
},
}
healthcheckCmd.Flags().StringVarP(&flags.Endpoint, "endpoint", "e", "http://localhost:"+common.EnvConfig.Port, "Endpoint for Pocket ID")
healthcheckCmd.Flags().BoolVarP(&flags.Verbose, "verbose", "v", false, "Enable verbose mode")
rootCmd.AddCommand(healthcheckCmd)
}

View File

@@ -0,0 +1,107 @@
package cmds
import (
"context"
"errors"
"fmt"
"strings"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/spf13/cobra"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
)
type keyRotateFlags struct {
Alg string
Crv string
Yes bool
}
func init() {
var flags keyRotateFlags
keyRotateCmd := &cobra.Command{
Use: "key-rotate",
Short: "Generates a new token signing key and replaces the current one",
RunE: func(cmd *cobra.Command, args []string) error {
db := bootstrap.NewDatabase()
return keyRotate(cmd.Context(), flags, db, &common.EnvConfig)
},
}
keyRotateCmd.Flags().StringVarP(&flags.Alg, "alg", "a", "RS256", "Key algorithm. Supported values: RS256, RS384, RS512, ES256, ES384, ES512, EdDSA")
keyRotateCmd.Flags().StringVarP(&flags.Crv, "crv", "c", "", "Curve name when using EdDSA keys. Supported values: Ed25519")
keyRotateCmd.Flags().BoolVarP(&flags.Yes, "yes", "y", false, "Do not prompt for confirmation")
rootCmd.AddCommand(keyRotateCmd)
}
func keyRotate(ctx context.Context, flags keyRotateFlags, db *gorm.DB, envConfig *common.EnvConfigSchema) error {
// Validate the flags
switch strings.ToUpper(flags.Alg) {
case jwa.RS256().String(), jwa.RS384().String(), jwa.RS512().String(),
jwa.ES256().String(), jwa.ES384().String(), jwa.ES512().String():
// All good, but uppercase it for consistency
flags.Alg = strings.ToUpper(flags.Alg)
case strings.ToUpper(jwa.EdDSA().String()):
// Ensure Crv is set and valid
switch strings.ToUpper(flags.Crv) {
case strings.ToUpper(jwa.Ed25519().String()):
// All good, but ensure consistency in casing
flags.Crv = jwa.Ed25519().String()
case "":
return errors.New("a curve name is required when algorithm is EdDSA")
default:
return errors.New("unsupported EdDSA curve; supported values: Ed25519")
}
case "":
return errors.New("key algorithm is required")
default:
return errors.New("unsupported key algorithm; supported values: RS256, RS384, RS512, ES256, ES384, ES512, EdDSA")
}
if !flags.Yes {
fmt.Println("WARNING: Rotating the private key will invalidate all existing tokens. Both pocket-id and all client applications will likely need to be restarted.")
ok, err := utils.PromptForConfirmation("Confirm")
if err != nil {
return err
}
if !ok {
fmt.Println("Aborted")
return nil
}
}
// Init the services we need
appConfigService := service.NewAppConfigService(ctx, db)
// Get the key provider
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, appConfigService.GetDbConfig().InstanceID.Value)
if err != nil {
return fmt.Errorf("failed to get key provider: %w", err)
}
// Generate a new key
key, err := jwkutils.GenerateKey(flags.Alg, flags.Crv)
if err != nil {
return fmt.Errorf("failed to generate key: %w", err)
}
// Save the key
err = keyProvider.SaveKey(key)
if err != nil {
return fmt.Errorf("failed to store new key: %w", err)
}
fmt.Println("Key rotated successfully")
fmt.Println("Note: if pocket-id is running, you will need to restart it for the new key to be loaded")
return nil
}

View File

@@ -0,0 +1,214 @@
package cmds
import (
"os"
"path/filepath"
"testing"
"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/service"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
testingutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
func TestKeyRotate(t *testing.T) {
tests := []struct {
name string
flags keyRotateFlags
wantErr bool
errMsg string
}{
{
name: "valid RS256",
flags: keyRotateFlags{
Alg: "RS256",
Yes: true,
},
wantErr: false,
},
{
name: "valid EdDSA with Ed25519",
flags: keyRotateFlags{
Alg: "EdDSA",
Crv: "Ed25519",
Yes: true,
},
wantErr: false,
},
{
name: "invalid algorithm",
flags: keyRotateFlags{
Alg: "INVALID",
Yes: true,
},
wantErr: true,
errMsg: "unsupported key algorithm",
},
{
name: "EdDSA without curve",
flags: keyRotateFlags{
Alg: "EdDSA",
Yes: true,
},
wantErr: true,
errMsg: "a curve name is required when algorithm is EdDSA",
},
{
name: "empty algorithm",
flags: keyRotateFlags{
Alg: "",
Yes: true,
},
wantErr: true,
errMsg: "key algorithm is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Run("file storage", func(t *testing.T) {
testKeyRotateWithFileStorage(t, tt.flags, tt.wantErr, tt.errMsg)
})
t.Run("database storage", func(t *testing.T) {
testKeyRotateWithDatabaseStorage(t, tt.flags, tt.wantErr, tt.errMsg)
})
})
}
}
func testKeyRotateWithFileStorage(t *testing.T, flags keyRotateFlags, wantErr bool, errMsg string) {
// Create temporary directory for keys
tempDir := t.TempDir()
keysPath := filepath.Join(tempDir, "keys")
err := os.MkdirAll(keysPath, 0755)
require.NoError(t, err)
// Set up file storage config
envConfig := &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: keysPath,
}
// Create test database
db := testingutils.NewDatabaseForTest(t)
// Initialize app config service and create instance
appConfigService := service.NewAppConfigService(t.Context(), db)
instanceID := appConfigService.GetDbConfig().InstanceID.Value
// Check if key exists before rotation
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, instanceID)
require.NoError(t, err)
// Run the key rotation
err = keyRotate(t.Context(), flags, db, envConfig)
if wantErr {
require.Error(t, err)
if errMsg != "" {
require.ErrorContains(t, err, errMsg)
}
return
}
require.NoError(t, err)
// Verify key was created
key, err := keyProvider.LoadKey()
require.NoError(t, err)
require.NotNil(t, key)
// Verify the algorithm matches what we requested
alg, _ := key.Algorithm()
assert.NotEmpty(t, alg)
if flags.Alg != "" {
expectedAlg := flags.Alg
if expectedAlg == "EdDSA" {
// EdDSA keys should have the EdDSA algorithm
assert.Equal(t, "EdDSA", alg.String())
} else {
assert.Equal(t, expectedAlg, alg.String())
}
}
}
func testKeyRotateWithDatabaseStorage(t *testing.T, flags keyRotateFlags, wantErr bool, errMsg string) {
// Set up database storage config
envConfig := &common.EnvConfigSchema{
KeysStorage: "database",
EncryptionKey: "test-encryption-key-characters-long",
}
// Create test database
db := testingutils.NewDatabaseForTest(t)
// Initialize app config service and create instance
appConfigService := service.NewAppConfigService(t.Context(), db)
instanceID := appConfigService.GetDbConfig().InstanceID.Value
// Get key provider
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, instanceID)
require.NoError(t, err)
// Run the key rotation
err = keyRotate(t.Context(), flags, db, envConfig)
if wantErr {
require.Error(t, err)
if errMsg != "" {
require.ErrorContains(t, err, errMsg)
}
return
}
require.NoError(t, err)
// Verify key was created
key, err := keyProvider.LoadKey()
require.NoError(t, err)
require.NotNil(t, key)
// Verify the algorithm matches what we requested
alg, _ := key.Algorithm()
assert.NotEmpty(t, alg)
if flags.Alg != "" {
expectedAlg := flags.Alg
if expectedAlg == "EdDSA" {
// EdDSA keys should have the EdDSA algorithm
assert.Equal(t, "EdDSA", alg.String())
} else {
assert.Equal(t, expectedAlg, alg.String())
}
}
}
func TestKeyRotateMultipleAlgorithms(t *testing.T) {
algorithms := []struct {
alg string
crv string
}{
{"RS256", ""},
{"RS384", ""},
// Skip RSA-4096 key generation test as it can take a long time
// {"RS512", ""},
{"ES256", ""},
{"ES384", ""},
{"ES512", ""},
{"EdDSA", "Ed25519"},
}
for _, algo := range algorithms {
t.Run(algo.alg, func(t *testing.T) {
// Test with database storage for all algorithms
testKeyRotateWithDatabaseStorage(t, keyRotateFlags{
Alg: algo.alg,
Crv: algo.crv,
Yes: true,
}, false, "")
})
}
}

View File

@@ -6,77 +6,77 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/spf13/cobra"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/bootstrap" "github.com/pocket-id/pocket-id/backend/internal/bootstrap"
"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/service" "github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils/signals"
) )
// OneTimeAccessToken creates a one-time access token for the given user var oneTimeAccessTokenCmd = &cobra.Command{
// Args must contain the username or email of the user Use: "one-time-access-token [username or email]",
func OneTimeAccessToken(args []string) error { Short: "Generates a one-time access token for the given user",
// Get a context that is canceled when the application is stopping Args: cobra.ExactArgs(1),
ctx := signals.SignalContext(context.Background()) RunE: func(cmd *cobra.Command, args []string) error {
// Get the username or email of the user
userArg := args[0]
// Get the username or email of the user // Connect to the database
// Note length is 2 because the first argument is always the command (one-time-access-token) db := bootstrap.NewDatabase()
if len(args) != 2 {
return errors.New("missing username or email of user; usage: one-time-access-token <username or email>")
}
userArg := args[1]
// Connect to the database // Create the access token
db := bootstrap.NewDatabase() var oneTimeAccessToken *model.OneTimeAccessToken
err := db.Transaction(func(tx *gorm.DB) error {
// Load the user to retrieve the user ID
var user model.User
queryCtx, queryCancel := context.WithTimeout(cmd.Context(), 10*time.Second)
defer queryCancel()
txErr := tx.
WithContext(queryCtx).
Where("username = ? OR email = ?", userArg, userArg).
First(&user).
Error
switch {
case errors.Is(txErr, gorm.ErrRecordNotFound):
return errors.New("user not found")
case txErr != nil:
return fmt.Errorf("failed to query for user: %w", txErr)
case user.ID == "":
return errors.New("invalid user loaded: ID is empty")
}
// Create the access token // Create a new access token that expires in 1 hour
var oneTimeAccessToken *model.OneTimeAccessToken oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Now().Add(time.Hour))
err := db.Transaction(func(tx *gorm.DB) error { if txErr != nil {
// Load the user to retrieve the user ID return fmt.Errorf("failed to generate access token: %w", txErr)
var user model.User }
queryCtx, queryCancel := context.WithTimeout(ctx, 10*time.Second)
defer queryCancel() queryCtx, queryCancel = context.WithTimeout(cmd.Context(), 10*time.Second)
txErr := tx. defer queryCancel()
WithContext(queryCtx). txErr = tx.
Where("username = ? OR email = ?", userArg, userArg). WithContext(queryCtx).
First(&user). Create(oneTimeAccessToken).
Error Error
switch { if txErr != nil {
case errors.Is(txErr, gorm.ErrRecordNotFound): return fmt.Errorf("failed to save access token: %w", txErr)
return errors.New("user not found") }
case txErr != nil:
return fmt.Errorf("failed to query for user: %w", txErr) return nil
case user.ID == "": })
return errors.New("invalid user loaded: ID is empty") if err != nil {
return err
} }
// Create a new access token that expires in 1 hour // Print the result
oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Now().Add(time.Hour)) fmt.Printf(`A one-time access token valid for 1 hour has been created for "%s".`+"\n", userArg)
if txErr != nil { fmt.Printf("Use the following URL to sign in once: %s/lc/%s\n", common.EnvConfig.AppURL, oneTimeAccessToken.Token)
return fmt.Errorf("failed to generate access token: %w", txErr)
}
queryCtx, queryCancel = context.WithTimeout(ctx, 10*time.Second)
defer queryCancel()
txErr = tx.
WithContext(queryCtx).
Create(oneTimeAccessToken).
Error
if txErr != nil {
return fmt.Errorf("failed to save access token: %w", txErr)
}
return nil return nil
}) },
if err != nil { }
return err
} func init() {
rootCmd.AddCommand(oneTimeAccessTokenCmd)
// Print the result
fmt.Printf(`A one-time access token valid for 1 hour has been created for "%s".`+"\n", userArg)
fmt.Printf("Use the following URL to sign in once: %s/lc/%s\n", common.EnvConfig.AppURL, oneTimeAccessToken.Token)
return nil
} }

View File

@@ -0,0 +1,36 @@
package cmds
import (
"context"
"log/slog"
"os"
"github.com/spf13/cobra"
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
"github.com/pocket-id/pocket-id/backend/internal/utils/signals"
)
var rootCmd = &cobra.Command{
Use: "pocket-id",
Short: "A simple and easy-to-use OIDC provider that allows users to authenticate with their passkeys to your services.",
Long: "By default, this command starts the pocket-id server.",
Run: func(cmd *cobra.Command, args []string) {
// Start the server
err := bootstrap.Bootstrap(cmd.Context())
if err != nil {
slog.Error("Failed to run pocket-id", "error", err)
os.Exit(1)
}
},
}
func Execute() {
// Get a context that is canceled when the application is stopping
ctx := signals.SignalContext(context.Background())
err := rootCmd.ExecuteContext(ctx)
if err != nil {
os.Exit(1)
}
}

View File

@@ -0,0 +1,19 @@
package cmds
import (
"fmt"
"github.com/spf13/cobra"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
func init() {
rootCmd.AddCommand(&cobra.Command{
Use: "version",
Short: "Print the version number",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("pocket-id " + common.Version)
},
})
}

View File

@@ -1,6 +1,8 @@
package common package common
import ( import (
"errors"
"fmt"
"log" "log"
"net/url" "net/url"
@@ -18,9 +20,10 @@ const (
) )
const ( const (
DbProviderSqlite DbProvider = "sqlite" DbProviderSqlite DbProvider = "sqlite"
DbProviderPostgres DbProvider = "postgres" DbProviderPostgres DbProvider = "postgres"
MaxMindGeoLiteCityUrl string = "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz" MaxMindGeoLiteCityUrl string = "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz"
defaultSqliteConnString string = "file:data/pocket-id.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(2500)&_txlock=immediate"
) )
type EnvConfigSchema struct { type EnvConfigSchema struct {
@@ -30,6 +33,9 @@ type EnvConfigSchema struct {
DbConnectionString string `env:"DB_CONNECTION_STRING"` DbConnectionString string `env:"DB_CONNECTION_STRING"`
UploadPath string `env:"UPLOAD_PATH"` UploadPath string `env:"UPLOAD_PATH"`
KeysPath string `env:"KEYS_PATH"` KeysPath string `env:"KEYS_PATH"`
KeysStorage string `env:"KEYS_STORAGE"`
EncryptionKey string `env:"ENCRYPTION_KEY"`
EncryptionKeyFile string `env:"ENCRYPTION_KEY_FILE"`
Port string `env:"PORT"` Port string `env:"PORT"`
Host string `env:"HOST"` Host string `env:"HOST"`
UnixSocket string `env:"UNIX_SOCKET"` UnixSocket string `env:"UNIX_SOCKET"`
@@ -45,52 +51,83 @@ type EnvConfigSchema struct {
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"` AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
} }
var EnvConfig = &EnvConfigSchema{ var EnvConfig = defaultConfig()
AppEnv: "production",
DbProvider: "sqlite",
DbConnectionString: "file:data/pocket-id.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(2500)&_txlock=immediate",
UploadPath: "data/uploads",
KeysPath: "data/keys",
AppURL: "http://localhost:1411",
Port: "1411",
Host: "0.0.0.0",
UnixSocket: "",
UnixSocketMode: "",
MaxMindLicenseKey: "",
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
LocalIPv6Ranges: "",
UiConfigDisabled: false,
MetricsEnabled: false,
TracingEnabled: false,
TrustProxy: false,
AnalyticsDisabled: false,
}
func init() { func init() {
if err := env.ParseWithOptions(EnvConfig, env.Options{}); err != nil { err := parseEnvConfig()
log.Fatal(err) if err != nil {
log.Fatalf("Configuration error: %v", err)
}
}
func defaultConfig() EnvConfigSchema {
return EnvConfigSchema{
AppEnv: "production",
DbProvider: "sqlite",
DbConnectionString: "",
UploadPath: "data/uploads",
KeysPath: "data/keys",
KeysStorage: "", // "database" or "file"
EncryptionKey: "",
AppURL: "http://localhost:1411",
Port: "1411",
Host: "0.0.0.0",
UnixSocket: "",
UnixSocketMode: "",
MaxMindLicenseKey: "",
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
LocalIPv6Ranges: "",
UiConfigDisabled: false,
MetricsEnabled: false,
TracingEnabled: false,
TrustProxy: false,
AnalyticsDisabled: false,
}
}
func parseEnvConfig() error {
err := env.ParseWithOptions(&EnvConfig, env.Options{})
if err != nil {
return fmt.Errorf("error parsing env config: %w", err)
} }
// Validate the environment variables // Validate the environment variables
switch EnvConfig.DbProvider { switch EnvConfig.DbProvider {
case DbProviderSqlite: case DbProviderSqlite:
if EnvConfig.DbConnectionString == "" { if EnvConfig.DbConnectionString == "" {
log.Fatal("Missing required env var 'DB_CONNECTION_STRING' for SQLite database") EnvConfig.DbConnectionString = defaultSqliteConnString
} }
case DbProviderPostgres: case DbProviderPostgres:
if EnvConfig.DbConnectionString == "" { if EnvConfig.DbConnectionString == "" {
log.Fatal("Missing required env var 'DB_CONNECTION_STRING' for Postgres database") return errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database")
} }
default: default:
log.Fatal("Invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'") return errors.New("invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")
} }
parsedAppUrl, err := url.Parse(EnvConfig.AppURL) parsedAppUrl, err := url.Parse(EnvConfig.AppURL)
if err != nil { if err != nil {
log.Fatal("APP_URL is not a valid URL") return errors.New("APP_URL is not a valid URL")
} }
if parsedAppUrl.Path != "" { if parsedAppUrl.Path != "" {
log.Fatal("APP_URL must not contain a path") return errors.New("APP_URL must not contain a path")
} }
switch EnvConfig.KeysStorage {
// KeysStorage defaults to "file" if empty
case "":
EnvConfig.KeysStorage = "file"
case "database":
// If KeysStorage is "database", a key must be specified
if EnvConfig.EncryptionKey == "" && EnvConfig.EncryptionKeyFile == "" {
return errors.New("ENCRYPTION_KEY or ENCRYPTION_KEY_FILE must be non-empty when KEYS_STORAGE is database")
}
case "file":
// All good, these are valid values
default:
return fmt.Errorf("invalid value for KEYS_STORAGE: %s", EnvConfig.KeysStorage)
}
return nil
} }

View File

@@ -0,0 +1,188 @@
package common
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseEnvConfig(t *testing.T) {
// Store original config to restore later
originalConfig := EnvConfig
t.Cleanup(func() {
EnvConfig = originalConfig
})
t.Run("should parse valid SQLite config correctly", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
err := parseEnvConfig()
require.NoError(t, err)
assert.Equal(t, DbProviderSqlite, EnvConfig.DbProvider)
})
t.Run("should parse valid Postgres config correctly", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "postgres")
t.Setenv("DB_CONNECTION_STRING", "postgres://user:pass@localhost/db")
t.Setenv("APP_URL", "https://example.com")
err := parseEnvConfig()
require.NoError(t, err)
assert.Equal(t, DbProviderPostgres, EnvConfig.DbProvider)
})
t.Run("should fail with invalid DB_PROVIDER", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "invalid")
t.Setenv("DB_CONNECTION_STRING", "test")
t.Setenv("APP_URL", "http://localhost:3000")
err := parseEnvConfig()
require.Error(t, err)
assert.ErrorContains(t, err, "invalid DB_PROVIDER value")
})
t.Run("should set default SQLite connection string when DB_CONNECTION_STRING is empty", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "") // Explicitly empty
t.Setenv("APP_URL", "http://localhost:3000")
err := parseEnvConfig()
require.NoError(t, err)
assert.Equal(t, defaultSqliteConnString, EnvConfig.DbConnectionString)
})
t.Run("should fail when Postgres DB_CONNECTION_STRING is missing", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "postgres")
t.Setenv("APP_URL", "http://localhost:3000")
err := parseEnvConfig()
require.Error(t, err)
assert.ErrorContains(t, err, "missing required env var 'DB_CONNECTION_STRING' for Postgres")
})
t.Run("should fail with invalid APP_URL", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "€://not-a-valid-url")
err := parseEnvConfig()
require.Error(t, err)
assert.ErrorContains(t, err, "APP_URL is not a valid URL")
})
t.Run("should fail when APP_URL contains path", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000/path")
err := parseEnvConfig()
require.Error(t, err)
assert.ErrorContains(t, err, "APP_URL must not contain a path")
})
t.Run("should default KEYS_STORAGE to 'file' when empty", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
err := parseEnvConfig()
require.NoError(t, err)
assert.Equal(t, "file", EnvConfig.KeysStorage)
})
t.Run("should fail when KEYS_STORAGE is 'database' but no encryption key", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("KEYS_STORAGE", "database")
err := parseEnvConfig()
require.Error(t, err)
assert.ErrorContains(t, err, "ENCRYPTION_KEY or ENCRYPTION_KEY_FILE must be non-empty")
})
t.Run("should accept valid KEYS_STORAGE values", func(t *testing.T) {
validStorageTypes := []string{"file", "database"}
for _, storage := range validStorageTypes {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("KEYS_STORAGE", storage)
if storage == "database" {
t.Setenv("ENCRYPTION_KEY", "test-key")
}
err := parseEnvConfig()
require.NoError(t, err)
assert.Equal(t, storage, EnvConfig.KeysStorage)
}
})
t.Run("should fail with invalid KEYS_STORAGE value", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("KEYS_STORAGE", "invalid")
err := parseEnvConfig()
require.Error(t, err)
assert.ErrorContains(t, err, "invalid value for KEYS_STORAGE")
})
t.Run("should parse boolean environment variables correctly", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("UI_CONFIG_DISABLED", "true")
t.Setenv("METRICS_ENABLED", "true")
t.Setenv("TRACING_ENABLED", "false")
t.Setenv("TRUST_PROXY", "true")
t.Setenv("ANALYTICS_DISABLED", "false")
err := parseEnvConfig()
require.NoError(t, err)
assert.True(t, EnvConfig.UiConfigDisabled)
assert.True(t, EnvConfig.MetricsEnabled)
assert.False(t, EnvConfig.TracingEnabled)
assert.True(t, EnvConfig.TrustProxy)
assert.False(t, EnvConfig.AnalyticsDisabled)
})
t.Run("should parse string environment variables correctly", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "postgres")
t.Setenv("DB_CONNECTION_STRING", "postgres://test")
t.Setenv("APP_URL", "https://prod.example.com")
t.Setenv("APP_ENV", "staging")
t.Setenv("UPLOAD_PATH", "/custom/uploads")
t.Setenv("KEYS_PATH", "/custom/keys")
t.Setenv("PORT", "8080")
t.Setenv("HOST", "127.0.0.1")
t.Setenv("UNIX_SOCKET", "/tmp/app.sock")
t.Setenv("MAXMIND_LICENSE_KEY", "test-license")
t.Setenv("GEOLITE_DB_PATH", "/custom/geolite.mmdb")
err := parseEnvConfig()
require.NoError(t, err)
assert.Equal(t, "staging", EnvConfig.AppEnv)
assert.Equal(t, "/custom/uploads", EnvConfig.UploadPath)
assert.Equal(t, "8080", EnvConfig.Port)
assert.Equal(t, "127.0.0.1", EnvConfig.Host)
})
}

View File

@@ -89,6 +89,7 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
response := dto.AuthorizeOidcClientResponseDto{ response := dto.AuthorizeOidcClientResponseDto{
Code: code, Code: code,
CallbackURL: callbackURL, CallbackURL: callbackURL,
Issuer: common.EnvConfig.AppURL,
} }
c.JSON(http.StatusOK, response) c.JSON(http.StatusOK, response)

View File

@@ -69,20 +69,21 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
return nil, fmt.Errorf("failed to get key algorithm: %w", err) return nil, fmt.Errorf("failed to get key algorithm: %w", err)
} }
config := map[string]any{ config := map[string]any{
"issuer": appUrl, "issuer": appUrl,
"authorization_endpoint": appUrl + "/authorize", "authorization_endpoint": appUrl + "/authorize",
"token_endpoint": appUrl + "/api/oidc/token", "token_endpoint": appUrl + "/api/oidc/token",
"userinfo_endpoint": appUrl + "/api/oidc/userinfo", "userinfo_endpoint": appUrl + "/api/oidc/userinfo",
"end_session_endpoint": appUrl + "/api/oidc/end-session", "end_session_endpoint": appUrl + "/api/oidc/end-session",
"introspection_endpoint": appUrl + "/api/oidc/introspect", "introspection_endpoint": appUrl + "/api/oidc/introspect",
"device_authorization_endpoint": appUrl + "/api/oidc/device/authorize", "device_authorization_endpoint": appUrl + "/api/oidc/device/authorize",
"jwks_uri": appUrl + "/.well-known/jwks.json", "jwks_uri": appUrl + "/.well-known/jwks.json",
"grant_types_supported": []string{service.GrantTypeAuthorizationCode, service.GrantTypeRefreshToken, service.GrantTypeDeviceCode}, "grant_types_supported": []string{service.GrantTypeAuthorizationCode, service.GrantTypeRefreshToken, service.GrantTypeDeviceCode},
"scopes_supported": []string{"openid", "profile", "email", "groups"}, "scopes_supported": []string{"openid", "profile", "email", "groups"},
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"}, "claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
"response_types_supported": []string{"code", "id_token"}, "response_types_supported": []string{"code", "id_token"},
"subject_types_supported": []string{"public"}, "subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{alg.String()}, "id_token_signing_alg_values_supported": []string{alg.String()},
"authorization_response_iss_parameter_supported": true,
} }
return json.Marshal(config) return json.Marshal(config)
} }

View File

@@ -1,7 +1,6 @@
package dto package dto
import ( import (
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
) )
@@ -9,14 +8,14 @@ type AuditLogDto struct {
ID string `json:"id"` ID string `json:"id"`
CreatedAt datatype.DateTime `json:"createdAt"` CreatedAt datatype.DateTime `json:"createdAt"`
Event model.AuditLogEvent `json:"event"` Event string `json:"event"`
IpAddress string `json:"ipAddress"` IpAddress string `json:"ipAddress"`
Country string `json:"country"` Country string `json:"country"`
City string `json:"city"` City string `json:"city"`
Device string `json:"device"` Device string `json:"device"`
UserID string `json:"userID"` UserID string `json:"userID"`
Username string `json:"username"` Username string `json:"username"`
Data model.AuditLogData `json:"data"` Data map[string]string `json:"data"`
} }
type AuditLogFilterDto struct { type AuditLogFilterDto struct {

View File

@@ -1,162 +1,27 @@
package dto package dto
import ( import (
"errors" "fmt"
"reflect"
"time"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "github.com/jinzhu/copier"
) )
// MapStructList maps a list of source structs to a list of destination structs // MapStructList maps a list of source structs to a list of destination structs
func MapStructList[S any, D any](source []S, destination *[]D) error { func MapStructList[S any, D any](source []S, destination *[]D) (err error) {
*destination = make([]D, 0, len(source)) *destination = make([]D, len(source))
for _, item := range source { for i, item := range source {
var destItem D err = MapStruct(item, &((*destination)[i]))
if err := MapStruct(item, &destItem); err != nil { if err != nil {
return err return fmt.Errorf("failed to map field %d: %w", i, err)
} }
*destination = append(*destination, destItem)
} }
return nil return nil
} }
// MapStruct maps a source struct to a destination struct // MapStruct maps a source struct to a destination struct
func MapStruct[S any, D any](source S, destination *D) error { func MapStruct(source any, destination any) error {
// Ensure destination is a non-nil pointer return copier.CopyWithOption(destination, source, copier.Option{
destValue := reflect.ValueOf(destination) DeepCopy: true,
if destValue.Kind() != reflect.Ptr || destValue.IsNil() { })
return errors.New("destination must be a non-nil pointer to a struct")
}
// Ensure source is a struct
sourceValue := reflect.ValueOf(source)
if sourceValue.Kind() != reflect.Struct {
return errors.New("source must be a struct")
}
return mapStructInternal(sourceValue, destValue.Elem())
}
func mapStructInternal(sourceVal reflect.Value, destVal reflect.Value) error {
for i := 0; i < destVal.NumField(); i++ {
destField := destVal.Field(i)
destFieldType := destVal.Type().Field(i)
if destFieldType.Anonymous {
if err := mapStructInternal(sourceVal, destField); err != nil {
return err
}
continue
}
sourceField := sourceVal.FieldByName(destFieldType.Name)
if sourceField.IsValid() && destField.CanSet() {
if err := mapField(sourceField, destField); err != nil {
return err
}
}
}
return nil
}
//nolint:gocognit
func mapField(sourceField reflect.Value, destField reflect.Value) error {
// Handle pointer to struct in source
if sourceField.Kind() == reflect.Ptr && !sourceField.IsNil() {
switch {
case sourceField.Elem().Kind() == reflect.Struct:
switch {
case destField.Kind() == reflect.Struct:
// Map from pointer to struct -> struct
return mapStructInternal(sourceField.Elem(), destField)
case destField.Kind() == reflect.Ptr && destField.CanSet():
// Map from pointer to struct -> pointer to struct
if destField.IsNil() {
destField.Set(reflect.New(destField.Type().Elem()))
}
return mapStructInternal(sourceField.Elem(), destField.Elem())
}
case destField.Kind() == reflect.Ptr &&
destField.CanSet() &&
sourceField.Elem().Type().AssignableTo(destField.Type().Elem()):
// Handle primitive pointer types (e.g., *string to *string)
if destField.IsNil() {
destField.Set(reflect.New(destField.Type().Elem()))
}
destField.Elem().Set(sourceField.Elem())
return nil
case destField.Kind() != reflect.Ptr &&
destField.CanSet() &&
sourceField.Elem().Type().AssignableTo(destField.Type()):
// Handle *T to T conversion for primitive types
destField.Set(sourceField.Elem())
return nil
}
}
// Handle pointer to struct in destination
if destField.Kind() == reflect.Ptr && destField.CanSet() {
switch {
case sourceField.Kind() == reflect.Struct:
// Map from struct -> pointer to struct
if destField.IsNil() {
destField.Set(reflect.New(destField.Type().Elem()))
}
return mapStructInternal(sourceField, destField.Elem())
case !sourceField.IsZero() && sourceField.Type().AssignableTo(destField.Type().Elem()):
// Handle T to *T conversion for primitive types
if destField.IsNil() {
destField.Set(reflect.New(destField.Type().Elem()))
}
destField.Elem().Set(sourceField)
return nil
}
}
switch {
case sourceField.Type() == destField.Type():
destField.Set(sourceField)
case sourceField.Kind() == reflect.Slice && destField.Kind() == reflect.Slice:
return mapSlice(sourceField, destField)
case sourceField.Kind() == reflect.Struct && destField.Kind() == reflect.Struct:
return mapStructInternal(sourceField, destField)
default:
return mapSpecialTypes(sourceField, destField)
}
return nil
}
func mapSlice(sourceField reflect.Value, destField reflect.Value) error {
if sourceField.Type().Elem() == destField.Type().Elem() {
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
for j := 0; j < sourceField.Len(); j++ {
newSlice.Index(j).Set(sourceField.Index(j))
}
destField.Set(newSlice)
} else if sourceField.Type().Elem().Kind() == reflect.Struct && destField.Type().Elem().Kind() == reflect.Struct {
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
for j := 0; j < sourceField.Len(); j++ {
sourceElem := sourceField.Index(j)
destElem := reflect.New(destField.Type().Elem()).Elem()
if err := mapStructInternal(sourceElem, destElem); err != nil {
return err
}
newSlice.Index(j).Set(destElem)
}
destField.Set(newSlice)
}
return nil
}
func mapSpecialTypes(sourceField reflect.Value, destField reflect.Value) error {
if _, ok := sourceField.Interface().(datatype.DateTime); ok {
if sourceField.Type() == reflect.TypeOf(datatype.DateTime{}) && destField.Type() == reflect.TypeOf(time.Time{}) {
dateValue := sourceField.Interface().(datatype.DateTime)
destField.Set(reflect.ValueOf(dateValue.ToTime()))
}
}
return nil
} }

View File

@@ -0,0 +1,197 @@
package dto
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type sourceStruct struct {
AString string
AStringPtr *string
ABool bool
ABoolPtr *bool
ACustomDateTime datatype.DateTime
ACustomDateTimePtr *datatype.DateTime
ANilStringPtr *string
ASlice []string
AMap map[string]int
AStruct embeddedStruct
AStructPtr *embeddedStruct
StringPtrToString *string
EmptyStringPtrToString *string
NilStringPtrToString *string
IntToInt64 int
AuditLogEventToString model.AuditLogEvent
}
type destStruct struct {
AString string
AStringPtr *string
ABool bool
ABoolPtr *bool
ACustomDateTime datatype.DateTime
ACustomDateTimePtr *datatype.DateTime
ANilStringPtr *string
ASlice []string
AMap map[string]int
AStruct embeddedStruct
AStructPtr *embeddedStruct
StringPtrToString string
EmptyStringPtrToString string
NilStringPtrToString string
IntToInt64 int64
AuditLogEventToString string
}
type embeddedStruct struct {
Foo string
Bar int64
}
func TestMapStruct(t *testing.T) {
src := sourceStruct{
AString: "abcd",
AStringPtr: utils.Ptr("xyz"),
ABool: true,
ABoolPtr: utils.Ptr(false),
ACustomDateTime: datatype.DateTime(time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)),
ACustomDateTimePtr: utils.Ptr(datatype.DateTime(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC))),
ANilStringPtr: nil,
ASlice: []string{"a", "b", "c"},
AMap: map[string]int{
"a": 1,
"b": 2,
},
AStruct: embeddedStruct{
Foo: "bar",
Bar: 42,
},
AStructPtr: &embeddedStruct{
Foo: "quo",
Bar: 111,
},
StringPtrToString: utils.Ptr("foobar"),
EmptyStringPtrToString: utils.Ptr(""),
NilStringPtrToString: nil,
IntToInt64: 99,
AuditLogEventToString: model.AuditLogEventAccountCreated,
}
var dst destStruct
err := MapStruct(src, &dst)
require.NoError(t, err)
assert.Equal(t, src.AString, dst.AString)
_ = assert.NotNil(t, src.AStringPtr) &&
assert.Equal(t, *src.AStringPtr, *dst.AStringPtr)
assert.Equal(t, src.ABool, dst.ABool)
_ = assert.NotNil(t, src.ABoolPtr) &&
assert.Equal(t, *src.ABoolPtr, *dst.ABoolPtr)
assert.Equal(t, src.ACustomDateTime, dst.ACustomDateTime)
_ = assert.NotNil(t, src.ACustomDateTimePtr) &&
assert.Equal(t, *src.ACustomDateTimePtr, *dst.ACustomDateTimePtr)
assert.Nil(t, dst.ANilStringPtr)
assert.Equal(t, src.ASlice, dst.ASlice)
assert.Equal(t, src.AMap, dst.AMap)
assert.Equal(t, "bar", dst.AStruct.Foo)
assert.Equal(t, int64(42), dst.AStruct.Bar)
_ = assert.NotNil(t, src.AStructPtr) &&
assert.Equal(t, "quo", dst.AStructPtr.Foo) &&
assert.Equal(t, int64(111), dst.AStructPtr.Bar)
assert.Equal(t, "foobar", dst.StringPtrToString)
assert.Empty(t, dst.EmptyStringPtrToString)
assert.Empty(t, dst.NilStringPtrToString)
assert.Equal(t, int64(99), dst.IntToInt64)
assert.Equal(t, "ACCOUNT_CREATED", dst.AuditLogEventToString)
}
func TestMapStructList(t *testing.T) {
sources := []sourceStruct{
{
AString: "first",
AStringPtr: utils.Ptr("one"),
ABool: true,
ABoolPtr: utils.Ptr(false),
ACustomDateTime: datatype.DateTime(time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)),
ACustomDateTimePtr: utils.Ptr(datatype.DateTime(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC))),
ASlice: []string{"a", "b"},
AMap: map[string]int{
"a": 1,
"b": 2,
},
AStruct: embeddedStruct{
Foo: "first_struct",
Bar: 10,
},
IntToInt64: 10,
},
{
AString: "second",
AStringPtr: utils.Ptr("two"),
ABool: false,
ABoolPtr: utils.Ptr(true),
ACustomDateTime: datatype.DateTime(time.Date(2026, 6, 7, 8, 9, 10, 0, time.UTC)),
ACustomDateTimePtr: utils.Ptr(datatype.DateTime(time.Date(2023, 6, 7, 8, 9, 10, 0, time.UTC))),
ASlice: []string{"c", "d", "e"},
AMap: map[string]int{
"c": 3,
"d": 4,
},
AStruct: embeddedStruct{
Foo: "second_struct",
Bar: 20,
},
IntToInt64: 20,
},
}
var destinations []destStruct
err := MapStructList(sources, &destinations)
require.NoError(t, err)
require.Len(t, destinations, 2)
// Verify first element
assert.Equal(t, "first", destinations[0].AString)
assert.Equal(t, "one", *destinations[0].AStringPtr)
assert.True(t, destinations[0].ABool)
assert.False(t, *destinations[0].ABoolPtr)
assert.Equal(t, datatype.DateTime(time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)), destinations[0].ACustomDateTime)
assert.Equal(t, datatype.DateTime(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)), *destinations[0].ACustomDateTimePtr)
assert.Equal(t, []string{"a", "b"}, destinations[0].ASlice)
assert.Equal(t, map[string]int{"a": 1, "b": 2}, destinations[0].AMap)
assert.Equal(t, "first_struct", destinations[0].AStruct.Foo)
assert.Equal(t, int64(10), destinations[0].AStruct.Bar)
assert.Equal(t, int64(10), destinations[0].IntToInt64)
// Verify second element
assert.Equal(t, "second", destinations[1].AString)
assert.Equal(t, "two", *destinations[1].AStringPtr)
assert.False(t, destinations[1].ABool)
assert.True(t, *destinations[1].ABoolPtr)
assert.Equal(t, datatype.DateTime(time.Date(2026, 6, 7, 8, 9, 10, 0, time.UTC)), destinations[1].ACustomDateTime)
assert.Equal(t, datatype.DateTime(time.Date(2023, 6, 7, 8, 9, 10, 0, time.UTC)), *destinations[1].ACustomDateTimePtr)
assert.Equal(t, []string{"c", "d", "e"}, destinations[1].ASlice)
assert.Equal(t, map[string]int{"c": 3, "d": 4}, destinations[1].AMap)
assert.Equal(t, "second_struct", destinations[1].AStruct.Foo)
assert.Equal(t, int64(20), destinations[1].AStruct.Bar)
assert.Equal(t, int64(20), destinations[1].IntToInt64)
}
func TestMapStructList_EmptySource(t *testing.T) {
var sources []sourceStruct
var destinations []destStruct
err := MapStructList(sources, &destinations)
require.NoError(t, err)
assert.Empty(t, destinations)
}

View File

@@ -57,6 +57,7 @@ type AuthorizeOidcClientRequestDto struct {
type AuthorizeOidcClientResponseDto struct { type AuthorizeOidcClientResponseDto struct {
Code string `json:"code"` Code string `json:"code"`
CallbackURL string `json:"callbackURL"` CallbackURL string `json:"callbackURL"`
Issuer string `json:"issuer"`
} }
type AuthorizationRequiredDto struct { type AuthorizationRequiredDto struct {
@@ -149,7 +150,7 @@ type AuthorizedOidcClientDto struct {
} }
type OidcClientPreviewDto struct { type OidcClientPreviewDto struct {
IdToken map[string]interface{} `json:"idToken"` IdToken map[string]any `json:"idToken"`
AccessToken map[string]interface{} `json:"accessToken"` AccessToken map[string]any `json:"accessToken"`
UserInfo map[string]interface{} `json:"userInfo"` UserInfo map[string]any `json:"userInfo"`
} }

View File

@@ -8,13 +8,13 @@ import (
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
) )
// [a-zA-Z0-9] : The username must start with an alphanumeric character
// [a-zA-Z0-9_.@-]* : The rest of the username can contain alphanumeric characters, dots, underscores, hyphens, and "@" symbols
// [a-zA-Z0-9]$ : The username must end with an alphanumeric character
var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9][a-zA-Z0-9_.@-]*[a-zA-Z0-9]$")
var validateUsername validator.Func = func(fl validator.FieldLevel) bool { var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
// [a-zA-Z0-9] : The username must start with an alphanumeric character return validateUsernameRegex.MatchString(fl.Field().String())
// [a-zA-Z0-9_.@-]* : The rest of the username can contain alphanumeric characters, dots, underscores, hyphens, and "@" symbols
// [a-zA-Z0-9]$ : The username must end with an alphanumeric character
regex := "^[a-zA-Z0-9][a-zA-Z0-9_.@-]*[a-zA-Z0-9]$"
matched, _ := regexp.MatchString(regex, fl.Field().String())
return matched
} }
func init() { func init() {

View File

@@ -29,7 +29,7 @@ func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
// Skip rate limiting for localhost and test environment // Skip rate limiting for localhost and test environment
// If the client ip is localhost the request comes from the frontend // If the client ip is localhost the request comes from the frontend
if ip == "127.0.0.1" || ip == "::1" || common.EnvConfig.AppEnv == "test" { if ip == "" || ip == "127.0.0.1" || ip == "::1" || common.EnvConfig.AppEnv == "test" {
c.Next() c.Next()
return return
} }

View File

@@ -10,7 +10,7 @@ type AuditLog struct {
Base Base
Event AuditLogEvent `sortable:"true"` Event AuditLogEvent `sortable:"true"`
IpAddress string `sortable:"true"` IpAddress *string `sortable:"true"`
Country string `sortable:"true"` Country string `sortable:"true"`
City string `sortable:"true"` City string `sortable:"true"`
UserAgent string `sortable:"true"` UserAgent string `sortable:"true"`

View File

@@ -0,0 +1,11 @@
package model
type KV struct {
Key string `gorm:"primaryKey;not null"`
Value *string
}
// TableName overrides the table name used by KV to `kv`
func (KV) TableName() string {
return "kv"
}

View File

@@ -4,10 +4,12 @@ import (
"sync/atomic" "sync/atomic"
"testing" "testing"
"github.com/stretchr/testify/require"
"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/stretchr/testify/require" testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
) )
// NewTestAppConfigService is a function used by tests to create AppConfigService objects with pre-defined configuration values // NewTestAppConfigService is a function used by tests to create AppConfigService objects with pre-defined configuration values
@@ -22,7 +24,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 := newDatabaseForTest(t) db := testutils.NewDatabaseForTest(t)
service := &AppConfigService{ service := &AppConfigService{
db: db, db: db,
} }
@@ -36,7 +38,7 @@ 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 := newDatabaseForTest(t) db := testutils.NewDatabaseForTest(t)
// Populate the config table with some initial values // Populate the config table with some initial values
err := db. err := db.
@@ -66,7 +68,7 @@ func TestLoadDbConfig(t *testing.T) {
}) })
t.Run("ignores unknown config keys", func(t *testing.T) { t.Run("ignores unknown config keys", func(t *testing.T) {
db := newDatabaseForTest(t) db := testutils.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{
@@ -87,7 +89,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 := newDatabaseForTest(t) db := testutils.NewDatabaseForTest(t)
// Initial state // Initial state
err := db.Create([]model.AppConfigVariable{ err := db.Create([]model.AppConfigVariable{
@@ -129,7 +131,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 := newDatabaseForTest(t) db := testutils.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"},
@@ -165,7 +167,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 := newDatabaseForTest(t) db := testutils.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"},
@@ -189,7 +191,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 := newDatabaseForTest(t) db := testutils.NewDatabaseForTest(t)
// Create a service with default config // Create a service with default config
service := &AppConfigService{ service := &AppConfigService{
@@ -214,7 +216,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 := newDatabaseForTest(t) db := testutils.NewDatabaseForTest(t)
// Create a service with default config // Create a service with default config
service := &AppConfigService{ service := &AppConfigService{
@@ -258,7 +260,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 := newDatabaseForTest(t) db := testutils.NewDatabaseForTest(t)
// Create a service with default config // Create a service with default config
service := &AppConfigService{ service := &AppConfigService{
@@ -279,7 +281,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 := newDatabaseForTest(t) db := testutils.NewDatabaseForTest(t)
// Create a service with default config // Create a service with default config
service := &AppConfigService{ service := &AppConfigService{
@@ -295,7 +297,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 := newDatabaseForTest(t) db := testutils.NewDatabaseForTest(t)
// Create a service with default config // Create a service with default config
service := &AppConfigService{ service := &AppConfigService{
@@ -313,7 +315,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 := newDatabaseForTest(t) db := testutils.NewDatabaseForTest(t)
// Create a service with default config // Create a service with default config
service := &AppConfigService{ service := &AppConfigService{
@@ -386,7 +388,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 := newDatabaseForTest(t) db := testutils.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{
@@ -451,7 +453,7 @@ func TestUpdateAppConfig(t *testing.T) {
// Disable UI config // Disable UI config
common.EnvConfig.UiConfigDisabled = true common.EnvConfig.UiConfigDisabled = true
db := newDatabaseForTest(t) db := testutils.NewDatabaseForTest(t)
service := &AppConfigService{ service := &AppConfigService{
db: db, db: db,
} }

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"log/slog"
userAgentParser "github.com/mileusna/useragent" userAgentParser "github.com/mileusna/useragent"
"github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/dto"
@@ -25,15 +26,15 @@ func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailSe
} }
// Create creates a new audit log entry in the database // Create creates a new audit log entry in the database
func (s *AuditLogService) Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) model.AuditLog { func (s *AuditLogService) Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) (model.AuditLog, bool) {
country, city, err := s.geoliteService.GetLocationByIP(ipAddress) country, city, err := s.geoliteService.GetLocationByIP(ipAddress)
if err != nil { if err != nil {
log.Printf("Failed to get IP location: %v", err) // Log the error but don't interrupt the operation
slog.Warn("Failed to get IP location", "error", err)
} }
auditLog := model.AuditLog{ auditLog := model.AuditLog{
Event: event, Event: event,
IpAddress: ipAddress,
Country: country, Country: country,
City: city, City: city,
UserAgent: userAgent, UserAgent: userAgent,
@@ -41,33 +42,47 @@ func (s *AuditLogService) Create(ctx context.Context, event model.AuditLogEvent,
Data: data, Data: data,
} }
if ipAddress != "" {
// Only set ipAddress if not empty, because on Postgres we use INET columns that don't allow non-null empty values
auditLog.IpAddress = &ipAddress
}
// Save the audit log in the database // Save the audit log in the database
err = tx. err = tx.
WithContext(ctx). WithContext(ctx).
Create(&auditLog). Create(&auditLog).
Error Error
if err != nil { if err != nil {
log.Printf("Failed to create audit log: %v", err) slog.Error("Failed to create audit log", "error", err)
return model.AuditLog{} return model.AuditLog{}, false
} }
return auditLog return auditLog, true
} }
// CreateNewSignInWithEmail creates a new audit log entry in the database and sends an email if the device hasn't been used before // CreateNewSignInWithEmail creates a new audit log entry in the database and sends an email if the device hasn't been used before
func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB) model.AuditLog { func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB) model.AuditLog {
createdAuditLog := s.Create(ctx, model.AuditLogEventSignIn, ipAddress, userAgent, userID, model.AuditLogData{}, tx) createdAuditLog, ok := s.Create(ctx, model.AuditLogEventSignIn, ipAddress, userAgent, userID, model.AuditLogData{}, tx)
if !ok {
// At this point the transaction has been canceled already, and error has been logged
return createdAuditLog
}
// Count the number of times the user has logged in from the same device // Count the number of times the user has logged in from the same device
var count int64 var count int64
err := tx. stmt := tx.
WithContext(ctx). WithContext(ctx).
Model(&model.AuditLog{}). Model(&model.AuditLog{}).
Where("user_id = ? AND ip_address = ? AND user_agent = ?", userID, ipAddress, userAgent). Where("user_id = ? AND user_agent = ?", userID, ipAddress)
Count(&count). if ipAddress == "" {
Error // An empty IP address is stored as NULL in the database
stmt = stmt.Where("ip_address IS NULL")
} else {
stmt = stmt.Where("ip_address = ?", ipAddress)
}
err := stmt.Count(&count).Error
if err != nil { if err != nil {
log.Printf("Failed to count audit logs: %v\n", err) log.Printf("Failed to count audit logs: %v", err)
return createdAuditLog return createdAuditLog
} }

View File

@@ -17,6 +17,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/jwa"
"github.com/lestrrat-go/jwx/v3/jwk" "github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt" "github.com/lestrrat-go/jwx/v3/jwt"
"gorm.io/gorm" "gorm.io/gorm"
@@ -25,6 +26,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/utils"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
"github.com/pocket-id/pocket-id/backend/resources" "github.com/pocket-id/pocket-id/backend/resources"
) )
@@ -60,7 +62,7 @@ func (s *TestService) initExternalIdP() error {
return fmt.Errorf("failed to generate private key: %w", err) return fmt.Errorf("failed to generate private key: %w", err)
} }
s.externalIdPKey, err = utils.ImportRawKey(rawKey) s.externalIdPKey, err = jwkutils.ImportRawKey(rawKey, jwa.ES256().String(), "")
if err != nil { if err != nil {
return fmt.Errorf("failed to import private key: %w", err) return fmt.Errorf("failed to import private key: %w", err)
} }

View File

@@ -122,6 +122,10 @@ func (s *GeoLiteService) DisableUpdater() bool {
// GetLocationByIP returns the country and city of the given IP address. // GetLocationByIP returns the country and city of the given IP address.
func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string, err error) { func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string, err error) {
if ipAddress == "" {
return "", "", nil
}
// 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 // Check IPv6 local ranges first
@@ -147,6 +151,11 @@ func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string
} }
} }
addr, err := netip.ParseAddr(ipAddress)
if err != nil {
return "", "", fmt.Errorf("failed to parse IP address: %w", err)
}
// Race condition between reading and writing the database. // Race condition between reading and writing the database.
s.mutex.RLock() s.mutex.RLock()
defer s.mutex.RUnlock() defer s.mutex.RUnlock()
@@ -157,11 +166,6 @@ func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string
} }
defer db.Close() defer db.Close()
addr, err := netip.ParseAddr(ipAddress)
if err != nil {
return "", "", fmt.Errorf("failed to parse IP address: %w", err)
}
var record struct { var record struct {
City struct { City struct {
Names map[string]string `maxminddb:"names"` Names map[string]string `maxminddb:"names"`

View File

@@ -6,6 +6,8 @@ import (
"testing" "testing"
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestGeoLiteService_IPv6LocalRanges(t *testing.T) { func TestGeoLiteService_IPv6LocalRanges(t *testing.T) {
@@ -80,15 +82,9 @@ func TestGeoLiteService_IPv6LocalRanges(t *testing.T) {
t.Errorf("Expected error or internal network classification for external IP") t.Errorf("Expected error or internal network classification for external IP")
} }
} else { } else {
if err != nil { require.NoError(t, err)
t.Errorf("Expected no error for local IP, got: %v", err) assert.Equal(t, tt.expectedCountry, country)
} assert.Equal(t, tt.expectedCity, city)
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)
}
} }
}) })
} }
@@ -148,9 +144,7 @@ func TestGeoLiteService_isLocalIPv6(t *testing.T) {
} }
result := service.isLocalIPv6(ip) result := service.isLocalIPv6(ip)
if result != tt.expected { assert.Equal(t, tt.expected, result)
t.Errorf("Expected %v, got %v for IP %s", tt.expected, result, tt.testIP)
}
}) })
} }
} }
@@ -214,18 +208,13 @@ func TestGeoLiteService_initializeIPv6LocalRanges(t *testing.T) {
err := service.initializeIPv6LocalRanges() err := service.initializeIPv6LocalRanges()
if tt.expectError && err == nil { if tt.expectError {
t.Errorf("Expected error but got none") require.Error(t, err)
} } else {
if !tt.expectError && err != nil { require.NoError(t, err)
t.Errorf("Expected no error but got: %v", err)
} }
rangeCount := len(service.localIPv6Ranges) assert.Len(t, service.localIPv6Ranges, tt.expectCount)
if rangeCount != tt.expectCount {
t.Errorf("Expected %d ranges, got %d", tt.expectCount, rangeCount)
}
}) })
} }
} }

View File

@@ -2,23 +2,20 @@ package service
import ( import (
"context" "context"
"crypto/rand"
"crypto/rsa"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log" "log"
"os"
"path/filepath"
"time" "time"
"github.com/lestrrat-go/jwx/v3/jwa" "github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk" "github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt" "github.com/lestrrat-go/jwx/v3/jwt"
"gorm.io/gorm"
"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" jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
) )
const ( const (
@@ -26,8 +23,9 @@ const (
// This is a JSON file containing a key encoded as JWK // This is a JSON file containing a key encoded as JWK
PrivateKeyFile = "jwt_private_key.json" PrivateKeyFile = "jwt_private_key.json"
// RsaKeySize is the size, in bits, of the RSA key to generate if none is found // PrivateKeyFileEncrypted is the path in the data/keys folder where the encrypted key is stored
RsaKeySize = 2048 // This is a encrypted JSON file containing a key encoded as JWK
PrivateKeyFileEncrypted = "jwt_private_key.json.enc"
// KeyUsageSigning is the usage for the private keys, for the "use" property // KeyUsageSigning is the usage for the private keys, for the "use" property
KeyUsageSigning = "sig" KeyUsageSigning = "sig"
@@ -59,58 +57,74 @@ const (
) )
type JwtService struct { type JwtService struct {
envConfig *common.EnvConfigSchema
privateKey jwk.Key privateKey jwk.Key
keyId string keyId string
appConfigService *AppConfigService appConfigService *AppConfigService
jwksEncoded []byte jwksEncoded []byte
} }
func NewJwtService(appConfigService *AppConfigService) *JwtService { func NewJwtService(db *gorm.DB, appConfigService *AppConfigService) *JwtService {
service := &JwtService{} service := &JwtService{}
// Ensure keys are generated or loaded // Ensure keys are generated or loaded
if err := service.init(appConfigService, common.EnvConfig.KeysPath); err != nil { err := service.init(db, appConfigService, &common.EnvConfig)
if err != nil {
log.Fatalf("Failed to initialize jwt service: %v", err) log.Fatalf("Failed to initialize jwt service: %v", err)
} }
return service return service
} }
func (s *JwtService) init(appConfigService *AppConfigService, keysPath string) error { func (s *JwtService) init(db *gorm.DB, appConfigService *AppConfigService, envConfig *common.EnvConfigSchema) (err error) {
s.appConfigService = appConfigService s.appConfigService = appConfigService
s.envConfig = envConfig
// Ensure keys are generated or loaded // Ensure keys are generated or loaded
return s.loadOrGenerateKey(keysPath) return s.loadOrGenerateKey(db)
} }
// loadOrGenerateKey loads the private key from the given path or generates it if not existing. func (s *JwtService) loadOrGenerateKey(db *gorm.DB) error {
func (s *JwtService) loadOrGenerateKey(keysPath string) error { // Get the key provider
var key jwk.Key keyProvider, err := jwkutils.GetKeyProvider(db, s.envConfig, s.appConfigService.GetDbConfig().InstanceID.Value)
// First, check if we have a JWK file
// If we do, then we just load that
jwkPath := filepath.Join(keysPath, PrivateKeyFile)
ok, err := utils.FileExists(jwkPath)
if err != nil { if err != nil {
return fmt.Errorf("failed to check if private key file (JWK) exists at path '%s': %w", jwkPath, err) return fmt.Errorf("failed to get key provider: %w", err)
} }
if ok {
key, err = s.loadKeyJWK(jwkPath)
if err != nil {
return fmt.Errorf("failed to load private key file (JWK) at path '%s': %w", jwkPath, err)
}
// Set the key, and we are done // Try loading a key
key, err := keyProvider.LoadKey()
if err != nil {
return fmt.Errorf("failed to load key (provider type '%s'): %w", s.envConfig.KeysStorage, err)
}
// If we have a key, store it in the object and we're done
if key != nil {
err = s.SetKey(key) err = s.SetKey(key)
if err != nil { if err != nil {
return fmt.Errorf("failed to set private key: %w", err) return fmt.Errorf("failed to set private key: %w", err)
} }
return nil return nil
} }
// If we are here, we need to generate a new key // If we are here, we need to generate a new key
key, err = s.generateNewRSAKey() err = s.generateKey()
if err != nil {
return fmt.Errorf("failed to generate key: %w", err)
}
// Save the newly-generated key
err = keyProvider.SaveKey(s.privateKey)
if err != nil {
return fmt.Errorf("failed to save private key (provider type '%s'): %w", s.envConfig.KeysStorage, err)
}
return nil
}
// generateKey generates a new key and stores it in the object
func (s *JwtService) generateKey() error {
// Default is to generate RS256 (RSA-2048) keys
key, err := jwkutils.GenerateKey(jwa.RS256().String(), "")
if err != nil { if err != nil {
return fmt.Errorf("failed to generate new private key: %w", err) return fmt.Errorf("failed to generate new private key: %w", err)
} }
@@ -121,12 +135,6 @@ func (s *JwtService) loadOrGenerateKey(keysPath string) error {
return fmt.Errorf("failed to set private key: %w", err) return fmt.Errorf("failed to set private key: %w", err)
} }
// Save the key as JWK
err = SaveKeyJWK(s.privateKey, jwkPath)
if err != nil {
return fmt.Errorf("failed to save private key file at path '%s': %w", jwkPath, err)
}
return nil return nil
} }
@@ -192,13 +200,13 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
Subject(user.ID). Subject(user.ID).
Expiration(now.Add(s.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes())). Expiration(now.Add(s.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes())).
IssuedAt(now). IssuedAt(now).
Issuer(common.EnvConfig.AppURL). Issuer(s.envConfig.AppURL).
Build() Build()
if err != nil { if err != nil {
return "", fmt.Errorf("failed to build token: %w", err) return "", fmt.Errorf("failed to build token: %w", err)
} }
err = SetAudienceString(token, common.EnvConfig.AppURL) err = SetAudienceString(token, s.envConfig.AppURL)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err) return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err)
} }
@@ -229,8 +237,8 @@ func (s *JwtService) VerifyAccessToken(tokenString string) (jwt.Token, error) {
jwt.WithValidate(true), jwt.WithValidate(true),
jwt.WithKey(alg, s.privateKey), jwt.WithKey(alg, s.privateKey),
jwt.WithAcceptableSkew(clockSkew), jwt.WithAcceptableSkew(clockSkew),
jwt.WithAudience(common.EnvConfig.AppURL), jwt.WithAudience(s.envConfig.AppURL),
jwt.WithIssuer(common.EnvConfig.AppURL), jwt.WithIssuer(s.envConfig.AppURL),
jwt.WithValidator(TokenTypeValidator(AccessTokenJWTType)), jwt.WithValidator(TokenTypeValidator(AccessTokenJWTType)),
) )
if err != nil { if err != nil {
@@ -246,7 +254,7 @@ func (s *JwtService) BuildIDToken(userClaims map[string]any, clientID string, no
token, err := jwt.NewBuilder(). token, err := jwt.NewBuilder().
Expiration(now.Add(1 * time.Hour)). Expiration(now.Add(1 * time.Hour)).
IssuedAt(now). IssuedAt(now).
Issuer(common.EnvConfig.AppURL). Issuer(s.envConfig.AppURL).
Build() Build()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to build token: %w", err) return nil, fmt.Errorf("failed to build token: %w", err)
@@ -305,7 +313,7 @@ func (s *JwtService) VerifyIdToken(tokenString string, acceptExpiredTokens bool)
jwt.WithValidate(true), jwt.WithValidate(true),
jwt.WithKey(alg, s.privateKey), jwt.WithKey(alg, s.privateKey),
jwt.WithAcceptableSkew(clockSkew), jwt.WithAcceptableSkew(clockSkew),
jwt.WithIssuer(common.EnvConfig.AppURL), jwt.WithIssuer(s.envConfig.AppURL),
jwt.WithValidator(TokenTypeValidator(IDTokenJWTType)), jwt.WithValidator(TokenTypeValidator(IDTokenJWTType)),
) )
@@ -335,7 +343,7 @@ func (s *JwtService) BuildOAuthAccessToken(user model.User, clientID string) (jw
Subject(user.ID). Subject(user.ID).
Expiration(now.Add(1 * time.Hour)). Expiration(now.Add(1 * time.Hour)).
IssuedAt(now). IssuedAt(now).
Issuer(common.EnvConfig.AppURL). Issuer(s.envConfig.AppURL).
Build() Build()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to build token: %w", err) return nil, fmt.Errorf("failed to build token: %w", err)
@@ -377,7 +385,7 @@ func (s *JwtService) VerifyOAuthAccessToken(tokenString string) (jwt.Token, erro
jwt.WithValidate(true), jwt.WithValidate(true),
jwt.WithKey(alg, s.privateKey), jwt.WithKey(alg, s.privateKey),
jwt.WithAcceptableSkew(clockSkew), jwt.WithAcceptableSkew(clockSkew),
jwt.WithIssuer(common.EnvConfig.AppURL), jwt.WithIssuer(s.envConfig.AppURL),
jwt.WithValidator(TokenTypeValidator(OAuthAccessTokenJWTType)), jwt.WithValidator(TokenTypeValidator(OAuthAccessTokenJWTType)),
) )
if err != nil { if err != nil {
@@ -393,7 +401,7 @@ func (s *JwtService) GenerateOAuthRefreshToken(userID string, clientID string, r
Subject(userID). Subject(userID).
Expiration(now.Add(RefreshTokenDuration)). Expiration(now.Add(RefreshTokenDuration)).
IssuedAt(now). IssuedAt(now).
Issuer(common.EnvConfig.AppURL). Issuer(s.envConfig.AppURL).
Build() Build()
if err != nil { if err != nil {
return "", fmt.Errorf("failed to build token: %w", err) return "", fmt.Errorf("failed to build token: %w", err)
@@ -430,7 +438,7 @@ func (s *JwtService) VerifyOAuthRefreshToken(tokenString string) (userID, client
jwt.WithValidate(true), jwt.WithValidate(true),
jwt.WithKey(alg, s.privateKey), jwt.WithKey(alg, s.privateKey),
jwt.WithAcceptableSkew(clockSkew), jwt.WithAcceptableSkew(clockSkew),
jwt.WithIssuer(common.EnvConfig.AppURL), jwt.WithIssuer(s.envConfig.AppURL),
jwt.WithValidator(TokenTypeValidator(OAuthRefreshTokenJWTType)), jwt.WithValidator(TokenTypeValidator(OAuthRefreshTokenJWTType)),
) )
if err != nil { if err != nil {
@@ -488,7 +496,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)
} }
utils.EnsureAlgInKey(pubKey) jwkutils.EnsureAlgInKey(pubKey, "", "")
return pubKey, nil return pubKey, nil
} }
@@ -517,56 +525,6 @@ func (s *JwtService) GetKeyAlg() (jwa.KeyAlgorithm, error) {
return alg, nil return alg, nil
} }
func (s *JwtService) loadKeyJWK(path string) (jwk.Key, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read key data: %w", err)
}
key, err := jwk.ParseKey(data)
if err != nil {
return nil, fmt.Errorf("failed to parse key: %w", err)
}
return key, nil
}
func (s *JwtService) generateNewRSAKey() (jwk.Key, error) {
// We generate RSA keys only
rawKey, err := rsa.GenerateKey(rand.Reader, RsaKeySize)
if err != nil {
return nil, fmt.Errorf("failed to generate RSA private key: %w", err)
}
// Import the raw key
return utils.ImportRawKey(rawKey)
}
// SaveKeyJWK saves a JWK to a file
func SaveKeyJWK(key jwk.Key, path string) error {
dir := filepath.Dir(path)
err := os.MkdirAll(dir, 0700)
if err != nil {
return fmt.Errorf("failed to create directory '%s' for key file: %w", dir, err)
}
keyFile, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to create key file: %w", err)
}
defer keyFile.Close()
// Write the JSON file to disk
enc := json.NewEncoder(keyFile)
enc.SetEscapeHTML(false)
err = enc.Encode(key)
if err != nil {
return fmt.Errorf("failed to write key file: %w", err)
}
return nil
}
// 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) {

View File

@@ -21,7 +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" jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
) )
func TestJwtService_Init(t *testing.T) { func TestJwtService_Init(t *testing.T) {
@@ -33,9 +33,16 @@ func TestJwtService_Init(t *testing.T) {
// Create a temporary directory for the test // Create a temporary directory for the test
tempDir := t.TempDir() tempDir := t.TempDir()
// Setup the environment variable required by the token verification
mockEnvConfig := &common.EnvConfigSchema{
AppURL: "https://test.example.com",
KeysStorage: "file",
KeysPath: tempDir,
}
// Initialize the JWT service // Initialize the JWT service
service := &JwtService{} service := &JwtService{}
err := service.init(mockConfig, tempDir) err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service") require.NoError(t, err, "Failed to initialize JWT service")
// Verify the private key was set // Verify the private key was set
@@ -66,9 +73,16 @@ func TestJwtService_Init(t *testing.T) {
// Create a temporary directory for the test // Create a temporary directory for the test
tempDir := t.TempDir() tempDir := t.TempDir()
// Setup the environment variable required by the token verification
mockEnvConfig := &common.EnvConfigSchema{
AppURL: "https://test.example.com",
KeysStorage: "file",
KeysPath: tempDir,
}
// First create a service to generate a key // First create a service to generate a key
firstService := &JwtService{} firstService := &JwtService{}
err := firstService.init(mockConfig, tempDir) err := firstService.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err) require.NoError(t, err)
// Get the key ID of the first service // Get the key ID of the first service
@@ -77,7 +91,7 @@ func TestJwtService_Init(t *testing.T) {
// Now create a new service that should load the existing key // Now create a new service that should load the existing key
secondService := &JwtService{} secondService := &JwtService{}
err = secondService.init(mockConfig, tempDir) err = secondService.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err) require.NoError(t, err)
// Verify the loaded key has the same ID as the original // Verify the loaded key has the same ID as the original
@@ -90,12 +104,19 @@ func TestJwtService_Init(t *testing.T) {
// Create a temporary directory for the test // Create a temporary directory for the test
tempDir := t.TempDir() tempDir := t.TempDir()
// Setup the environment variable required by the token verification
mockEnvConfig := &common.EnvConfigSchema{
AppURL: "https://test.example.com",
KeysStorage: "file",
KeysPath: tempDir,
}
// Create a new JWK and save it to disk // Create a new JWK and save it to disk
origKeyID := createECDSAKeyJWK(t, tempDir) origKeyID := createECDSAKeyJWK(t, tempDir)
// Now create a new service that should load the existing key // Now create a new service that should load the existing key
svc := &JwtService{} svc := &JwtService{}
err := svc.init(mockConfig, tempDir) err := svc.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err) require.NoError(t, err)
// Ensure loaded key has the right algorithm // Ensure loaded key has the right algorithm
@@ -113,12 +134,19 @@ func TestJwtService_Init(t *testing.T) {
// Create a temporary directory for the test // Create a temporary directory for the test
tempDir := t.TempDir() tempDir := t.TempDir()
// Setup the environment variable required by the token verification
mockEnvConfig := &common.EnvConfigSchema{
AppURL: "https://test.example.com",
KeysStorage: "file",
KeysPath: tempDir,
}
// Create a new JWK and save it to disk // Create a new JWK and save it to disk
origKeyID := createEdDSAKeyJWK(t, tempDir) origKeyID := createEdDSAKeyJWK(t, tempDir)
// Now create a new service that should load the existing key // Now create a new service that should load the existing key
svc := &JwtService{} svc := &JwtService{}
err := svc.init(mockConfig, tempDir) err := svc.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err) require.NoError(t, err)
// Ensure loaded key has the right algorithm and curve // Ensure loaded key has the right algorithm and curve
@@ -147,9 +175,16 @@ func TestJwtService_GetPublicJWK(t *testing.T) {
// Create a temporary directory for the test // Create a temporary directory for the test
tempDir := t.TempDir() tempDir := t.TempDir()
// Setup the environment variable required by the token verification
mockEnvConfig := &common.EnvConfigSchema{
AppURL: "https://test.example.com",
KeysStorage: "file",
KeysPath: tempDir,
}
// Create a JWT service with initialized key // Create a JWT service with initialized key
service := &JwtService{} service := &JwtService{}
err := service.init(mockConfig, tempDir) err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service") require.NoError(t, err, "Failed to initialize JWT service")
// Get the JWK (public key) // Get the JWK (public key)
@@ -178,12 +213,19 @@ func TestJwtService_GetPublicJWK(t *testing.T) {
// Create a temporary directory for the test // Create a temporary directory for the test
tempDir := t.TempDir() tempDir := t.TempDir()
// Setup the environment variable required by the token verification
mockEnvConfig := &common.EnvConfigSchema{
AppURL: "https://test.example.com",
KeysStorage: "file",
KeysPath: tempDir,
}
// Create an ECDSA key and save it as JWK // Create an ECDSA key and save it as JWK
originalKeyID := createECDSAKeyJWK(t, tempDir) originalKeyID := createECDSAKeyJWK(t, tempDir)
// Create a JWT service that loads the ECDSA key // Create a JWT service that loads the ECDSA key
service := &JwtService{} service := &JwtService{}
err := service.init(mockConfig, tempDir) err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service") require.NoError(t, err, "Failed to initialize JWT service")
// Get the JWK (public key) // Get the JWK (public key)
@@ -216,12 +258,19 @@ func TestJwtService_GetPublicJWK(t *testing.T) {
// Create a temporary directory for the test // Create a temporary directory for the test
tempDir := t.TempDir() tempDir := t.TempDir()
// Setup the environment variable required by the token verification
mockEnvConfig := &common.EnvConfigSchema{
AppURL: "https://test.example.com",
KeysStorage: "file",
KeysPath: tempDir,
}
// Create an EdDSA key and save it as JWK // Create an EdDSA key and save it as JWK
originalKeyID := createEdDSAKeyJWK(t, tempDir) originalKeyID := createEdDSAKeyJWK(t, tempDir)
// Create a JWT service that loads the EdDSA key // Create a JWT service that loads the EdDSA key
service := &JwtService{} service := &JwtService{}
err := service.init(mockConfig, tempDir) err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service") require.NoError(t, err, "Failed to initialize JWT service")
// Get the JWK (public key) // Get the JWK (public key)
@@ -276,16 +325,16 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
}) })
// Setup the environment variable required by the token verification // Setup the environment variable required by the token verification
originalAppURL := common.EnvConfig.AppURL mockEnvConfig := &common.EnvConfigSchema{
common.EnvConfig.AppURL = "https://test.example.com" AppURL: "https://test.example.com",
defer func() { KeysStorage: "file",
common.EnvConfig.AppURL = originalAppURL KeysPath: tempDir,
}() }
t.Run("generates token for regular user", func(t *testing.T) { t.Run("generates token for regular user", func(t *testing.T) {
// Create a JWT service // Create a JWT service
service := &JwtService{} service := &JwtService{}
err := service.init(mockConfig, tempDir) err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service") require.NoError(t, err, "Failed to initialize JWT service")
// Create a test user // Create a test user
@@ -328,7 +377,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
t.Run("generates token for admin user", func(t *testing.T) { t.Run("generates token for admin user", func(t *testing.T) {
// Create a JWT service // Create a JWT service
service := &JwtService{} service := &JwtService{}
err := service.init(mockConfig, tempDir) err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service") require.NoError(t, err, "Failed to initialize JWT service")
// Create a test admin user // Create a test admin user
@@ -364,7 +413,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
}) })
service := &JwtService{} service := &JwtService{}
err := service.init(customMockConfig, tempDir) err := service.init(nil, customMockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service") require.NoError(t, err, "Failed to initialize JWT service")
// Create a test user // Create a test user
@@ -399,7 +448,10 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
// Create a JWT service that loads the key // Create a JWT service that loads the key
service := &JwtService{} service := &JwtService{}
err := service.init(mockConfig, tempDir) err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service") require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key // Verify it loaded the right key
@@ -453,7 +505,10 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
// Create a JWT service that loads the key // Create a JWT service that loads the key
service := &JwtService{} service := &JwtService{}
err := service.init(mockConfig, tempDir) err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service") require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key // Verify it loaded the right key
@@ -507,7 +562,10 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
// Create a JWT service that loads the key // Create a JWT service that loads the key
service := &JwtService{} service := &JwtService{}
err := service.init(mockConfig, tempDir) err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service") require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key // Verify it loaded the right key
@@ -563,16 +621,16 @@ func TestGenerateVerifyIdToken(t *testing.T) {
}) })
// Setup the environment variable required by the token verification // Setup the environment variable required by the token verification
originalAppURL := common.EnvConfig.AppURL mockEnvConfig := &common.EnvConfigSchema{
common.EnvConfig.AppURL = "https://test.example.com" AppURL: "https://test.example.com",
defer func() { KeysStorage: "file",
common.EnvConfig.AppURL = originalAppURL KeysPath: tempDir,
}() }
t.Run("generates and verifies ID token with standard claims", func(t *testing.T) { t.Run("generates and verifies ID token with standard claims", func(t *testing.T) {
// Create a JWT service // Create a JWT service
service := &JwtService{} service := &JwtService{}
err := service.init(mockConfig, tempDir) err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service") require.NoError(t, err, "Failed to initialize JWT service")
// Create test claims // Create test claims
@@ -601,7 +659,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID") assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID")
issuer, ok := claims.Issuer() issuer, ok := claims.Issuer()
_ = assert.True(t, ok, "Issuer not found in token") && _ = assert.True(t, ok, "Issuer not found in token") &&
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL") assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
// Check token expiration time is approximately 1 hour from now // Check token expiration time is approximately 1 hour from now
expectedExp := time.Now().Add(1 * time.Hour) expectedExp := time.Now().Add(1 * time.Hour)
@@ -614,7 +672,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
t.Run("can accept expired tokens if told so", func(t *testing.T) { t.Run("can accept expired tokens if told so", func(t *testing.T) {
// Create a JWT service // Create a JWT service
service := &JwtService{} service := &JwtService{}
err := service.init(mockConfig, tempDir) err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service") require.NoError(t, err, "Failed to initialize JWT service")
// Create test claims // Create test claims
@@ -628,7 +686,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
// Create a token that's already expired // Create a token that's already expired
token, err := jwt.NewBuilder(). token, err := jwt.NewBuilder().
Subject(userClaims["sub"].(string)). Subject(userClaims["sub"].(string)).
Issuer(common.EnvConfig.AppURL). Issuer(service.envConfig.AppURL).
Audience([]string{clientID}). Audience([]string{clientID}).
IssuedAt(time.Now().Add(-2 * time.Hour)). IssuedAt(time.Now().Add(-2 * time.Hour)).
Expiration(time.Now().Add(-1 * time.Hour)). // Expired 1 hour ago Expiration(time.Now().Add(-1 * time.Hour)). // Expired 1 hour ago
@@ -666,13 +724,13 @@ func TestGenerateVerifyIdToken(t *testing.T) {
assert.Equal(t, userClaims["sub"], subject, "Token subject should match user ID") assert.Equal(t, userClaims["sub"], subject, "Token subject should match user ID")
issuer, ok := claims.Issuer() issuer, ok := claims.Issuer()
_ = assert.True(t, ok, "Issuer not found in token") && _ = assert.True(t, ok, "Issuer not found in token") &&
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL") assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
}) })
t.Run("generates and verifies ID token with nonce", func(t *testing.T) { t.Run("generates and verifies ID token with nonce", func(t *testing.T) {
// Create a JWT service // Create a JWT service
service := &JwtService{} service := &JwtService{}
err := service.init(mockConfig, tempDir) err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service") require.NoError(t, err, "Failed to initialize JWT service")
// Create test claims with nonce // Create test claims with nonce
@@ -703,7 +761,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
t.Run("fails verification with incorrect issuer", func(t *testing.T) { t.Run("fails verification with incorrect issuer", func(t *testing.T) {
// Create a JWT service // Create a JWT service
service := &JwtService{} service := &JwtService{}
err := service.init(mockConfig, tempDir) err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service") require.NoError(t, err, "Failed to initialize JWT service")
// Generate a token with standard claims // Generate a token with standard claims
@@ -714,7 +772,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
require.NoError(t, err, "Failed to generate ID token") require.NoError(t, err, "Failed to generate ID token")
// Temporarily change the app URL to simulate wrong issuer // Temporarily change the app URL to simulate wrong issuer
common.EnvConfig.AppURL = "https://wrong-issuer.com" service.envConfig.AppURL = "https://wrong-issuer.com"
// Verify should fail due to issuer mismatch // Verify should fail due to issuer mismatch
_, err = service.VerifyIdToken(tokenString, false) _, err = service.VerifyIdToken(tokenString, false)
@@ -731,7 +789,10 @@ func TestGenerateVerifyIdToken(t *testing.T) {
// Create a JWT service that loads the key // Create a JWT service that loads the key
service := &JwtService{} service := &JwtService{}
err := service.init(mockConfig, tempDir) err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service") require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key // Verify it loaded the right key
@@ -762,7 +823,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
assert.Equal(t, "eddsauser456", subject, "Token subject should match user ID") assert.Equal(t, "eddsauser456", subject, "Token subject should match user ID")
issuer, ok := claims.Issuer() issuer, ok := claims.Issuer()
_ = assert.True(t, ok, "Issuer not found in token") && _ = assert.True(t, ok, "Issuer not found in token") &&
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL") assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
// Verify the key type is OKP // Verify the key type is OKP
publicKey, err := service.GetPublicJWK() publicKey, err := service.GetPublicJWK()
@@ -784,7 +845,10 @@ func TestGenerateVerifyIdToken(t *testing.T) {
// Create a JWT service that loads the key // Create a JWT service that loads the key
service := &JwtService{} service := &JwtService{}
err := service.init(mockConfig, tempDir) err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service") require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key // Verify it loaded the right key
@@ -795,7 +859,6 @@ func TestGenerateVerifyIdToken(t *testing.T) {
// Create test claims // Create test claims
userClaims := map[string]interface{}{ userClaims := map[string]interface{}{
"sub": "ecdsauser456", "sub": "ecdsauser456",
"name": "ECDSA User",
"email": "ecdsauser@example.com", "email": "ecdsauser@example.com",
} }
const clientID = "ecdsa-client-123" const clientID = "ecdsa-client-123"
@@ -815,7 +878,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
assert.Equal(t, "ecdsauser456", subject, "Token subject should match user ID") assert.Equal(t, "ecdsauser456", subject, "Token subject should match user ID")
issuer, ok := claims.Issuer() issuer, ok := claims.Issuer()
_ = assert.True(t, ok, "Issuer not found in token") && _ = assert.True(t, ok, "Issuer not found in token") &&
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL") assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
// Verify the key type is EC // Verify the key type is EC
publicKey, err := service.GetPublicJWK() publicKey, err := service.GetPublicJWK()
@@ -837,7 +900,10 @@ func TestGenerateVerifyIdToken(t *testing.T) {
// Create a JWT service that loads the key // Create a JWT service that loads the key
service := &JwtService{} service := &JwtService{}
err := service.init(mockConfig, tempDir) err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service") require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key // Verify it loaded the right key
@@ -868,17 +934,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
assert.Equal(t, "rsauser456", subject, "Token subject should match user ID") assert.Equal(t, "rsauser456", subject, "Token subject should match user ID")
issuer, ok := claims.Issuer() issuer, ok := claims.Issuer()
_ = assert.True(t, ok, "Issuer not found in token") && _ = assert.True(t, ok, "Issuer not found in token") &&
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL") assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
// Verify the key type is RSA
publicKey, err := service.GetPublicJWK()
require.NoError(t, err)
assert.Equal(t, jwa.RSA().String(), publicKey.KeyType().String(), "Key type should be RSA")
// Verify the algorithm is RS256
alg, ok := publicKey.Algorithm()
require.True(t, ok)
assert.Equal(t, jwa.RS256().String(), alg.String(), "Algorithm should be RS256")
}) })
} }
@@ -892,16 +948,16 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
}) })
// Setup the environment variable required by the token verification // Setup the environment variable required by the token verification
originalAppURL := common.EnvConfig.AppURL mockEnvConfig := &common.EnvConfigSchema{
common.EnvConfig.AppURL = "https://test.example.com" AppURL: "https://test.example.com",
defer func() { KeysStorage: "file",
common.EnvConfig.AppURL = originalAppURL KeysPath: tempDir,
}() }
t.Run("generates and verifies OAuth access token with standard claims", func(t *testing.T) { t.Run("generates and verifies OAuth access token with standard claims", func(t *testing.T) {
// Create a JWT service // Create a JWT service
service := &JwtService{} service := &JwtService{}
err := service.init(mockConfig, tempDir) err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service") require.NoError(t, err, "Failed to initialize JWT service")
// Create a test user // Create a test user
@@ -931,7 +987,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID") assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID")
issuer, ok := claims.Issuer() issuer, ok := claims.Issuer()
_ = assert.True(t, ok, "Issuer not found in token") && _ = assert.True(t, ok, "Issuer not found in token") &&
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL") assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
// Check token expiration time is approximately 1 hour from now // Check token expiration time is approximately 1 hour from now
expectedExp := time.Now().Add(1 * time.Hour) expectedExp := time.Now().Add(1 * time.Hour)
@@ -944,7 +1000,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
t.Run("fails verification for expired token", func(t *testing.T) { t.Run("fails verification for expired token", func(t *testing.T) {
// Create a JWT service with a mock function to generate an expired token // Create a JWT service with a mock function to generate an expired token
service := &JwtService{} service := &JwtService{}
err := service.init(mockConfig, tempDir) err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service") require.NoError(t, err, "Failed to initialize JWT service")
// Create a test user // Create a test user
@@ -961,7 +1017,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
Expiration(time.Now().Add(-1 * time.Hour)). // Expired 1 hour ago Expiration(time.Now().Add(-1 * time.Hour)). // Expired 1 hour ago
IssuedAt(time.Now().Add(-2 * time.Hour)). IssuedAt(time.Now().Add(-2 * time.Hour)).
Audience([]string{clientID}). Audience([]string{clientID}).
Issuer(common.EnvConfig.AppURL). Issuer(service.envConfig.AppURL).
Build() Build()
require.NoError(t, err, "Failed to build token") require.NoError(t, err, "Failed to build token")
@@ -980,11 +1036,17 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
t.Run("fails verification with invalid signature", func(t *testing.T) { t.Run("fails verification with invalid signature", func(t *testing.T) {
// Create two JWT services with different keys // Create two JWT services with different keys
service1 := &JwtService{} service1 := &JwtService{}
err := service1.init(mockConfig, t.TempDir()) // Use a different temp dir err := service1.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: t.TempDir(), // Use a different temp dir
})
require.NoError(t, err, "Failed to initialize first JWT service") require.NoError(t, err, "Failed to initialize first JWT service")
service2 := &JwtService{} service2 := &JwtService{}
err = service2.init(mockConfig, t.TempDir()) // Use a different temp dir err = service2.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: t.TempDir(), // Use a different temp dir
})
require.NoError(t, err, "Failed to initialize second JWT service") require.NoError(t, err, "Failed to initialize second JWT service")
// Create a test user // Create a test user
@@ -1014,7 +1076,10 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
// Create a JWT service that loads the key // Create a JWT service that loads the key
service := &JwtService{} service := &JwtService{}
err := service.init(mockConfig, tempDir) err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service") require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key // Verify it loaded the right key
@@ -1068,7 +1133,10 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
// Create a JWT service that loads the key // Create a JWT service that loads the key
service := &JwtService{} service := &JwtService{}
err := service.init(mockConfig, tempDir) err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service") require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key // Verify it loaded the right key
@@ -1122,7 +1190,10 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
// Create a JWT service that loads the key // Create a JWT service that loads the key
service := &JwtService{} service := &JwtService{}
err := service.init(mockConfig, tempDir) err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service") require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key // Verify it loaded the right key
@@ -1176,16 +1247,16 @@ func TestGenerateVerifyOAuthRefreshToken(t *testing.T) {
mockConfig := NewTestAppConfigService(&model.AppConfig{}) mockConfig := NewTestAppConfigService(&model.AppConfig{})
// Setup the environment variable required by the token verification // Setup the environment variable required by the token verification
originalAppURL := common.EnvConfig.AppURL mockEnvConfig := &common.EnvConfigSchema{
common.EnvConfig.AppURL = "https://test.example.com" AppURL: "https://test.example.com",
defer func() { KeysStorage: "file",
common.EnvConfig.AppURL = originalAppURL KeysPath: tempDir,
}() }
t.Run("generates and verifies refresh token", func(t *testing.T) { t.Run("generates and verifies refresh token", func(t *testing.T) {
// Create a JWT service // Create a JWT service
service := &JwtService{} service := &JwtService{}
err := service.init(mockConfig, tempDir) err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service") require.NoError(t, err, "Failed to initialize JWT service")
// Create a test user // Create a test user
@@ -1211,7 +1282,7 @@ func TestGenerateVerifyOAuthRefreshToken(t *testing.T) {
t.Run("fails verification for expired token", func(t *testing.T) { t.Run("fails verification for expired token", func(t *testing.T) {
// Create a JWT service // Create a JWT service
service := &JwtService{} service := &JwtService{}
err := service.init(mockConfig, tempDir) err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service") require.NoError(t, err, "Failed to initialize JWT service")
// Generate a token using JWT directly to create an expired token // Generate a token using JWT directly to create an expired token
@@ -1220,7 +1291,7 @@ func TestGenerateVerifyOAuthRefreshToken(t *testing.T) {
Expiration(time.Now().Add(-1 * time.Hour)). // Expired 1 hour ago Expiration(time.Now().Add(-1 * time.Hour)). // Expired 1 hour ago
IssuedAt(time.Now().Add(-2 * time.Hour)). IssuedAt(time.Now().Add(-2 * time.Hour)).
Audience([]string{"client123"}). Audience([]string{"client123"}).
Issuer(common.EnvConfig.AppURL). Issuer(service.envConfig.AppURL).
Build() Build()
require.NoError(t, err, "Failed to build token") require.NoError(t, err, "Failed to build token")
@@ -1236,11 +1307,17 @@ func TestGenerateVerifyOAuthRefreshToken(t *testing.T) {
t.Run("fails verification with invalid signature", func(t *testing.T) { t.Run("fails verification with invalid signature", func(t *testing.T) {
// Create two JWT services with different keys // Create two JWT services with different keys
service1 := &JwtService{} service1 := &JwtService{}
err := service1.init(mockConfig, t.TempDir()) err := service1.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: t.TempDir(), // Use a different temp dir
})
require.NoError(t, err, "Failed to initialize first JWT service") require.NoError(t, err, "Failed to initialize first JWT service")
service2 := &JwtService{} service2 := &JwtService{}
err = service2.init(mockConfig, t.TempDir()) err = service2.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: t.TempDir(), // Use a different temp dir
})
require.NoError(t, err, "Failed to initialize second JWT service") require.NoError(t, err, "Failed to initialize second JWT service")
// Generate a token with the first service // Generate a token with the first service
@@ -1308,7 +1385,10 @@ func TestGetTokenType(t *testing.T) {
// Initialize the JWT service // Initialize the JWT service
mockConfig := NewTestAppConfigService(&model.AppConfig{}) mockConfig := NewTestAppConfigService(&model.AppConfig{})
service := &JwtService{} service := &JwtService{}
err := service.init(mockConfig, tempDir) err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service") require.NoError(t, err, "Failed to initialize JWT service")
buildTokenForType := func(t *testing.T, typ string, setClaimsFn func(b *jwt.Builder)) string { buildTokenForType := func(t *testing.T, typ string, setClaimsFn func(b *jwt.Builder)) string {
@@ -1402,10 +1482,19 @@ func TestGetTokenType(t *testing.T) {
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 := utils.ImportRawKey(privateKeyRaw) privateKey, err := jwkutils.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)) keyProvider := &jwkutils.KeyProviderFile{}
err = keyProvider.Init(jwkutils.KeyProviderOpts{
EnvConfig: &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: path,
},
})
require.NoError(t, err, "Failed to init file key provider")
err = keyProvider.SaveKey(privateKey)
require.NoError(t, err, "Failed to save key") require.NoError(t, err, "Failed to save key")
kid, _ := privateKey.KeyID() kid, _ := privateKey.KeyID()

View File

@@ -13,6 +13,9 @@ import (
"net/url" "net/url"
"strings" "strings"
"time" "time"
"unicode/utf8"
"github.com/google/uuid"
"github.com/go-ldap/ldap/v3" "github.com/go-ldap/ldap/v3"
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/common"
@@ -122,7 +125,7 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
ldapGroupIDs := make(map[string]struct{}, len(result.Entries)) ldapGroupIDs := make(map[string]struct{}, len(result.Entries))
for _, value := range result.Entries { for _, value := range result.Entries {
ldapId := value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.Value) ldapId := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.Value))
// Skip groups without a valid LDAP ID // Skip groups without a valid LDAP ID
if ldapId == "" { if ldapId == "" {
@@ -194,7 +197,7 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
syncGroup := dto.UserGroupCreateDto{ syncGroup := dto.UserGroupCreateDto{
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value), Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value), FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
LdapID: value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.Value), LdapID: ldapId,
} }
if databaseGroup.ID == "" { if databaseGroup.ID == "" {
@@ -286,7 +289,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
ldapUserIDs := make(map[string]struct{}, len(result.Entries)) ldapUserIDs := make(map[string]struct{}, len(result.Entries))
for _, value := range result.Entries { for _, value := range result.Entries {
ldapId := value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.Value) ldapId := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.Value))
// Skip users without a valid LDAP ID // Skip users without a valid LDAP ID
if ldapId == "" { if ldapId == "" {
@@ -468,3 +471,21 @@ func getDNProperty(property string, str string) string {
// CN not found, return an empty string // CN not found, return an empty string
return "" return ""
} }
// convertLdapIdToString converts LDAP IDs to valid UTF-8 strings.
// LDAP servers may return binary UUIDs (16 bytes) or other non-UTF-8 data.
func convertLdapIdToString(ldapId string) string {
if utf8.ValidString(ldapId) {
return ldapId
}
// Try to parse as binary UUID (16 bytes)
if len(ldapId) == 16 {
if parsedUUID, err := uuid.FromBytes([]byte(ldapId)); err == nil {
return parsedUUID.String()
}
}
// As a last resort, encode as base64 to make it UTF-8 safe
return base64.StdEncoding.EncodeToString([]byte(ldapId))
}

View File

@@ -71,3 +71,36 @@ func TestGetDNProperty(t *testing.T) {
}) })
} }
} }
func TestConvertLdapIdToString(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "valid UTF-8 string",
input: "simple-utf8-id",
expected: "simple-utf8-id",
},
{
name: "binary UUID (16 bytes)",
input: string([]byte{0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf1}),
expected: "12345678-9abc-def0-1234-56789abcdef1",
},
{
name: "non-UTF8, non-UUID returns base64",
input: string([]byte{0xff, 0xfe, 0xfd, 0xfc}),
expected: "//79/A==",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := convertLdapIdToString(tt.input)
if got != tt.expected {
t.Errorf("Expected %q, got %q", tt.expected, got)
}
})
}
}

View File

@@ -255,7 +255,7 @@ func (s *OidcService) createTokenFromDeviceCode(ctx context.Context, input dto.O
tx.Rollback() tx.Rollback()
}() }()
_, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input)) _, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input), true)
if err != nil { if err != nil {
return CreatedTokens{}, err return CreatedTokens{}, err
} }
@@ -336,7 +336,7 @@ func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, inpu
tx.Rollback() tx.Rollback()
}() }()
client, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input)) client, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input), true)
if err != nil { if err != nil {
return CreatedTokens{}, err return CreatedTokens{}, err
} }
@@ -420,7 +420,7 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
tx.Rollback() tx.Rollback()
}() }()
client, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input)) client, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input), true)
if err != nil { if err != nil {
return CreatedTokens{}, err return CreatedTokens{}, err
} }
@@ -490,6 +490,11 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
} }
func (s *OidcService) IntrospectToken(ctx context.Context, creds ClientAuthCredentials, tokenString string) (introspectDto dto.OidcIntrospectionResponseDto, err error) { func (s *OidcService) IntrospectToken(ctx context.Context, creds ClientAuthCredentials, tokenString string) (introspectDto dto.OidcIntrospectionResponseDto, err error) {
client, err := s.verifyClientCredentialsInternal(ctx, s.db, creds, false)
if err != nil {
return introspectDto, err
}
// Get the type of the token and the client ID // Get the type of the token and the client ID
tokenType, token, err := s.jwtService.GetTokenType(tokenString) tokenType, token, err := s.jwtService.GetTokenType(tokenString)
if err != nil { if err != nil {
@@ -498,24 +503,16 @@ func (s *OidcService) IntrospectToken(ctx context.Context, creds ClientAuthCrede
return introspectDto, nil //nolint:nilerr return introspectDto, nil //nolint:nilerr
} }
// If we don't have a client ID, get it from the token // Get the audience from the token
// Otherwise, we need to make sure that the client ID passed as credential matches
tokenAudiences, _ := token.Audience() tokenAudiences, _ := token.Audience()
if len(tokenAudiences) != 1 || tokenAudiences[0] == "" { if len(tokenAudiences) != 1 || tokenAudiences[0] == "" {
// We just treat the token as invalid
introspectDto.Active = false introspectDto.Active = false
return introspectDto, nil return introspectDto, nil
} }
if creds.ClientID == "" {
creds.ClientID = tokenAudiences[0]
} else if creds.ClientID != tokenAudiences[0] {
return introspectDto, &common.OidcMissingClientCredentialsError{}
}
// Verify the credentials for the call // Audience must match the client ID
client, err := s.verifyClientCredentialsInternal(ctx, s.db, creds) if client.ID != tokenAudiences[0] {
if err != nil { return introspectDto, &common.OidcMissingClientCredentialsError{}
return introspectDto, err
} }
// Introspect the token // Introspect the token
@@ -1137,7 +1134,7 @@ func (s *OidcService) CreateDeviceAuthorization(ctx context.Context, input dto.O
ClientSecret: input.ClientSecret, ClientSecret: input.ClientSecret,
ClientAssertionType: input.ClientAssertionType, ClientAssertionType: input.ClientAssertionType,
ClientAssertion: input.ClientAssertion, ClientAssertion: input.ClientAssertion,
}) }, true)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1385,24 +1382,39 @@ func clientAuthCredentialsFromCreateTokensDto(d *dto.OidcCreateTokensDto) Client
} }
} }
func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, tx *gorm.DB, input ClientAuthCredentials) (*model.OidcClient, error) { func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, tx *gorm.DB, input ClientAuthCredentials, allowPublicClientsWithoutAuth bool) (client *model.OidcClient, err error) {
// First, ensure we have a valid client ID isClientAssertion := input.ClientAssertionType == ClientAssertionTypeJWTBearer && input.ClientAssertion != ""
if input.ClientID == "" {
// Determine the client ID based on the authentication method
var clientID string
switch {
case isClientAssertion:
// Extract client ID from the JWT assertion's 'sub' claim
clientID, err = s.extractClientIDFromAssertion(input.ClientAssertion)
if err != nil {
slog.Error("Failed to extract client ID from assertion", "error", err)
return nil, &common.OidcClientAssertionInvalidError{}
}
case input.ClientID != "":
// Use the provided client ID for other authentication methods
clientID = input.ClientID
default:
return nil, &common.OidcMissingClientCredentialsError{} return nil, &common.OidcMissingClientCredentialsError{}
} }
// Load the OIDC client's configuration // Load the OIDC client's configuration
var client model.OidcClient err = tx.
err := tx.
WithContext(ctx). WithContext(ctx).
First(&client, "id = ?", input.ClientID). First(&client, "id = ?", clientID).
Error Error
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) && isClientAssertion {
return nil, &common.OidcClientAssertionInvalidError{}
}
return nil, err return nil, err
} }
// We have 3 options // Validate credentials based on the authentication method
// If credentials are provided, we validate them; otherwise, we can continue without credentials for public clients only
switch { switch {
// First, if we have a client secret, we validate it // First, if we have a client secret, we validate it
case input.ClientSecret != "": case input.ClientSecret != "":
@@ -1410,21 +1422,21 @@ func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, tx *g
if err != nil { if err != nil {
return nil, &common.OidcClientSecretInvalidError{} return nil, &common.OidcClientSecretInvalidError{}
} }
return &client, nil return client, nil
// Next, check if we want to use client assertions from federated identities // Next, check if we want to use client assertions from federated identities
case input.ClientAssertionType == ClientAssertionTypeJWTBearer && input.ClientAssertion != "": case isClientAssertion:
err = s.verifyClientAssertionFromFederatedIdentities(ctx, &client, input) err = s.verifyClientAssertionFromFederatedIdentities(ctx, client, input)
if err != nil { if err != nil {
log.Printf("Invalid assertion for client '%s': %v", client.ID, err) log.Printf("Invalid assertion for client '%s': %v", client.ID, err)
return nil, &common.OidcClientAssertionInvalidError{} return nil, &common.OidcClientAssertionInvalidError{}
} }
return &client, nil return client, nil
// There's no credentials // There's no credentials
// This is allowed only if the client is public // This is allowed only if the client is public
case client.IsPublic: case client.IsPublic && allowPublicClientsWithoutAuth:
return &client, nil return client, nil
// If we're here, we have no credentials AND the client is not public, so credentials are required // If we're here, we have no credentials AND the client is not public, so credentials are required
default: default:
@@ -1523,6 +1535,23 @@ func (s *OidcService) verifyClientAssertionFromFederatedIdentities(ctx context.C
return nil return nil
} }
// extractClientIDFromAssertion extracts the client_id from the JWT assertion's 'sub' claim
func (s *OidcService) extractClientIDFromAssertion(assertion string) (string, error) {
// Parse the JWT without verification first to get the claims
insecureToken, err := jwt.ParseInsecure([]byte(assertion))
if err != nil {
return "", fmt.Errorf("failed to parse JWT assertion: %w", err)
}
// Extract the subject claim which must be the client_id according to RFC 7523
sub, ok := insecureToken.Subject()
if !ok || sub == "" {
return "", fmt.Errorf("missing or invalid 'sub' claim in JWT assertion")
}
return sub, nil
}
func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, userID string, scopes string) (*dto.OidcClientPreviewDto, error) { func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, userID string, scopes string) (*dto.OidcClientPreviewDto, error) {
tx := s.db.Begin() tx := s.db.Begin()
defer func() { defer func() {

View File

@@ -18,6 +18,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/dto" "github.com/pocket-id/pocket-id/backend/internal/dto"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
) )
// generateTestECDSAKey creates an ECDSA key for testing // generateTestECDSAKey creates an ECDSA key for testing
@@ -62,12 +63,12 @@ func TestOidcService_jwkSetForURL(t *testing.T) {
) )
mockResponses := map[string]*http.Response{ mockResponses := map[string]*http.Response{
//nolint:bodyclose //nolint:bodyclose
url1: NewMockResponse(http.StatusOK, string(jwkSetJSON1)), url1: testutils.NewMockResponse(http.StatusOK, string(jwkSetJSON1)),
//nolint:bodyclose //nolint:bodyclose
url2: NewMockResponse(http.StatusOK, string(jwkSetJSON2)), url2: testutils.NewMockResponse(http.StatusOK, string(jwkSetJSON2)),
} }
httpClient := &http.Client{ httpClient := &http.Client{
Transport: &MockRoundTripper{ Transport: &testutils.MockRoundTripper{
Responses: mockResponses, Responses: mockResponses,
}, },
} }
@@ -134,13 +135,12 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
const ( const (
federatedClientIssuer = "https://external-idp.com" federatedClientIssuer = "https://external-idp.com"
federatedClientAudience = "https://pocket-id.com" federatedClientAudience = "https://pocket-id.com"
federatedClientSubject = "123456abcdef"
federatedClientIssuerDefaults = "https://external-idp-defaults.com/" federatedClientIssuerDefaults = "https://external-idp-defaults.com/"
) )
var err error var err error
// Create a test database // Create a test database
db := newDatabaseForTest(t) db := testutils.NewDatabaseForTest(t)
// Create two JWKs for testing // Create two JWKs for testing
privateJWK, jwkSetJSON := generateTestECDSAKey(t) privateJWK, jwkSetJSON := generateTestECDSAKey(t)
@@ -150,12 +150,12 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
// Create a mock HTTP client with custom transport to return the JWKS // Create a mock HTTP client with custom transport to return the JWKS
httpClient := &http.Client{ httpClient := &http.Client{
Transport: &MockRoundTripper{ Transport: &testutils.MockRoundTripper{
Responses: map[string]*http.Response{ Responses: map[string]*http.Response{
//nolint:bodyclose //nolint:bodyclose
federatedClientIssuer + "/jwks.json": NewMockResponse(http.StatusOK, string(jwkSetJSON)), federatedClientIssuer + "/jwks.json": testutils.NewMockResponse(http.StatusOK, string(jwkSetJSON)),
//nolint:bodyclose //nolint:bodyclose
federatedClientIssuerDefaults + ".well-known/jwks.json": NewMockResponse(http.StatusOK, string(jwkSetJSONDefaults)), federatedClientIssuerDefaults + ".well-known/jwks.json": testutils.NewMockResponse(http.StatusOK, string(jwkSetJSONDefaults)),
}, },
}, },
} }
@@ -192,18 +192,24 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
federatedClient, err := s.CreateClient(t.Context(), dto.OidcClientCreateDto{ federatedClient, err := s.CreateClient(t.Context(), dto.OidcClientCreateDto{
Name: "Federated Client", Name: "Federated Client",
CallbackURLs: []string{"https://example.com/callback"}, CallbackURLs: []string{"https://example.com/callback"},
}, "test-user-id")
require.NoError(t, err)
federatedClient, err = s.UpdateClient(t.Context(), federatedClient.ID, dto.OidcClientCreateDto{
Name: federatedClient.Name,
CallbackURLs: federatedClient.CallbackURLs,
Credentials: dto.OidcClientCredentialsDto{ Credentials: dto.OidcClientCredentialsDto{
FederatedIdentities: []dto.OidcClientFederatedIdentityDto{ FederatedIdentities: []dto.OidcClientFederatedIdentityDto{
{ {
Issuer: federatedClientIssuer, Issuer: federatedClientIssuer,
Audience: federatedClientAudience, Audience: federatedClientAudience,
Subject: federatedClientSubject, Subject: federatedClient.ID,
JWKS: federatedClientIssuer + "/jwks.json", JWKS: federatedClientIssuer + "/jwks.json",
}, },
{Issuer: federatedClientIssuerDefaults}, {Issuer: federatedClientIssuerDefaults},
}, },
}, },
}, "test-user-id") })
require.NoError(t, err) require.NoError(t, err)
// Test cases for confidential client (using client secret) // Test cases for confidential client (using client secret)
@@ -213,7 +219,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{ client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
ClientID: confidentialClient.ID, ClientID: confidentialClient.ID,
ClientSecret: confidentialSecret, ClientSecret: confidentialSecret,
}) }, true)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, client) require.NotNil(t, client)
assert.Equal(t, confidentialClient.ID, client.ID) assert.Equal(t, confidentialClient.ID, client.ID)
@@ -224,7 +230,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{ client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
ClientID: confidentialClient.ID, ClientID: confidentialClient.ID,
ClientSecret: "invalid-secret", ClientSecret: "invalid-secret",
}) }, true)
require.Error(t, err) require.Error(t, err)
require.ErrorIs(t, err, &common.OidcClientSecretInvalidError{}) require.ErrorIs(t, err, &common.OidcClientSecretInvalidError{})
assert.Nil(t, client) assert.Nil(t, client)
@@ -234,7 +240,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
// Test with missing client secret // Test with missing client secret
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{ client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
ClientID: confidentialClient.ID, ClientID: confidentialClient.ID,
}) }, true)
require.Error(t, err) require.Error(t, err)
require.ErrorIs(t, err, &common.OidcMissingClientCredentialsError{}) require.ErrorIs(t, err, &common.OidcMissingClientCredentialsError{})
assert.Nil(t, client) assert.Nil(t, client)
@@ -247,11 +253,21 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
// Public clients don't require client secret // Public clients don't require client secret
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{ client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
ClientID: publicClient.ID, ClientID: publicClient.ID,
}) }, true)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, client) require.NotNil(t, client)
assert.Equal(t, publicClient.ID, client.ID) assert.Equal(t, publicClient.ID, client.ID)
}) })
t.Run("Fails with no credentials if allowPublicClientsWithoutAuth is false", func(t *testing.T) {
// Public clients don't require client secret
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
ClientID: publicClient.ID,
}, false)
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcMissingClientCredentialsError{})
assert.Nil(t, client)
})
}) })
// Test cases for federated client using JWT assertion // Test cases for federated client using JWT assertion
@@ -261,7 +277,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
token, err := jwt.NewBuilder(). token, err := jwt.NewBuilder().
Issuer(federatedClientIssuer). Issuer(federatedClientIssuer).
Audience([]string{federatedClientAudience}). Audience([]string{federatedClientAudience}).
Subject(federatedClientSubject). Subject(federatedClient.ID).
IssuedAt(time.Now()). IssuedAt(time.Now()).
Expiration(time.Now().Add(10 * time.Minute)). Expiration(time.Now().Add(10 * time.Minute)).
Build() Build()
@@ -274,7 +290,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
ClientID: federatedClient.ID, ClientID: federatedClient.ID,
ClientAssertionType: ClientAssertionTypeJWTBearer, ClientAssertionType: ClientAssertionTypeJWTBearer,
ClientAssertion: string(signedToken), ClientAssertion: string(signedToken),
}) }, true)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, client) require.NotNil(t, client)
assert.Equal(t, federatedClient.ID, client.ID) assert.Equal(t, federatedClient.ID, client.ID)
@@ -286,7 +302,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
ClientID: federatedClient.ID, ClientID: federatedClient.ID,
ClientAssertionType: ClientAssertionTypeJWTBearer, ClientAssertionType: ClientAssertionTypeJWTBearer,
ClientAssertion: "invalid.jwt.token", ClientAssertion: "invalid.jwt.token",
}) }, true)
require.Error(t, err) require.Error(t, err)
require.ErrorIs(t, err, &common.OidcClientAssertionInvalidError{}) require.ErrorIs(t, err, &common.OidcClientAssertionInvalidError{})
assert.Nil(t, client) assert.Nil(t, client)
@@ -298,7 +314,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
builder := jwt.NewBuilder(). builder := jwt.NewBuilder().
Issuer(federatedClientIssuer). Issuer(federatedClientIssuer).
Audience([]string{federatedClientAudience}). Audience([]string{federatedClientAudience}).
Subject(federatedClientSubject). Subject(federatedClient.ID).
IssuedAt(time.Now()). IssuedAt(time.Now()).
Expiration(time.Now().Add(10 * time.Minute)) Expiration(time.Now().Add(10 * time.Minute))
@@ -315,7 +331,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
ClientID: federatedClient.ID, ClientID: federatedClient.ID,
ClientAssertionType: ClientAssertionTypeJWTBearer, ClientAssertionType: ClientAssertionTypeJWTBearer,
ClientAssertion: string(signedToken), ClientAssertion: string(signedToken),
}) }, true)
require.Error(t, err) require.Error(t, err)
require.ErrorIs(t, err, &common.OidcClientAssertionInvalidError{}) require.ErrorIs(t, err, &common.OidcClientAssertionInvalidError{})
require.Nil(t, client) require.Nil(t, client)
@@ -356,7 +372,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
ClientID: federatedClient.ID, ClientID: federatedClient.ID,
ClientAssertionType: ClientAssertionTypeJWTBearer, ClientAssertionType: ClientAssertionTypeJWTBearer,
ClientAssertion: string(signedToken), ClientAssertion: string(signedToken),
}) }, true)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, client) require.NotNil(t, client)
assert.Equal(t, federatedClient.ID, client.ID) assert.Equal(t, federatedClient.ID, client.ID)

View File

@@ -469,9 +469,7 @@ func (s *UserService) ExchangeOneTimeAccessToken(ctx context.Context, token stri
return model.User{}, "", err return model.User{}, "", err
} }
if ipAddress != "" && userAgent != "" { s.auditLogService.Create(ctx, model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{}, tx)
s.auditLogService.Create(ctx, model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{}, tx)
}
err = tx.Commit().Error err = tx.Commit().Error
if err != nil { if err != nil {
@@ -552,6 +550,9 @@ func (s *UserService) SignUpInitialAdmin(ctx context.Context, signUpData dto.Sig
} }
user, err := s.createUserInternal(ctx, userToCreate, false, tx) user, err := s.createUserInternal(ctx, userToCreate, false, tx)
if err != nil {
return model.User{}, "", err
}
token, err := s.jwtService.GenerateAccessToken(user) token, err := s.jwtService.GenerateAccessToken(user)
if err != nil { if err != nil {

View File

@@ -0,0 +1,24 @@
package utils
import (
"bufio"
"fmt"
"os"
"strings"
)
// PromptForConfirmation prompts the user to answer "y" in the terminal
func PromptForConfirmation(prompt string) (bool, error) {
fmt.Print(prompt + " [y/N]: ")
reader := bufio.NewReader(os.Stdin)
r, err := reader.ReadString('\n')
if err != nil {
return false, fmt.Errorf("failed to read response: %w", err)
}
r = strings.TrimSpace(strings.ToLower(r))
ok := r == "yes" || r == "y"
return ok, nil
}

View File

@@ -0,0 +1,69 @@
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"fmt"
"io"
)
// ErrDecrypt is returned by Decrypt when the operation failed for any reason
var ErrDecrypt = errors.New("failed to decrypt data")
// Encrypt a byte slice using AES-GCM and a random nonce
// Important: do not encrypt more than ~4 billion messages with the same key!
func Encrypt(key []byte, plaintext []byte, associatedData []byte) (ciphertext []byte, err error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("failed to create block cipher: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create AEAD cipher: %w", err)
}
// Generate a random nonce
nonce := make([]byte, aead.NonceSize())
_, err = io.ReadFull(rand.Reader, nonce)
if err != nil {
return nil, fmt.Errorf("failed to generate random nonce: %w", err)
}
// Allocate the slice for the result, with additional space for the nonce and overhead
ciphertext = make([]byte, 0, len(plaintext)+aead.NonceSize()+aead.Overhead())
ciphertext = append(ciphertext, nonce...)
// Encrypt the plaintext
// Tag is automatically added at the end
ciphertext = aead.Seal(ciphertext, nonce, plaintext, associatedData)
return ciphertext, nil
}
// Decrypt a byte slice using AES-GCM
func Decrypt(key []byte, ciphertext []byte, associatedData []byte) (plaintext []byte, err error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("failed to create block cipher: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create AEAD cipher: %w", err)
}
// Extract the nonce
if len(ciphertext) < (aead.NonceSize() + aead.Overhead()) {
return nil, ErrDecrypt
}
// Decrypt the data
plaintext, err = aead.Open(nil, ciphertext[:aead.NonceSize()], ciphertext[aead.NonceSize():], associatedData)
if err != nil {
// Note: we do not return the exact error here, to avoid disclosing information
return nil, ErrDecrypt
}
return plaintext, nil
}

View File

@@ -0,0 +1,208 @@
package crypto
import (
"crypto/rand"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEncryptDecrypt(t *testing.T) {
tests := []struct {
name string
keySize int
plaintext string
associatedData []byte
}{
{
name: "AES-128 with short plaintext",
keySize: 16,
plaintext: "Hello, World!",
associatedData: []byte("test-aad"),
},
{
name: "AES-192 with medium plaintext",
keySize: 24,
plaintext: "This is a longer message to test encryption and decryption",
associatedData: []byte("associated-data-192"),
},
{
name: "AES-256 with unicode",
keySize: 32,
plaintext: "Hello 世界! 🌍 Testing unicode characters", //nolint:gosmopolitan
associatedData: []byte("unicode-test"),
},
{
name: "No associated data",
keySize: 32,
plaintext: "Testing without associated data",
associatedData: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Generate random key
key := make([]byte, tt.keySize)
_, err := rand.Read(key)
require.NoError(t, err, "Failed to generate random key")
plaintext := []byte(tt.plaintext)
// Test encryption
ciphertext, err := Encrypt(key, plaintext, tt.associatedData)
require.NoError(t, err, "Encrypt should succeed")
// Verify ciphertext is different from plaintext (unless empty)
if len(plaintext) > 0 {
assert.NotEqual(t, plaintext, ciphertext)
}
// Test decryption
decrypted, err := Decrypt(key, ciphertext, tt.associatedData)
require.NoError(t, err, "Decrypt should succeed")
// Verify decrypted text matches original
assert.Equal(t, plaintext, decrypted, "Decrypted text should match original")
})
}
}
func TestEncryptWithInvalidKeySize(t *testing.T) {
invalidKeySizes := []int{8, 12, 33, 47, 55, 128}
for _, keySize := range invalidKeySizes {
t.Run(fmt.Sprintf("Key size %d", keySize), func(t *testing.T) {
key := make([]byte, keySize)
plaintext := []byte("test message")
_, err := Encrypt(key, plaintext, nil)
require.Error(t, err)
assert.ErrorContains(t, err, "invalid key size")
})
}
}
func TestDecryptWithInvalidKeySize(t *testing.T) {
invalidKeySizes := []int{8, 12, 33, 47, 55, 128}
for _, keySize := range invalidKeySizes {
t.Run(fmt.Sprintf("Key size %d", keySize), func(t *testing.T) {
key := make([]byte, keySize)
ciphertext := []byte("fake ciphertext")
_, err := Decrypt(key, ciphertext, nil)
require.Error(t, err)
assert.ErrorContains(t, err, "invalid key size")
})
}
}
func TestDecryptWithInvalidCiphertext(t *testing.T) {
key := make([]byte, 32)
_, err := rand.Read(key)
require.NoError(t, err, "Failed to generate random key")
tests := []struct {
name string
ciphertext []byte
}{
{
name: "empty ciphertext",
ciphertext: []byte{},
},
{
name: "too short ciphertext",
ciphertext: []byte("short"),
},
{
name: "random invalid data",
ciphertext: []byte("this is not valid encrypted data"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := Decrypt(key, tt.ciphertext, nil)
require.Error(t, err)
require.ErrorIs(t, err, ErrDecrypt)
})
}
}
func TestDecryptWithWrongKey(t *testing.T) {
// Generate two different keys
key1 := make([]byte, 32)
key2 := make([]byte, 32)
_, err := rand.Read(key1)
require.NoError(t, err)
_, err = rand.Read(key2)
require.NoError(t, err)
plaintext := []byte("secret message")
// Encrypt with key1
ciphertext, err := Encrypt(key1, plaintext, nil)
require.NoError(t, err)
// Try to decrypt with key2
_, err = Decrypt(key2, ciphertext, nil)
require.Error(t, err)
require.ErrorIs(t, err, ErrDecrypt)
}
func TestDecryptWithWrongAssociatedData(t *testing.T) {
key := make([]byte, 32)
_, err := rand.Read(key)
require.NoError(t, err, "Failed to generate random key")
plaintext := []byte("secret message")
correctAAD := []byte("correct-aad")
wrongAAD := []byte("wrong-aad")
// Encrypt with correct AAD
ciphertext, err := Encrypt(key, plaintext, correctAAD)
require.NoError(t, err)
// Try to decrypt with wrong AAD
_, err = Decrypt(key, ciphertext, wrongAAD)
require.Error(t, err)
require.ErrorIs(t, err, ErrDecrypt)
// Verify correct AAD works
decrypted, err := Decrypt(key, ciphertext, correctAAD)
require.NoError(t, err)
assert.Equal(t, plaintext, decrypted, "Decrypted text should match original when using correct AAD")
}
func TestEncryptDecryptConsistency(t *testing.T) {
key := make([]byte, 32)
_, err := rand.Read(key)
require.NoError(t, err)
plaintext := []byte("consistency test message")
associatedData := []byte("test-aad")
// Encrypt multiple times and verify we get different ciphertexts (due to random IV)
ciphertext1, err := Encrypt(key, plaintext, associatedData)
require.NoError(t, err)
ciphertext2, err := Encrypt(key, plaintext, associatedData)
require.NoError(t, err)
// Ciphertexts should be different (due to random IV)
assert.NotEqual(t, ciphertext1, ciphertext2, "Multiple encryptions of same plaintext should produce different ciphertexts")
// Both should decrypt to the same plaintext
decrypted1, err := Decrypt(key, ciphertext1, associatedData)
require.NoError(t, err)
decrypted2, err := Decrypt(key, ciphertext2, associatedData)
require.NoError(t, err)
assert.Equal(t, plaintext, decrypted1, "First decrypted text should match original")
assert.Equal(t, plaintext, decrypted2, "Second decrypted text should match original")
assert.Equal(t, decrypted1, decrypted2, "Both decrypted texts should be identical")
}

View File

@@ -0,0 +1,50 @@
package jwk
import (
"fmt"
"github.com/lestrrat-go/jwx/v3/jwk"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
type KeyProviderOpts struct {
EnvConfig *common.EnvConfigSchema
DB *gorm.DB
Kek []byte
}
type KeyProvider interface {
Init(opts KeyProviderOpts) error
LoadKey() (jwk.Key, error)
SaveKey(key jwk.Key) error
}
func GetKeyProvider(db *gorm.DB, envConfig *common.EnvConfigSchema, instanceID string) (keyProvider KeyProvider, err error) {
// Load the encryption key (KEK) if present
kek, err := LoadKeyEncryptionKey(envConfig, instanceID)
if err != nil {
return nil, fmt.Errorf("failed to load encryption key: %w", err)
}
// Get the key provider
switch envConfig.KeysStorage {
case "file", "":
keyProvider = &KeyProviderFile{}
case "database":
keyProvider = &KeyProviderDatabase{}
default:
return nil, fmt.Errorf("invalid key storage '%s'", envConfig.KeysStorage)
}
err = keyProvider.Init(KeyProviderOpts{
DB: db,
EnvConfig: envConfig,
Kek: kek,
})
if err != nil {
return nil, fmt.Errorf("failed to init key provider of type '%s': %w", envConfig.KeysStorage, err)
}
return keyProvider, nil
}

View File

@@ -0,0 +1,109 @@
package jwk
import (
"context"
"encoding/base64"
"errors"
"fmt"
"time"
"github.com/lestrrat-go/jwx/v3/jwk"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model"
cryptoutils "github.com/pocket-id/pocket-id/backend/internal/utils/crypto"
)
const PrivateKeyDBKey = "jwt_private_key.json"
type KeyProviderDatabase struct {
db *gorm.DB
kek []byte
}
func (f *KeyProviderDatabase) Init(opts KeyProviderOpts) error {
if len(opts.Kek) == 0 {
return errors.New("an encryption key is required when using the 'database' key provider")
}
f.db = opts.DB
f.kek = opts.Kek
return nil
}
func (f *KeyProviderDatabase) LoadKey() (key jwk.Key, err error) {
row := model.KV{
Key: PrivateKeyDBKey,
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err = f.db.WithContext(ctx).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// Key not present in the database - return nil so a new one can be generated
return nil, nil
} else if err != nil {
return nil, fmt.Errorf("failed to retrieve private key from the database: %w", err)
}
if row.Value == nil || *row.Value == "" {
// Key not present in the database - return nil so a new one can be generated
return nil, nil
}
// Decode from base64
enc, err := base64.StdEncoding.DecodeString(*row.Value)
if err != nil {
return nil, fmt.Errorf("failed to read encrypted private key: not a valid base64-encoded value: %w", err)
}
// Decrypt the data
data, err := cryptoutils.Decrypt(f.kek, enc, nil)
if err != nil {
return nil, fmt.Errorf("failed to decrypt private key: %w", err)
}
// Parse the key
key, err = jwk.ParseKey(data)
if err != nil {
return nil, fmt.Errorf("failed to parse encrypted private key: %w", err)
}
return key, nil
}
func (f *KeyProviderDatabase) SaveKey(key jwk.Key) error {
// Encode the key to JSON
data, err := EncodeJWKBytes(key)
if err != nil {
return fmt.Errorf("failed to encode key to JSON: %w", err)
}
// Encrypt the key then encode to Base64
enc, err := cryptoutils.Encrypt(f.kek, data, nil)
if err != nil {
return fmt.Errorf("failed to encrypt key: %w", err)
}
encB64 := base64.StdEncoding.EncodeToString(enc)
// Save to database
row := model.KV{
Key: PrivateKeyDBKey,
Value: &encB64,
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err = f.db.WithContext(ctx).Create(&row).Error
if err != nil {
// There's one scenario where if Pocket ID is started fresh with more than 1 replica, they both could be trying to create the private key in the database at the same time
// In this case, only one of the replicas will succeed; the other one(s) will return an error here, which will cascade down and cause the replica(s) to crash and be restarted (at that point they'll load the then-existing key from the database)
return fmt.Errorf("failed to store private key in database: %w", err)
}
return nil
}
// Compile-time interface check
var _ KeyProvider = (*KeyProviderDatabase)(nil)

View File

@@ -0,0 +1,275 @@
package jwk
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/base64"
"testing"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/model"
cryptoutils "github.com/pocket-id/pocket-id/backend/internal/utils/crypto"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
func TestKeyProviderDatabase_Init(t *testing.T) {
t.Run("Init fails when KEK is not provided", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
provider := &KeyProviderDatabase{}
err := provider.Init(KeyProviderOpts{
DB: db,
Kek: nil, // No KEK
})
require.Error(t, err, "Expected error when KEK is not provided")
require.ErrorContains(t, err, "encryption key is required")
})
t.Run("Init succeeds with KEK", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
provider := &KeyProviderDatabase{}
err := provider.Init(KeyProviderOpts{
DB: db,
Kek: generateTestKEK(t),
})
require.NoError(t, err, "Expected no error when KEK is provided")
})
}
func TestKeyProviderDatabase_LoadKey(t *testing.T) {
// Generate a test key to use in our tests
pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
key, err := jwk.Import(pk)
require.NoError(t, err)
t.Run("LoadKey with no existing key", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
kek := generateTestKEK(t)
provider := &KeyProviderDatabase{}
err := provider.Init(KeyProviderOpts{
DB: db,
Kek: kek,
})
require.NoError(t, err)
// Load key when none exists
loadedKey, err := provider.LoadKey()
require.NoError(t, err)
assert.Nil(t, loadedKey, "Expected nil key when no key exists in database")
})
t.Run("LoadKey with existing key", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
kek := generateTestKEK(t)
provider := &KeyProviderDatabase{}
err := provider.Init(KeyProviderOpts{
DB: db,
Kek: kek,
})
require.NoError(t, err)
// Save a key
err = provider.SaveKey(key)
require.NoError(t, err)
// Load the key
loadedKey, err := provider.LoadKey()
require.NoError(t, err)
assert.NotNil(t, loadedKey, "Expected non-nil key when key exists in database")
// Verify the loaded key is the same as the original
keyBytes, err := EncodeJWKBytes(key)
require.NoError(t, err)
loadedKeyBytes, err := EncodeJWKBytes(loadedKey)
require.NoError(t, err)
assert.Equal(t, keyBytes, loadedKeyBytes, "Expected loaded key to match original key")
})
t.Run("LoadKey with invalid base64", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
kek := generateTestKEK(t)
provider := &KeyProviderDatabase{}
err := provider.Init(KeyProviderOpts{
DB: db,
Kek: kek,
})
require.NoError(t, err)
// Insert invalid base64 data
invalidBase64 := "not-valid-base64"
err = db.Create(&model.KV{
Key: PrivateKeyDBKey,
Value: &invalidBase64,
}).Error
require.NoError(t, err)
// Attempt to load the key
loadedKey, err := provider.LoadKey()
require.Error(t, err, "Expected error when loading key with invalid base64")
require.ErrorContains(t, err, "not a valid base64-encoded value")
assert.Nil(t, loadedKey, "Expected nil key when loading fails")
})
t.Run("LoadKey with invalid encrypted data", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
kek := generateTestKEK(t)
provider := &KeyProviderDatabase{}
err := provider.Init(KeyProviderOpts{
DB: db,
Kek: kek,
})
require.NoError(t, err)
// Insert valid base64 but invalid encrypted data
invalidData := base64.StdEncoding.EncodeToString([]byte("not-valid-encrypted-data"))
err = db.Create(&model.KV{
Key: PrivateKeyDBKey,
Value: &invalidData,
}).Error
require.NoError(t, err)
// Attempt to load the key
loadedKey, err := provider.LoadKey()
require.Error(t, err, "Expected error when loading key with invalid encrypted data")
require.ErrorContains(t, err, "failed to decrypt")
assert.Nil(t, loadedKey, "Expected nil key when loading fails")
})
t.Run("LoadKey with valid encrypted data but wrong KEK", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
originalKek := generateTestKEK(t)
// Save a key with the original KEK
originalProvider := &KeyProviderDatabase{}
err := originalProvider.Init(KeyProviderOpts{
DB: db,
Kek: originalKek,
})
require.NoError(t, err)
err = originalProvider.SaveKey(key)
require.NoError(t, err)
// Now try to load with a different KEK
differentKek := generateTestKEK(t)
differentProvider := &KeyProviderDatabase{}
err = differentProvider.Init(KeyProviderOpts{
DB: db,
Kek: differentKek,
})
require.NoError(t, err)
// Attempt to load the key with the wrong KEK
loadedKey, err := differentProvider.LoadKey()
require.Error(t, err, "Expected error when loading key with wrong KEK")
require.ErrorContains(t, err, "failed to decrypt")
assert.Nil(t, loadedKey, "Expected nil key when loading fails")
})
t.Run("LoadKey with invalid key data", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
kek := generateTestKEK(t)
provider := &KeyProviderDatabase{}
err := provider.Init(KeyProviderOpts{
DB: db,
Kek: kek,
})
require.NoError(t, err)
// Create invalid key data (valid JSON but not a valid JWK)
invalidKeyData := []byte(`{"not": "a valid jwk"}`)
// Encrypt the invalid key data
encryptedData, err := cryptoutils.Encrypt(kek, invalidKeyData, nil)
require.NoError(t, err)
// Base64 encode the encrypted data
encodedData := base64.StdEncoding.EncodeToString(encryptedData)
// Save to database
err = db.Create(&model.KV{
Key: PrivateKeyDBKey,
Value: &encodedData,
}).Error
require.NoError(t, err)
// Attempt to load the key
loadedKey, err := provider.LoadKey()
require.Error(t, err, "Expected error when loading invalid key data")
require.ErrorContains(t, err, "failed to parse")
assert.Nil(t, loadedKey, "Expected nil key when loading fails")
})
}
func TestKeyProviderDatabase_SaveKey(t *testing.T) {
// Generate a test key to use in our tests
pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
key, err := jwk.Import(pk)
require.NoError(t, err)
t.Run("SaveKey and verify database record", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
kek := generateTestKEK(t)
provider := &KeyProviderDatabase{}
err := provider.Init(KeyProviderOpts{
DB: db,
Kek: kek,
})
require.NoError(t, err)
// Save the key
err = provider.SaveKey(key)
require.NoError(t, err, "Expected no error when saving key")
// Verify record exists in database
var kv model.KV
err = db.Where("key = ?", PrivateKeyDBKey).First(&kv).Error
require.NoError(t, err, "Expected to find key in database")
require.NotNil(t, kv.Value, "Expected non-nil value in database")
assert.NotEmpty(t, *kv.Value, "Expected non-empty value in database")
// Decode and decrypt to verify content
encBytes, err := base64.StdEncoding.DecodeString(*kv.Value)
require.NoError(t, err, "Expected valid base64 encoding")
decBytes, err := cryptoutils.Decrypt(kek, encBytes, nil)
require.NoError(t, err, "Expected valid encrypted data")
parsedKey, err := jwk.ParseKey(decBytes)
require.NoError(t, err, "Expected valid JWK data")
// Compare keys
keyBytes, err := EncodeJWKBytes(key)
require.NoError(t, err)
parsedKeyBytes, err := EncodeJWKBytes(parsedKey)
require.NoError(t, err)
assert.Equal(t, keyBytes, parsedKeyBytes, "Expected saved key to match original key")
})
}
func generateTestKEK(t *testing.T) []byte {
t.Helper()
// Generate a 32-byte kek
kek := make([]byte, 32)
_, err := rand.Read(kek)
require.NoError(t, err)
return kek
}

View File

@@ -0,0 +1,202 @@
package jwk
import (
"encoding/base64"
"fmt"
"os"
"path/filepath"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils"
cryptoutils "github.com/pocket-id/pocket-id/backend/internal/utils/crypto"
)
const (
// PrivateKeyFile is the path in the data/keys folder where the key is stored
// This is a JSON file containing a key encoded as JWK
PrivateKeyFile = "jwt_private_key.json"
// PrivateKeyFileEncrypted is the path in the data/keys folder where the encrypted key is stored
// This is a encrypted JSON file containing a key encoded as JWK
PrivateKeyFileEncrypted = "jwt_private_key.json.enc"
)
type KeyProviderFile struct {
envConfig *common.EnvConfigSchema
kek []byte
}
func (f *KeyProviderFile) Init(opts KeyProviderOpts) error {
f.envConfig = opts.EnvConfig
f.kek = opts.Kek
return nil
}
func (f *KeyProviderFile) LoadKey() (jwk.Key, error) {
if len(f.kek) > 0 {
return f.loadEncryptedKey()
}
return f.loadKey()
}
func (f *KeyProviderFile) SaveKey(key jwk.Key) error {
if len(f.kek) > 0 {
return f.saveKeyEncrypted(key)
}
return f.saveKey(key)
}
func (f *KeyProviderFile) loadKey() (jwk.Key, error) {
var key jwk.Key
// First, check if we have a JWK file
// If we do, then we just load that
jwkPath := f.jwkPath()
ok, err := utils.FileExists(jwkPath)
if err != nil {
return nil, fmt.Errorf("failed to check if private key file exists at path '%s': %w", jwkPath, err)
}
if !ok {
// File doesn't exist, no key was loaded
return nil, nil
}
data, err := os.ReadFile(jwkPath)
if err != nil {
return nil, fmt.Errorf("failed to read private key file at path '%s': %w", jwkPath, err)
}
key, err = jwk.ParseKey(data)
if err != nil {
return nil, fmt.Errorf("failed to parse private key file at path '%s': %w", jwkPath, err)
}
return key, nil
}
func (f *KeyProviderFile) loadEncryptedKey() (key jwk.Key, err error) {
// First, check if we have an encrypted JWK file
// If we do, then we just load that
encJwkPath := f.encJwkPath()
ok, err := utils.FileExists(encJwkPath)
if err != nil {
return nil, fmt.Errorf("failed to check if encrypted private key file exists at path '%s': %w", encJwkPath, err)
}
if ok {
encB64, err := os.ReadFile(encJwkPath)
if err != nil {
return nil, fmt.Errorf("failed to read encrypted private key file at path '%s': %w", encJwkPath, err)
}
// Decode from base64
enc := make([]byte, base64.StdEncoding.DecodedLen(len(encB64)))
n, err := base64.StdEncoding.Decode(enc, encB64)
if err != nil {
return nil, fmt.Errorf("failed to read encrypted private key file at path '%s': not a valid base64-encoded file: %w", encJwkPath, err)
}
// Decrypt the data
data, err := cryptoutils.Decrypt(f.kek, enc[:n], nil)
if err != nil {
return nil, fmt.Errorf("failed to decrypt private key file at path '%s': %w", encJwkPath, err)
}
// Parse the key
key, err = jwk.ParseKey(data)
if err != nil {
return nil, fmt.Errorf("failed to parse encrypted private key file at path '%s': %w", encJwkPath, err)
}
return key, nil
}
// Check if we have an un-encrypted JWK file
key, err = f.loadKey()
if err != nil {
return nil, fmt.Errorf("failed to load un-encrypted key file: %w", err)
}
if key == nil {
// No key exists, encrypted or un-encrypted
return nil, nil
}
// If we are here, we have loaded a key that was un-encrypted
// We need to replace the plaintext key with the encrypted one before we return
err = f.saveKeyEncrypted(key)
if err != nil {
return nil, fmt.Errorf("failed to save encrypted key file: %w", err)
}
jwkPath := f.jwkPath()
err = os.Remove(jwkPath)
if err != nil {
return nil, fmt.Errorf("failed to remove un-encrypted key file at path '%s': %w", jwkPath, err)
}
return key, nil
}
func (f *KeyProviderFile) saveKey(key jwk.Key) error {
err := os.MkdirAll(f.envConfig.KeysPath, 0700)
if err != nil {
return fmt.Errorf("failed to create directory '%s' for key file: %w", f.envConfig.KeysPath, err)
}
jwkPath := f.jwkPath()
keyFile, err := os.OpenFile(jwkPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to create key file at path '%s': %w", jwkPath, err)
}
defer keyFile.Close()
// Write the JSON file to disk
err = EncodeJWK(keyFile, key)
if err != nil {
return fmt.Errorf("failed to write key file at path '%s': %w", jwkPath, err)
}
return nil
}
func (f *KeyProviderFile) saveKeyEncrypted(key jwk.Key) error {
err := os.MkdirAll(f.envConfig.KeysPath, 0700)
if err != nil {
return fmt.Errorf("failed to create directory '%s' for encrypted key file: %w", f.envConfig.KeysPath, err)
}
// Encode the key to JSON
data, err := EncodeJWKBytes(key)
if err != nil {
return fmt.Errorf("failed to encode key to JSON: %w", err)
}
// Encrypt the key then encode to Base64
enc, err := cryptoutils.Encrypt(f.kek, data, nil)
if err != nil {
return fmt.Errorf("failed to encrypt key: %w", err)
}
encB64 := make([]byte, base64.StdEncoding.EncodedLen(len(enc)))
base64.StdEncoding.Encode(encB64, enc)
// Write to disk
encJwkPath := f.encJwkPath()
err = os.WriteFile(encJwkPath, encB64, 0600)
if err != nil {
return fmt.Errorf("failed to write encrypted key file at path '%s': %w", encJwkPath, err)
}
return nil
}
func (f *KeyProviderFile) jwkPath() string {
return filepath.Join(f.envConfig.KeysPath, PrivateKeyFile)
}
func (f *KeyProviderFile) encJwkPath() string {
return filepath.Join(f.envConfig.KeysPath, PrivateKeyFileEncrypted)
}
// Compile-time interface check
var _ KeyProvider = (*KeyProviderFile)(nil)

View File

@@ -0,0 +1,320 @@
package jwk
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/base64"
"os"
"path/filepath"
"testing"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils"
cryptoutils "github.com/pocket-id/pocket-id/backend/internal/utils/crypto"
)
func TestKeyProviderFile_LoadKey(t *testing.T) {
// Generate a test key to use in our tests
pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
key, err := jwk.Import(pk)
require.NoError(t, err)
t.Run("LoadKey with no existing key", func(t *testing.T) {
tempDir := t.TempDir()
provider := &KeyProviderFile{}
err := provider.Init(KeyProviderOpts{
EnvConfig: &common.EnvConfigSchema{
KeysPath: tempDir,
},
})
require.NoError(t, err)
// Load key when none exists
loadedKey, err := provider.LoadKey()
require.NoError(t, err)
assert.Nil(t, loadedKey, "Expected nil key when no key exists")
})
t.Run("LoadKey with no existing key (with kek)", func(t *testing.T) {
tempDir := t.TempDir()
provider := &KeyProviderFile{}
err = provider.Init(KeyProviderOpts{
EnvConfig: &common.EnvConfigSchema{
KeysPath: tempDir,
},
Kek: makeKEK(t),
})
require.NoError(t, err)
// Load key when none exists
loadedKey, err := provider.LoadKey()
require.NoError(t, err)
assert.Nil(t, loadedKey, "Expected nil key when no key exists")
})
t.Run("LoadKey with unencrypted key", func(t *testing.T) {
tempDir := t.TempDir()
provider := &KeyProviderFile{}
err := provider.Init(KeyProviderOpts{
EnvConfig: &common.EnvConfigSchema{
KeysPath: tempDir,
},
})
require.NoError(t, err)
// Save a key
err = provider.SaveKey(key)
require.NoError(t, err)
// Make sure the key file exists
keyPath := filepath.Join(tempDir, PrivateKeyFile)
exists, err := utils.FileExists(keyPath)
require.NoError(t, err)
assert.True(t, exists, "Expected key file to exist")
// Load the key
loadedKey, err := provider.LoadKey()
require.NoError(t, err)
assert.NotNil(t, loadedKey, "Expected non-nil key when key exists")
// Verify the loaded key is the same as the original
keyBytes, err := EncodeJWKBytes(key)
require.NoError(t, err)
loadedKeyBytes, err := EncodeJWKBytes(loadedKey)
require.NoError(t, err)
assert.Equal(t, keyBytes, loadedKeyBytes, "Expected loaded key to match original key")
})
t.Run("LoadKey with encrypted key", func(t *testing.T) {
tempDir := t.TempDir()
provider := &KeyProviderFile{}
err = provider.Init(KeyProviderOpts{
EnvConfig: &common.EnvConfigSchema{
KeysPath: tempDir,
},
Kek: makeKEK(t),
})
require.NoError(t, err)
// Save a key (will be encrypted)
err = provider.SaveKey(key)
require.NoError(t, err)
// Make sure the encrypted key file exists
encKeyPath := filepath.Join(tempDir, PrivateKeyFileEncrypted)
exists, err := utils.FileExists(encKeyPath)
require.NoError(t, err)
assert.True(t, exists, "Expected encrypted key file to exist")
// Make sure the unencrypted key file does not exist
keyPath := filepath.Join(tempDir, PrivateKeyFile)
exists, err = utils.FileExists(keyPath)
require.NoError(t, err)
assert.False(t, exists, "Expected unencrypted key file to not exist")
// Load the key
loadedKey, err := provider.LoadKey()
require.NoError(t, err)
assert.NotNil(t, loadedKey, "Expected non-nil key when encrypted key exists")
// Verify the loaded key is the same as the original
keyBytes, err := EncodeJWKBytes(key)
require.NoError(t, err)
loadedKeyBytes, err := EncodeJWKBytes(loadedKey)
require.NoError(t, err)
assert.Equal(t, keyBytes, loadedKeyBytes, "Expected loaded key to match original key")
})
t.Run("LoadKey replaces unencrypted key with encrypted key when kek is provided", func(t *testing.T) {
tempDir := t.TempDir()
// First, create an unencrypted key
providerNoKek := &KeyProviderFile{}
err := providerNoKek.Init(KeyProviderOpts{
EnvConfig: &common.EnvConfigSchema{
KeysPath: tempDir,
},
})
require.NoError(t, err)
// Save an unencrypted key
err = providerNoKek.SaveKey(key)
require.NoError(t, err)
// Verify unencrypted key exists
keyPath := filepath.Join(tempDir, PrivateKeyFile)
exists, err := utils.FileExists(keyPath)
require.NoError(t, err)
assert.True(t, exists, "Expected unencrypted key file to exist")
// Now create a provider with a kek
kek := make([]byte, 32)
_, err = rand.Read(kek)
require.NoError(t, err)
providerWithKek := &KeyProviderFile{}
err = providerWithKek.Init(KeyProviderOpts{
EnvConfig: &common.EnvConfigSchema{
KeysPath: tempDir,
},
Kek: kek,
})
require.NoError(t, err)
// Load the key - this should convert the unencrypted key to encrypted
loadedKey, err := providerWithKek.LoadKey()
require.NoError(t, err)
assert.NotNil(t, loadedKey, "Expected non-nil key when loading and converting key")
// Verify the unencrypted key no longer exists
exists, err = utils.FileExists(keyPath)
require.NoError(t, err)
assert.False(t, exists, "Expected unencrypted key file to be removed")
// Verify the encrypted key file exists
encKeyPath := filepath.Join(tempDir, PrivateKeyFileEncrypted)
exists, err = utils.FileExists(encKeyPath)
require.NoError(t, err)
assert.True(t, exists, "Expected encrypted key file to exist after conversion")
// Verify the key data
keyBytes, err := EncodeJWKBytes(key)
require.NoError(t, err)
loadedKeyBytes, err := EncodeJWKBytes(loadedKey)
require.NoError(t, err)
assert.Equal(t, keyBytes, loadedKeyBytes, "Expected loaded key to match original key after conversion")
})
}
func TestKeyProviderFile_SaveKey(t *testing.T) {
// Generate a test key to use in our tests
pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
key, err := jwk.Import(pk)
require.NoError(t, err)
t.Run("SaveKey unencrypted", func(t *testing.T) {
tempDir := t.TempDir()
provider := &KeyProviderFile{}
err := provider.Init(KeyProviderOpts{
EnvConfig: &common.EnvConfigSchema{
KeysPath: tempDir,
},
})
require.NoError(t, err)
// Save the key
err = provider.SaveKey(key)
require.NoError(t, err)
// Verify the key file exists
keyPath := filepath.Join(tempDir, PrivateKeyFile)
exists, err := utils.FileExists(keyPath)
require.NoError(t, err)
assert.True(t, exists, "Expected key file to exist")
// Verify the content of the key file
data, err := os.ReadFile(keyPath)
require.NoError(t, err)
parsedKey, err := jwk.ParseKey(data)
require.NoError(t, err)
// Compare the saved key with the original
keyBytes, err := EncodeJWKBytes(key)
require.NoError(t, err)
parsedKeyBytes, err := EncodeJWKBytes(parsedKey)
require.NoError(t, err)
assert.Equal(t, keyBytes, parsedKeyBytes, "Expected saved key to match original key")
})
t.Run("SaveKey encrypted", func(t *testing.T) {
tempDir := t.TempDir()
// Generate a 64-byte kek
kek := makeKEK(t)
provider := &KeyProviderFile{}
err = provider.Init(KeyProviderOpts{
EnvConfig: &common.EnvConfigSchema{
KeysPath: tempDir,
},
Kek: kek,
})
require.NoError(t, err)
// Save the key (will be encrypted)
err = provider.SaveKey(key)
require.NoError(t, err)
// Verify the encrypted key file exists
encKeyPath := filepath.Join(tempDir, PrivateKeyFileEncrypted)
exists, err := utils.FileExists(encKeyPath)
require.NoError(t, err)
assert.True(t, exists, "Expected encrypted key file to exist")
// Verify the unencrypted key file doesn't exist
keyPath := filepath.Join(tempDir, PrivateKeyFile)
exists, err = utils.FileExists(keyPath)
require.NoError(t, err)
assert.False(t, exists, "Expected unencrypted key file to not exist")
// Manually decrypt the encrypted key file to verify it contains the correct key
encB64, err := os.ReadFile(encKeyPath)
require.NoError(t, err)
// Decode from base64
enc := make([]byte, base64.StdEncoding.DecodedLen(len(encB64)))
n, err := base64.StdEncoding.Decode(enc, encB64)
require.NoError(t, err)
enc = enc[:n] // Trim any padding
// Decrypt the data
data, err := cryptoutils.Decrypt(kek, enc, nil)
require.NoError(t, err)
// Parse the key
parsedKey, err := jwk.ParseKey(data)
require.NoError(t, err)
// Compare the decrypted key with the original
keyBytes, err := EncodeJWKBytes(key)
require.NoError(t, err)
parsedKeyBytes, err := EncodeJWKBytes(parsedKey)
require.NoError(t, err)
assert.Equal(t, keyBytes, parsedKeyBytes, "Expected decrypted key to match original key")
})
}
func makeKEK(t *testing.T) []byte {
t.Helper()
// Generate a 32-byte kek
kek := make([]byte, 32)
_, err := rand.Read(kek)
require.NoError(t, err)
return kek
}

View File

@@ -0,0 +1,180 @@
package jwk
import (
"bytes"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/hmac"
"crypto/rand"
"crypto/rsa"
"crypto/sha3"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"hash"
"io"
"os"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
const (
// KeyUsageSigning is the usage for the private keys, for the "use" property
KeyUsageSigning = "sig"
)
// EncodeJWK encodes a jwk.Key to a writable stream.
func EncodeJWK(w io.Writer, key jwk.Key) error {
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
return enc.Encode(key)
}
// EncodeJWKBytes encodes a jwk.Key to a byte slice.
func EncodeJWKBytes(key jwk.Key) ([]byte, error) {
b := &bytes.Buffer{}
err := EncodeJWK(b, key)
if err != nil {
return nil, err
}
return b.Bytes(), nil
}
// LoadKeyEncryptionKey loads the key encryption key for JWKs
func LoadKeyEncryptionKey(envConfig *common.EnvConfigSchema, instanceID string) (kek []byte, err error) {
// Try getting the key from the env var as string
kekInput := []byte(envConfig.EncryptionKey)
// If there's nothing in the env, try loading from file
if len(kekInput) == 0 && envConfig.EncryptionKeyFile != "" {
kekInput, err = os.ReadFile(envConfig.EncryptionKeyFile)
if err != nil {
return nil, fmt.Errorf("failed to read key file '%s': %w", envConfig.EncryptionKeyFile, err)
}
}
// If there's still no key, return
if len(kekInput) == 0 {
return nil, nil
}
// We need a 256-bit key for encryption with AES-GCM-256
// We use HMAC with SHA3-256 here to derive the key from the one passed as input
// The key is tied to a specific instance of Pocket ID
h := hmac.New(func() hash.Hash { return sha3.New256() }, kekInput)
fmt.Fprint(h, "pocketid/"+instanceID+"/jwk-kek")
kek = h.Sum(nil)
return kek, nil
}
// 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, alg string, crv string) (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, alg, crv)
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 (and "crv", if needed), set depending on the key type
func EnsureAlgInKey(key jwk.Key, alg string, crv string) {
_, ok := key.Algorithm()
if ok {
// Algorithm is already set
return
}
if alg != "" {
_ = key.Set(jwk.AlgorithmKey, alg)
if crv != "" {
eca, ok := jwa.LookupEllipticCurveAlgorithm(crv)
if ok {
switch key.KeyType() {
case jwa.EC():
_ = key.Set(jwk.ECDSACrvKey, eca)
case jwa.OKP():
_ = key.Set(jwk.OKPCrvKey, eca)
}
}
}
return
}
// If we don't have an algorithm, set the default for the key type
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())
_ = key.Set(jwk.ECDSACrvKey, jwa.P256())
case jwa.OKP():
// Default to EdDSA and Ed25519 for OKP keys
_ = key.Set(jwk.AlgorithmKey, jwa.EdDSA())
_ = key.Set(jwk.OKPCrvKey, jwa.Ed25519())
}
}
// GenerateKey generates a new jwk.Key
func GenerateKey(alg string, crv string) (key jwk.Key, err error) {
var rawKey any
switch alg {
case jwa.RS256().String():
rawKey, err = rsa.GenerateKey(rand.Reader, 2048)
case jwa.RS384().String():
rawKey, err = rsa.GenerateKey(rand.Reader, 3072)
case jwa.RS512().String():
rawKey, err = rsa.GenerateKey(rand.Reader, 4096)
case jwa.ES256().String():
rawKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
case jwa.ES384().String():
rawKey, err = ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
case jwa.ES512().String():
rawKey, err = ecdsa.GenerateKey(elliptic.P521(), rand.Reader)
case jwa.EdDSA().String():
switch crv {
case jwa.Ed25519().String():
_, rawKey, err = ed25519.GenerateKey(rand.Reader)
default:
return nil, errors.New("unsupported curve for EdDSA algorithm")
}
default:
return nil, errors.New("unsupported key algorithm")
}
if err != nil {
return nil, fmt.Errorf("failed to generate private key: %w", err)
}
// Import the raw key
return ImportRawKey(rawKey, alg, crv)
}

View File

@@ -0,0 +1,324 @@
package jwk
import (
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"testing"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGenerateKey(t *testing.T) {
tests := []struct {
name string
alg string
crv string
expectError bool
expectedAlg jwa.SignatureAlgorithm
}{
{
name: "RS256",
alg: jwa.RS256().String(),
crv: "",
expectError: false,
expectedAlg: jwa.RS256(),
},
{
name: "RS384",
alg: jwa.RS384().String(),
crv: "",
expectError: false,
expectedAlg: jwa.RS384(),
},
// Skip the RS512 test as generating a RSA-4096 key can take some time
/* {
name: "RS512",
alg: jwa.RS512().String(),
crv: "",
expectError: false,
expectedAlg: jwa.RS512(),
}, */
{
name: "ES256",
alg: jwa.ES256().String(),
crv: jwa.P256().String(),
expectError: false,
expectedAlg: jwa.ES256(),
},
{
name: "ES384",
alg: jwa.ES384().String(),
crv: jwa.P384().String(),
expectError: false,
expectedAlg: jwa.ES384(),
},
{
name: "ES512",
alg: jwa.ES512().String(),
crv: jwa.P521().String(),
expectError: false,
expectedAlg: jwa.ES512(),
},
{
name: "EdDSA with Ed25519",
alg: jwa.EdDSA().String(),
crv: jwa.Ed25519().String(),
expectError: false,
expectedAlg: jwa.EdDSA(),
},
{
name: "EdDSA with unsupported curve",
alg: jwa.EdDSA().String(),
crv: "unsupported",
expectError: true,
},
{
name: "Unsupported algorithm",
alg: "UNSUPPORTED",
crv: "",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
key, err := GenerateKey(tt.alg, tt.crv)
if tt.expectError {
require.Error(t, err)
assert.Nil(t, key)
return
}
require.NoError(t, err)
require.NotNil(t, key)
// Verify the algorithm is set correctly
alg, ok := key.Algorithm()
require.True(t, ok, "algorithm should be set in the key")
assert.Equal(t, tt.expectedAlg.String(), alg.String())
// Verify other required fields are set
kid, ok := key.KeyID()
assert.True(t, ok, "key ID should be set")
assert.NotEmpty(t, kid, "key ID should not be empty")
usage, ok := key.KeyUsage()
assert.True(t, ok, "key usage should be set")
assert.Equal(t, KeyUsageSigning, usage)
var crv any
_ = key.Get("crv", &crv)
// Verify key type matches expected algorithm
switch tt.expectedAlg {
case jwa.RS256(), jwa.RS384(), jwa.RS512():
assert.Equal(t, jwa.RSA(), key.KeyType())
assert.Nil(t, crv)
case jwa.ES256(), jwa.ES384(), jwa.ES512():
assert.Equal(t, jwa.EC(), key.KeyType())
eca, ok := crv.(jwa.EllipticCurveAlgorithm)
_ = assert.NotNil(t, crv) &&
assert.True(t, ok) &&
assert.Equal(t, tt.crv, eca.String())
case jwa.EdDSA():
assert.Equal(t, jwa.OKP(), key.KeyType())
eca, ok := crv.(jwa.EllipticCurveAlgorithm)
_ = assert.NotNil(t, crv) &&
assert.True(t, ok) &&
assert.Equal(t, tt.crv, eca.String())
}
})
}
}
func TestEnsureAlgInKey(t *testing.T) {
// Generate an RSA-2048 key
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
t.Run("does not change alg already set", func(t *testing.T) {
// Import the RSA key
key, err := jwk.Import(rsaKey)
require.NoError(t, err)
// Pre-set the algorithm
_ = key.Set(jwk.AlgorithmKey, jwa.RS256())
// Call EnsureAlgInKey with a different algorithm
EnsureAlgInKey(key, jwa.RS384().String(), "")
// Verify the algorithm wasn't changed
alg, ok := key.Algorithm()
require.True(t, ok)
assert.Equal(t, jwa.RS256().String(), alg.String())
})
t.Run("set algorithm to explicitly-provided value", func(t *testing.T) {
tests := []struct {
name string
keyGen func() (any, error)
alg string
crv string
expectedAlg jwa.SignatureAlgorithm
expectedCrv string
}{
{
name: "RSA key with RS384",
keyGen: func() (any, error) {
return rsaKey, nil
},
alg: jwa.RS384().String(),
crv: "",
expectedAlg: jwa.RS384(),
expectedCrv: "",
},
{
name: "ECDSA key with ES384",
keyGen: func() (any, error) {
return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
},
alg: jwa.ES384().String(),
crv: jwa.P384().String(),
expectedAlg: jwa.ES384(),
expectedCrv: jwa.P384().String(),
},
{
name: "Ed25519 key with EdDSA",
keyGen: func() (any, error) {
_, priv, err := ed25519.GenerateKey(rand.Reader)
return priv, err
},
alg: jwa.EdDSA().String(),
crv: jwa.Ed25519().String(),
expectedAlg: jwa.EdDSA(),
expectedCrv: jwa.Ed25519().String(),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rawKey, err := tt.keyGen()
require.NoError(t, err)
key, err := jwk.Import(rawKey)
require.NoError(t, err)
// Ensure no algorithm is set initially
_, ok := key.Algorithm()
assert.False(t, ok)
// Call EnsureAlgInKey
EnsureAlgInKey(key, tt.alg, tt.crv)
// Verify the algorithm was set correctly
alg, ok := key.Algorithm()
require.True(t, ok)
assert.Equal(t, tt.expectedAlg.String(), alg.String())
// Verify curve if expected
if tt.expectedCrv != "" {
var crv any
_ = key.Get("crv", &crv)
require.NotNil(t, crv)
eca, ok := crv.(jwa.EllipticCurveAlgorithm)
require.True(t, ok)
assert.Equal(t, tt.expectedCrv, eca.String())
}
})
}
})
t.Run("set default algorithms if not present", func(t *testing.T) {
tests := []struct {
name string
keyGen func() (any, error)
expectedAlg jwa.SignatureAlgorithm
expectedCrv string
}{
{
name: "RSA key defaults to RS256",
keyGen: func() (any, error) {
return rsaKey, nil
},
expectedAlg: jwa.RS256(),
expectedCrv: "",
},
{
name: "ECDSA key defaults to ES256 with P256",
keyGen: func() (any, error) {
return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
},
expectedAlg: jwa.ES256(),
expectedCrv: jwa.P256().String(),
},
{
name: "Ed25519 key defaults to EdDSA with Ed25519",
keyGen: func() (any, error) {
_, priv, err := ed25519.GenerateKey(rand.Reader)
return priv, err
},
expectedAlg: jwa.EdDSA(),
expectedCrv: jwa.Ed25519().String(),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rawKey, err := tt.keyGen()
require.NoError(t, err)
key, err := jwk.Import(rawKey)
require.NoError(t, err)
// Ensure no algorithm is set initially
_, ok := key.Algorithm()
assert.False(t, ok)
// Call EnsureAlgInKey with empty parameters
EnsureAlgInKey(key, "", "")
// Verify the default algorithm was set
alg, ok := key.Algorithm()
require.True(t, ok)
assert.Equal(t, tt.expectedAlg.String(), alg.String())
// Verify curve if expected
if tt.expectedCrv != "" {
var crv any
_ = key.Get("crv", &crv)
require.NotNil(t, crv)
eca, ok := crv.(jwa.EllipticCurveAlgorithm)
require.True(t, ok)
assert.Equal(t, tt.expectedCrv, eca.String())
}
})
}
})
t.Run("invalid curve should not set curve parameter", func(t *testing.T) {
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
key, err := jwk.Import(rsaKey)
require.NoError(t, err)
// Call EnsureAlgInKey with invalid curve
EnsureAlgInKey(key, jwa.RS256().String(), "invalid-curve")
// Verify algorithm was set but curve was not
alg, ok := key.Algorithm()
require.True(t, ok)
assert.Equal(t, jwa.RS256().String(), alg.String())
var crv any
_ = key.Get("crv", &crv)
assert.Nil(t, crv)
})
}

View File

@@ -1,69 +0,0 @@
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

@@ -1,9 +1,8 @@
package service // This file is only imported by unit tests
package testing
import ( import (
"io"
"net/http"
"strings"
"testing" "testing"
"time" "time"
@@ -21,7 +20,10 @@ import (
"github.com/pocket-id/pocket-id/backend/resources" "github.com/pocket-id/pocket-id/backend/resources"
) )
func newDatabaseForTest(t *testing.T) *gorm.DB { // NewDatabaseForTest returns a new instance of GORM connected to an in-memory SQLite database.
// Each database connection is unique for the test.
// All migrations are automatically performed.
func NewDatabaseForTest(t *testing.T) *gorm.DB {
t.Helper() t.Helper()
// Get a name for this in-memory database that is specific to the test // Get a name for this in-memory database that is specific to the test
@@ -68,30 +70,3 @@ type testLoggerAdapter struct {
func (l testLoggerAdapter) Printf(format string, args ...any) { func (l testLoggerAdapter) Printf(format string, args ...any) {
l.t.Logf(format, args...) 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

@@ -0,0 +1,38 @@
// This file is only imported by unit tests
package testing
import (
"io"
"net/http"
"strings"
_ "github.com/golang-migrate/migrate/v4/source/file"
)
// 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),
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,4 @@
ALTER TABLE audit_logs ALTER COLUMN ip_address SET NOT NULL;
DROP INDEX IF EXISTS idx_audit_logs_created_at;
DROP INDEX IF EXISTS idx_audit_logs_user_agent;

View File

@@ -0,0 +1,5 @@
ALTER TABLE audit_logs ALTER COLUMN ip_address DROP NOT NULL;
-- Add missing indexes
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at);
CREATE INDEX idx_audit_logs_user_agent ON audit_logs(user_agent);

View File

@@ -0,0 +1 @@
DROP TABLE kv;

View File

@@ -0,0 +1,6 @@
-- The "kv" tables contains miscellaneous key-value pairs
CREATE TABLE kv
(
"key" TEXT NOT NULL PRIMARY KEY,
"value" TEXT
);

View File

@@ -0,0 +1,30 @@
-- Re-create the table with non-nullable ip_address
-- We then move the data and rename the table
CREATE TABLE audit_logs_new
(
id TEXT NOT NULL PRIMARY KEY,
created_at DATETIME,
event TEXT NOT NULL,
ip_address TEXT NOT NULL,
user_agent TEXT NOT NULL,
data BLOB NOT NULL,
user_id TEXT REFERENCES users,
country TEXT,
city TEXT
);
INSERT INTO audit_logs_new
SELECT id, created_at, event, ip_address, user_agent, data, user_id, country, city
FROM audit_logs;
DROP TABLE audit_logs;
ALTER TABLE audit_logs_new RENAME TO audit_logs;
-- Re-create indexes
CREATE INDEX idx_audit_logs_event ON audit_logs(event);
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at);
CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id);
CREATE INDEX idx_audit_logs_user_agent ON audit_logs(user_agent);
CREATE INDEX idx_audit_logs_client_name ON audit_logs((json_extract(data, '$.clientName')));
CREATE INDEX idx_audit_logs_country ON audit_logs(country);

View File

@@ -0,0 +1,30 @@
-- Re-create the table with nullable ip_address
-- We then move the data and rename the table
CREATE TABLE audit_logs_new
(
id TEXT NOT NULL PRIMARY KEY,
created_at DATETIME,
event TEXT NOT NULL,
ip_address TEXT,
user_agent TEXT NOT NULL,
data BLOB NOT NULL,
user_id TEXT REFERENCES users,
country TEXT,
city TEXT
);
INSERT INTO audit_logs_new
SELECT id, created_at, event, ip_address, user_agent, data, user_id, country, city
FROM audit_logs;
DROP TABLE audit_logs;
ALTER TABLE audit_logs_new RENAME TO audit_logs;
-- Re-create indexes
CREATE INDEX idx_audit_logs_event ON audit_logs(event);
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at);
CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id);
CREATE INDEX idx_audit_logs_user_agent ON audit_logs(user_agent);
CREATE INDEX idx_audit_logs_client_name ON audit_logs((json_extract(data, '$.clientName')));
CREATE INDEX idx_audit_logs_country ON audit_logs(country);

View File

@@ -0,0 +1 @@
DROP TABLE kv;

View File

@@ -0,0 +1,6 @@
-- The "kv" tables contains miscellaneous key-value pairs
CREATE TABLE kv
(
"key" TEXT NOT NULL PRIMARY KEY,
"value" TEXT NOT NULL
);

View File

@@ -9,7 +9,7 @@ services:
- "./data:/app/data" - "./data:/app/data"
# Optional healthcheck # Optional healthcheck
healthcheck: healthcheck:
test: "curl -f http://localhost:1411/healthz" test: [ "CMD", "/app/pocket-id", "healthcheck" ]
interval: 1m30s interval: 1m30s
timeout: 5s timeout: 5s
retries: 2 retries: 2

View File

@@ -65,7 +65,7 @@
"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_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.", "authenticate_with_passkey_to_access_account": "Pro přístup k vašemu účtu použijte přístupový klíč.",
"authenticate": "Autentizovat", "authenticate": "Autentizovat",
"please_try_again": "Prosím, zkuste znovu.", "please_try_again": "Prosím, zkuste znovu.",
"continue": "Pokračovat", "continue": "Pokračovat",
@@ -178,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": "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.", "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 bze použití přístupového klíče, 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.",
"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",
@@ -308,24 +308,25 @@
"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": "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": "Tímto odstraníte nahraný obrázek a obnovíte výchozí. Chcete pokračovat?",
"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.",
"select_the_language_you_want_to_use": "Vyberte jazyk, který chcete použít. Některé jazyky nemusí být plně přeloženy.", "select_the_language_you_want_to_use": "Vyberte jazyk, který chcete použít. Upozorňujeme, že některé texty mohou být automaticky přeloženy a mohou být nepřesné.",
"contribute_to_translation": "Pokud narazíte na nějaký problém, můžete přispět k překladu na <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
"personal": "Osobní", "personal": "Osobní",
"global": "Globální", "global": "Globální",
"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", "all_locations": "Všechna místa",
"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_ui_animations": "Turn off animations throughout the UI.", "turn_off_ui_animations": "Vypnout animace v celém uživatelském rozhraní.",
"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.",
@@ -372,51 +373,51 @@
"select_an_option": "Vyberte možnost", "select_an_option": "Vyberte možnost",
"select_user": "Vyberte uživatele", "select_user": "Vyberte uživatele",
"error": "Chyba", "error": "Chyba",
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Select an accent color to customize the appearance of Pocket ID.", "select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Vyberte barvu zvýraznění k přizpůsobení vzhledu Pocket ID.",
"accent_color": "Accent Color", "accent_color": "Barva zvýraznění",
"custom_accent_color": "Custom Accent Color", "custom_accent_color": "Vlastní zvýrazňující barva",
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).", "custom_accent_color_description": "Zadejte vlastní barvu pomocí platných CSS barev (např. hex, rgb, hsl).",
"color_value": "Color Value", "color_value": "Hodnota barvy",
"apply": "Apply", "apply": "Použít",
"signup_token": "Signup Token", "signup_token": "Registrační token",
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.", "create_a_signup_token_to_allow_new_user_registration": "Vytvořit registrační token pro povolení registrace nového uživatele.",
"usage_limit": "Usage Limit", "usage_limit": "Limit využití",
"number_of_times_token_can_be_used": "Number of times the signup token can be used.", "number_of_times_token_can_be_used": "Kolikrát lze použít registrační token.",
"expires": "Expires", "expires": "Vyprší",
"signup": "Sign Up", "signup": "Zaregistrovat se",
"signup_requires_valid_token": "A valid signup token is required to create an account", "signup_requires_valid_token": "Pro vytvoření účtu je vyžadován platný registrační token",
"validating_signup_token": "Validating signup token", "validating_signup_token": "Ověřování registračního tokenu",
"go_to_login": "Go to login", "go_to_login": "Přejít na přihlášení",
"signup_to_appname": "Sign Up to {appName}", "signup_to_appname": "Zaregistrujte se do {appName}",
"create_your_account_to_get_started": "Create your account to get started.", "create_your_account_to_get_started": "Vytvořte si svůj účet a začněte.",
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.", "initial_account_creation_description": "Vytvořte si prosím svůj účet, abyste mohli začít. Později si budete moci nastavit přístupový klíč.",
"setup_your_passkey": "Set up your passkey", "setup_your_passkey": "Nastavte svůj přístupový klíč",
"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.", "create_a_passkey_to_securely_access_your_account": "Vytvořte přístupový klíč pro bezpečný přístup k vašemu účtu. Toto bude váš hlavní způsob přihlášení.",
"skip_for_now": "Skip for now", "skip_for_now": "Prozatím přeskočit",
"account_created": "Account Created", "account_created": "Účet vytvořen",
"enable_user_signups": "Enable User Signups", "enable_user_signups": "Povolit registraci uživatelů",
"enable_user_signups_description": "Whether the User Signup functionality should be enabled.", "enable_user_signups_description": "Určuje, zda by měla být funkce registrace uživatele povolena.",
"user_signups_are_disabled": "User signups are currently disabled", "user_signups_are_disabled": "Registrace uživatelů jsou v současné době zakázány",
"create_signup_token": "Create Signup Token", "create_signup_token": "Vytvořit registrační token",
"view_active_signup_tokens": "View Active Signup Tokens", "view_active_signup_tokens": "Zobrazit aktivní registrační tokeny",
"manage_signup_tokens": "Manage Signup Tokens", "manage_signup_tokens": "Spravovat registrační tokeny",
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.", "view_and_manage_active_signup_tokens": "Zobrazit a spravovat aktivní registrační tokeny.",
"signup_token_deleted_successfully": "Signup token deleted successfully.", "signup_token_deleted_successfully": "Registrační token byl úspěšně odstraněn.",
"expired": "Expired", "expired": "Vypršel",
"used_up": "Used Up", "used_up": "Použito",
"active": "Active", "active": "Aktiv",
"usage": "Usage", "usage": "Využití",
"created": "Created", "created": "Vytvořeno",
"token": "Token", "token": "Token",
"loading": "Loading", "loading": "Načítání",
"delete_signup_token": "Delete Signup Token", "delete_signup_token": "Odstranit registrační 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.", "are_you_sure_you_want_to_delete_this_signup_token": "Jste si jisti, že chcete odstranit tento registrační token? Tuto akci nelze vrátit zpět.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.", "signup_disabled_description": "Registrace uživatelů jsou kompletně zakázány. Nové uživatelské účty mohou vytvářet pouze správci.",
"signup_with_token": "Signup with token", "signup_with_token": "Zaregistrovat se s tokenem",
"signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.", "signup_with_token_description": "Uživatelé se mohou zaregistrovat pouze pomocí platného registračního tokenu který byl vytvořen správcem.",
"signup_open": "Open Signup", "signup_open": "Otevřená registrace",
"signup_open_description": "Anyone can create a new account without restrictions.", "signup_open_description": "Kdokoli si může vytvořit nový účet bez omezení.",
"of": "of", "of": "z",
"skip_passkey_setup": "Skip Passkey Setup", "skip_passkey_setup": "Přeskočit nastavení přístupového klíče",
"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." "skip_passkey_setup_description": "Je důrazně doporučeno nastavit přístupový klíč, bez něho se nebudete moci přihlásit, jakmile aktuální relace vyprší."
} }

View File

@@ -65,7 +65,7 @@
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Vil du logge ud af {appName} med kontoen <b>{username}</b>?", "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}", "sign_in_to_appname": "Log ind på {appName}",
"please_try_to_sign_in_again": "Prøv at logge ind igen.", "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_with_passkey_to_access_account": "Bekræft din identitet med din adgangskode for at få adgang til din konto.",
"authenticate": "Bekræft identitet", "authenticate": "Bekræft identitet",
"please_try_again": "Prøv venligst igen.", "please_try_again": "Prøv venligst igen.",
"continue": "Fortsæt", "continue": "Fortsæt",
@@ -312,13 +312,14 @@
"reset": "Nulstil", "reset": "Nulstil",
"reset_to_default": "Nulstil til standard", "reset_to_default": "Nulstil til standard",
"profile_picture_has_been_reset": "Profilbilledet er blevet nulstillet. Det kan tage et par minutter at opdatere.", "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.", "select_the_language_you_want_to_use": "Vælg det sprog, du ønsker at bruge. Bemærk, at nogle tekster kan blive oversat automatisk og derfor kan være unøjagtige.",
"contribute_to_translation": "Hvis du finder et problem, er du velkommen til at bidrage til oversættelsen på <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
"personal": "Personlig", "personal": "Personlig",
"global": "Global", "global": "Global",
"all_users": "Alle brugere", "all_users": "Alle brugere",
"all_events": "Alle hændelser", "all_events": "Alle hændelser",
"all_clients": "Alle klienter", "all_clients": "Alle klienter",
"all_locations": "All Locations", "all_locations": "Alle lokationer",
"global_audit_log": "Global aktivitetslog", "global_audit_log": "Global aktivitetslog",
"see_all_account_activities_from_the_last_3_months": "Se al brugeraktivitet for de seneste 3 måneder.", "see_all_account_activities_from_the_last_3_months": "Se al brugeraktivitet for de seneste 3 måneder.",
"token_sign_in": "Token-login", "token_sign_in": "Token-login",
@@ -378,45 +379,45 @@
"custom_accent_color_description": "Indtast en brugerdefineret farve i et gyldigt CSS-format (f.eks. hex, rgb, hsl).", "custom_accent_color_description": "Indtast en brugerdefineret farve i et gyldigt CSS-format (f.eks. hex, rgb, hsl).",
"color_value": "Farveværdi", "color_value": "Farveværdi",
"apply": "Anvend", "apply": "Anvend",
"signup_token": "Signup Token", "signup_token": "Tilmeldingstoken",
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.", "create_a_signup_token_to_allow_new_user_registration": "Opret en tilmeldingstoken for at tillade registrering af nye brugere.",
"usage_limit": "Usage Limit", "usage_limit": "Brugsbegrænsning",
"number_of_times_token_can_be_used": "Number of times the signup token can be used.", "number_of_times_token_can_be_used": "Antal gange, som tilmeldingstokenet kan bruges.",
"expires": "Expires", "expires": "Udløber",
"signup": "Sign Up", "signup": "Tilmeld",
"signup_requires_valid_token": "A valid signup token is required to create an account", "signup_requires_valid_token": "Der kræves en gyldig tilmeldingstoken for at oprette en konto.",
"validating_signup_token": "Validating signup token", "validating_signup_token": "Validering af tilmeldingstoken",
"go_to_login": "Go to login", "go_to_login": "Gå til login",
"signup_to_appname": "Sign Up to {appName}", "signup_to_appname": "Tilmeld dig {appName}",
"create_your_account_to_get_started": "Create your account to get started.", "create_your_account_to_get_started": "Opret din konto for at komme i gang.",
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.", "initial_account_creation_description": "Opret din konto for at komme i gang. Du kan oprette en adgangskode senere.",
"setup_your_passkey": "Set up your passkey", "setup_your_passkey": "Opret din adgangskode",
"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.", "create_a_passkey_to_securely_access_your_account": "Opret en adgangskode for at få sikker adgang til din konto. Dette bliver din primære måde at logge ind på.",
"skip_for_now": "Skip for now", "skip_for_now": "Spring over for nu",
"account_created": "Account Created", "account_created": "Konto oprettet",
"enable_user_signups": "Enable User Signups", "enable_user_signups": "Aktiver brugerregistrering",
"enable_user_signups_description": "Whether the User Signup functionality should be enabled.", "enable_user_signups_description": "Om brugerregistreringsfunktionen skal være aktiveret.",
"user_signups_are_disabled": "User signups are currently disabled", "user_signups_are_disabled": "Brugerregistrering er i øjeblikket deaktiveret.",
"create_signup_token": "Create Signup Token", "create_signup_token": "Opret tilmeldingstoken",
"view_active_signup_tokens": "View Active Signup Tokens", "view_active_signup_tokens": "Vis aktive tilmeldingstokener",
"manage_signup_tokens": "Manage Signup Tokens", "manage_signup_tokens": "Administrer tilmeldingstokener",
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.", "view_and_manage_active_signup_tokens": "Se og administrer aktive tilmeldingstokens.",
"signup_token_deleted_successfully": "Signup token deleted successfully.", "signup_token_deleted_successfully": "Tilmeldingstoken slettet.",
"expired": "Expired", "expired": "Udløbet",
"used_up": "Used Up", "used_up": "Brugt op",
"active": "Active", "active": "Aktiv",
"usage": "Usage", "usage": "Anvendelse",
"created": "Created", "created": "Oprettet",
"token": "Token", "token": "Token",
"loading": "Loading", "loading": "Indlæsning",
"delete_signup_token": "Delete Signup Token", "delete_signup_token": "Slet tilmeldingstoken",
"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.", "are_you_sure_you_want_to_delete_this_signup_token": "Er du sikker på, at du vil slette denne tilmeldingstoken? Denne handling kan ikke fortrydes.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.", "signup_disabled_description": "Brugerregistreringer er fuldstændigt deaktiveret. Kun administratorer kan oprette nye brugerkonti.",
"signup_with_token": "Signup with token", "signup_with_token": "Tilmeld dig med token",
"signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.", "signup_with_token_description": "Brugere kan kun tilmelde sig ved hjælp af en gyldig tilmeldingstoken, der er oprettet af en administrator.",
"signup_open": "Open Signup", "signup_open": "Åben tilmelding",
"signup_open_description": "Anyone can create a new account without restrictions.", "signup_open_description": "Alle kan oprette en ny konto uden begrænsninger.",
"of": "of", "of": "af",
"skip_passkey_setup": "Skip Passkey Setup", "skip_passkey_setup": "Spring Passkey-opsætning over",
"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." "skip_passkey_setup_description": "Det anbefales stærkt at oprette en adgangskode, da du ellers bliver låst ude af din konto, så snart sessionen udløber."
} }

View File

@@ -65,7 +65,7 @@
"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_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.", "authenticate_with_passkey_to_access_account": "Melde dich mit deinem Passwort an, um auf dein Konto zuzugreifen.",
"authenticate": "Authentifizieren", "authenticate": "Authentifizieren",
"please_try_again": "Bitte versuche es noch einmal.", "please_try_again": "Bitte versuche es noch einmal.",
"continue": "Fortsetzen", "continue": "Fortsetzen",
@@ -312,7 +312,8 @@
"reset": "Zurücksetzen", "reset": "Zurücksetzen",
"reset_to_default": "Auf Standard zurücksetzen", "reset_to_default": "Auf Standard zurücksetzen",
"profile_picture_has_been_reset": "Das Profilbild wurde zurückgesetzt. Es kann einige Minuten dauern, bis es aktualisiert wird.", "profile_picture_has_been_reset": "Das Profilbild wurde zurückgesetzt. Es kann einige Minuten dauern, bis es aktualisiert wird.",
"select_the_language_you_want_to_use": "Wähle die Sprache aus, die du verwenden möchtest. Einige Sprachen sind möglicherweise nicht vollständig übersetzt.", "select_the_language_you_want_to_use": "Wähl die Sprache aus, die du benutzen willst. Bitte beachte, dass manche Texte automatisch übersetzt werden und vielleicht nicht ganz richtig sind.",
"contribute_to_translation": "Wenn du ein Problem findest, kannst du gerne bei der Übersetzung auf <link href='https://crowdin.com/project/pocket-id'>Crowdin</link> mitmachen.",
"personal": "Persönlich", "personal": "Persönlich",
"global": "Global", "global": "Global",
"all_users": "Alle Benutzer", "all_users": "Alle Benutzer",
@@ -347,9 +348,9 @@
"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",
"federated_client_credentials": "Federated Client Credentials", "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.", "federated_client_credentials_description": "Mit Hilfe von Verbund-Client-Anmeldeinformationen kannst du OIDC-Clients mit JWT-Tokens authentifizieren, die von Drittanbietern ausgestellt wurden.",
"add_federated_client_credential": "Add Federated Client Credential", "add_federated_client_credential": "Föderierte Client-Anmeldeinfos hinzufügen",
"add_another_federated_client_credential": "Add another federated client credential", "add_another_federated_client_credential": "Weitere Anmeldeinformationen für einen Verbundclient hinzufügen",
"oidc_allowed_group_count": "Erlaubte Gruppenanzahl", "oidc_allowed_group_count": "Erlaubte Gruppenanzahl",
"unrestricted": "Uneingeschränkt", "unrestricted": "Uneingeschränkt",
"show_advanced_options": "Erweiterte Optionen anzeigen", "show_advanced_options": "Erweiterte Optionen anzeigen",
@@ -372,51 +373,51 @@
"select_an_option": "Wähle eine Option", "select_an_option": "Wähle eine Option",
"select_user": "Benutzer auswählen", "select_user": "Benutzer auswählen",
"error": "Fehler", "error": "Fehler",
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Select an accent color to customize the appearance of Pocket ID.", "select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Wähl eine Akzentfarbe aus, um das Aussehen von Pocket ID anzupassen.",
"accent_color": "Akzentfarbe", "accent_color": "Akzentfarbe",
"custom_accent_color": "Benutzerdefinierte 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).", "custom_accent_color_description": "Geben Sie eine benutzerdefinierte Farbe mit gültigen CSS-Farbformaten ein (z.B. hex, rgb, hsl).",
"color_value": "Farbwert", "color_value": "Farbwert",
"apply": "Übernehmen", "apply": "Übernehmen",
"signup_token": "Signup Token", "signup_token": "Anmeldungstoken",
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.", "create_a_signup_token_to_allow_new_user_registration": "Erstell ein Anmeldetoken, damit sich neue Benutzer registrieren können.",
"usage_limit": "Usage Limit", "usage_limit": "Nutzungsbeschränkung",
"number_of_times_token_can_be_used": "Number of times the signup token can be used.", "number_of_times_token_can_be_used": "Wie oft der Anmeldetoken benutzt werden kann.",
"expires": "Expires", "expires": "Läuft ab",
"signup": "Sign Up", "signup": "Anmelden",
"signup_requires_valid_token": "A valid signup token is required to create an account", "signup_requires_valid_token": "Zum Erstellen eines Kontos brauchst du einen gültigen Anmeldetoken.",
"validating_signup_token": "Validating signup token", "validating_signup_token": "Anmeldungstoken bestätigen",
"go_to_login": "Go to login", "go_to_login": "Zum Login gehen",
"signup_to_appname": "Sign Up to {appName}", "signup_to_appname": "Melde dich bei „ {appName}“ an",
"create_your_account_to_get_started": "Create your account to get started.", "create_your_account_to_get_started": "Erstell dein Konto, um loszulegen.",
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.", "initial_account_creation_description": "Erstell dein Konto, um loszulegen. Du kannst später einen Passkey einrichten.",
"setup_your_passkey": "Set up your passkey", "setup_your_passkey": "Passwort einrichten",
"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.", "create_a_passkey_to_securely_access_your_account": "Erstell einen Passkey, um sicher auf dein Konto zuzugreifen. Das wird deine Hauptmethode zum Anmelden sein.",
"skip_for_now": "Skip for now", "skip_for_now": "Jetzt überspringen",
"account_created": "Account Created", "account_created": "Konto erstellt",
"enable_user_signups": "Enable User Signups", "enable_user_signups": "Benutzeranmeldungen aktivieren",
"enable_user_signups_description": "Whether the User Signup functionality should be enabled.", "enable_user_signups_description": "Ob die Funktion zur Benutzeranmeldung aktiviert werden soll.",
"user_signups_are_disabled": "User signups are currently disabled", "user_signups_are_disabled": "Benutzeranmeldungen sind im Moment deaktiviert.",
"create_signup_token": "Create Signup Token", "create_signup_token": "Anmeldungstoken erstellen",
"view_active_signup_tokens": "View Active Signup Tokens", "view_active_signup_tokens": "Aktive Anmeldetoken anzeigen",
"manage_signup_tokens": "Manage Signup Tokens", "manage_signup_tokens": "Anmeldungstoken verwalten",
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.", "view_and_manage_active_signup_tokens": "Aktive Anmeldetoken anzeigen und verwalten.",
"signup_token_deleted_successfully": "Signup token deleted successfully.", "signup_token_deleted_successfully": "Anmeldungstoken erfolgreich gelöscht.",
"expired": "Expired", "expired": "Abgelaufen",
"used_up": "Used Up", "used_up": "Aufgebraucht",
"active": "Active", "active": "Aktiv",
"usage": "Usage", "usage": "Verwendung",
"created": "Created", "created": "Erstellt",
"token": "Token", "token": "Token",
"loading": "Loading", "loading": "Laden",
"delete_signup_token": "Delete Signup Token", "delete_signup_token": "Anmeldungstoken löschen",
"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.", "are_you_sure_you_want_to_delete_this_signup_token": "Willst du diesen Anmeldetoken wirklich löschen? Das kannst du nicht rückgängig machen.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.", "signup_disabled_description": "Benutzeranmeldungen sind komplett deaktiviert. Nur Admins können neue Benutzerkonten erstellen.",
"signup_with_token": "Signup with token", "signup_with_token": "Mit Token anmelden",
"signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.", "signup_with_token_description": "Benutzer können sich nur mit einem gültigen Anmeldetoken anmelden, das von einem Administrator erstellt wurde.",
"signup_open": "Open Signup", "signup_open": "Anmeldung offen",
"signup_open_description": "Anyone can create a new account without restrictions.", "signup_open_description": "Jeder kann ohne Einschränkungen ein neues Konto erstellen.",
"of": "of", "of": "von",
"skip_passkey_setup": "Skip Passkey Setup", "skip_passkey_setup": "Passwort-Einrichtung überspringen",
"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." "skip_passkey_setup_description": "Es wird dringend empfohlen, einen Passkey einzurichten, da du sonst nach Ablauf der Sitzung aus deinem Konto ausgesperrt wirst."
} }

View File

@@ -312,7 +312,8 @@
"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.",
"select_the_language_you_want_to_use": "Select the language you want to use. Some languages may not be fully translated.", "select_the_language_you_want_to_use": "Select the language you want to use. Please note that some text may be automatically translated and could be inaccurate.",
"contribute_to_translation": "If you find an issue you're welcome to contribute to the translation on <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
"personal": "Personal", "personal": "Personal",
"global": "Global", "global": "Global",
"all_users": "All Users", "all_users": "All Users",

View File

@@ -3,7 +3,7 @@
"my_account": "Mi Cuenta", "my_account": "Mi Cuenta",
"logout": "Cerrar sesión", "logout": "Cerrar sesión",
"confirm": "Confirmar", "confirm": "Confirmar",
"docs": "Docs", "docs": "Documentos",
"key": "Clave", "key": "Clave",
"value": "Valor", "value": "Valor",
"remove_custom_claim": "Eliminar reclamo personalizado", "remove_custom_claim": "Eliminar reclamo personalizado",
@@ -25,7 +25,7 @@
"go_back_to_home": "Volver al Inicio", "go_back_to_home": "Volver al Inicio",
"dont_have_access_to_your_passkey": "¿No tiene acceso a su Passkey?", "dont_have_access_to_your_passkey": "¿No tiene acceso a su Passkey?",
"login_background": "Fondo de página de acceso", "login_background": "Fondo de página de acceso",
"logo": "Logo", "logo": "Logotipo",
"login_code": "Código de inicio de sesión", "login_code": "Código de inicio de sesión",
"create_a_login_code_to_sign_in_without_a_passkey_once": "Crear un código de acceso que el usuario pueda utilizar para iniciar sesión sin un Passkey una vez.", "create_a_login_code_to_sign_in_without_a_passkey_once": "Crear un código de acceso que el usuario pueda utilizar para iniciar sesión sin un Passkey una vez.",
"one_hour": "1 hora", "one_hour": "1 hora",
@@ -65,7 +65,7 @@
"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_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.", "authenticate_with_passkey_to_access_account": "Autentifíquese con su clave de acceso para acceder a su cuenta.",
"authenticate": "Autenticar", "authenticate": "Autenticar",
"please_try_again": "Por favor intente nuevamente.", "please_try_again": "Por favor intente nuevamente.",
"continue": "Continuar", "continue": "Continuar",
@@ -137,7 +137,7 @@
"api_key_created": "API Key creada", "api_key_created": "API Key creada",
"for_security_reasons_this_key_will_only_be_shown_once": "Por razones de seguridad, esta clave sólo se mostrará una vez. Por favor, guárdala de forma segura.", "for_security_reasons_this_key_will_only_be_shown_once": "Por razones de seguridad, esta clave sólo se mostrará una vez. Por favor, guárdala de forma segura.",
"description": "Descripción", "description": "Descripción",
"api_key": "API Key", "api_key": "Clave API",
"close": "Cerrar", "close": "Cerrar",
"name_to_identify_this_api_key": "Nombra esta API Key para identificarla.", "name_to_identify_this_api_key": "Nombra esta API Key para identificarla.",
"expires_at": "Expira el", "expires_at": "Expira el",
@@ -169,254 +169,255 @@
"smtp_port": "Puerto SMTP", "smtp_port": "Puerto SMTP",
"smtp_user": "Usuario SMTP", "smtp_user": "Usuario SMTP",
"smtp_password": "Contraseña SMTP", "smtp_password": "Contraseña SMTP",
"smtp_from": "SMTP From", "smtp_from": "SMTP Desde",
"smtp_tls_option": "SMTP TLS Option", "smtp_tls_option": "Opción SMTP TLS",
"email_tls_option": "Email TLS Option", "email_tls_option": "Opción TLS para correo electrónico",
"skip_certificate_verification": "Omitir la verificación del certificado", "skip_certificate_verification": "Omitir la verificación del certificado",
"this_can_be_useful_for_selfsigned_certificates": "Esto puede ser útil para certificados autofirmados.", "this_can_be_useful_for_selfsigned_certificates": "Esto puede ser útil para certificados autofirmados.",
"enabled_emails": "Enabled Emails", "enabled_emails": "Correos electrónicos habilitados",
"email_login_notification": "Email Login Notification", "email_login_notification": "Notificación de inicio de sesión por correo electrónico",
"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": "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.", "allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Permite a los usuarios omitir las claves de acceso solicitando un código de inicio de sesión que se envía a su correo electrónico. Esto reduce significativamente la seguridad, ya que cualquier persona con acceso al correo electrónico del usuario puede obtener acceso.",
"email_login_code_from_admin": "Email Login Code from Admin", "email_login_code_from_admin": "Código de inicio de sesión por correo electrónico del administrador",
"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",
"application_configuration_updated_successfully": "Configuración actualizada correctamente", "application_configuration_updated_successfully": "Configuración actualizada correctamente",
"application_name": "Nombre de la aplicación", "application_name": "Nombre de la aplicación",
"session_duration": "Duración de la sesión", "session_duration": "Duración de la sesión",
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "La duración de una sesión en minutos antes de que el usuario tenga que iniciar sesión de nuevo.", "the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "La duración de una sesión en minutos antes de que el usuario tenga que iniciar sesión de nuevo.",
"enable_self_account_editing": "Enable Self-Account Editing", "enable_self_account_editing": "Habilitar la edición de la cuenta personal",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Si los usuarios deberían poder editar los detalles de su propia cuenta.", "whether_the_users_should_be_able_to_edit_their_own_account_details": "Si los usuarios deberían poder editar los detalles de su propia cuenta.",
"emails_verified": "Emails Verified", "emails_verified": "Correos electrónicos verificados",
"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.", "whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Si el correo electrónico del usuario debe marcarse como verificado para los clientes OIDC.",
"ldap_configuration_updated_successfully": "LDAP configuration updated successfully", "ldap_configuration_updated_successfully": "Configuración LDAP actualizada correctamente",
"ldap_disabled_successfully": "LDAP disabled successfully", "ldap_disabled_successfully": "LDAP desactivado correctamente",
"ldap_sync_finished": "LDAP sync finished", "ldap_sync_finished": "Sincronización LDAP finalizada",
"client_configuration": "Client Configuration", "client_configuration": "Configuración del cliente",
"ldap_url": "LDAP URL", "ldap_url": "URL LDAP",
"ldap_bind_dn": "LDAP Bind DN", "ldap_bind_dn": "DN de enlace LDAP",
"ldap_bind_password": "LDAP Bind Password", "ldap_bind_password": "Contraseña de enlace LDAP",
"ldap_base_dn": "LDAP Base DN", "ldap_base_dn": "DN base LDAP",
"user_search_filter": "User Search Filter", "user_search_filter": "Filtro de búsqueda de usuarios",
"the_search_filter_to_use_to_search_or_sync_users": "The Search filter to use to search/sync users.", "the_search_filter_to_use_to_search_or_sync_users": "El filtro de búsqueda que se utilizará para buscar/sincronizar usuarios.",
"groups_search_filter": "Groups Search Filter", "groups_search_filter": "Filtro de búsqueda de grupos",
"the_search_filter_to_use_to_search_or_sync_groups": "The Search filter to use to search/sync groups.", "the_search_filter_to_use_to_search_or_sync_groups": "El filtro de búsqueda que se utilizará para buscar/sincronizar grupos.",
"attribute_mapping": "Attribute Mapping", "attribute_mapping": "Asignación de atributos",
"user_unique_identifier_attribute": "User Unique Identifier Attribute", "user_unique_identifier_attribute": "Atributo identificador único de usuario",
"the_value_of_this_attribute_should_never_change": "The value of this attribute should never change.", "the_value_of_this_attribute_should_never_change": "El valor de este atributo nunca debe cambiar.",
"username_attribute": "Atributo Nombre de usuario", "username_attribute": "Atributo Nombre de usuario",
"user_mail_attribute": "Atributo de Correo de Usuario", "user_mail_attribute": "Atributo de Correo de Usuario",
"user_first_name_attribute": "User First Name Attribute", "user_first_name_attribute": "Atributo «Nombre de usuario»",
"user_last_name_attribute": "User Last Name Attribute", "user_last_name_attribute": "Atributo de apellido del usuario",
"user_profile_picture_attribute": "User Profile Picture Attribute", "user_profile_picture_attribute": "Atributo de la imagen del perfil del usuario",
"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.", "the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "El valor de este atributo puede ser una URL, un archivo binario o una imagen codificada en base64.",
"group_members_attribute": "Group Members Attribute", "group_members_attribute": "Atributo de los miembros del grupo",
"the_attribute_to_use_for_querying_members_of_a_group": "The attribute to use for querying members of a group.", "the_attribute_to_use_for_querying_members_of_a_group": "El atributo que se utilizará para consultar los miembros de un grupo.",
"group_unique_identifier_attribute": "Group Unique Identifier Attribute", "group_unique_identifier_attribute": "Atributo identificador único de grupo",
"group_name_attribute": "Group Name Attribute", "group_name_attribute": "Atributo de nombre de grupo",
"admin_group_name": "Admin Group Name", "admin_group_name": "Nombre del grupo de administración",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Members of this group will have Admin Privileges in Pocket ID.", "members_of_this_group_will_have_admin_privileges_in_pocketid": "Los miembros de este grupo tendrán privilegios de administrador en Pocket ID.",
"disable": "Disable", "disable": "Desactivar",
"sync_now": "Sync now", "sync_now": "Sincronizar ahora",
"enable": "Enable", "enable": "Habilitar",
"user_created_successfully": "User created successfully", "user_created_successfully": "Usuario creado correctamente",
"create_user": "Create User", "create_user": "Crear usuario",
"add_a_new_user_to_appname": "Add a new user to {appName}", "add_a_new_user_to_appname": "Añade un nuevo usuario a {appName}",
"add_user": "Add User", "add_user": "Añadir usuario",
"manage_users": "Manage Users", "manage_users": "Administrar usuarios",
"admin_privileges": "Admin Privileges", "admin_privileges": "Privilegios de administrador",
"admins_have_full_access_to_the_admin_panel": "Admins have full access to the admin panel.", "admins_have_full_access_to_the_admin_panel": "Los administradores tienen acceso completo al panel de administración.",
"delete_firstname_lastname": "Delete {firstName} {lastName}", "delete_firstname_lastname": "Eliminar {firstName} {lastName}",
"are_you_sure_you_want_to_delete_this_user": "Are you sure you want to delete this user?", "are_you_sure_you_want_to_delete_this_user": "¿Estás seguro de que deseas eliminar este usuario?",
"user_deleted_successfully": "User deleted successfully", "user_deleted_successfully": "Usuario eliminado correctamente",
"role": "Role", "role": "Función",
"source": "Source", "source": "Fuente",
"admin": "Admin", "admin": "Administrador",
"user": "User", "user": "Usuario",
"local": "Local", "local": "Local",
"toggle_menu": "Toggle menu", "toggle_menu": "Menú desplegable",
"edit": "Edit", "edit": "Editar",
"user_groups_updated_successfully": "User groups updated successfully", "user_groups_updated_successfully": "Grupos de usuarios actualizados correctamente",
"user_updated_successfully": "User updated successfully", "user_updated_successfully": "Usuario actualizado correctamente",
"custom_claims_updated_successfully": "Custom claims updated successfully", "custom_claims_updated_successfully": "Reclamaciones personalizadas actualizadas correctamente",
"back": "Back", "back": "Atrás",
"user_details_firstname_lastname": "User Details {firstName} {lastName}", "user_details_firstname_lastname": "Detalles del usuario {firstName} {lastName}",
"manage_which_groups_this_user_belongs_to": "Manage which groups this user belongs to.", "manage_which_groups_this_user_belongs_to": "Gestiona los grupos a los que pertenece este usuario.",
"custom_claims": "Custom Claims", "custom_claims": "Reclamaciones personalizadas",
"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.", "custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Las reclamaciones personalizadas son pares clave-valor que se pueden utilizar para almacenar información adicional sobre un usuario. Estas reclamaciones se incluirán en el token de identificación si se solicita el ámbito «perfil».",
"user_group_created_successfully": "User group created successfully", "user_group_created_successfully": "Grupo de usuarios creado correctamente",
"create_user_group": "Create User Group", "create_user_group": "Crear grupo de usuarios",
"create_a_new_group_that_can_be_assigned_to_users": "Create a new group that can be assigned to users.", "create_a_new_group_that_can_be_assigned_to_users": "Crea un nuevo grupo que se pueda asignar a los usuarios.",
"add_group": "Add Group", "add_group": "Añadir grupo",
"manage_user_groups": "Manage User Groups", "manage_user_groups": "Gestionar grupos de usuarios",
"friendly_name": "Friendly Name", "friendly_name": "Nombre descriptivo",
"name_that_will_be_displayed_in_the_ui": "Name that will be displayed in the UI", "name_that_will_be_displayed_in_the_ui": "Nombre que se mostrará en la interfaz de usuario.",
"name_that_will_be_in_the_groups_claim": "Name that will be in the \"groups\" claim", "name_that_will_be_in_the_groups_claim": "Nombre que aparecerá en la reclamación «grupos».",
"delete_name": "Delete {name}", "delete_name": "Eliminar {name}",
"are_you_sure_you_want_to_delete_this_user_group": "Are you sure you want to delete this user group?", "are_you_sure_you_want_to_delete_this_user_group": "¿Estás seguro de que deseas eliminar este grupo de usuarios?",
"user_group_deleted_successfully": "User group deleted successfully", "user_group_deleted_successfully": "Grupo de usuarios eliminado correctamente",
"user_count": "User Count", "user_count": "Número de usuarios",
"user_group_updated_successfully": "User group updated successfully", "user_group_updated_successfully": "Grupo de usuarios actualizado correctamente",
"users_updated_successfully": "Users updated successfully", "users_updated_successfully": "Usuarios actualizados correctamente",
"user_group_details_name": "User Group Details {name}", "user_group_details_name": "Detalles del grupo de usuarios {name}",
"assign_users_to_this_group": "Assign users to this group.", "assign_users_to_this_group": "Asigna usuarios a este grupo.",
"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.", "custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Las reclamaciones personalizadas son pares clave-valor que se pueden utilizar para almacenar información adicional sobre un usuario. Estas reclamaciones se incluirán en el token de identificación si se solicita el ámbito «perfil». Las reclamaciones personalizadas definidas en el usuario tendrán prioridad si hay conflictos.",
"oidc_client_created_successfully": "OIDC client created successfully", "oidc_client_created_successfully": "Cliente OIDC creado correctamente",
"create_oidc_client": "Create OIDC Client", "create_oidc_client": "Crear cliente OIDC",
"add_a_new_oidc_client_to_appname": "Add a new OIDC client to {appName}.", "add_a_new_oidc_client_to_appname": "Añade un nuevo cliente OIDC a {appName}.",
"add_oidc_client": "Add OIDC Client", "add_oidc_client": "Añadir cliente OIDC",
"manage_oidc_clients": "Manage OIDC Clients", "manage_oidc_clients": "Gestionar clientes OIDC",
"one_time_link": "One Time Link", "one_time_link": "Enlace único",
"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": "Utiliza este enlace para iniciar sesión una vez. Esto es necesario para los usuarios que aún no han añadido una clave de acceso o la han perdido.",
"add": "Add", "add": "Añadir",
"callback_urls": "Callback URLs", "callback_urls": "URL de devolución de llamada",
"logout_callback_urls": "Logout Callback URLs", "logout_callback_urls": "URL de devolución de llamada al cerrar sesión",
"public_client": "Public Client", "public_client": "Cliente público",
"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.", "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", "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.", "public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "El intercambio de claves públicas es una función de seguridad que evita los ataques CSRF y la interceptación de códigos de autorización.",
"name_logo": "{name} logo", "name_logo": "{name} logotipo",
"change_logo": "Change Logo", "change_logo": "Cambiar logotipo",
"upload_logo": "Subir Logo", "upload_logo": "Subir Logo",
"remove_logo": "Remove Logo", "remove_logo": "Eliminar logotipo",
"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": "¿Estás seguro de que deseas eliminar este cliente OIDC?",
"oidc_client_deleted_successfully": "OIDC client deleted successfully", "oidc_client_deleted_successfully": "Cliente OIDC eliminado correctamente",
"authorization_url": "Authorization URL", "authorization_url": "URL de autorización",
"oidc_discovery_url": "OIDC Discovery URL", "oidc_discovery_url": "URL de descubrimiento de OIDC",
"token_url": "Token URL", "token_url": "URL del token",
"userinfo_url": "Userinfo URL", "userinfo_url": "URL de información del usuario",
"logout_url": "Logout URL", "logout_url": "URL de cierre de sesión",
"certificate_url": "Certificate URL", "certificate_url": "URL del certificado",
"enabled": "Enabled", "enabled": "Habilitado",
"disabled": "Disabled", "disabled": "Discapacitado",
"oidc_client_updated_successfully": "OIDC client updated successfully", "oidc_client_updated_successfully": "Cliente OIDC actualizado correctamente",
"create_new_client_secret": "Create new client secret", "create_new_client_secret": "Crear nuevo secreto de cliente",
"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.", "are_you_sure_you_want_to_create_a_new_client_secret": "¿Estás seguro de que deseas crear un nuevo secreto de cliente? El antiguo quedará invalidado.",
"generate": "Generate", "generate": "Generar",
"new_client_secret_created_successfully": "New client secret created successfully", "new_client_secret_created_successfully": "Se ha creado correctamente un nuevo secreto de cliente.",
"allowed_user_groups_updated_successfully": "Allowed user groups updated successfully", "allowed_user_groups_updated_successfully": "Grupos de usuarios permitidos actualizados correctamente",
"oidc_client_name": "OIDC Client {name}", "oidc_client_name": "Cliente OIDC {name}",
"client_id": "Client ID", "client_id": "ID de cliente",
"client_secret": "Client secret", "client_secret": "Secreto del cliente",
"show_more_details": "Show more details", "show_more_details": "Mostrar más detalles",
"allowed_user_groups": "Allowed User Groups", "allowed_user_groups": "Grupos de usuarios permitidos",
"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": "Añade grupos de usuarios a este cliente para restringir el acceso a los usuarios de estos grupos. Si no se selecciona ningún grupo de usuarios, todos los usuarios tendrán acceso a este cliente.",
"favicon": "Favicon", "favicon": "Favicon",
"light_mode_logo": "Logo del modo Claro", "light_mode_logo": "Logo del modo Claro",
"dark_mode_logo": "Dark Mode Logo", "dark_mode_logo": "Logotipo en modo oscuro",
"background_image": "Background Image", "background_image": "Imagen de fondo",
"language": "Language", "language": "Idioma",
"reset_profile_picture_question": "Reset profile picture?", "reset_profile_picture_question": "¿Restablecer foto de perfil?",
"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": "Esto eliminará la imagen subida y restablecerá la imagen de perfil predeterminada. ¿Quieres continuar?",
"reset": "Reset", "reset": "Restablecer",
"reset_to_default": "Reset to default", "reset_to_default": "Restablecer valores predeterminados",
"profile_picture_has_been_reset": "Profile picture has been reset. It may take a few minutes to update.", "profile_picture_has_been_reset": "Se ha restablecido la foto de perfil. La actualización puede tardar unos minutos.",
"select_the_language_you_want_to_use": "Select the language you want to use. Some languages may not be fully translated.", "select_the_language_you_want_to_use": "Selecciona el idioma que deseas utilizar. Ten en cuenta que algunos textos pueden traducirse automáticamente y pueden contener imprecisiones.",
"contribute_to_translation": "Si encuentras un problema, puedes contribuir a la traducción en <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
"personal": "Personal", "personal": "Personal",
"global": "Global", "global": "Global",
"all_users": "All Users", "all_users": "Todos los usuarios",
"all_events": "All Events", "all_events": "Todos los eventos",
"all_clients": "All Clients", "all_clients": "Todos los clientes",
"all_locations": "All Locations", "all_locations": "Todas las ubicaciones",
"global_audit_log": "Global Audit Log", "global_audit_log": "Registro de auditoría global",
"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": "Ver toda la actividad de los usuarios durante los últimos 3 meses.",
"token_sign_in": "Token Sign In", "token_sign_in": "Inicio de sesión con token",
"client_authorization": "Client Authorization", "client_authorization": "Autorización del cliente",
"new_client_authorization": "New Client Authorization", "new_client_authorization": "Autorización de nuevo cliente",
"disable_animations": "Disable Animations", "disable_animations": "Desactivar animaciones",
"turn_off_ui_animations": "Turn off animations throughout the UI.", "turn_off_ui_animations": "Desactiva las animaciones en toda la interfaz de usuario.",
"user_disabled": "Account Disabled", "user_disabled": "Cuenta desactivada",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.", "disabled_users_cannot_log_in_or_use_services": "Los usuarios con discapacidad no pueden iniciar sesión ni utilizar los servicios.",
"user_disabled_successfully": "User has been disabled successfully.", "user_disabled_successfully": "El usuario ha sido desactivado correctamente.",
"user_enabled_successfully": "User has been enabled successfully.", "user_enabled_successfully": "El usuario se ha habilitado correctamente.",
"status": "Status", "status": "Estado",
"disable_firstname_lastname": "Disable {firstName} {lastName}", "disable_firstname_lastname": "Desactivar {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.", "are_you_sure_you_want_to_disable_this_user": "¿Estás seguro de que deseas desactivar este usuario? No podrá iniciar sesión ni acceder a ningún servicio.",
"ldap_soft_delete_users": "Keep disabled users from LDAP.", "ldap_soft_delete_users": "Impide que los usuarios deshabilitados accedan a LDAP.",
"ldap_soft_delete_users_description": "When enabled, users removed from LDAP will be disabled rather than deleted from the system.", "ldap_soft_delete_users_description": "Cuando está habilitada, los usuarios eliminados de LDAP se desactivarán en lugar de eliminarse del sistema.",
"login_code_email_success": "The login code has been sent to the user.", "login_code_email_success": "El código de inicio de sesión se ha enviado al usuario.",
"send_email": "Send Email", "send_email": "Enviar correo electrónico",
"show_code": "Show Code", "show_code": "Mostrar código",
"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.", "callback_url_description": "URL proporcionadas por tu cliente. Se añadirán automáticamente si se dejan en blanco. Se admiten comodines (*), pero es mejor evitarlos por motivos de seguridad.",
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.", "logout_callback_url_description": "URL proporcionadas por tu cliente para cerrar sesión. Se admiten comodines (*), pero es mejor evitarlos para mayor seguridad.",
"api_key_expiration": "API Key Expiration", "api_key_expiration": "Caducidad de la clave API",
"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": "Envía un correo electrónico al usuario cuando tu clave API esté a punto de caducar.",
"authorize_device": "Authorize Device", "authorize_device": "Autorizar dispositivo",
"the_device_has_been_authorized": "The device has been authorized.", "the_device_has_been_authorized": "El dispositivo ha sido autorizado.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.", "enter_code_displayed_in_previous_step": "Introduce el código que se mostró en el paso anterior.",
"authorize": "Authorize", "authorize": "Autorizar",
"federated_client_credentials": "Federated Client Credentials", "federated_client_credentials": "Credenciales de cliente federadas",
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.", "federated_client_credentials_description": "Mediante credenciales de cliente federadas, puedes autenticar clientes OIDC utilizando tokens JWT emitidos por autoridades de terceros.",
"add_federated_client_credential": "Add Federated Client Credential", "add_federated_client_credential": "Añadir credenciales de cliente federado",
"add_another_federated_client_credential": "Add another federated client credential", "add_another_federated_client_credential": "Añadir otra credencial de cliente federado",
"oidc_allowed_group_count": "Allowed Group Count", "oidc_allowed_group_count": "Recuento de grupos permitidos",
"unrestricted": "Unrestricted", "unrestricted": "Sin restricciones",
"show_advanced_options": "Show Advanced Options", "show_advanced_options": "Mostrar opciones avanzadas",
"hide_advanced_options": "Hide Advanced Options", "hide_advanced_options": "Ocultar opciones avanzadas",
"oidc_data_preview": "OIDC Data Preview", "oidc_data_preview": "Vista previa de datos OIDC",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users", "preview_the_oidc_data_that_would_be_sent_for_different_users": "Previsualiza los datos OIDC que se enviarían para diferentes usuarios.",
"id_token": "ID Token", "id_token": "Token de identificación",
"access_token": "Access Token", "access_token": "Token de acceso",
"userinfo": "Userinfo", "userinfo": "Información del usuario",
"id_token_payload": "ID Token Payload", "id_token_payload": "Carga útil del token de identificación",
"access_token_payload": "Access Token Payload", "access_token_payload": "Carga útil del token de acceso",
"userinfo_endpoint_response": "Userinfo Endpoint Response", "userinfo_endpoint_response": "Respuesta del punto final de información del usuario",
"copy": "Copy", "copy": "Copia",
"no_preview_data_available": "No preview data available", "no_preview_data_available": "No hay datos de vista previa disponibles.",
"copy_all": "Copy All", "copy_all": "Copiar todo",
"preview": "Preview", "preview": "Vista previa",
"preview_for_user": "Preview for {name} ({email})", "preview_for_user": "Vista previa de « {name} » ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user", "preview_the_oidc_data_that_would_be_sent_for_this_user": "Previsualiza los datos OIDC que se enviarían para este usuario.",
"show": "Show", "show": "Mostrar",
"select_an_option": "Select an option", "select_an_option": "Selecciona una opción",
"select_user": "Select User", "select_user": "Seleccionar usuario",
"error": "Error", "error": "Error",
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Select an accent color to customize the appearance of Pocket ID.", "select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Selecciona un color de acento para personalizar la apariencia de Pocket ID.",
"accent_color": "Accent Color", "accent_color": "Color de acento",
"custom_accent_color": "Custom Accent Color", "custom_accent_color": "Color de acento personalizado",
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).", "custom_accent_color_description": "Introduce un color personalizado utilizando formatos de color CSS válidos (por ejemplo, hex, rgb, hsl).",
"color_value": "Color Value", "color_value": "Valor del color",
"apply": "Apply", "apply": "Aplicar",
"signup_token": "Signup Token", "signup_token": "Token de registro",
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.", "create_a_signup_token_to_allow_new_user_registration": "Crea un token de registro para permitir el registro de nuevos usuarios.",
"usage_limit": "Usage Limit", "usage_limit": "Límite de uso",
"number_of_times_token_can_be_used": "Number of times the signup token can be used.", "number_of_times_token_can_be_used": "Número de veces que se puede utilizar el token de registro.",
"expires": "Expires", "expires": "Caduca",
"signup": "Sign Up", "signup": "Regístrate",
"signup_requires_valid_token": "A valid signup token is required to create an account", "signup_requires_valid_token": "Se requiere un token de registro válido para crear una cuenta.",
"validating_signup_token": "Validating signup token", "validating_signup_token": "Validación del token de registro",
"go_to_login": "Go to login", "go_to_login": "Ir al inicio de sesión",
"signup_to_appname": "Sign Up to {appName}", "signup_to_appname": "Regístrate en {appName}",
"create_your_account_to_get_started": "Create your account to get started.", "create_your_account_to_get_started": "Crea tu cuenta para empezar.",
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.", "initial_account_creation_description": "Crea tu cuenta para empezar. Podrás configurar una contraseña más adelante.",
"setup_your_passkey": "Set up your passkey", "setup_your_passkey": "Configura tu clave maestra",
"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.", "create_a_passkey_to_securely_access_your_account": "Crea una contraseña maestra para acceder de forma segura a tu cuenta. Esta será tu forma principal de iniciar sesión.",
"skip_for_now": "Skip for now", "skip_for_now": "Saltar por ahora",
"account_created": "Account Created", "account_created": "Cuenta creada",
"enable_user_signups": "Enable User Signups", "enable_user_signups": "Habilitar registros de usuarios",
"enable_user_signups_description": "Whether the User Signup functionality should be enabled.", "enable_user_signups_description": "Si se debe habilitar la función de registro de usuarios.",
"user_signups_are_disabled": "User signups are currently disabled", "user_signups_are_disabled": "El registro de usuarios está desactivado actualmente.",
"create_signup_token": "Create Signup Token", "create_signup_token": "Crear token de registro",
"view_active_signup_tokens": "View Active Signup Tokens", "view_active_signup_tokens": "Ver tokens de registro activos",
"manage_signup_tokens": "Manage Signup Tokens", "manage_signup_tokens": "Gestionar tokens de registro",
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.", "view_and_manage_active_signup_tokens": "Ver y gestionar los tokens de registro activos.",
"signup_token_deleted_successfully": "Signup token deleted successfully.", "signup_token_deleted_successfully": "Token de registro eliminado correctamente.",
"expired": "Expired", "expired": "Caducado",
"used_up": "Used Up", "used_up": "Agotado",
"active": "Active", "active": "Activo",
"usage": "Usage", "usage": "Uso",
"created": "Created", "created": "Creado",
"token": "Token", "token": "Token",
"loading": "Loading", "loading": "Cargando",
"delete_signup_token": "Delete Signup Token", "delete_signup_token": "Eliminar token de registro",
"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.", "are_you_sure_you_want_to_delete_this_signup_token": "¿Estás seguro de que deseas eliminar este token de registro? Esta acción no se puede deshacer.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.", "signup_disabled_description": "El registro de usuarios está completamente desactivado. Solo los administradores pueden crear nuevas cuentas de usuario.",
"signup_with_token": "Signup with token", "signup_with_token": "Regístrate con token",
"signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.", "signup_with_token_description": "Los usuarios solo pueden registrarse utilizando un token de registro válido creado por un administrador.",
"signup_open": "Open Signup", "signup_open": "Inscripción abierta",
"signup_open_description": "Anyone can create a new account without restrictions.", "signup_open_description": "Cualquiera puede crear una nueva cuenta sin restricciones.",
"of": "of", "of": "de",
"skip_passkey_setup": "Skip Passkey Setup", "skip_passkey_setup": "Omitir la configuración de la clave de acceso",
"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." "skip_passkey_setup_description": "Es muy recomendable configurar una contraseña maestra, ya que sin ella no podrás acceder a tu cuenta una vez que expire la sesión."
} }

View File

@@ -6,8 +6,8 @@
"docs": "Documentation", "docs": "Documentation",
"key": "Clé", "key": "Clé",
"value": "Valeur", "value": "Valeur",
"remove_custom_claim": "Remove custom claim", "remove_custom_claim": "Supprimer la revendication personnalisée",
"add_custom_claim": "Add custom claim", "add_custom_claim": "Ajouter une revendication personnalisée",
"add_another": "Ajouter un autre", "add_another": "Ajouter un autre",
"select_a_date": "Sélectionner une date", "select_a_date": "Sélectionner une date",
"select_file": "Sélectionner un fichier", "select_file": "Sélectionner un fichier",
@@ -65,7 +65,7 @@
"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_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.", "authenticate_with_passkey_to_access_account": "Authentifiez-vous avec votre clé d'accès pour accéder à votre compte.",
"authenticate": "S'authentifier", "authenticate": "S'authentifier",
"please_try_again": "Veuillez réessayer.", "please_try_again": "Veuillez réessayer.",
"continue": "Continuer", "continue": "Continuer",
@@ -195,9 +195,9 @@
"ldap_sync_finished": "Synchronisation LDAP terminée", "ldap_sync_finished": "Synchronisation LDAP terminée",
"client_configuration": "Configuration du client", "client_configuration": "Configuration du client",
"ldap_url": "URL du serveur LDAP", "ldap_url": "URL du serveur LDAP",
"ldap_bind_dn": "LDAP Bind DN", "ldap_bind_dn": "Nom d'identification LDAP",
"ldap_bind_password": "Attribuer un mot de passe LDAP", "ldap_bind_password": "Attribuer un mot de passe LDAP",
"ldap_base_dn": "LDAP Base DN", "ldap_base_dn": "DN de base LDAP",
"user_search_filter": "Filtre de recherche utilisateur", "user_search_filter": "Filtre de recherche utilisateur",
"the_search_filter_to_use_to_search_or_sync_users": "Le filtre de recherche à utiliser pour rechercher/synchroniser les utilisateurs.", "the_search_filter_to_use_to_search_or_sync_users": "Le filtre de recherche à utiliser pour rechercher/synchroniser les utilisateurs.",
"groups_search_filter": "Filtre de recherche de groupes", "groups_search_filter": "Filtre de recherche de groupes",
@@ -213,7 +213,7 @@
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "La valeur de cet attribut peut être une URL, un binaire ou une image encodée en base64.", "the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "La valeur de cet attribut peut être une URL, un binaire ou une image encodée en base64.",
"group_members_attribute": "Attribut des membres du groupe", "group_members_attribute": "Attribut des membres du groupe",
"the_attribute_to_use_for_querying_members_of_a_group": "L'attribut à utiliser pour interroger les membres d'un groupe.", "the_attribute_to_use_for_querying_members_of_a_group": "L'attribut à utiliser pour interroger les membres d'un groupe.",
"group_unique_identifier_attribute": "Group Unique Identifier Attribute", "group_unique_identifier_attribute": "Attribut d'identifiant unique de groupe",
"group_name_attribute": "Attribut de nom de groupe", "group_name_attribute": "Attribut de nom de groupe",
"admin_group_name": "Nom du groupe administrateur", "admin_group_name": "Nom du groupe administrateur",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Les membres de ce groupe auront des privilèges d'administrateur dans Pocket ID.", "members_of_this_group_will_have_admin_privileges_in_pocketid": "Les membres de ce groupe auront des privilèges d'administrateur dans Pocket ID.",
@@ -239,7 +239,7 @@
"edit": "Modifier", "edit": "Modifier",
"user_groups_updated_successfully": "Groupes d'utilisateurs mis à jour avec succès", "user_groups_updated_successfully": "Groupes d'utilisateurs mis à jour avec succès",
"user_updated_successfully": "Utilisateur mis à jour avec succès", "user_updated_successfully": "Utilisateur mis à jour avec succès",
"custom_claims_updated_successfully": "Custom claims updated successfully", "custom_claims_updated_successfully": "Les réclamations personnalisées ont été mises à jour avec succès.",
"back": "Retour", "back": "Retour",
"user_details_firstname_lastname": "Détails de l'utilisateur {firstName} {lastName}", "user_details_firstname_lastname": "Détails de l'utilisateur {firstName} {lastName}",
"manage_which_groups_this_user_belongs_to": "Gérer les groupes auxquels cet utilisateur appartient.", "manage_which_groups_this_user_belongs_to": "Gérer les groupes auxquels cet utilisateur appartient.",
@@ -298,7 +298,7 @@
"allowed_user_groups_updated_successfully": "Groupes d'utilisateurs autorisés mis à jour avec succès", "allowed_user_groups_updated_successfully": "Groupes d'utilisateurs autorisés mis à jour avec succès",
"oidc_client_name": "Client OIDC {name}", "oidc_client_name": "Client OIDC {name}",
"client_id": "ID du client", "client_id": "ID du client",
"client_secret": "Client secret", "client_secret": "Secret client",
"show_more_details": "Afficher plus de détails", "show_more_details": "Afficher plus de détails",
"allowed_user_groups": "Groupes d'utilisateurs autorisés", "allowed_user_groups": "Groupes d'utilisateurs autorisés",
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Ajouter des groupes d'utilisateurs à ce client permet de restreindre l'accès aux utilisateurs de ces groupes. Si aucun groupe d'utilisateurs n'est sélectionné, tous les utilisateurs auront accès à ce client.", "add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Ajouter des groupes d'utilisateurs à ce client permet de restreindre l'accès aux utilisateurs de ces groupes. Si aucun groupe d'utilisateurs n'est sélectionné, tous les utilisateurs auront accès à ce client.",
@@ -312,9 +312,10 @@
"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": "Choisis la langue que tu veux utiliser. Attention, certains textes peuvent être traduits automatiquement et ne pas être tout à fait exacts.",
"contribute_to_translation": "Si tu trouves un problème, n'hésite pas à contribuer à la traduction sur <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
"personal": "Personnel", "personal": "Personnel",
"global": "Global", "global": "Mondial",
"all_users": "Tous les utilisateurs", "all_users": "Tous les utilisateurs",
"all_events": "Tous les événements", "all_events": "Tous les événements",
"all_clients": "Tous les clients", "all_clients": "Tous les clients",
@@ -378,45 +379,45 @@
"custom_accent_color_description": "Entrez une couleur personnalisée en utilisant un format CSS valide (par ex. hex, rgb, hsl).", "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", "color_value": "Valeur de la couleur",
"apply": "Appliquer", "apply": "Appliquer",
"signup_token": "Signup Token", "signup_token": "Jeton d'inscription",
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.", "create_a_signup_token_to_allow_new_user_registration": "Créez un jeton d'inscription pour autoriser l'enregistrement de nouveaux utilisateurs.",
"usage_limit": "Usage Limit", "usage_limit": "Limite d'utilisation",
"number_of_times_token_can_be_used": "Number of times the signup token can be used.", "number_of_times_token_can_be_used": "Nombre de fois que le jeton d'inscription peut être utilisé.",
"expires": "Expires", "expires": "Expire",
"signup": "Sign Up", "signup": "S'inscrire",
"signup_requires_valid_token": "A valid signup token is required to create an account", "signup_requires_valid_token": "Un jeton d'inscription valide est requis pour créer un compte.",
"validating_signup_token": "Validating signup token", "validating_signup_token": "Validation du jeton d'inscription",
"go_to_login": "Go to login", "go_to_login": "Aller à la connexion",
"signup_to_appname": "Sign Up to {appName}", "signup_to_appname": "Inscription à {appName}",
"create_your_account_to_get_started": "Create your account to get started.", "create_your_account_to_get_started": "Créez votre compte pour commencer.",
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.", "initial_account_creation_description": "Veuillez créer votre compte pour commencer. Vous pourrez configurer une clé d'accès ultérieurement.",
"setup_your_passkey": "Set up your passkey", "setup_your_passkey": "Configurer votre clé d'accès",
"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.", "create_a_passkey_to_securely_access_your_account": "Créez une clé d'accès pour accéder en toute sécurité à votre compte. Elle sera votre méthode principale de connexion.",
"skip_for_now": "Skip for now", "skip_for_now": "Ignorer pour le moment",
"account_created": "Account Created", "account_created": "Compte créé",
"enable_user_signups": "Enable User Signups", "enable_user_signups": "Activer les inscriptions utilisateur",
"enable_user_signups_description": "Whether the User Signup functionality should be enabled.", "enable_user_signups_description": "Détermine si la fonctionnalité d'inscription des utilisateurs doit être activée.",
"user_signups_are_disabled": "User signups are currently disabled", "user_signups_are_disabled": "Les inscriptions utilisateur sont actuellement désactivées",
"create_signup_token": "Create Signup Token", "create_signup_token": "Créer un jeton d'inscription",
"view_active_signup_tokens": "View Active Signup Tokens", "view_active_signup_tokens": "Voir les jetons d'inscription actifs",
"manage_signup_tokens": "Manage Signup Tokens", "manage_signup_tokens": "Gérer les jetons d'inscription",
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.", "view_and_manage_active_signup_tokens": "Voir et gérer les jetons d'inscription actifs.",
"signup_token_deleted_successfully": "Signup token deleted successfully.", "signup_token_deleted_successfully": "Jeton d'inscription supprimé avec succès.",
"expired": "Expired", "expired": "Expiré",
"used_up": "Used Up", "used_up": "Utilisé",
"active": "Active", "active": "Actif",
"usage": "Usage", "usage": "Utilisation",
"created": "Created", "created": "Créé",
"token": "Token", "token": "Jeton",
"loading": "Loading", "loading": "Chargement",
"delete_signup_token": "Delete Signup Token", "delete_signup_token": "Supprimer le jeton d'inscription",
"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.", "are_you_sure_you_want_to_delete_this_signup_token": "Êtes-vous sûr de vouloir supprimer ce jeton d'inscription ? Cette action est irréversible.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.", "signup_disabled_description": "Les inscriptions utilisateur sont complètement désactivées. Seuls les administrateurs peuvent créer de nouveaux comptes utilisateur.",
"signup_with_token": "Signup with token", "signup_with_token": "Inscription avec jeton",
"signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.", "signup_with_token_description": "Les utilisateurs ne peuvent s'inscrire qu'en utilisant un jeton d'inscription valide créé par un administrateur.",
"signup_open": "Open Signup", "signup_open": "Inscription ouverte",
"signup_open_description": "Anyone can create a new account without restrictions.", "signup_open_description": "Toute personne peut créer un nouveau compte sans restriction.",
"of": "of", "of": "sur",
"skip_passkey_setup": "Skip Passkey Setup", "skip_passkey_setup": "Ignorer la configuration de la clé d'accès",
"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." "skip_passkey_setup_description": "Il est fortement recommandé de configurer une clé d'accès, car sans elle, vous serez verrouillé hors de votre compte dès l'expiration de la session."
} }

View File

@@ -65,7 +65,7 @@
"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_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.", "authenticate_with_passkey_to_access_account": "Autenticati con la tua passkey per accedere al tuo account.",
"authenticate": "Autentica", "authenticate": "Autentica",
"please_try_again": "Per favore, riprova.", "please_try_again": "Per favore, riprova.",
"continue": "Continua", "continue": "Continua",
@@ -312,7 +312,8 @@
"reset": "Reimposta", "reset": "Reimposta",
"reset_to_default": "Ripristina valori predefiniti", "reset_to_default": "Ripristina valori predefiniti",
"profile_picture_has_been_reset": "L'immagine del profilo è stata reimpostata. Potrebbero essere necessari alcuni minuti per l'aggiornamento.", "profile_picture_has_been_reset": "L'immagine del profilo è stata reimpostata. Potrebbero essere necessari alcuni minuti per l'aggiornamento.",
"select_the_language_you_want_to_use": "Seleziona la lingua che desideri utilizzare. Alcune lingue potrebbero non essere completamente tradotte.", "select_the_language_you_want_to_use": "Scegli la lingua che vuoi usare. Tieni presente che alcuni testi potrebbero essere tradotti automaticamente e potrebbero non essere accurati.",
"contribute_to_translation": "Se trovi un problema, puoi dare una mano con la traduzione su <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
"personal": "Personale", "personal": "Personale",
"global": "Globale", "global": "Globale",
"all_users": "Tutti gli utenti", "all_users": "Tutti gli utenti",
@@ -378,45 +379,45 @@
"custom_accent_color_description": "Inserisci un colore personalizzato usando formati di colore CSS validi (es: hex, rgb, hsl).", "custom_accent_color_description": "Inserisci un colore personalizzato usando formati di colore CSS validi (es: hex, rgb, hsl).",
"color_value": "Valore Colore", "color_value": "Valore Colore",
"apply": "Applica", "apply": "Applica",
"signup_token": "Signup Token", "signup_token": "Codice d'iscrizione",
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.", "create_a_signup_token_to_allow_new_user_registration": "Crea un codice d'iscrizione per consentire la registrazione di un nuovo utente.",
"usage_limit": "Usage Limit", "usage_limit": "Limite di utilizzo",
"number_of_times_token_can_be_used": "Number of times the signup token can be used.", "number_of_times_token_can_be_used": "Numero di volte che il codice d'iscrizione può essere usato.",
"expires": "Expires", "expires": "Scadenza",
"signup": "Sign Up", "signup": "Registrati",
"signup_requires_valid_token": "A valid signup token is required to create an account", "signup_requires_valid_token": "È necessario un codice d'iscrizione valido per creare un account",
"validating_signup_token": "Validating signup token", "validating_signup_token": "Convalida codice d'iscrizione",
"go_to_login": "Go to login", "go_to_login": "Vai alla login",
"signup_to_appname": "Sign Up to {appName}", "signup_to_appname": "Accedi a {appName}",
"create_your_account_to_get_started": "Create your account to get started.", "create_your_account_to_get_started": "Crea il tuo account per iniziare.",
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.", "initial_account_creation_description": "Crea il tuo account per iniziare. Successivamente sarai in grado di impostare una passkey.",
"setup_your_passkey": "Set up your passkey", "setup_your_passkey": "Imposta la tua 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.", "create_a_passkey_to_securely_access_your_account": "Crea una passkey per accedere in modo sicuro al tuo account. Questo sarà la modalità principale.",
"skip_for_now": "Skip for now", "skip_for_now": "Salta per ora",
"account_created": "Account Created", "account_created": "Account creato",
"enable_user_signups": "Enable User Signups", "enable_user_signups": "Abilita Iscrizioni Utente",
"enable_user_signups_description": "Whether the User Signup functionality should be enabled.", "enable_user_signups_description": "Indica se la funzionalità di registrazione utente deve essere abilitata.",
"user_signups_are_disabled": "User signups are currently disabled", "user_signups_are_disabled": "Le iscrizioni utente sono attualmente disattivate",
"create_signup_token": "Create Signup Token", "create_signup_token": "Crea Codice d'iscrizione",
"view_active_signup_tokens": "View Active Signup Tokens", "view_active_signup_tokens": "Visualizza codici d'iscrizione attivi",
"manage_signup_tokens": "Manage Signup Tokens", "manage_signup_tokens": "Gestisci Codici d'iscrizione",
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.", "view_and_manage_active_signup_tokens": "Visualizza e gestisci i codici d'iscrizione attivi.",
"signup_token_deleted_successfully": "Signup token deleted successfully.", "signup_token_deleted_successfully": "Codice d'iscrizione eliminato con successo.",
"expired": "Expired", "expired": "Scaduto",
"used_up": "Used Up", "used_up": "Utilizzato",
"active": "Active", "active": "Attivo",
"usage": "Usage", "usage": "Utilizzo",
"created": "Created", "created": "Creato",
"token": "Token", "token": "Token",
"loading": "Loading", "loading": "Caricamento",
"delete_signup_token": "Delete Signup Token", "delete_signup_token": "Elimina Codice d'iscrizione",
"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.", "are_you_sure_you_want_to_delete_this_signup_token": "Sei sicuro di voler eliminare questo codice d'iscrizione? Questa azione non può essere annullata.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.", "signup_disabled_description": "Le iscrizioni utente sono completamente disabilitate. Solo gli amministratori possono creare nuovi account utente.",
"signup_with_token": "Signup with token", "signup_with_token": "Registrati con codice",
"signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.", "signup_with_token_description": "Gli utenti possono registrarsi solo usando un codice d'iscrizione valido, creato da un amministratore.",
"signup_open": "Open Signup", "signup_open": "Apri Registrazione",
"signup_open_description": "Anyone can create a new account without restrictions.", "signup_open_description": "Chiunque può creare un nuovo account senza restrizioni.",
"of": "of", "of": "di",
"skip_passkey_setup": "Skip Passkey Setup", "skip_passkey_setup": "Salta Impostazione Passkey",
"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." "skip_passkey_setup_description": "Si consiglia vivamente di impostare una passkey perché senza di essa, sarai tagliato fuori dal tuo account non appena scadrà la sessione."
} }

View File

@@ -3,7 +3,7 @@
"my_account": "Mijn account", "my_account": "Mijn account",
"logout": "Uitloggen", "logout": "Uitloggen",
"confirm": "Bevestigen", "confirm": "Bevestigen",
"docs": "Docs", "docs": "Documenten",
"key": "Sleutel", "key": "Sleutel",
"value": "Waarde", "value": "Waarde",
"remove_custom_claim": "Aangepaste claim verwijderen", "remove_custom_claim": "Aangepaste claim verwijderen",
@@ -65,7 +65,7 @@
"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_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.", "authenticate_with_passkey_to_access_account": "Log in met je passkey om toegang te krijgen tot je account.",
"authenticate": "Authenticeren", "authenticate": "Authenticeren",
"please_try_again": "Probeer het opnieuw.", "please_try_again": "Probeer het opnieuw.",
"continue": "Doorgaan", "continue": "Doorgaan",
@@ -153,7 +153,7 @@
"actions": "Acties", "actions": "Acties",
"images_updated_successfully": "Afbeeldingen succesvol bijgewerkt", "images_updated_successfully": "Afbeeldingen succesvol bijgewerkt",
"general": "Algemeen", "general": "Algemeen",
"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": "Zet e-mailmeldingen aan om mensen te laten weten als iemand inlogt vanaf een nieuw apparaat of een nieuwe plek.",
"ldap": "LDAP", "ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configureer LDAP-instellingen om gebruikers en groepen vanaf een LDAP-server te synchroniseren.", "configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configureer LDAP-instellingen om gebruikers en groepen vanaf een LDAP-server te synchroniseren.",
"images": "Afbeeldingen", "images": "Afbeeldingen",
@@ -177,10 +177,10 @@
"enabled_emails": "Ingeschakelde e-mails", "enabled_emails": "Ingeschakelde e-mails",
"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": "E-mail login code aangevraagd door gebruiker",
"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.", "allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Hiermee kunnen gebruikers wachtwoorden omzeilen door een inlogcode aan te vragen die naar hun e-mail wordt gestuurd. Dit maakt het een stuk minder veilig, omdat iedereen die toegang heeft tot de e-mail van de gebruiker binnen kan komen.",
"email_login_code_from_admin": "Email Login Code from Admin", "email_login_code_from_admin": "E-mail inlogcode van beheerder",
"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": "Hiermee kan een admin een inlogcode naar de gebruiker mailen.",
"send_test_email": "Test-e-mail verzenden", "send_test_email": "Test-e-mail verzenden",
"application_configuration_updated_successfully": "Toepassingsconfiguratie succesvol bijgewerkt", "application_configuration_updated_successfully": "Toepassingsconfiguratie succesvol bijgewerkt",
"application_name": "Toepassingsnaam", "application_name": "Toepassingsnaam",
@@ -308,115 +308,116 @@
"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": "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": "Hiermee wordt de geüploade afbeelding verwijderd en wordt de profielfoto teruggezet naar de standaardinstelling. Wil je doorgaan?",
"reset": "Reset", "reset": "Opnieuw instellen",
"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.",
"select_the_language_you_want_to_use": "Selecteer de taal die u wilt gebruiken. Sommige talen zijn mogelijk niet volledig vertaald.", "select_the_language_you_want_to_use": "Kies de taal die je wilt gebruiken. Let op: sommige teksten worden automatisch vertaald en kunnen onnauwkeurig zijn.",
"contribute_to_translation": "Als je een fout vindt, kun je altijd helpen met de vertaling op <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
"personal": "Persoonlijk", "personal": "Persoonlijk",
"global": "Globaal", "global": "Globaal",
"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", "all_locations": "Alle locaties",
"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": "Inloggen met token",
"client_authorization": "Client autorisatie", "client_authorization": "Client autorisatie",
"new_client_authorization": "Nieuwe clientautorisatie", "new_client_authorization": "Nieuwe clientautorisatie",
"disable_animations": "Disable Animations", "disable_animations": "Animatie uitzetten",
"turn_off_ui_animations": "Turn off animations throughout the UI.", "turn_off_ui_animations": "Zet alle animaties in de gebruikersinterface uit.",
"user_disabled": "Account Disabled", "user_disabled": "Account uitgeschakeld",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.", "disabled_users_cannot_log_in_or_use_services": "Gebruikers met een handicap kunnen niet inloggen of diensten gebruiken.",
"user_disabled_successfully": "User has been disabled successfully.", "user_disabled_successfully": "Je bent nu uitgelogd.",
"user_enabled_successfully": "User has been enabled successfully.", "user_enabled_successfully": "Je bent nu aangemeld.",
"status": "Status", "status": "Status",
"disable_firstname_lastname": "Disable {firstName} {lastName}", "disable_firstname_lastname": "{firstName} {lastName}uitschakelen",
"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.", "are_you_sure_you_want_to_disable_this_user": "Weet je zeker dat je deze gebruiker wilt uitschakelen? Ze kunnen dan niet meer inloggen of diensten gebruiken.",
"ldap_soft_delete_users": "Keep disabled users from LDAP.", "ldap_soft_delete_users": "Voorkom dat gebruikers met een handicap toegang krijgen tot LDAP.",
"ldap_soft_delete_users_description": "When enabled, users removed from LDAP will be disabled rather than deleted from the system.", "ldap_soft_delete_users_description": "Als dit is ingeschakeld, worden gebruikers die uit LDAP worden verwijderd, uitgeschakeld in plaats van uit het systeem verwijderd.",
"login_code_email_success": "The login code has been sent to the user.", "login_code_email_success": "De inlogcode is naar je gestuurd.",
"send_email": "Send Email", "send_email": "E-mail sturen",
"show_code": "Show Code", "show_code": "Code tonen",
"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.", "callback_url_description": "URL's die je klant heeft gegeven. Als je dit leeg laat, worden ze automatisch toegevoegd. Je kunt jokertekens (*) gebruiken, maar voor de veiligheid kun je die beter niet doen.",
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.", "logout_callback_url_description": "URL's die je klant heeft gegeven om uit te loggen. Je kunt jokertekens (*) gebruiken, maar dat is niet zo'n goed idee voor de veiligheid.",
"api_key_expiration": "API Key Expiration", "api_key_expiration": "API-sleutel verloopt",
"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": "Stuur een mailtje naar de gebruiker als hun API-sleutel bijna afloopt.",
"authorize_device": "Authorize Device", "authorize_device": "Apparaat autoriseren",
"the_device_has_been_authorized": "The device has been authorized.", "the_device_has_been_authorized": "Het apparaat is goedgekeurd.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.", "enter_code_displayed_in_previous_step": "Voer de code in die je in de vorige stap hebt gezien.",
"authorize": "Authorize", "authorize": "Autoriseren",
"federated_client_credentials": "Federated Client Credentials", "federated_client_credentials": "Federatieve clientreferenties",
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.", "federated_client_credentials_description": "Met federatieve clientreferenties kun je OIDC-clients verifiëren met JWT-tokens die zijn uitgegeven door andere instanties.",
"add_federated_client_credential": "Add Federated Client Credential", "add_federated_client_credential": "Federatieve clientreferenties toevoegen",
"add_another_federated_client_credential": "Add another federated client credential", "add_another_federated_client_credential": "Voeg nog een federatieve clientreferentie toe",
"oidc_allowed_group_count": "Allowed Group Count", "oidc_allowed_group_count": "Toegestaan aantal groepen",
"unrestricted": "Unrestricted", "unrestricted": "Onbeperkt",
"show_advanced_options": "Show Advanced Options", "show_advanced_options": "Geavanceerde opties weergeven",
"hide_advanced_options": "Hide Advanced Options", "hide_advanced_options": "Verberg geavanceerde opties",
"oidc_data_preview": "OIDC Data Preview", "oidc_data_preview": "OIDC-gegevensvoorbeeld",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users", "preview_the_oidc_data_that_would_be_sent_for_different_users": "Bekijk een voorbeeld van de OIDC-gegevens die voor verschillende gebruikers zouden worden verzonden.",
"id_token": "ID Token", "id_token": "ID-token",
"access_token": "Access Token", "access_token": "Toegangstoken",
"userinfo": "Userinfo", "userinfo": "Gebruikersinfo",
"id_token_payload": "ID Token Payload", "id_token_payload": "ID-token payload",
"access_token_payload": "Access Token Payload", "access_token_payload": "Toegangstoken-payload",
"userinfo_endpoint_response": "Userinfo Endpoint Response", "userinfo_endpoint_response": "Gebruikersinfo Eindpuntrespons",
"copy": "Copy", "copy": "Kopieer",
"no_preview_data_available": "No preview data available", "no_preview_data_available": "Geen voorbeeldgegevens beschikbaar",
"copy_all": "Copy All", "copy_all": "Alles kopiëren",
"preview": "Preview", "preview": "Voorbeeld",
"preview_for_user": "Preview for {name} ({email})", "preview_for_user": "Voorbeeld van {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user", "preview_the_oidc_data_that_would_be_sent_for_this_user": "Bekijk een voorbeeld van de OIDC-gegevens die voor deze gebruiker zouden worden verzonden.",
"show": "Show", "show": "Laten zien",
"select_an_option": "Select an option", "select_an_option": "Kies een optie",
"select_user": "Select User", "select_user": "Gebruiker kiezen",
"error": "Error", "error": "Fout",
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Select an accent color to customize the appearance of Pocket ID.", "select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Kies een accentkleur om hoe Pocket ID eruitziet aan te passen.",
"accent_color": "Accent Color", "accent_color": "Accentkleur",
"custom_accent_color": "Custom Accent Color", "custom_accent_color": "Aangepaste accentkleur",
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).", "custom_accent_color_description": "Voer een eigen kleur in met een geldige CSS-kleurcode (bijvoorbeeld hex, rgb, hsl).",
"color_value": "Color Value", "color_value": "Kleurwaarde",
"apply": "Apply", "apply": "Solliciteren",
"signup_token": "Signup Token", "signup_token": "Aanmeldingstoken",
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.", "create_a_signup_token_to_allow_new_user_registration": "Maak een aanmeldingstoken aan om nieuwe gebruikers te laten registreren.",
"usage_limit": "Usage Limit", "usage_limit": "Gebruikslimiet",
"number_of_times_token_can_be_used": "Number of times the signup token can be used.", "number_of_times_token_can_be_used": "Hoe vaak je het aanmeldingstoken kunt gebruiken.",
"expires": "Expires", "expires": "Verloopt",
"signup": "Sign Up", "signup": "Aanmelden",
"signup_requires_valid_token": "A valid signup token is required to create an account", "signup_requires_valid_token": "Je hebt een geldige registratietoken nodig om een account aan te maken.",
"validating_signup_token": "Validating signup token", "validating_signup_token": "Inlogtoken checken",
"go_to_login": "Go to login", "go_to_login": "Ga naar inloggen",
"signup_to_appname": "Sign Up to {appName}", "signup_to_appname": "Meld je aan voor {appName}",
"create_your_account_to_get_started": "Create your account to get started.", "create_your_account_to_get_started": "Maak je account aan om te beginnen.",
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.", "initial_account_creation_description": "Maak een account aan om te beginnen. Je kunt later een wachtwoord instellen.",
"setup_your_passkey": "Set up your passkey", "setup_your_passkey": "Stel je passkey in",
"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.", "create_a_passkey_to_securely_access_your_account": "Maak een toegangscode aan om veilig toegang te krijgen tot je account. Dit wordt je belangrijkste manier om in te loggen.",
"skip_for_now": "Skip for now", "skip_for_now": "Voor nu even overslaan",
"account_created": "Account Created", "account_created": "Account aangemaakt",
"enable_user_signups": "Enable User Signups", "enable_user_signups": "Gebruikersregistratie inschakelen",
"enable_user_signups_description": "Whether the User Signup functionality should be enabled.", "enable_user_signups_description": "Of de functie voor gebruikersregistratie moet worden ingeschakeld.",
"user_signups_are_disabled": "User signups are currently disabled", "user_signups_are_disabled": "Je kunt nu niet aanmelden.",
"create_signup_token": "Create Signup Token", "create_signup_token": "Aanmeldingstoken maken",
"view_active_signup_tokens": "View Active Signup Tokens", "view_active_signup_tokens": "Actieve aanmeldingstokens bekijken",
"manage_signup_tokens": "Manage Signup Tokens", "manage_signup_tokens": "Aanmeldingstokens beheren",
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.", "view_and_manage_active_signup_tokens": "Bekijk en beheer actieve aanmeldingstokens.",
"signup_token_deleted_successfully": "Signup token deleted successfully.", "signup_token_deleted_successfully": "Aanmeldingstoken succesvol verwijderd.",
"expired": "Expired", "expired": "Verlopen",
"used_up": "Used Up", "used_up": "Opgebruikt",
"active": "Active", "active": "Actief",
"usage": "Usage", "usage": "Gebruik",
"created": "Created", "created": "Gemaakt",
"token": "Token", "token": "Token",
"loading": "Loading", "loading": "Bezig met laden",
"delete_signup_token": "Delete Signup Token", "delete_signup_token": "Registratietoken verwijderen",
"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.", "are_you_sure_you_want_to_delete_this_signup_token": "Weet je zeker dat je dit aanmeldingstoken wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.", "signup_disabled_description": "Gebruikersregistraties zijn helemaal uitgeschakeld. Alleen beheerders kunnen nieuwe gebruikersaccounts aanmaken.",
"signup_with_token": "Signup with token", "signup_with_token": "Aanmelden met token",
"signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.", "signup_with_token_description": "Je kunt je alleen aanmelden met een geldige aanmeldtoken die door een beheerder is aangemaakt.",
"signup_open": "Open Signup", "signup_open": "Open inschrijving",
"signup_open_description": "Anyone can create a new account without restrictions.", "signup_open_description": "Iedereen kan zonder beperkingen een nieuw account aanmaken.",
"of": "of", "of": "van",
"skip_passkey_setup": "Skip Passkey Setup", "skip_passkey_setup": "Pas de instellingen voor de toegangssleutel over",
"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." "skip_passkey_setup_description": "Het is echt een aanrader om een wachtwoord in te stellen, want zonder dat word je uit je account gegooid zodra de sessie afloopt."
} }

View File

@@ -3,7 +3,7 @@
"my_account": "Moje konto", "my_account": "Moje konto",
"logout": "Wyloguj się", "logout": "Wyloguj się",
"confirm": "Potwierdź", "confirm": "Potwierdź",
"docs": "Docs", "docs": "Dokumenty",
"key": "Klucz", "key": "Klucz",
"value": "Wartość", "value": "Wartość",
"remove_custom_claim": "Usuń niestandardowy atrybut", "remove_custom_claim": "Usuń niestandardowy atrybut",
@@ -65,7 +65,7 @@
"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_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.", "authenticate_with_passkey_to_access_account": "Uwierzytelnij się za pomocą klucza dostępowego, aby uzyskać dostęp do konta.",
"authenticate": "Uwierzytelnij", "authenticate": "Uwierzytelnij",
"please_try_again": "Spróbuj ponownie.", "please_try_again": "Spróbuj ponownie.",
"continue": "Kontynuuj", "continue": "Kontynuuj",
@@ -178,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": "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.", "allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Umożliwia użytkownikom ominięcie kluczy dostępu poprzez wysłanie kodu logowania na ich adres e-mail. Znacznie obniża to poziom bezpieczeństwa, ponieważ dostęp do konta może uzyskać każda osoba mająca dostęp do poczty e-mail użytkownika.",
"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",
@@ -308,24 +308,25 @@
"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": "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": "Spowoduje to usunięcie przesłanego zdjęcia i przywrócenie domyślnego zdjęcia profilowego. Czy chcesz kontynuować?",
"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.",
"select_the_language_you_want_to_use": "Wybierz język, którego chcesz używać. Niektóre języki mogą nie być w pełni przetłumaczone.", "select_the_language_you_want_to_use": "Wybierz język, którego chcesz używać. Pamiętaj, że niektóre fragmenty tekstu mogą zostać automatycznie przetłumaczone i mogą zawierać nieścisłości.",
"contribute_to_translation": "Jeśli znajdziesz błąd, możesz wziąć udział w tłumaczeniu na <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
"personal": "Osobiste", "personal": "Osobiste",
"global": "Globalne", "global": "Globalne",
"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", "all_locations": "Wszystkie lokalizacje",
"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_ui_animations": "Turn off animations throughout the UI.", "turn_off_ui_animations": "Wyłącz animacje w całym interfejsie użytkownika.",
"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.",
@@ -338,85 +339,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(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.", "callback_url_description": "Adresy URL podane przez klienta. Zostaną automatycznie dodane, jeśli pole pozostanie puste. Obsługiwane są symbole wieloznaczne (*), ale dla większego bezpieczeństwa najlepiej ich unikać.",
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.", "logout_callback_url_description": "Adresy URL podane przez klienta do wylogowania. Obsługiwane są symbole wieloznaczne (*), ale dla większego bezpieczeństwa najlepiej ich unikać.",
"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": "Połączone poświadczenia klienta",
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.", "federated_client_credentials_description": "Korzystając z połączonych poświadczeń klienta, możecie uwierzytelnić klientów OIDC za pomocą tokenów JWT wydanych przez zewnętrzne organy.",
"add_federated_client_credential": "Add Federated Client Credential", "add_federated_client_credential": "Dodaj poświadczenia klienta federacyjnego",
"add_another_federated_client_credential": "Add another federated client credential", "add_another_federated_client_credential": "Dodaj kolejne poświadczenia klienta federacyjnego",
"oidc_allowed_group_count": "Allowed Group Count", "oidc_allowed_group_count": "Dopuszczalna liczba grup",
"unrestricted": "Unrestricted", "unrestricted": "Bez ograniczeń",
"show_advanced_options": "Show Advanced Options", "show_advanced_options": "Pokaż opcje zaawansowane",
"hide_advanced_options": "Hide Advanced Options", "hide_advanced_options": "Ukryj opcje zaawansowane",
"oidc_data_preview": "OIDC Data Preview", "oidc_data_preview": "Podgląd danych OIDC",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users", "preview_the_oidc_data_that_would_be_sent_for_different_users": "Podgląd danych OIDC, które zostaną wysłane dla różnych użytkowników",
"id_token": "ID Token", "id_token": "Token identyfikacyjny",
"access_token": "Access Token", "access_token": "Token dostępu",
"userinfo": "Userinfo", "userinfo": "Informacje o użytkowniku",
"id_token_payload": "ID Token Payload", "id_token_payload": "Ładunek tokenu identyfikacyjnego",
"access_token_payload": "Access Token Payload", "access_token_payload": "Dane ładunku tokenu dostępu",
"userinfo_endpoint_response": "Userinfo Endpoint Response", "userinfo_endpoint_response": "Odpowiedź punktu końcowego informacji o użytkowniku",
"copy": "Copy", "copy": "Kopiuj",
"no_preview_data_available": "No preview data available", "no_preview_data_available": "Brak dostępnych danych podglądu",
"copy_all": "Copy All", "copy_all": "Skopiuj wszystko",
"preview": "Preview", "preview": "Podgląd",
"preview_for_user": "Preview for {name} ({email})", "preview_for_user": "Zapowiedź książki „ {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user", "preview_the_oidc_data_that_would_be_sent_for_this_user": "Wyświetl podgląd danych OIDC, które zostaną wysłane dla tego użytkownika.",
"show": "Show", "show": "Pokaż",
"select_an_option": "Select an option", "select_an_option": "Wybierz opcję",
"select_user": "Select User", "select_user": "Wybierz użytkownika",
"error": "Error", "error": "Błąd",
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Select an accent color to customize the appearance of Pocket ID.", "select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Wybierz kolor akcentujący, aby dostosować wygląd Pocket ID.",
"accent_color": "Accent Color", "accent_color": "Kolor akcentujący",
"custom_accent_color": "Custom Accent Color", "custom_accent_color": "Niestandardowy kolor akcentujący",
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).", "custom_accent_color_description": "Wprowadź niestandardowy kolor, używając prawidłowych formatów kolorów CSS (np. hex, rgb, hsl).",
"color_value": "Color Value", "color_value": "Wartość koloru",
"apply": "Apply", "apply": "Zastosuj",
"signup_token": "Signup Token", "signup_token": "Token rejestracyjny",
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.", "create_a_signup_token_to_allow_new_user_registration": "Utwórz token rejestracji, aby umożliwić rejestrację nowych użytkowników.",
"usage_limit": "Usage Limit", "usage_limit": "Limit użytkowania",
"number_of_times_token_can_be_used": "Number of times the signup token can be used.", "number_of_times_token_can_be_used": "Liczba przypadków, w których można użyć tokenu rejestracji.",
"expires": "Expires", "expires": "Wygasają",
"signup": "Sign Up", "signup": "Zarejestruj się",
"signup_requires_valid_token": "A valid signup token is required to create an account", "signup_requires_valid_token": "Aby utworzyć konto, wymagany jest ważny token rejestracyjny.",
"validating_signup_token": "Validating signup token", "validating_signup_token": "Weryfikacja tokenu rejestracji",
"go_to_login": "Go to login", "go_to_login": "Przejdź do logowania",
"signup_to_appname": "Sign Up to {appName}", "signup_to_appname": "Zarejestruj się na stronie {appName}",
"create_your_account_to_get_started": "Create your account to get started.", "create_your_account_to_get_started": "Załóż konto, aby rozpocząć.",
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.", "initial_account_creation_description": "Aby rozpocząć, utwórz konto. Klucz dostępu będzie można skonfigurować później.",
"setup_your_passkey": "Set up your passkey", "setup_your_passkey": "Skonfiguruj swój klucz dostępu",
"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.", "create_a_passkey_to_securely_access_your_account": "Utwórz klucz dostępu, aby uzyskać bezpieczny dostęp do swojego konta. Będzie to główny sposób logowania.",
"skip_for_now": "Skip for now", "skip_for_now": "Pomiń na razie",
"account_created": "Account Created", "account_created": "Konto utworzone",
"enable_user_signups": "Enable User Signups", "enable_user_signups": "Włącz rejestrację użytkowników",
"enable_user_signups_description": "Whether the User Signup functionality should be enabled.", "enable_user_signups_description": "Czy funkcja rejestracji użytkowników powinna być włączona.",
"user_signups_are_disabled": "User signups are currently disabled", "user_signups_are_disabled": "Rejestracja użytkowników jest obecnie wyłączona.",
"create_signup_token": "Create Signup Token", "create_signup_token": "Utwórz token rejestracji",
"view_active_signup_tokens": "View Active Signup Tokens", "view_active_signup_tokens": "Wyświetl aktywne tokeny rejestracji",
"manage_signup_tokens": "Manage Signup Tokens", "manage_signup_tokens": "Zarządzaj tokenami rejestracji",
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.", "view_and_manage_active_signup_tokens": "Wyświetlaj aktywne tokeny rejestracji i zarządzaj nimi.",
"signup_token_deleted_successfully": "Signup token deleted successfully.", "signup_token_deleted_successfully": "Token rejestracji został pomyślnie usunięty.",
"expired": "Expired", "expired": "Wygasło",
"used_up": "Used Up", "used_up": "Zużyte",
"active": "Active", "active": "Aktywny",
"usage": "Usage", "usage": "Zastosowanie",
"created": "Created", "created": "Stworzone",
"token": "Token", "token": "Token",
"loading": "Loading", "loading": "Ładowanie",
"delete_signup_token": "Delete Signup Token", "delete_signup_token": "Usuń token rejestracji",
"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.", "are_you_sure_you_want_to_delete_this_signup_token": "Czy na pewno chcesz usunąć ten token rejestracji? Tego działania nie można cofnąć.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.", "signup_disabled_description": "Rejestracja użytkowników jest całkowicie wyłączona. Tylko administratorzy mogą tworzyć nowe konta użytkowników.",
"signup_with_token": "Signup with token", "signup_with_token": "Zarejestruj się za pomocą tokenu",
"signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.", "signup_with_token_description": "Użytkownicy mogą zarejestrować się wyłącznie przy użyciu ważnego tokenu rejestracyjnego utworzonego przez administratora.",
"signup_open": "Open Signup", "signup_open": "Otwórz rejestrację",
"signup_open_description": "Anyone can create a new account without restrictions.", "signup_open_description": "Każdy może utworzyć nowe konto bez żadnych ograniczeń.",
"of": "of", "of": "z",
"skip_passkey_setup": "Skip Passkey Setup", "skip_passkey_setup": "Pomiń konfigurację klucza dostępu",
"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." "skip_passkey_setup_description": "Zdecydowanie zalecamy skonfigurowanie klucza dostępu, ponieważ bez niego utracisz dostęp do konta zaraz po wygaśnięciu sesji."
} }

View File

@@ -3,11 +3,11 @@
"my_account": "Minha Conta", "my_account": "Minha Conta",
"logout": "Sair", "logout": "Sair",
"confirm": "Confirmar", "confirm": "Confirmar",
"docs": "Docs", "docs": "Documentos",
"key": "Chave", "key": "Chave",
"value": "Valor", "value": "Valor",
"remove_custom_claim": "Remove custom claim", "remove_custom_claim": "Tirar reivindicação personalizada",
"add_custom_claim": "Add custom claim", "add_custom_claim": "Adicionar reivindicação personalizada",
"add_another": "Adicionar outro", "add_another": "Adicionar outro",
"select_a_date": "Selecione a data", "select_a_date": "Selecione a data",
"select_file": "Selecionar Arquivo", "select_file": "Selecionar Arquivo",
@@ -23,11 +23,11 @@
"click_to_copy": "Clique para copiar", "click_to_copy": "Clique para copiar",
"something_went_wrong": "Algo deu errado", "something_went_wrong": "Algo deu errado",
"go_back_to_home": "Voltar para o início", "go_back_to_home": "Voltar para o início",
"dont_have_access_to_your_passkey": "Don't have access to your passkey?", "dont_have_access_to_your_passkey": "Não tem acesso à sua chave de acesso?",
"login_background": "Login background", "login_background": "Histórico de login",
"logo": "Logo", "logo": "Logotipo",
"login_code": "Código de Login:", "login_code": "Código de Login:",
"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.", "create_a_login_code_to_sign_in_without_a_passkey_once": "Crie um código de login que o usuário possa usar para entrar sem precisar digitar uma senha.",
"one_hour": "1 hora", "one_hour": "1 hora",
"twelve_hours": "12 horas", "twelve_hours": "12 horas",
"one_day": "1 dia", "one_day": "1 dia",
@@ -37,53 +37,53 @@
"generate_code": "Gerar Código", "generate_code": "Gerar Código",
"name": "Nome", "name": "Nome",
"browser_unsupported": "Navegador não suportado", "browser_unsupported": "Navegador não suportado",
"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": "Esse navegador não aceita chaves de acesso. Tenta usar outro jeito de entrar.",
"an_unknown_error_occurred": "Ocorreu um erro desconhecido", "an_unknown_error_occurred": "Ocorreu um erro desconhecido",
"authentication_process_was_aborted": "O processo de autenticação foi abortado", "authentication_process_was_aborted": "O processo de autenticação foi abortado",
"error_occurred_with_authenticator": "An error occurred with the authenticator", "error_occurred_with_authenticator": "Ocorreu um erro com o autenticador",
"authenticator_does_not_support_discoverable_credentials": "O autenticador não suporta credenciais detectáveis", "authenticator_does_not_support_discoverable_credentials": "O autenticador não suporta credenciais detectáveis",
"authenticator_does_not_support_resident_keys": "The authenticator does not support resident keys", "authenticator_does_not_support_resident_keys": "O autenticador não aceita chaves residentes.",
"passkey_was_previously_registered": "This passkey was previously registered", "passkey_was_previously_registered": "Essa chave mestra já foi registrada antes.",
"authenticator_does_not_support_any_of_the_requested_algorithms": "The authenticator does not support any of the requested algorithms", "authenticator_does_not_support_any_of_the_requested_algorithms": "O autenticador não suporta nenhum dos algoritmos solicitados.",
"authenticator_timed_out": "Tempo limite do autenticador atingido", "authenticator_timed_out": "Tempo limite do autenticador atingido",
"critical_error_occurred_contact_administrator": "A critical error occurred. Please contact your administrator.", "critical_error_occurred_contact_administrator": "Ocorreu um erro grave. Por favor, entre em contato com o administrador.",
"sign_in_to": "Entrar em {name}", "sign_in_to": "Entrar em {name}",
"client_not_found": "Cliente não encontrado", "client_not_found": "Cliente não encontrado",
"client_wants_to_access_the_following_information": "<b>{client}</b> quer acessar as seguintes informações:", "client_wants_to_access_the_following_information": "<b>{client}</b> quer acessar as seguintes informações:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Você quer entrar em <b>{client}</b> com a sua conta {appName}?", "do_you_want_to_sign_in_to_client_with_your_app_name_account": "Você quer entrar em <b>{client}</b> com a sua conta {appName}?",
"email": "E-mail", "email": "E-mail",
"view_your_email_address": "Ver seu endereço de e-mail", "view_your_email_address": "Ver seu endereço de e-mail",
"profile": "Profile", "profile": "Perfil",
"view_your_profile_information": "View your profile information", "view_your_profile_information": "Dá uma olhada nas informações do seu perfil",
"groups": "Grupos", "groups": "Grupos",
"view_the_groups_you_are_a_member_of": "View the groups you are a member of", "view_the_groups_you_are_a_member_of": "Dá uma olhada nos grupos que você faz parte",
"cancel": "Cancelar", "cancel": "Cancelar",
"sign_in": "Sign in", "sign_in": "Entrar",
"try_again": "Tentar novamente", "try_again": "Tentar novamente",
"client_logo": "Logo do Cliente", "client_logo": "Logo do Cliente",
"sign_out": "Sign out", "sign_out": "Sair",
"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": "Tenta entrar de novo.",
"authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.", "authenticate_with_passkey_to_access_account": "Autentique-se com sua chave de acesso para entrar na sua conta.",
"authenticate": "Autenticar", "authenticate": "Autenticar",
"please_try_again": "Please try again.", "please_try_again": "Tenta de novo, por favor.",
"continue": "Continuar", "continue": "Continuar",
"alternative_sign_in": "Alternative Sign In", "alternative_sign_in": "Entrar de outra forma",
"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": "Se você não tem acesso à sua chave de acesso, pode entrar usando um dos métodos a seguir.",
"use_your_passkey_instead": "Use your passkey instead?", "use_your_passkey_instead": "Quer usar sua chave de acesso?",
"email_login": "Email Login", "email_login": "Entrar com e-mail",
"enter_a_login_code_to_sign_in": "Enter a login code to sign in.", "enter_a_login_code_to_sign_in": "Digite um código de login para entrar.",
"request_a_login_code_via_email": "Request a login code via email.", "request_a_login_code_via_email": "Pede um código de login por e-mail.",
"go_back": "Voltar", "go_back": "Voltar",
"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.", "an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "Mandamos um e-mail pro endereço que você deu, se ele estiver no nosso sistema.",
"enter_code": "Enter code", "enter_code": "Digite o código",
"enter_your_email_address_to_receive_an_email_with_a_login_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": "Digite seu e-mail pra receber um e-mail com um código de login.",
"your_email": "Seu e-mail", "your_email": "Seu e-mail",
"submit": "Submit", "submit": "Enviar",
"enter_the_code_you_received_to_sign_in": "Enter the code you received to sign in.", "enter_the_code_you_received_to_sign_in": "Digite o código que você recebeu para entrar.",
"code": "Código", "code": "Código",
"invalid_redirect_url": "Invalid redirect URL", "invalid_redirect_url": "URL de redirecionamento inválido",
"audit_log": "Registro de Auditoria", "audit_log": "Registro de Auditoria",
"users": "Usuários", "users": "Usuários",
"user_groups": "Grupo de Usuários", "user_groups": "Grupo de Usuários",
@@ -94,7 +94,7 @@
"update_pocket_id": "Atualizar Pocket ID", "update_pocket_id": "Atualizar Pocket ID",
"powered_by": "Fornecido por", "powered_by": "Fornecido por",
"see_your_account_activities_from_the_last_3_months": "Veja suas atividades de conta dos últimos 3 meses.", "see_your_account_activities_from_the_last_3_months": "Veja suas atividades de conta dos últimos 3 meses.",
"time": "Time", "time": "Hora",
"event": "Evento", "event": "Evento",
"approximate_location": "Localização Aproximada", "approximate_location": "Localização Aproximada",
"ip_address": "Endereço de IP", "ip_address": "Endereço de IP",
@@ -104,15 +104,15 @@
"account_details_updated_successfully": "Detalhes da conta atualizados com sucesso", "account_details_updated_successfully": "Detalhes da conta atualizados com sucesso",
"profile_picture_updated_successfully": "Foto do perfil atualizada com sucesso. Pode demorar alguns minutos para atualizar.", "profile_picture_updated_successfully": "Foto do perfil atualizada com sucesso. Pode demorar alguns minutos para atualizar.",
"account_settings": "Configurações de Conta", "account_settings": "Configurações de Conta",
"passkey_missing": "Passkey missing", "passkey_missing": "Chave de acesso ausente",
"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": "Adicione uma senha para evitar perder o acesso à sua conta.",
"single_passkey_configured": "Single Passkey Configured", "single_passkey_configured": "Chave ú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": "É melhor adicionar mais de uma senha de acesso pra evitar perder o acesso à sua conta.",
"account_details": "Detalhes da Conta", "account_details": "Detalhes da Conta",
"passkeys": "Passkeys", "passkeys": "Chaves-mestras",
"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": "Gerencie as chaves de acesso que você pode usar para se autenticar.",
"add_passkey": "Add Passkey", "add_passkey": "Adicionar chave de acesso",
"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": "Crie um código de login único para entrar em outro dispositivo sem precisar de uma senha.",
"create": "Criar", "create": "Criar",
"first_name": "Primeiro nome", "first_name": "Primeiro nome",
"last_name": "Último nome", "last_name": "Último nome",
@@ -124,299 +124,300 @@
"added_on": "Adicionado em", "added_on": "Adicionado em",
"rename": "Renomear", "rename": "Renomear",
"delete": "Apagar", "delete": "Apagar",
"are_you_sure_you_want_to_delete_this_passkey": "Are you sure you want to delete this passkey?", "are_you_sure_you_want_to_delete_this_passkey": "Tem certeza que quer apagar essa chave de acesso?",
"passkey_deleted_successfully": "Passkey deleted successfully", "passkey_deleted_successfully": "Chave de acesso apagada com sucesso",
"delete_passkey_name": "Delete {passkeyName}", "delete_passkey_name": "Apagar {passkeyName}",
"passkey_name_updated_successfully": "Passkey name updated successfully", "passkey_name_updated_successfully": "Nome da chave de acesso atualizado com sucesso",
"name_passkey": "Name Passkey", "name_passkey": "Nome da chave de acesso",
"name_your_passkey_to_easily_identify_it_later": "Name your passkey to easily identify it later.", "name_your_passkey_to_easily_identify_it_later": "Dê um nome à sua chave de acesso para identificá-la facilmente mais tarde.",
"create_api_key": "Create API Key", "create_api_key": "Criar chave API",
"add_a_new_api_key_for_programmatic_access": "Add a new API key for programmatic access.", "add_a_new_api_key_for_programmatic_access": "Adiciona uma nova chave API para acesso programático.",
"add_api_key": "Add API Key", "add_api_key": "Adicionar chave API",
"manage_api_keys": "Manage API Keys", "manage_api_keys": "Gerenciar chaves API",
"api_key_created": "API Key Created", "api_key_created": "Chave API criada",
"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 segurança, essa chave só vai aparecer uma vez. Guarde-a em um lugar seguro.",
"description": "Descrição", "description": "Descrição",
"api_key": "API Key", "api_key": "Chave API",
"close": "Fechar", "close": "Fechar",
"name_to_identify_this_api_key": "Name to identify this API key.", "name_to_identify_this_api_key": "Nome pra identificar essa chave API.",
"expires_at": "Expires At", "expires_at": "Vence em",
"when_this_api_key_will_expire": "When this API key will expire.", "when_this_api_key_will_expire": "Quando essa chave API vai expirar.",
"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": "Descrição opcional para ajudar a identificar a finalidade desta chave.",
"expiration_date_must_be_in_the_future": "Expiration date must be in the future", "expiration_date_must_be_in_the_future": "A data de validade precisa ser no futuro.",
"revoke_api_key": "Revoke API Key", "revoke_api_key": "Revogar chave API",
"never": "Nunca", "never": "Nunca",
"revoke": "Revogar", "revoke": "Revogar",
"api_key_revoked_successfully": "API key revoked successfully", "api_key_revoked_successfully": "Chave API revogada com sucesso",
"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": "Tem certeza que quer cancelar a chave API “{apiKeyName}”? Isso vai desligar todas as integrações que usam essa chave.",
"last_used": "Last Used", "last_used": "Último uso",
"actions": "Ações", "actions": "Ações",
"images_updated_successfully": "Imagens atualizadas com sucesso", "images_updated_successfully": "Imagens atualizadas com sucesso",
"general": "Geral", "general": "Geral",
"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": "Ative as notificações por e-mail para avisar os usuários quando um login for detectado em um novo dispositivo ou local.",
"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": "Configure as definições LDAP para sincronizar usuários e grupos de um servidor LDAP.",
"images": "Imagens", "images": "Imagens",
"update": "Update", "update": "Atualização",
"email_configuration_updated_successfully": "Email configuration updated successfully", "email_configuration_updated_successfully": "Configuração do e-mail atualizada com sucesso",
"save_changes_question": "Salvar alterações?", "save_changes_question": "Salvar alterações?",
"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?", "you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "Você precisa salvar as alterações antes de enviar um e-mail de teste. Quer salvar agora?",
"save_and_send": "Salvar e enviar", "save_and_send": "Salvar e enviar",
"test_email_sent_successfully": "Test email sent successfully to your email address.", "test_email_sent_successfully": "E-mail de teste enviado com sucesso para o seu endereço de e-mail.",
"failed_to_send_test_email": "Failed to send test email. Check the server logs for more information.", "failed_to_send_test_email": "Não deu certo enviar o e-mail de teste. Dá uma olhada nos registros do servidor pra saber mais.",
"smtp_configuration": "SMTP Configuration", "smtp_configuration": "Configuração SMTP",
"smtp_host": "SMTP Host", "smtp_host": "Host SMTP",
"smtp_port": "SMTP Port", "smtp_port": "Porta SMTP",
"smtp_user": "SMTP User", "smtp_user": "Usuário SMTP",
"smtp_password": "SMTP Password", "smtp_password": "Senha SMTP",
"smtp_from": "SMTP From", "smtp_from": "SMTP De",
"smtp_tls_option": "SMTP TLS Option", "smtp_tls_option": "Opção SMTP TLS",
"email_tls_option": "Email TLS Option", "email_tls_option": "Opção TLS para e-mail",
"skip_certificate_verification": "Skip Certificate Verification", "skip_certificate_verification": "Pular a verificação do certificado",
"this_can_be_useful_for_selfsigned_certificates": "This can be useful for self-signed certificates.", "this_can_be_useful_for_selfsigned_certificates": "Isso pode ser útil para certificados autoassinados.",
"enabled_emails": "Enabled Emails", "enabled_emails": "E-mails ativados",
"email_login_notification": "Email Login Notification", "email_login_notification": "Notificação de login por e-mail",
"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": "Manda um e-mail pro usuário quando ele entrar com um novo aparelho.",
"emai_login_code_requested_by_user": "Email Login Code Requested by User", "emai_login_code_requested_by_user": "Código de login por e-mail solicitado pelo usuário",
"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.", "allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Permite que os usuários pulem as senhas pedindo um código de login que vai pro e-mail deles. Isso deixa a segurança bem mais fraca, já que qualquer um que tiver acesso ao e-mail do usuário pode entrar.",
"email_login_code_from_admin": "Email Login Code from Admin", "email_login_code_from_admin": "Código de login por e-mail do administrador",
"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": "Permite que um administrador envie um código de login pro usuário por e-mail.",
"send_test_email": "Send test email", "send_test_email": "Enviar e-mail de teste",
"application_configuration_updated_successfully": "Application configuration updated successfully", "application_configuration_updated_successfully": "A configuração do aplicativo foi atualizada com sucesso.",
"application_name": "Application Name", "application_name": "Nome do aplicativo",
"session_duration": "Session Duration", "session_duration": "Duração da sessão",
"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.", "the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "O tempo que dura uma sessão, em minutos, antes que o usuário precise fazer login de novo.",
"enable_self_account_editing": "Enable Self-Account Editing", "enable_self_account_editing": "Ativar edição da conta pessoal",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Whether the users should be able to edit their own account details.", "whether_the_users_should_be_able_to_edit_their_own_account_details": "Se os usuários podem editar os detalhes da conta deles.",
"emails_verified": "Emails Verified", "emails_verified": "E-mails verificados",
"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.", "whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Se o e-mail do usuário deve ser marcado como verificado para os clientes OIDC.",
"ldap_configuration_updated_successfully": "LDAP configuration updated successfully", "ldap_configuration_updated_successfully": "Configuração LDAP atualizada com sucesso",
"ldap_disabled_successfully": "LDAP disabled successfully", "ldap_disabled_successfully": "LDAP desativado com sucesso",
"ldap_sync_finished": "LDAP sync finished", "ldap_sync_finished": "Sincronização LDAP concluída",
"client_configuration": "Client Configuration", "client_configuration": "Configuração do cliente",
"ldap_url": "LDAP URL", "ldap_url": "URL LDAP",
"ldap_bind_dn": "LDAP Bind DN", "ldap_bind_dn": "DN de ligação LDAP",
"ldap_bind_password": "LDAP Bind Password", "ldap_bind_password": "Senha de ligação LDAP",
"ldap_base_dn": "LDAP Base DN", "ldap_base_dn": "DN base LDAP",
"user_search_filter": "User Search Filter", "user_search_filter": "Filtro de pesquisa de usuários",
"the_search_filter_to_use_to_search_or_sync_users": "The Search filter to use to search/sync users.", "the_search_filter_to_use_to_search_or_sync_users": "O filtro de pesquisa que você usa pra procurar/sincronizar usuários.",
"groups_search_filter": "Groups Search Filter", "groups_search_filter": "Filtro de pesquisa de grupos",
"the_search_filter_to_use_to_search_or_sync_groups": "The Search filter to use to search/sync groups.", "the_search_filter_to_use_to_search_or_sync_groups": "O filtro de pesquisa que você usa pra procurar/sincronizar grupos.",
"attribute_mapping": "Attribute Mapping", "attribute_mapping": "Mapeamento de atributos",
"user_unique_identifier_attribute": "User Unique Identifier Attribute", "user_unique_identifier_attribute": "Atributo de identificador único do usuário",
"the_value_of_this_attribute_should_never_change": "The value of this attribute should never change.", "the_value_of_this_attribute_should_never_change": "O valor desse atributo nunca deve mudar.",
"username_attribute": "Username Attribute", "username_attribute": "Atributo do nome de usuário",
"user_mail_attribute": "User Mail Attribute", "user_mail_attribute": "Atributo de e-mail do usuário",
"user_first_name_attribute": "User First Name Attribute", "user_first_name_attribute": "Atributo do primeiro nome do usuário",
"user_last_name_attribute": "User Last Name Attribute", "user_last_name_attribute": "Atributo do sobrenome do usuário",
"user_profile_picture_attribute": "User Profile Picture Attribute", "user_profile_picture_attribute": "Atributo da foto do perfil do usuário",
"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.", "the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "O valor desse atributo pode ser uma URL, um binário ou uma imagem codificada em base64.",
"group_members_attribute": "Group Members Attribute", "group_members_attribute": "Atributo dos membros do grupo",
"the_attribute_to_use_for_querying_members_of_a_group": "The attribute to use for querying members of a group.", "the_attribute_to_use_for_querying_members_of_a_group": "O atributo a ser usado para consultar membros de um grupo.",
"group_unique_identifier_attribute": "Group Unique Identifier Attribute", "group_unique_identifier_attribute": "Atributo identificador exclusivo do grupo",
"group_name_attribute": "Group Name Attribute", "group_name_attribute": "Atributo do nome do grupo",
"admin_group_name": "Admin Group Name", "admin_group_name": "Nome do grupo de administradores",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Members of this group will have Admin Privileges in Pocket ID.", "members_of_this_group_will_have_admin_privileges_in_pocketid": "Os membros desse grupo vão ter privilégios de administrador no Pocket ID.",
"disable": "Desativar", "disable": "Desativar",
"sync_now": "Sincronizar agora", "sync_now": "Sincronizar agora",
"enable": "Enable", "enable": "Ativar",
"user_created_successfully": "User created successfully", "user_created_successfully": "Usuário criado com sucesso",
"create_user": "Criar Usuário", "create_user": "Criar Usuário",
"add_a_new_user_to_appname": "Adicionar um novo usuário para {appName}", "add_a_new_user_to_appname": "Adicionar um novo usuário para {appName}",
"add_user": "Adicionar Usuário", "add_user": "Adicionar Usuário",
"manage_users": "Gerenciar Usuários", "manage_users": "Gerenciar Usuários",
"admin_privileges": "Admin Privileges", "admin_privileges": "Privilégios de administrador",
"admins_have_full_access_to_the_admin_panel": "Admins have full access to the admin panel.", "admins_have_full_access_to_the_admin_panel": "Os administradores têm acesso total ao painel de administração.",
"delete_firstname_lastname": "Delete {firstName} {lastName}", "delete_firstname_lastname": "Apagar {firstName} {lastName}",
"are_you_sure_you_want_to_delete_this_user": "Are you sure you want to delete this user?", "are_you_sure_you_want_to_delete_this_user": "Tem certeza que quer excluir esse usuário?",
"user_deleted_successfully": "User deleted successfully", "user_deleted_successfully": "Usuário excluído com sucesso",
"role": "Role", "role": "Função",
"source": "Source", "source": "Fonte",
"admin": "Admin", "admin": "Admin",
"user": "User", "user": "Usuário",
"local": "Local", "local": "Local",
"toggle_menu": "Toggle menu", "toggle_menu": "Alternar menu",
"edit": "Editar", "edit": "Editar",
"user_groups_updated_successfully": "User groups updated successfully", "user_groups_updated_successfully": "Grupos de usuários atualizados com sucesso",
"user_updated_successfully": "User updated successfully", "user_updated_successfully": "Usuário atualizado com sucesso",
"custom_claims_updated_successfully": "Custom claims updated successfully", "custom_claims_updated_successfully": "Reclamações personalizadas atualizadas com sucesso",
"back": "Voltar", "back": "Voltar",
"user_details_firstname_lastname": "User Details {firstName} {lastName}", "user_details_firstname_lastname": "Detalhes do usuário {firstName} {lastName}",
"manage_which_groups_this_user_belongs_to": "Manage which groups this user belongs to.", "manage_which_groups_this_user_belongs_to": "Controle quais grupos esse usuário faz parte.",
"custom_claims": "Custom Claims", "custom_claims": "Reclamações personalizadas",
"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.", "custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "As reivindicações personalizadas são pares de chave-valor que podem ser usados para guardar informações adicionais sobre um usuário. Essas reivindicações serão incluídas no token de identificação se o escopo “perfil” for solicitado.",
"user_group_created_successfully": "User group created successfully", "user_group_created_successfully": "Grupo de usuários criado com sucesso",
"create_user_group": "Create User Group", "create_user_group": "Criar Grupo de Usuários",
"create_a_new_group_that_can_be_assigned_to_users": "Create a new group that can be assigned to users.", "create_a_new_group_that_can_be_assigned_to_users": "Crie um novo grupo que possa ser atribuído aos usuários.",
"add_group": "Adicionar Grupo", "add_group": "Adicionar Grupo",
"manage_user_groups": "Manage User Groups", "manage_user_groups": "Gerenciar grupos de usuários",
"friendly_name": "Nome Amigável", "friendly_name": "Nome Amigável",
"name_that_will_be_displayed_in_the_ui": "Name that will be displayed in the UI", "name_that_will_be_displayed_in_the_ui": "Nome que vai aparecer na interface do usuário",
"name_that_will_be_in_the_groups_claim": "Name that will be in the \"groups\" claim", "name_that_will_be_in_the_groups_claim": "Nome que vai aparecer na reivindicação “grupos”",
"delete_name": "Delete {name}", "delete_name": "Apagar {name}",
"are_you_sure_you_want_to_delete_this_user_group": "Are you sure you want to delete this user group?", "are_you_sure_you_want_to_delete_this_user_group": "Tem certeza que quer excluir esse grupo de usuários?",
"user_group_deleted_successfully": "User group deleted successfully", "user_group_deleted_successfully": "Grupo de usuários excluído com sucesso",
"user_count": "User Count", "user_count": "Contagem de usuários",
"user_group_updated_successfully": "User group updated successfully", "user_group_updated_successfully": "Grupo de usuários atualizado com sucesso",
"users_updated_successfully": "Users updated successfully", "users_updated_successfully": "Usuários atualizados com sucesso",
"user_group_details_name": "User Group Details {name}", "user_group_details_name": "Detalhes do grupo de usuários {name}",
"assign_users_to_this_group": "Assign users to this group.", "assign_users_to_this_group": "Adicione usuários a este grupo.",
"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.", "custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "As reivindicações personalizadas são pares de chave-valor que podem ser usados para guardar informações adicionais sobre um usuário. Essas reivindicações serão incluídas no token de identificação se o escopo “perfil” for solicitado. As reivindicações personalizadas definidas no usuário serão priorizadas se houver conflitos.",
"oidc_client_created_successfully": "OIDC client created successfully", "oidc_client_created_successfully": "Cliente OIDC criado com sucesso",
"create_oidc_client": "Create OIDC Client", "create_oidc_client": "Criar cliente OIDC",
"add_a_new_oidc_client_to_appname": "Add a new OIDC client to {appName}.", "add_a_new_oidc_client_to_appname": "Adicione um novo cliente OIDC em {appName}.",
"add_oidc_client": "Add OIDC Client", "add_oidc_client": "Adicionar cliente OIDC",
"manage_oidc_clients": "Manage OIDC Clients", "manage_oidc_clients": "Gerenciar clientes OIDC",
"one_time_link": "One Time Link", "one_time_link": "Link único",
"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": "Use este link para fazer login uma vez. Isso é necessário para usuários que ainda não adicionaram uma chave de acesso ou que a perderam.",
"add": "Adicionar", "add": "Adicionar",
"callback_urls": "Callback URLs", "callback_urls": "URLs de retorno de chamada",
"logout_callback_urls": "Logout Callback URLs", "logout_callback_urls": "URLs de retorno de chamada de logout",
"public_client": "Public Client", "public_client": "Cliente Público",
"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.", "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", "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.", "public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "A troca de chaves públicas é um recurso de segurança que evita ataques CSRF e interceptação de códigos de autorização.",
"name_logo": "{name} logo", "name_logo": "{name} logotipo",
"change_logo": "Change Logo", "change_logo": "Alterar logotipo",
"upload_logo": "Upload Logo", "upload_logo": "Carregar logotipo",
"remove_logo": "Remove Logo", "remove_logo": "Tirar o logotipo",
"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": "Tem certeza que quer apagar esse cliente OIDC?",
"oidc_client_deleted_successfully": "OIDC client deleted successfully", "oidc_client_deleted_successfully": "Cliente OIDC excluído com sucesso",
"authorization_url": "Authorization URL", "authorization_url": "URL de autorização",
"oidc_discovery_url": "OIDC Discovery URL", "oidc_discovery_url": "URL de descoberta OIDC",
"token_url": "Token URL", "token_url": "URL do token",
"userinfo_url": "Userinfo URL", "userinfo_url": "URL de informações do usuário",
"logout_url": "Logout URL", "logout_url": "URL de logout",
"certificate_url": "Certificate URL", "certificate_url": "URL do certificado",
"enabled": "Habilitado", "enabled": "Habilitado",
"disabled": "Disabled", "disabled": "Deficientes",
"oidc_client_updated_successfully": "OIDC client updated successfully", "oidc_client_updated_successfully": "Cliente OIDC atualizado com sucesso",
"create_new_client_secret": "Create new client secret", "create_new_client_secret": "Criar novo segredo do cliente",
"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.", "are_you_sure_you_want_to_create_a_new_client_secret": "Tem certeza que quer criar um novo segredo de cliente? O antigo vai ser invalidado.",
"generate": "Generate", "generate": "Gerar",
"new_client_secret_created_successfully": "New client secret created successfully", "new_client_secret_created_successfully": "Novo segredo do cliente criado com sucesso",
"allowed_user_groups_updated_successfully": "Allowed user groups updated successfully", "allowed_user_groups_updated_successfully": "Grupos de usuários permitidos atualizados com sucesso",
"oidc_client_name": "OIDC Client {name}", "oidc_client_name": "Cliente OIDC {name}",
"client_id": "Client ID", "client_id": "ID do cliente",
"client_secret": "Client secret", "client_secret": "Segredo do cliente",
"show_more_details": "Show more details", "show_more_details": "Mostrar mais detalhes",
"allowed_user_groups": "Allowed User Groups", "allowed_user_groups": "Grupos de usuários permitidos",
"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": "Adicione grupos de usuários a este cliente para restringir o acesso aos usuários desses grupos. Se nenhum grupo de usuários for selecionado, todos os usuários terão acesso a este cliente.",
"favicon": "Favicon", "favicon": "Favicon",
"light_mode_logo": "Light Mode Logo", "light_mode_logo": "Logotipo do modo claro",
"dark_mode_logo": "Dark Mode Logo", "dark_mode_logo": "Logotipo do Modo Escuro",
"background_image": "Background Image", "background_image": "Imagem de fundo",
"language": "Idioma", "language": "Idioma",
"reset_profile_picture_question": "Reset profile picture?", "reset_profile_picture_question": "Queres redefinir a tua foto de perfil?",
"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": "Isso vai tirar a foto que você mandou e voltar a foto do perfil pro padrão. Quer mesmo continuar?",
"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": "A foto do perfil foi redefinida. A atualização pode demorar alguns minutos.",
"select_the_language_you_want_to_use": "Select the language you want to use. Some languages may not be fully translated.", "select_the_language_you_want_to_use": "Escolha o idioma que você quer usar. Lembre-se de que alguns textos podem ser traduzidos automaticamente e podem não estar certos.",
"personal": "Personal", "contribute_to_translation": "Se você encontrar algum problema, pode ajudar na tradução no <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
"personal": "Pessoal",
"global": "Global", "global": "Global",
"all_users": "All Users", "all_users": "Todos os usuários",
"all_events": "All Events", "all_events": "Todos os eventos",
"all_clients": "All Clients", "all_clients": "Todos os clientes",
"all_locations": "All Locations", "all_locations": "Todos os locais",
"global_audit_log": "Global Audit Log", "global_audit_log": "Registro de auditoria global",
"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": "Dá uma olhada em tudo que os usuários fizeram nos últimos 3 meses.",
"token_sign_in": "Token Sign In", "token_sign_in": "Entrar com token",
"client_authorization": "Client Authorization", "client_authorization": "Autorização do cliente",
"new_client_authorization": "New Client Authorization", "new_client_authorization": "Autorização de novo cliente",
"disable_animations": "Disable Animations", "disable_animations": "Desativar animações",
"turn_off_ui_animations": "Turn off animations throughout the UI.", "turn_off_ui_animations": "Desligue as animações em toda a interface do usuário.",
"user_disabled": "Account Disabled", "user_disabled": "Conta desativada",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.", "disabled_users_cannot_log_in_or_use_services": "Usuários com deficiência não conseguem fazer login ou usar os serviços.",
"user_disabled_successfully": "User has been disabled successfully.", "user_disabled_successfully": "O usuário foi desativado com sucesso.",
"user_enabled_successfully": "User has been enabled successfully.", "user_enabled_successfully": "O usuário foi ativado com sucesso.",
"status": "Status", "status": "Status",
"disable_firstname_lastname": "Disable {firstName} {lastName}", "disable_firstname_lastname": "Desativar {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.", "are_you_sure_you_want_to_disable_this_user": "Tem certeza que quer desativar esse usuário? Ele não vai conseguir entrar nem acessar nenhum serviço.",
"ldap_soft_delete_users": "Keep disabled users from LDAP.", "ldap_soft_delete_users": "Impedir que usuários desativados acessem o LDAP.",
"ldap_soft_delete_users_description": "When enabled, users removed from LDAP will be disabled rather than deleted from the system.", "ldap_soft_delete_users_description": "Quando ativada, os usuários removidos do LDAP serão desativados em vez de excluídos do sistema.",
"login_code_email_success": "The login code has been sent to the user.", "login_code_email_success": "O código de login foi enviado para o usuário.",
"send_email": "Send Email", "send_email": "Enviar e-mail",
"show_code": "Show Code", "show_code": "Mostrar código",
"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.", "callback_url_description": "URL(s) fornecido(s) pelo seu cliente. Vai ser adicionado automaticamente se você deixar em branco. Caracteres curinga (*) são aceitos, mas é melhor evitar para garantir mais segurança.",
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.", "logout_callback_url_description": "URL(s) que seu cliente deu pra sair da conta. Você pode usar curingas (*), mas é melhor evitar pra ficar mais seguro.",
"api_key_expiration": "API Key Expiration", "api_key_expiration": "Expiração da chave API",
"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": "Manda um e-mail pro usuário quando a chave API dele estiver quase a expirar.",
"authorize_device": "Authorize Device", "authorize_device": "Autorizar dispositivo",
"the_device_has_been_authorized": "The device has been authorized.", "the_device_has_been_authorized": "O dispositivo foi autorizado.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.", "enter_code_displayed_in_previous_step": "Digite o código que apareceu na etapa anterior.",
"authorize": "Authorize", "authorize": "Autorizar",
"federated_client_credentials": "Federated Client Credentials", "federated_client_credentials": "Credenciais de Cliente Federadas",
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.", "federated_client_credentials_description": "Usando credenciais de cliente federadas, você pode autenticar clientes OIDC usando tokens JWT emitidos por autoridades de terceiros.",
"add_federated_client_credential": "Add Federated Client Credential", "add_federated_client_credential": "Adicionar credencial de cliente federado",
"add_another_federated_client_credential": "Add another federated client credential", "add_another_federated_client_credential": "Adicionar outra credencial de cliente federado",
"oidc_allowed_group_count": "Allowed Group Count", "oidc_allowed_group_count": "Contagem de grupos permitidos",
"unrestricted": "Unrestricted", "unrestricted": "Sem restrições",
"show_advanced_options": "Show Advanced Options", "show_advanced_options": "Mostrar opções avançadas",
"hide_advanced_options": "Hide Advanced Options", "hide_advanced_options": "Ocultar opções avançadas",
"oidc_data_preview": "OIDC Data Preview", "oidc_data_preview": "Pré-visualização dos dados OIDC",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users", "preview_the_oidc_data_that_would_be_sent_for_different_users": "Dá uma olhada nos dados OIDC que seriam enviados para diferentes usuários",
"id_token": "ID Token", "id_token": "Token de identificação",
"access_token": "Access Token", "access_token": "Token de acesso",
"userinfo": "Userinfo", "userinfo": "Informações do usuário",
"id_token_payload": "ID Token Payload", "id_token_payload": "Carga útil do token de identificação",
"access_token_payload": "Access Token Payload", "access_token_payload": "Carga útil do token de acesso",
"userinfo_endpoint_response": "Userinfo Endpoint Response", "userinfo_endpoint_response": "Resposta do ponto final da informação do usuário",
"copy": "Copy", "copy": "Copiar",
"no_preview_data_available": "No preview data available", "no_preview_data_available": "Não tem dados de pré-visualização disponíveis",
"copy_all": "Copy All", "copy_all": "Copiar tudo",
"preview": "Preview", "preview": "Pré-visualização",
"preview_for_user": "Preview for {name} ({email})", "preview_for_user": "Prévia de “ {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user", "preview_the_oidc_data_that_would_be_sent_for_this_user": "Dá uma olhada nos dados OIDC que seriam enviados para esse usuário.",
"show": "Show", "show": "Mostrar",
"select_an_option": "Select an option", "select_an_option": "Escolha uma opção",
"select_user": "Select User", "select_user": "Selecionar usuário",
"error": "Error", "error": "Erro",
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Select an accent color to customize the appearance of Pocket ID.", "select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Escolha uma cor de destaque pra personalizar a aparência do Pocket ID.",
"accent_color": "Accent Color", "accent_color": "Cor de destaque",
"custom_accent_color": "Custom Accent Color", "custom_accent_color": "Cor de destaque personalizada",
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).", "custom_accent_color_description": "Digite uma cor personalizada usando formatos de cor CSS válidos (por exemplo, hex, rgb, hsl).",
"color_value": "Color Value", "color_value": "Valor da cor",
"apply": "Apply", "apply": "Inscreva-se",
"signup_token": "Signup Token", "signup_token": "Token de inscrição",
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.", "create_a_signup_token_to_allow_new_user_registration": "Crie um token de inscrição para permitir o registro de novos usuários.",
"usage_limit": "Usage Limit", "usage_limit": "Limite de uso",
"number_of_times_token_can_be_used": "Number of times the signup token can be used.", "number_of_times_token_can_be_used": "Número de vezes que o token de inscrição pode ser usado.",
"expires": "Expires", "expires": "Vence",
"signup": "Sign Up", "signup": "Cadastre-se",
"signup_requires_valid_token": "A valid signup token is required to create an account", "signup_requires_valid_token": "É preciso um token de inscrição válido pra criar uma conta.",
"validating_signup_token": "Validating signup token", "validating_signup_token": "Validando o token de inscrição",
"go_to_login": "Go to login", "go_to_login": "Vá para o login",
"signup_to_appname": "Sign Up to {appName}", "signup_to_appname": "Cadastre-se em {appName}",
"create_your_account_to_get_started": "Create your account to get started.", "create_your_account_to_get_started": "Crie sua conta pra começar.",
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.", "initial_account_creation_description": "Crie sua conta pra começar. Você vai poder definir uma senha mais tarde.",
"setup_your_passkey": "Set up your passkey", "setup_your_passkey": "Configure sua chave de acesso",
"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.", "create_a_passkey_to_securely_access_your_account": "Crie uma senha para acessar sua conta com segurança. Essa vai ser sua principal forma de entrar.",
"skip_for_now": "Skip for now", "skip_for_now": "Pular por enquanto",
"account_created": "Account Created", "account_created": "Conta criada",
"enable_user_signups": "Enable User Signups", "enable_user_signups": "Ativar inscrições de usuários",
"enable_user_signups_description": "Whether the User Signup functionality should be enabled.", "enable_user_signups_description": "Se a funcionalidade de cadastro de usuários deve ser ativada.",
"user_signups_are_disabled": "User signups are currently disabled", "user_signups_are_disabled": "As inscrições de usuários estão desativadas no momento.",
"create_signup_token": "Create Signup Token", "create_signup_token": "Criar token de inscrição",
"view_active_signup_tokens": "View Active Signup Tokens", "view_active_signup_tokens": "Ver tokens de inscrição ativos",
"manage_signup_tokens": "Manage Signup Tokens", "manage_signup_tokens": "Gerenciar tokens de inscrição",
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.", "view_and_manage_active_signup_tokens": "Veja e gerencie os tokens de inscrição ativos.",
"signup_token_deleted_successfully": "Signup token deleted successfully.", "signup_token_deleted_successfully": "Token de inscrição apagado com sucesso.",
"expired": "Expired", "expired": "Expirado",
"used_up": "Used Up", "used_up": "Gasto",
"active": "Active", "active": "Ativo",
"usage": "Usage", "usage": "Como usar",
"created": "Created", "created": "Criado",
"token": "Token", "token": "Token",
"loading": "Loading", "loading": "Carregando",
"delete_signup_token": "Delete Signup Token", "delete_signup_token": "Apagar token de inscrição",
"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.", "are_you_sure_you_want_to_delete_this_signup_token": "Tem certeza que quer apagar esse token de inscrição? Não dá pra voltar atrás.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.", "signup_disabled_description": "As inscrições de usuários estão totalmente desativadas. Só os administradores podem criar novas contas de usuário.",
"signup_with_token": "Signup with token", "signup_with_token": "Cadastre-se com token",
"signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.", "signup_with_token_description": "Os usuários só podem se cadastrar usando um token de cadastro válido criado por um administrador.",
"signup_open": "Open Signup", "signup_open": "Inscrição aberta",
"signup_open_description": "Anyone can create a new account without restrictions.", "signup_open_description": "Qualquer pessoa pode criar uma conta nova sem restrições.",
"of": "of", "of": "de",
"skip_passkey_setup": "Skip Passkey Setup", "skip_passkey_setup": "Pular configuração da chave de acesso",
"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." "skip_passkey_setup_description": "É super recomendável criar uma senha de acesso, porque sem ela você vai ficar sem poder entrar na sua conta assim que a sessão acabar."
} }

View File

@@ -37,7 +37,7 @@
"generate_code": "Сгенерировать код", "generate_code": "Сгенерировать код",
"name": "Имя", "name": "Имя",
"browser_unsupported": "Браузер не поддерживается", "browser_unsupported": "Браузер не поддерживается",
"this_browser_does_not_support_passkeys": "Этот браузер не поддерживает passkey. Пожалуйста, воспользуйтесь альтернативным способом входа.", "this_browser_does_not_support_passkeys": "Этот браузер не поддерживает ключи доступа. Пожалуйста, воспользуйтесь альтернативным способом входа.",
"an_unknown_error_occurred": "Произошла неизвестная ошибка", "an_unknown_error_occurred": "Произошла неизвестная ошибка",
"authentication_process_was_aborted": "Процесс аутентификации был прерван", "authentication_process_was_aborted": "Процесс аутентификации был прерван",
"error_occurred_with_authenticator": "С аутентификатором произошла ошибка", "error_occurred_with_authenticator": "С аутентификатором произошла ошибка",
@@ -65,7 +65,7 @@
"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_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.", "authenticate_with_passkey_to_access_account": "Авторизуйтесь с использованием passkey для доступа к вашей учетной записи.",
"authenticate": "Авторизоваться", "authenticate": "Авторизоваться",
"please_try_again": "Пожалуйста, повторите попытку.", "please_try_again": "Пожалуйста, повторите попытку.",
"continue": "Продолжить", "continue": "Продолжить",
@@ -107,11 +107,11 @@
"passkey_missing": "Passkey отсутствует", "passkey_missing": "Passkey отсутствует",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Пожалуйста, добавьте passkey, чтобы избежать утери доступа к вашей учетной записи.", "please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Пожалуйста, добавьте passkey, чтобы избежать утери доступа к вашей учетной записи.",
"single_passkey_configured": "Настроен один passkey", "single_passkey_configured": "Настроен один passkey",
"it_is_recommended_to_add_more_than_one_passkey": "Рекомендуется добавить более одного passkey во избежание потери доступа к вашей учетной записи.", "it_is_recommended_to_add_more_than_one_passkey": "Рекомендуется добавить более одного ключа доступа во избежание потери доступа к вашей учетной записи.",
"account_details": "Детали учетной записи", "account_details": "Детали учетной записи",
"passkeys": "Passkeys", "passkeys": "Ключи доступа",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Управляйте passkeys, которые вы можете использовать для аутентификации себя.", "manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Управляйте ключами доступа, которые вы можете использовать для аутентификации.",
"add_passkey": "Добавить Passkey", "add_passkey": "Добавить ключ",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Создайте одноразовый код входа, чтобы войти с другого устройства без passkey.", "create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Создайте одноразовый код входа, чтобы войти с другого устройства без passkey.",
"create": "Создать", "create": "Создать",
"first_name": "Имя", "first_name": "Имя",
@@ -178,7 +178,7 @@
"email_login_notification": "Уведомление о логине по электронной почте", "email_login_notification": "Уведомление о логине по электронной почте",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Отправлять пользователю письмо при входе с нового устройства.", "send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Отправлять пользователю письмо при входе с нового устройства.",
"emai_login_code_requested_by_user": "Код входа по электронной почте, запрошенный пользователем", "emai_login_code_requested_by_user": "Код входа по электронной почте, запрошенный пользователем",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Позволяет пользователям обходить вход через passkey, запросив код входа, отправляемый на их электронную почту. Это значительно снижает безопасность так как любой человек, имеющий доступ к электронной почте пользователя, может получить доступ.", "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": "Отправить тестовое письмо",
@@ -244,7 +244,7 @@
"user_details_firstname_lastname": "Данные пользователя {firstName} {lastName}", "user_details_firstname_lastname": "Данные пользователя {firstName} {lastName}",
"manage_which_groups_this_user_belongs_to": "Управление группами, к которым принадлежит этот пользователь.", "manage_which_groups_this_user_belongs_to": "Управление группами, к которым принадлежит этот пользователь.",
"custom_claims": "Пользовательские claims", "custom_claims": "Пользовательские claims",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Пользовательские claims — это пары ключ-значение, которые могут использоваться для хранения дополнительной информации о пользователе. Эти пары будут включены в ID Token при запросе scope 'profile'.", "custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Пользовательские claims — это пары ключ-значение, которые могут использоваться для хранения дополнительной информации о пользователе. Эти пары будут включены в ID Token при запросе scope \"profile\".",
"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": "Создайте новую группу, которая может быть назначена пользователям.",
@@ -252,7 +252,7 @@
"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": "Название, которое будет в 'groups' claim", "name_that_will_be_in_the_groups_claim": "Название, которое будет в 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": "Группа пользователей успешно удалена",
@@ -261,7 +261,7 @@
"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": "Пользовательские claims — это пары ключ-значение, которые могут использоваться для хранения дополнительной информации о пользователе. Эти пары будут включены в ID Token при запросе scope 'profile'. Пользовательские claims, определенные для пользователя, в случае конфликта будут приоритизированы.", "custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Пользовательские claims — это пары ключ-значение, которые могут использоваться для хранения дополнительной информации о пользователе. Эти пары будут включены в ID Token при запросе scope \"profile\". Пользовательские claims, определенные для пользователя, в случае конфликта будут приоритизированы.",
"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": "Добавить новый OIDC клиент в {appName}.", "add_a_new_oidc_client_to_appname": "Добавить новый OIDC клиент в {appName}.",
@@ -289,7 +289,7 @@
"logout_url": "Logout URL", "logout_url": "Logout URL",
"certificate_url": "Certificate URL", "certificate_url": "Certificate URL",
"enabled": "Включен", "enabled": "Включен",
"disabled": "Выключен", "disabled": "Отключено",
"oidc_client_updated_successfully": "OIDC клиент успешно обновлен", "oidc_client_updated_successfully": "OIDC клиент успешно обновлен",
"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": "Вы уверены, что хотите создать новый клиентский секрет? Старый будет аннулирован.",
@@ -312,7 +312,8 @@
"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": "Выберите язык, который вы хотите использовать. Обратите внимание на то, что часть текста может быть переведена автоматически и содержать неточности.",
"contribute_to_translation": "Если вы нашли ошибку, приглашаем вас помочь с переводом на <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
"personal": "Персональный", "personal": "Персональный",
"global": "Глобальный", "global": "Глобальный",
"all_users": "Все пользователи", "all_users": "Все пользователи",
@@ -326,7 +327,7 @@
"new_client_authorization": "Новая авторизация в клиенте", "new_client_authorization": "Новая авторизация в клиенте",
"disable_animations": "Отключить анимации", "disable_animations": "Отключить анимации",
"turn_off_ui_animations": "Выключить анимации по всему интерфейсу.", "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": "Пользователь успешно включен.",
@@ -378,45 +379,45 @@
"custom_accent_color_description": "Введите пользовательский цвет, используя правильные цветовые форматы CSS (например, hex, rgb, hsl).", "custom_accent_color_description": "Введите пользовательский цвет, используя правильные цветовые форматы CSS (например, hex, rgb, hsl).",
"color_value": "Значение цвета", "color_value": "Значение цвета",
"apply": "Применить", "apply": "Применить",
"signup_token": "Signup Token", "signup_token": "Токен регистрации",
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.", "create_a_signup_token_to_allow_new_user_registration": "Создайте токен регистрации, чтобы разрешить регистрацию нового пользователя.",
"usage_limit": "Usage Limit", "usage_limit": "Лимит использований",
"number_of_times_token_can_be_used": "Number of times the signup token can be used.", "number_of_times_token_can_be_used": "Количество раз, которое может быть использован токен регистрации.",
"expires": "Expires", "expires": "Истекает",
"signup": "Sign Up", "signup": "Зарегистрироваться",
"signup_requires_valid_token": "A valid signup token is required to create an account", "signup_requires_valid_token": "Для создания учетной записи необходим действительный токен регистрации",
"validating_signup_token": "Validating signup token", "validating_signup_token": "Проверка токена регистрации",
"go_to_login": "Go to login", "go_to_login": "Перейти ко входу",
"signup_to_appname": "Sign Up to {appName}", "signup_to_appname": "Зарегистрироваться в {appName}",
"create_your_account_to_get_started": "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.", "initial_account_creation_description": "Пожалуйста, создайте свою учетную запись, чтобы начать. Вы сможете настроить passkey позже.",
"setup_your_passkey": "Set up your passkey", "setup_your_passkey": "Настроить 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.", "create_a_passkey_to_securely_access_your_account": "Создайте passkey для безопасного доступа к учетной записи. Это будет ваш основной способ входа.",
"skip_for_now": "Skip for now", "skip_for_now": "Пока пропустить",
"account_created": "Account Created", "account_created": "Учетная запись создана",
"enable_user_signups": "Enable User Signups", "enable_user_signups": "Включить регистрацию пользователей",
"enable_user_signups_description": "Whether the User Signup functionality should be enabled.", "enable_user_signups_description": "Должна ли быть включена функция регистрации пользователя.",
"user_signups_are_disabled": "User signups are currently disabled", "user_signups_are_disabled": "Регистрация пользователей в настоящее время отключена",
"create_signup_token": "Create Signup Token", "create_signup_token": "Создать токен регистрации",
"view_active_signup_tokens": "View Active Signup Tokens", "view_active_signup_tokens": "Показать активные токены регистрации",
"manage_signup_tokens": "Manage Signup Tokens", "manage_signup_tokens": "Управление токенами регистрации",
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.", "view_and_manage_active_signup_tokens": "Просмотр и управление активными токенами регистрации.",
"signup_token_deleted_successfully": "Signup token deleted successfully.", "signup_token_deleted_successfully": "Токен регистрации успешно удалён.",
"expired": "Expired", "expired": "Истёк",
"used_up": "Used Up", "used_up": "Использован",
"active": "Active", "active": "Активен",
"usage": "Usage", "usage": "Использований",
"created": "Created", "created": "Создан",
"token": "Token", "token": "Токен",
"loading": "Loading", "loading": "Загрузка",
"delete_signup_token": "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.", "are_you_sure_you_want_to_delete_this_signup_token": "Вы уверены, что хотите удалить этот токен регистрации? Это действие нельзя отменить.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.", "signup_disabled_description": "Регистрация пользователей полностью отключена. Только администраторы могут создавать новые учетные записи пользователей.",
"signup_with_token": "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_with_token_description": "Пользователи могут зарегистрироваться только с помощью действительного токена регистрации, созданного администратором.",
"signup_open": "Open Signup", "signup_open": "Открытая регистрация",
"signup_open_description": "Anyone can create a new account without restrictions.", "signup_open_description": "Любой может создать новую учетную запись без ограничений.",
"of": "of", "of": "из",
"skip_passkey_setup": "Skip Passkey Setup", "skip_passkey_setup": "Пропустить настройку passkey",
"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." "skip_passkey_setup_description": "Настоятельно рекомендуется настроить passkey, так как без него вы более не сможете войти в учетную запись после истечения сессии."
} }

View File

@@ -1,11 +1,11 @@
{ {
"$schema": "", "$schema": "https://inlang.com/schema/inlang-message-format",
"my_account": "账户", "my_account": "账户",
"logout": "登出", "logout": "登出",
"confirm": "确认", "confirm": "确认",
"docs": "文档", "docs": "文档",
"key": "Key", "key": "键名",
"value": "Value", "value": "键值",
"remove_custom_claim": "删除自定义声明", "remove_custom_claim": "删除自定义声明",
"add_custom_claim": "添加自定义声明", "add_custom_claim": "添加自定义声明",
"add_another": "添加另一个", "add_another": "添加另一个",
@@ -17,7 +17,7 @@
"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": "点击复制",
@@ -25,7 +25,7 @@
"go_back_to_home": "返回首页", "go_back_to_home": "返回首页",
"dont_have_access_to_your_passkey": "无法使用您的通行密钥?试试其他登录方式。", "dont_have_access_to_your_passkey": "无法使用您的通行密钥?试试其他登录方式。",
"login_background": "登录页背景图", "login_background": "登录页背景图",
"logo": "Logo", "logo": "图标",
"login_code": "临时登录码", "login_code": "临时登录码",
"create_a_login_code_to_sign_in_without_a_passkey_once": "创建一个临时登录码,用户可以使用它一次性登录而无需通行密钥。", "create_a_login_code_to_sign_in_without_a_passkey_once": "创建一个临时登录码,用户可以使用它一次性登录而无需通行密钥。",
"one_hour": "1 小时", "one_hour": "1 小时",
@@ -65,7 +65,7 @@
"do_you_want_to_sign_out_of_pocketid_with_the_account": "您确定要退出 {appName} 应用中的帐号 <b>{username}</b> 吗?", "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_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.", "authenticate_with_passkey_to_access_account": "使用您的通行密钥认证以访问您的账户。",
"authenticate": "登录", "authenticate": "登录",
"please_try_again": "请再试一次。", "please_try_again": "请再试一次。",
"continue": "继续", "continue": "继续",
@@ -82,7 +82,7 @@
"your_email": "您的电子邮件", "your_email": "您的电子邮件",
"submit": "提交", "submit": "提交",
"enter_the_code_you_received_to_sign_in": "输入您收到的登录码以登录。", "enter_the_code_you_received_to_sign_in": "输入您收到的登录码以登录。",
"code": "Code", "code": "代码",
"invalid_redirect_url": "无效的重定向 URL", "invalid_redirect_url": "无效的重定向 URL",
"audit_log": "日志", "audit_log": "日志",
"users": "用户", "users": "用户",
@@ -92,7 +92,7 @@
"application_configuration": "设置", "application_configuration": "设置",
"settings": "设置", "settings": "设置",
"update_pocket_id": "更新 Pocket ID", "update_pocket_id": "更新 Pocket ID",
"powered_by": "Powered by", "powered_by": "",
"see_your_account_activities_from_the_last_3_months": "查看过去 3 个月的账户活动。", "see_your_account_activities_from_the_last_3_months": "查看过去 3 个月的账户活动。",
"time": "时间", "time": "时间",
"event": "事件", "event": "事件",
@@ -194,27 +194,27 @@
"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 地址",
"ldap_bind_dn": "LDAP Bind DN", "ldap_bind_dn": "LDAP 绑定用户专有名称",
"ldap_bind_password": "LDAP Bind Password", "ldap_bind_password": "LDAP 绑定密码",
"ldap_base_dn": "LDAP Base DN", "ldap_base_dn": "LDAP 基础用户专有名称",
"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": "群组搜索过滤器",
"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": "用户唯一标识属性",
"the_value_of_this_attribute_should_never_change": "此属性的值不应更改。", "the_value_of_this_attribute_should_never_change": "此属性的值不应更改。",
"username_attribute": "Username Attribute", "username_attribute": "用户名称属性",
"user_mail_attribute": "User Mail Attribute", "user_mail_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_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": "群组成员属性",
"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_name_attribute": "Group Name Attribute", "group_name_attribute": "群组名称属性",
"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": "禁用",
@@ -282,12 +282,12 @@
"remove_logo": "移除 Logo", "remove_logo": "移除 Logo",
"are_you_sure_you_want_to_delete_this_oidc_client": "您确定要删除此 OIDC 客户端吗?", "are_you_sure_you_want_to_delete_this_oidc_client": "您确定要删除此 OIDC 客户端吗?",
"oidc_client_deleted_successfully": "OIDC 客户端删除成功", "oidc_client_deleted_successfully": "OIDC 客户端删除成功",
"authorization_url": "Authorization URL", "authorization_url": "授权网址",
"oidc_discovery_url": "OIDC Discovery URL", "oidc_discovery_url": "OIDC发现网址",
"token_url": "Token URL", "token_url": "令牌网址",
"userinfo_url": "Userinfo URL", "userinfo_url": "用户信息网址",
"logout_url": "Logout URL", "logout_url": "登出网址",
"certificate_url": "Certificate URL", "certificate_url": "证书网址",
"enabled": "已启用", "enabled": "已启用",
"disabled": "已禁用", "disabled": "已禁用",
"oidc_client_updated_successfully": "OIDC 客户端更新成功", "oidc_client_updated_successfully": "OIDC 客户端更新成功",
@@ -312,13 +312,14 @@
"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": "选择您要使用的语言。请注意,部分文本可能已被自动翻译,翻译结果可能不准确。",
"contribute_to_translation": "如果您发现任何问题,欢迎在<link href='https://crowdin.com/project/pocket-id'>Crowdin上</link>参与翻译。",
"personal": "个人", "personal": "个人",
"global": "全局", "global": "全局",
"all_users": "所有用户", "all_users": "所有用户",
"all_events": "所有事件", "all_events": "所有事件",
"all_clients": "所有客户端", "all_clients": "所有客户端",
"all_locations": "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 登录",
@@ -356,9 +357,9 @@
"hide_advanced_options": "隐藏高级选项", "hide_advanced_options": "隐藏高级选项",
"oidc_data_preview": "OIDC 数据预览", "oidc_data_preview": "OIDC 数据预览",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "预览将发送给不同用户的 OIDC 数据", "preview_the_oidc_data_that_would_be_sent_for_different_users": "预览将发送给不同用户的 OIDC 数据",
"id_token": "ID Token", "id_token": "身份令牌",
"access_token": "Access Token", "access_token": "访问令牌",
"userinfo": "Userinfo", "userinfo": "用户信息",
"id_token_payload": "ID Token 有效载载", "id_token_payload": "ID Token 有效载载",
"access_token_payload": "Access Token 有效载载", "access_token_payload": "Access Token 有效载载",
"userinfo_endpoint_response": "Userinfo 端点响应", "userinfo_endpoint_response": "Userinfo 端点响应",
@@ -372,51 +373,51 @@
"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.", "select_an_accent_color_to_customize_the_appearance_of_pocket_id": "自定义一个主题颜色以定制 Pocket ID 的外观。",
"accent_color": "Accent Color", "accent_color": "主题颜色",
"custom_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).", "custom_accent_color_description": "输入自定义颜色必须使用有效的CSS颜色格式例如hexrgb,或者hsl)。",
"color_value": "Color Value", "color_value": "颜色值",
"apply": "Apply", "apply": "应用",
"signup_token": "Signup Token", "signup_token": "注册令牌",
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.", "create_a_signup_token_to_allow_new_user_registration": "创建一个注册令牌以允许新用户注册。",
"usage_limit": "Usage Limit", "usage_limit": "使用限制",
"number_of_times_token_can_be_used": "Number of times the signup token can be used.", "number_of_times_token_can_be_used": "注册令牌最多使用次数。",
"expires": "Expires", "expires": "过期时间",
"signup": "Sign Up", "signup": "注册",
"signup_requires_valid_token": "A valid signup token is required to create an account", "signup_requires_valid_token": "必须输入有效注册令牌才能注册新账户",
"validating_signup_token": "Validating signup token", "validating_signup_token": "正在验证注册令牌",
"go_to_login": "Go to login", "go_to_login": "跳转到登录界面",
"signup_to_appname": "Sign Up to {appName}", "signup_to_appname": "注册 {appName}",
"create_your_account_to_get_started": "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.", "initial_account_creation_description": "请先创建您的账户以开始使用。您可以稍后再设置通行密钥。",
"setup_your_passkey": "Set up your passkey", "setup_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.", "create_a_passkey_to_securely_access_your_account": "创建一个通行密钥来安全地访问您的账户。这将是您最主要的登录方式。",
"skip_for_now": "Skip for now", "skip_for_now": "暂时跳过",
"account_created": "Account Created", "account_created": "账户已创建",
"enable_user_signups": "Enable User Signups", "enable_user_signups": "允许用户注册",
"enable_user_signups_description": "Whether the User Signup functionality should be enabled.", "enable_user_signups_description": "是否启用新用户注册功能。",
"user_signups_are_disabled": "User signups are currently disabled", "user_signups_are_disabled": "目前禁止新用户注册",
"create_signup_token": "Create Signup Token", "create_signup_token": "创建一个注册令牌",
"view_active_signup_tokens": "View Active Signup Tokens", "view_active_signup_tokens": "查看有效注册令牌",
"manage_signup_tokens": "Manage Signup Tokens", "manage_signup_tokens": "管理注册令牌",
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.", "view_and_manage_active_signup_tokens": "查看和管理有效注册令牌。",
"signup_token_deleted_successfully": "Signup token deleted successfully.", "signup_token_deleted_successfully": "已成功删除注册令牌。",
"expired": "Expired", "expired": "已过期",
"used_up": "Used Up", "used_up": "已使用",
"active": "Active", "active": "有效",
"usage": "Usage", "usage": "用量",
"created": "Created", "created": "已创建",
"token": "Token", "token": "令牌",
"loading": "Loading", "loading": "正在加载",
"delete_signup_token": "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.", "are_you_sure_you_want_to_delete_this_signup_token": "确定要删除这个注册令牌吗?此操作不可撤销。",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.", "signup_disabled_description": "已完全禁止新用户注册。只有管理员可以创建新账户。",
"signup_with_token": "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_with_token_description": "用户必须持有管理员创建的有效注册令牌才能注册新账户。",
"signup_open": "Open Signup", "signup_open": "开放注册",
"signup_open_description": "Anyone can create a new account without restrictions.", "signup_open_description": "任何人都可以无限制地注册新账户。",
"of": "of", "of": "中的",
"skip_passkey_setup": "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." "skip_passkey_setup_description": "强烈建议设置一个通行密钥,否则您在此会话结束后将无法访问您的账户。"
} }

View File

@@ -3,7 +3,7 @@
"my_account": "我的帳號", "my_account": "我的帳號",
"logout": "登出", "logout": "登出",
"confirm": "確認", "confirm": "確認",
"docs": "Docs", "docs": "文件",
"key": "Key", "key": "Key",
"value": "Value", "value": "Value",
"remove_custom_claim": "移除自定義 claim", "remove_custom_claim": "移除自定義 claim",
@@ -65,7 +65,7 @@
"do_you_want_to_sign_out_of_pocketid_with_the_account": "您確定要使用帳號 <b>{username}</b> 登出 {appName} 嗎?", "do_you_want_to_sign_out_of_pocketid_with_the_account": "您確定要使用帳號 <b>{username}</b> 登出 {appName} 嗎?",
"sign_in_to_appname": "登入 {appName}", "sign_in_to_appname": "登入 {appName}",
"please_try_to_sign_in_again": "請嘗試重新登入。", "please_try_to_sign_in_again": "請嘗試重新登入。",
"authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.", "authenticate_with_passkey_to_access_account": "使用您的密碼金鑰進行身份驗證以存取您的帳號。",
"authenticate": "驗證", "authenticate": "驗證",
"please_try_again": "請再試一次。", "please_try_again": "請再試一次。",
"continue": "繼續", "continue": "繼續",
@@ -93,7 +93,7 @@
"settings": "設定", "settings": "設定",
"update_pocket_id": "更新 Pocket ID", "update_pocket_id": "更新 Pocket ID",
"powered_by": "技術支援", "powered_by": "技術支援",
"see_your_account_activities_from_the_last_3_months": "查看您過去 3 個月的帳活動。", "see_your_account_activities_from_the_last_3_months": "查看您過去 3 個月的帳活動。",
"time": "時間", "time": "時間",
"event": "事件", "event": "事件",
"approximate_location": "概略位置", "approximate_location": "概略位置",
@@ -103,12 +103,12 @@
"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": "請新增密碼金鑰以避免日後無法存取您的帳。",
"single_passkey_configured": "已設定一組密碼金鑰", "single_passkey_configured": "已設定一組密碼金鑰",
"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": "新增密碼金鑰",
@@ -178,7 +178,7 @@
"email_login_notification": "電子郵件登入通知", "email_login_notification": "電子郵件登入通知",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "使用者從新裝置登入時寄送電子郵件通知。", "send_an_email_to_the_user_when_they_log_in_from_a_new_device": "使用者從新裝置登入時寄送電子郵件通知。",
"emai_login_code_requested_by_user": "使用者請求電子郵件登入代碼", "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.", "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": "發送測試郵件",
@@ -186,7 +186,7 @@
"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 客戶端使用。",
@@ -308,25 +308,26 @@
"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. Do you want to continue?", "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": "選擇您使用的語言。請注意,某些文字可能會自動翻譯,且可能不準確。",
"contribute_to_translation": "如果您發現問題,歡迎您在<link href='https://crowdin.com/project/pocket-id'>Crowdin</link> 上提供翻譯。",
"personal": "個人", "personal": "個人",
"global": "全域", "global": "全域",
"all_users": "所有使用者", "all_users": "所有使用者",
"all_events": "所有事件", "all_events": "所有事件",
"all_clients": "所有客戶端", "all_clients": "所有客戶端",
"all_locations": "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_ui_animations": "Turn off animations throughout the UI.", "turn_off_ui_animations": "關閉整個 UI 的動畫。",
"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": "使用者已成功啟用。",
@@ -347,76 +348,76 @@
"enter_code_displayed_in_previous_step": "請輸入上一步顯示的代碼。", "enter_code_displayed_in_previous_step": "請輸入上一步顯示的代碼。",
"authorize": "授權", "authorize": "授權",
"federated_client_credentials": "聯邦身分", "federated_client_credentials": "聯邦身分",
"federated_client_credentials_description": "使用聯邦身分,您可以透過由第三方授權機構簽發的 JWT 權杖來驗證 OIDC 客戶端。", "federated_client_credentials_description": "使用聯邦身分,您可以透過由第三方授權機構簽發的 JWT 令牌來驗證 OIDC 客戶端。",
"add_federated_client_credential": "增加聯邦身分", "add_federated_client_credential": "增加聯邦身分",
"add_another_federated_client_credential": "新增另一組聯邦身分", "add_another_federated_client_credential": "新增另一組聯邦身分",
"oidc_allowed_group_count": "允許的群組數量", "oidc_allowed_group_count": "允許的群組數量",
"unrestricted": "未受限制", "unrestricted": "未受限制",
"show_advanced_options": "顯示進階選項", "show_advanced_options": "顯示進階選項",
"hide_advanced_options": "隱藏進階選項", "hide_advanced_options": "隱藏進階選項",
"oidc_data_preview": "OIDC Data Preview", "oidc_data_preview": "OIDC 資料預覽",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users", "preview_the_oidc_data_that_would_be_sent_for_different_users": "預覽將傳送給不同使用者的 OIDC 資料",
"id_token": "ID Token", "id_token": "ID 令牌",
"access_token": "Access Token", "access_token": "存取令牌",
"userinfo": "Userinfo", "userinfo": "使用者資訊",
"id_token_payload": "ID Token Payload", "id_token_payload": "ID 令牌有效負載",
"access_token_payload": "Access Token Payload", "access_token_payload": "存取令牌有效負載",
"userinfo_endpoint_response": "Userinfo Endpoint Response", "userinfo_endpoint_response": "使用者資訊端點回應",
"copy": "Copy", "copy": "複製",
"no_preview_data_available": "No preview data available", "no_preview_data_available": "無預覽資料",
"copy_all": "Copy All", "copy_all": "全部複製",
"preview": "Preview", "preview": "預覽",
"preview_for_user": "Preview for {name} ({email})", "preview_for_user": "預覽 {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user", "preview_the_oidc_data_that_would_be_sent_for_this_user": "預覽將為此使用者傳送的 OIDC 資料",
"show": "Show", "show": "顯示",
"select_an_option": "Select an option", "select_an_option": "選擇一個選項",
"select_user": "Select User", "select_user": "選擇使用者",
"error": "Error", "error": "錯誤",
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Select an accent color to customize the appearance of Pocket ID.", "select_an_accent_color_to_customize_the_appearance_of_pocket_id": "選擇強調色以自訂 Pocket ID 的外觀。",
"accent_color": "Accent Color", "accent_color": "強調色",
"custom_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).", "custom_accent_color_description": "使用有效的 CSS 顏色格式(例如:hexrgbhsl)輸入自訂顏色。",
"color_value": "Color Value", "color_value": "顏色值",
"apply": "Apply", "apply": "套用",
"signup_token": "Signup Token", "signup_token": "註冊令牌",
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.", "create_a_signup_token_to_allow_new_user_registration": "建立註冊令牌,允許新使用者註冊。",
"usage_limit": "Usage Limit", "usage_limit": "使用次數限制",
"number_of_times_token_can_be_used": "Number of times the signup token can be used.", "number_of_times_token_can_be_used": "註冊令牌可使用的次數。",
"expires": "Expires", "expires": "到期",
"signup": "Sign Up", "signup": "註冊",
"signup_requires_valid_token": "A valid signup token is required to create an account", "signup_requires_valid_token": "建立帳號需要有效的註冊令牌",
"validating_signup_token": "Validating signup token", "validating_signup_token": "驗證註冊標記",
"go_to_login": "Go to login", "go_to_login": "前往登入",
"signup_to_appname": "Sign Up to {appName}", "signup_to_appname": "註冊 {appName}",
"create_your_account_to_get_started": "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.", "initial_account_creation_description": "請先建立您的帳號以開始。您稍後可以設定密碼金鑰。",
"setup_your_passkey": "Set up your passkey", "setup_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.", "create_a_passkey_to_securely_access_your_account": "建立密碼金鑰以安全存取您的帳號。這將是您登入的主要方式。",
"skip_for_now": "Skip for now", "skip_for_now": "暫時跳過",
"account_created": "Account Created", "account_created": "帳號已建立",
"enable_user_signups": "Enable User Signups", "enable_user_signups": "啟用使用者註冊",
"enable_user_signups_description": "Whether the User Signup functionality should be enabled.", "enable_user_signups_description": "是否要啟用使用者註冊功能。",
"user_signups_are_disabled": "User signups are currently disabled", "user_signups_are_disabled": "使用者註冊目前已停用",
"create_signup_token": "Create Signup Token", "create_signup_token": "建立註冊令牌",
"view_active_signup_tokens": "View Active Signup Tokens", "view_active_signup_tokens": "檢視有效的註冊令牌",
"manage_signup_tokens": "Manage Signup Tokens", "manage_signup_tokens": "管理註冊令牌",
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.", "view_and_manage_active_signup_tokens": "檢視並管理有效的註冊令牌。",
"signup_token_deleted_successfully": "Signup token deleted successfully.", "signup_token_deleted_successfully": "註冊令牌已成功刪除。",
"expired": "Expired", "expired": "已過期",
"used_up": "Used Up", "used_up": "已用完",
"active": "Active", "active": "活躍",
"usage": "Usage", "usage": "使用方式",
"created": "Created", "created": "已建立",
"token": "Token", "token": "令牌",
"loading": "Loading", "loading": "載入中",
"delete_signup_token": "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.", "are_you_sure_you_want_to_delete_this_signup_token": "您確定要刪除這個註冊令牌嗎?此動作無法撤銷。",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.", "signup_disabled_description": "使用者註冊已完全停用。只有管理員可以建立新的使用者帳號。",
"signup_with_token": "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_with_token_description": "使用者只能使用管理員建立的有效登入令牌註冊。",
"signup_open": "Open Signup", "signup_open": "開放報名",
"signup_open_description": "Anyone can create a new account without restrictions.", "signup_open_description": "任何人都可以不受限制地建立新帳號。",
"of": "of", "of": "",
"skip_passkey_setup": "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." "skip_passkey_setup_description": "我們強烈建議您設定密碼金鑰,因為如果沒有密碼金鑰,當工作階段到期時,您就會被鎖住。"
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "pocket-id-frontend", "name": "pocket-id-frontend",
"version": "1.5.0", "version": "1.6.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -72,7 +72,7 @@
</Card.Header> </Card.Header>
{#if expanded} {#if expanded}
<div transition:slide={{ duration: 200 }}> <div transition:slide={{ duration: 200 }}>
<Card.Content class="pt-5"> <Card.Content>
{@render children()} {@render children()}
</Card.Content> </Card.Content>
</div> </div>

View File

@@ -5,7 +5,7 @@
</script> </script>
<AlertDialog.Root bind:open={$confirmDialogStore.open}> <AlertDialog.Root bind:open={$confirmDialogStore.open}>
<AlertDialog.Content> <AlertDialog.Content class="z-9999">
<AlertDialog.Header> <AlertDialog.Header>
<AlertDialog.Title>{$confirmDialogStore.title}</AlertDialog.Title> <AlertDialog.Title>{$confirmDialogStore.title}</AlertDialog.Title>
<AlertDialog.Description> <AlertDialog.Description>

View File

@@ -58,6 +58,8 @@
selectedIndex = -1; selectedIndex = -1;
} }
}); });
$effect(() => handleOnInput());
</script> </script>
<div <div
@@ -84,6 +86,8 @@
trapFocus={false} trapFocus={false}
interactOutsideBehavior="ignore" interactOutsideBehavior="ignore"
onCloseAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()}
avoidCollisions={false}
strategy="absolute"
> >
{#each filteredSuggestions as suggestion, index} {#each filteredSuggestions as suggestion, index}
<div <div

View File

@@ -43,7 +43,7 @@
{description} {description}
{#if docsLink} {#if docsLink}
<a <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" class="relative text-black after:absolute after:bottom-0 after:left-0 after:h-px after:w-full after:translate-y-[-1px] after:bg-white dark:text-white"
href={docsLink} href={docsLink}
target="_blank" target="_blank"
> >
@@ -72,7 +72,7 @@
{/if} {/if}
{/if} {/if}
{#if input?.error} {#if input?.error}
<p class="text-destructive mt-1 text-xs text-start">{input.error}</p> <p class="text-destructive mt-1 text-start text-xs">{input.error}</p>
{/if} {/if}
</div> </div>
</div> </div>

View File

@@ -3,6 +3,7 @@
import * as Avatar from '$lib/components/ui/avatar'; import * as Avatar from '$lib/components/ui/avatar';
import Button from '$lib/components/ui/button/button.svelte'; import Button from '$lib/components/ui/button/button.svelte';
import { m } from '$lib/paraglide/messages'; import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import { cachedProfilePicture } from '$lib/utils/cached-image-util'; import { cachedProfilePicture } from '$lib/utils/cached-image-util';
import { LucideLoader, LucideRefreshCw, LucideUpload } from '@lucide/svelte'; import { LucideLoader, LucideRefreshCw, LucideUpload } from '@lucide/svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@@ -54,8 +55,16 @@
label: m.reset(), label: m.reset(),
action: async () => { action: async () => {
isLoading = true; isLoading = true;
await resetCallback().catch(); try {
isLoading = false; await resetCallback();
await fetch(cachedProfilePicture.getUrl(userId, { skipCache: true }))
.then((response) => response.blob())
.then((blob) => {
imageDataURL = URL.createObjectURL(blob);
});
} finally {
isLoading = false;
}
} }
} }
}); });
@@ -64,7 +73,7 @@
<div class="flex flex-col items-center gap-6 sm:flex-row"> <div class="flex flex-col items-center gap-6 sm:flex-row">
<div class="shrink-0"> <div class="shrink-0">
{#if isLdapUser} {#if isLdapUser && $appConfigStore.ldapEnabled}
<Avatar.Root class="size-24"> <Avatar.Root class="size-24">
<Avatar.Image class="object-cover" src={imageDataURL} /> <Avatar.Image class="object-cover" src={imageDataURL} />
</Avatar.Root> </Avatar.Root>
@@ -96,7 +105,7 @@
<div class="grow"> <div class="grow">
<h3 class="font-medium">{m.profile_picture()}</h3> <h3 class="font-medium">{m.profile_picture()}</h3>
{#if isLdapUser} {#if isLdapUser && $appConfigStore.ldapEnabled}
<p class="text-muted-foreground text-sm"> <p class="text-muted-foreground text-sm">
{m.profile_picture_is_managed_by_ldap_server()} {m.profile_picture_is_managed_by_ldap_server()}
</p> </p>
@@ -105,7 +114,12 @@
{m.click_profile_picture_to_upload_custom()} {m.click_profile_picture_to_upload_custom()}
</p> </p>
<p class="text-muted-foreground mb-2 text-sm">{m.image_should_be_in_format()}</p> <p class="text-muted-foreground mb-2 text-sm">{m.image_should_be_in_format()}</p>
<Button variant="outline" size="sm" onclick={onReset} disabled={isLoading || isLdapUser}> <Button
variant="outline"
size="sm"
onclick={onReset}
disabled={isLoading || (isLdapUser && $appConfigStore.ldapEnabled)}
>
<LucideRefreshCw class="mr-2 size-4" /> <LucideRefreshCw class="mr-2 size-4" />
{m.reset_to_default()} {m.reset_to_default()}
</Button> </Button>

View File

@@ -0,0 +1,83 @@
<!-- Component to display messages from Paraglide with support for links in the format <link href="url">text</link>. -->
<!-- This gets redundant in the future, because the library will support this natively. https://github.com/opral/inlang-sdk/issues/240 -->
<script lang="ts">
let {
m
}: {
m: string;
} = $props();
interface MessagePart {
type: 'text' | 'link';
content: string;
href?: string;
}
function parseMessage(content: string): MessagePart[] | string {
// Regex to match only <link href="url">text</link> format
const linkRegex = /<link\s+href=(['"])(.*?)\1>(.*?)<\/link>/g;
if (!linkRegex.test(content)) {
return content;
}
// Reset regex lastIndex for reuse
linkRegex.lastIndex = 0;
const parts: MessagePart[] = [];
let lastIndex = 0;
let match;
while ((match = linkRegex.exec(content)) !== null) {
// Add text before the link
if (match.index > lastIndex) {
const textContent = content.slice(lastIndex, match.index);
if (textContent) {
parts.push({ type: 'text', content: textContent });
}
}
const href = match[2];
const linkText = match[3];
parts.push({
type: 'link',
content: linkText,
href: href
});
lastIndex = match.index + match[0].length;
}
// Add remaining text after the last link
if (lastIndex < content.length) {
const remainingText = content.slice(lastIndex);
if (remainingText) {
parts.push({ type: 'text', content: remainingText });
}
}
return parts;
}
const parsedContent = parseMessage(m);
</script>
{#if typeof parsedContent === 'string'}
{parsedContent}
{:else}
{#each parsedContent as part}
{#if part.type === 'text'}
{part.content}
{:else if part.type === 'link'}
<a
class="text-black underline dark:text-white"
href={part.href}
target="_blank"
rel="noopener noreferrer"
>
{part.content}
</a>
{/if}
{/each}
{/if}

View File

@@ -13,7 +13,7 @@
<p <p
bind:this={ref} bind:this={ref}
data-slot="card-description" data-slot="card-description"
class={cn('text-muted-foreground text-sm', className)} class={cn('text-muted-foreground mt-1 text-sm', className)}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}

View File

@@ -40,7 +40,7 @@
{selectionDisabled} {selectionDisabled}
> >
{#snippet rows({ item })} {#snippet rows({ item })}
<Table.Cell>{item.name}</Table.Cell> <Table.Cell>{item.friendlyName}</Table.Cell>
{/snippet} {/snippet}
</AdvancedTable> </AdvancedTable>
{/if} {/if}

View File

@@ -48,4 +48,5 @@ export type OidcDeviceCodeInfo = {
export type AuthorizeResponse = { export type AuthorizeResponse = {
code: string; code: string;
callbackURL: string; callbackURL: string;
issuer: string;
}; };

View File

@@ -57,8 +57,8 @@
await oidService await oidService
.authorize(client!.id, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod) .authorize(client!.id, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod)
.then(async ({ code, callbackURL }) => { .then(async ({ code, callbackURL, issuer }) => {
onSuccess(code, callbackURL); onSuccess(code, callbackURL, issuer);
}); });
} catch (e) { } catch (e) {
errorMessage = getWebauthnErrorMessage(e); errorMessage = getWebauthnErrorMessage(e);
@@ -66,12 +66,13 @@
} }
} }
function onSuccess(code: string, callbackURL: string) { function onSuccess(code: string, callbackURL: string, issuer: string) {
success = true; success = true;
setTimeout(() => { setTimeout(() => {
const redirectURL = new URL(callbackURL); const redirectURL = new URL(callbackURL);
redirectURL.searchParams.append('code', code); redirectURL.searchParams.append('code', code);
redirectURL.searchParams.append('state', authorizeState); redirectURL.searchParams.append('state', authorizeState);
redirectURL.searchParams.append('iss', issuer);
window.location.href = redirectURL.toString(); window.location.href = redirectURL.toString();
}, 1000); }, 1000);

View File

@@ -44,7 +44,7 @@
> >
<main <main
in:fade={{ duration: 200 }} in:fade={{ duration: 200 }}
class="mx-auto flex w-full max-w-[1640px] flex-col gap-x-8 gap-y-8 overflow-hidden p-4 md:p-8 lg:flex-row" class="mx-auto flex w-full max-w-[1640px] flex-col gap-x-8 gap-y-8 p-4 md:p-8 lg:flex-row"
> >
<div class="min-w-[200px] xl:min-w-[250px]"> <div class="min-w-[200px] xl:min-w-[250px]">
<div in:fly={{ x: -15, duration: 200 }} class="sticky top-6"> <div in:fly={{ x: -15, duration: 200 }} class="sticky top-6">

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import FormattedMessage from '$lib/components/formatted-message.svelte';
import * as Alert from '$lib/components/ui/alert'; import * as Alert from '$lib/components/ui/alert';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
@@ -9,7 +10,6 @@
import type { Passkey } from '$lib/types/passkey.type'; import type { Passkey } from '$lib/types/passkey.type';
import type { UserCreate } from '$lib/types/user.type'; import type { UserCreate } from '$lib/types/user.type';
import { axiosErrorToast, getWebauthnErrorMessage } from '$lib/utils/error-util'; import { axiosErrorToast, getWebauthnErrorMessage } from '$lib/utils/error-util';
import { startRegistration } from '@simplewebauthn/browser';
import { import {
KeyRound, KeyRound,
Languages, Languages,
@@ -17,6 +17,7 @@
RectangleEllipsis, RectangleEllipsis,
UserCog UserCog
} from '@lucide/svelte'; } from '@lucide/svelte';
import { startRegistration } from '@simplewebauthn/browser';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import AccountForm from './account-form.svelte'; import AccountForm from './account-form.svelte';
import LocalePicker from './locale-picker.svelte'; import LocalePicker from './locale-picker.svelte';
@@ -33,6 +34,10 @@
const userService = new UserService(); const userService = new UserService();
const webauthnService = new WebAuthnService(); const webauthnService = new WebAuthnService();
const userInfoInputDisabled = $derived(
!$appConfigStore.allowOwnAccountEdit || (!!account.ldapId && $appConfigStore.ldapEnabled)
);
async function updateAccount(user: UserCreate) { async function updateAccount(user: UserCreate) {
let success = true; let success = true;
await userService await userService
@@ -117,31 +122,27 @@
</div> </div>
<!-- Account details card --> <!-- Account details card -->
<fieldset <Card.Root>
disabled={!$appConfigStore.allowOwnAccountEdit || <Card.Header>
(!!account.ldapId && $appConfigStore.ldapEnabled)} <Card.Title>
> <UserCog class="text-primary/80 size-5" />
<Card.Root> {m.account_details()}
<Card.Header> </Card.Title>
<Card.Title> </Card.Header>
<UserCog class="text-primary/80 size-5" /> <Card.Content>
{m.account_details()} <AccountForm
</Card.Title> {account}
</Card.Header> userId={account.id}
<Card.Content> callback={updateAccount}
<AccountForm isLdapUser={!!account.ldapId}
{account} {userInfoInputDisabled}
userId={account.id} />
callback={updateAccount} </Card.Content>
isLdapUser={!!account.ldapId} </Card.Root>
/>
</Card.Content>
</Card.Root>
</fieldset>
<!-- Passkey management card --> <!-- Passkey management card -->
<div> <div>
<Card.Root> <Card.Root class="gap-3">
<Card.Header> <Card.Header>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
@@ -200,6 +201,8 @@
</Card.Title> </Card.Title>
<Card.Description> <Card.Description>
{m.select_the_language_you_want_to_use()} {m.select_the_language_you_want_to_use()}
<br />
<FormattedMessage m={m.contribute_to_translation()} />
</Card.Description> </Card.Description>
</div> </div>
<LocalePicker /> <LocalePicker />

View File

@@ -15,12 +15,14 @@
callback, callback,
account, account,
userId, userId,
isLdapUser = false isLdapUser = false,
userInfoInputDisabled = false
}: { }: {
account: UserCreate; account: UserCreate;
userId: string; userId: string;
callback: (user: UserCreate) => Promise<boolean>; callback: (user: UserCreate) => Promise<boolean>;
isLdapUser?: boolean; isLdapUser?: boolean;
userInfoInputDisabled?: boolean;
} = $props(); } = $props();
let isLoading = $state(false); let isLoading = $state(false);
@@ -78,26 +80,28 @@
<hr class="border-border" /> <hr class="border-border" />
<!-- User Information --> <!-- User Information -->
<div> <fieldset disabled={userInfoInputDisabled}>
<div class="flex flex-col gap-3 sm:flex-row"> <div>
<div class="w-full"> <div class="flex flex-col gap-3 sm:flex-row">
<FormInput label={m.first_name()} bind:input={$inputs.firstName} /> <div class="w-full">
<FormInput label={m.first_name()} bind:input={$inputs.firstName} />
</div>
<div class="w-full">
<FormInput label={m.last_name()} bind:input={$inputs.lastName} />
</div>
</div> </div>
<div class="w-full"> <div class="mt-3 flex flex-col gap-3 sm:flex-row">
<FormInput label={m.last_name()} bind:input={$inputs.lastName} /> <div class="w-full">
<FormInput label={m.email()} bind:input={$inputs.email} />
</div>
<div class="w-full">
<FormInput label={m.username()} bind:input={$inputs.username} />
</div>
</div> </div>
</div> </div>
<div class="mt-3 flex flex-col gap-3 sm:flex-row">
<div class="w-full">
<FormInput label={m.email()} bind:input={$inputs.email} />
</div>
<div class="w-full">
<FormInput label={m.username()} bind:input={$inputs.username} />
</div>
</div>
</div>
<div class="flex justify-end pt-2"> <div class="flex justify-end pt-2">
<Button {isLoading} type="submit">{m.save()}</Button> <Button {isLoading} type="submit">{m.save()}</Button>
</div> </div>
</fieldset>
</form> </form>

View File

@@ -21,7 +21,7 @@
'pt-BR': 'Português brasileiro', 'pt-BR': 'Português brasileiro',
ru: 'Русский', ru: 'Русский',
'zh-CN': '简体中文', 'zh-CN': '简体中文',
'zh-TW': '繁體中文(臺灣)' 'zh-TW': '繁體中文(臺灣'
}; };
async function updateLocale(locale: Locale) { async function updateLocale(locale: Locale) {

View File

@@ -45,7 +45,7 @@
emailApiKeyExpirationEnabled: z.boolean() emailApiKeyExpirationEnabled: z.boolean()
}); });
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, appConfig); let { inputs, ...form } = $derived(createForm(formSchema, appConfig));
async function onSubmit() { async function onSubmit() {
const data = form.validate(); const data = form.validate();

View File

@@ -58,7 +58,7 @@
accentColor: z.string() accentColor: z.string()
}); });
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig); let { inputs, ...form } = $derived(createForm(formSchema, appConfig));
async function onSubmit() { async function onSubmit() {
const data = form.validate(); const data = form.validate();

View File

@@ -68,7 +68,7 @@
ldapSoftDeleteUsers: z.boolean() ldapSoftDeleteUsers: z.boolean()
}); });
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig); let { inputs, ...form } = $derived(createForm(formSchema, appConfig));
async function onSubmit() { async function onSubmit() {
const data = form.validate(); const data = form.validate();