Compare commits

..

28 Commits

Author SHA1 Message Date
Elias Schneider
9c54e2e6b0 release: 1.8.0 2025-08-23 18:57:19 +02:00
Elias Schneider
a5efb95065 feat: allow custom client IDs (#864) 2025-08-23 18:41:05 +02:00
Elias Schneider
625f235740 fix: enable foreign key check for sqlite (#863)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
2025-08-23 17:54:51 +02:00
Elias Schneider
2c122d413d refactor: run formatter 2025-08-23 17:46:59 +02:00
Elias Schneider
fc0c99a232 fix: oidc client advanced options color 2025-08-23 17:40:58 +02:00
Elias Schneider
24e274200f fix: ferated identities can't be cleared 2025-08-23 17:40:06 +02:00
Elias Schneider
0aab3f3c7a fix: authorization can't be revoked 2025-08-23 17:28:27 +02:00
Zeedif
182d809028 feat(signup): add default user groups and claims for new users (#812)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-22 14:25:02 +02:00
Elias Schneider
c51265dafb chore(translations): change alternative sign in methods text 2025-08-22 13:06:38 +02:00
Robert Mang
0cb039d35d feat: add option to OIDC client to require re-authentication (#747)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-22 08:56:40 +02:00
Alessandro (Ale) Segala
7ab0fd3028 fix: for one-time access tokens and signup tokens, pass TTLs instead of absolute expiration date (#855) 2025-08-22 08:02:56 +02:00
Maxime R
49f0fa423c chore: strip debug symbol from backend binary (#856) 2025-08-21 15:46:45 +00:00
Elias Schneider
61e63e411d chore(translations): update translations via Crowdin (#850) 2025-08-20 17:07:08 -05:00
Alessandro (Ale) Segala
9339e88a5a fix: move audit log call before TX is committed (#854) 2025-08-20 17:01:53 -05:00
Elias Schneider
fe003b927c fix: delete webauthn session after login to prevent replay attacks 2025-08-20 15:49:19 +02:00
Kyle Mendell
f5b5b1bd85 tests: use proper async calls for cleanupBackend function (#846) 2025-08-20 10:38:03 +02:00
James18232
d28bfac81f feat: login code font change (#851)
Co-authored-by: James18232 <80368042+James18232@users.noreply.github.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-19 14:10:57 +00:00
Elias Schneider
b04e3e8ecf chore(translations): update translations via Crowdin (#848) 2025-08-19 12:03:51 +02:00
Kyle Mendell
d77d8eb068 chore(translations): add Korean files 2025-08-18 14:53:19 -05:00
Elias Schneider
7cd88aca25 chore(translations): update translations via Crowdin (#841) 2025-08-18 11:21:27 -05:00
Gergő Gutyina
b5e6371eaa fix(deps): bump rollup from 4.45.3 to 4.46.3 (#845) 2025-08-18 07:44:42 -05:00
github-actions[bot]
544b98c1d0 chore: update AAGUIDs (#844)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-08-17 22:52:58 -05:00
Elias Schneider
3188e92257 feat: display all accessible oidc clients in the dashboard (#832)
Co-authored-by: Kyle Mendell <ksm@ofkm.us>
2025-08-17 22:47:34 +02:00
Elias Schneider
3fa2f9a162 chore(translations): update translations via Crowdin (#821) 2025-08-16 22:50:21 -05:00
James18232
7b1f6b8857 fix: ignore client secret if client is public (#836)
Co-authored-by: James18232 <80368042+James18232@users.noreply.github.com>
2025-08-16 17:55:32 +02:00
Alessandro (Ale) Segala
17d8893bdb chore: update deps and Go 1.25 (#833) 2025-08-14 22:33:27 -05:00
Elias Schneider
0e44f245af fix: non admin users can't revoke oidc client but see edit link 2025-08-12 09:46:15 +02:00
github-actions[bot]
824e8f1a0f chore: update AAGUIDs (#826)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-08-10 21:33:29 -05:00
113 changed files with 3191 additions and 1029 deletions

View File

@@ -32,9 +32,9 @@ jobs:
go-version-file: backend/go.mod
- name: Run Golangci-lint
uses: golangci/golangci-lint-action@dec74fa03096ff515422f71d18d41307cacde373 # v7.0.0
uses: golangci/golangci-lint-action@v8.0.0
with:
version: v2.0.2
version: v2.4.0
args: --build-tags=exclude_frontend
working-directory: backend
only-new-issues: ${{ github.event_name == 'pull_request' }}

View File

@@ -1 +1 @@
1.7.0
1.8.0

View File

@@ -1,3 +1,28 @@
## [](https://github.com/pocket-id/pocket-id/compare/v1.7.0...v) (2025-08-23)
### Features
* add option to OIDC client to require re-authentication ([#747](https://github.com/pocket-id/pocket-id/issues/747)) ([0cb039d](https://github.com/pocket-id/pocket-id/commit/0cb039d35d49206011064e622f3bfd3d8f88720f))
* allow custom client IDs ([#864](https://github.com/pocket-id/pocket-id/issues/864)) ([a5efb95](https://github.com/pocket-id/pocket-id/commit/a5efb9506582884c70b9b1fd737ebdd44b101b47))
* display all accessible oidc clients in the dashboard ([#832](https://github.com/pocket-id/pocket-id/issues/832)) ([3188e92](https://github.com/pocket-id/pocket-id/commit/3188e92257afcaf7a16dd418e4c40626d7e1d034))
* login code font change ([#851](https://github.com/pocket-id/pocket-id/issues/851)) ([d28bfac](https://github.com/pocket-id/pocket-id/commit/d28bfac81fc24ee79e4896538a616f0a89ab30a5))
* **signup:** add default user groups and claims for new users ([#812](https://github.com/pocket-id/pocket-id/issues/812)) ([182d809](https://github.com/pocket-id/pocket-id/commit/182d8090286f9953171c6c410283be679889aca7))
### Bug Fixes
* authorization can't be revoked ([0aab3f3](https://github.com/pocket-id/pocket-id/commit/0aab3f3c7ad8c1b14939de3ded60c9f201eab8fc))
* delete webauthn session after login to prevent replay attacks ([fe003b9](https://github.com/pocket-id/pocket-id/commit/fe003b927ce7772692439992860c804de89ce424))
* **deps:** bump rollup from 4.45.3 to 4.46.3 ([#845](https://github.com/pocket-id/pocket-id/issues/845)) ([b5e6371](https://github.com/pocket-id/pocket-id/commit/b5e6371eaaf3d9e85d8b05c457487c4425fa8381))
* enable foreign key check for sqlite ([#863](https://github.com/pocket-id/pocket-id/issues/863)) ([625f235](https://github.com/pocket-id/pocket-id/commit/625f23574001ebd7074b8d98d448a2811847be16))
* ferated identities can't be cleared ([24e2742](https://github.com/pocket-id/pocket-id/commit/24e274200fe4002d01c58cc3fa74094b598d7599))
* for one-time access tokens and signup tokens, pass TTLs instead of absolute expiration date ([#855](https://github.com/pocket-id/pocket-id/issues/855)) ([7ab0fd3](https://github.com/pocket-id/pocket-id/commit/7ab0fd30286e6b67b5ce586484d82a20c42b471d))
* ignore client secret if client is public ([#836](https://github.com/pocket-id/pocket-id/issues/836)) ([7b1f6b8](https://github.com/pocket-id/pocket-id/commit/7b1f6b88572bac1f3e838a9e904917fbd5fbdf61))
* move audit log call before TX is committed ([#854](https://github.com/pocket-id/pocket-id/issues/854)) ([9339e88](https://github.com/pocket-id/pocket-id/commit/9339e88a5a26ff77a5e40149cbb1a5b339b7ec6a))
* non admin users can't revoke oidc client but see edit link ([0e44f24](https://github.com/pocket-id/pocket-id/commit/0e44f245afcdf8179bf619613ca9ef4bffa176ca))
* oidc client advanced options color ([fc0c99a](https://github.com/pocket-id/pocket-id/commit/fc0c99a232b0efb1a5b5d2c551102418b1080293))
## [](https://github.com/pocket-id/pocket-id/compare/v1.6.4...v) (2025-08-10)

View File

@@ -52,7 +52,7 @@ If you use [Dev Containers](https://code.visualstudio.com/docs/remote/containers
If you don't use Dev Containers, you need to install the following tools manually:
- [Node.js](https://nodejs.org/en/download/) >= 22
- [Go](https://golang.org/doc/install) >= 1.24
- [Go](https://golang.org/doc/install) >= 1.25
- [Git](https://git-scm.com/downloads)
### 2. Setup

View File

@@ -18,7 +18,7 @@ COPY ./frontend ./frontend/
RUN BUILD_OUTPUT_PATH=dist pnpm --filter pocket-id-frontend run build
# Stage 2: Build Backend
FROM golang:1.24-alpine AS backend-builder
FROM golang:1.25-alpine AS backend-builder
ARG BUILD_TAGS
WORKDIR /build
COPY ./backend/go.mod ./backend/go.sum ./

View File

@@ -1,34 +1,34 @@
module github.com/pocket-id/pocket-id/backend
go 1.24.0
go 1.25
require (
github.com/caarlos0/env/v11 v11.3.1
github.com/cenkalti/backoff/v5 v5.0.2
github.com/cenkalti/backoff/v5 v5.0.3
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
github.com/disintegration/imaging v1.6.2
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
github.com/emersion/go-smtp v0.21.3
github.com/fxamacker/cbor/v2 v2.7.0
github.com/gin-gonic/gin v1.10.0
github.com/glebarez/go-sqlite v1.21.2
github.com/fxamacker/cbor/v2 v2.9.0
github.com/gin-gonic/gin v1.10.1
github.com/glebarez/go-sqlite v1.22.0
github.com/glebarez/sqlite v1.11.0
github.com/go-co-op/gocron/v2 v2.15.0
github.com/go-co-op/gocron/v2 v2.16.3
github.com/go-ldap/ldap/v3 v3.4.10
github.com/go-playground/validator/v10 v10.25.0
github.com/go-playground/validator/v10 v10.27.0
github.com/go-webauthn/webauthn v0.11.2
github.com/golang-migrate/migrate/v4 v4.18.2
github.com/golang-migrate/migrate/v4 v4.18.3
github.com/google/uuid v1.6.0
github.com/hashicorp/go-uuid v1.0.3
github.com/jinzhu/copier v0.4.0
github.com/joho/godotenv v1.5.1
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2
github.com/lestrrat-go/jwx/v3 v3.0.1
github.com/lestrrat-go/httprc/v3 v3.0.0
github.com/lestrrat-go/jwx/v3 v3.0.10
github.com/lmittmann/tint v1.1.2
github.com/mattn/go-isatty v0.0.20
github.com/mileusna/useragent v1.3.5
github.com/orandin/slog-gorm v1.4.0
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8
github.com/samber/slog-gin v1.15.1
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
@@ -43,55 +43,56 @@ require (
go.opentelemetry.io/otel/sdk/log v0.10.0
go.opentelemetry.io/otel/sdk/metric v1.35.0
go.opentelemetry.io/otel/trace v1.37.0
golang.org/x/crypto v0.39.0
golang.org/x/image v0.24.0
golang.org/x/text v0.26.0
golang.org/x/time v0.9.0
gorm.io/driver/postgres v1.5.11
gorm.io/gorm v1.25.12
golang.org/x/crypto v0.41.0
golang.org/x/image v0.30.0
golang.org/x/text v0.28.0
golang.org/x/time v0.12.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.30.1
)
require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.12.10 // indirect
github.com/bytedance/sonic/loader v0.2.3 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/disintegration/gift v1.1.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-webauthn/x v0.1.16 // indirect
github.com/go-webauthn/x v0.1.23 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/google/go-tpm v0.9.3 // indirect
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
github.com/google/go-tpm v0.9.5 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // 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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.2 // indirect
github.com/jackc/pgx/v5 v5.7.5 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
@@ -99,7 +100,7 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.22.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
@@ -110,7 +111,8 @@ require (
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/ugorji/go/codec v1.2.12 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/valyala/fastjson v1.6.4 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.59.0 // indirect
@@ -127,18 +129,18 @@ require (
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 // indirect
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/arch v0.14.0 // indirect
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect
google.golang.org/grpc v1.71.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
google.golang.org/protobuf v1.36.7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.65.10 // indirect
modernc.org/libc v1.66.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.38.0 // indirect
modernc.org/sqlite v1.38.2 // indirect
)

View File

@@ -8,30 +8,28 @@ github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7V
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/sonic v1.12.10 h1:uVCQr6oS5669E9ZVW0HyksTLfNS7Q/9hV6IVS4nEMsI=
github.com/bytedance/sonic v1.12.10/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wioyEqssH0=
github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=
github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
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/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8=
github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM=
github.com/dhui/dktest v0.4.5 h1:uUfYBIVREmj/Rw6MvgmqNAYzTiKOHJak+enB5Di73MM=
github.com/dhui/dktest v0.4.5/go.mod h1:tmcyeHDKagvlDrz7gDKq4UAJOLIfVZYkfD5OnHDwcCo=
github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
@@ -54,22 +52,22 @@ github.com/emersion/go-smtp v0.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGV
github.com/emersion/go-smtp v0.21.3/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-co-op/gocron/v2 v2.15.0 h1:Kpvo71VSihE+RImmpA+3ta5CcMhoRzMGw4dJawrj4zo=
github.com/go-co-op/gocron/v2 v2.15.0/go.mod h1:ZF70ZwEqz0OO4RBXE1sNxnANy/zvwLcattWEFsqpKig=
github.com/go-co-op/gocron/v2 v2.16.3 h1:kYqukZqBa8RC2+AFAHnunmKcs9GRTjwBo8WRF3I6cbI=
github.com/go-co-op/gocron/v2 v2.16.3/go.mod h1:aTf7/+5Jo2E+cyAqq625UQ6DzpkV96b22VHIUAt6l3c=
github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU=
github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -83,27 +81,27 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8=
github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc=
github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0=
github.com/go-webauthn/x v0.1.16 h1:EaVXZntpyHviN9ykjdRBQIw9B0Ed3LO5FW7mDiMQEa8=
github.com/go-webauthn/x v0.1.16/go.mod h1:jhYjfwe/AVYaUs2mUXArj7vvZj+SpooQPyyQGNab+Us=
github.com/go-webauthn/x v0.1.23 h1:9lEO0s+g8iTyz5Vszlg/rXTGrx3CjcD0RZQ1GPZCaxI=
github.com/go-webauthn/x v0.1.23/go.mod h1:AJd3hI7NfEp/4fI6T4CHD753u91l510lglU7/NMN6+E=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs=
github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc=
github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
@@ -127,8 +125,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
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/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
@@ -157,10 +155,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -169,16 +165,18 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2 h1:SDxjGoH7qj0nBXVrcrxX8eD94wEnjR+EEuqqmeqQYlY=
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2/go.mod h1:Nwo81sMxE0DcvTB+rJyynNhv/DUu2yZErV7sscw9pHE=
github.com/lestrrat-go/jwx/v3 v3.0.1 h1:fH3T748FCMbXoF9UXXNS9i0q6PpYyJZK/rKSbkt2guY=
github.com/lestrrat-go/jwx/v3 v3.0.1/go.mod h1:XP2WqxMOSzHSyf3pfibCcfsLqbomxakAnNqiuaH8nwo=
github.com/lestrrat-go/httprc/v3 v3.0.0 h1:nZUx/zFg5uc2rhlu1L1DidGr5Sj02JbXvGSpnY4LMrc=
github.com/lestrrat-go/httprc/v3 v3.0.0/go.mod h1:k2U1QIiyVqAKtkffbg+cUmsyiPGQsb9aAfNQiNFuQ9Q=
github.com/lestrrat-go/jwx/v3 v3.0.10 h1:XuoCBhZBncRIjMQ32HdEc76rH0xK/Qv2wq5TBouYJDw=
github.com/lestrrat-go/jwx/v3 v3.0.10/go.mod h1:kNMedLgTpHvPJkK5EMVa1JFz+UVyY2dMmZKu3qjl/Pk=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
@@ -212,10 +210,10 @@ github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQ
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/orandin/slog-gorm v1.4.0 h1:FgA8hJufF9/jeNSYoEXmHPPBwET2gwlF3B85JdpsTUU=
github.com/orandin/slog-gorm v1.4.0/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA=
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2 h1:jG+FaCBv3h6GD5F+oenTfe3+0NmX8sCKjni5k3A5Dek=
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2/go.mod h1:rHaQJ5SjfCdL4sqCKa3FhklRcaXga2/qyvmQuA+ZJ6M=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8 h1:aM1/rO6p+XV+l+seD7UCtFZgsOefDTrFVLvPoZWjXZs=
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8/go.mod h1:Jts8ztuE0PkUwY7VCJyp6B68ujQfr6G9P5Dn3Yx9u6w=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -246,7 +244,6 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -254,13 +251,14 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -318,8 +316,8 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4=
golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
@@ -327,20 +325,20 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE=
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -352,8 +350,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -361,8 +359,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -375,8 +373,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -395,18 +393,18 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
@@ -414,8 +412,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ=
google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -423,20 +421,22 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4=
gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
modernc.org/cc/v4 v4.26.3 h1:yEN8dzrkRFnn4PUUKXLYIqVf2PJYAEjMTFjO3BDGc3I=
modernc.org/cc/v4 v4.26.3/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.3 h1:3qaU+7f7xxTUmvU1pJTZiDLAIoJVdUSSauJNHg9yXoA=
modernc.org/fileutil v1.3.3/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/fileutil v1.3.15 h1:rJAXTP6ilMW/1+kzDiqmBlHLWszheUFXIyGQIAvjJpY=
modernc.org/fileutil v1.3.15/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.65.10 h1:ZwEk8+jhW7qBjHIT+wd0d9VjitRyQef9BnzlzGwMODc=
modernc.org/libc v1.65.10/go.mod h1:StFvYpx7i/mXtBAfVOjaU0PWZOvIRoZSgXhrwXzr8Po=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.7 h1:rjhZ8OSCybKWxS1CJr0hikpEi6Vg+944Ouyrd+bQsoY=
modernc.org/libc v1.66.7/go.mod h1:ln6tbWX0NH+mzApEoDRvilBvAWFt1HX7AUA4VDdVDPM=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -445,10 +445,9 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI=
modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE=
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=

View File

@@ -86,9 +86,6 @@ func connectDatabase() (db *gorm.DB, err error) {
if common.EnvConfig.DbConnectionString == "" {
return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for SQLite database")
}
if !strings.HasPrefix(common.EnvConfig.DbConnectionString, "file:") {
return nil, errors.New("invalid value for env var 'DB_CONNECTION_STRING': does not begin with 'file:'")
}
sqliteutil.RegisterSqliteFunctions()
connString, err := parseSqliteConnectionString(common.EnvConfig.DbConnectionString)
if err != nil {
@@ -123,25 +120,43 @@ func connectDatabase() (db *gorm.DB, err error) {
return nil, err
}
// The official C implementation of SQLite allows some additional properties in the connection string
// that are not supported in the in the modernc.org/sqlite driver, and which must be passed as PRAGMA args instead.
// To ensure that people can use similar args as in the C driver, which was also used by Pocket ID
// previously (via github.com/mattn/go-sqlite3), we are converting some options.
func parseSqliteConnectionString(connString string) (string, error) {
if !strings.HasPrefix(connString, "file:") {
connString = "file:" + connString
}
// Check if we're using an in-memory database
isMemoryDB := isSqliteInMemory(connString)
// Parse the connection string
connStringUrl, err := url.Parse(connString)
if err != nil {
return "", fmt.Errorf("failed to parse SQLite connection string: %w", err)
}
// Convert options for the old SQLite driver to the new one
convertSqlitePragmaArgs(connStringUrl)
// Add the default and required params
err = addSqliteDefaultParameters(connStringUrl, isMemoryDB)
if err != nil {
return "", fmt.Errorf("invalid SQLite connection string: %w", err)
}
return connStringUrl.String(), nil
}
// The official C implementation of SQLite allows some additional properties in the connection string
// that are not supported in the in the modernc.org/sqlite driver, and which must be passed as PRAGMA args instead.
// To ensure that people can use similar args as in the C driver, which was also used by Pocket ID
// previously (via github.com/mattn/go-sqlite3), we are converting some options.
// Note this function updates connStringUrl.
func convertSqlitePragmaArgs(connStringUrl *url.URL) {
// Reference: https://github.com/mattn/go-sqlite3?tab=readme-ov-file#connection-string
// This only includes a subset of options, excluding those that are not relevant to us
qs := make(url.Values, len(connStringUrl.Query()))
for k, v := range connStringUrl.Query() {
switch k {
switch strings.ToLower(k) {
case "_auto_vacuum", "_vacuum":
qs.Add("_pragma", "auto_vacuum("+v[0]+")")
case "_busy_timeout", "_timeout":
@@ -162,9 +177,123 @@ func parseSqliteConnectionString(connString string) (string, error) {
}
}
// Update the connStringUrl object
connStringUrl.RawQuery = qs.Encode()
}
// Adds the default (and some required) parameters to the SQLite connection string.
// Note this function updates connStringUrl.
func addSqliteDefaultParameters(connStringUrl *url.URL, isMemoryDB bool) error {
// This function include code adapted from https://github.com/dapr/components-contrib/blob/v1.14.6/
// Copyright (C) 2023 The Dapr Authors
// License: Apache2
const defaultBusyTimeout = 2500 * time.Millisecond
// Get the "query string" from the connection string if present
qs := connStringUrl.Query()
if len(qs) == 0 {
qs = make(url.Values, 2)
}
// If the database is in-memory, we must ensure that cache=shared is set
if isMemoryDB {
qs["cache"] = []string{"shared"}
}
// Check if the database is read-only or immutable
isReadOnly := false
if len(qs["mode"]) > 0 {
// Keep the first value only
qs["mode"] = []string{
strings.ToLower(qs["mode"][0]),
}
if qs["mode"][0] == "ro" {
isReadOnly = true
}
}
if len(qs["immutable"]) > 0 {
// Keep the first value only
qs["immutable"] = []string{
strings.ToLower(qs["immutable"][0]),
}
if qs["immutable"][0] == "1" {
isReadOnly = true
}
}
// We do not want to override a _txlock if set, but we'll show a warning if it's not "immediate"
if len(qs["_txlock"]) > 0 {
// Keep the first value only
qs["_txlock"] = []string{
strings.ToLower(qs["_txlock"][0]),
}
if qs["_txlock"][0] != "immediate" {
slog.Warn("SQLite connection is being created with a _txlock different from the recommended value 'immediate'")
}
} else {
qs["_txlock"] = []string{"immediate"}
}
// Add pragma values
var hasBusyTimeout, hasJournalMode bool
if len(qs["_pragma"]) == 0 {
qs["_pragma"] = make([]string, 0, 3)
} else {
for _, p := range qs["_pragma"] {
p = strings.ToLower(p)
switch {
case strings.HasPrefix(p, "busy_timeout"):
hasBusyTimeout = true
case strings.HasPrefix(p, "journal_mode"):
hasJournalMode = true
case strings.HasPrefix(p, "foreign_keys"):
return errors.New("found forbidden option '_pragma=foreign_keys' in the connection string")
}
}
}
if !hasBusyTimeout {
qs["_pragma"] = append(qs["_pragma"], fmt.Sprintf("busy_timeout(%d)", defaultBusyTimeout.Milliseconds()))
}
if !hasJournalMode {
switch {
case isMemoryDB:
// For in-memory databases, set the journal to MEMORY, the only allowed option besides OFF (which would make transactions ineffective)
qs["_pragma"] = append(qs["_pragma"], "journal_mode(MEMORY)")
case isReadOnly:
// Set the journaling mode to "DELETE" (the default) if the database is read-only
qs["_pragma"] = append(qs["_pragma"], "journal_mode(DELETE)")
default:
// Enable WAL
qs["_pragma"] = append(qs["_pragma"], "journal_mode(WAL)")
}
}
// Forcefully enable foreign keys
qs["_pragma"] = append(qs["_pragma"], "foreign_keys(1)")
// Update the connStringUrl object
connStringUrl.RawQuery = qs.Encode()
return connStringUrl.String(), nil
return nil
}
// isSqliteInMemory returns true if the connection string is for an in-memory database.
func isSqliteInMemory(connString string) bool {
lc := strings.ToLower(connString)
// First way to define an in-memory database is to use ":memory:" or "file::memory:" as connection string
if strings.HasPrefix(lc, ":memory:") || strings.HasPrefix(lc, "file::memory:") {
return true
}
// Another way is to pass "mode=memory" in the "query string"
idx := strings.IndexRune(lc, '?')
if idx < 0 {
return false
}
qs, _ := url.ParseQuery(lc[(idx + 1):])
return len(qs["mode"]) > 0 && qs["mode"][0] == "memory"
}
func getGormLogger() gormLogger.Interface {

View File

@@ -8,23 +8,93 @@ import (
"github.com/stretchr/testify/require"
)
func TestParseSqliteConnectionString(t *testing.T) {
func TestIsSqliteInMemory(t *testing.T) {
tests := []struct {
name string
input string
expected string
expectedError bool
name string
connStr string
expected bool
}{
{
name: "memory database with :memory:",
connStr: ":memory:",
expected: true,
},
{
name: "memory database with file::memory:",
connStr: "file::memory:",
expected: true,
},
{
name: "memory database with :MEMORY: (uppercase)",
connStr: ":MEMORY:",
expected: true,
},
{
name: "memory database with FILE::MEMORY: (uppercase)",
connStr: "FILE::MEMORY:",
expected: true,
},
{
name: "memory database with mixed case",
connStr: ":Memory:",
expected: true,
},
{
name: "has mode=memory",
connStr: "file:data?mode=memory",
expected: true,
},
{
name: "file database",
connStr: "data.db",
expected: false,
},
{
name: "file database with path",
connStr: "/path/to/data.db",
expected: false,
},
{
name: "file database with file: prefix",
connStr: "file:data.db",
expected: false,
},
{
name: "empty string",
connStr: "",
expected: false,
},
{
name: "string containing memory but not at start",
connStr: "data:memory:.db",
expected: false,
},
{
name: "has mode=ro",
connStr: "file:data?mode=ro",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isSqliteInMemory(tt.connStr)
assert.Equal(t, tt.expected, result)
})
}
}
func TestConvertSqlitePragmaArgs(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "basic file path",
input: "file:test.db",
expected: "file:test.db",
},
{
name: "adds file: prefix if missing",
input: "test.db",
expected: "file:test.db",
},
{
name: "converts _busy_timeout to pragma",
input: "file:test.db?_busy_timeout=5000",
@@ -100,46 +170,161 @@ func TestParseSqliteConnectionString(t *testing.T) {
input: "file:test.db?_fk=1&mode=rw&_timeout=5000",
expected: "file:test.db?_pragma=foreign_keys%281%29&_pragma=busy_timeout%285000%29&mode=rw",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resultURL, _ := url.Parse(tt.input)
convertSqlitePragmaArgs(resultURL)
// Parse both URLs to compare components independently
expectedURL, err := url.Parse(tt.expected)
require.NoError(t, err)
// Compare scheme and path components
compareQueryStrings(t, expectedURL, resultURL)
})
}
}
func TestAddSqliteDefaultParameters(t *testing.T) {
tests := []struct {
name string
input string
isMemoryDB bool
expected string
expectError bool
}{
{
name: "invalid URL format",
input: "file:invalid#$%^&*@test.db",
expectedError: true,
name: "basic file database",
input: "file:test.db",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate",
},
{
name: "in-memory database",
input: "file::memory:",
isMemoryDB: true,
expected: "file::memory:?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28MEMORY%29&_txlock=immediate&cache=shared",
},
{
name: "read-only database with mode=ro",
input: "file:test.db?mode=ro",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&mode=ro",
},
{
name: "immutable database",
input: "file:test.db?immutable=1",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&immutable=1",
},
{
name: "database with existing _txlock",
input: "file:test.db?_txlock=deferred",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=deferred",
},
{
name: "database with existing busy_timeout pragma",
input: "file:test.db?_pragma=busy_timeout%285000%29",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%285000%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate",
},
{
name: "database with existing journal_mode pragma",
input: "file:test.db?_pragma=journal_mode%28DELETE%29",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate",
},
{
name: "database with forbidden foreign_keys pragma",
input: "file:test.db?_pragma=foreign_keys%280%29",
isMemoryDB: false,
expectError: true,
},
{
name: "database with multiple existing pragmas",
input: "file:test.db?_pragma=busy_timeout%283000%29&_pragma=journal_mode%28TRUNCATE%29&_pragma=synchronous%28NORMAL%29",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%283000%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28TRUNCATE%29&_pragma=synchronous%28NORMAL%29&_txlock=immediate",
},
{
name: "in-memory database with cache already set",
input: "file::memory:?cache=private",
isMemoryDB: true,
expected: "file::memory:?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28MEMORY%29&_txlock=immediate&cache=shared",
},
{
name: "database with mode=rw (not read-only)",
input: "file:test.db?mode=rw",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate&mode=rw",
},
{
name: "database with immutable=0 (not immutable)",
input: "file:test.db?immutable=0",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate&immutable=0",
},
{
name: "database with mixed case mode=RO",
input: "file:test.db?mode=RO",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&mode=ro",
},
{
name: "database with mixed case immutable=1",
input: "file:test.db?immutable=1",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&immutable=1",
},
{
name: "complex database configuration",
input: "file:test.db?cache=shared&mode=rwc&_txlock=immediate&_pragma=synchronous%28FULL%29",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_pragma=synchronous%28FULL%29&_txlock=immediate&cache=shared&mode=rwc",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := parseSqliteConnectionString(tt.input)
resultURL, err := url.Parse(tt.input)
require.NoError(t, err)
if tt.expectedError {
err = addSqliteDefaultParameters(resultURL, tt.isMemoryDB)
if tt.expectError {
require.Error(t, err)
return
}
require.NoError(t, err)
// Parse both URLs to compare components independently
expectedURL, err := url.Parse(tt.expected)
require.NoError(t, err)
resultURL, err := url.Parse(result)
require.NoError(t, err)
// Compare scheme and path components
assert.Equal(t, expectedURL.Scheme, resultURL.Scheme)
assert.Equal(t, expectedURL.Path, resultURL.Path)
// Compare query parameters regardless of order
expectedQuery := expectedURL.Query()
resultQuery := resultURL.Query()
assert.Len(t, expectedQuery, len(resultQuery))
for key, expectedValues := range expectedQuery {
resultValues, ok := resultQuery[key]
_ = assert.True(t, ok) &&
assert.ElementsMatch(t, expectedValues, resultValues)
}
compareQueryStrings(t, expectedURL, resultURL)
})
}
}
func compareQueryStrings(t *testing.T, expectedURL *url.URL, resultURL *url.URL) {
t.Helper()
// Compare scheme and path components
assert.Equal(t, expectedURL.Scheme, resultURL.Scheme)
assert.Equal(t, expectedURL.Path, resultURL.Path)
// Compare query parameters regardless of order
expectedQuery := expectedURL.Query()
resultQuery := resultURL.Query()
assert.Len(t, expectedQuery, len(resultQuery))
for key, expectedValues := range expectedQuery {
resultValues, ok := resultQuery[key]
_ = assert.True(t, ok) &&
assert.ElementsMatch(t, expectedValues, resultValues)
}
}

View File

@@ -140,7 +140,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
addr = common.EnvConfig.UnixSocket
}
listener, err := net.Listen(network, addr)
listener, err := net.Listen(network, addr) //nolint:noctx
if err != nil {
return nil, fmt.Errorf("failed to create %s listener: %w", network, err)
}

View File

@@ -46,22 +46,21 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (sv
return nil, fmt.Errorf("failed to create JWT service: %w", err)
}
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService)
svc.customClaimService = service.NewCustomClaimService(db)
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService)
if err != nil {
return nil, fmt.Errorf("failed to create OIDC service: %w", err)
}
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService)
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService)
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
svc.webauthnService, err = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService)
if err != nil {
return nil, fmt.Errorf("failed to create WebAuthn service: %w", err)
}
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService, svc.webauthnService)
if err != nil {
return nil, fmt.Errorf("failed to create OIDC service: %w", err)
}
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService)
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService)
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService)
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
return svc, nil
}

View File

@@ -51,7 +51,7 @@ var oneTimeAccessTokenCmd = &cobra.Command{
}
// Create a new access token that expires in 1 hour
oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Now().Add(time.Hour))
oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Hour)
if txErr != nil {
return fmt.Errorf("failed to generate access token: %w", txErr)
}

View File

@@ -26,7 +26,7 @@ const (
DbProviderSqlite DbProvider = "sqlite"
DbProviderPostgres DbProvider = "postgres"
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"
defaultSqliteConnString string = "data/pocket-id.db"
)
type EnvConfigSchema struct {

View File

@@ -350,6 +350,15 @@ func (e *OidcAuthorizationPendingError) HttpStatusCode() int {
return http.StatusBadRequest
}
type ReauthenticationRequiredError struct{}
func (e *ReauthenticationRequiredError) Error() string {
return "reauthentication required"
}
func (e *ReauthenticationRequiredError) HttpStatusCode() int {
return http.StatusUnauthorized
}
type OpenSignupDisabledError struct{}
func (e *OpenSignupDisabledError) Error() string {
@@ -359,3 +368,13 @@ func (e *OpenSignupDisabledError) Error() string {
func (e *OpenSignupDisabledError) HttpStatusCode() int {
return http.StatusForbidden
}
type ClientIdAlreadyExistsError struct{}
func (e *ClientIdAlreadyExistsError) Error() string {
return "Client ID already in use"
}
func (e *ClientIdAlreadyExistsError) HttpStatusCode() int {
return http.StatusBadRequest
}

View File

@@ -55,10 +55,12 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.POST("/oidc/device/verify", authMiddleware.WithAdminNotRequired().Add(), oc.verifyDeviceCodeHandler)
group.GET("/oidc/device/info", authMiddleware.WithAdminNotRequired().Add(), oc.getDeviceCodeInfoHandler)
group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAuthorizedClientsHandler)
group.GET("/oidc/users/:id/clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler)
group.GET("/oidc/users/me/authorized-clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAuthorizedClientsHandler)
group.GET("/oidc/users/:id/authorized-clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler)
group.DELETE("/oidc/users/me/clients/:clientId", authMiddleware.WithAdminNotRequired().Add(), oc.revokeOwnClientAuthorizationHandler)
group.DELETE("/oidc/users/me/authorized-clients/:clientId", authMiddleware.WithAdminNotRequired().Add(), oc.revokeOwnClientAuthorizationHandler)
group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAccessibleClientsHandler)
}
@@ -490,11 +492,11 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) {
// @Accept json
// @Produce json
// @Param id path string true "Client ID"
// @Param client body dto.OidcClientCreateDto true "Client information"
// @Param client body dto.OidcClientUpdateDto true "Client information"
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Updated client"
// @Router /api/oidc/clients/{id} [put]
func (oc *OidcController) updateClientHandler(c *gin.Context) {
var input dto.OidcClientCreateDto
var input dto.OidcClientUpdateDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
@@ -660,7 +662,7 @@ func (oc *OidcController) deviceAuthorizationHandler(c *gin.Context) {
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
// @Router /api/oidc/users/me/clients [get]
// @Router /api/oidc/users/me/authorized-clients [get]
func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) {
userID := c.GetString("userID")
oc.listAuthorizedClients(c, userID)
@@ -676,7 +678,7 @@ func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) {
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
// @Router /api/oidc/users/{id}/clients [get]
// @Router /api/oidc/users/{id}/authorized-clients [get]
func (oc *OidcController) listAuthorizedClientsHandler(c *gin.Context) {
userID := c.Param("id")
oc.listAuthorizedClients(c, userID)
@@ -713,7 +715,7 @@ func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) {
// @Tags OIDC
// @Param clientId path string true "Client ID to revoke authorization for"
// @Success 204 "No Content"
// @Router /api/oidc/users/me/clients/{clientId} [delete]
// @Router /api/oidc/users/me/authorized-clients/{clientId} [delete]
func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) {
clientID := c.Param("clientId")
@@ -728,6 +730,37 @@ func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) {
c.Status(http.StatusNoContent)
}
// listOwnAccessibleClientsHandler godoc
// @Summary List accessible OIDC clients for current user
// @Description Get a list of OIDC clients that the current user can access
// @Tags OIDC
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.AccessibleOidcClientDto]
// @Router /api/oidc/users/me/clients [get]
func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) {
userID := c.GetString("userID")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
clients, pagination, err := oc.oidcService.ListAccessibleOidcClients(c.Request.Context(), userID, sortedPaginationRequest)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, dto.Paginated[dto.AccessibleOidcClientDto]{
Data: clients,
Pagination: pagination,
})
}
func (oc *OidcController) verifyDeviceCodeHandler(c *gin.Context) {
userCode := c.Query("code")
if userCode == "" {

View File

@@ -14,6 +14,11 @@ import (
"golang.org/x/time/rate"
)
const (
defaultOneTimeAccessTokenDuration = 15 * time.Minute
defaultSignupTokenDuration = time.Hour
)
// NewUserController creates a new controller for user management endpoints
// @Summary User management controller
// @Description Initializes all user-related API endpoints
@@ -331,10 +336,17 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bo
return
}
var ttl time.Duration
if own {
input.UserID = c.GetString("userID")
ttl = defaultOneTimeAccessTokenDuration
} else {
ttl = input.TTL.Duration
if ttl <= 0 {
ttl = defaultOneTimeAccessTokenDuration
}
}
token, err := uc.userService.CreateOneTimeAccessToken(c.Request.Context(), input.UserID, input.ExpiresAt)
token, err := uc.userService.CreateOneTimeAccessToken(c.Request.Context(), input.UserID, ttl)
if err != nil {
_ = c.Error(err)
return
@@ -411,7 +423,11 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
userID := c.Param("id")
err := uc.userService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, input.ExpiresAt)
ttl := input.TTL.Duration
if ttl <= 0 {
ttl = defaultOneTimeAccessTokenDuration
}
err := uc.userService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, ttl)
if err != nil {
_ = c.Error(err)
return
@@ -526,14 +542,20 @@ func (uc *UserController) createSignupTokenHandler(c *gin.Context) {
return
}
signupToken, err := uc.userService.CreateSignupToken(c.Request.Context(), input.ExpiresAt, input.UsageLimit)
ttl := input.TTL.Duration
if ttl <= 0 {
ttl = defaultSignupTokenDuration
}
signupToken, err := uc.userService.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit)
if err != nil {
_ = c.Error(err)
return
}
var tokenDto dto.SignupTokenDto
if err := dto.MapStruct(signupToken, &tokenDto); err != nil {
err = dto.MapStruct(signupToken, &tokenDto)
if err != nil {
_ = c.Error(err)
return
}

View File

@@ -25,6 +25,8 @@ func NewWebauthnController(group *gin.RouterGroup, authMiddleware *middleware.Au
group.POST("/webauthn/logout", authMiddleware.WithAdminNotRequired().Add(), wc.logoutHandler)
group.POST("/webauthn/reauthenticate", authMiddleware.WithAdminNotRequired().Add(), rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), wc.reauthenticateHandler)
group.GET("/webauthn/credentials", authMiddleware.WithAdminNotRequired().Add(), wc.listCredentialsHandler)
group.PATCH("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.updateCredentialHandler)
group.DELETE("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.deleteCredentialHandler)
@@ -171,3 +173,33 @@ func (wc *WebauthnController) logoutHandler(c *gin.Context) {
cookie.AddAccessTokenCookie(c, 0, "")
c.Status(http.StatusNoContent)
}
func (wc *WebauthnController) reauthenticateHandler(c *gin.Context) {
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
if err != nil {
_ = c.Error(&common.MissingSessionIdError{})
return
}
var token string
// Try to create a reauthentication token with WebAuthn
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
if err == nil {
token, err = wc.webAuthnService.CreateReauthenticationTokenWithWebauthn(c.Request.Context(), sessionID, credentialAssertionData)
if err != nil {
_ = c.Error(err)
return
}
} else {
// If WebAuthn fails, try to create a reauthentication token with the access token
accessToken, _ := c.Cookie(cookie.AccessTokenCookieName)
token, err = wc.webAuthnService.CreateReauthenticationTokenWithAccessToken(c.Request.Context(), accessToken)
if err != nil {
_ = c.Error(err)
return
}
}
c.JSON(http.StatusOK, gin.H{"reauthenticationToken": token})
}

View File

@@ -18,6 +18,8 @@ type AppConfigUpdateDto struct {
DisableAnimations string `json:"disableAnimations" binding:"required"`
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
AllowUserSignups string `json:"allowUserSignups" binding:"required,oneof=disabled withToken open"`
SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs" binding:"omitempty,json"`
SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" binding:"omitempty,json"`
AccentColor string `json:"accentColor"`
SmtpHost string `json:"smtpHost"`
SmtpPort string `json:"smtpPort"`

View File

@@ -3,10 +3,11 @@ package dto
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
type OidcClientMetaDataDto struct {
ID string `json:"id"`
Name string `json:"name"`
HasLogo bool `json:"hasLogo"`
LaunchURL *string `json:"launchURL"`
ID string `json:"id"`
Name string `json:"name"`
HasLogo bool `json:"hasLogo"`
LaunchURL *string `json:"launchURL"`
RequiresReauthentication bool `json:"requiresReauthentication"`
}
type OidcClientDto struct {
@@ -28,14 +29,20 @@ type OidcClientWithAllowedGroupsCountDto struct {
AllowedUserGroupsCount int64 `json:"allowedUserGroupsCount"`
}
type OidcClientUpdateDto struct {
Name string `json:"name" binding:"required,max=50" unorm:"nfc"`
CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
RequiresReauthentication bool `json:"requiresReauthentication"`
Credentials OidcClientCredentialsDto `json:"credentials"`
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
}
type OidcClientCreateDto struct {
Name string `json:"name" binding:"required,max=50" unorm:"nfc"`
CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
Credentials OidcClientCredentialsDto `json:"credentials"`
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
OidcClientUpdateDto
ID string `json:"id" binding:"omitempty,client_id,min=2,max=128"`
}
type OidcClientCredentialsDto struct {
@@ -50,12 +57,13 @@ type OidcClientFederatedIdentityDto struct {
}
type AuthorizeOidcClientRequestDto struct {
ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope" binding:"required"`
CallbackURL string `json:"callbackURL"`
Nonce string `json:"nonce"`
CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"`
ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope" binding:"required"`
CallbackURL string `json:"callbackURL"`
Nonce string `json:"nonce"`
CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"`
ReauthenticationToken string `json:"reauthenticationToken"`
}
type AuthorizeOidcClientResponseDto struct {
@@ -159,3 +167,8 @@ type OidcClientPreviewDto struct {
AccessToken map[string]any `json:"accessToken"`
UserInfo map[string]any `json:"userInfo"`
}
type AccessibleOidcClientDto struct {
OidcClientMetaDataDto
LastUsedAt *datatype.DateTime `json:"lastUsedAt"`
}

View File

@@ -1,14 +1,13 @@
package dto
import (
"time"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type SignupTokenCreateDto struct {
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"`
TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"`
UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"`
}
type SignupTokenDto struct {

View File

@@ -1,7 +1,7 @@
package dto
import (
"time"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type UserDto struct {
@@ -30,8 +30,8 @@ type UserCreateDto struct {
}
type OneTimeAccessTokenCreateDto struct {
UserID string `json:"userId"`
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
UserID string `json:"userId"`
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
}
type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
@@ -40,7 +40,7 @@ type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
}
type OneTimeAccessEmailAsAdminDto struct {
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
}
type UserUpdateUserGroupDto struct {

View File

@@ -1,29 +1,52 @@
package dto
import (
"log/slog"
"os"
"regexp"
"time"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/gin-gonic/gin/binding"
"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 {
return validateUsernameRegex.MatchString(fl.Field().String())
}
func init() {
v, _ := binding.Validator.Engine().(*validator.Validate)
err := v.RegisterValidation("username", validateUsername)
v := binding.Validator.Engine().(*validator.Validate)
// [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 validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$")
// Maximum allowed value for TTLs
const maxTTL = 31 * 24 * time.Hour
// Errors here are development-time ones
err := v.RegisterValidation("username", func(fl validator.FieldLevel) bool {
return validateUsernameRegex.MatchString(fl.Field().String())
})
if err != nil {
slog.Error("Failed to register custom validation", slog.Any("error", err))
os.Exit(1)
return
panic("Failed to register custom validation for username: " + err.Error())
}
err = v.RegisterValidation("client_id", func(fl validator.FieldLevel) bool {
return validateClientIDRegex.MatchString(fl.Field().String())
})
if err != nil {
panic("Failed to register custom validation for client_id: " + err.Error())
}
err = v.RegisterValidation("ttl", func(fl validator.FieldLevel) bool {
ttl, ok := fl.Field().Interface().(utils.JSONDuration)
if !ok {
return false
}
// Allow zero, which means the field wasn't set
return ttl.Duration == 0 || ttl.Duration > time.Second && ttl.Duration <= maxTTL
})
if err != nil {
panic("Failed to register custom validation for ttl: " + err.Error())
}
}

View File

@@ -25,6 +25,7 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro
s.registerJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true),
s.registerJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
s.registerJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
s.registerJob(ctx, "ClearReauthenticationTokens", def, jobs.clearReauthenticationTokens, true),
s.registerJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
)
}
@@ -104,6 +105,20 @@ func (j *DbCleanupJobs) clearOidcRefreshTokens(ctx context.Context) error {
return nil
}
// ClearReauthenticationTokens deletes reauthentication tokens that have expired
func (j *DbCleanupJobs) clearReauthenticationTokens(ctx context.Context) error {
st := j.db.
WithContext(ctx).
Delete(&model.ReauthenticationToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired reauthentication tokens: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired reauthentication tokens", slog.Int64("count", st.RowsAffected))
return nil
}
// ClearAuditLogs deletes audit logs older than 90 days
func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
st := j.db.

View File

@@ -34,13 +34,15 @@ func (a *AppConfigVariable) AsDurationMinutes() time.Duration {
type AppConfig struct {
// General
AppName AppConfigVariable `key:"appName,public"` // Public
SessionDuration AppConfigVariable `key:"sessionDuration"`
EmailsVerified AppConfigVariable `key:"emailsVerified"`
AccentColor AppConfigVariable `key:"accentColor,public"` // Public
DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public
AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public
AllowUserSignups AppConfigVariable `key:"allowUserSignups,public"` // Public
AppName AppConfigVariable `key:"appName,public"` // Public
SessionDuration AppConfigVariable `key:"sessionDuration"`
EmailsVerified AppConfigVariable `key:"emailsVerified"`
AccentColor AppConfigVariable `key:"accentColor,public"` // Public
DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public
AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public
AllowUserSignups AppConfigVariable `key:"allowUserSignups,public"` // Public
SignupDefaultUserGroupIDs AppConfigVariable `key:"signupDefaultUserGroupIDs"`
SignupDefaultCustomClaims AppConfigVariable `key:"signupDefaultCustomClaims"`
// Internal
BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal
LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal

View File

@@ -40,20 +40,22 @@ type OidcAuthorizationCode struct {
type OidcClient struct {
Base
Name string `sortable:"true"`
Secret string
CallbackURLs UrlList
LogoutCallbackURLs UrlList
ImageType *string
HasLogo bool `gorm:"-"`
IsPublic bool
PkceEnabled bool
Credentials OidcClientCredentials
LaunchURL *string
Name string `sortable:"true"`
Secret string
CallbackURLs UrlList
LogoutCallbackURLs UrlList
ImageType *string
HasLogo bool `gorm:"-"`
IsPublic bool
PkceEnabled bool
RequiresReauthentication bool
Credentials OidcClientCredentials
LaunchURL *string
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
CreatedByID string
CreatedBy User
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
CreatedByID *string
CreatedBy *User
UserAuthorizedOidcClients []UserAuthorizedOidcClient `gorm:"foreignKey:ClientID;references:ID"`
}
type OidcRefreshToken struct {

View File

@@ -45,6 +45,15 @@ type PublicKeyCredentialRequestOptions struct {
Timeout time.Duration
}
type ReauthenticationToken struct {
Base
Token string
ExpiresAt datatype.DateTime
UserID string
User User
}
type AuthenticatorTransportList []protocol.AuthenticatorTransport //nolint:recvcheck
// Scan and Value methods for GORM to handle the custom type

View File

@@ -60,13 +60,15 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
// Values are the default ones
return &model.AppConfig{
// General
AppName: model.AppConfigVariable{Value: "Pocket ID"},
SessionDuration: model.AppConfigVariable{Value: "60"},
EmailsVerified: model.AppConfigVariable{Value: "false"},
DisableAnimations: model.AppConfigVariable{Value: "false"},
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
AllowUserSignups: model.AppConfigVariable{Value: "disabled"},
AccentColor: model.AppConfigVariable{Value: "default"},
AppName: model.AppConfigVariable{Value: "Pocket ID"},
SessionDuration: model.AppConfigVariable{Value: "60"},
EmailsVerified: model.AppConfigVariable{Value: "false"},
DisableAnimations: model.AppConfigVariable{Value: "false"},
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
AllowUserSignups: model.AppConfigVariable{Value: "disabled"},
SignupDefaultUserGroupIDs: model.AppConfigVariable{Value: "[]"},
SignupDefaultCustomClaims: model.AppConfigVariable{Value: "[]"},
AccentColor: model.AppConfigVariable{Value: "default"},
// Internal
BackgroundImageType: model.AppConfigVariable{Value: "jpg"},
LogoLightImageType: model.AppConfigVariable{Value: "svg"},

View File

@@ -55,16 +55,46 @@ const (
// UpdateCustomClaimsForUser updates the custom claims for a user
func (s *CustomClaimService) UpdateCustomClaimsForUser(ctx context.Context, userID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
return s.updateCustomClaims(ctx, UserID, userID, claims)
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
updatedClaims, err := s.updateCustomClaimsInternal(ctx, UserID, userID, claims, tx)
if err != nil {
return nil, err
}
err = tx.Commit().Error
if err != nil {
return nil, err
}
return updatedClaims, nil
}
// UpdateCustomClaimsForUserGroup updates the custom claims for a user group
func (s *CustomClaimService) UpdateCustomClaimsForUserGroup(ctx context.Context, userGroupID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
return s.updateCustomClaims(ctx, UserGroupID, userGroupID, claims)
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
updatedClaims, err := s.updateCustomClaimsInternal(ctx, UserGroupID, userGroupID, claims, tx)
if err != nil {
return nil, err
}
err = tx.Commit().Error
if err != nil {
return nil, err
}
return updatedClaims, nil
}
// updateCustomClaims updates the custom claims for a user or user group
func (s *CustomClaimService) updateCustomClaims(ctx context.Context, idType idType, value string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
// updateCustomClaimsInternal updates the custom claims for a user or user group within a transaction
func (s *CustomClaimService) updateCustomClaimsInternal(ctx context.Context, idType idType, value string, claims []dto.CustomClaimCreateDto, tx *gorm.DB) ([]model.CustomClaim, error) {
// Check for duplicate keys in the claims slice
seenKeys := make(map[string]struct{})
for _, claim := range claims {
@@ -74,11 +104,6 @@ func (s *CustomClaimService) updateCustomClaims(ctx context.Context, idType idTy
seenKeys[claim.Key] = struct{}{}
}
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var existingClaims []model.CustomClaim
err := tx.
WithContext(ctx).
@@ -150,11 +175,6 @@ func (s *CustomClaimService) updateCustomClaims(ctx context.Context, idType idTy
return nil, err
}
err = tx.Commit().Error
if err != nil {
return nil, err
}
return updatedClaims, nil
}

View File

@@ -159,7 +159,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
CallbackURLs: model.UrlList{"http://nextcloud/auth/callback"},
LogoutCallbackURLs: model.UrlList{"http://nextcloud/auth/logout/callback"},
ImageType: utils.StringPointer("png"),
CreatedByID: users[0].ID,
CreatedByID: utils.Ptr(users[0].ID),
},
{
Base: model.Base{
@@ -168,7 +168,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Name: "Immich",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
CallbackURLs: model.UrlList{"http://immich/auth/callback"},
CreatedByID: users[1].ID,
CreatedByID: utils.Ptr(users[1].ID),
AllowedUserGroups: []model.UserGroup{
userGroups[1],
},
@@ -181,7 +181,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Secret: "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a", // n4VfQeXlTzA6yKpWbR9uJcMdSx2qH0Lo
CallbackURLs: model.UrlList{"http://tailscale/auth/callback"},
LogoutCallbackURLs: model.UrlList{"http://tailscale/auth/logout/callback"},
CreatedByID: users[0].ID,
CreatedByID: utils.Ptr(users[0].ID),
},
{
Base: model.Base{
@@ -190,7 +190,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Name: "Federated",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
CallbackURLs: model.UrlList{"http://federated/auth/callback"},
CreatedByID: users[1].ID,
CreatedByID: utils.Ptr(users[1].ID),
AllowedUserGroups: []model.UserGroup{},
Credentials: model.OidcClientCredentials{
FederatedIdentities: []model.OidcClientFederatedIdentity{

View File

@@ -50,6 +50,7 @@ type OidcService struct {
appConfigService *AppConfigService
auditLogService *AuditLogService
customClaimService *CustomClaimService
webAuthnService *WebAuthnService
httpClient *http.Client
jwkCache *jwk.Cache
@@ -62,6 +63,7 @@ func NewOidcService(
appConfigService *AppConfigService,
auditLogService *AuditLogService,
customClaimService *CustomClaimService,
webAuthnService *WebAuthnService,
) (s *OidcService, err error) {
s = &OidcService{
db: db,
@@ -69,6 +71,7 @@ func NewOidcService(
appConfigService: appConfigService,
auditLogService: auditLogService,
customClaimService: customClaimService,
webAuthnService: webAuthnService,
}
// Note: we don't pass the HTTP Client with OTel instrumented to this because requests are always made in background and not tied to a specific trace
@@ -123,6 +126,16 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
return "", "", err
}
if client.RequiresReauthentication {
if input.ReauthenticationToken == "" {
return "", "", &common.ReauthenticationRequiredError{}
}
err = s.webAuthnService.ConsumeReauthenticationToken(ctx, tx, input.ReauthenticationToken, userID)
if err != nil {
return "", "", err
}
}
// If the client is not public, the code challenge must be provided
if client.IsPublic && input.CodeChallenge == "" {
return "", "", &common.OidcMissingCodeChallengeError{}
@@ -641,8 +654,7 @@ func (s *OidcService) ListClients(ctx context.Context, name string, sortedPagina
}
// As allowedUserGroupsCount is not a column, we need to manually sort it
isValidSortDirection := sortedPaginationRequest.Sort.Direction == "asc" || sortedPaginationRequest.Sort.Direction == "desc"
if sortedPaginationRequest.Sort.Column == "allowedUserGroupsCount" && isValidSortDirection {
if sortedPaginationRequest.Sort.Column == "allowedUserGroupsCount" && utils.IsValidSortDirection(sortedPaginationRequest.Sort.Direction) {
query = query.Select("oidc_clients.*, COUNT(oidc_clients_allowed_user_groups.oidc_client_id)").
Joins("LEFT JOIN oidc_clients_allowed_user_groups ON oidc_clients.id = oidc_clients_allowed_user_groups.oidc_client_id").
Group("oidc_clients.id").
@@ -658,22 +670,28 @@ func (s *OidcService) ListClients(ctx context.Context, name string, sortedPagina
func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCreateDto, userID string) (model.OidcClient, error) {
client := model.OidcClient{
CreatedByID: userID,
Base: model.Base{
ID: input.ID,
},
CreatedByID: utils.Ptr(userID),
}
updateOIDCClientModelFromDto(&client, &input)
updateOIDCClientModelFromDto(&client, &input.OidcClientUpdateDto)
err := s.db.
WithContext(ctx).
Create(&client).
Error
if err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.OidcClient{}, &common.ClientIdAlreadyExistsError{}
}
return model.OidcClient{}, err
}
return client, nil
}
func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input dto.OidcClientCreateDto) (model.OidcClient, error) {
func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input dto.OidcClientUpdateDto) (model.OidcClient, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
@@ -707,7 +725,7 @@ func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input d
return client, nil
}
func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClientCreateDto) {
func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClientUpdateDto) {
// Base fields
client.Name = input.Name
client.CallbackURLs = input.CallbackURLs
@@ -715,20 +733,20 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien
client.IsPublic = input.IsPublic
// PKCE is required for public clients
client.PkceEnabled = input.IsPublic || input.PkceEnabled
client.RequiresReauthentication = input.RequiresReauthentication
client.LaunchURL = input.LaunchURL
// Credentials
if len(input.Credentials.FederatedIdentities) > 0 {
client.Credentials.FederatedIdentities = make([]model.OidcClientFederatedIdentity, len(input.Credentials.FederatedIdentities))
for i, fi := range input.Credentials.FederatedIdentities {
client.Credentials.FederatedIdentities[i] = model.OidcClientFederatedIdentity{
Issuer: fi.Issuer,
Audience: fi.Audience,
Subject: fi.Subject,
JWKS: fi.JWKS,
}
client.Credentials.FederatedIdentities = make([]model.OidcClientFederatedIdentity, len(input.Credentials.FederatedIdentities))
for i, fi := range input.Credentials.FederatedIdentities {
client.Credentials.FederatedIdentities[i] = model.OidcClientFederatedIdentity{
Issuer: fi.Issuer,
Audience: fi.Audience,
Subject: fi.Subject,
JWKS: fi.JWKS,
}
}
}
func (s *OidcService) DeleteClient(ctx context.Context, clientID string) error {
@@ -1336,6 +1354,81 @@ func (s *OidcService) RevokeAuthorizedClient(ctx context.Context, userID string,
return nil
}
func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]dto.AccessibleOidcClientDto, utils.PaginationResponse, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var user model.User
err := tx.
WithContext(ctx).
Preload("UserGroups").
First(&user, "id = ?", userID).
Error
if err != nil {
return nil, utils.PaginationResponse{}, err
}
userGroupIDs := make([]string, len(user.UserGroups))
for i, group := range user.UserGroups {
userGroupIDs[i] = group.ID
}
// Build the query for accessible clients
query := tx.
WithContext(ctx).
Model(&model.OidcClient{}).
Preload("UserAuthorizedOidcClients", "user_id = ?", userID).
Distinct()
// If user has no groups, only return clients with no allowed user groups
if len(userGroupIDs) == 0 {
query = query.
Joins("LEFT JOIN oidc_clients_allowed_user_groups ON oidc_clients.id = oidc_clients_allowed_user_groups.oidc_client_id").
Where("oidc_clients_allowed_user_groups.oidc_client_id IS NULL")
} else {
// Return clients with no allowed user groups OR clients where user is in allowed groups
query = query.
Joins("LEFT JOIN oidc_clients_allowed_user_groups ON oidc_clients.id = oidc_clients_allowed_user_groups.oidc_client_id").
Where("oidc_clients_allowed_user_groups.oidc_client_id IS NULL OR oidc_clients_allowed_user_groups.user_group_id IN (?)", userGroupIDs)
}
var clients []model.OidcClient
// Handle custom sorting for lastUsedAt column
var response utils.PaginationResponse
if sortedPaginationRequest.Sort.Column == "lastUsedAt" && utils.IsValidSortDirection(sortedPaginationRequest.Sort.Direction) {
query = query.
Joins("LEFT JOIN user_authorized_oidc_clients ON oidc_clients.id = user_authorized_oidc_clients.client_id AND user_authorized_oidc_clients.user_id = ?", userID).
Order("user_authorized_oidc_clients.last_used_at " + sortedPaginationRequest.Sort.Direction)
}
response, err = utils.PaginateAndSort(sortedPaginationRequest, query, &clients)
if err != nil {
return nil, utils.PaginationResponse{}, err
}
dtos := make([]dto.AccessibleOidcClientDto, len(clients))
for i, client := range clients {
var lastUsedAt *datatype.DateTime
if len(client.UserAuthorizedOidcClients) > 0 {
lastUsedAt = &client.UserAuthorizedOidcClients[0].LastUsedAt
}
dtos[i] = dto.AccessibleOidcClientDto{
OidcClientMetaDataDto: dto.OidcClientMetaDataDto{
ID: client.ID,
Name: client.Name,
LaunchURL: client.LaunchURL,
HasLogo: client.HasLogo,
},
LastUsedAt: lastUsedAt,
}
}
return dtos, response, err
}
func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, userID string, scope string, tx *gorm.DB) (string, error) {
refreshToken, err := utils.GenerateRandomAlphanumericString(40)
if err != nil {
@@ -1462,8 +1555,8 @@ func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, tx *g
// Validate credentials based on the authentication method
switch {
// First, if we have a client secret, we validate it
case input.ClientSecret != "":
// First, if we have a client secret, we validate it unless client is marked as public
case input.ClientSecret != "" && !client.IsPublic:
err = bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(input.ClientSecret))
if err != nil {
return nil, &common.OidcClientSecretInvalidError{}

View File

@@ -171,8 +171,10 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
// Create the test clients
// 1. Confidential client
confidentialClient, err := s.CreateClient(t.Context(), dto.OidcClientCreateDto{
Name: "Confidential Client",
CallbackURLs: []string{"https://example.com/callback"},
OidcClientUpdateDto: dto.OidcClientUpdateDto{
Name: "Confidential Client",
CallbackURLs: []string{"https://example.com/callback"},
},
}, "test-user-id")
require.NoError(t, err)
@@ -182,20 +184,24 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
// 2. Public client
publicClient, err := s.CreateClient(t.Context(), dto.OidcClientCreateDto{
Name: "Public Client",
CallbackURLs: []string{"https://example.com/callback"},
IsPublic: true,
OidcClientUpdateDto: dto.OidcClientUpdateDto{
Name: "Public Client",
CallbackURLs: []string{"https://example.com/callback"},
IsPublic: true,
},
}, "test-user-id")
require.NoError(t, err)
// 3. Confidential client with federated identity
federatedClient, err := s.CreateClient(t.Context(), dto.OidcClientCreateDto{
Name: "Federated Client",
CallbackURLs: []string{"https://example.com/callback"},
OidcClientUpdateDto: dto.OidcClientUpdateDto{
Name: "Federated Client",
CallbackURLs: []string{"https://example.com/callback"},
},
}, "test-user-id")
require.NoError(t, err)
federatedClient, err = s.UpdateClient(t.Context(), federatedClient.ID, dto.OidcClientCreateDto{
federatedClient, err = s.UpdateClient(t.Context(), federatedClient.ID, dto.OidcClientUpdateDto{
Name: federatedClient.Name,
CallbackURLs: federatedClient.CallbackURLs,
Credentials: dto.OidcClientCredentialsDto{

View File

@@ -32,8 +32,7 @@ func (s *UserGroupService) List(ctx context.Context, name string, sortedPaginati
}
// As userCount is not a column we need to manually sort it
isValidSortDirection := sortedPaginationRequest.Sort.Direction == "asc" || sortedPaginationRequest.Sort.Direction == "desc"
if sortedPaginationRequest.Sort.Column == "userCount" && isValidSortDirection {
if sortedPaginationRequest.Sort.Column == "userCount" && utils.IsValidSortDirection(sortedPaginationRequest.Sort.Direction) {
query = query.Select("user_groups.*, COUNT(user_groups_users.user_id)").
Joins("LEFT JOIN user_groups_users ON user_groups.id = user_groups_users.user_group_id").
Group("user_groups.id").

View File

@@ -3,6 +3,7 @@ package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -26,20 +27,22 @@ import (
)
type UserService struct {
db *gorm.DB
jwtService *JwtService
auditLogService *AuditLogService
emailService *EmailService
appConfigService *AppConfigService
db *gorm.DB
jwtService *JwtService
auditLogService *AuditLogService
emailService *EmailService
appConfigService *AppConfigService
customClaimService *CustomClaimService
}
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService) *UserService {
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService, customClaimService *CustomClaimService) *UserService {
return &UserService{
db: db,
jwtService: jwtService,
auditLogService: auditLogService,
emailService: emailService,
appConfigService: appConfigService,
db: db,
jwtService: jwtService,
auditLogService: auditLogService,
emailService: emailService,
appConfigService: appConfigService,
customClaimService: customClaimService,
}
}
@@ -268,9 +271,53 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
} else if err != nil {
return model.User{}, err
}
// Apply default groups and claims for new non-LDAP users
if !isLdapSync {
if err := s.applySignupDefaults(ctx, &user, tx); err != nil {
return model.User{}, err
}
}
return user, nil
}
func (s *UserService) applySignupDefaults(ctx context.Context, user *model.User, tx *gorm.DB) error {
config := s.appConfigService.GetDbConfig()
// Apply default user groups
var groupIDs []string
if v := config.SignupDefaultUserGroupIDs.Value; v != "" && v != "[]" {
if err := json.Unmarshal([]byte(v), &groupIDs); err != nil {
return fmt.Errorf("invalid SignupDefaultUserGroupIDs JSON: %w", err)
}
if len(groupIDs) > 0 {
var groups []model.UserGroup
if err := tx.WithContext(ctx).Where("id IN ?", groupIDs).Find(&groups).Error; err != nil {
return fmt.Errorf("failed to find default user groups: %w", err)
}
if err := tx.WithContext(ctx).Model(user).Association("UserGroups").Replace(groups); err != nil {
return fmt.Errorf("failed to associate default user groups: %w", err)
}
}
}
// Apply default custom claims
var claims []dto.CustomClaimCreateDto
if v := config.SignupDefaultCustomClaims.Value; v != "" && v != "[]" {
if err := json.Unmarshal([]byte(v), &claims); err != nil {
return fmt.Errorf("invalid SignupDefaultCustomClaims JSON: %w", err)
}
if len(claims) > 0 {
if _, err := s.customClaimService.updateCustomClaimsInternal(ctx, UserID, user.ID, claims, tx); err != nil {
return fmt.Errorf("failed to apply default custom claims: %w", err)
}
}
}
return nil
}
func (s *UserService) UpdateUser(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool) (model.User, error) {
tx := s.db.Begin()
defer func() {
@@ -348,13 +395,13 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
return user, nil
}
func (s *UserService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, userID string, expiration time.Time) error {
func (s *UserService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, userID string, ttl time.Duration) error {
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsAdminEnabled.IsTrue()
if isDisabled {
return &common.OneTimeAccessDisabledError{}
}
return s.requestOneTimeAccessEmailInternal(ctx, userID, "", expiration)
return s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl)
}
func (s *UserService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, userID, redirectPath string) error {
@@ -374,11 +421,10 @@ func (s *UserService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context
}
}
expiration := time.Now().Add(15 * time.Minute)
return s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, expiration)
return s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, 15*time.Minute)
}
func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, expiration time.Time) error {
func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, ttl time.Duration) error {
tx := s.db.Begin()
defer func() {
tx.Rollback()
@@ -389,7 +435,7 @@ func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, use
return err
}
oneTimeAccessToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, expiration, tx)
oneTimeAccessToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, ttl, tx)
if err != nil {
return err
}
@@ -421,7 +467,7 @@ func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, use
Code: oneTimeAccessToken,
LoginLink: link,
LoginLinkWithCode: linkWithCode,
ExpirationString: utils.DurationToString(time.Until(expiration).Round(time.Second)),
ExpirationString: utils.DurationToString(ttl),
})
if errInternal != nil {
slog.ErrorContext(innerCtx, "Failed to send one-time access token email", slog.Any("error", errInternal), slog.String("address", user.Email))
@@ -432,17 +478,18 @@ func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, use
return nil
}
func (s *UserService) CreateOneTimeAccessToken(ctx context.Context, userID string, expiresAt time.Time) (string, error) {
return s.createOneTimeAccessTokenInternal(ctx, userID, expiresAt, s.db)
func (s *UserService) CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (string, error) {
return s.createOneTimeAccessTokenInternal(ctx, userID, ttl, s.db)
}
func (s *UserService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, expiresAt time.Time, tx *gorm.DB) (string, error) {
oneTimeAccessToken, err := NewOneTimeAccessToken(userID, expiresAt)
func (s *UserService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, ttl time.Duration, tx *gorm.DB) (string, error) {
oneTimeAccessToken, err := NewOneTimeAccessToken(userID, ttl)
if err != nil {
return "", err
}
if err := tx.WithContext(ctx).Create(oneTimeAccessToken).Error; err != nil {
err = tx.WithContext(ctx).Create(oneTimeAccessToken).Error
if err != nil {
return "", err
}
@@ -504,7 +551,7 @@ func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroup
// Fetch the groups based on userGroupIds
var groups []model.UserGroup
if len(userGroupIds) > 0 {
err = tx.
err := tx.
WithContext(ctx).
Where("id IN (?)", userGroupIds).
Find(&groups).
@@ -642,17 +689,14 @@ func (s *UserService) disableUserInternal(ctx context.Context, userID string, tx
Error
}
func (s *UserService) CreateSignupToken(ctx context.Context, expiresAt time.Time, usageLimit int) (model.SignupToken, error) {
return s.createSignupTokenInternal(ctx, expiresAt, usageLimit, s.db)
}
func (s *UserService) createSignupTokenInternal(ctx context.Context, expiresAt time.Time, usageLimit int, tx *gorm.DB) (model.SignupToken, error) {
signupToken, err := NewSignupToken(expiresAt, usageLimit)
func (s *UserService) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int) (model.SignupToken, error) {
signupToken, err := NewSignupToken(ttl, usageLimit)
if err != nil {
return model.SignupToken{}, err
}
if err := tx.WithContext(ctx).Create(signupToken).Error; err != nil {
err = s.db.WithContext(ctx).Create(signupToken).Error
if err != nil {
return model.SignupToken{}, err
}
@@ -746,10 +790,10 @@ func (s *UserService) DeleteSignupToken(ctx context.Context, tokenID string) err
return s.db.WithContext(ctx).Delete(&model.SignupToken{}, "id = ?", tokenID).Error
}
func NewOneTimeAccessToken(userID string, expiresAt time.Time) (*model.OneTimeAccessToken, error) {
func NewOneTimeAccessToken(userID string, ttl time.Duration) (*model.OneTimeAccessToken, error) {
// If expires at is less than 15 minutes, use a 6-character token instead of 16
tokenLength := 16
if time.Until(expiresAt) <= 15*time.Minute {
if ttl <= 15*time.Minute {
tokenLength = 6
}
@@ -758,25 +802,27 @@ func NewOneTimeAccessToken(userID string, expiresAt time.Time) (*model.OneTimeAc
return nil, err
}
now := time.Now().Round(time.Second)
o := &model.OneTimeAccessToken{
UserID: userID,
ExpiresAt: datatype.DateTime(expiresAt),
ExpiresAt: datatype.DateTime(now.Add(ttl)),
Token: randomString,
}
return o, nil
}
func NewSignupToken(expiresAt time.Time, usageLimit int) (*model.SignupToken, error) {
func NewSignupToken(ttl time.Duration, usageLimit int) (*model.SignupToken, error) {
// Generate a random token
randomString, err := utils.GenerateRandomAlphanumericString(16)
if err != nil {
return nil, err
}
now := time.Now().Round(time.Second)
token := &model.SignupToken{
Token: randomString,
ExpiresAt: datatype.DateTime(expiresAt),
ExpiresAt: datatype.DateTime(now.Add(ttl)),
UsageLimit: usageLimit,
UsageCount: 0,
}

View File

@@ -221,13 +221,15 @@ func (s *WebAuthnService) VerifyLogin(ctx context.Context, sessionID string, cre
tx.Rollback()
}()
// Load & delete the session row
var storedSession model.WebauthnSession
err := tx.
WithContext(ctx).
First(&storedSession, "id = ?", sessionID).
Clauses(clause.Returning{}).
Delete(&storedSession, "id = ?", sessionID).
Error
if err != nil {
return model.User{}, "", err
return model.User{}, "", fmt.Errorf("failed to load WebAuthn session: %w", err)
}
session := webauthn.SessionData{
@@ -334,3 +336,136 @@ func (s *WebAuthnService) UpdateCredential(ctx context.Context, userID, credenti
func (s *WebAuthnService) updateWebAuthnConfig() {
s.webAuthn.Config.RPDisplayName = s.appConfigService.GetDbConfig().AppName.Value
}
func (s *WebAuthnService) CreateReauthenticationTokenWithAccessToken(ctx context.Context, accessToken string) (string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
token, err := s.jwtService.VerifyAccessToken(accessToken)
if err != nil {
return "", fmt.Errorf("invalid access token: %w", err)
}
userID, ok := token.Subject()
if !ok {
return "", fmt.Errorf("access token does not contain user ID")
}
// Check if token is issued less than a minute ago
tokenExpiration, ok := token.IssuedAt()
if !ok || time.Since(tokenExpiration) > time.Minute {
return "", &common.ReauthenticationRequiredError{}
}
var user model.User
err = tx.
WithContext(ctx).
First(&user, "id = ?", userID).
Error
if err != nil {
return "", fmt.Errorf("failed to load user: %w", err)
}
reauthToken, err := s.createReauthenticationToken(ctx, tx, user.ID)
if err != nil {
return "", err
}
err = tx.Commit().Error
if err != nil {
return "", err
}
return reauthToken, nil
}
func (s *WebAuthnService) CreateReauthenticationTokenWithWebauthn(ctx context.Context, sessionID string, credentialAssertionData *protocol.ParsedCredentialAssertionData) (string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
// Retrieve and delete the session
var storedSession model.WebauthnSession
err := tx.
WithContext(ctx).
Clauses(clause.Returning{}).
Delete(&storedSession, "id = ? AND expires_at > ?", sessionID, datatype.DateTime(time.Now())).
Error
if err != nil {
return "", fmt.Errorf("failed to load WebAuthn session: %w", err)
}
session := webauthn.SessionData{
Challenge: storedSession.Challenge,
Expires: storedSession.ExpiresAt.ToTime(),
}
// Validate the credential assertion
var user *model.User
_, err = s.webAuthn.ValidateDiscoverableLogin(func(_, userHandle []byte) (webauthn.User, error) {
innerErr := tx.
WithContext(ctx).
Preload("Credentials").
First(&user, "id = ?", string(userHandle)).
Error
if innerErr != nil {
return nil, innerErr
}
return user, nil
}, session, credentialAssertionData)
if err != nil || user == nil {
return "", err
}
// Create reauthentication token
token, err := s.createReauthenticationToken(ctx, tx, user.ID)
if err != nil {
return "", err
}
err = tx.Commit().Error
if err != nil {
return "", err
}
return token, nil
}
func (s *WebAuthnService) ConsumeReauthenticationToken(ctx context.Context, tx *gorm.DB, token string, userID string) error {
hashedToken := utils.CreateSha256Hash(token)
result := tx.WithContext(ctx).
Clauses(clause.Returning{}).
Delete(&model.ReauthenticationToken{}, "token = ? AND user_id = ? AND expires_at > ?", hashedToken, userID, datatype.DateTime(time.Now()))
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return &common.ReauthenticationRequiredError{}
}
return nil
}
func (s *WebAuthnService) createReauthenticationToken(ctx context.Context, tx *gorm.DB, userID string) (string, error) {
token, err := utils.GenerateRandomAlphanumericString(32)
if err != nil {
return "", err
}
reauthToken := model.ReauthenticationToken{
Token: utils.CreateSha256Hash(token),
ExpiresAt: datatype.DateTime(time.Now().Add(3 * time.Minute)),
UserID: userID,
}
err = tx.WithContext(ctx).Create(&reauthToken).Error
if err != nil {
return "", err
}
return token, nil
}

View File

@@ -0,0 +1,42 @@
package utils
import (
"encoding/json"
"errors"
"time"
)
// JSONDuration is a type that allows marshalling/unmarshalling a Duration
type JSONDuration struct {
time.Duration
}
func (d JSONDuration) MarshalJSON() ([]byte, error) {
return json.Marshal(d.String())
}
func (d *JSONDuration) UnmarshalJSON(b []byte) error {
var v any
err := json.Unmarshal(b, &v)
if err != nil {
return err
}
switch value := v.(type) {
case float64:
// If the value is a number, interpret it as a number of seconds
d.Duration = time.Duration(value) * time.Second
return nil
case string:
if v == "" {
return nil
}
var err error
d.Duration, err = time.ParseDuration(value)
if err != nil {
return err
}
return nil
default:
return errors.New("invalid duration")
}
}

View File

@@ -0,0 +1,64 @@
package utils
import (
"encoding/json"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestJSONDuration_MarshalJSON(t *testing.T) {
tests := []struct {
duration time.Duration
want string
}{
{time.Minute + 30*time.Second, "1m30s"},
{0, "0s"},
}
for _, tc := range tests {
d := JSONDuration{Duration: tc.duration}
b, err := json.Marshal(d)
require.NoError(t, err)
assert.Equal(t, `"`+tc.want+`"`, string(b))
}
}
func TestJSONDuration_UnmarshalJSON_String(t *testing.T) {
var d JSONDuration
err := json.Unmarshal([]byte(`"2h15m5s"`), &d)
require.NoError(t, err)
want := 2*time.Hour + 15*time.Minute + 5*time.Second
assert.Equal(t, want, d.Duration)
}
func TestJSONDuration_UnmarshalJSON_NumberSeconds(t *testing.T) {
tests := []struct {
json string
want time.Duration
}{
{"0", 0},
{"1", 1 * time.Second},
{"2.25", 2 * time.Second}, // Milliseconds are truncated
}
for _, tc := range tests {
var d JSONDuration
err := json.Unmarshal([]byte(tc.json), &d)
require.NoError(t, err, "input: %s", tc.json)
assert.Equal(t, tc.want, d.Duration, "input: %s", tc.json)
}
}
func TestJSONDuration_UnmarshalJSON_Invalid(t *testing.T) {
cases := [][]byte{
[]byte(`true`),
[]byte(`{}`),
[]byte(`"not-a-duration"`),
}
for _, b := range cases {
var d JSONDuration
err := json.Unmarshal(b, &d)
require.Error(t, err, "input: %s", string(b))
}
}

View File

@@ -3,6 +3,7 @@ package utils
import (
"reflect"
"strconv"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -35,9 +36,7 @@ func PaginateAndSort(sortedPaginationRequest SortedPaginationRequest, query *gor
sortField, sortFieldFound := reflect.TypeOf(result).Elem().Elem().FieldByName(capitalizedSortColumn)
isSortable, _ := strconv.ParseBool(sortField.Tag.Get("sortable"))
if sort.Direction == "" || (sort.Direction != "asc" && sort.Direction != "desc") {
sort.Direction = "asc"
}
sort.Direction = NormalizeSortDirection(sort.Direction)
if sortFieldFound && isSortable {
columnName := CamelCaseToSnakeCase(sort.Column)
@@ -85,3 +84,16 @@ func Paginate(page int, pageSize int, query *gorm.DB, result interface{}) (Pagin
ItemsPerPage: pageSize,
}, nil
}
func NormalizeSortDirection(direction string) string {
d := strings.ToLower(strings.TrimSpace(direction))
if d != "asc" && d != "desc" {
return "asc"
}
return d
}
func IsValidSortDirection(direction string) bool {
d := strings.ToLower(strings.TrimSpace(direction))
return d == "asc" || d == "desc"
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,2 @@
ALTER TABLE oidc_clients DROP COLUMN requires_reauthentication;
DROP TABLE IF EXISTS reauthentication_tokens;

View File

@@ -0,0 +1,11 @@
ALTER TABLE oidc_clients ADD COLUMN requires_reauthentication BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE reauthentication_tokens (
id TEXT PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE
);
CREATE INDEX idx_reauthentication_tokens_token ON reauthentication_tokens(token);

View File

@@ -0,0 +1,8 @@
ALTER TABLE public.audit_logs
DROP CONSTRAINT IF EXISTS audit_logs_user_id_fkey,
ADD CONSTRAINT audit_logs_user_id_fkey
FOREIGN KEY (user_id) REFERENCES public.users (id) ON DELETE CASCADE;
ALTER TABLE public.oidc_authorization_codes
ADD CONSTRAINT oidc_authorization_codes_client_fk
FOREIGN KEY (client_id) REFERENCES public.oidc_clients (id) ON DELETE CASCADE;

View File

@@ -0,0 +1,3 @@
ALTER TABLE oidc_clients DROP COLUMN requires_reauthentication;
DROP INDEX IF EXISTS idx_reauthentication_tokens_token;
DROP TABLE IF EXISTS reauthentication_tokens;

View File

@@ -0,0 +1,11 @@
ALTER TABLE oidc_clients ADD COLUMN requires_reauthentication BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE reauthentication_tokens (
id TEXT PRIMARY KEY,
created_at DATETIME NOT NULL,
token TEXT NOT NULL UNIQUE,
expires_at INTEGER NOT NULL,
user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE
);
CREATE INDEX idx_reauthentication_tokens_token ON reauthentication_tokens(token);

View File

@@ -0,0 +1,173 @@
---------------------------
-- Delete all orphaned rows
---------------------------
UPDATE oidc_clients
SET created_by_id = NULL
WHERE created_by_id IS NOT NULL
AND created_by_id NOT IN (SELECT id FROM users);
DELETE FROM oidc_authorization_codes WHERE user_id NOT IN (SELECT id FROM users);
DELETE FROM one_time_access_tokens WHERE user_id NOT IN (SELECT id FROM users);
DELETE FROM webauthn_credentials WHERE user_id NOT IN (SELECT id FROM users);
DELETE FROM audit_logs WHERE user_id IS NOT NULL AND user_id NOT IN (SELECT id FROM users);
DELETE FROM api_keys WHERE user_id IS NOT NULL AND user_id NOT IN (SELECT id FROM users);
DELETE FROM oidc_refresh_tokens WHERE user_id NOT IN (SELECT id FROM users) OR client_id NOT IN (SELECT id FROM oidc_clients);
DELETE FROM oidc_device_codes WHERE (user_id IS NOT NULL AND user_id NOT IN (SELECT id FROM users)) OR client_id NOT IN (SELECT id FROM oidc_clients);
DELETE FROM user_authorized_oidc_clients WHERE user_id NOT IN (SELECT id FROM users) OR client_id NOT IN (SELECT id FROM oidc_clients);
DELETE FROM user_groups_users WHERE user_id NOT IN (SELECT id FROM users) OR user_group_id NOT IN (SELECT id FROM user_groups);
DELETE FROM custom_claims WHERE (user_id IS NOT NULL AND user_id NOT IN (SELECT id FROM users)) OR (user_group_id IS NOT NULL AND user_group_id NOT IN (SELECT id FROM user_groups));
DELETE FROM oidc_clients_allowed_user_groups WHERE oidc_client_id NOT IN (SELECT id FROM oidc_clients) OR user_group_id NOT IN (SELECT id FROM user_groups);
DELETE FROM reauthentication_tokens WHERE user_id NOT IN (SELECT id FROM users);
---------------------------
-- Add missing foreign keys and edit cascade behavior where necessary
---------------------------
-- reauthentication_tokens: add missing FK user_id → users
CREATE TABLE reauthentication_tokens_new
(
id TEXT PRIMARY KEY,
created_at DATETIME NOT NULL,
token TEXT NOT NULL UNIQUE,
expires_at INTEGER NOT NULL,
user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE
);
INSERT INTO reauthentication_tokens_new (id, created_at, token, expires_at, user_id)
SELECT id, created_at, token, expires_at, user_id
FROM reauthentication_tokens;
DROP TABLE reauthentication_tokens;
ALTER TABLE reauthentication_tokens_new RENAME TO reauthentication_tokens;
CREATE INDEX idx_reauthentication_tokens_token
ON reauthentication_tokens (token);
-- oidc_authorization_codes: add FK client_id, user_id → CASCADE
CREATE TABLE oidc_authorization_codes_new
(
id TEXT PRIMARY KEY,
created_at DATETIME NOT NULL,
code TEXT NOT NULL UNIQUE,
scope TEXT NOT NULL,
nonce TEXT,
expires_at DATETIME NOT NULL,
user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE,
client_id TEXT NOT NULL REFERENCES oidc_clients ON DELETE CASCADE,
code_challenge TEXT,
code_challenge_method_sha256 NUMERIC
);
INSERT INTO oidc_authorization_codes_new
(id, created_at, code, scope, nonce, expires_at, user_id, client_id, code_challenge, code_challenge_method_sha256)
SELECT id, created_at, code, scope, nonce, expires_at, user_id, client_id, code_challenge, code_challenge_method_sha256
FROM oidc_authorization_codes;
DROP TABLE oidc_authorization_codes;
ALTER TABLE oidc_authorization_codes_new RENAME TO oidc_authorization_codes;
-- user_authorized_oidc_clients: add FK user_id, cascade client_id
CREATE TABLE user_authorized_oidc_clients_new
(
scope TEXT,
user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE,
client_id TEXT NOT NULL REFERENCES oidc_clients ON DELETE CASCADE,
last_used_at DATETIME NOT NULL,
PRIMARY KEY (user_id, client_id)
);
INSERT INTO user_authorized_oidc_clients_new (scope, user_id, client_id, last_used_at)
SELECT scope, user_id, client_id, last_used_at
FROM user_authorized_oidc_clients;
DROP TABLE user_authorized_oidc_clients;
ALTER TABLE user_authorized_oidc_clients_new RENAME TO user_authorized_oidc_clients;
-- audit_logs: user_id → CASCADE
CREATE TABLE audit_logs_new
(
id TEXT PRIMARY KEY,
created_at DATETIME NOT NULL,
event TEXT NOT NULL,
ip_address TEXT,
user_agent TEXT NOT NULL,
data BLOB NOT NULL,
user_id TEXT REFERENCES users ON DELETE CASCADE,
country TEXT,
city TEXT
);
INSERT INTO audit_logs_new
(id, created_at, event, ip_address, user_agent, data, user_id, country, city)
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;
CREATE INDEX idx_audit_logs_client_name ON audit_logs((json_extract(data, '$.clientName')));
CREATE INDEX idx_audit_logs_country ON audit_logs (country);
CREATE INDEX idx_audit_logs_created_at ON audit_logs (created_at);
CREATE INDEX idx_audit_logs_event ON audit_logs (event);
CREATE INDEX idx_audit_logs_user_agent ON audit_logs (user_agent);
CREATE INDEX idx_audit_logs_user_id ON audit_logs (user_id);
-- oidc_clients: created_by_id → SET NULL
CREATE TABLE oidc_clients_new
(
id TEXT PRIMARY KEY,
created_at DATETIME NOT NULL,
name TEXT,
secret TEXT,
callback_urls BLOB,
image_type TEXT,
created_by_id TEXT REFERENCES users ON DELETE SET NULL,
is_public BOOLEAN DEFAULT FALSE,
pkce_enabled BOOLEAN DEFAULT FALSE,
logout_callback_urls BLOB,
credentials TEXT,
launch_url TEXT,
requires_reauthentication BOOLEAN DEFAULT FALSE NOT NULL
);
INSERT INTO oidc_clients_new
(id, created_at, name, secret, callback_urls, image_type, created_by_id,
is_public, pkce_enabled, logout_callback_urls, credentials, launch_url, requires_reauthentication)
SELECT id, created_at, name, secret, callback_urls, image_type, created_by_id,
is_public, pkce_enabled, logout_callback_urls, credentials, launch_url, requires_reauthentication
FROM oidc_clients;
DROP TABLE oidc_clients;
ALTER TABLE oidc_clients_new RENAME TO oidc_clients;
-- one_time_access_tokens: user_id → CASCADE
CREATE TABLE one_time_access_tokens_new
(
id TEXT PRIMARY KEY,
created_at DATETIME NOT NULL,
token TEXT NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE
);
INSERT INTO one_time_access_tokens_new
(id, created_at, token, expires_at, user_id)
SELECT id, created_at, token, expires_at, user_id
FROM one_time_access_tokens;
DROP TABLE one_time_access_tokens;
ALTER TABLE one_time_access_tokens_new RENAME TO one_time_access_tokens;
-- webauthn_credentials: user_id → CASCADE
CREATE TABLE webauthn_credentials_new
(
id TEXT PRIMARY KEY,
created_at DATETIME NOT NULL,
name TEXT NOT NULL,
credential_id TEXT NOT NULL UNIQUE,
public_key BLOB NOT NULL,
attestation_type TEXT NOT NULL,
transport BLOB NOT NULL,
user_id TEXT REFERENCES users ON DELETE CASCADE,
backup_eligible BOOLEAN DEFAULT FALSE NOT NULL,
backup_state BOOLEAN DEFAULT FALSE NOT NULL
);
INSERT INTO webauthn_credentials_new
(id, created_at, name, credential_id, public_key, attestation_type,
transport, user_id, backup_eligible, backup_state)
SELECT id, created_at, name, credential_id, public_key, attestation_type,
transport, user_id, backup_eligible, backup_state
FROM webauthn_credentials;
DROP TABLE webauthn_credentials;
ALTER TABLE webauthn_credentials_new RENAME TO webauthn_credentials;

View File

@@ -429,5 +429,6 @@
"client_name_description": "Název klienta, který se zobrazuje v uživatelském rozhraní Pocket ID.",
"revoke_access": "Zrušit přístup",
"revoke_access_description": "Zrušit přístup k <b>{clientName}</b>. <b>{clientName}</b> už nebude mít přístup k informacím o vašem účtu.",
"revoke_access_successful": "Přístup k {clientName} byl úspěšně zrušen."
"revoke_access_successful": "Přístup k {clientName} byl úspěšně zrušen.",
"last_signed_in_ago": "Naposledy přihlášen {time} před"
}

View File

@@ -429,5 +429,6 @@
"client_name_description": "Navnet på den klient, der vises i Pocket ID-brugergrænsefladen.",
"revoke_access": "Tilbagekald adgang",
"revoke_access_description": "Tilbagekald adgang til <b>{clientName}</b>. <b>{clientName}</b> vil ikke længere kunne få adgang til dine kontooplysninger.",
"revoke_access_successful": "Adgangen til {clientName} er blevet ophævet."
"revoke_access_successful": "Adgangen til {clientName} er blevet ophævet.",
"last_signed_in_ago": "Sidst logget ind {time} siden"
}

View File

@@ -276,6 +276,8 @@
"public_clients_description": "Öffentliche Clients haben kein Client-Geheimnis und verwenden stattdessen PKCE. Aktiviere dies, wenn dein Client eine SPA oder mobile App ist.",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Der Public Key Code Exchange (öffentlicher Schlüsselaustausch) ist eine Sicherheitsfunktion, um CSRF Angriffe und Angriffe zum Abfangen von Autorisierungscodes zu verhindern.",
"requires_reauthentication": "Erfordert erneute Authentifizierung",
"requires_users_to_authenticate_again_on_each_authorization": "Erfordert eine neue Authentifizierung bei jeder Autorisierung, auch wenn der Benutzer bereits angemeldet ist",
"name_logo": "{name} Logo",
"change_logo": "Logo ändern",
"upload_logo": "Logo hochladen",
@@ -429,5 +431,6 @@
"client_name_description": "Der Name des Clients, der in der Pocket ID-Benutzeroberfläche angezeigt wird.",
"revoke_access": "Zugriff widerrufen",
"revoke_access_description": "Zugriff widerrufen <b>{clientName}</b>. <b>{clientName}</b> kann nicht mehr auf deine Kontoinfos zugreifen.",
"revoke_access_successful": "Der Zugriff auf „ {clientName} “ wurde erfolgreich gesperrt."
"revoke_access_successful": "Der Zugriff auf „ {clientName} “ wurde erfolgreich gesperrt.",
"last_signed_in_ago": "Zuletzt angemeldet vor {time} Stunden"
}

View File

@@ -23,7 +23,7 @@
"click_to_copy": "Click to copy",
"something_went_wrong": "Something went wrong",
"go_back_to_home": "Go back to home",
"dont_have_access_to_your_passkey": "Don't have access to your passkey?",
"alternative_sign_in_methods": "Alternative Sign In Methods",
"login_background": "Login background",
"logo": "Logo",
"login_code": "Login Code",
@@ -276,6 +276,8 @@
"public_clients_description": "Public clients do not have a client secret. They are designed for mobile, web, and native applications where secrets cannot be securely stored.",
"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.",
"requires_reauthentication": "Requires Re-Authentication",
"requires_users_to_authenticate_again_on_each_authorization": "Requires users to authenticate again on each authorization, even if already signed in",
"name_logo": "{name} logo",
"change_logo": "Change Logo",
"upload_logo": "Upload Logo",
@@ -385,6 +387,12 @@
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
"expires": "Expires",
"signup": "Sign Up",
"user_creation": "User Creation",
"configure_user_creation": "Manage user creation settings, including signup methods and default permissions for new users.",
"user_creation_groups_description": "Assign these groups automatically to new users upon signup.",
"user_creation_claims_description": "Assign these custom claims automatically to new users upon signup.",
"user_creation_updated_successfully": "User creation settings updated successfully.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.",
"signup_requires_valid_token": "A valid signup token is required to create an account",
"validating_signup_token": "Validating signup token",
"go_to_login": "Go to login",
@@ -396,7 +404,7 @@
"skip_for_now": "Skip for now",
"account_created": "Account Created",
"enable_user_signups": "Enable User Signups",
"enable_user_signups_description": "Whether the User Signup functionality should be enabled.",
"enable_user_signups_description": "Decide how users can sign up for new accounts in Pocket ID.",
"user_signups_are_disabled": "User signups are currently disabled",
"create_signup_token": "Create Signup Token",
"view_active_signup_tokens": "View Active Signup Tokens",
@@ -412,7 +420,6 @@
"loading": "Loading",
"delete_signup_token": "Delete Signup Token",
"are_you_sure_you_want_to_delete_this_signup_token": "Are you sure you want to delete this signup token? This action cannot be undone.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.",
"signup_with_token": "Signup with token",
"signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.",
"signup_open": "Open Signup",
@@ -429,5 +436,9 @@
"client_name_description": "The name of the client that shows in the Pocket ID UI.",
"revoke_access": "Revoke Access",
"revoke_access_description": "Revoke access to <b>{clientName}</b>. <b>{clientName}</b> will no longer be able to access your account information.",
"revoke_access_successful": "The access to {clientName} has been successfully revoked."
"revoke_access_successful": "The access to {clientName} has been successfully revoked.",
"last_signed_in_ago": "Last signed in {time} ago",
"invalid_client_id": "Client ID can only contain letters, numbers, underscores, and hyphens",
"custom_client_id_description": "Set a custom client ID if this is required by your application. Otherwise, leave it blank to generate a random one.",
"generated": "Generated"
}

View File

@@ -385,6 +385,12 @@
"number_of_times_token_can_be_used": "Número de veces que se puede utilizar el token de registro.",
"expires": "Caduca",
"signup": "Regístrate",
"user_creation": "Registro",
"configure_user_creation": "Gestiona la configuración de registro de usuarios, incluyendo los métodos de registro y los permisos por defecto para nuevos usuarios.",
"user_creation_groups_description": "Asigna estos grupos automáticamente a los nuevos usuarios al registrarse.",
"user_creation_claims_description": "Asigna estas reclamaciones personalizadas automáticamente a los nuevos usuarios al registrarse.",
"user_creation_updated_successfully": "Configuración de registro actualizada correctamente.",
"signup_disabled_description": "El registro de usuarios está completamente desactivado. Solo los administradores pueden crear nuevas cuentas de usuario.",
"signup_requires_valid_token": "Se requiere un token de registro válido para crear una cuenta.",
"validating_signup_token": "Validación del token de registro",
"go_to_login": "Ir al inicio de sesión",
@@ -412,7 +418,6 @@
"loading": "Cargando",
"delete_signup_token": "Eliminar token de registro",
"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": "El registro de usuarios está completamente desactivado. Solo los administradores pueden crear nuevas cuentas de usuario.",
"signup_with_token": "Regístrate con token",
"signup_with_token_description": "Los usuarios solo pueden registrarse utilizando un token de registro válido creado por un administrador.",
"signup_open": "Inscripción abierta",
@@ -429,5 +434,6 @@
"client_name_description": "El nombre del cliente que aparece en la interfaz de usuario de Pocket ID.",
"revoke_access": "Revocar acceso",
"revoke_access_description": "Revocar el acceso a <b>{clientName}</b>. <b>{clientName}</b> ya no podrás acceder a la información de tu cuenta.",
"revoke_access_successful": "El acceso a {clientName} ha sido revocado correctamente."
"revoke_access_successful": "El acceso a {clientName} ha sido revocado correctamente.",
"last_signed_in_ago": "Último inicio de sesión en {time} hace"
}

View File

@@ -429,5 +429,6 @@
"client_name_description": "Le nom du client qui apparaît dans l'interface utilisateur Pocket ID.",
"revoke_access": "Supprimer l'accès",
"revoke_access_description": "Supprimer l'accès à <b>{clientName}</b>. <b>{clientName}</b> ne pourra plus accéder aux infos de ton compte.",
"revoke_access_successful": "L'accès à {clientName} a été supprimé."
"revoke_access_successful": "L'accès à {clientName} a été supprimé.",
"last_signed_in_ago": "Dernière connexion il y a {time} il y a"
}

View File

@@ -429,5 +429,6 @@
"client_name_description": "Il nome del cliente che appare nell'interfaccia utente Pocket ID.",
"revoke_access": "Revoca accesso",
"revoke_access_description": "Revoca l'accesso a <b>{clientName}</b>. <b>{clientName}</b> non potrà più accedere alle informazioni del tuo account.",
"revoke_access_successful": "L'accesso a {clientName} è stato revocato con successo."
"revoke_access_successful": "L'accesso a {clientName} è stato revocato con successo.",
"last_signed_in_ago": "Ultimo accesso {time} fa"
}

434
frontend/messages/ko.json Normal file
View File

@@ -0,0 +1,434 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"my_account": "내 계정",
"logout": "로그아웃",
"confirm": "확인",
"docs": "문서",
"key": "키",
"value": "값",
"remove_custom_claim": "사용자 정의 클레임 제거",
"add_custom_claim": "사용자 정의 클레임 추가",
"add_another": "추가",
"select_a_date": "날짜 선택",
"select_file": "파일 선택",
"profile_picture": "프로필 사진",
"profile_picture_is_managed_by_ldap_server": "프로필 사진이 LDAP 서버에서 관리되어 여기에서 변경할 수 없습니다.",
"click_profile_picture_to_upload_custom": "프로필 사진을 클릭하여 파일에서 사용자 정의 사진을 업로드하세요.",
"image_should_be_in_format": "이미지는 PNG 또는 JPEG 형식이여야 합니다.",
"items_per_page": "페이지당 항목",
"no_items_found": "항목 없음",
"search": "검색...",
"expand_card": "카드 확장",
"copied": "복사됨",
"click_to_copy": "클릭하여 복사",
"something_went_wrong": "문제가 발생했습니다",
"go_back_to_home": "홈으로 돌아가기",
"dont_have_access_to_your_passkey": "패스키에 접근할 수 없나요?",
"login_background": "로그인 배경",
"logo": "로고",
"login_code": "로그인 코드",
"create_a_login_code_to_sign_in_without_a_passkey_once": "패스키 없이 한 번 로그인 할 수 있는 로그인 코드를 생성합니다.",
"one_hour": "1시간",
"twelve_hours": "12시간",
"one_day": "1일",
"one_week": "1주",
"one_month": "1달",
"expiration": "만료",
"generate_code": "코드 생성",
"name": "이름",
"browser_unsupported": "지원되지 않는 브라우저",
"this_browser_does_not_support_passkeys": "이 브라우저는 패스키를 지원하지 않습니다. 다른 로그인 방법을 사용하세요.",
"an_unknown_error_occurred": "알 수 없는 오류 발생",
"authentication_process_was_aborted": "인증 프로세스가 중단되었습니다",
"error_occurred_with_authenticator": "인증기에서 오류가 발생했습니다",
"authenticator_does_not_support_discoverable_credentials": "인증기가 발견 가능한 자격 증명을 지원하지 않습니다",
"authenticator_does_not_support_resident_keys": "인증기가 레지던트 키를 지원하지 않습니다",
"passkey_was_previously_registered": "이 패스키는 이미 등록되었습니다",
"authenticator_does_not_support_any_of_the_requested_algorithms": "인증기가 요청된 알고리즘 중 어느 것도 지원하지 않습니다",
"authenticator_timed_out": "인증기가 시간 초과되었습니다",
"critical_error_occurred_contact_administrator": "치명적인 오류가 발생했습니다. 관리자에게 연락해주세요.",
"sign_in_to": "{name}에 로그인",
"client_not_found": "클라이언트를 찾을 수 없습니다",
"client_wants_to_access_the_following_information": "<b>{client}</b>이(가) 다음 정보에 접근하려고 합니다:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "{appName} 계정으로 <b>{client}</b>에 로그인하시겠습니까?",
"email": "이메일",
"view_your_email_address": "이메일 주소 확인",
"profile": "프로필",
"view_your_profile_information": "프로필 정보 확인",
"groups": "그룹",
"view_the_groups_you_are_a_member_of": "멤버인 그룹 정보 확인",
"cancel": "취소",
"sign_in": "로그인",
"try_again": "다시 시도",
"client_logo": "클라이언트 로고",
"sign_out": "로그아웃",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "{appName}에서 계정 <b>{username}</b>을 로그아웃하시겠습니까?",
"sign_in_to_appname": "{appName}에 로그인",
"please_try_to_sign_in_again": "다시 로그인해주세요.",
"authenticate_with_passkey_to_access_account": "패스키로 본인 인증하여 계정에 접근하세요.",
"authenticate": "인증",
"please_try_again": "다시 시도해주세요.",
"continue": "계속",
"alternative_sign_in": "다른 로그인 방법",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "패스키에 접근할 수 없는 경우 다음 방법 중 하나를 이용하여 로그인할 수 있습니다.",
"use_your_passkey_instead": "대신 패스키 이용하기",
"email_login": "이메일 로그인",
"enter_a_login_code_to_sign_in": "로그인 코드를 입력하여 로그인하세요.",
"request_a_login_code_via_email": "이메일로 로그인 코드를 요청합니다.",
"go_back": "뒤로 가기",
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "입력한 이메일 주소가 시스템에 존재하는 경우 이메일이 발송됩니다.",
"enter_code": "코드 입력",
"enter_your_email_address_to_receive_an_email_with_a_login_code": "이메일 주소를 입력하여 로그인 코드가 포함된 이메일을 받을 수 있습니다.",
"your_email": "이메일 주소",
"submit": "확인",
"enter_the_code_you_received_to_sign_in": "로그인하기 위해 받은 코드를 입력하세요.",
"code": "코드",
"invalid_redirect_url": "잘못된 리다이렉트 URL",
"audit_log": "감사 로그",
"users": "사용자",
"user_groups": "사용자 그룹",
"oidc_clients": "OIDC 클라이언트",
"api_keys": "API 키",
"application_configuration": "애플리케이션 설정",
"settings": "설정",
"update_pocket_id": "Pocket ID 업데이트",
"powered_by": "제공:",
"see_your_account_activities_from_the_last_3_months": "지난 3개월 동안의 계정 활동을 확인하세요.",
"time": "시간",
"event": "이벤트",
"approximate_location": "대략적인 위치",
"ip_address": "IP 주소",
"device": "기기",
"client": "클라이언트",
"unknown": "알 수 없음",
"account_details_updated_successfully": "계정 세부 사항이 성공적으로 업데이트되었습니다",
"profile_picture_updated_successfully": "프로필 사진이 성공적으로 업데이트되었습니다. 업데이트 적용까지 몇 분 정도 걸릴 수 있습니다.",
"account_settings": "계정 설정",
"passkey_missing": "패스키가 없습니다",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "계정 접근 권한을 잃지 않기 위해 패스키를 추가해주세요.",
"single_passkey_configured": "패스키가 하나만 구성되었습니다",
"it_is_recommended_to_add_more_than_one_passkey": "계정 접근 권한을 잃지 않기 위해 패스키를 두 개 이상 추가하는 것이 권장됩니다.",
"account_details": "계정 세부 사항",
"passkeys": "패스키",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "사용자 인증에 사용하는 패스키를 관리하세요.",
"add_passkey": "패스키 추가",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "패스키 없이 다른 기기에서 로그인하기 위한 일회용 로그인 코드를 생성합니다.",
"create": "생성",
"first_name": "이름",
"last_name": "성",
"username": "사용자 이름",
"save": "저장",
"username_can_only_contain": "사용자 이름은 영어 소문자, 숫자, 밑줄, 점, 하이픈, '@' 기호만 포함할 수 있습니다",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "다음 코드를 사용하여 로그인하세요. 이 코드는 15분 후에 만료됩니다.",
"or_visit": "또는",
"added_on": "추가:",
"rename": "이름 변경",
"delete": "삭제",
"are_you_sure_you_want_to_delete_this_passkey": "이 패스키를 삭제하시겠습니까?",
"passkey_deleted_successfully": "패스키가 성공적으로 삭제되었습니다",
"delete_passkey_name": "{passkeyName} 삭제",
"passkey_name_updated_successfully": "패스키 이름이 성공적으로 업데이트되었습니다",
"name_passkey": "패스키 이름",
"name_your_passkey_to_easily_identify_it_later": "패스키의 이름을 지정하여 나중에 쉽게 구분할 수 있도록 합니다.",
"create_api_key": "API 키 생성",
"add_a_new_api_key_for_programmatic_access": "프로그램 접근을 위해 새로운 API 키를 추가합니다.",
"add_api_key": "API 키 추가",
"manage_api_keys": "API 키 관리",
"api_key_created": "API 키 생성됨",
"for_security_reasons_this_key_will_only_be_shown_once": "보안상의 이유로 이 키는 한 번만 표시됩니다. 안전하게 보관해 주세요.",
"description": "설명",
"api_key": "API 키",
"close": "닫기",
"name_to_identify_this_api_key": "API 키를 구분하기 위한 이름.",
"expires_at": "만료일",
"when_this_api_key_will_expire": "API 키의 만료일.",
"optional_description_to_help_identify_this_keys_purpose": "이 키의 목적을 알기 위한 설명. (선택)",
"expiration_date_must_be_in_the_future": "만료일은 미래의 날짜여야 합니다",
"revoke_api_key": "API 키 취소",
"never": "없음",
"revoke": "취소",
"api_key_revoked_successfully": "API 키가 성공적으로 취소되었습니다",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "API 키 \"{apiKeyName}\"를 정말로 취소하시겠습니까? 이 키를 사용하는 모든 통합이 작동하지 않습니다.",
"last_used": "마지막 사용",
"actions": "동작",
"images_updated_successfully": "이미지가 성공적으로 업데이트되었습니다",
"general": "일반",
"configure_smtp_to_send_emails": "새로운 기기나 위치에서 로그인 감지 시 이메일 알림을 활성화합니다.",
"ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "LDAP 서버에서 사용자와 그룹을 동기화하기 위해 LDAP을 구성합니다.",
"images": "이미지",
"update": "업데이트",
"email_configuration_updated_successfully": "이메일 설정이 성공적으로 업데이트되었습니다",
"save_changes_question": "변경 내용을 저장하시겠습니까?",
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "테스트 이메일을 전송하기 전에 변경 내용을 저장해야 합니다. 지금 저장하시겠습니까?",
"save_and_send": "저장하고 전송하기",
"test_email_sent_successfully": "테스트 이메일이 성공적으로 귀하의 이메일 주소에 전송되었습니다.",
"failed_to_send_test_email": "테스트 이메일 전송에 실패했씁니다. 자세한 내용을 서버 로그를 확인하세요.",
"smtp_configuration": "SMTP 구성",
"smtp_host": "SMTP 호스트",
"smtp_port": "SMTP 포트",
"smtp_user": "SMTP 사용자",
"smtp_password": "SMTP 비밀번호",
"smtp_from": "SMTP 발신자",
"smtp_tls_option": "SMTP TLS 옵션",
"email_tls_option": "이메일 TLS 옵션",
"skip_certificate_verification": "인증서 검증 건너뛰기",
"this_can_be_useful_for_selfsigned_certificates": "이 옵션은 자체 설명 인증서에 유용할 수 있습니다.",
"enabled_emails": "이메일 활성화",
"email_login_notification": "이메일 로그인 알림",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "사용자가 새로운 기기에서 로그인할 때 이메일을 전송합니다.",
"emai_login_code_requested_by_user": "사용자가 요청한 이메일 로그인 코드",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "사용자가 이메일로 전송된 로그인 코드를 요청하여 패스키를 우회할 수 있도록 합니다. 이 기능은 사용자의 이메일 접근 권한이 있는 누구나 접근할 수 있어 보안이 크게 약화됩니다.",
"email_login_code_from_admin": "관리자 로그인 코드 전송",
"allows_an_admin_to_send_a_login_code_to_the_user": "관리자가 사용자에게 로그인 코드를 전송할 수 있게 합니다.",
"send_test_email": "테스트 이메일 보내기",
"application_configuration_updated_successfully": "애플리케이션 구성이 성공적으로 업데이트되었습니다",
"application_name": "애플리케이션 이름",
"session_duration": "세션 기간",
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "사용자가 다시 로그인하기 전 세션의 시간(분).",
"enable_self_account_editing": "셀프 계정 편집 활성화",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "사용자가 자신의 계정 정보를 편집할 수 있습니다.",
"emails_verified": "이메일 인증됨",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "OIDC 클라이언트에게 사용자의 이메일이 인증된 것으로 표시합니다.",
"ldap_configuration_updated_successfully": "LDAP 구성이 성공적으로 업데이트되었습니다",
"ldap_disabled_successfully": "LDAP가 성공적으로 비활성화되었습니다",
"ldap_sync_finished": "LDAP 동기화 완료",
"client_configuration": "클라이언트 구성",
"ldap_url": "LDAP URL",
"ldap_bind_dn": "LDAP 바인드 DN",
"ldap_bind_password": "LDAP 바인드 비밀번호",
"ldap_base_dn": "LDAP 베이스 DN",
"user_search_filter": "사용자 검색 필터",
"the_search_filter_to_use_to_search_or_sync_users": "사용자 검색 및 동기화를 위한 검색 필터.",
"groups_search_filter": "그룹 검색 필터",
"the_search_filter_to_use_to_search_or_sync_groups": "그룹 검색 및 동기화를 위한 검색 필터.",
"attribute_mapping": "속성 매핑",
"user_unique_identifier_attribute": "사용자 고유 식별자 속성",
"the_value_of_this_attribute_should_never_change": "이 속성의 값은 절대 변경되면 안 됩니다.",
"username_attribute": "사용자 이름 속성",
"user_mail_attribute": "사용자 이메일 속성",
"user_first_name_attribute": "이름 속성",
"user_last_name_attribute": "성 속성",
"user_profile_picture_attribute": "프로필 사진 속성",
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "이 속성의 값은 URL, 바이너리 또는 base64 인코딩된 이미지일 수 있습니다.",
"group_members_attribute": "그룹 멤버 속성",
"the_attribute_to_use_for_querying_members_of_a_group": "그룹의 멤버를 질의할 때 사용할 속성.",
"group_unique_identifier_attribute": "그룹 고유 식별자 속성",
"group_name_attribute": "그룹 멤버 속성",
"admin_group_name": "관리자 그룹 이름",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "이 그룹의 멤버들은 Pocket ID에서 관리자 권한을 갖게 됩니다.",
"disable": "비활성화",
"sync_now": "지금 동기화",
"enable": "활성화",
"user_created_successfully": "사용자가 성공적으로 생성되었습니다",
"create_user": "사용자 생성",
"add_a_new_user_to_appname": "{appName}에 새로운 사용자를 추가하세요",
"add_user": "사용자 추가",
"manage_users": "사용자 관리",
"admin_privileges": "관리자 권한",
"admins_have_full_access_to_the_admin_panel": "관리자는 관리 패널에 대한 전체 접근 권한을 갖습니다.",
"delete_firstname_lastname": "{firstName} {lastName} 삭제",
"are_you_sure_you_want_to_delete_this_user": "이 사용자를 삭제하시겠습니까?",
"user_deleted_successfully": "사용자가 성공적으로 삭제되었습니다",
"role": "역할",
"source": "소스",
"admin": "관리자",
"user": "사용자",
"local": "로컬",
"toggle_menu": "메뉴 표시 전환",
"edit": "편집",
"user_groups_updated_successfully": "사용자 그룹이 성공적으로 업데이트되었습니다",
"user_updated_successfully": "사용자가 성공적으로 업데이트되었습니다",
"custom_claims_updated_successfully": "사용자 정의 클레임이 성공적으로 업데이트되었습니다",
"back": "뒤로",
"user_details_firstname_lastname": "{firstName} {lastName} 사용자 상세 정보",
"manage_which_groups_this_user_belongs_to": "이 사용자가 속한 그룹을 관리합니다.",
"custom_claims": "사용자 정의 클레임",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "사용자 정의 클레임은 사용자에 대한 추가 정보를 저장하기 위해 사용되는 키-값 쌍입니다. 이 클레임은 'profile' 스코프가 요청될 경우 ID 토큰에 포함됩니다.",
"user_group_created_successfully": "사용자 그룹이 성공적으로 생성되었습니다",
"create_user_group": "사용자 그룹 생성",
"create_a_new_group_that_can_be_assigned_to_users": "사용자에게 할당할 수 있는 새로운 그룹을 생성합니다.",
"add_group": "그룹 추가",
"manage_user_groups": "사용자 그룹 관리",
"friendly_name": "별칭",
"name_that_will_be_displayed_in_the_ui": "UI에 표시되는 이름",
"name_that_will_be_in_the_groups_claim": "\"groups\" 클레임에 표시되는 이름",
"delete_name": "{name} 삭제",
"are_you_sure_you_want_to_delete_this_user_group": "이 사용자 그룹을 삭제하시겠습니까?",
"user_group_deleted_successfully": "사용자 그룹이 성공적으로 삭제되었습니다",
"user_count": "사용자 수",
"user_group_updated_successfully": "사용자 그룹이 성공적으로 업데이트되었습니다",
"users_updated_successfully": "사용자가 성공적으로 업데이트되었습니다",
"user_group_details_name": "{name} 사용자 그룹 상세 정보",
"assign_users_to_this_group": "이 그룹에 사용자를 할당합니다.",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "사용자 정의 클레임은 사용자에 대한 추가 정보를 저장하기 위해 사용되는 키-값 쌍입니다. 이 클레임은 'profile' 스코프가 요청될 경우 ID 토큰에 포함됩니다. 사용자 정의 클레임은 충돌이 있을 경우 우선 순위를 갖습니다.",
"oidc_client_created_successfully": "OIDC 클라이언트가 성공적으로 생성되었습니다",
"create_oidc_client": "OIDC 클라이언트 생성",
"add_a_new_oidc_client_to_appname": "{appName}에 새로운 OIDC 클라이언트를 추가합니다.",
"add_oidc_client": "OIDC 클라이언트 추가",
"manage_oidc_clients": "OIDC 클라이언트 관리",
"one_time_link": "일회용 링크",
"use_this_link_to_sign_in_once": "이 링크를 사용하여 한 번 로그인하세요. 이 기능은 패스키를 추가하지 않았거나 패스키를 분실한 사용자에게 필요합니다.",
"add": "추가",
"callback_urls": "콜백 URL",
"logout_callback_urls": "로그아웃 콜백 URL",
"public_client": "공개 클라이언트",
"public_clients_description": "공개 클라이언트는 클라이언트 시크릿이 없습니다. 이들은 시크릿을 안전하게 보관할 수 없는 모바일, 웹, 네이티브 애플리케이션을 위해 설계되었습니다.",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "공개 키 코드 교환은 CSRF 및 승인 코드 가로채기 공격을 방지하기 위한 보안 기능입니다.",
"name_logo": "{name} 로고",
"change_logo": "로고 변경",
"upload_logo": "로고 업로드",
"remove_logo": "로고 삭제",
"are_you_sure_you_want_to_delete_this_oidc_client": "이 OIDC 클라이언트를 삭제하시겠습니까?",
"oidc_client_deleted_successfully": "OIDC 클라이언트가 성공적으로 삭제되었습니다",
"authorization_url": "승인 URL",
"oidc_discovery_url": "OIDC 디스커버리 URL",
"token_url": "토큰 URL",
"userinfo_url": "사용자 정보 URL",
"logout_url": "로그아웃 URL",
"certificate_url": "인증서 URL",
"enabled": "활성화됨",
"disabled": "비활성화됨",
"oidc_client_updated_successfully": "OIDC 클라이언트가 성공적으로 업데이트되었습니다",
"create_new_client_secret": "새로운 클라이언트 시크릿 생성",
"are_you_sure_you_want_to_create_a_new_client_secret": "새로운 클라이언트 시크릿 생성하시겠습니까? 기존 클라이언트 시크릿은 무효화됩니다.",
"generate": "생성",
"new_client_secret_created_successfully": "새로운 클라이언트 시크릿이 성공적으로 생성되었습니다",
"allowed_user_groups_updated_successfully": "허용된 사용자 그룹이 성공적으로 업데이트되었습니다",
"oidc_client_name": "OIDC 클라이언트 {name}",
"client_id": "클라이언트 ID",
"client_secret": "클라이언트 시크릿",
"show_more_details": "상세 정보 보기",
"allowed_user_groups": "허용된 사용자 그룹",
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "이 클라이언트에 사용자 그룹을 추가하여 해당 그룹의 사용자의 접근를 제한합니다. 사용자 그룹을 선택하지 않으면 모든 사용자가 이 클라이언트에 접근할 수 있습니다.",
"favicon": "파비콘",
"light_mode_logo": "라이트 모드 로고",
"dark_mode_logo": "다크 모드 로고",
"background_image": "배경 이미지",
"language": "언어",
"reset_profile_picture_question": "프로필 사진을 재설정하시겠습니까?",
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "업로드한 이미지를 삭제하고 프로필 이미지를 기본값으로 되돌립니다. 계속하시겠습니까?",
"reset": "재설정",
"reset_to_default": "기본값으로 재설정",
"profile_picture_has_been_reset": "프로필 사진이 재설정되었습니다. 업데이트에 몇 분 정도 소요될 수 있습니다.",
"select_the_language_you_want_to_use": "사용할 언어를 선택하세요. 일부 텍스트는 자동으로 번역되었을 수 있으며, 정확하지 않을 수 있습니다.",
"contribute_to_translation": "문제를 발견했다면 <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>에서 번역에 기여해 주세요.",
"personal": "개인",
"global": "전역",
"all_users": "모든 사용자",
"all_events": "모든 이벤트",
"all_clients": "모든 클라이언트",
"all_locations": "모든 위치",
"global_audit_log": "전역 감사 로그",
"see_all_account_activities_from_the_last_3_months": "지난 3개월 동안의 모든 사용자 활동을 확인하세요.",
"token_sign_in": "토큰 로그인",
"client_authorization": "클라이언트 승인",
"new_client_authorization": "새로운 클라이언트 승인",
"disable_animations": "애니메이션 비활성화",
"turn_off_ui_animations": "UI 전체의 애니메이션을 비활성화합니다.",
"user_disabled": "계정 비활성화",
"disabled_users_cannot_log_in_or_use_services": "비활성화된 사용자는 로그인하거나 서비스를 사용할 수 없습니다.",
"user_disabled_successfully": "사용자가 성공적으로 비활성화되었습니다.",
"user_enabled_successfully": "사용자가 성공적으로 활성화되었습니다.",
"status": "상태",
"disable_firstname_lastname": "{firstName} {lastName} 비활성화",
"are_you_sure_you_want_to_disable_this_user": "이 사용자를 비활성화하시겠습니까? 이 사용자는 로그인하거나 어떤 서비스에도 접근할 수 없게 됩니다.",
"ldap_soft_delete_users": "LDAP에 비활성화된 사용자 유지",
"ldap_soft_delete_users_description": "이 기능이 활성화되면 LDAP에서 삭제된 사용자는 시스템에서 삭제되지 않고 비활성화됩니다.",
"login_code_email_success": "로그인 코드가 사용자에게 전송되었습니다.",
"send_email": "이메일 전송",
"show_code": "코드 표시",
"callback_url_description": "클라이언트가 제공한 URL. 비워둔 경우 자동으로 추가됩니다. 와일드카드(*)도 지원하지만, 보안상의 이유로 사용을 권장하지 않습니다.",
"logout_callback_url_description": "클라이언트가 제공한 로그아웃 URL. 와일드카드(*)도 지원하지만, 보안상의 이유로 사용을 권장하지 않습니다.",
"api_key_expiration": "API 키 만료",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "API 키가 만료되기 전에 사용자에게 이메일을 전송합니다.",
"authorize_device": "기기 승인",
"the_device_has_been_authorized": "기기가 승인되었습니다.",
"enter_code_displayed_in_previous_step": "이전 단계에 표시된 코드를 입력하세요.",
"authorize": "승인",
"federated_client_credentials": "연동 클라이언트 자격 증명",
"federated_client_credentials_description": "연동 클라이언트 자격 증명을 이용하여, OIDC 클라이언트를 제3자 인증 기관에서 발급한 JWT 토큰을 이용해 인증할 수 있습니다.",
"add_federated_client_credential": "연동 클라이언트 자격 증명 추가",
"add_another_federated_client_credential": "다른 연동 클라이언트 자격 증명 추가",
"oidc_allowed_group_count": "허용된 그룹 수",
"unrestricted": "제한 없음",
"show_advanced_options": "고급 옵션 표시",
"hide_advanced_options": "고급 옵션 숨기기",
"oidc_data_preview": "OIDC 데이터 미리보기",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "여러 사용자를 위해 전송될 OIDC 데이터 미리보기",
"id_token": "ID 토큰",
"access_token": "접근 토큰",
"userinfo": "사용자 정보",
"id_token_payload": "ID 토큰 페이로드",
"access_token_payload": "접근 토큰 페이로드",
"userinfo_endpoint_response": "사용자 정보 엔드포인트 응답",
"copy": "복사",
"no_preview_data_available": "미리보기 데이터가 없습니다",
"copy_all": "모두 복사",
"preview": "미리보기",
"preview_for_user": "{name} ({email}) 미리보기",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "이 사용자를 위해 전송될 OIDC 데이터 미리보기",
"show": "표시",
"select_an_option": "옵션 선택",
"select_user": "사용자 선택",
"error": "오류",
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Pocket ID의 외관을 맞춤 설정하려면 강조 색상을 선택하세요.",
"accent_color": "강조 색상",
"custom_accent_color": "맞춤 강조 색상",
"custom_accent_color_description": "유효한 CSS 색상 형식(예: hex, rgb, hsl)을 사용하여 맞춤 색상을 입력하세요.",
"color_value": "색상 값",
"apply": "적용",
"signup_token": "계정 생성 토큰",
"create_a_signup_token_to_allow_new_user_registration": "새로운 사용자 등록을 허용하기 위해 계정 생성 토큰을 생성합니다.",
"usage_limit": "사용량 제한",
"number_of_times_token_can_be_used": "계정 생성 토큰을 사용할 수 있는 횟수.",
"expires": "만료일",
"signup": "계정 생성",
"signup_requires_valid_token": "계정을 생성하려면 유효한 계정 생성 토큰이 필요합니다",
"validating_signup_token": "계정 생성 토큰 검증",
"go_to_login": "로그인으로 이동",
"signup_to_appname": "{appName} 계정 생성하기",
"create_your_account_to_get_started": "계정을 만들어 시작하세요.",
"initial_account_creation_description": "시작하기 위해 계정을 만드세요. 패스키를 나중에 설정할 수 있습니다.",
"setup_your_passkey": "패스키 설정",
"create_a_passkey_to_securely_access_your_account": "계정에 안전하게 접근하기 위해 패스키를 생성하세요. 이 패스키는 로그인을 위한 주요 방법으로 사용됩니다.",
"skip_for_now": "지금은 건너뛰기",
"account_created": "계정 생성됨",
"enable_user_signups": "계정 생성 활성화",
"enable_user_signups_description": "계정 생성 기능이 활성화됩니다.",
"user_signups_are_disabled": "계정 생성이 현재 비활성화되었습니다",
"create_signup_token": "계정 생성 토큰 생성",
"view_active_signup_tokens": "활성 계정 생성 토큰 보기",
"manage_signup_tokens": "계정 생성 토큰 관리",
"view_and_manage_active_signup_tokens": "활성 계정 생성 토큰을 조회하고 관리합니다.",
"signup_token_deleted_successfully": "계정 생성 토큰이 성공적으로 삭제되었습니다.",
"expired": "만료됨",
"used_up": "사용 완료",
"active": "활성",
"usage": "사용량",
"created": "생성일",
"token": "토큰",
"loading": "불러오는 중",
"delete_signup_token": "계정 생성 토큰 삭제",
"are_you_sure_you_want_to_delete_this_signup_token": "이 계정 생성 토큰을 정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"signup_disabled_description": "계정 생성이 완전히 비활성화됩니다. 새로운 사용자 계정은 관리자만 생성할 수 있습니다.",
"signup_with_token": "토큰으로 계정 생성",
"signup_with_token_description": "사용자는 관리자가 생성한 유효한 계정 생성 토큰을 사용해야 가입할 수 있습니다.",
"signup_open": "계정 생성 허용",
"signup_open_description": "누구나 제한 없이 새로운 계정을 생성할 수 있습니다.",
"of": "의",
"skip_passkey_setup": "패스키 설정 건너뛰기",
"skip_passkey_setup_description": "패스키를 설정하는 것이 강력히 권장됩니다. 패스키가 없으면 세션이 만료되자마자 계정에 접근할 수 없게 됩니다.",
"my_apps": "내 앱",
"no_apps_available": "사용 가능한 앱이 없습니다",
"contact_your_administrator_for_app_access": "관리자에게 연락하여 앱의 접근 권한을 얻으세요.",
"launch": "실행",
"client_launch_url": "클라이언트 실행 URL",
"client_launch_url_description": "사용자가 '내 앱' 페이지에서 앱을 실행할 때 열릴 URL입니다.",
"client_name_description": "Pocket ID UI에 표시되는 클라이언트의 이름입니다.",
"revoke_access": "접근 권한 취소",
"revoke_access_description": "<b>{clientName}</b>의 접근 권한을 취소합니다. <b>{clientName}</b>은 더 이상 계정 정보에 접근할 수 없습니다.",
"revoke_access_successful": "{clientName}의 접근이 성공적으로 취소되었습니다.",
"last_signed_in_ago": "{time} 전에 로그인함"
}

View File

@@ -3,17 +3,17 @@
"my_account": "Mijn account",
"logout": "Uitloggen",
"confirm": "Bevestigen",
"docs": "Documenten",
"docs": "Documentatie",
"key": "Sleutel",
"value": "Waarde",
"remove_custom_claim": "Aangepaste claim verwijderen",
"add_custom_claim": "Aangepaste claim toevoegen",
"add_another": "Voeg er nog een toe",
"add_another": "Nog een toevoegen",
"select_a_date": "Selecteer een datum",
"select_file": "Selecteer bestand",
"profile_picture": "Profielfoto",
"profile_picture_is_managed_by_ldap_server": "De profielfoto wordt beheerd door de LDAP-server en kan hier niet worden gewijzigd.",
"click_profile_picture_to_upload_custom": "Klik op de profielfoto om een aangepaste foto uit uw bestanden te uploaden.",
"click_profile_picture_to_upload_custom": "Klik op de profielfoto om een aangepaste foto uit je bestanden te uploaden.",
"image_should_be_in_format": "De afbeelding moet in PNG- of JPEG-formaat zijn.",
"items_per_page": "Aantal per pagina",
"no_items_found": "Geen items gevonden",
@@ -22,8 +22,8 @@
"copied": "Gekopieerd",
"click_to_copy": "Klik om te kopiëren",
"something_went_wrong": "Er is iets misgegaan",
"go_back_to_home": "Ga terug naar huis",
"dont_have_access_to_your_passkey": "Heeft u geen toegang tot uw toegangscode?",
"go_back_to_home": "Terug naar beginpagina",
"dont_have_access_to_your_passkey": "Heb je geen toegang tot je passkey?",
"login_background": "Inlogachtergrond",
"logo": "Logo",
"login_code": "Inlogcode",
@@ -42,46 +42,46 @@
"authentication_process_was_aborted": "Het authenticatieproces is afgebroken",
"error_occurred_with_authenticator": "Er is een fout opgetreden met de authenticator",
"authenticator_does_not_support_discoverable_credentials": "De authenticator ondersteunt geen vindbare referenties",
"authenticator_does_not_support_resident_keys": "De authenticator ondersteunt geen residente sleutels",
"authenticator_does_not_support_resident_keys": "De authenticator ondersteunt geen vaste sleutels",
"passkey_was_previously_registered": "Deze toegangscode is eerder geregistreerd",
"authenticator_does_not_support_any_of_the_requested_algorithms": "De authenticator ondersteunt geen van de gevraagde algoritmen",
"authenticator_timed_out": "De authenticator is verlopen",
"critical_error_occurred_contact_administrator": "Er is een kritieke fout opgetreden. Neem contact op met uw beheerder.",
"sign_in_to": "Meld u aan bij {name}",
"critical_error_occurred_contact_administrator": "Er is een kritieke fout opgetreden. Neem contact op met de beheerder.",
"sign_in_to": "Meld je aan bij {name}",
"client_not_found": "Client niet gevonden",
"client_wants_to_access_the_following_information": "<b>{client}</b> wil toegang tot de volgende informatie:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Wilt u zich aanmelden bij <b>{client}</b> met uw {appName} account?",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Wil je je aanmelden bij <b>{client}</b> met je {appName} account?",
"email": "E-mail",
"view_your_email_address": "Bekijk uw e-mailadres",
"view_your_email_address": "Bekijk je e-mailadres",
"profile": "Profiel",
"view_your_profile_information": "Bekijk uw profielgegevens",
"view_your_profile_information": "Bekijk je profielgegevens",
"groups": "Groepen",
"view_the_groups_you_are_a_member_of": "Bekijk de groepen waarvan u lid bent",
"view_the_groups_you_are_a_member_of": "Bekijk de groepen waarvan je lid bent",
"cancel": "Annuleren",
"sign_in": "Aanmelden",
"try_again": "Probeer het opnieuw",
"client_logo": "Client logo",
"sign_out": "Afmelden",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Wilt u zich afmelden bij Pocket ID met het account <b>{username}</b> ?",
"sign_in_to_appname": "Meld u aan bij {appName}",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Wil je je afmelden bij {appName} met het account <b>{username}</b>?",
"sign_in_to_appname": "Meld je aan bij {appName}",
"please_try_to_sign_in_again": "Probeer opnieuw in te loggen.",
"authenticate_with_passkey_to_access_account": "Log in met je passkey om toegang te krijgen tot je account.",
"authenticate_with_passkey_to_access_account": "Log in met uw passkey om toegang te krijgen tot uw account.",
"authenticate": "Authenticeren",
"please_try_again": "Probeer het opnieuw.",
"continue": "Doorgaan",
"alternative_sign_in": "Alternatieve aanmelding",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Als u geen toegang hebt tot uw passkeys, kunt u zich op een van de volgende manieren aanmelden.",
"use_your_passkey_instead": "Wilt u in plaats daarvan uw toegangscode gebruiken?",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Als je geen toegang hebt tot je passkeys, kun je je op een van de volgende manieren aanmelden.",
"use_your_passkey_instead": "Wil je in plaats daarvan je toegangscode gebruiken?",
"email_login": "E-mail inloggen",
"enter_a_login_code_to_sign_in": "Voer een inlogcode in om in te loggen.",
"request_a_login_code_via_email": "Vraag een inlogcode aan via e-mail.",
"go_back": "Ga terug",
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "Er is een e-mail verzonden naar het opgegeven e-mailadres, indien dit in het systeem voorkomt.",
"go_back": "Terug",
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "Er is een e-mail verzonden naar het opgegeven e-mailadres, indien deze in het systeem voorkomt.",
"enter_code": "Voer code in",
"enter_your_email_address_to_receive_an_email_with_a_login_code": "Voer uw e-mailadres in om een e-mail met een inlogcode te ontvangen.",
"your_email": "Uw e-mail",
"enter_your_email_address_to_receive_an_email_with_a_login_code": "Voer je e-mailadres in om een e-mail met een inlogcode te ontvangen.",
"your_email": "Je e-mail",
"submit": "Indienen",
"enter_the_code_you_received_to_sign_in": "Voer de code in die u hebt ontvangen om in te loggen.",
"enter_the_code_you_received_to_sign_in": "Voer de code in die je hebt ontvangen om in te loggen.",
"code": "Code",
"invalid_redirect_url": "Ongeldige omleidings-URL",
"audit_log": "Audit logboek",
@@ -93,52 +93,52 @@
"settings": "Instellingen",
"update_pocket_id": "Pocket-ID bijwerken",
"powered_by": "Aangedreven door",
"see_your_account_activities_from_the_last_3_months": "Bekijk uw accountactiviteiten van de afgelopen 3 maanden.",
"see_your_account_activities_from_the_last_3_months": "Bekijk je accountactiviteiten van de afgelopen 3 maanden.",
"time": "Tijd",
"event": "Evenement",
"approximate_location": "Geschatte locatie",
"ip_address": "IP-adres",
"device": "Apparaat",
"client": "Cliënt",
"client": "Client",
"unknown": "Onbekend",
"account_details_updated_successfully": "Accountgegevens succesvol bijgewerkt",
"profile_picture_updated_successfully": "Profielfoto succesvol bijgewerkt. Het kan enkele minuten duren voordat de wijzigingen zichtbaar zijn.",
"account_settings": "Accountinstellingen",
"passkey_missing": "Passkey ontbreekt",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Voeg een passkey toe om te voorkomen dat u de toegang tot uw account verliest.",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Voeg een passkey toe om te voorkomen dat je de toegang tot je account verliest.",
"single_passkey_configured": "Eén enkele toegangscode geconfigureerd",
"it_is_recommended_to_add_more_than_one_passkey": "Het is raadzaam om meer dan één toegangscode toe te voegen om te voorkomen dat u de toegang tot uw account verliest.",
"it_is_recommended_to_add_more_than_one_passkey": "Het is raadzaam om meer dan één toegangscode toe te voegen om te voorkomen dat je de toegang tot uw account verliest.",
"account_details": "Accountgegevens",
"passkeys": "Toegangscodes",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Beheer de toegangscodes waarmee u uzelf kunt verifiëren.",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Beheer de toegangscodes waarmee je jezelf kunt verifiëren.",
"add_passkey": "Passkey toevoegen",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Maak een eenmalige inlogcode aan om in te loggen vanaf een ander apparaat zonder passkey.",
"create": "Creëren",
"create": "Aanmaken",
"first_name": "Voornaam",
"last_name": "Achternaam",
"username": "Gebruikersnaam",
"save": "Opslaan",
"username_can_only_contain": "Gebruikersnaam mag alleen kleine letters, cijfers, onderstrepingstekens, punten, koppeltekens en '@'-symbolen bevatten",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Meld u aan met de volgende code. De code verloopt over 15 minuten.",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Meld je aan met de volgende code. De code verloopt over 15 minuten.",
"or_visit": "of bezoek",
"added_on": "Toegevoegd op",
"rename": "Hernoemen",
"delete": "Verwijderen",
"are_you_sure_you_want_to_delete_this_passkey": "Weet u zeker dat u deze toegangscode wilt verwijderen?",
"are_you_sure_you_want_to_delete_this_passkey": "Weet je zeker dat je deze passkey wilt verwijderen?",
"passkey_deleted_successfully": "Passkey succesvol verwijderd",
"delete_passkey_name": "Verwijder {passkeyName}",
"passkey_name_updated_successfully": "Passkey naam succesvol bijgewerkt",
"name_passkey": "Naam Passkey",
"name_your_passkey_to_easily_identify_it_later": "Geef uw toegangscode een naam, zodat u deze later gemakkelijk kunt terugvinden.",
"name_passkey": "Naam passkey",
"name_your_passkey_to_easily_identify_it_later": "Geef je passkey een naam, zodat je deze later gemakkelijk kunt terugvinden.",
"create_api_key": "API-sleutel aanmaken",
"add_a_new_api_key_for_programmatic_access": "Voeg een nieuwe API-sleutel toe voor programmatische toegang.",
"add_api_key": "API-sleutel toevoegen",
"manage_api_keys": "API-sleutels beheren",
"api_key_created": "API-sleutel gemaakt",
"for_security_reasons_this_key_will_only_be_shown_once": "Om veiligheidsredenen wordt deze sleutel slechts één keer getoond. Bewaar hem veilig.",
"for_security_reasons_this_key_will_only_be_shown_once": "Om veiligheidsredenen wordt deze sleutel slechts één keer getoond. Bewaar deze veilig.",
"description": "Beschrijving",
"api_key": "API-sleutel",
"close": "Dichtbij",
"close": "Sluiten",
"name_to_identify_this_api_key": "Naam om deze API-sleutel te identificeren.",
"expires_at": "Verloopt op",
"when_this_api_key_will_expire": "Wanneer deze API-sleutel verloopt.",
@@ -146,30 +146,30 @@
"expiration_date_must_be_in_the_future": "Vervaldatum moet in de toekomst liggen",
"revoke_api_key": "API-sleutel intrekken",
"never": "Nooit",
"revoke": "Herroepen",
"revoke": "Intrekken",
"api_key_revoked_successfully": "API-sleutel succesvol ingetrokken",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Weet u zeker dat u de API-sleutel \" {apiKeyName} \" wilt intrekken? Hiermee worden alle integraties die deze sleutel gebruiken, verbroken.",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Weet je zeker dat u de API-sleutel \"{apiKeyName}\" wilt intrekken? Hiermee worden alle integraties die deze sleutel gebruiken, verbroken.",
"last_used": "Laatst gebruikt",
"actions": "Acties",
"images_updated_successfully": "Afbeeldingen succesvol bijgewerkt",
"general": "Algemeen",
"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.",
"configure_smtp_to_send_emails": "Zet e-mailmeldingen aan om gebruikers te laten weten als iemand inlogt vanaf een nieuw apparaat of een nieuwe plek.",
"ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configureer LDAP-instellingen om gebruikers en groepen vanaf een LDAP-server te synchroniseren.",
"images": "Afbeeldingen",
"update": "Update",
"email_configuration_updated_successfully": "E-mailconfiguratie succesvol bijgewerkt",
"save_changes_question": "Wijzigingen opslaan?",
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "U moet de wijzigingen opslaan voordat u een test-e-mail verzendt. Wilt u nu opslaan?",
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "Je moet de wijzigingen opslaan voordat je een test-e-mail verzendt. Wil je nu opslaan?",
"save_and_send": "Opslaan en verzenden",
"test_email_sent_successfully": "Test-e-mail succesvol verzonden naar uw e-mailadres.",
"test_email_sent_successfully": "Test-e-mail succesvol verzonden naar je e-mailadres.",
"failed_to_send_test_email": "Het is niet gelukt om een test-e-mail te versturen. Controleer de serverlogs voor meer informatie.",
"smtp_configuration": "SMTP-configuratie",
"smtp_host": "SMTP-host",
"smtp_port": "SMTP-poort",
"smtp_user": "SMTP-gebruiker",
"smtp_password": "SMTP-wachtwoord",
"smtp_from": "SMTP van",
"smtp_from": "SMTP-afzender",
"smtp_tls_option": "SMTP TLS-optie",
"email_tls_option": "E-mail TLS-optie",
"skip_certificate_verification": "Certificaatverificatie overslaan",
@@ -178,15 +178,15 @@
"email_login_notification": "E-mail-inlogmelding",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Stuur een e-mail naar de gebruiker wanneer deze zich aanmeldt vanaf een nieuw apparaat.",
"emai_login_code_requested_by_user": "E-mail login code aangevraagd door gebruiker",
"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.",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Hiermee kunnen gebruikers passkeys omzeilen door een inlogcode aan te vragen die naar hun e-mail wordt gestuurd. Dit maakt het inloggen een stuk minder veilig, omdat iedereen die toegang heeft tot de e-mail van de gebruiker binnen kan komen.",
"email_login_code_from_admin": "E-mail inlogcode van beheerder",
"allows_an_admin_to_send_a_login_code_to_the_user": "Hiermee kan een admin een inlogcode naar de gebruiker mailen.",
"allows_an_admin_to_send_a_login_code_to_the_user": "Hiermee kan een beheerder een inlogcode naar de gebruiker mailen.",
"send_test_email": "Test-e-mail verzenden",
"application_configuration_updated_successfully": "Toepassingsconfiguratie succesvol bijgewerkt",
"application_name": "Toepassingsnaam",
"session_duration": "Sessieduur",
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "De duur van een sessie in minuten voordat de gebruiker zich opnieuw moet aanmelden.",
"enable_self_account_editing": "Zelf-accountbewerking inschakelen",
"enable_self_account_editing": "Bewerken van eigen account mogelijk maken",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Of gebruikers hun eigen accountgegevens moeten kunnen bewerken.",
"emails_verified": "E-mails geverifieerd",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Of het e-mailadres van de gebruiker als geverifieerd moet worden gemarkeerd voor de OIDC-clients.",
@@ -198,26 +198,26 @@
"ldap_bind_dn": "LDAP Bind-DN",
"ldap_bind_password": "LDAP Bind-wachtwoord",
"ldap_base_dn": "LDAP-basis-DN",
"user_search_filter": "Gebruikerszoekfilter",
"the_search_filter_to_use_to_search_or_sync_users": "Het zoekfilter waarmee u gebruikers kunt zoeken/synchroniseren.",
"groups_search_filter": "Groepen Zoekfilter",
"the_search_filter_to_use_to_search_or_sync_groups": "Het zoekfilter waarmee u groepen kunt zoeken/synchroniseren.",
"user_search_filter": "Zoekfilter gebruikers",
"the_search_filter_to_use_to_search_or_sync_users": "Het zoekfilter waarmee je gebruikers kunt zoeken/synchroniseren.",
"groups_search_filter": "Zoekfilter groepen",
"the_search_filter_to_use_to_search_or_sync_groups": "Het zoekfilter waarmee je groepen kunt zoeken/synchroniseren.",
"attribute_mapping": "Attribuuttoewijzing",
"user_unique_identifier_attribute": "Gebruiker uniek identificatiekenmerk",
"the_value_of_this_attribute_should_never_change": "De waarde van dit kenmerk mag nooit veranderen.",
"username_attribute": "Gebruikersnaam Attribuut",
"user_mail_attribute": "Gebruikersmailkenmerk",
"user_first_name_attribute": "Gebruikersvoornaam Attribuut",
"user_last_name_attribute": "Gebruikersnaam Achternaam Attribuut",
"user_profile_picture_attribute": "Gebruikersprofielfoto-attribuut",
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "De waarde van dit kenmerk kan een URL, een binair bestand of een base64-gecodeerde afbeelding zijn.",
"group_members_attribute": "Groepsleden Attribuut",
"the_attribute_to_use_for_querying_members_of_a_group": "Het kenmerk dat gebruikt moet worden om leden van een groep te bevragen.",
"group_unique_identifier_attribute": "Groeps uniek identificatiekenmerk",
"group_name_attribute": "Groepsnaam Attribuut",
"user_unique_identifier_attribute": "Uniek gebruikersidentificatie attribuut",
"the_value_of_this_attribute_should_never_change": "De waarde van dit attribuut mag nooit veranderen.",
"username_attribute": "Gebruikersnaam attribuut",
"user_mail_attribute": "Gebruikers e-mail attribuut",
"user_first_name_attribute": "Gebruikers voornaam attribuut",
"user_last_name_attribute": "Gebruikers achternaam attribuut",
"user_profile_picture_attribute": "Gebruikers profielfoto attribuut",
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "De waarde van dit attribuut kan een URL, een binair bestand of een base64-gecodeerde afbeelding zijn.",
"group_members_attribute": "Groepsleden attribuut",
"the_attribute_to_use_for_querying_members_of_a_group": "Het attribuut dat gebruikt moet worden om leden van een groep te bevragen.",
"group_unique_identifier_attribute": "Uniek groepsidentificatie attribuut",
"group_name_attribute": "Groepsnaam attribuut",
"admin_group_name": "Naam van beheerdersgroep",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Leden van deze groep hebben beheerdersrechten in Pocket ID.",
"disable": "Uitzetten",
"disable": "Uitschakelen",
"sync_now": "Nu synchroniseren",
"enable": "Inschakelen",
"user_created_successfully": "Gebruiker succesvol aangemaakt",
@@ -228,7 +228,7 @@
"admin_privileges": "Beheerdersrechten",
"admins_have_full_access_to_the_admin_panel": "Beheerders hebben volledige toegang tot het beheerderspaneel.",
"delete_firstname_lastname": "Verwijderen {firstName} {lastName}",
"are_you_sure_you_want_to_delete_this_user": "Weet u zeker dat u deze gebruiker wilt verwijderen?",
"are_you_sure_you_want_to_delete_this_user": "Weet je zeker dat u deze gebruiker wilt verwijderen?",
"user_deleted_successfully": "Gebruiker succesvol verwijderd",
"role": "Rol",
"source": "Bron",
@@ -236,7 +236,7 @@
"user": "Gebruiker",
"local": "Lokaal",
"toggle_menu": "Menu wisselen",
"edit": "Bewerking",
"edit": "Bewerk",
"user_groups_updated_successfully": "Gebruikersgroepen succesvol bijgewerkt",
"user_updated_successfully": "Gebruiker succesvol bijgewerkt",
"custom_claims_updated_successfully": "Aangepaste claims succesvol bijgewerkt",
@@ -252,9 +252,9 @@
"manage_user_groups": "Gebruikersgroepen beheren",
"friendly_name": "Vriendelijke naam",
"name_that_will_be_displayed_in_the_ui": "Naam die in de gebruikersinterface wordt weergegeven",
"name_that_will_be_in_the_groups_claim": "Naam die in de claim 'groepen' zal staan",
"name_that_will_be_in_the_groups_claim": "Naam die in de claim 'groups' zal staan",
"delete_name": "Verwijder {name}",
"are_you_sure_you_want_to_delete_this_user_group": "Weet u zeker dat u deze gebruikersgroep wilt verwijderen?",
"are_you_sure_you_want_to_delete_this_user_group": "Weet je zeker dat je deze gebruikersgroep wilt verwijderen?",
"user_group_deleted_successfully": "Gebruikersgroep succesvol verwijderd",
"user_count": "Gebruikersaantal",
"user_group_updated_successfully": "Gebruikersgroep succesvol bijgewerkt",
@@ -268,19 +268,19 @@
"add_oidc_client": "OIDC-client toevoegen",
"manage_oidc_clients": "OIDC-clients beheren",
"one_time_link": "Eenmalige link",
"use_this_link_to_sign_in_once": "Gebruik deze link om u eenmalig aan te melden. Dit is nodig voor gebruikers die nog geen passkey hebben toegevoegd of deze kwijt zijn.",
"use_this_link_to_sign_in_once": "Gebruik deze link om eenmalig aan te melden. Dit is nodig voor gebruikers die nog geen passkey hebben toegevoegd of deze kwijt zijn.",
"add": "Toevoegen",
"callback_urls": "Callback-URL's",
"logout_callback_urls": "Callback-URL's voor afmelden",
"public_client": "Publieke client",
"public_clients_description": "Publieke clients hebben geen client secret en gebruiken in plaats daarvan PKCE. Schakel dit in als uw client een SPA of mobiele app is.",
"public_clients_description": "Publieke clients hebben geen client secret en gebruiken in plaats daarvan PKCE. Schakel dit in als je client een SPA of mobiele app is.",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange is een beveiligingsfunctie om CSRF- en autorisatiecode-onderscheppingsaanvallen te voorkomen.",
"name_logo": "{name} logo",
"change_logo": "Logo wijzigen",
"upload_logo": "Logo uploaden",
"remove_logo": "Logo verwijderen",
"are_you_sure_you_want_to_delete_this_oidc_client": "Weet u zeker dat u deze OIDC-client wilt verwijderen?",
"are_you_sure_you_want_to_delete_this_oidc_client": "Weet je zeker dat je deze OIDC-client wilt verwijderen?",
"oidc_client_deleted_successfully": "OIDC-client succesvol verwijderd",
"authorization_url": "Autorisatie-URL",
"oidc_discovery_url": "OIDC-ontdekkings-URL",
@@ -292,12 +292,12 @@
"disabled": "Uitgeschakeld",
"oidc_client_updated_successfully": "OIDC-client succesvol bijgewerkt",
"create_new_client_secret": "Nieuw clientgeheim aanmaken",
"are_you_sure_you_want_to_create_a_new_client_secret": "Weet u zeker dat u een nieuw client secret wilt aanmaken? De oude wordt ongeldig.",
"are_you_sure_you_want_to_create_a_new_client_secret": "Weet je zeker dat je een nieuw client secret wilt aanmaken? De oude wordt ongeldig.",
"generate": "Genereren",
"new_client_secret_created_successfully": "Nieuw clientgeheim succesvol aangemaakt",
"allowed_user_groups_updated_successfully": "Toegestane gebruikersgroepen succesvol bijgewerkt",
"oidc_client_name": "OIDC-client {name}",
"client_id": "Client id",
"client_id": "Client ID",
"client_secret": "Client geheim",
"show_more_details": "Meer details weergeven",
"allowed_user_groups": "Toegestane gebruikersgroepen",
@@ -308,12 +308,12 @@
"background_image": "Achtergrondfoto",
"language": "Taal",
"reset_profile_picture_question": "Profielfoto opnieuw instellen?",
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "Hiermee wordt de geüploade afbeelding verwijderd en wordt de profielfoto teruggezet naar de standaardinstelling. Wil je doorgaan?",
"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. Wilt u doorgaan?",
"reset": "Opnieuw instellen",
"reset_to_default": "Standaardinstellingen herstellen",
"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": "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>.",
"select_the_language_you_want_to_use": "Kies de taal die u wilt gebruiken. Let op: sommige teksten worden automatisch vertaald en kunnen onnauwkeurig zijn.",
"contribute_to_translation": "Als u een fout vindt, kun je altijd helpen met de vertaling op <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
"personal": "Persoonlijk",
"global": "Globaal",
"all_users": "Alle gebruikers",
@@ -325,33 +325,33 @@
"token_sign_in": "Inloggen met token",
"client_authorization": "Client autorisatie",
"new_client_authorization": "Nieuwe clientautorisatie",
"disable_animations": "Animatie uitzetten",
"disable_animations": "Animaties uitzetten",
"turn_off_ui_animations": "Zet alle animaties in de gebruikersinterface uit.",
"user_disabled": "Account uitgeschakeld",
"disabled_users_cannot_log_in_or_use_services": "Gebruikers met een handicap kunnen niet inloggen of diensten gebruiken.",
"user_disabled_successfully": "Je bent nu uitgelogd.",
"user_enabled_successfully": "Je bent nu aangemeld.",
"disabled_users_cannot_log_in_or_use_services": "Uitgeschakelde gebruikers kunnen niet inloggen of diensten gebruiken.",
"user_disabled_successfully": "Gebruiker is succesvol uitgeschakeld.",
"user_enabled_successfully": "Gebruiker is succesvol geactiveerd.",
"status": "Status",
"disable_firstname_lastname": "{firstName} {lastName}uitschakelen",
"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": "Voorkom dat gebruikers met een handicap toegang krijgen tot LDAP.",
"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": "De inlogcode is naar je gestuurd.",
"send_email": "E-mail sturen",
"show_code": "Code tonen",
"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 die je klant heeft gegeven om uit te loggen. Je kunt jokertekens (*) gebruiken, maar dat is niet zo'n goed idee voor de veiligheid.",
"disable_firstname_lastname": "{firstName} {lastName} uitschakelen",
"are_you_sure_you_want_to_disable_this_user": "Weet u zeker dat u deze gebruiker wilt uitschakelen? Deze kan dan niet meer inloggen of diensten gebruiken.",
"ldap_soft_delete_users": "Voorkom dat in LDAP uitgeschakelde gebruikers toegang krijgen.",
"ldap_soft_delete_users_description": "Als dit is ingeschakeld, worden gebruikers die uit LDAP worden verwijderd, uitgeschakeld in plaats van daadwerkelijk uit het systeem verwijderd.",
"login_code_email_success": "De inlogcode is naar de gebruiker gestuurd.",
"send_email": "Verstuur e-mail",
"show_code": "Toon code",
"callback_url_description": "URL's die de client heeft aangegeven. Als je dit leeg laat, worden ze automatisch toegevoegd. Je kunt jokertekens (*) gebruiken, maar voor de veiligheid kun je dat beter niet doen.",
"logout_callback_url_description": "URL's die uw client heeft aangegeven om uit te loggen. Je kunt jokertekens (*) gebruiken, maar voor de veiligheid kun je dat beter niet doen.",
"api_key_expiration": "API-sleutel verloopt",
"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.",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Stuur een e-mail naar de gebruiker als de geldigheid van hun API-sleutel bijna verloopt.",
"authorize_device": "Apparaat autoriseren",
"the_device_has_been_authorized": "Het apparaat is goedgekeurd.",
"enter_code_displayed_in_previous_step": "Voer de code in die je in de vorige stap hebt gezien.",
"enter_code_displayed_in_previous_step": "Voer de code in die in de vorige stap werd getoond.",
"authorize": "Autoriseren",
"federated_client_credentials": "Federatieve clientreferenties",
"federated_client_credentials_description": "Met federatieve clientreferenties kun je OIDC-clients verifiëren met JWT-tokens die zijn uitgegeven door andere instanties.",
"federated_client_credentials_description": "Met federatieve clientreferenties kunt u OIDC-clients verifiëren met JWT-tokens die zijn uitgegeven door andere instanties.",
"add_federated_client_credential": "Federatieve clientreferenties toevoegen",
"add_another_federated_client_credential": "Voeg nog een federatieve clientreferentie toe",
"oidc_allowed_group_count": "Toegestaan aantal groepen",
"oidc_allowed_group_count": "Aantal groepen met toegang",
"unrestricted": "Onbeperkt",
"show_advanced_options": "Geavanceerde opties weergeven",
"hide_advanced_options": "Verberg geavanceerde opties",
@@ -378,56 +378,57 @@
"custom_accent_color": "Aangepaste accentkleur",
"custom_accent_color_description": "Voer een eigen kleur in met een geldige CSS-kleurcode (bijvoorbeeld hex, rgb, hsl).",
"color_value": "Kleurwaarde",
"apply": "Solliciteren",
"apply": "Toepassen",
"signup_token": "Aanmeldingstoken",
"create_a_signup_token_to_allow_new_user_registration": "Maak een aanmeldingstoken aan om nieuwe gebruikers te laten registreren.",
"usage_limit": "Gebruikslimiet",
"number_of_times_token_can_be_used": "Hoe vaak je het aanmeldingstoken kunt gebruiken.",
"number_of_times_token_can_be_used": "Hoe vaak het aanmeldingstoken gebruikt kan worden.",
"expires": "Verloopt",
"signup": "Aanmelden",
"signup_requires_valid_token": "Je hebt een geldige registratietoken nodig om een account aan te maken.",
"signup_requires_valid_token": "U heeft een geldige registratietoken nodig om een account aan te maken.",
"validating_signup_token": "Inlogtoken checken",
"go_to_login": "Ga naar inloggen",
"signup_to_appname": "Meld je aan voor {appName}",
"create_your_account_to_get_started": "Maak je account aan om te beginnen.",
"initial_account_creation_description": "Maak een account aan om te beginnen. Je kunt later een wachtwoord instellen.",
"setup_your_passkey": "Stel je passkey 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.",
"signup_to_appname": "Meld u aan voor {appName}",
"create_your_account_to_get_started": "Om te beginnen moet u een account aanmaken.",
"initial_account_creation_description": "Maak een account aan om te beginnen. U kunt later een wachtwoord instellen.",
"setup_your_passkey": "Stel uw passkey in",
"create_a_passkey_to_securely_access_your_account": "Maak een toegangscode aan om veilig toegang te krijgen tot je account. Dit is je primaire manier om in te loggen.",
"skip_for_now": "Voor nu even overslaan",
"account_created": "Account aangemaakt",
"enable_user_signups": "Gebruikersregistratie inschakelen",
"enable_user_signups_description": "Of de functie voor gebruikersregistratie moet worden ingeschakeld.",
"user_signups_are_disabled": "Je kunt nu niet aanmelden.",
"user_signups_are_disabled": "Gebruikersregistraties zijn nu uitgeschakeld.",
"create_signup_token": "Aanmeldingstoken maken",
"view_active_signup_tokens": "Actieve aanmeldingstokens bekijken",
"manage_signup_tokens": "Aanmeldingstokens beheren",
"view_and_manage_active_signup_tokens": "Bekijk en beheer actieve aanmeldingstokens.",
"signup_token_deleted_successfully": "Aanmeldingstoken succesvol verwijderd.",
"expired": "Verlopen",
"used_up": "Opgebruikt",
"used_up": "Verbruikt",
"active": "Actief",
"usage": "Gebruik",
"created": "Gemaakt",
"token": "Token",
"loading": "Bezig met laden",
"delete_signup_token": "Registratietoken verwijderen",
"are_you_sure_you_want_to_delete_this_signup_token": "Weet je zeker dat je dit aanmeldingstoken wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"are_you_sure_you_want_to_delete_this_signup_token": "Weet u zeker dat u dit aanmeldingstoken wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"signup_disabled_description": "Gebruikersregistraties zijn helemaal uitgeschakeld. Alleen beheerders kunnen nieuwe gebruikersaccounts aanmaken.",
"signup_with_token": "Aanmelden met token",
"signup_with_token_description": "Je kunt je alleen aanmelden met een geldige aanmeldtoken die door een beheerder is aangemaakt.",
"signup_with_token_description": "U kunt zich alleen aanmelden met een geldige aanmeldtoken die door een beheerder is aangemaakt.",
"signup_open": "Open inschrijving",
"signup_open_description": "Iedereen kan zonder beperkingen een nieuw account aanmaken.",
"of": "van",
"skip_passkey_setup": "Pas de instellingen voor de toegangssleutel over",
"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.",
"skip_passkey_setup": "Sla de instellingen voor de toegangssleutel over",
"skip_passkey_setup_description": "Het wordt aangeraden om een passkey in te stellen, want zonder dit kunt u niet meer inloggen zodra de sessie afloopt.",
"my_apps": "Mijn apps",
"no_apps_available": "Geen apps beschikbaar",
"contact_your_administrator_for_app_access": "Neem contact op met je beheerder om toegang te krijgen tot applicaties.",
"launch": "Lancering",
"client_launch_url": "URL voor lancering door klant",
"contact_your_administrator_for_app_access": "Neem contact op met de beheerder om toegang te krijgen tot applicaties.",
"launch": "Openen",
"client_launch_url": "URL voor openen door gebruiker",
"client_launch_url_description": "De URL die wordt geopend als iemand de app start vanaf de pagina Mijn apps.",
"client_name_description": "De naam van de klant die je in de Pocket ID-UI ziet.",
"client_name_description": "De naam van de client die wordt getoond in de Pocket ID UI.",
"revoke_access": "Toegang intrekken",
"revoke_access_description": "Toegang intrekken tot <b>{clientName}</b>. <b>{clientName}</b> kan je accountgegevens niet meer bekijken.",
"revoke_access_successful": "De toegang tot {clientName} is nu echt geblokkeerd."
"revoke_access_description": "Toegang intrekken tot <b>{clientName}</b>. <b>{clientName}</b> kun je accountgegevens niet meer gebruiken.",
"revoke_access_successful": "De toegang tot {clientName} is nu echt geblokkeerd.",
"last_signed_in_ago": "Laatst ingelogd {time} geleden"
}

View File

@@ -429,5 +429,6 @@
"client_name_description": "Nazwa klienta wyświetlana w interfejsie użytkownika Pocket ID.",
"revoke_access": "Cofnij dostęp",
"revoke_access_description": "Cofnij dostęp do <b>{clientName}</b>. <b>{clientName}</b> nie będzie już mógł uzyskać dostępu do informacji o Twoim koncie.",
"revoke_access_successful": "Dostęp do strony {clientName} został pomyślnie cofnięty."
"revoke_access_successful": "Dostęp do strony {clientName} został pomyślnie cofnięty.",
"last_signed_in_ago": "Ostatnio zalogowany {time} temu"
}

View File

@@ -429,5 +429,6 @@
"client_name_description": "O nome do cliente que aparece na interface do Pocket ID.",
"revoke_access": "Revogar acesso",
"revoke_access_description": "Revogar acesso a <b>{clientName}</b>. <b>{clientName}</b> não vai mais conseguir acessar as informações da sua conta.",
"revoke_access_successful": "O acesso a {clientName} foi revogado com sucesso."
"revoke_access_successful": "O acesso a {clientName} foi revogado com sucesso.",
"last_signed_in_ago": "Último login em {time} atrás"
}

View File

@@ -112,7 +112,7 @@
"passkeys": "Пасскеи",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Управляйте пасскеями, которые вы можете использовать для аутентификации себя.",
"add_passkey": "Добавить пасскей",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Создайте одноразовый код входа, чтобы войти с другого устройства без passkey.",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Создайте одноразовый код входа, чтобы войти с другого устройства без пасскея.",
"create": "Создать",
"first_name": "Имя",
"last_name": "Фамилия",
@@ -419,15 +419,16 @@
"signup_open_description": "Любой может создать новую учетную запись без ограничений.",
"of": "из",
"skip_passkey_setup": "Пропустить настройку пасскея",
"skip_passkey_setup_description": "Настоятельно рекомендуется настроить passkey, так как без него вы более не сможете войти в учетную запись после истечения сессии.",
"skip_passkey_setup_description": "Настоятельно рекомендуется настроить пасскей, так как без него вы более не сможете войти в учетную запись после истечения сессии.",
"my_apps": "Мои приложения",
"no_apps_available": "Нет доступных приложений",
"contact_your_administrator_for_app_access": "Свяжись с администратором, чтобы получить доступ к приложениям.",
"launch": "Запуск",
"client_launch_url": "URL запуска клиента",
"launch": "Запустить",
"client_launch_url": "Клиентский URL для запуска",
"client_launch_url_description": "URL-адрес, который откроется, когда кто-то запустит приложение со страницы «Мои приложения».",
"client_name_description": "Имя клиента, которое показывается в интерфейсе Pocket ID.",
"revoke_access": "Отменить доступ",
"revoke_access_description": "Отменить доступ к <b>{clientName}</b>. <b>{clientName}</b> больше не сможет заходить в твою учетную запись.",
"revoke_access_successful": "Доступ к {clientName} был успешно заблокирован."
"client_name_description": "Имя клиента, которое отображается в интерфейсе Pocket ID.",
"revoke_access": "Отозвать доступ",
"revoke_access_description": "Отозвать доступ к <b>{clientName}</b>. <b>{clientName}</b> больше не сможет получить доступ к информации вашей учетной записи.",
"revoke_access_successful": "Доступ к {clientName} успешно отозван.",
"last_signed_in_ago": "Последний вход {time} назад"
}

View File

@@ -173,7 +173,7 @@
"smtp_tls_option": "Тип SMTP TLS",
"email_tls_option": "TLS налаштування електронної пошти",
"skip_certificate_verification": "Пропустити перевірку сертифіката",
"this_can_be_useful_for_selfsigned_certificates": "Ця опція може бути корисною для спопідписних сертифікатів.",
"this_can_be_useful_for_selfsigned_certificates": "Ця опція може бути корисною для самопідписаних сертифікатів.",
"enabled_emails": "Увімкнені електронні листи",
"email_login_notification": "Сповіщення електронною поштою про вхід",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Надіслати електронний лист користувачеві після входу з нового пристрою.",
@@ -196,7 +196,7 @@
"client_configuration": "Налаштування клієнтів",
"ldap_url": "URL-адреса LDAP",
"ldap_bind_dn": "LDAP Bind DN",
"ldap_bind_password": "Пароль прив’язки LDAP",
"ldap_bind_password": "Пароль LDAP Bind",
"ldap_base_dn": "LDAP Base DN",
"user_search_filter": "Фільтр пошуку користувачів",
"the_search_filter_to_use_to_search_or_sync_users": "Фільтр пошуку для пошуку/синхронізації користувачів.",
@@ -251,8 +251,8 @@
"add_group": "Створити групу",
"manage_user_groups": "Керування групами користувачів",
"friendly_name": "Зручна назва",
"name_that_will_be_displayed_in_the_ui": "Ім'я, яке буде показуватися в інтерфейсі користувача",
"name_that_will_be_in_the_groups_claim": "Ім'я, яке буде в атрибуті \"groups\"",
"name_that_will_be_displayed_in_the_ui": "Назва, яка буде показуватися в інтерфейсі користувача",
"name_that_will_be_in_the_groups_claim": "Назва, яка буде в атрибуті \"groups\"",
"delete_name": "Видалити {name}",
"are_you_sure_you_want_to_delete_this_user_group": "Ви впевнені, що хочете видалити цю групу користувачів?",
"user_group_deleted_successfully": "Групу користувачів успішно видалено",
@@ -420,14 +420,15 @@
"of": "з",
"skip_passkey_setup": "Пропустити налаштування ключа доступу",
"skip_passkey_setup_description": "Рекомендується налаштувати ключ доступу, оскільки без нього ви не зможете увійти у свій обліковий запис після закінчення сеансу.",
"my_apps": "Мої програми",
"my_apps": "Мої додатки",
"no_apps_available": "Немає доступних додатків",
"contact_your_administrator_for_app_access": "Зверніться до адміністратора, щоб отримати доступ до додатків.",
"launch": "Запуск",
"client_launch_url": "URL-адреса запуску клієнта",
"client_launch_url": "URL-адреса для запуску клієнта",
"client_launch_url_description": "URL-адреса, яка відкриється, коли користувач запустить програму зі сторінки «Мої програми».",
"client_name_description": "Ім'я клієнта, яке відображається в інтерфейсі Pocket ID.",
"client_name_description": "Назва клієнта, яке відображається в інтерфейсі Pocket ID.",
"revoke_access": "Скасувати доступ",
"revoke_access_description": "Скасувати доступ до <b>{clientName}</b>. <b>{clientName}</b> більше не зможе отримати доступ до інформації вашого облікового запису.",
"revoke_access_successful": "Доступ до {clientName} було успішно скасовано."
"revoke_access_description": "Скасувати доступ для <b>{clientName}</b>. <b>{clientName}</b> більше не зможе отримати доступ до інформації вашого облікового запису.",
"revoke_access_successful": "Доступ для {clientName} було успішно скасовано.",
"last_signed_in_ago": "Останній вхід {time} тому"
}

View File

@@ -429,5 +429,6 @@
"client_name_description": "Tên của khách hàng hiển thị trong giao diện Pocket ID.",
"revoke_access": "Hủy quyền truy cập",
"revoke_access_description": "Hủy quyền truy cập vào <b>{clientName}</b>. <b>{clientName}</b> sẽ không còn có thể truy cập thông tin tài khoản của bạn.",
"revoke_access_successful": "Quyền truy cập vào {clientName} đã bị thu hồi thành công."
"revoke_access_successful": "Quyền truy cập vào {clientName} đã bị thu hồi thành công.",
"last_signed_in_ago": "Lần đăng nhập cuối cùng cách đây {time}"
}

View File

@@ -429,5 +429,6 @@
"client_name_description": "在Pocket ID用户界面中显示的客户端名称。",
"revoke_access": "撤销访问权限",
"revoke_access_description": "撤销对 <b>{clientName}</b>. <b>{clientName}</b>将无法再访问您的账户信息。",
"revoke_access_successful": "对 {clientName} 的访问权限已成功撤销。"
"revoke_access_successful": "对 {clientName} 的访问权限已成功撤销。",
"last_signed_in_ago": "最后一次登录 {time} 前"
}

View File

@@ -429,5 +429,6 @@
"client_name_description": "顯示在 Pocket ID UI 中的用戶端名稱。",
"revoke_access": "撤銷存取權",
"revoke_access_description": "撤銷存取 <b>{clientName}</b>. <b>{clientName}</b>將無法再存取您的帳戶資訊。",
"revoke_access_successful": "{clientName} 的存取權已成功取消。"
"revoke_access_successful": "{clientName} 的存取權已成功取消。",
"last_signed_in_ago": "上次登入 {time} 前"
}

View File

@@ -1,6 +1,6 @@
{
"name": "pocket-id-frontend",
"version": "1.7.0",
"version": "1.8.0",
"private": true,
"type": "module",
"scripts": {
@@ -18,6 +18,7 @@
"@tailwindcss/vite": "^4.1.11",
"axios": "^1.11.0",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"jose": "^5.10.0",
"qrcode": "^1.5.4",
"sveltekit-superforms": "^2.27.1",
@@ -46,6 +47,7 @@
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.6.14",
"rollup": "^4.46.3",
"svelte": "^5.36.16",
"svelte-check": "^4.3.0",
"svelte-sonner": "^1.0.5",

View File

@@ -9,6 +9,7 @@
"es",
"fr",
"it",
"ko",
"nl",
"pl",
"pt-BR",

View File

@@ -132,6 +132,7 @@
/* Font */
--font-playfair: 'Playfair Display', serif;
--font-code: 'Google Sans', sans-serif;
}
@layer base {
@@ -167,6 +168,11 @@
font-weight: 700;
src: url('/fonts/PlayfairDisplay-Bold.woff') format('woff');
}
@font-face {
font-family: 'Google Sans';
font-weight: 600;
src: url('/fonts/GoogleSansCode-SemiBold.ttf') format('truetype');
}
}
@keyframes accordion-down {

View File

@@ -0,0 +1,140 @@
<script lang="ts">
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as Command from '$lib/components/ui/command';
import * as Popover from '$lib/components/ui/popover';
import { cn } from '$lib/utils/style';
import { LoaderCircle, LucideCheck, LucideChevronDown } from '@lucide/svelte';
import type { FormEventHandler } from 'svelte/elements';
type Item = {
value: string;
label: string;
};
let {
items,
selectedItems = $bindable(),
onSelect,
oninput,
isLoading = false,
placeholder = 'Select items...',
searchText = 'Search...',
noItemsText = 'No items found.',
disableInternalSearch = false,
id
}: {
items: Item[];
selectedItems: string[];
onSelect?: (value: string[]) => void;
oninput?: FormEventHandler<HTMLInputElement>;
isLoading?: boolean;
placeholder?: string;
searchText?: string;
noItemsText?: string;
disableInternalSearch?: boolean;
id?: string;
} = $props();
let open = $state(false);
let searchValue = $state('');
let filteredItems = $state(items);
const selectedLabels = $derived(
items.filter((item) => selectedItems.includes(item.value)).map((item) => item.label)
);
function handleItemSelect(value: string) {
let newSelectedItems: string[];
if (selectedItems.includes(value)) {
newSelectedItems = selectedItems.filter((item) => item !== value);
} else {
newSelectedItems = [...selectedItems, value];
}
selectedItems = newSelectedItems;
onSelect?.(newSelectedItems);
}
function filterItems(search: string) {
if (disableInternalSearch) return;
searchValue = search;
if (!search) {
filteredItems = items;
} else {
filteredItems = items.filter((item) =>
item.label.toLowerCase().includes(search.toLowerCase())
);
}
}
// Reset search value when the popover is closed
$effect(() => {
if (!open) {
filterItems('');
}
filteredItems = items;
});
</script>
<Popover.Root bind:open>
<Popover.Trigger {id}>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
role="combobox"
aria-expanded={open}
class="h-auto min-h-10 w-full justify-between"
>
<div class="flex flex-wrap items-center gap-1">
{#if selectedItems.length > 0}
{#each selectedLabels as label}
<Badge variant="secondary">{label}</Badge>
{/each}
{:else}
<span class="text-muted-foreground font-normal">{placeholder}</span>
{/if}
</div>
<LucideChevronDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="p-0" sameWidth>
<Command.Root shouldFilter={false}>
<Command.Input
placeholder={searchText}
value={searchValue}
oninput={(e) => {
filterItems(e.currentTarget.value);
oninput?.(e);
}}
/>
<Command.Empty>
{#if isLoading}
<div class="flex w-full items-center justify-center py-2">
<LoaderCircle class="size-4 animate-spin" />
</div>
{:else}
{noItemsText}
{/if}
</Command.Empty>
<Command.Group class="max-h-60 overflow-y-auto">
{#each filteredItems as item}
<Command.Item
aria-checked={selectedItems.includes(item.value)}
value={item.value}
onSelect={() => {
handleItemSelect(item.value);
}}
>
<LucideCheck
class={cn('mr-2 size-4', !selectedItems.includes(item.value) && 'text-transparent')}
/>
{item.label}
</Command.Item>
{/each}
</Command.Group>
</Command.Root>
</Popover.Content>
</Popover.Root>

View File

@@ -45,7 +45,7 @@
)}`}
class="text-muted-foreground text-xs transition-colors hover:underline"
>
{m.dont_have_access_to_your_passkey()}
{m.alternative_sign_in_methods()}
</a>
</div>
{/if}
@@ -82,7 +82,7 @@
)}`}
class="text-muted-foreground mt-7 flex justify-center text-xs transition-colors hover:underline"
>
{m.dont_have_access_to_your_passkey()}
{m.alternative_sign_in_methods()}
</a>
{/if}
</Card.CardContent>

View File

@@ -36,8 +36,7 @@
async function createLoginCode() {
try {
const expiration = new Date(Date.now() + availableExpirations[selectedExpiration] * 1000);
code = await userService.createOneTimeAccessToken(expiration, userId!);
code = await userService.createOneTimeAccessToken(userId!, availableExpirations[selectedExpiration]);
oneTimeLink = `${page.url.origin}/lc/${code}`;
} catch (e) {
axiosErrorToast(e);
@@ -46,8 +45,7 @@
async function sendLoginCodeEmail() {
try {
const expiration = new Date(Date.now() + availableExpirations[selectedExpiration] * 1000);
await userService.requestOneTimeAccessEmailAsAdmin(userId!, expiration);
await userService.requestOneTimeAccessEmailAsAdmin(userId!, availableExpirations[selectedExpiration]);
toast.success(m.login_code_email_success());
onOpenChange(false);
} catch (e) {
@@ -81,7 +79,7 @@
value={Object.keys(availableExpirations)[0]}
onValueChange={(v) => (selectedExpiration = v! as keyof typeof availableExpirations)}
>
<Select.Trigger id="expiration" class="h-9 w-full">
<Select.Trigger id="expiration" class="w-full h-9">
{selectedExpiration}
</Select.Trigger>
<Select.Content>
@@ -108,10 +106,10 @@
{:else}
<div class="flex flex-col items-center gap-2">
<CopyToClipboard value={code!}>
<p class="text-3xl font-semibold">{code}</p>
<p class="text-3xl font-code">{code}</p>
</CopyToClipboard>
<div class="text-muted-foreground my-2 flex items-center justify-center gap-3">
<div class="flex items-center justify-center gap-3 my-2 text-muted-foreground">
<Separator />
<p class="text-xs text-nowrap">{m.or_visit()}</p>
<Separator />

View File

@@ -37,8 +37,7 @@
async function createSignupToken() {
try {
const expiration = new Date(Date.now() + availableExpirations[selectedExpiration] * 1000);
signupToken = await userService.createSignupToken(expiration, usageLimit);
signupToken = await userService.createSignupToken(availableExpirations[selectedExpiration], usageLimit);
signupLink = `${page.url.origin}/st/${signupToken}`;
if (onTokenCreated) {

View File

@@ -14,10 +14,15 @@ export default class AppConfigService extends APIService {
}
async update(appConfig: AllAppConfig) {
// Convert all values to string
const appConfigConvertedToString = {};
// Convert all values to string, stringifying JSON where needed
const appConfigConvertedToString: Record<string, string> = {};
for (const key in appConfig) {
(appConfigConvertedToString as any)[key] = (appConfig as any)[key].toString();
const value = (appConfig as any)[key];
if (typeof value === 'object' && value !== null) {
appConfigConvertedToString[key] = JSON.stringify(value);
} else {
appConfigConvertedToString[key] = String(value);
}
}
const res = await this.api.put('/application-configuration', appConfigConvertedToString);
return this.parseConfigList(res.data);
@@ -66,6 +71,16 @@ export default class AppConfigService extends APIService {
}
private parseValue(value: string) {
// Try to parse JSON first
try {
const parsed = JSON.parse(value);
if (typeof parsed === 'object' && parsed !== null) {
return parsed;
}
value = String(parsed);
} catch {}
// Handle rest of the types
if (value === 'true') {
return true;
} else if (value === 'false') {

View File

@@ -1,9 +1,10 @@
import type {
AuthorizedOidcClient,
AccessibleOidcClient,
AuthorizeResponse,
OidcClient,
OidcClientCreate,
OidcClientMetaData,
OidcClientUpdate,
OidcClientWithAllowedUserGroups,
OidcClientWithAllowedUserGroupsCount,
OidcDeviceCodeInfo
@@ -19,7 +20,8 @@ class OidcService extends APIService {
callbackURL: string,
nonce?: string,
codeChallenge?: string,
codeChallengeMethod?: string
codeChallengeMethod?: string,
reauthenticationToken?: string
) {
const res = await this.api.post('/oidc/authorize', {
scope,
@@ -27,7 +29,8 @@ class OidcService extends APIService {
callbackURL,
clientId,
codeChallenge,
codeChallengeMethod
codeChallengeMethod,
reauthenticationToken
});
return res.data as AuthorizeResponse;
@@ -65,7 +68,7 @@ class OidcService extends APIService {
return (await this.api.get(`/oidc/clients/${id}/meta`)).data as OidcClientMetaData;
}
async updateClient(id: string, client: OidcClientCreate) {
async updateClient(id: string, client: OidcClientUpdate) {
return (await this.api.put(`/oidc/clients/${id}`, client)).data as OidcClient;
}
@@ -115,22 +118,16 @@ class OidcService extends APIService {
return response.data;
}
async listAuthorizedClients(options?: SearchPaginationSortRequest) {
async listOwnAccessibleClients(options?: SearchPaginationSortRequest) {
const res = await this.api.get('/oidc/users/me/clients', {
params: options
});
return res.data as Paginated<AuthorizedOidcClient>;
}
async listAuthorizedClientsForUser(userId: string, options?: SearchPaginationSortRequest) {
const res = await this.api.get(`/oidc/users/${userId}/clients`, {
params: options
});
return res.data as Paginated<AuthorizedOidcClient>;
return res.data as Paginated<AccessibleOidcClient>;
}
async revokeOwnAuthorizedClient(clientId: string) {
await this.api.delete(`/oidc/users/me/clients/${clientId}`);
await this.api.delete(`/oidc/users/me/authorized-clients/${clientId}`);
}
}

View File

@@ -75,17 +75,17 @@ export default class UserService extends APIService {
cachedProfilePicture.bustCache(userId);
}
async createOneTimeAccessToken(expiresAt: Date, userId: string) {
async createOneTimeAccessToken(userId: string = 'me', ttl?: string|number) {
const res = await this.api.post(`/users/${userId}/one-time-access-token`, {
userId,
expiresAt
ttl,
});
return res.data.token;
}
async createSignupToken(expiresAt: Date, usageLimit: number) {
async createSignupToken(ttl: string|number, usageLimit: number) {
const res = await this.api.post(`/signup-tokens`, {
expiresAt,
ttl,
usageLimit
});
return res.data.token;
@@ -100,8 +100,8 @@ export default class UserService extends APIService {
await this.api.post('/one-time-access-email', { email, redirectPath });
}
async requestOneTimeAccessEmailAsAdmin(userId: string, expiresAt: Date) {
await this.api.post(`/users/${userId}/one-time-access-email`, { expiresAt });
async requestOneTimeAccessEmailAsAdmin(userId: string, ttl: string|number) {
await this.api.post(`/users/${userId}/one-time-access-email`, { ttl });
}
async updateUserGroups(id: string, userGroupIds: string[]) {

View File

@@ -37,6 +37,11 @@ class WebAuthnService extends APIService {
async updateCredentialName(id: string, name: string) {
await this.api.patch(`/webauthn/credentials/${id}`, { name });
}
async reauthenticate(body?: AuthenticationResponseJSON) {
const res = await this.api.post('/webauthn/reauthenticate', body);
return res.data.reauthenticationToken as string;
}
}
export default WebAuthnService;

View File

@@ -4,9 +4,9 @@ import { writable } from 'svelte/store';
const userStore = writable<User | null>(null);
const setUser = (user: User) => {
const setUser = async (user: User) => {
if (user.locale) {
setLocale(user.locale, false);
await setLocale(user.locale, false);
}
userStore.set(user);
};

View File

@@ -1,3 +1,5 @@
import type { CustomClaim } from './custom-claim.type';
export type AppConfig = {
appName: string;
allowOwnAccountEdit: boolean;
@@ -14,6 +16,8 @@ export type AllAppConfig = AppConfig & {
// General
sessionDuration: number;
emailsVerified: boolean;
signupDefaultUserGroupIDs: string[];
signupDefaultCustomClaims: CustomClaim[];
// Email
smtpHost: string;
smtpPort: number;

View File

@@ -4,6 +4,7 @@ export type OidcClientMetaData = {
id: string;
name: string;
hasLogo: boolean;
requiresReauthentication: boolean;
launchURL?: string;
};
@@ -23,6 +24,7 @@ export type OidcClient = OidcClientMetaData & {
logoutCallbackURLs: string[];
isPublic: boolean;
pkceEnabled: boolean;
requiresReauthentication: boolean;
credentials?: OidcClientCredentials;
launchURL?: string;
};
@@ -35,7 +37,13 @@ export type OidcClientWithAllowedUserGroupsCount = OidcClient & {
allowedUserGroupsCount: number;
};
export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>;
export type OidcClientUpdate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>;
export type OidcClientCreate = OidcClientUpdate & {
id?: string;
};
export type OidcClientUpdateWithLogo = OidcClientUpdate & {
logo: File | null | undefined;
};
export type OidcClientCreateWithLogo = OidcClientCreate & {
logo: File | null | undefined;
@@ -53,7 +61,6 @@ export type AuthorizeResponse = {
issuer: string;
};
export type AuthorizedOidcClient = {
scope: string;
client: OidcClientMetaData;
export type AccessibleOidcClient = OidcClientMetaData & {
lastUsedAt: Date | null;
};

View File

@@ -1,10 +1,26 @@
import { setLocale as setParaglideLocale, type Locale } from '$lib/paraglide/runtime';
import { setDefaultOptions } from 'date-fns';
import { z } from 'zod/v4';
export function setLocale(locale: Locale, reload = true) {
import(`../../../node_modules/zod/v4/locales/${locale}.js`)
.then((zodLocale) => z.config(zodLocale.default()))
.finally(() => {
setParaglideLocale(locale, { reload });
export async function setLocale(locale: Locale, reload = true) {
const [zodResult, dateFnsResult] = await Promise.allSettled([
import(`../../../node_modules/zod/v4/locales/${locale}.js`),
import(`../../../node_modules/date-fns/locale/${locale}.js`)
]);
if (zodResult.status === 'fulfilled') {
z.config(zodResult.value.default());
} else {
console.warn(`Failed to load zod locale for ${locale}:`, zodResult.reason);
}
setParaglideLocale(locale, { reload });
if (dateFnsResult.status === 'fulfilled') {
setDefaultOptions({
locale: dateFnsResult.value.default
});
} else {
console.warn(`Failed to load date-fns locale for ${locale}:`, dateFnsResult.reason);
}
}

View File

@@ -1,9 +1,7 @@
import z from 'zod/v4';
export const optionalString = z
.string()
.transform((v) => (v === '' ? undefined : v))
.optional();
export const emptyToUndefined = <T>(validation: z.ZodType<T>) =>
z.preprocess((v) => (v === '' ? undefined : v), validation);
export const optionalUrl = z
.url()

View File

@@ -6,8 +6,6 @@
import Header from '$lib/components/header/header.svelte';
import { Toaster } from '$lib/components/ui/sonner';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import { getAuthRedirectPath } from '$lib/utils/redirection-util';
import { ModeWatcher } from 'mode-watcher';
import type { Snippet } from 'svelte';
@@ -28,14 +26,6 @@
if (redirectPath) {
goto(redirectPath);
}
if (user) {
userStore.setUser(user);
}
if (appConfig) {
appConfigStore.set(appConfig);
}
</script>
{#if !appConfig}

View File

@@ -1,5 +1,7 @@
import AppConfigService from '$lib/services/app-config-service';
import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import type { LayoutLoad } from './$types';
export const ssr = false;
@@ -19,6 +21,14 @@ export const load: LayoutLoad = async () => {
const [user, appConfig] = await Promise.all([userPromise, appConfigPromise]);
if (user) {
await userStore.setUser(user);
}
if (appConfig) {
appConfigStore.set(appConfig);
}
return {
user,
appConfig

View File

@@ -11,7 +11,7 @@
import userStore from '$lib/stores/user-store';
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { LucideMail, LucideUser, LucideUsers } from '@lucide/svelte';
import { startAuthentication } from '@simplewebauthn/browser';
import { startAuthentication, type AuthenticationResponseJSON } from '@simplewebauthn/browser';
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import type { PageProps } from './$types';
@@ -29,6 +29,7 @@
let errorMessage: string | null = $state(null);
let authorizationRequired = $state(false);
let authorizationConfirmed = $state(false);
let userSignedInAt: Date | undefined;
onMount(() => {
if ($userStore) {
@@ -38,13 +39,16 @@
async function authorize() {
isLoading = true;
let authResponse: AuthenticationResponseJSON | undefined;
try {
// Get access token if not signed in
if (!$userStore?.id) {
const loginOptions = await webauthnService.getLoginOptions();
const authResponse = await startAuthentication({ optionsJSON: loginOptions });
authResponse = await startAuthentication({ optionsJSON: loginOptions });
const user = await webauthnService.finishLogin(authResponse);
userStore.setUser(user);
userSignedInAt = new Date();
}
if (!authorizationConfirmed) {
@@ -56,8 +60,28 @@
}
}
let reauthToken: string | undefined;
if (client?.requiresReauthentication) {
let authResponse;
const signedInRecently =
userSignedInAt && userSignedInAt.getTime() > Date.now() - 60 * 1000;
if (!signedInRecently) {
const loginOptions = await webauthnService.getLoginOptions();
authResponse = await startAuthentication({ optionsJSON: loginOptions });
}
reauthToken = await webauthnService.reauthenticate(authResponse);
}
await oidService
.authorize(client!.id, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod)
.authorize(
client!.id,
scope,
callbackURL,
nonce,
codeChallenge,
codeChallengeMethod,
reauthToken
)
.then(async ({ code, callbackURL, issuer }) => {
onSuccess(code, callbackURL, issuer);
});

View File

@@ -45,7 +45,7 @@
const loginOptions = await webauthnService.getLoginOptions();
const authResponse = await startAuthentication({ optionsJSON: loginOptions });
const user = await webauthnService.finishLogin(authResponse);
userStore.setUser(user);
await userStore.setUser(user);
}
const info = await oidcService.getDeviceCodeInfo(userCode);

View File

@@ -23,7 +23,7 @@
const authResponse = await startAuthentication({ optionsJSON: loginOptions });
const user = await webauthnService.finishLogin(authResponse);
userStore.setUser(user);
await userStore.setUser(user);
goto('/settings');
} catch (e) {
error = getWebauthnErrorMessage(e);

View File

@@ -23,7 +23,7 @@
isLoading = true;
try {
const user = await userService.exchangeOneTimeAccessToken(code);
userStore.setUser(user);
await userStore.setUser(user);
try {
goto(data.redirect);

View File

@@ -16,6 +16,7 @@
es: 'Español',
fr: 'Français',
it: 'Italiano',
ko: '한국어',
nl: 'Nederlands',
pl: 'Polski',
'pt-BR': 'Português brasileiro',
@@ -31,7 +32,7 @@
...$userStore!,
locale
});
setLocale(locale);
await setLocale(locale);
}
</script>

View File

@@ -22,9 +22,8 @@
$effect(() => {
if (show) {
const expiration = new Date(Date.now() + 15 * 60 * 1000);
userService
.createOneTimeAccessToken(expiration, 'me')
.createOneTimeAccessToken('me')
.then((c) => {
code = c;
loginCodeLink = page.url.origin + '/lc/' + code;
@@ -52,9 +51,9 @@
<div class="flex flex-col items-center gap-2">
<CopyToClipboard value={code!}>
<p class="text-3xl font-semibold">{code}</p>
<p class="text-3xl font-code">{code}</p>
</CopyToClipboard>
<div class="text-muted-foreground my-2 flex items-center justify-center gap-3">
<div class="flex items-center justify-center gap-3 my-2 text-muted-foreground">
<Separator />
<p class="text-xs text-nowrap">{m.or_visit()}</p>
<Separator />

View File

@@ -5,7 +5,7 @@
import type { ApiKeyCreate } from '$lib/types/api-key.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { optionalString } from '$lib/utils/zod-util';
import { emptyToUndefined } from '$lib/utils/zod-util';
import { z } from 'zod/v4';
let {
@@ -28,7 +28,7 @@
const formSchema = z.object({
name: z.string().min(3).max(50),
description: optionalString,
description: emptyToUndefined(z.string().optional()),
expiresAt: z.date().min(new Date(), m.expiration_date_must_be_in_the_future())
});

View File

@@ -5,11 +5,12 @@
import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllAppConfig } from '$lib/types/application-configuration';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideImage, Mail, SlidersHorizontal, UserSearch } from '@lucide/svelte';
import { LucideImage, Mail, SlidersHorizontal, UserSearch, Users } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import AppConfigEmailForm from './forms/app-config-email-form.svelte';
import AppConfigGeneralForm from './forms/app-config-general-form.svelte';
import AppConfigLdapForm from './forms/app-config-ldap-form.svelte';
import AppConfigSignupDefaultsForm from './forms/app-config-signup-defaults-form.svelte';
import UpdateApplicationImages from './update-application-images.svelte';
let { data } = $props();
@@ -68,6 +69,17 @@
</CollapsibleCard>
</div>
<div>
<CollapsibleCard
id="application-configuration-signup-defaults"
icon={Users}
title={m.user_creation()}
description={m.configure_user_creation()}
>
<AppConfigSignupDefaultsForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
</div>
<div>
<CollapsibleCard
id="application-configuration-email"

View File

@@ -23,27 +23,11 @@
let isLoading = $state(false);
const signupOptions = {
disabled: {
label: m.disabled(),
description: m.signup_disabled_description()
},
withToken: {
label: m.signup_with_token(),
description: m.signup_with_token_description()
},
open: {
label: m.signup_open(),
description: m.signup_open_description()
}
};
const updatedAppConfig = {
appName: appConfig.appName,
sessionDuration: appConfig.sessionDuration,
emailsVerified: appConfig.emailsVerified,
allowOwnAccountEdit: appConfig.allowOwnAccountEdit,
allowUserSignups: appConfig.allowUserSignups,
disableAnimations: appConfig.disableAnimations,
accentColor: appConfig.accentColor
};
@@ -53,7 +37,6 @@
sessionDuration: z.number().min(1).max(43200),
emailsVerified: z.boolean(),
allowOwnAccountEdit: z.boolean(),
allowUserSignups: z.enum(['disabled', 'withToken', 'open']),
disableAnimations: z.boolean(),
accentColor: z.string()
});
@@ -80,55 +63,6 @@
description={m.the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again()}
bind:input={$inputs.sessionDuration}
/>
<div class="grid gap-2">
<div>
<Label class="mb-0" for="enable-user-signup">{m.enable_user_signups()}</Label>
<p class="text-muted-foreground text-[0.8rem]">
{m.enable_user_signups_description()}
</p>
</div>
<Select.Root
disabled={$appConfigStore.uiConfigDisabled}
type="single"
value={$inputs.allowUserSignups.value}
onValueChange={(v) =>
($inputs.allowUserSignups.value = v as typeof $inputs.allowUserSignups.value)}
>
<Select.Trigger
class="w-full"
aria-label={m.enable_user_signups()}
placeholder={m.enable_user_signups()}
>
{signupOptions[$inputs.allowUserSignups.value]?.label}
</Select.Trigger>
<Select.Content>
<Select.Item value="disabled">
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{signupOptions.disabled.label}</span>
<span class="text-muted-foreground text-xs">
{signupOptions.disabled.description}
</span>
</div>
</Select.Item>
<Select.Item value="withToken">
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{signupOptions.withToken.label}</span>
<span class="text-muted-foreground text-xs">
{signupOptions.withToken.description}
</span>
</div>
</Select.Item>
<Select.Item value="open">
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{signupOptions.open.label}</span>
<span class="text-muted-foreground text-xs">
{signupOptions.open.description}
</span>
</div>
</Select.Item>
</Select.Content>
</Select.Root>
</div>
<SwitchWithLabel
id="self-account-editing"
label={m.enable_self_account_editing()}

View File

@@ -0,0 +1,176 @@
<script lang="ts">
import CustomClaimsInput from '$lib/components/form/custom-claims-input.svelte';
import SearchableMultiSelect from '$lib/components/form/searchable-multi-select.svelte';
import { Button } from '$lib/components/ui/button';
import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select';
import { m } from '$lib/paraglide/messages';
import UserGroupService from '$lib/services/user-group-service';
import type { AllAppConfig } from '$lib/types/application-configuration';
import { debounced } from '$lib/utils/debounce-util';
import { preventDefault } from '$lib/utils/event-util';
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
let {
appConfig,
callback
}: {
appConfig: AllAppConfig;
callback: (updatedConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props();
const userGroupService = new UserGroupService();
let userGroups = $state<{ value: string; label: string }[]>([]);
let selectedGroups = $state<{ value: string; label: string }[]>([]);
let customClaims = $state(appConfig.signupDefaultCustomClaims || []);
let allowUserSignups = $state(appConfig.allowUserSignups);
let isLoading = $state(false);
let isUserSearchLoading = $state(false);
const signupOptions = {
disabled: {
label: m.disabled(),
description: m.signup_disabled_description()
},
withToken: {
label: m.signup_with_token(),
description: m.signup_with_token_description()
},
open: {
label: m.signup_open(),
description: m.signup_open_description()
}
};
async function loadUserGroups(search?: string) {
userGroups = (await userGroupService.list({ search })).data.map((group) => ({
value: group.id,
label: group.name
}));
// Ensure selected groups are still in the list
for (const selectedGroup of selectedGroups) {
if (!userGroups.some((g) => g.value === selectedGroup.value)) {
userGroups.push(selectedGroup);
}
}
}
async function loadSelectedGroups() {
selectedGroups = (
await Promise.all(
appConfig.signupDefaultUserGroupIDs.map((groupId) => userGroupService.get(groupId))
)
).map((group) => ({
value: group.id,
label: group.name
}));
}
const onUserGroupSearch = debounced(
async (search: string) => await loadUserGroups(search),
300,
(loading) => (isUserSearchLoading = loading)
);
async function onSubmit() {
isLoading = true;
await callback({
allowUserSignups: allowUserSignups,
signupDefaultUserGroupIDs: selectedGroups.map((g) => g.value),
signupDefaultCustomClaims: customClaims
});
toast.success(m.user_creation_updated_successfully());
isLoading = false;
}
$effect(() => {
loadSelectedGroups();
customClaims = appConfig.signupDefaultCustomClaims || [];
allowUserSignups = appConfig.allowUserSignups;
});
onMount(() => loadUserGroups());
</script>
<form class="space-y-6" onsubmit={preventDefault(onSubmit)}>
<div class="grid gap-2">
<div>
<Label class="mb-0" for="enable-user-signup">{m.enable_user_signups()}</Label>
<p class="text-muted-foreground text-[0.8rem]">
{m.enable_user_signups_description()}
</p>
</div>
<Select.Root
type="single"
value={allowUserSignups}
onValueChange={(v) => (allowUserSignups = v as typeof allowUserSignups)}
>
<Select.Trigger
id="enable-user-signup"
class="w-full"
aria-label={m.enable_user_signups()}
placeholder={m.enable_user_signups()}
>
{signupOptions[allowUserSignups]?.label}
</Select.Trigger>
<Select.Content>
<Select.Item value="disabled">
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{signupOptions.disabled.label}</span>
<span class="text-muted-foreground text-xs">
{signupOptions.disabled.description}
</span>
</div>
</Select.Item>
<Select.Item value="withToken">
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{signupOptions.withToken.label}</span>
<span class="text-muted-foreground text-xs">
{signupOptions.withToken.description}
</span>
</div>
</Select.Item>
<Select.Item value="open">
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{signupOptions.open.label}</span>
<span class="text-muted-foreground text-xs">
{signupOptions.open.description}
</span>
</div>
</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div>
<Label for="default-groups" class="mb-0">{m.user_groups()}</Label>
<p class="text-muted-foreground mt-1 mb-2 text-xs">
{m.user_creation_groups_description()}
</p>
<SearchableMultiSelect
id="default-groups"
items={userGroups}
oninput={(e) => onUserGroupSearch(e.currentTarget.value)}
selectedItems={selectedGroups.map((g) => g.value)}
onSelect={(selected) => {
selectedGroups = userGroups.filter((g) => selected.includes(g.value));
}}
isLoading={isUserSearchLoading}
disableInternalSearch
/>
</div>
<div>
<Label class="mb-0">{m.custom_claims()}</Label>
<p class="text-muted-foreground mt-1 mb-2 text-xs">
{m.user_creation_claims_description()}
</p>
<CustomClaimsInput bind:customClaims />
</div>
<div class="flex justify-end pt-2">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
</form>

View File

@@ -70,7 +70,7 @@
{#if expandAddClient}
<div transition:slide>
<Card.Content>
<OIDCClientForm callback={createOIDCClient} />
<OIDCClientForm mode="create" callback={createOIDCClient} />
</Card.Content>
</div>
{/if}

View File

@@ -36,7 +36,8 @@
[m.userinfo_url()]: `https://${page.url.hostname}/api/oidc/userinfo`,
[m.logout_url()]: `https://${page.url.hostname}/api/oidc/end-session`,
[m.certificate_url()]: `https://${page.url.hostname}/.well-known/jwks.json`,
[m.pkce()]: client.pkceEnabled ? m.enabled() : m.disabled()
[m.pkce()]: client.pkceEnabled ? m.enabled() : m.disabled(),
[m.requires_reauthentication()]: client.requiresReauthentication ? m.enabled() : m.disabled()
});
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
@@ -49,6 +50,9 @@
client.isPublic = updatedClient.isPublic;
setupDetails[m.pkce()] = updatedClient.pkceEnabled ? m.enabled() : m.disabled();
setupDetails[m.requires_reauthentication()] = updatedClient.requiresReauthentication
? m.enabled()
: m.disabled();
await Promise.all([dataPromise, imagePromise])
.then(() => {
@@ -120,14 +124,14 @@
<Card.Content>
<div class="flex flex-col">
<div class="mb-2 flex flex-col sm:flex-row sm:items-center">
<Label class="mb-0 w-44">{m.client_id()}</Label>
<Label class="mb-0 w-50">{m.client_id()}</Label>
<CopyToClipboard value={client.id}>
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
</CopyToClipboard>
</div>
{#if !client.isPublic}
<div class="mt-1 mb-2 flex flex-col sm:flex-row sm:items-center">
<Label class="mb-0 w-44">{m.client_secret()}</Label>
<Label class="mb-0 w-50">{m.client_secret()}</Label>
{#if $clientSecretStore}
<CopyToClipboard value={$clientSecretStore}>
<span class="text-muted-foreground text-sm" data-testid="client-secret">
@@ -154,7 +158,7 @@
<div transition:slide>
{#each Object.entries(setupDetails) as [key, value]}
<div class="mb-5 flex flex-col sm:flex-row sm:items-center">
<Label class="mb-0 w-44">{key}</Label>
<Label class="mb-0 w-50">{key}</Label>
<CopyToClipboard {value}>
<span class="text-muted-foreground text-sm">{value}</span>
</CopyToClipboard>
@@ -175,7 +179,7 @@
</Card.Root>
<Card.Root>
<Card.Content>
<OidcForm existingClient={client} callback={updateClient} />
<OidcForm mode="update" existingClient={client} callback={updateClient} />
</Card.Content>
</Card.Root>
<CollapsibleCard

View File

@@ -6,24 +6,30 @@
import { Button } from '$lib/components/ui/button';
import Label from '$lib/components/ui/label/label.svelte';
import { m } from '$lib/paraglide/messages';
import type { OidcClient, OidcClientCreateWithLogo } from '$lib/types/oidc.type';
import type {
OidcClient,
OidcClientCreateWithLogo,
OidcClientUpdateWithLogo
} from '$lib/types/oidc.type';
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { cn } from '$lib/utils/style';
import { emptyToUndefined, optionalUrl } from '$lib/utils/zod-util';
import { LucideChevronDown } from '@lucide/svelte';
import { slide } from 'svelte/transition';
import { z } from 'zod/v4';
import FederatedIdentitiesInput from './federated-identities-input.svelte';
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
import { optionalUrl } from '$lib/utils/zod-util';
let {
callback,
existingClient
existingClient,
mode
}: {
existingClient?: OidcClient;
callback: (user: OidcClientCreateWithLogo) => Promise<boolean>;
callback: (client: OidcClientCreateWithLogo | OidcClientUpdateWithLogo) => Promise<boolean>;
mode: 'create' | 'update';
} = $props();
let isLoading = $state(false);
@@ -34,11 +40,13 @@
);
const client = {
id: '',
name: existingClient?.name || '',
callbackURLs: existingClient?.callbackURLs || [],
logoutCallbackURLs: existingClient?.logoutCallbackURLs || [],
isPublic: existingClient?.isPublic || false,
pkceEnabled: existingClient?.pkceEnabled || false,
requiresReauthentication: existingClient?.requiresReauthentication || false,
launchURL: existingClient?.launchURL || '',
credentials: {
federatedIdentities: existingClient?.credentials?.federatedIdentities || []
@@ -46,11 +54,22 @@
};
const formSchema = z.object({
id: emptyToUndefined(
z
.string()
.min(2)
.max(128)
.regex(/^[a-zA-Z0-9_-]+$/, {
message: m.invalid_client_id()
})
.optional()
),
name: z.string().min(2).max(50),
callbackURLs: z.array(z.string().nonempty()).default([]),
logoutCallbackURLs: z.array(z.string().nonempty()),
isPublic: z.boolean(),
pkceEnabled: z.boolean(),
requiresReauthentication: z.boolean(),
launchURL: optionalUrl,
credentials: z.object({
federatedIdentities: z.array(
@@ -147,6 +166,12 @@
description={m.public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks()}
bind:checked={$inputs.pkceEnabled.value}
/>
<SwitchWithLabel
id="requires-reauthentication"
label={m.requires_reauthentication()}
description={m.requires_users_to_authenticate_again_on_each_authorization()}
bind:checked={$inputs.requiresReauthentication.value}
/>
</div>
<div class="mt-8">
<Label for="logo">{m.logo()}</Label>
@@ -177,7 +202,16 @@
</div>
{#if showAdvancedOptions}
<div class="mt-5 md:col-span-2" transition:slide={{ duration: 200 }}>
<div class="mt-7 flex flex-col gap-y-7 md:col-span-2" transition:slide={{ duration: 200 }}>
{#if mode == 'create'}
<FormInput
label={m.client_id()}
placeholder={m.generated()}
class="w-full md:w-1/2"
description={m.custom_client_id_description()}
bind:input={$inputs.id}
/>
{/if}
<FederatedIdentitiesInput
client={existingClient}
bind:federatedIdentities={$inputs.credentials.value.federatedIdentities}
@@ -189,7 +223,7 @@
<div class="relative mt-5 flex justify-center">
<Button
variant="ghost"
class="text-muted-foregroun"
class="text-muted-foreground"
onclick={() => (showAdvancedOptions = !showAdvancedOptions)}
>
{showAdvancedOptions ? m.hide_advanced_options() : m.show_advanced_options()}

View File

@@ -3,25 +3,25 @@
import * as Pagination from '$lib/components/ui/pagination';
import { m } from '$lib/paraglide/messages';
import OIDCService from '$lib/services/oidc-service';
import type { AuthorizedOidcClient, OidcClientMetaData } from '$lib/types/oidc.type';
import type { AccessibleOidcClient, OidcClientMetaData } from '$lib/types/oidc.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LayoutDashboard } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { default as AuthorizedOidcClientCard } from './authorized-oidc-client-card.svelte';
import AuthorizedOidcClientCard from './authorized-oidc-client-card.svelte';
let { data } = $props();
let authorizedClients: Paginated<AuthorizedOidcClient> = $state(data.authorizedClients);
let clients: Paginated<AccessibleOidcClient> = $state(data.clients);
let requestOptions: SearchPaginationSortRequest = $state(data.appRequestOptions);
const oidcService = new OIDCService();
async function onRefresh(options: SearchPaginationSortRequest) {
authorizedClients = await oidcService.listAuthorizedClients(options);
clients = await oidcService.listOwnAccessibleClients(options);
}
async function onPageChange(page: number) {
requestOptions.pagination = { limit: authorizedClients.pagination.itemsPerPage, page };
requestOptions.pagination = { limit: clients.pagination.itemsPerPage, page };
onRefresh(requestOptions);
}
@@ -64,7 +64,7 @@
</h1>
</div>
{#if authorizedClients.data.length === 0}
{#if clients.data.length === 0}
<div class="py-16 text-center">
<LayoutDashboard class="text-muted-foreground mx-auto mb-4 size-16" />
<h3 class="text-muted-foreground mb-2 text-lg font-medium">
@@ -76,20 +76,23 @@
</div>
{:else}
<div class="space-y-8">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{#each authorizedClients.data as authorizedClient}
<AuthorizedOidcClientCard {authorizedClient} onRevoke={revokeAuthorizedClient} />
<div
class="grid gap-3"
style="grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));"
>
{#each clients.data as client}
<AuthorizedOidcClientCard {client} onRevoke={revokeAuthorizedClient} />
{/each}
</div>
{#if authorizedClients.pagination.totalPages > 1}
{#if clients.pagination.totalPages > 1}
<div class="border-border flex items-center justify-center border-t pt-3">
<Pagination.Root
class="mx-0 w-auto"
count={authorizedClients.pagination.totalItems}
perPage={authorizedClients.pagination.itemsPerPage}
count={clients.pagination.totalItems}
perPage={clients.pagination.itemsPerPage}
{onPageChange}
page={authorizedClients.pagination.currentPage}
page={clients.pagination.currentPage}
>
{#snippet children({ pages })}
<Pagination.Content class="flex justify-center">
@@ -101,7 +104,7 @@
<Pagination.Item>
<Pagination.Link
{page}
isActive={authorizedClients.pagination.currentPage === page.value}
isActive={clients.pagination.currentPage === page.value}
>
{page.value}
</Pagination.Link>

View File

@@ -16,7 +16,7 @@ export const load: PageLoad = async () => {
}
};
const authorizedClients = await oidcService.listAuthorizedClients(appRequestOptions);
const clients = await oidcService.listOwnAccessibleClients(appRequestOptions);
return { authorizedClients, appRequestOptions };
return { clients, appRequestOptions };
};

View File

@@ -4,23 +4,26 @@
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { m } from '$lib/paraglide/messages';
import userStore from '$lib/stores/user-store';
import type { AuthorizedOidcClient, OidcClientMetaData } from '$lib/types/oidc.type';
import type { AccessibleOidcClient, OidcClientMetaData } from '$lib/types/oidc.type';
import { cachedApplicationLogo, cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import {
LucideBan,
LucideEllipsisVertical,
LucideExternalLink,
LucideLogIn,
LucidePencil
} from '@lucide/svelte';
import { formatDistanceToNow } from 'date-fns';
import { mode } from 'mode-watcher';
let {
authorizedClient,
client,
onRevoke
}: {
authorizedClient: AuthorizedOidcClient;
client: AccessibleOidcClient;
onRevoke: (client: OidcClientMetaData) => Promise<void>;
} = $props();
@@ -28,7 +31,7 @@
</script>
<Card.Root
class="border-muted group h-[140px] p-5 transition-all duration-200 hover:shadow-md"
class="border-muted group relative h-[140px] p-5 transition-all duration-200 hover:shadow-md"
data-testid="authorized-oidc-client-card"
>
<Card.Content class=" p-0">
@@ -36,60 +39,84 @@
<div class="aspect-square h-[56px]">
<ImageBox
class="grow rounded-lg object-contain"
src={authorizedClient.client.hasLogo
? cachedOidcClientLogo.getUrl(authorizedClient.client.id)
src={client.hasLogo
? cachedOidcClientLogo.getUrl(client.id)
: cachedApplicationLogo.getUrl(isLightMode)}
alt={m.name_logo({ name: authorizedClient.client.name })}
alt={m.name_logo({ name: client.name })}
/>
</div>
<div class="flex w-full justify-between gap-3">
<div>
<div class="mb-1 flex items-start gap-2">
<h3
class="text-foreground line-clamp-2 leading-tight font-semibold break-words break-all text-ellipsis"
class="text-foreground line-clamp-2 text-ellipsis break-words break-all font-semibold leading-tight"
>
{authorizedClient.client.name}
{client.name}
</h3>
</div>
{#if authorizedClient.client.launchURL}
{#if client.launchURL}
<p
class="text-muted-foreground line-clamp-1 text-xs break-words break-all text-ellipsis"
class="text-muted-foreground line-clamp-1 text-ellipsis break-words break-all text-xs"
>
{new URL(authorizedClient.client.launchURL).hostname}
{new URL(client.launchURL).hostname}
</p>
{/if}
</div>
<div>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<LucideEllipsisVertical class="size-4" />
<span class="sr-only">{m.toggle_menu()}</span>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item
onclick={() => goto(`/settings/admin/oidc-clients/${authorizedClient.client.id}`)}
><LucidePencil class="mr-2 size-4" /> {m.edit()}</DropdownMenu.Item
>
{#if $userStore?.isAdmin}
<DropdownMenu.Item
class="text-red-500 focus:!text-red-700"
onclick={() => onRevoke(authorizedClient.client)}
><LucideBan class="mr-2 size-4" />{m.revoke()}</DropdownMenu.Item
>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
{#if $userStore?.isAdmin || client.lastUsedAt}
<div>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<LucideEllipsisVertical class="size-4" />
<span class="sr-only">{m.toggle_menu()}</span>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{#if $userStore?.isAdmin}
<DropdownMenu.Item
onclick={() => goto(`/settings/admin/oidc-clients/${client.id}`)}
><LucidePencil class="mr-2 size-4" /> {m.edit()}</DropdownMenu.Item
>
{/if}
{#if client.lastUsedAt}
<DropdownMenu.Item
class="text-red-500 focus:!text-red-700"
onclick={() => onRevoke(client)}
><LucideBan class="mr-2 size-4" />{m.revoke()}</DropdownMenu.Item
>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
{/if}
</div>
</div>
<div class="mt-2 flex justify-end">
<div class="mt-2 flex items-end justify-between">
{#if client.lastUsedAt}
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
<p class="text-muted-foreground flex items-center text-xs">
<LucideLogIn class="mr-1 size-3" />
{formatDistanceToNow(client.lastUsedAt, { addSuffix: true })}
</p>
</Tooltip.Trigger>
<Tooltip.Content
>{m.last_signed_in_ago({
time: formatDistanceToNow(client.lastUsedAt)
})}</Tooltip.Content
>
</Tooltip.Root></Tooltip.Provider
>
{:else}
<div></div>
{/if}
<Button
href={authorizedClient.client.launchURL}
href={client.launchURL}
target="_blank"
size="sm"
class="h-8 text-xs"
disabled={!authorizedClient.client.launchURL}
rel="noopener noreferrer"
disabled={!client.launchURL}
>
{m.launch()}
<LucideExternalLink class="ml-1 size-3" />

View File

@@ -32,7 +32,7 @@
return false;
}
userStore.setUser(result.data);
await userStore.setUser(result.data);
isLoading = false;
goto('/signup/add-passkey');

View File

@@ -30,7 +30,7 @@
return false;
}
userStore.setUser(result.data);
await userStore.setUser(result.data);
isLoading = false;
goto('/signup/add-passkey');

Binary file not shown.

179
pnpm-lock.yaml generated
View File

@@ -22,6 +22,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
date-fns:
specifier: ^4.1.0
version: 4.1.0
jose:
specifier: ^5.10.0
version: 5.10.0
@@ -101,6 +104,9 @@ importers:
prettier-plugin-tailwindcss:
specifier: ^0.6.14
version: 0.6.14(prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.36.17))(prettier@3.6.2)
rollup:
specifier: ^4.46.3
version: 4.46.3
svelte:
specifier: ^5.36.16
version: 5.36.17
@@ -474,103 +480,103 @@ packages:
'@poppinss/macroable@1.0.5':
resolution: {integrity: sha512-6u61y1HHd090MEk1Av0/1btDmm2Hh/+XoJj+HgFYRh9koUPI822ybJbwLHuqjLNCiY+o1gRykg2igEqOf/VBZw==}
'@rollup/rollup-android-arm-eabi@4.45.3':
resolution: {integrity: sha512-8oQkCTve4H4B4JpmD2FV7fV2ZPTxJHN//bRhCqPUU8v6c5APlxteAXyc7BFaEb4aGpUzrPLU4PoAcGhwmRzZTA==}
'@rollup/rollup-android-arm-eabi@4.46.3':
resolution: {integrity: sha512-UmTdvXnLlqQNOCJnyksjPs1G4GqXNGW1LrzCe8+8QoaLhhDeTXYBgJ3k6x61WIhlHX2U+VzEJ55TtIjR/HTySA==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.45.3':
resolution: {integrity: sha512-StOsmdXHU2hx3UFTTs6yYxCSwSIgLsfjUBICXyWj625M32OOjakXlaZuGKL+jA3Nvv35+hMxrm/64eCoT07SYQ==}
'@rollup/rollup-android-arm64@4.46.3':
resolution: {integrity: sha512-8NoxqLpXm7VyeI0ocidh335D6OKT0UJ6fHdnIxf3+6oOerZZc+O7r+UhvROji6OspyPm+rrIdb1gTXtVIqn+Sg==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.45.3':
resolution: {integrity: sha512-6CfLF3eqKhCdhK0GUnR5ZS99OFz+dtOeB/uePznLKxjCsk5QjT/V0eSEBb4vj+o/ri3i35MseSEQHCLLAgClVw==}
'@rollup/rollup-darwin-arm64@4.46.3':
resolution: {integrity: sha512-csnNavqZVs1+7/hUKtgjMECsNG2cdB8F7XBHP6FfQjqhjF8rzMzb3SLyy/1BG7YSfQ+bG75Ph7DyedbUqwq1rA==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.45.3':
resolution: {integrity: sha512-QLWyWmAJG9elNTNLdcSXUT/M+J7DhEmvs1XPHYcgYkse3UHf9iWTJ+yTPlKMIetiQnNi+cNp+gY4gvjDpREfKw==}
'@rollup/rollup-darwin-x64@4.46.3':
resolution: {integrity: sha512-r2MXNjbuYabSIX5yQqnT8SGSQ26XQc8fmp6UhlYJd95PZJkQD1u82fWP7HqvGUf33IsOC6qsiV+vcuD4SDP6iw==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.45.3':
resolution: {integrity: sha512-ZOvBq+5nL0yrZIEo1eq6r7MPvkJ8kC1XATS/yHvcq3WbDNKNKBQ1uIF4hicyzDMoJt72G+sn1nKsFXpifZyRDA==}
'@rollup/rollup-freebsd-arm64@4.46.3':
resolution: {integrity: sha512-uluObTmgPJDuJh9xqxyr7MV61Imq+0IvVsAlWyvxAaBSNzCcmZlhfYcRhCdMaCsy46ccZa7vtDDripgs9Jkqsw==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.45.3':
resolution: {integrity: sha512-AYvGR07wecEnyYSovyJ71pTOulbNvsrpRpK6i/IM1b0UGX1vFx51afYuPYPxnvE9aCl5xPnhQicEvdIMxClRgQ==}
'@rollup/rollup-freebsd-x64@4.46.3':
resolution: {integrity: sha512-AVJXEq9RVHQnejdbFvh1eWEoobohUYN3nqJIPI4mNTMpsyYN01VvcAClxflyk2HIxvLpRcRggpX1m9hkXkpC/A==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.45.3':
resolution: {integrity: sha512-Yx8Cp38tfRRToVLuIWzBHV25/QPzpUreOPIiUuNV7KahNPurYg2pYQ4l7aYnvpvklO1riX4643bXLvDsYSBIrA==}
'@rollup/rollup-linux-arm-gnueabihf@4.46.3':
resolution: {integrity: sha512-byyflM+huiwHlKi7VHLAYTKr67X199+V+mt1iRgJenAI594vcmGGddWlu6eHujmcdl6TqSNnvqaXJqZdnEWRGA==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm-musleabihf@4.45.3':
resolution: {integrity: sha512-4dIYRNxlXGDKnO6qgcda6LxnObPO6r1OBU9HG8F9pAnHHLtfbiOqCzDvkeHknx+5mfFVH4tWOl+h+cHylwsPWA==}
'@rollup/rollup-linux-arm-musleabihf@4.46.3':
resolution: {integrity: sha512-aLm3NMIjr4Y9LklrH5cu7yybBqoVCdr4Nvnm8WB7PKCn34fMCGypVNpGK0JQWdPAzR/FnoEoFtlRqZbBBLhVoQ==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm64-gnu@4.45.3':
resolution: {integrity: sha512-M6uVlWKmhLN7LguLDu6396K1W5IBlAaRonjlHQgc3s4dOGceu0FeBuvbXiUPYvup/6b5Ln7IEX7XNm68DN4vrg==}
'@rollup/rollup-linux-arm64-gnu@4.46.3':
resolution: {integrity: sha512-VtilE6eznJRDIoFOzaagQodUksTEfLIsvXymS+UdJiSXrPW7Ai+WG4uapAc3F7Hgs791TwdGh4xyOzbuzIZrnw==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-arm64-musl@4.45.3':
resolution: {integrity: sha512-emaYiOTQJUd6fC9a6jcw9zIWtzaUiuBC+vomggaM4In2iOra/lA6IMHlqZqQZr08NYXrOPMVigreLMeSAwv3Uw==}
'@rollup/rollup-linux-arm64-musl@4.46.3':
resolution: {integrity: sha512-dG3JuS6+cRAL0GQ925Vppafi0qwZnkHdPeuZIxIPXqkCLP02l7ka+OCyBoDEv8S+nKHxfjvjW4OZ7hTdHkx8/w==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-loongarch64-gnu@4.45.3':
resolution: {integrity: sha512-3P77T5AQ4UfVRJSrTKLiUZDJ6XsxeP80027bp6mOFh8sevSD038mYuIYFiUtrSJxxgFb+NgRJFF9oIa0rlUsmg==}
'@rollup/rollup-linux-loongarch64-gnu@4.46.3':
resolution: {integrity: sha512-iU8DxnxEKJptf8Vcx4XvAUdpkZfaz0KWfRrnIRrOndL0SvzEte+MTM7nDH4A2Now4FvTZ01yFAgj6TX/mZl8hQ==}
cpu: [loong64]
os: [linux]
'@rollup/rollup-linux-ppc64-gnu@4.45.3':
resolution: {integrity: sha512-/VPH3ZVeSlmCBPhZdx/+4dMXDjaGMhDsWOBo9EwSkGbw2+OAqaslL53Ao2OqCxR0GgYjmmssJ+OoG+qYGE7IBg==}
'@rollup/rollup-linux-ppc64-gnu@4.46.3':
resolution: {integrity: sha512-VrQZp9tkk0yozJoQvQcqlWiqaPnLM6uY1qPYXvukKePb0fqaiQtOdMJSxNFUZFsGw5oA5vvVokjHrx8a9Qsz2A==}
cpu: [ppc64]
os: [linux]
'@rollup/rollup-linux-riscv64-gnu@4.45.3':
resolution: {integrity: sha512-Hs5if0PjROl1MGMmZX3xMAIfqcGxQE2SJWUr/CpDQsOQn43Wq4IvXXxUMWtiY/BrzdqCCJlRgJ5DKxzS3qWkCw==}
'@rollup/rollup-linux-riscv64-gnu@4.46.3':
resolution: {integrity: sha512-uf2eucWSUb+M7b0poZ/08LsbcRgaDYL8NCGjUeFMwCWFwOuFcZ8D9ayPl25P3pl+D2FH45EbHdfyUesQ2Lt9wA==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-riscv64-musl@4.45.3':
resolution: {integrity: sha512-Qm0WOwh3Lk388+HJFl1ILGbd2iOoQf6yl4fdGqOjBzEA+5JYbLcwd+sGsZjs5pkt8Cr/1G42EiXmlRp9ZeTvFA==}
'@rollup/rollup-linux-riscv64-musl@4.46.3':
resolution: {integrity: sha512-7tnUcDvN8DHm/9ra+/nF7lLzYHDeODKKKrh6JmZejbh1FnCNZS8zMkZY5J4sEipy2OW1d1Ncc4gNHUd0DLqkSg==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-s390x-gnu@4.45.3':
resolution: {integrity: sha512-VJdknTaYw+TqXzlh9c7vaVMh/fV2sU8Khfk4a9vAdYXJawpjf6z3U1k7vDWx2IQ9ZOPoOPxgVpDfYOYhxD7QUA==}
'@rollup/rollup-linux-s390x-gnu@4.46.3':
resolution: {integrity: sha512-MUpAOallJim8CsJK+4Lc9tQzlfPbHxWDrGXZm2z6biaadNpvh3a5ewcdat478W+tXDoUiHwErX/dOql7ETcLqg==}
cpu: [s390x]
os: [linux]
'@rollup/rollup-linux-x64-gnu@4.45.3':
resolution: {integrity: sha512-SUDXU5YabLAMl86FpupSQQEWzVG8X0HM+Q/famnJusbPiUgQnTGuSxtxg4UAYgv1ZmRV1nioYYXsgtSokU/7+Q==}
'@rollup/rollup-linux-x64-gnu@4.46.3':
resolution: {integrity: sha512-F42IgZI4JicE2vM2PWCe0N5mR5vR0gIdORPqhGQ32/u1S1v3kLtbZ0C/mi9FFk7C5T0PgdeyWEPajPjaUpyoKg==}
cpu: [x64]
os: [linux]
'@rollup/rollup-linux-x64-musl@4.45.3':
resolution: {integrity: sha512-ezmqknOUFgZMN6wW+Avlo4sXF3Frswd+ncrwMz4duyZ5Eqd+dAYgJ+A1MY+12LNZ7XDhCiijJceueYvtnzdviw==}
'@rollup/rollup-linux-x64-musl@4.46.3':
resolution: {integrity: sha512-oLc+JrwwvbimJUInzx56Q3ujL3Kkhxehg7O1gWAYzm8hImCd5ld1F2Gry5YDjR21MNb5WCKhC9hXgU7rRlyegQ==}
cpu: [x64]
os: [linux]
'@rollup/rollup-win32-arm64-msvc@4.45.3':
resolution: {integrity: sha512-1YfXoUEE++gIW66zNB9Twd0Ua5xCXpfYppFUxVT/Io5ZT3fO6Se+C/Jvmh3usaIHHyi53t3kpfjydO2GAy5eBA==}
'@rollup/rollup-win32-arm64-msvc@4.46.3':
resolution: {integrity: sha512-lOrQ+BVRstruD1fkWg9yjmumhowR0oLAAzavB7yFSaGltY8klttmZtCLvOXCmGE9mLIn8IBV/IFrQOWz5xbFPg==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.45.3':
resolution: {integrity: sha512-Iok2YA3PvC163rVZf2Zy81A0g88IUcSPeU5pOilcbICXre2EP1mxn1Db/l09Z/SK1vdSLtpJXAnwGuMOyf5O9g==}
'@rollup/rollup-win32-ia32-msvc@4.46.3':
resolution: {integrity: sha512-vvrVKPRS4GduGR7VMH8EylCBqsDcw6U+/0nPDuIjXQRbHJc6xOBj+frx8ksfZAh6+Fptw5wHrN7etlMmQnPQVg==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.45.3':
resolution: {integrity: sha512-HwHCH5GQTOeGYP5wBEBXFVhfQecwRl24Rugoqhh8YwGarsU09bHhOKuqlyW4ZolZCan3eTUax7UJbGSmKSM51A==}
'@rollup/rollup-win32-x64-msvc@4.46.3':
resolution: {integrity: sha512-fi3cPxCnu3ZeM3EwKZPgXbWoGzm2XHgB/WShKI81uj8wG0+laobmqy5wbgEwzstlbLu4MyO8C19FyhhWseYKNQ==}
cpu: [x64]
os: [win32]
@@ -977,6 +983,9 @@ packages:
engines: {node: '>=4'}
hasBin: true
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
dayjs@1.11.13:
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
@@ -1763,8 +1772,8 @@ packages:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
rollup@4.45.3:
resolution: {integrity: sha512-STwyHZF3G+CrmZhB+qDiROq9s8B5PrOCYN6dtmOvwz585XBnyeHk1GTEhHJtUVb355/9uZhOazyVclTt5uahzA==}
rollup@4.46.3:
resolution: {integrity: sha512-RZn2XTjXb8t5g13f5YclGoilU/kwT696DIkY3sywjdZidNSi3+vseaQov7D7BZXVJCPv3pDWUN69C78GGbXsKw==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
@@ -2423,64 +2432,64 @@ snapshots:
'@poppinss/macroable@1.0.5':
optional: true
'@rollup/rollup-android-arm-eabi@4.45.3':
'@rollup/rollup-android-arm-eabi@4.46.3':
optional: true
'@rollup/rollup-android-arm64@4.45.3':
'@rollup/rollup-android-arm64@4.46.3':
optional: true
'@rollup/rollup-darwin-arm64@4.45.3':
'@rollup/rollup-darwin-arm64@4.46.3':
optional: true
'@rollup/rollup-darwin-x64@4.45.3':
'@rollup/rollup-darwin-x64@4.46.3':
optional: true
'@rollup/rollup-freebsd-arm64@4.45.3':
'@rollup/rollup-freebsd-arm64@4.46.3':
optional: true
'@rollup/rollup-freebsd-x64@4.45.3':
'@rollup/rollup-freebsd-x64@4.46.3':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.45.3':
'@rollup/rollup-linux-arm-gnueabihf@4.46.3':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.45.3':
'@rollup/rollup-linux-arm-musleabihf@4.46.3':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.45.3':
'@rollup/rollup-linux-arm64-gnu@4.46.3':
optional: true
'@rollup/rollup-linux-arm64-musl@4.45.3':
'@rollup/rollup-linux-arm64-musl@4.46.3':
optional: true
'@rollup/rollup-linux-loongarch64-gnu@4.45.3':
'@rollup/rollup-linux-loongarch64-gnu@4.46.3':
optional: true
'@rollup/rollup-linux-ppc64-gnu@4.45.3':
'@rollup/rollup-linux-ppc64-gnu@4.46.3':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.45.3':
'@rollup/rollup-linux-riscv64-gnu@4.46.3':
optional: true
'@rollup/rollup-linux-riscv64-musl@4.45.3':
'@rollup/rollup-linux-riscv64-musl@4.46.3':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.45.3':
'@rollup/rollup-linux-s390x-gnu@4.46.3':
optional: true
'@rollup/rollup-linux-x64-gnu@4.45.3':
'@rollup/rollup-linux-x64-gnu@4.46.3':
optional: true
'@rollup/rollup-linux-x64-musl@4.45.3':
'@rollup/rollup-linux-x64-musl@4.46.3':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.45.3':
'@rollup/rollup-win32-arm64-msvc@4.46.3':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.45.3':
'@rollup/rollup-win32-ia32-msvc@4.46.3':
optional: true
'@rollup/rollup-win32-x64-msvc@4.45.3':
'@rollup/rollup-win32-x64-msvc@4.46.3':
optional: true
'@sideway/address@4.1.5':
@@ -2919,6 +2928,8 @@ snapshots:
cssesc@3.0.0: {}
date-fns@4.1.0: {}
dayjs@1.11.13:
optional: true
@@ -3569,30 +3580,30 @@ snapshots:
reusify@1.1.0: {}
rollup@4.45.3:
rollup@4.46.3:
dependencies:
'@types/estree': 1.0.8
optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.45.3
'@rollup/rollup-android-arm64': 4.45.3
'@rollup/rollup-darwin-arm64': 4.45.3
'@rollup/rollup-darwin-x64': 4.45.3
'@rollup/rollup-freebsd-arm64': 4.45.3
'@rollup/rollup-freebsd-x64': 4.45.3
'@rollup/rollup-linux-arm-gnueabihf': 4.45.3
'@rollup/rollup-linux-arm-musleabihf': 4.45.3
'@rollup/rollup-linux-arm64-gnu': 4.45.3
'@rollup/rollup-linux-arm64-musl': 4.45.3
'@rollup/rollup-linux-loongarch64-gnu': 4.45.3
'@rollup/rollup-linux-ppc64-gnu': 4.45.3
'@rollup/rollup-linux-riscv64-gnu': 4.45.3
'@rollup/rollup-linux-riscv64-musl': 4.45.3
'@rollup/rollup-linux-s390x-gnu': 4.45.3
'@rollup/rollup-linux-x64-gnu': 4.45.3
'@rollup/rollup-linux-x64-musl': 4.45.3
'@rollup/rollup-win32-arm64-msvc': 4.45.3
'@rollup/rollup-win32-ia32-msvc': 4.45.3
'@rollup/rollup-win32-x64-msvc': 4.45.3
'@rollup/rollup-android-arm-eabi': 4.46.3
'@rollup/rollup-android-arm64': 4.46.3
'@rollup/rollup-darwin-arm64': 4.46.3
'@rollup/rollup-darwin-x64': 4.46.3
'@rollup/rollup-freebsd-arm64': 4.46.3
'@rollup/rollup-freebsd-x64': 4.46.3
'@rollup/rollup-linux-arm-gnueabihf': 4.46.3
'@rollup/rollup-linux-arm-musleabihf': 4.46.3
'@rollup/rollup-linux-arm64-gnu': 4.46.3
'@rollup/rollup-linux-arm64-musl': 4.46.3
'@rollup/rollup-linux-loongarch64-gnu': 4.46.3
'@rollup/rollup-linux-ppc64-gnu': 4.46.3
'@rollup/rollup-linux-riscv64-gnu': 4.46.3
'@rollup/rollup-linux-riscv64-musl': 4.46.3
'@rollup/rollup-linux-s390x-gnu': 4.46.3
'@rollup/rollup-linux-x64-gnu': 4.46.3
'@rollup/rollup-linux-x64-musl': 4.46.3
'@rollup/rollup-win32-arm64-msvc': 4.46.3
'@rollup/rollup-win32-ia32-msvc': 4.46.3
'@rollup/rollup-win32-x64-msvc': 4.46.3
fsevents: 2.3.3
run-parallel@1.2.0:
@@ -3887,7 +3898,7 @@ snapshots:
fdir: 6.4.6(picomatch@4.0.3)
picomatch: 4.0.3
postcss: 8.5.6
rollup: 4.45.3
rollup: 4.46.3
tinyglobby: 0.2.14
optionalDependencies:
'@types/node': 22.16.5

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