Compare commits

...

32 Commits

Author SHA1 Message Date
Elias Schneider
453a765107 release: 0.49.0 2025-04-20 20:00:09 +02:00
Elias Schneider
f03645d545 chore(translations): update translations via Crowdin (#467) 2025-04-20 17:59:49 +00:00
Elias Schneider
55273d68c9 chore(translations): fix typo in key 2025-04-20 19:51:12 +02:00
Elias Schneider
4e05b82f02 fix: hide alternative sign in button if user is already authenticated 2025-04-20 19:03:58 +02:00
Elias Schneider
2597907578 refactor: fix type errors 2025-04-20 18:54:45 +02:00
Kyle Mendell
debef9a66b ci/cd: setup caching and improve ci job performance (#465) 2025-04-20 11:48:46 -05:00
Elias Schneider
9122e75101 feat: add ability to disable API key expiration email 2025-04-20 18:41:03 +02:00
Elias Schneider
fe1c4b18cd feat: add ability to send login code via email (#457)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-04-20 18:32:40 +02:00
Elias Schneider
e571996cb5 fix: disable animations not respected on authorize and logout page 2025-04-20 17:04:00 +02:00
Elias Schneider
fb862d3ec3 chore(translations): update translations via Crowdin (#459)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-04-20 09:43:27 -05:00
Kyle Mendell
26f01f205b feat: send email to user when api key expires within 7 days (#451)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-04-20 14:40:20 +00:00
Elias Schneider
c37a3e0ed1 fix: remove limit of 20 callback URLs 2025-04-20 16:32:11 +02:00
Elias Schneider
eb689eb56e feat: add description to callback URL inputs 2025-04-20 00:32:27 +02:00
Elias Schneider
60bad9e985 fix: locale change in dropdown doesn't work on first try 2025-04-20 00:31:33 +02:00
Elias Schneider
e21ee8a871 chore: add kmendell to FUNDING.yml 2025-04-19 18:51:01 +02:00
Elias Schneider
04006eb5cc release: 0.48.0 2025-04-18 18:34:52 +02:00
Elias Schneider
84f1d5c906 fix: user querying fails on global audit log page with Postgres 2025-04-18 18:33:14 +02:00
Elias Schneider
983e989be1 chore(translations): update translations via Crowdin (#456) 2025-04-18 18:21:04 +02:00
Kyle Mendell
c843a60131 feat: disable/enable users (#437)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-04-18 15:38:50 +00:00
Elias Schneider
56a8b5d0c0 feat: add gif support for logo and background image 2025-04-18 17:31:04 +02:00
Elias Schneider
f0dce41fbc fix: callback URL doesn't get rejected if it starts with a different string 2025-04-17 20:52:58 +02:00
Elias Schneider
0111a58dac fix: add "type" as reserved claim 2025-04-17 20:41:21 +02:00
Elias Schneider
50e4c5c314 chore(translations): update translations via Crowdin (#444) 2025-04-17 20:19:50 +02:00
Kyle Mendell
5a6dfd9e50 fix: profile picture empty for users without first or last name (#449) 2025-04-17 20:19:10 +02:00
Elias Schneider
75fbfee4d8 chore(translations): add Italian 2025-04-17 19:13:47 +02:00
dependabot[bot]
65ee500ef3 chore(deps): bump golang.org/x/net from 0.36.0 to 0.38.0 in /backend in the go_modules group across 1 directory (#450)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-16 18:26:55 -05:00
Elias Schneider
80f108e5d6 release: 0.47.0 2025-04-16 16:32:27 +02:00
Elias Schneider
9b2d622990 tests: adapt JWTs in e2e tests 2025-04-16 16:30:38 +02:00
Elias Schneider
adf74586af fix: define token type as claim for better client compatibility 2025-04-16 15:58:38 +02:00
Kyle Mendell
b45cf68295 feat: disable animations setting toggle (#442)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-04-15 19:28:10 +00:00
dependabot[bot]
d9dd67c51f chore(deps-dev): bump @sveltejs/kit from 2.16.1 to 2.20.6 in /frontend in the npm_and_yarn group across 1 directory (#443)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-15 20:38:03 +02:00
Grégory Paul
abf17f6211 feat: add qrcode representation of one time link (#424) (#436)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
Co-authored-by: Kyle Mendell <kmendell@outlook.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-04-14 13:16:46 +00:00
98 changed files with 1907 additions and 468 deletions

2
.github/FUNDING.yml vendored
View File

@@ -1,2 +1,2 @@
# These are supported funding model platforms # These are supported funding model platforms
github: stonith404 github: [stonith404, kmendell]

View File

@@ -19,21 +19,28 @@ jobs:
timeout-minutes: 20 timeout-minutes: 20
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Build and export - name: Build and export
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
tags: pocket-id/pocket-id:test push: false
load: false
tags: pocket-id:test
outputs: type=docker,dest=/tmp/docker-image.tar outputs: type=docker,dest=/tmp/docker-image.tar
build-args: BUILD_TAGS=e2etest build-args: BUILD_TAGS=e2etest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Upload Docker image artifact - name: Upload Docker image artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: docker-image name: docker-image
path: /tmp/docker-image.tar path: /tmp/docker-image.tar
retention-days: 1
test-sqlite: test-sqlite:
if: github.event.pull_request.head.ref != 'i18n_crowdin' if: github.event.pull_request.head.ref != 'i18n_crowdin'
@@ -47,13 +54,22 @@ jobs:
cache: "npm" cache: "npm"
cache-dependency-path: frontend/package-lock.json cache-dependency-path: frontend/package-lock.json
- name: Cache Playwright Browsers
uses: actions/cache@v3
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('frontend/package-lock.json') }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Download Docker image artifact - name: Download Docker image artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: docker-image name: docker-image
path: /tmp path: /tmp
- name: Load Docker Image - name: Load Docker image
run: docker load -i /tmp/docker-image.tar run: docker load -i /tmp/docker-image.tar
- name: Install frontend dependencies - name: Install frontend dependencies
@@ -62,6 +78,7 @@ jobs:
- name: Install Playwright Browsers - name: Install Playwright Browsers
working-directory: ./frontend working-directory: ./frontend
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium run: npx playwright install --with-deps chromium
- name: Run Docker Container with Sqlite DB - name: Run Docker Container with Sqlite DB
@@ -69,7 +86,7 @@ jobs:
docker run -d --name pocket-id-sqlite \ docker run -d --name pocket-id-sqlite \
-p 80:80 \ -p 80:80 \
-e APP_ENV=test \ -e APP_ENV=test \
pocket-id/pocket-id:test pocket-id:test
docker logs -f pocket-id-sqlite &> /tmp/backend.log & docker logs -f pocket-id-sqlite &> /tmp/backend.log &
@@ -77,16 +94,18 @@ jobs:
working-directory: ./frontend working-directory: ./frontend
run: npx playwright test run: npx playwright test
- uses: actions/upload-artifact@v4 - name: Upload Frontend Test Report
if: always() uses: actions/upload-artifact@v4
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with: with:
name: playwright-report-sqlite name: playwright-report-sqlite
path: frontend/tests/.report path: frontend/tests/.report
include-hidden-files: true include-hidden-files: true
retention-days: 15 retention-days: 15
- uses: actions/upload-artifact@v4 - name: Upload Backend Test Report
if: always() uses: actions/upload-artifact@v4
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with: with:
name: backend-sqlite name: backend-sqlite
path: /tmp/backend.log path: /tmp/backend.log
@@ -105,12 +124,39 @@ jobs:
cache: "npm" cache: "npm"
cache-dependency-path: frontend/package-lock.json cache-dependency-path: frontend/package-lock.json
- name: Cache Playwright Browsers
uses: actions/cache@v3
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('frontend/package-lock.json') }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Cache PostgreSQL Docker image
uses: actions/cache@v3
id: postgres-cache
with:
path: /tmp/postgres-image.tar
key: postgres-17-${{ runner.os }}
- name: Pull and save PostgreSQL image
if: steps.postgres-cache.outputs.cache-hit != 'true'
run: |
docker pull postgres:17
docker save postgres:17 > /tmp/postgres-image.tar
- name: Load PostgreSQL image from cache
if: steps.postgres-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/postgres-image.tar
- name: Download Docker image artifact - name: Download Docker image artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: docker-image name: docker-image
path: /tmp path: /tmp
- name: Load Docker Image
- name: Load Docker image
run: docker load -i /tmp/docker-image.tar run: docker load -i /tmp/docker-image.tar
- name: Install frontend dependencies - name: Install frontend dependencies
@@ -119,6 +165,7 @@ jobs:
- name: Install Playwright Browsers - name: Install Playwright Browsers
working-directory: ./frontend working-directory: ./frontend
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium run: npx playwright install --with-deps chromium
- name: Create Docker network - name: Create Docker network
@@ -153,7 +200,7 @@ jobs:
-e APP_ENV=test \ -e APP_ENV=test \
-e DB_PROVIDER=postgres \ -e DB_PROVIDER=postgres \
-e DB_CONNECTION_STRING=postgresql://postgres:postgres@pocket-id-db:5432/pocket-id \ -e DB_CONNECTION_STRING=postgresql://postgres:postgres@pocket-id-db:5432/pocket-id \
pocket-id/pocket-id:test pocket-id:test
docker logs -f pocket-id-postgres &> /tmp/backend.log & docker logs -f pocket-id-postgres &> /tmp/backend.log &
@@ -161,7 +208,8 @@ jobs:
working-directory: ./frontend working-directory: ./frontend
run: npx playwright test run: npx playwright test
- uses: actions/upload-artifact@v4 - name: Upload Frontend Test Report
uses: actions/upload-artifact@v4
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin' if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with: with:
name: playwright-report-postgres name: playwright-report-postgres
@@ -169,8 +217,9 @@ jobs:
include-hidden-files: true include-hidden-files: true
retention-days: 15 retention-days: 15
- uses: actions/upload-artifact@v4 - name: Upload Backend Test Report
if: always() uses: actions/upload-artifact@v4
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with: with:
name: backend-postgres name: backend-postgres
path: /tmp/backend.log path: /tmp/backend.log

View File

@@ -1 +1 @@
0.46.0 0.49.0

View File

@@ -1,3 +1,50 @@
## [](https://github.com/pocket-id/pocket-id/compare/v0.48.0...v) (2025-04-20)
### Features
* add ability to disable API key expiration email ([9122e75](https://github.com/pocket-id/pocket-id/commit/9122e75101ad39a40135ccf931eb2bfd351b5db6))
* add ability to send login code via email ([#457](https://github.com/pocket-id/pocket-id/issues/457)) ([fe1c4b1](https://github.com/pocket-id/pocket-id/commit/fe1c4b18cdcc46a4256e0c111b34f1ce00f8e0e1))
* add description to callback URL inputs ([eb689eb](https://github.com/pocket-id/pocket-id/commit/eb689eb56ec9eaf8b0fb1485040e26f841b9225d))
* send email to user when api key expires within 7 days ([#451](https://github.com/pocket-id/pocket-id/issues/451)) ([26f01f2](https://github.com/pocket-id/pocket-id/commit/26f01f205be01fb8abd8c2e564c90c0fc4480ea5))
### Bug Fixes
* disable animations not respected on authorize and logout page ([e571996](https://github.com/pocket-id/pocket-id/commit/e571996cb57d04232c1f47ab337ad656f48bb3cb))
* hide alternative sign in button if user is already authenticated ([4e05b82](https://github.com/pocket-id/pocket-id/commit/4e05b82f02740a4bae07cec6c6a64acd34ca0fc3))
* locale change in dropdown doesn't work on first try ([60bad9e](https://github.com/pocket-id/pocket-id/commit/60bad9e9859d81c9967e6939e1ed10a65145a936))
* remove limit of 20 callback URLs ([c37a3e0](https://github.com/pocket-id/pocket-id/commit/c37a3e0ed177c3bd2b9a618d1f4b0709004478b0))
## [](https://github.com/pocket-id/pocket-id/compare/v0.47.0...v) (2025-04-18)
### Features
* add gif support for logo and background image ([56a8b5d](https://github.com/pocket-id/pocket-id/commit/56a8b5d0c02643f869b77cf8475ddf2f9473880b))
* disable/enable users ([#437](https://github.com/pocket-id/pocket-id/issues/437)) ([c843a60](https://github.com/pocket-id/pocket-id/commit/c843a60131b813177b1e270c4f5d97613c700efa))
### Bug Fixes
* add "type" as reserved claim ([0111a58](https://github.com/pocket-id/pocket-id/commit/0111a58dac0342c5ac2fa25a050e8773810d2b0a))
* callback URL doesn't get rejected if it starts with a different string ([f0dce41](https://github.com/pocket-id/pocket-id/commit/f0dce41fbc5649b3a8fe65de36ca20efa521b880))
* profile picture empty for users without first or last name ([#449](https://github.com/pocket-id/pocket-id/issues/449)) ([5a6dfd9](https://github.com/pocket-id/pocket-id/commit/5a6dfd9e505f4c84e91b4b378b082fab10e8a8a8))
* user querying fails on global audit log page with Postgres ([84f1d5c](https://github.com/pocket-id/pocket-id/commit/84f1d5c906ec3f9a74ad3d2f36526eea847af5dd))
## [](https://github.com/pocket-id/pocket-id/compare/v0.46.0...v) (2025-04-16)
### Features
* add qrcode representation of one time link ([#424](https://github.com/pocket-id/pocket-id/issues/424)) ([#436](https://github.com/pocket-id/pocket-id/issues/436)) ([abf17f6](https://github.com/pocket-id/pocket-id/commit/abf17f62114a2de549b62cec462b9b0659ee23a7))
* disable animations setting toggle ([#442](https://github.com/pocket-id/pocket-id/issues/442)) ([b45cf68](https://github.com/pocket-id/pocket-id/commit/b45cf68295975f51777dab95950b98b8db0a9ae5))
### Bug Fixes
* define token type as claim for better client compatibility ([adf7458](https://github.com/pocket-id/pocket-id/commit/adf74586afb6ef9a00fb122c150b0248c5bc23f0))
## [](https://github.com/pocket-id/pocket-id/compare/v0.45.0...v) (2025-04-13) ## [](https://github.com/pocket-id/pocket-id/compare/v0.45.0...v) (2025-04-13)

View File

@@ -1,6 +1,6 @@
module github.com/pocket-id/pocket-id/backend module github.com/pocket-id/pocket-id/backend
go 1.24 go 1.24.0
require ( require (
github.com/caarlos0/env/v11 v11.3.1 github.com/caarlos0/env/v11 v11.3.1
@@ -79,7 +79,7 @@ require (
go.uber.org/atomic v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect
golang.org/x/arch v0.13.0 // indirect golang.org/x/arch v0.13.0 // indirect
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
golang.org/x/net v0.36.0 // indirect golang.org/x/net v0.38.0 // indirect
golang.org/x/sync v0.12.0 // indirect golang.org/x/sync v0.12.0 // indirect
golang.org/x/sys v0.31.0 // indirect golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect golang.org/x/text v0.23.0 // indirect

View File

@@ -255,8 +255,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.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 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.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=

View File

@@ -49,7 +49,7 @@ func initRouter(ctx context.Context, db *gorm.DB, appConfigService *service.AppC
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService) oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
userGroupService := service.NewUserGroupService(db, appConfigService) userGroupService := service.NewUserGroupService(db, appConfigService)
ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService) ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService)
apiKeyService := service.NewApiKeyService(db) apiKeyService := service.NewApiKeyService(db, emailService)
rateLimitMiddleware := middleware.NewRateLimitMiddleware() rateLimitMiddleware := middleware.NewRateLimitMiddleware()
@@ -61,9 +61,10 @@ func initRouter(ctx context.Context, db *gorm.DB, appConfigService *service.AppC
job.RegisterLdapJobs(ctx, ldapService, appConfigService) job.RegisterLdapJobs(ctx, ldapService, appConfigService)
job.RegisterDbCleanupJobs(ctx, db) job.RegisterDbCleanupJobs(ctx, db)
job.RegisterFileCleanupJobs(ctx, db) job.RegisterFileCleanupJobs(ctx, db)
job.RegisterApiKeyExpiryJob(ctx, apiKeyService, appConfigService)
// Initialize middleware for specific routes // Initialize middleware for specific routes
authMiddleware := middleware.NewAuthMiddleware(apiKeyService, jwtService) authMiddleware := middleware.NewAuthMiddleware(apiKeyService, userService, jwtService)
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware() fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
// Set up API routes // Set up API routes

View File

@@ -82,11 +82,6 @@ type FileTypeNotSupportedError struct{}
func (e *FileTypeNotSupportedError) Error() string { return "file type not supported" } func (e *FileTypeNotSupportedError) Error() string { return "file type not supported" }
func (e *FileTypeNotSupportedError) HttpStatusCode() int { return 400 } func (e *FileTypeNotSupportedError) HttpStatusCode() int { return 400 }
type InvalidCredentialsError struct{}
func (e *InvalidCredentialsError) Error() string { return "no user found with provided credentials" }
func (e *InvalidCredentialsError) HttpStatusCode() int { return 400 }
type FileTooLargeError struct { type FileTooLargeError struct {
MaxSize string MaxSize string
} }
@@ -229,8 +224,7 @@ type InvalidUUIDError struct{}
func (e *InvalidUUIDError) Error() string { func (e *InvalidUUIDError) Error() string {
return "Invalid UUID" return "Invalid UUID"
} }
func (e *InvalidUUIDError) HttpStatusCode() int { return http.StatusBadRequest }
type InvalidEmailError struct{}
type OneTimeAccessDisabledError struct{} type OneTimeAccessDisabledError struct{}
@@ -244,31 +238,34 @@ type InvalidAPIKeyError struct{}
func (e *InvalidAPIKeyError) Error() string { func (e *InvalidAPIKeyError) Error() string {
return "Invalid Api Key" return "Invalid Api Key"
} }
func (e *InvalidAPIKeyError) HttpStatusCode() int { return http.StatusUnauthorized }
type NoAPIKeyProvidedError struct{} type NoAPIKeyProvidedError struct{}
func (e *NoAPIKeyProvidedError) Error() string { func (e *NoAPIKeyProvidedError) Error() string {
return "No API Key Provided" return "No API Key Provided"
} }
func (e *NoAPIKeyProvidedError) HttpStatusCode() int { return http.StatusUnauthorized }
type APIKeyNotFoundError struct{} type APIKeyNotFoundError struct{}
func (e *APIKeyNotFoundError) Error() string { func (e *APIKeyNotFoundError) Error() string {
return "API Key Not Found" return "API Key Not Found"
} }
func (e *APIKeyNotFoundError) HttpStatusCode() int { return http.StatusUnauthorized }
type APIKeyExpirationDateError struct{} type APIKeyExpirationDateError struct{}
func (e *APIKeyExpirationDateError) Error() string { func (e *APIKeyExpirationDateError) Error() string {
return "API Key expiration time must be in the future" return "API Key expiration time must be in the future"
} }
func (e *APIKeyExpirationDateError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcInvalidRefreshTokenError struct{} type OidcInvalidRefreshTokenError struct{}
func (e *OidcInvalidRefreshTokenError) Error() string { func (e *OidcInvalidRefreshTokenError) Error() string {
return "refresh token is invalid or expired" return "refresh token is invalid or expired"
} }
func (e *OidcInvalidRefreshTokenError) HttpStatusCode() int { func (e *OidcInvalidRefreshTokenError) HttpStatusCode() int {
return http.StatusBadRequest return http.StatusBadRequest
} }
@@ -278,7 +275,6 @@ type OidcMissingRefreshTokenError struct{}
func (e *OidcMissingRefreshTokenError) Error() string { func (e *OidcMissingRefreshTokenError) Error() string {
return "refresh token is required" return "refresh token is required"
} }
func (e *OidcMissingRefreshTokenError) HttpStatusCode() int { func (e *OidcMissingRefreshTokenError) HttpStatusCode() int {
return http.StatusBadRequest return http.StatusBadRequest
} }
@@ -288,7 +284,15 @@ type OidcMissingAuthorizationCodeError struct{}
func (e *OidcMissingAuthorizationCodeError) Error() string { func (e *OidcMissingAuthorizationCodeError) Error() string {
return "authorization code is required" return "authorization code is required"
} }
func (e *OidcMissingAuthorizationCodeError) HttpStatusCode() int { func (e *OidcMissingAuthorizationCodeError) HttpStatusCode() int {
return http.StatusBadRequest return http.StatusBadRequest
} }
type UserDisabledError struct{}
func (e *UserDisabledError) Error() string {
return "User account is disabled"
}
func (e *UserDisabledError) HttpStatusCode() int {
return http.StatusForbidden
}

View File

@@ -43,9 +43,10 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.POST("/users/me/one-time-access-token", authMiddleware.WithAdminNotRequired().Add(), uc.createOwnOneTimeAccessTokenHandler) group.POST("/users/me/one-time-access-token", authMiddleware.WithAdminNotRequired().Add(), uc.createOwnOneTimeAccessTokenHandler)
group.POST("/users/:id/one-time-access-token", authMiddleware.Add(), uc.createAdminOneTimeAccessTokenHandler) group.POST("/users/:id/one-time-access-token", authMiddleware.Add(), uc.createAdminOneTimeAccessTokenHandler)
group.POST("/users/:id/one-time-access-email", authMiddleware.Add(), uc.RequestOneTimeAccessEmailAsAdminHandler)
group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler) group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler)
group.POST("/one-time-access-token/setup", uc.getSetupAccessTokenHandler) group.POST("/one-time-access-token/setup", uc.getSetupAccessTokenHandler)
group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.requestOneTimeAccessEmailHandler) group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.RequestOneTimeAccessEmailAsUnauthenticatedUserHandler)
group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler) group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler)
group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler) group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler)
@@ -356,18 +357,63 @@ func (uc *UserController) createOwnOneTimeAccessTokenHandler(c *gin.Context) {
uc.createOneTimeAccessTokenHandler(c, true) uc.createOneTimeAccessTokenHandler(c, true)
} }
// createAdminOneTimeAccessTokenHandler godoc
// @Summary Create one-time access token for user (admin)
// @Description Generate a one-time access token for a specific user (admin only)
// @Tags Users
// @Param id path string true "User ID"
// @Param body body dto.OneTimeAccessTokenCreateDto true "Token options"
// @Success 201 {object} object "{ \"token\": \"string\" }"
// @Router /api/users/{id}/one-time-access-token [post]
func (uc *UserController) createAdminOneTimeAccessTokenHandler(c *gin.Context) { func (uc *UserController) createAdminOneTimeAccessTokenHandler(c *gin.Context) {
uc.createOneTimeAccessTokenHandler(c, false) uc.createOneTimeAccessTokenHandler(c, false)
} }
func (uc *UserController) requestOneTimeAccessEmailHandler(c *gin.Context) { // RequestOneTimeAccessEmailAsUnauthenticatedUserHandler godoc
var input dto.OneTimeAccessEmailDto // @Summary Request one-time access email
// @Description Request a one-time access email for unauthenticated users
// @Tags Users
// @Accept json
// @Produce json
// @Param body body dto.OneTimeAccessEmailAsUnauthenticatedUserDto true "Email request information"
// @Success 204 "No Content"
// @Router /api/one-time-access-email [post]
func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler(c *gin.Context) {
var input dto.OneTimeAccessEmailAsUnauthenticatedUserDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
} }
err := uc.userService.RequestOneTimeAccessEmail(c.Request.Context(), input.Email, input.RedirectPath) err := uc.userService.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), input.Email, input.RedirectPath)
if err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// RequestOneTimeAccessEmailAsAdminHandler godoc
// @Summary Request one-time access email (admin)
// @Description Request a one-time access email for a specific user (admin only)
// @Tags Users
// @Accept json
// @Produce json
// @Param id path string true "User ID"
// @Param body body dto.OneTimeAccessEmailAsAdminDto true "Email request options"
// @Success 204 "No Content"
// @Router /api/users/{id}/one-time-access-email [post]
func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context) {
var input dto.OneTimeAccessEmailAsAdminDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
userID := c.Param("id")
err := uc.userService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, input.ExpiresAt)
if err != nil { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return

View File

@@ -11,12 +11,13 @@ type ApiKeyCreateDto struct {
} }
type ApiKeyDto struct { type ApiKeyDto struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
ExpiresAt datatype.DateTime `json:"expiresAt"` ExpiresAt datatype.DateTime `json:"expiresAt"`
LastUsedAt *datatype.DateTime `json:"lastUsedAt"` LastUsedAt *datatype.DateTime `json:"lastUsedAt"`
CreatedAt datatype.DateTime `json:"createdAt"` CreatedAt datatype.DateTime `json:"createdAt"`
ExpirationEmailSent bool `json:"expirationEmailSent"`
} }
type ApiKeyResponseDto struct { type ApiKeyResponseDto struct {

View File

@@ -12,35 +12,39 @@ type AppConfigVariableDto struct {
} }
type AppConfigUpdateDto struct { type AppConfigUpdateDto struct {
AppName string `json:"appName" binding:"required,min=1,max=30"` AppName string `json:"appName" binding:"required,min=1,max=30"`
SessionDuration string `json:"sessionDuration" binding:"required"` SessionDuration string `json:"sessionDuration" binding:"required"`
EmailsVerified string `json:"emailsVerified" binding:"required"` EmailsVerified string `json:"emailsVerified" binding:"required"`
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"` DisableAnimations string `json:"disableAnimations" binding:"required"`
SmtpHost string `json:"smtpHost"` AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
SmtpPort string `json:"smtpPort"` SmtpHost string `json:"smtpHost"`
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"` SmtpPort string `json:"smtpPort"`
SmtpUser string `json:"smtpUser"` SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
SmtpPassword string `json:"smtpPassword"` SmtpUser string `json:"smtpUser"`
SmtpTls string `json:"smtpTls" binding:"required,oneof=none starttls tls"` SmtpPassword string `json:"smtpPassword"`
SmtpSkipCertVerify string `json:"smtpSkipCertVerify"` SmtpTls string `json:"smtpTls" binding:"required,oneof=none starttls tls"`
LdapEnabled string `json:"ldapEnabled" binding:"required"` SmtpSkipCertVerify string `json:"smtpSkipCertVerify"`
LdapUrl string `json:"ldapUrl"` LdapEnabled string `json:"ldapEnabled" binding:"required"`
LdapBindDn string `json:"ldapBindDn"` LdapUrl string `json:"ldapUrl"`
LdapBindPassword string `json:"ldapBindPassword"` LdapBindDn string `json:"ldapBindDn"`
LdapBase string `json:"ldapBase"` LdapBindPassword string `json:"ldapBindPassword"`
LdapUserSearchFilter string `json:"ldapUserSearchFilter"` LdapBase string `json:"ldapBase"`
LdapUserGroupSearchFilter string `json:"ldapUserGroupSearchFilter"` LdapUserSearchFilter string `json:"ldapUserSearchFilter"`
LdapSkipCertVerify string `json:"ldapSkipCertVerify"` LdapUserGroupSearchFilter string `json:"ldapUserGroupSearchFilter"`
LdapAttributeUserUniqueIdentifier string `json:"ldapAttributeUserUniqueIdentifier"` LdapSkipCertVerify string `json:"ldapSkipCertVerify"`
LdapAttributeUserUsername string `json:"ldapAttributeUserUsername"` LdapAttributeUserUniqueIdentifier string `json:"ldapAttributeUserUniqueIdentifier"`
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"` LdapAttributeUserUsername string `json:"ldapAttributeUserUsername"`
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"` LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"`
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"` LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"`
LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"` LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"`
LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"` LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"`
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"` LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"`
LdapAttributeGroupName string `json:"ldapAttributeGroupName"` LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`
LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"` LdapAttributeGroupName string `json:"ldapAttributeGroupName"`
EmailOneTimeAccessEnabled string `json:"emailOneTimeAccessEnabled" binding:"required"` LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"`
EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"` LdapSoftDeleteUsers string `json:"ldapSoftDeleteUsers"`
EmailOneTimeAccessAsAdminEnabled string `json:"emailOneTimeAccessAsAdminEnabled" binding:"required"`
EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required"`
EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"`
EmailApiKeyExpirationEnabled string `json:"emailApiKeyExpirationEnabled" binding:"required"`
} }

View File

@@ -13,6 +13,7 @@ type UserDto struct {
CustomClaims []CustomClaimDto `json:"customClaims"` CustomClaims []CustomClaimDto `json:"customClaims"`
UserGroups []UserGroupDto `json:"userGroups"` UserGroups []UserGroupDto `json:"userGroups"`
LdapID *string `json:"ldapId"` LdapID *string `json:"ldapId"`
Disabled bool `json:"disabled"`
} }
type UserCreateDto struct { type UserCreateDto struct {
@@ -22,6 +23,7 @@ type UserCreateDto struct {
LastName string `json:"lastName" binding:"required,min=1,max=50"` LastName string `json:"lastName" binding:"required,min=1,max=50"`
IsAdmin bool `json:"isAdmin"` IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"` Locale *string `json:"locale"`
Disabled bool `json:"disabled"`
LdapID string `json:"-"` LdapID string `json:"-"`
} }
@@ -30,11 +32,15 @@ type OneTimeAccessTokenCreateDto struct {
ExpiresAt time.Time `json:"expiresAt" binding:"required"` ExpiresAt time.Time `json:"expiresAt" binding:"required"`
} }
type OneTimeAccessEmailDto struct { type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
Email string `json:"email" binding:"required,email"` Email string `json:"email" binding:"required,email"`
RedirectPath string `json:"redirectPath"` RedirectPath string `json:"redirectPath"`
} }
type OneTimeAccessEmailAsAdminDto struct {
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
}
type UserUpdateUserGroupDto struct { type UserUpdateUserGroupDto struct {
UserGroupIds []string `json:"userGroupIds" binding:"required"` UserGroupIds []string `json:"userGroupIds" binding:"required"`
} }

View File

@@ -0,0 +1,53 @@
package job
import (
"context"
"log"
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
type ApiKeyEmailJobs struct {
apiKeyService *service.ApiKeyService
appConfigService *service.AppConfigService
}
func RegisterApiKeyExpiryJob(ctx context.Context, apiKeyService *service.ApiKeyService, appConfigService *service.AppConfigService) {
jobs := &ApiKeyEmailJobs{
apiKeyService: apiKeyService,
appConfigService: appConfigService,
}
scheduler, err := gocron.NewScheduler()
if err != nil {
log.Fatalf("Failed to create a new scheduler: %v", err)
}
registerJob(ctx, scheduler, "ExpiredApiKeyEmailJob", "0 0 * * *", jobs.checkAndNotifyExpiringApiKeys)
scheduler.Start()
}
func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error {
// Skip if the feature is disabled
if !j.appConfigService.GetDbConfig().EmailApiKeyExpirationEnabled.IsTrue() {
return nil
}
apiKeys, err := j.apiKeyService.ListExpiringApiKeys(ctx, 7)
if err != nil {
log.Printf("Failed to list expiring API keys: %v", err)
return err
}
for _, key := range apiKeys {
if key.User.Email == "" {
continue
}
if err := j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key); err != nil {
log.Printf("Failed to send email for key %s: %v", key.ID, err)
}
}
return nil
}

View File

@@ -41,7 +41,10 @@ func (m *ApiKeyAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (userI
return "", false, &common.NotSignedInError{} return "", false, &common.NotSignedInError{}
} }
// Check if the user is an admin if user.Disabled {
return "", false, &common.UserDisabledError{}
}
if adminRequired && !user.IsAdmin { if adminRequired && !user.IsAdmin {
return "", false, &common.MissingPermissionError{} return "", false, &common.MissingPermissionError{}
} }

View File

@@ -19,11 +19,12 @@ type AuthOptions struct {
func NewAuthMiddleware( func NewAuthMiddleware(
apiKeyService *service.ApiKeyService, apiKeyService *service.ApiKeyService,
userService *service.UserService,
jwtService *service.JwtService, jwtService *service.JwtService,
) *AuthMiddleware { ) *AuthMiddleware {
return &AuthMiddleware{ return &AuthMiddleware{
apiKeyMiddleware: NewApiKeyAuthMiddleware(apiKeyService, jwtService), apiKeyMiddleware: NewApiKeyAuthMiddleware(apiKeyService, jwtService),
jwtMiddleware: NewJwtAuthMiddleware(jwtService), jwtMiddleware: NewJwtAuthMiddleware(jwtService, userService),
options: AuthOptions{ options: AuthOptions{
AdminRequired: true, AdminRequired: true,
SuccessOptional: false, SuccessOptional: false,
@@ -57,12 +58,13 @@ func (m *AuthMiddleware) WithSuccessOptional() *AuthMiddleware {
func (m *AuthMiddleware) Add() gin.HandlerFunc { func (m *AuthMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
// First try JWT auth
userID, isAdmin, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired) userID, isAdmin, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired)
if err == nil { if err == nil {
// JWT auth succeeded, continue with the request
c.Set("userID", userID) c.Set("userID", userID)
c.Set("userIsAdmin", isAdmin) c.Set("userIsAdmin", isAdmin)
if c.IsAborted() {
return
}
c.Next() c.Next()
return return
} }
@@ -70,9 +72,11 @@ func (m *AuthMiddleware) Add() gin.HandlerFunc {
// JWT auth failed, try API key auth // JWT auth failed, try API key auth
userID, isAdmin, err = m.apiKeyMiddleware.Verify(c, m.options.AdminRequired) userID, isAdmin, err = m.apiKeyMiddleware.Verify(c, m.options.AdminRequired)
if err == nil { if err == nil {
// API key auth succeeded, continue with the request
c.Set("userID", userID) c.Set("userID", userID)
c.Set("userIsAdmin", isAdmin) c.Set("userIsAdmin", isAdmin)
if c.IsAborted() {
return
}
c.Next() c.Next()
return return
} }

View File

@@ -10,11 +10,12 @@ import (
) )
type JwtAuthMiddleware struct { type JwtAuthMiddleware struct {
jwtService *service.JwtService userService *service.UserService
jwtService *service.JwtService
} }
func NewJwtAuthMiddleware(jwtService *service.JwtService) *JwtAuthMiddleware { func NewJwtAuthMiddleware(jwtService *service.JwtService, userService *service.UserService) *JwtAuthMiddleware {
return &JwtAuthMiddleware{jwtService: jwtService} return &JwtAuthMiddleware{jwtService: jwtService, userService: userService}
} }
func (m *JwtAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc { func (m *JwtAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
@@ -55,12 +56,16 @@ func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject
return return
} }
// Check if the user is an admin user, err := m.userService.GetUser(c, subject)
isAdmin, err = service.GetIsAdmin(token)
if err != nil { if err != nil {
return "", false, &common.TokenInvalidError{} return "", false, &common.NotSignedInError{}
} }
if adminRequired && !isAdmin {
if user.Disabled {
return "", false, &common.UserDisabledError{}
}
if adminRequired && !user.IsAdmin {
return "", false, &common.MissingPermissionError{} return "", false, &common.MissingPermissionError{}
} }

View File

@@ -5,11 +5,12 @@ import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
type ApiKey struct { type ApiKey struct {
Base Base
Name string `sortable:"true"` Name string `sortable:"true"`
Key string Key string
Description *string Description *string
ExpiresAt datatype.DateTime `sortable:"true"` ExpiresAt datatype.DateTime `sortable:"true"`
LastUsedAt *datatype.DateTime `sortable:"true"` LastUsedAt *datatype.DateTime `sortable:"true"`
ExpirationEmailSent bool
UserID string UserID string
User User User User

View File

@@ -34,21 +34,24 @@ type AppConfig struct {
AppName AppConfigVariable `key:"appName,public"` // Public AppName AppConfigVariable `key:"appName,public"` // Public
SessionDuration AppConfigVariable `key:"sessionDuration"` SessionDuration AppConfigVariable `key:"sessionDuration"`
EmailsVerified AppConfigVariable `key:"emailsVerified"` EmailsVerified AppConfigVariable `key:"emailsVerified"`
DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public
AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public
// Internal // Internal
BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal
LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal
LogoDarkImageType AppConfigVariable `key:"logoDarkImageType,internal"` // Internal LogoDarkImageType AppConfigVariable `key:"logoDarkImageType,internal"` // Internal
// Email // Email
SmtpHost AppConfigVariable `key:"smtpHost"` SmtpHost AppConfigVariable `key:"smtpHost"`
SmtpPort AppConfigVariable `key:"smtpPort"` SmtpPort AppConfigVariable `key:"smtpPort"`
SmtpFrom AppConfigVariable `key:"smtpFrom"` SmtpFrom AppConfigVariable `key:"smtpFrom"`
SmtpUser AppConfigVariable `key:"smtpUser"` SmtpUser AppConfigVariable `key:"smtpUser"`
SmtpPassword AppConfigVariable `key:"smtpPassword"` SmtpPassword AppConfigVariable `key:"smtpPassword"`
SmtpTls AppConfigVariable `key:"smtpTls"` SmtpTls AppConfigVariable `key:"smtpTls"`
SmtpSkipCertVerify AppConfigVariable `key:"smtpSkipCertVerify"` SmtpSkipCertVerify AppConfigVariable `key:"smtpSkipCertVerify"`
EmailLoginNotificationEnabled AppConfigVariable `key:"emailLoginNotificationEnabled"` EmailLoginNotificationEnabled AppConfigVariable `key:"emailLoginNotificationEnabled"`
EmailOneTimeAccessEnabled AppConfigVariable `key:"emailOneTimeAccessEnabled,public"` // Public EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigVariable `key:"emailOneTimeAccessAsUnauthenticatedEnabled,public"` // Public
EmailOneTimeAccessAsAdminEnabled AppConfigVariable `key:"emailOneTimeAccessAsAdminEnabled,public"` // Public
EmailApiKeyExpirationEnabled AppConfigVariable `key:"emailApiKeyExpirationEnabled"`
// LDAP // LDAP
LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public
LdapUrl AppConfigVariable `key:"ldapUrl"` LdapUrl AppConfigVariable `key:"ldapUrl"`
@@ -68,6 +71,7 @@ type AppConfig struct {
LdapAttributeGroupUniqueIdentifier AppConfigVariable `key:"ldapAttributeGroupUniqueIdentifier"` LdapAttributeGroupUniqueIdentifier AppConfigVariable `key:"ldapAttributeGroupUniqueIdentifier"`
LdapAttributeGroupName AppConfigVariable `key:"ldapAttributeGroupName"` LdapAttributeGroupName AppConfigVariable `key:"ldapAttributeGroupName"`
LdapAttributeAdminGroup AppConfigVariable `key:"ldapAttributeAdminGroup"` LdapAttributeAdminGroup AppConfigVariable `key:"ldapAttributeAdminGroup"`
LdapSoftDeleteUsers AppConfigVariable `key:"ldapSoftDeleteUsers"`
} }
func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable { func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable {
@@ -75,7 +79,7 @@ func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable {
cfgValue := reflect.ValueOf(c).Elem() cfgValue := reflect.ValueOf(c).Elem()
cfgType := cfgValue.Type() cfgType := cfgValue.Type()
res := make([]AppConfigVariable, cfgType.NumField()) var res []AppConfigVariable
for i := range cfgType.NumField() { for i := range cfgType.NumField() {
field := cfgType.Field(i) field := cfgType.Field(i)
@@ -92,10 +96,12 @@ func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable {
fieldValue := cfgValue.Field(i) fieldValue := cfgValue.Field(i)
res[i] = AppConfigVariable{ appConfigVariable := AppConfigVariable{
Key: key, Key: key,
Value: fieldValue.FieldByName("Value").String(), Value: fieldValue.FieldByName("Value").String(),
} }
res = append(res, appConfigVariable)
} }
return res return res

View File

@@ -19,6 +19,7 @@ type User struct {
IsAdmin bool `sortable:"true"` IsAdmin bool `sortable:"true"`
Locale *string Locale *string
LdapID *string LdapID *string
Disabled bool `sortable:"true"`
CustomClaims []CustomClaim CustomClaims []CustomClaim
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"` UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
@@ -67,9 +68,12 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
func (u User) FullName() string { return u.FirstName + " " + u.LastName } func (u User) FullName() string { return u.FirstName + " " + u.LastName }
func (u User) Initials() string { func (u User) Initials() string {
return strings.ToUpper( first := utils.GetFirstCharacter(u.FirstName)
utils.GetFirstCharacter(u.FirstName) + utils.GetFirstCharacter(u.LastName), last := utils.GetFirstCharacter(u.LastName)
) if first == "" && last == "" && len(u.Username) >= 2 {
return strings.ToUpper(u.Username[:2])
}
return strings.ToUpper(first + last)
} }
type OneTimeAccessToken struct { type OneTimeAccessToken struct {

View File

@@ -6,6 +6,7 @@ import (
"time" "time"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/dto"
@@ -16,11 +17,12 @@ import (
) )
type ApiKeyService struct { type ApiKeyService struct {
db *gorm.DB db *gorm.DB
emailService *EmailService
} }
func NewApiKeyService(db *gorm.DB) *ApiKeyService { func NewApiKeyService(db *gorm.DB, emailService *EmailService) *ApiKeyService {
return &ApiKeyService{db: db} return &ApiKeyService{db: db, emailService: emailService}
} }
func (s *ApiKeyService) ListApiKeys(ctx context.Context, userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.ApiKey, utils.PaginationResponse, error) { func (s *ApiKeyService) ListApiKeys(ctx context.Context, userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.ApiKey, utils.PaginationResponse, error) {
@@ -117,3 +119,47 @@ func (s *ApiKeyService) ValidateApiKey(ctx context.Context, apiKey string) (mode
return key.User, nil return key.User, nil
} }
func (s *ApiKeyService) ListExpiringApiKeys(ctx context.Context, daysAhead int) ([]model.ApiKey, error) {
var keys []model.ApiKey
now := time.Now()
cutoff := now.AddDate(0, 0, daysAhead)
err := s.db.
WithContext(ctx).
Preload("User").
Where("expires_at > ? AND expires_at <= ? AND expiration_email_sent = ?", datatype.DateTime(now), datatype.DateTime(cutoff), false).
Find(&keys).
Error
return keys, err
}
func (s *ApiKeyService) SendApiKeyExpiringSoonEmail(ctx context.Context, apiKey model.ApiKey) error {
user := apiKey.User
if user.ID == "" {
if err := s.db.WithContext(ctx).First(&user, "id = ?", apiKey.UserID).Error; err != nil {
return err
}
}
err := SendEmail(ctx, s.emailService, email.Address{
Name: user.FullName(),
Email: user.Email,
}, ApiKeyExpiringSoonTemplate, &ApiKeyExpiringSoonTemplateData{
ApiKeyName: apiKey.Name,
ExpiresAt: apiKey.ExpiresAt.ToTime(),
Name: user.FirstName,
})
if err != nil {
return err
}
// Mark the API key as having had an expiration email sent
return s.db.WithContext(ctx).
Model(&model.ApiKey{}).
Where("id = ?", apiKey.ID).
Update("expiration_email_sent", true).
Error
}

View File

@@ -58,6 +58,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
AppName: model.AppConfigVariable{Value: "Pocket ID"}, AppName: model.AppConfigVariable{Value: "Pocket ID"},
SessionDuration: model.AppConfigVariable{Value: "60"}, SessionDuration: model.AppConfigVariable{Value: "60"},
EmailsVerified: model.AppConfigVariable{Value: "false"}, EmailsVerified: model.AppConfigVariable{Value: "false"},
DisableAnimations: model.AppConfigVariable{Value: "false"},
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"}, AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
// Internal // Internal
BackgroundImageType: model.AppConfigVariable{Value: "jpg"}, BackgroundImageType: model.AppConfigVariable{Value: "jpg"},
@@ -72,7 +73,9 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
SmtpTls: model.AppConfigVariable{Value: "none"}, SmtpTls: model.AppConfigVariable{Value: "none"},
SmtpSkipCertVerify: model.AppConfigVariable{Value: "false"}, SmtpSkipCertVerify: model.AppConfigVariable{Value: "false"},
EmailLoginNotificationEnabled: model.AppConfigVariable{Value: "false"}, EmailLoginNotificationEnabled: model.AppConfigVariable{Value: "false"},
EmailOneTimeAccessEnabled: model.AppConfigVariable{Value: "false"}, EmailOneTimeAccessAsUnauthenticatedEnabled: model.AppConfigVariable{Value: "false"},
EmailOneTimeAccessAsAdminEnabled: model.AppConfigVariable{Value: "false"},
EmailApiKeyExpirationEnabled: model.AppConfigVariable{Value: "false"},
// LDAP // LDAP
LdapEnabled: model.AppConfigVariable{Value: "false"}, LdapEnabled: model.AppConfigVariable{Value: "false"},
LdapUrl: model.AppConfigVariable{}, LdapUrl: model.AppConfigVariable{},
@@ -92,6 +95,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{}, LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{},
LdapAttributeGroupName: model.AppConfigVariable{}, LdapAttributeGroupName: model.AppConfigVariable{},
LdapAttributeAdminGroup: model.AppConfigVariable{}, LdapAttributeAdminGroup: model.AppConfigVariable{},
LdapSoftDeleteUsers: model.AppConfigVariable{Value: "true"},
} }
} }
@@ -149,11 +153,6 @@ func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppCon
return nil, &common.UiConfigDisabledError{} return nil, &common.UiConfigDisabledError{}
} }
// If EmailLoginNotificationEnabled is set to false (explicitly), disable the EmailOneTimeAccessEnabled
if input.EmailLoginNotificationEnabled == "false" {
input.EmailOneTimeAccessEnabled = "false"
}
// Start the transaction // Start the transaction
tx, err := s.updateAppConfigStartTransaction(ctx) tx, err := s.updateAppConfigStartTransaction(ctx)
if err != nil { if err != nil {

View File

@@ -447,44 +447,6 @@ func TestUpdateAppConfig(t *testing.T) {
} }
}) })
t.Run("auto disables EmailOneTimeAccessEnabled when EmailLoginNotificationEnabled is false", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// First enable both settings
err = service.UpdateAppConfigValues(t.Context(),
"emailLoginNotificationEnabled", "true",
"emailOneTimeAccessEnabled", "true",
)
require.NoError(t, err)
// Verify both are enabled
config := service.GetDbConfig()
require.True(t, config.EmailLoginNotificationEnabled.IsTrue())
require.True(t, config.EmailOneTimeAccessEnabled.IsTrue())
// Now disable EmailLoginNotificationEnabled
input := dto.AppConfigUpdateDto{
EmailLoginNotificationEnabled: "false",
// Don't set EmailOneTimeAccessEnabled, it should be auto-disabled
}
// Update config
_, err = service.UpdateAppConfig(t.Context(), input)
require.NoError(t, err)
// Verify EmailOneTimeAccessEnabled was automatically disabled
config = service.GetDbConfig()
require.False(t, config.EmailLoginNotificationEnabled.IsTrue())
require.False(t, config.EmailOneTimeAccessEnabled.IsTrue())
})
t.Run("cannot update when UiConfigDisabled is true", func(t *testing.T) { t.Run("cannot update when UiConfigDisabled is true", func(t *testing.T) {
// Save the original state and restore it after the test // Save the original state and restore it after the test
originalUiConfigDisabled := common.EnvConfig.UiConfigDisabled originalUiConfigDisabled := common.EnvConfig.UiConfigDisabled

View File

@@ -90,7 +90,7 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
} }
innerErr = SendEmail(innerCtx, s.emailService, email.Address{ innerErr = SendEmail(innerCtx, s.emailService, email.Address{
Name: user.Username, Name: user.FullName(),
Email: user.Email, Email: user.Email,
}, NewLoginTemplate, &NewLoginTemplateData{ }, NewLoginTemplate, &NewLoginTemplateData{
IPAddress: ipAddress, IPAddress: ipAddress,
@@ -164,8 +164,8 @@ func (s *AuditLogService) ListUsernamesWithIds(ctx context.Context) (users map[s
WithContext(ctx). WithContext(ctx).
Joins("User"). Joins("User").
Model(&model.AuditLog{}). Model(&model.AuditLog{}).
Select("DISTINCT User.id, User.username"). Select("DISTINCT \"User\".id, \"User\".username").
Where("User.username IS NOT NULL") Where("\"User\".username IS NOT NULL")
type Result struct { type Result struct {
ID string `gorm:"column:id"` ID string `gorm:"column:id"`

View File

@@ -26,6 +26,7 @@ func isReservedClaim(key string) bool {
"email", "email",
"preferred_username", "preferred_username",
"groups", "groups",
TokenTypeClaim,
"sub", "sub",
"iss", "iss",
"aud", "aud",

View File

@@ -104,10 +104,10 @@ func SendEmail[V any](ctx context.Context, srv *EmailService, toEmail email.Addr
// so we use the domain of the from address instead (the same as Thunderbird does) // so we use the domain of the from address instead (the same as Thunderbird does)
// if the address does not have an @ (which would be unusual), we use hostname // if the address does not have an @ (which would be unusual), we use hostname
from_address := dbConfig.SmtpFrom.Value fromAddress := dbConfig.SmtpFrom.Value
domain := "" domain := ""
if strings.Contains(from_address, "@") { if strings.Contains(fromAddress, "@") {
domain = strings.Split(from_address, "@")[1] domain = strings.Split(fromAddress, "@")[1]
} else { } else {
hostname, err := os.Hostname() hostname, err := os.Hostname()
if err != nil { if err != nil {

View File

@@ -42,6 +42,13 @@ var TestTemplate = email.Template[struct{}]{
}, },
} }
var ApiKeyExpiringSoonTemplate = email.Template[ApiKeyExpiringSoonTemplateData]{
Path: "api-key-expiring-soon",
Title: func(data *email.TemplateData[ApiKeyExpiringSoonTemplateData]) string {
return fmt.Sprintf("API Key \"%s\" Expiring Soon", data.Data.ApiKeyName)
},
}
type NewLoginTemplateData struct { type NewLoginTemplateData struct {
IPAddress string IPAddress string
Country string Country string
@@ -54,7 +61,14 @@ type OneTimeAccessTemplateData = struct {
Code string Code string
LoginLink string LoginLink string
LoginLinkWithCode string LoginLinkWithCode string
ExpirationString string
}
type ApiKeyExpiringSoonTemplateData struct {
Name string
ApiKeyName string
ExpiresAt time.Time
} }
// this is list of all template paths used for preloading templates // this is list of all template paths used for preloading templates
var emailTemplatesPaths = []string{NewLoginTemplate.Path, OneTimeAccessTemplate.Path, TestTemplate.Path} var emailTemplatesPaths = []string{NewLoginTemplate.Path, OneTimeAccessTemplate.Path, TestTemplate.Path, ApiKeyExpiringSoonTemplate.Path}

View File

@@ -1,6 +1,7 @@
package service package service
import ( import (
"context"
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"encoding/base64" "encoding/base64"
@@ -11,11 +12,8 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/lestrrat-go/jwx/v3/jws"
"github.com/lestrrat-go/jwx/v3/jwa" "github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk" "github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt" "github.com/lestrrat-go/jwx/v3/jwt"
@@ -40,11 +38,17 @@ const (
// This may be omitted on non-admin tokens // This may be omitted on non-admin tokens
IsAdminClaim = "isAdmin" IsAdminClaim = "isAdmin"
// AccessTokenJWTType is the media type for access tokens // TokenTypeClaim is the claim used to identify the type of token
AccessTokenJWTType = "AT+JWT" TokenTypeClaim = "type"
// IDTokenJWTType is the media type for ID tokens // OAuthAccessTokenJWTType identifies a JWT as an OAuth access token
IDTokenJWTType = "ID+JWT" OAuthAccessTokenJWTType = "oauth-access-token" //nolint:gosec
// AccessTokenJWTType identifies a JWT as an access token used by Pocket ID
AccessTokenJWTType = "access-token"
// IDTokenJWTType identifies a JWT as an ID token used by Pocket ID
IDTokenJWTType = "id-token"
// Acceptable clock skew for verifying tokens // Acceptable clock skew for verifying tokens
clockSkew = time.Minute clockSkew = time.Minute
@@ -195,6 +199,11 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err) return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err)
} }
err = SetTokenType(token, AccessTokenJWTType)
if err != nil {
return "", fmt.Errorf("failed to set 'type' claim in token: %w", err)
}
err = SetIsAdmin(token, user.IsAdmin) err = SetIsAdmin(token, user.IsAdmin)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to set 'isAdmin' claim in token: %w", err) return "", fmt.Errorf("failed to set 'isAdmin' claim in token: %w", err)
@@ -218,6 +227,7 @@ func (s *JwtService) VerifyAccessToken(tokenString string) (jwt.Token, error) {
jwt.WithAcceptableSkew(clockSkew), jwt.WithAcceptableSkew(clockSkew),
jwt.WithAudience(common.EnvConfig.AppURL), jwt.WithAudience(common.EnvConfig.AppURL),
jwt.WithIssuer(common.EnvConfig.AppURL), jwt.WithIssuer(common.EnvConfig.AppURL),
jwt.WithValidator(TokenTypeValidator(AccessTokenJWTType)),
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse token: %w", err) return nil, fmt.Errorf("failed to parse token: %w", err)
@@ -242,6 +252,11 @@ func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string,
return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err) return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err)
} }
err = SetTokenType(token, IDTokenJWTType)
if err != nil {
return "", fmt.Errorf("failed to set 'type' claim in token: %w", err)
}
for k, v := range userClaims { for k, v := range userClaims {
err = token.Set(k, v) err = token.Set(k, v)
if err != nil { if err != nil {
@@ -256,13 +271,8 @@ func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string,
} }
} }
headers, err := CreateTokenTypeHeader(IDTokenJWTType)
if err != nil {
return "", fmt.Errorf("failed to set token type: %w", err)
}
alg, _ := s.privateKey.Algorithm() alg, _ := s.privateKey.Algorithm()
signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey, jws.WithProtectedHeaders(headers))) signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey))
if err != nil { if err != nil {
return "", fmt.Errorf("failed to sign token: %w", err) return "", fmt.Errorf("failed to sign token: %w", err)
} }
@@ -281,6 +291,7 @@ func (s *JwtService) VerifyIdToken(tokenString string, acceptExpiredTokens bool)
jwt.WithKey(alg, s.privateKey), jwt.WithKey(alg, s.privateKey),
jwt.WithAcceptableSkew(clockSkew), jwt.WithAcceptableSkew(clockSkew),
jwt.WithIssuer(common.EnvConfig.AppURL), jwt.WithIssuer(common.EnvConfig.AppURL),
jwt.WithValidator(TokenTypeValidator(IDTokenJWTType)),
) )
// By default, jwt.Parse includes 3 default validators for "nbf", "iat", and "exp" // By default, jwt.Parse includes 3 default validators for "nbf", "iat", and "exp"
@@ -299,11 +310,6 @@ func (s *JwtService) VerifyIdToken(tokenString string, acceptExpiredTokens bool)
return nil, fmt.Errorf("failed to parse token: %w", err) return nil, fmt.Errorf("failed to parse token: %w", err)
} }
err = VerifyTokenTypeHeader(tokenString, IDTokenJWTType)
if err != nil {
return nil, fmt.Errorf("failed to verify token type: %w", err)
}
return token, nil return token, nil
} }
@@ -324,13 +330,13 @@ func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string)
return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err) return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err)
} }
headers, err := CreateTokenTypeHeader(AccessTokenJWTType) err = SetTokenType(token, OAuthAccessTokenJWTType)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to set token type: %w", err) return "", fmt.Errorf("failed to set 'type' claim in token: %w", err)
} }
alg, _ := s.privateKey.Algorithm() alg, _ := s.privateKey.Algorithm()
signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey, jws.WithProtectedHeaders(headers))) signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey))
if err != nil { if err != nil {
return "", fmt.Errorf("failed to sign token: %w", err) return "", fmt.Errorf("failed to sign token: %w", err)
} }
@@ -346,16 +352,12 @@ func (s *JwtService) VerifyOauthAccessToken(tokenString string) (jwt.Token, erro
jwt.WithKey(alg, s.privateKey), jwt.WithKey(alg, s.privateKey),
jwt.WithAcceptableSkew(clockSkew), jwt.WithAcceptableSkew(clockSkew),
jwt.WithIssuer(common.EnvConfig.AppURL), jwt.WithIssuer(common.EnvConfig.AppURL),
jwt.WithValidator(TokenTypeValidator(OAuthAccessTokenJWTType)),
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse token: %w", err) return nil, fmt.Errorf("failed to parse token: %w", err)
} }
err = VerifyTokenTypeHeader(tokenString, AccessTokenJWTType)
if err != nil {
return nil, fmt.Errorf("failed to verify token type: %w", err)
}
return token, nil return token, nil
} }
@@ -510,15 +512,12 @@ func GetIsAdmin(token jwt.Token) (bool, error) {
return isAdmin, err return isAdmin, err
} }
// CreateTokenTypeHeader creates a new JWS header with the given token type // SetTokenType sets the "type" claim in the token
func CreateTokenTypeHeader(tokenType string) (jws.Headers, error) { func SetTokenType(token jwt.Token, tokenType string) error {
headers := jws.NewHeaders() if tokenType == "" {
err := headers.Set(jws.TypeKey, tokenType) return nil
if err != nil {
return nil, fmt.Errorf("failed to set token type: %w", err)
} }
return token.Set(TokenTypeClaim, tokenType)
return headers, nil
} }
// SetIsAdmin sets the "isAdmin" claim in the token // SetIsAdmin sets the "isAdmin" claim in the token
@@ -536,36 +535,17 @@ func SetAudienceString(token jwt.Token, audience string) error {
return token.Set(jwt.AudienceKey, audience) return token.Set(jwt.AudienceKey, audience)
} }
// VerifyTokenTypeHeader verifies that the "typ" header in the token matches the expected type // TokenTypeValidator is a validator function that checks the "type" claim in the token
func VerifyTokenTypeHeader(tokenBytes string, expectedTokenType string) error { func TokenTypeValidator(expectedTokenType string) jwt.ValidatorFunc {
// Parse the raw token string purely as a JWS message structure return func(_ context.Context, t jwt.Token) error {
// We don't need to verify the signature at this stage, just inspect headers. var tokenType string
msg, err := jws.Parse([]byte(tokenBytes)) err := t.Get(TokenTypeClaim, &tokenType)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse token as JWS message: %w", err) return fmt.Errorf("failed to get token type claim: %w", err)
}
if tokenType != expectedTokenType {
return fmt.Errorf("invalid token type: expected %s, got %s", expectedTokenType, tokenType)
}
return nil
} }
// Get the list of signatures attached to the message. Usually just one for JWT.
signatures := msg.Signatures()
if len(signatures) == 0 {
return errors.New("JWS message contains no signatures")
}
protectedHeaders := signatures[0].ProtectedHeaders()
if protectedHeaders == nil {
return fmt.Errorf("JWS signature has no protected headers")
}
// Retrieve the 'typ' header value from the PROTECTED headers.
var typHeaderValue string
err = protectedHeaders.Get(jws.TypeKey, &typHeaderValue)
if err != nil {
return fmt.Errorf("token is missing required protected header '%s'", jws.TypeKey)
}
if !strings.EqualFold(typHeaderValue, expectedTokenType) {
return fmt.Errorf("'%s' header mismatch: expected '%s', got '%s'", jws.TypeKey, expectedTokenType, typHeaderValue)
}
return nil
} }

View File

@@ -1,20 +1,18 @@
package service package service
import ( import (
"context"
"crypto/ecdsa" "crypto/ecdsa"
"crypto/ed25519" "crypto/ed25519"
"crypto/elliptic" "crypto/elliptic"
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
"testing" "testing"
"time" "time"
"github.com/lestrrat-go/jwx/v3/jws"
"github.com/lestrrat-go/jwx/v3/jwa" "github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk" "github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt" "github.com/lestrrat-go/jwx/v3/jwt"
@@ -636,6 +634,9 @@ func TestGenerateVerifyIdToken(t *testing.T) {
Build() Build()
require.NoError(t, err, "Failed to build token") require.NoError(t, err, "Failed to build token")
err = SetTokenType(token, IDTokenJWTType)
require.NoError(t, err, "Failed to set token type")
// Add custom claims // Add custom claims
for k, v := range userClaims { for k, v := range userClaims {
if k != "sub" { // Already set above if k != "sub" { // Already set above
@@ -644,13 +645,8 @@ func TestGenerateVerifyIdToken(t *testing.T) {
} }
} }
// Create headers with the specified type
hdrs := jws.NewHeaders()
err = hdrs.Set(jws.TypeKey, "ID+JWT")
require.NoError(t, err, "Failed to set header type")
// Sign the token // Sign the token
signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), service.privateKey, jws.WithProtectedHeaders(hdrs))) signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), service.privateKey))
require.NoError(t, err, "Failed to sign token") require.NoError(t, err, "Failed to sign token")
tokenString := string(signed) tokenString := string(signed)
@@ -968,6 +964,9 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
Build() Build()
require.NoError(t, err, "Failed to build token") require.NoError(t, err, "Failed to build token")
err = SetTokenType(token, OAuthAccessTokenJWTType)
require.NoError(t, err, "Failed to set token type")
signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), service.privateKey)) signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), service.privateKey))
require.NoError(t, err, "Failed to sign token") require.NoError(t, err, "Failed to sign token")
@@ -1168,59 +1167,50 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
}) })
} }
func TestVerifyTokenTypeHeader(t *testing.T) { func TestTokenTypeValidator(t *testing.T) {
mockConfig := &AppConfigService{} // Create a context for the validator function
tempDir := t.TempDir() ctx := context.Background()
// Helper function to create a token with a specific type header
createTokenWithType := func(tokenType string) (string, error) {
// Create a simple JWT token
token := jwt.New()
err := token.Set("test_claim", "test_value")
if err != nil {
return "", fmt.Errorf("failed to set claim: %w", err)
}
// Create headers with the specified type
hdrs := jws.NewHeaders()
if tokenType != "" {
err = hdrs.Set(jws.TypeKey, tokenType)
if err != nil {
return "", fmt.Errorf("failed to set type header: %w", err)
}
}
// Sign the token with the headers
service := &JwtService{}
err = service.init(mockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), service.privateKey, jws.WithProtectedHeaders(hdrs)))
if err != nil {
return "", fmt.Errorf("failed to sign token: %w", err)
}
return string(signed), nil
}
t.Run("succeeds when token type matches expected type", func(t *testing.T) { t.Run("succeeds when token type matches expected type", func(t *testing.T) {
// Create a token with "JWT" type // Create a token with the expected type
tokenString, err := createTokenWithType("JWT") token := jwt.New()
require.NoError(t, err, "Failed to create test token") err := token.Set(TokenTypeClaim, AccessTokenJWTType)
require.NoError(t, err, "Failed to set token type claim")
// Verify the token type // Create a validator function for the expected type
err = VerifyTokenTypeHeader(tokenString, "JWT") validator := TokenTypeValidator(AccessTokenJWTType)
assert.NoError(t, err, "Should accept token with matching type")
// Validate the token
err = validator(ctx, token)
assert.NoError(t, err, "Validator should accept token with matching type")
}) })
t.Run("fails when token type doesn't match expected type", func(t *testing.T) { t.Run("fails when token type doesn't match expected type", func(t *testing.T) {
// Create a token with "AT+JWT" type // Create a token with a different type
tokenString, err := createTokenWithType("AT+JWT") token := jwt.New()
require.NoError(t, err, "Failed to create test token") err := token.Set(TokenTypeClaim, OAuthAccessTokenJWTType)
require.NoError(t, err, "Failed to set token type claim")
// Verify the token with different expected type // Create a validator function for a different expected type
err = VerifyTokenTypeHeader(tokenString, "JWT") validator := TokenTypeValidator(IDTokenJWTType)
require.Error(t, err, "Should reject token with non-matching type")
assert.Contains(t, err.Error(), "header mismatch: expected 'JWT', got 'AT+JWT'") // Validate the token
err = validator(ctx, token)
require.Error(t, err, "Validator should reject token with non-matching type")
assert.Contains(t, err.Error(), "invalid token type: expected id-token, got oauth-access-token")
})
t.Run("fails when token type claim is missing", func(t *testing.T) {
// Create a token without a type claim
token := jwt.New()
// Create a validator function
validator := TokenTypeValidator(AccessTokenJWTType)
// Validate the token
err := validator(ctx, token)
require.Error(t, err, "Validator should reject token without type claim")
assert.Contains(t, err.Error(), "failed to get token type claim")
}) })
} }

View File

@@ -279,6 +279,22 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
Where("ldap_id = ?", ldapId). Where("ldap_id = ?", ldapId).
First(&databaseUser). First(&databaseUser).
Error Error
// If a user is found (even if disabled), enable them since they're now back in LDAP
if databaseUser.ID != "" && databaseUser.Disabled {
// Use the transaction instead of the direct context
err = tx.
WithContext(ctx).
Model(&model.User{}).
Where("id = ?", databaseUser.ID).
Update("disabled", false).
Error
if err != nil {
log.Printf("Failed to enable user %s: %v", databaseUser.Username, err)
}
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
// This could error with ErrRecordNotFound and we want to ignore that here // This could error with ErrRecordNotFound and we want to ignore that here
return fmt.Errorf("failed to query for LDAP user ID '%s': %w", ldapId, err) return fmt.Errorf("failed to query for LDAP user ID '%s': %w", ldapId, err)
@@ -336,24 +352,32 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
err = tx. err = tx.
WithContext(ctx). WithContext(ctx).
Find(&ldapUsersInDb, "ldap_id IS NOT NULL"). Find(&ldapUsersInDb, "ldap_id IS NOT NULL").
Select("ldap_id"). Select("id, username, ldap_id, disabled").
Error Error
if err != nil { if err != nil {
return fmt.Errorf("failed to fetch users from database: %w", err) return fmt.Errorf("failed to fetch users from database: %w", err)
} }
// Delete users that no longer exist in LDAP // Mark users as disabled or delete users that no longer exist in LDAP
for _, user := range ldapUsersInDb { for _, user := range ldapUsersInDb {
// Skip if the user ID exists in the fetched LDAP results
if _, exists := ldapUserIDs[*user.LdapID]; exists { if _, exists := ldapUserIDs[*user.LdapID]; exists {
continue continue
} }
err = s.userService.deleteUserInternal(ctx, user.ID, true, tx) if dbConfig.LdapSoftDeleteUsers.IsTrue() {
if err != nil { err = s.userService.DisableUser(ctx, user.ID, tx)
return fmt.Errorf("failed to delete user '%s': %w", user.Username, err) if err != nil {
log.Printf("Failed to disable user %s: %v", user.Username, err)
continue
}
} else {
err = s.userService.DeleteUser(ctx, user.ID, true)
if err != nil {
log.Printf("Failed to delete user %s: %v", user.Username, err)
continue
}
} }
log.Printf("Deleted user '%s'", user.Username)
} }
return nil return nil

View File

@@ -955,7 +955,7 @@ func (s *OidcService) getCallbackURL(urls []string, inputCallbackURL string) (ca
} }
for _, callbackPattern := range urls { for _, callbackPattern := range urls {
regexPattern := strings.ReplaceAll(regexp.QuoteMeta(callbackPattern), `\*`, ".*") + "$" regexPattern := "^" + strings.ReplaceAll(regexp.QuoteMeta(callbackPattern), `\*`, ".*") + "$"
matched, err := regexp.MatchString(regexPattern, inputCallbackURL) matched, err := regexp.MatchString(regexPattern, inputCallbackURL)
if err != nil { if err != nil {
return "", err return "", err

View File

@@ -38,14 +38,19 @@ func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditL
func (s *UserService) ListUsers(ctx context.Context, searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.User, utils.PaginationResponse, error) { func (s *UserService) ListUsers(ctx context.Context, searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.User, utils.PaginationResponse, error) {
var users []model.User var users []model.User
query := s.db.WithContext(ctx).Model(&model.User{}) query := s.db.WithContext(ctx).
Model(&model.User{}).
Preload("UserGroups").
Preload("CustomClaims")
if searchTerm != "" { if searchTerm != "" {
searchPattern := "%" + searchTerm + "%" searchPattern := "%" + searchTerm + "%"
query = query.Where("email LIKE ? OR first_name LIKE ? OR username LIKE ?", searchPattern, searchPattern, searchPattern) query = query.Where("email LIKE ? OR first_name LIKE ? OR last_name LIKE ? OR username LIKE ?",
searchPattern, searchPattern, searchPattern, searchPattern)
} }
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &users) pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &users)
return users, pagination, err return users, pagination, err
} }
@@ -170,9 +175,28 @@ func (s *UserService) UpdateProfilePicture(userID string, file io.Reader) error
} }
func (s *UserService) DeleteUser(ctx context.Context, userID string, allowLdapDelete bool) error { func (s *UserService) DeleteUser(ctx context.Context, userID string, allowLdapDelete bool) error {
return s.db.Transaction(func(tx *gorm.DB) error { tx := s.db.Begin()
return s.deleteUserInternal(ctx, userID, allowLdapDelete, tx)
}) var user model.User
if err := tx.WithContext(ctx).First(&user, "id = ?", userID).Error; err != nil {
tx.Rollback()
return err
}
// Only soft-delete if user is LDAP and soft-delete is enabled and not allowing hard delete
if user.LdapID != nil && s.appConfigService.GetDbConfig().LdapSoftDeleteUsers.IsTrue() && !allowLdapDelete {
if !user.Disabled {
tx.Rollback()
return fmt.Errorf("LDAP user must be disabled before deletion")
}
}
// Otherwise, hard delete (local users or LDAP users when allowed)
if err := s.deleteUserInternal(ctx, userID, allowLdapDelete, tx); err != nil {
tx.Rollback()
return err
}
return tx.Commit().Error
} }
func (s *UserService) deleteUserInternal(ctx context.Context, userID string, allowLdapDelete bool, tx *gorm.DB) error { func (s *UserService) deleteUserInternal(ctx context.Context, userID string, allowLdapDelete bool, tx *gorm.DB) error {
@@ -187,8 +211,8 @@ func (s *UserService) deleteUserInternal(ctx context.Context, userID string, all
return fmt.Errorf("failed to load user to delete: %w", err) return fmt.Errorf("failed to load user to delete: %w", err)
} }
// Disallow deleting the user if it is an LDAP user and LDAP is enabled // Disallow deleting the user if it is an LDAP user, LDAP is enabled, and the user is not disabled
if !allowLdapDelete && user.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() { if !allowLdapDelete && !user.Disabled && user.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue() {
return &common.LdapUserUpdateError{} return &common.LdapUserUpdateError{}
} }
@@ -299,6 +323,7 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
user.Locale = updatedUser.Locale user.Locale = updatedUser.Locale
if !updateOwnUser { if !updateOwnUser {
user.IsAdmin = updatedUser.IsAdmin user.IsAdmin = updatedUser.IsAdmin
user.Disabled = updatedUser.Disabled
} }
err = tx. err = tx.
@@ -323,23 +348,24 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
return user, nil return user, nil
} }
func (s *UserService) RequestOneTimeAccessEmail(ctx context.Context, emailAddress, redirectPath string) error { func (s *UserService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, userID string, expiration time.Time) error {
tx := s.db.Begin() isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsAdminEnabled.IsTrue()
defer func() {
tx.Rollback()
}()
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessEnabled.IsTrue()
if isDisabled { if isDisabled {
return &common.OneTimeAccessDisabledError{} return &common.OneTimeAccessDisabledError{}
} }
var user model.User return s.requestOneTimeAccessEmailInternal(ctx, userID, "", expiration)
err := tx.
WithContext(ctx). }
Where("email = ?", emailAddress).
First(&user). func (s *UserService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, userID, redirectPath string) error {
Error isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue()
if isDisabled {
return &common.OneTimeAccessDisabledError{}
}
var userId string
err := s.db.Model(&model.User{}).Select("id").Where("email = ?", userID).First(&userId).Error
if err != nil { if err != nil {
// Do not return error if user not found to prevent email enumeration // Do not return error if user not found to prevent email enumeration
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -349,7 +375,22 @@ func (s *UserService) RequestOneTimeAccessEmail(ctx context.Context, emailAddres
} }
} }
oneTimeAccessToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, time.Now().Add(15*time.Minute), tx) expiration := time.Now().Add(15 * time.Minute)
return s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, expiration)
}
func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, expiration time.Time) error {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
user, err := s.GetUser(ctx, userID)
if err != nil {
return err
}
oneTimeAccessToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, expiration, tx)
if err != nil { if err != nil {
return err return err
} }
@@ -374,12 +415,13 @@ func (s *UserService) RequestOneTimeAccessEmail(ctx context.Context, emailAddres
} }
errInternal := SendEmail(innerCtx, s.emailService, email.Address{ errInternal := SendEmail(innerCtx, s.emailService, email.Address{
Name: user.Username, Name: user.FullName(),
Email: user.Email, Email: user.Email,
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{ }, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
Code: oneTimeAccessToken, Code: oneTimeAccessToken,
LoginLink: link, LoginLink: link,
LoginLinkWithCode: linkWithCode, LoginLinkWithCode: linkWithCode,
ExpirationString: utils.DurationToString(time.Until(expiration).Round(time.Second)),
}) })
if errInternal != nil { if errInternal != nil {
log.Printf("Failed to send email to '%s': %v\n", user.Email, errInternal) log.Printf("Failed to send email to '%s': %v\n", user.Email, errInternal)
@@ -606,3 +648,11 @@ func (s *UserService) ResetProfilePicture(userID string) error {
return nil return nil
} }
func (s *UserService) DisableUser(ctx context.Context, userID string, tx *gorm.DB) error {
return tx.WithContext(ctx).
Model(&model.User{}).
Where("id = ?", userID).
Update("disabled", true).
Error
}

View File

@@ -244,6 +244,10 @@ func (s *WebAuthnService) VerifyLogin(ctx context.Context, sessionID string, cre
return model.User{}, "", err return model.User{}, "", err
} }
if user.Disabled {
return model.User{}, "", &common.UserDisabledError{}
}
token, err := s.jwtService.GenerateAccessToken(*user) token, err := s.jwtService.GenerateAccessToken(*user)
if err != nil { if err != nil {
return model.User{}, "", err return model.User{}, "", err

View File

@@ -0,0 +1,52 @@
package utils
import (
"fmt"
"time"
)
// DurationToString converts a time.Duration to a human-readable string. Respects minutes, hours and days.
func DurationToString(duration time.Duration) string {
// For a duration less than a day
if duration < 24*time.Hour {
hours := int(duration.Hours())
mins := int(duration.Minutes()) % 60
switch hours {
case 0:
return fmt.Sprintf("%d minutes", mins)
case 1:
if mins == 0 {
return "1 hour"
}
return fmt.Sprintf("1 hour and %d minutes", mins)
default:
if mins == 0 {
return fmt.Sprintf("%d hours", hours)
}
return fmt.Sprintf("%d hours and %d minutes", hours, mins)
}
} else {
// For durations of a day or more
days := int(duration.Hours() / 24)
hours := int(duration.Hours()) % 24
switch hours {
case 0:
if days == 1 {
return "1 day"
}
return fmt.Sprintf("%d days", days)
case 1:
if days == 1 {
return "1 day and 1 hour"
}
return fmt.Sprintf("%d days and 1 hour", days)
default:
if days == 1 {
return fmt.Sprintf("1 day and %d hours", hours)
}
return fmt.Sprintf("%d days and %d hours", days, hours)
}
}
}

View File

@@ -30,6 +30,8 @@ func GetImageMimeType(ext string) string {
return "image/svg+xml" return "image/svg+xml"
case "ico": case "ico":
return "image/x-icon" return "image/x-icon"
case "gif":
return "image/gif"
default: default:
return "" return ""
} }

View File

@@ -0,0 +1,17 @@
{{ define "base" }}
<div class="header">
<div class="logo">
<img src="{{ .LogoURL }}" alt="{{ .AppName }}" width="32" height="32" style="width: 32px; height: 32px; max-width: 32px;"/>
<h1>{{ .AppName }}</h1>
</div>
<div class="warning">Warning</div>
</div>
<div class="content">
<h2>API Key Expiring Soon</h2>
<p>
Hello {{ .Data.Name }},<br/><br/>
This is a reminder that your API key <strong>{{ .Data.ApiKeyName }}</strong> will expire on <strong>{{ .Data.ExpiresAt.Format "2006-01-02 15:04:05 MST" }}</strong>.<br/><br/>
Please generate a new API key if you need continued access.
</p>
</div>
{{ end }}

View File

@@ -0,0 +1,10 @@
{{ define "base" -}}
API Key Expiring Soon
====================
Hello {{ .Data.Name }},
This is a reminder that your API key "{{ .Data.ApiKeyName }}" will expire on {{ .Data.ExpiresAt.Format "2006-01-02 15:04:05 MST" }}.
Please generate a new API key if you need continued access.
{{ end -}}

View File

@@ -8,7 +8,7 @@
<div class="content"> <div class="content">
<h2>Login Code</h2> <h2>Login Code</h2>
<p class="message"> <p class="message">
Click the button below to sign in to {{ .AppName }} with a login code.</br>Or visit <a href="{{ .Data.LoginLink }}">{{ .Data.LoginLink }}</a> and enter the code <strong>{{ .Data.Code }}</strong>.</br></br>This code expires in 15 minutes. Click the button below to sign in to {{ .AppName }} with a login code.</br>Or visit <a href="{{ .Data.LoginLink }}">{{ .Data.LoginLink }}</a> and enter the code <strong>{{ .Data.Code }}</strong>.</br></br>This code expires in {{.Data.ExpirationString}}.
</p> </p>
<div class="button-container"> <div class="button-container">
<a class="button" href="{{ .Data.LoginLinkWithCode }}" class="button">Sign In</a> <a class="button" href="{{ .Data.LoginLinkWithCode }}" class="button">Sign In</a>

View File

@@ -2,7 +2,7 @@
Login Code Login Code
==================== ====================
Click the link below to sign in to {{ .AppName }} with a login code. This code expires in 15 minutes. Click the link below to sign in to {{ .AppName }} with a login code. This code expires in {{.Data.ExpirationString}}.
{{ .Data.LoginLinkWithCode }} {{ .Data.LoginLinkWithCode }}

View File

@@ -0,0 +1,4 @@
DROP INDEX idx_users_disabled;
ALTER TABLE users
DROP COLUMN disabled;

View File

@@ -0,0 +1,2 @@
ALTER TABLE users
ADD COLUMN IF NOT EXISTS disabled BOOLEAN DEFAULT FALSE NOT NULL;

View File

@@ -0,0 +1,2 @@
ALTER TABLE api_keys
DROP COLUMN IF EXISTS expiration_email_sent;

View File

@@ -0,0 +1,2 @@
ALTER TABLE api_keys
ADD COLUMN expiration_email_sent BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -0,0 +1,4 @@
DROP INDEX idx_users_disabled;
ALTER TABLE users
DROP COLUMN disabled;

View File

@@ -0,0 +1,2 @@
ALTER TABLE users
ADD COLUMN disabled NUMERIC DEFAULT FALSE NOT NULL;

View File

@@ -0,0 +1,2 @@
ALTER TABLE api_keys
DROP COLUMN expiration_email_sent;

View File

@@ -0,0 +1,2 @@
ALTER TABLE api_keys
ADD COLUMN expiration_email_sent BOOLEAN NOT NULL DEFAULT 0;

View File

@@ -156,7 +156,7 @@
"actions": "Akce", "actions": "Akce",
"images_updated_successfully": "Obrázky úspěšně aktualizovány", "images_updated_successfully": "Obrázky úspěšně aktualizovány",
"general": "Obecné", "general": "Obecné",
"enable_email_notifications_to_alert_users_when_a_login_is_detected_from_a_new_device_or_location": "Povolte e-mailová oznámení pro upozornění uživatelů, pokud je zjištěno přihlášení z nového zařízení nebo umístění.", "configure_smtp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.",
"ldap": "LDAP", "ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Nastavte LDAP pro synchronizaci uživatelů a skupin z LDAP serveru.", "configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Nastavte LDAP pro synchronizaci uživatelů a skupin z LDAP serveru.",
"images": "Obrázky", "images": "Obrázky",
@@ -180,7 +180,10 @@
"enabled_emails": "Povolené e-maily", "enabled_emails": "Povolené e-maily",
"email_login_notification": "E-mailovová oznámení o přihlášení", "email_login_notification": "E-mailovová oznámení o přihlášení",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Poslat uživateli e-mail, když se přihlásí z nového zařízení.", "send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Poslat uživateli e-mail, když se přihlásí z nového zařízení.",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Umožňuje uživatelům přihlásit se pomocí přihlašovacího kódu, který je odeslán na jejich e-mail. To výrazně snižuje bezpečnost, protože každý, kdo má přístup k e-mailu uživatele, může získat vstup.", "emai_login_code_requested_by_user": "Email Login Code Requested by User",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
"email_login_code_from_admin": "Email Login Code from Admin",
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
"send_test_email": "Odeslat testovací e-mail", "send_test_email": "Odeslat testovací e-mail",
"application_configuration_updated_successfully": "Nastavení aplikace bylo úspěšně aktualizováno", "application_configuration_updated_successfully": "Nastavení aplikace bylo úspěšně aktualizováno",
"application_name": "Název aplikace", "application_name": "Název aplikace",
@@ -322,5 +325,22 @@
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.", "see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
"token_sign_in": "Token Sign In", "token_sign_in": "Token Sign In",
"client_authorization": "Client Authorization", "client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization" "new_client_authorization": "New Client Authorization",
"disable_animations": "Disable Animations",
"turn_off_all_animations_throughout_the_admin_ui": "Turn off all animations throughout the Admin UI.",
"user_disabled": "Account Disabled",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
"user_disabled_successfully": "User has been disabled successfully.",
"user_enabled_successfully": "User has been enabled successfully.",
"status": "Status",
"disable_firstname_lastname": "Disable {firstName} {lastName}",
"are_you_sure_you_want_to_disable_this_user": "Are you sure you want to disable this user? They will not be able to log in or access any services.",
"ldap_soft_delete_users": "Keep disabled users from LDAP.",
"ldap_soft_delete_users_description": "When enabled, users removed from LDAP will be disabled rather than deleted from the system.",
"login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email",
"show_code": "Show Code",
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API Key Expiration",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire."
} }

View File

@@ -156,7 +156,7 @@
"actions": "Aktionen", "actions": "Aktionen",
"images_updated_successfully": "Bild erfolgreich aktualisiert", "images_updated_successfully": "Bild erfolgreich aktualisiert",
"general": "Allgemein", "general": "Allgemein",
"enable_email_notifications_to_alert_users_when_a_login_is_detected_from_a_new_device_or_location": "Aktiviere E-Mail Benachrichtigungen, um Benutzer zu informieren, wenn ein Login von einem neuen Gerät oder Standort erkannt wird.", "configure_smtp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.",
"ldap": "LDAP", "ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Konfiguriere LDAP-Einstellungen, um Benutzer und Gruppen von einem LDAP-Server zu synchronisieren.", "configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Konfiguriere LDAP-Einstellungen, um Benutzer und Gruppen von einem LDAP-Server zu synchronisieren.",
"images": "Bilder", "images": "Bilder",
@@ -180,7 +180,10 @@
"enabled_emails": "E-Mails aktivieren", "enabled_emails": "E-Mails aktivieren",
"email_login_notification": "E-Mail Benachrichtigung bei Login", "email_login_notification": "E-Mail Benachrichtigung bei Login",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Sende dem Benutzer eine E-Mail, wenn er sich von einem neuen Gerät aus anmeldet.", "send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Sende dem Benutzer eine E-Mail, wenn er sich von einem neuen Gerät aus anmeldet.",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Ermöglicht Benutzer, sich mit einem Login-Code anzumelden, der an ihre E-Mail gesendet wurde. Dies reduziert die Sicherheit erheblich, da jeder, der Zugriff auf die E-Mail des Benutzers hat, Zugang bekommen kann.", "emai_login_code_requested_by_user": "Email Login Code Requested by User",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
"email_login_code_from_admin": "Email Login Code from Admin",
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
"send_test_email": "Test-E-Mail senden", "send_test_email": "Test-E-Mail senden",
"application_configuration_updated_successfully": "Anwendungskonfiguration erfolgreich aktualisiert", "application_configuration_updated_successfully": "Anwendungskonfiguration erfolgreich aktualisiert",
"application_name": "Anwendungsname", "application_name": "Anwendungsname",
@@ -322,5 +325,22 @@
"see_all_account_activities_from_the_last_3_months": "Sieh dir alle Benutzeraktivitäten der letzten 3 Monate an.", "see_all_account_activities_from_the_last_3_months": "Sieh dir alle Benutzeraktivitäten der letzten 3 Monate an.",
"token_sign_in": "Token-Anmeldung", "token_sign_in": "Token-Anmeldung",
"client_authorization": "Client-Autorisierung", "client_authorization": "Client-Autorisierung",
"new_client_authorization": "Neue Client-Autorisierung" "new_client_authorization": "Neue Client-Autorisierung",
"disable_animations": "Disable Animations",
"turn_off_all_animations_throughout_the_admin_ui": "Turn off all animations throughout the Admin UI.",
"user_disabled": "Account Disabled",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
"user_disabled_successfully": "User has been disabled successfully.",
"user_enabled_successfully": "User has been enabled successfully.",
"status": "Status",
"disable_firstname_lastname": "Disable {firstName} {lastName}",
"are_you_sure_you_want_to_disable_this_user": "Are you sure you want to disable this user? They will not be able to log in or access any services.",
"ldap_soft_delete_users": "Keep disabled users from LDAP.",
"ldap_soft_delete_users_description": "When enabled, users removed from LDAP will be disabled rather than deleted from the system.",
"login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email",
"show_code": "Show Code",
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API Key Expiration",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire."
} }

View File

@@ -156,7 +156,7 @@
"actions": "Actions", "actions": "Actions",
"images_updated_successfully": "Images updated successfully", "images_updated_successfully": "Images updated successfully",
"general": "General", "general": "General",
"enable_email_notifications_to_alert_users_when_a_login_is_detected_from_a_new_device_or_location": "Enable email notifications to alert users when a login is detected from a new device or location.", "configure_smtp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.",
"ldap": "LDAP", "ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configure LDAP settings to sync users and groups from an LDAP server.", "configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configure LDAP settings to sync users and groups from an LDAP server.",
"images": "Images", "images": "Images",
@@ -180,7 +180,10 @@
"enabled_emails": "Enabled Emails", "enabled_emails": "Enabled Emails",
"email_login_notification": "Email Login Notification", "email_login_notification": "Email Login Notification",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Send an email to the user when they log in from a new device.", "send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Send an email to the user when they log in from a new device.",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to sign in with a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.", "emai_login_code_requested_by_user": "Email Login Code Requested by User",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
"email_login_code_from_admin": "Email Login Code from Admin",
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
"send_test_email": "Send test email", "send_test_email": "Send test email",
"application_configuration_updated_successfully": "Application configuration updated successfully", "application_configuration_updated_successfully": "Application configuration updated successfully",
"application_name": "Application Name", "application_name": "Application Name",
@@ -322,5 +325,22 @@
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.", "see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
"token_sign_in": "Token Sign In", "token_sign_in": "Token Sign In",
"client_authorization": "Client Authorization", "client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization" "new_client_authorization": "New Client Authorization",
"disable_animations": "Disable Animations",
"turn_off_all_animations_throughout_the_admin_ui": "Turn off all animations throughout the Admin UI.",
"user_disabled": "Account Disabled",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
"user_disabled_successfully": "User has been disabled successfully.",
"user_enabled_successfully": "User has been enabled successfully.",
"status": "Status",
"disable_firstname_lastname": "Disable {firstName} {lastName}",
"are_you_sure_you_want_to_disable_this_user": "Are you sure you want to disable this user? They will not be able to log in or access any services.",
"ldap_soft_delete_users": "Keep disabled users from LDAP.",
"ldap_soft_delete_users_description": "When enabled, users removed from LDAP will be disabled rather than deleted from the system.",
"login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email",
"show_code": "Show Code",
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API Key Expiration",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire."
} }

View File

@@ -156,7 +156,7 @@
"actions": "Actions", "actions": "Actions",
"images_updated_successfully": "Images updated successfully", "images_updated_successfully": "Images updated successfully",
"general": "General", "general": "General",
"enable_email_notifications_to_alert_users_when_a_login_is_detected_from_a_new_device_or_location": "Enable email notifications to alert users when a login is detected from a new device or location.", "configure_smtp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.",
"ldap": "LDAP", "ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configure LDAP settings to sync users and groups from an LDAP server.", "configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configure LDAP settings to sync users and groups from an LDAP server.",
"images": "Images", "images": "Images",
@@ -180,7 +180,10 @@
"enabled_emails": "Enabled Emails", "enabled_emails": "Enabled Emails",
"email_login_notification": "Email Login Notification", "email_login_notification": "Email Login Notification",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Send an email to the user when they log in from a new device.", "send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Send an email to the user when they log in from a new device.",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to sign in with a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.", "emai_login_code_requested_by_user": "Email Login Code Requested by User",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
"email_login_code_from_admin": "Email Login Code from Admin",
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
"send_test_email": "Send test email", "send_test_email": "Send test email",
"application_configuration_updated_successfully": "Application configuration updated successfully", "application_configuration_updated_successfully": "Application configuration updated successfully",
"application_name": "Application Name", "application_name": "Application Name",
@@ -322,5 +325,22 @@
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.", "see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
"token_sign_in": "Token Sign In", "token_sign_in": "Token Sign In",
"client_authorization": "Client Authorization", "client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization" "new_client_authorization": "New Client Authorization",
"disable_animations": "Disable Animations",
"turn_off_all_animations_throughout_the_admin_ui": "Turn off all animations throughout the Admin UI.",
"user_disabled": "Account Disabled",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
"user_disabled_successfully": "User has been disabled successfully.",
"user_enabled_successfully": "User has been enabled successfully.",
"status": "Status",
"disable_firstname_lastname": "Disable {firstName} {lastName}",
"are_you_sure_you_want_to_disable_this_user": "Are you sure you want to disable this user? They will not be able to log in or access any services.",
"ldap_soft_delete_users": "Keep disabled users from LDAP.",
"ldap_soft_delete_users_description": "When enabled, users removed from LDAP will be disabled rather than deleted from the system.",
"login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email",
"show_code": "Show Code",
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API Key Expiration",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire."
} }

View File

@@ -156,7 +156,7 @@
"actions": "Actions", "actions": "Actions",
"images_updated_successfully": "Image mise à jour avec succès", "images_updated_successfully": "Image mise à jour avec succès",
"general": "Général", "general": "Général",
"enable_email_notifications_to_alert_users_when_a_login_is_detected_from_a_new_device_or_location": "Activer les notifications par e-mail pour alerter les utilisateurs lorsqu'une connexion est détecté à partir d'un nouvel appareil ou d'un nouvel emplacement.", "configure_smtp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.",
"ldap": "LDAP", "ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configurer les paramètres LDAP pour synchroniser les utilisateurs et les groupes à partir d'un serveur LDAP.", "configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configurer les paramètres LDAP pour synchroniser les utilisateurs et les groupes à partir d'un serveur LDAP.",
"images": "Images", "images": "Images",
@@ -180,7 +180,10 @@
"enabled_emails": "Emails activés", "enabled_emails": "Emails activés",
"email_login_notification": "Notification de connexion par e-mail", "email_login_notification": "Notification de connexion par e-mail",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Envoyer un email à l'utilisateur lorsqu'il se connecte à partir d'un nouvel appareil.", "send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Envoyer un email à l'utilisateur lorsqu'il se connecte à partir d'un nouvel appareil.",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Permet aux utilisateurs de se connecter avec un code de connexion envoyé à leur adresse e-mail. Cela réduit considérablement la sécurité car toute personne ayant accès à l'e-mail de l'utilisateur peuvent se connecter.", "emai_login_code_requested_by_user": "Email Login Code Requested by User",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
"email_login_code_from_admin": "Email Login Code from Admin",
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
"send_test_email": "", "send_test_email": "",
"application_configuration_updated_successfully": "Mise à jour de l'application avec succès", "application_configuration_updated_successfully": "Mise à jour de l'application avec succès",
"application_name": "Nom de l'application", "application_name": "Nom de l'application",
@@ -322,5 +325,22 @@
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.", "see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
"token_sign_in": "Token Sign In", "token_sign_in": "Token Sign In",
"client_authorization": "Client Authorization", "client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization" "new_client_authorization": "New Client Authorization",
"disable_animations": "Disable Animations",
"turn_off_all_animations_throughout_the_admin_ui": "Turn off all animations throughout the Admin UI.",
"user_disabled": "Account Disabled",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
"user_disabled_successfully": "User has been disabled successfully.",
"user_enabled_successfully": "User has been enabled successfully.",
"status": "Status",
"disable_firstname_lastname": "Disable {firstName} {lastName}",
"are_you_sure_you_want_to_disable_this_user": "Are you sure you want to disable this user? They will not be able to log in or access any services.",
"ldap_soft_delete_users": "Keep disabled users from LDAP.",
"ldap_soft_delete_users_description": "When enabled, users removed from LDAP will be disabled rather than deleted from the system.",
"login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email",
"show_code": "Show Code",
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API Key Expiration",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire."
} }

View File

@@ -0,0 +1,346 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"my_account": "Il mio account",
"logout": "Disconnetti",
"confirm": "Conferma",
"key": "Chiave",
"value": "Valore",
"remove_custom_claim": "Rimuovi attributo personalizzato",
"add_custom_claim": "Aggiungi attributo personalizzato",
"add_another": "Aggiungi altro",
"select_a_date": "Seleziona una data",
"select_file": "Seleziona File",
"profile_picture": "Immagine del profilo",
"profile_picture_is_managed_by_ldap_server": "L'immagine del profilo è gestita dal server LDAP e non può essere modificata qui.",
"click_profile_picture_to_upload_custom": "Clicca sull'immagine del profilo per caricarne una personalizzata dai tuoi file.",
"image_should_be_in_format": "L'immagine deve essere in formato PNG o JPEG.",
"items_per_page": "Elementi per pagina",
"no_items_found": "Nessun elemento trovato",
"search": "Cerca...",
"expand_card": "Espandi scheda",
"copied": "Copiato",
"click_to_copy": "Clicca per copiare",
"something_went_wrong": "Qualcosa è andato storto",
"go_back_to_home": "Torna alla home",
"dont_have_access_to_your_passkey": "Non hai accesso alla tua passkey?",
"login_background": "Sfondo di accesso",
"logo": "Logo",
"login_code": "Codice di accesso",
"create_a_login_code_to_sign_in_without_a_passkey_once": "Crea un codice di accesso che l'utente può utilizzare per accedere una volta senza passkey.",
"one_hour": "1 ora",
"twelve_hours": "12 ore",
"one_day": "1 giorno",
"one_week": "1 settimana",
"one_month": "1 mese",
"expiration": "Scadenza",
"generate_code": "Genera Codice",
"name": "Nome",
"browser_unsupported": "Browser non supportato",
"this_browser_does_not_support_passkeys": "Questo browser non supporta le passkey. Si prega di utilizzare un metodo di accesso alternativo.",
"an_unknown_error_occurred": "Si è verificato un errore sconosciuto",
"authentication_process_was_aborted": "Il processo di autenticazione è stato interrotto",
"error_occurred_with_authenticator": "Si è verificato un errore con l'autenticatore",
"authenticator_does_not_support_discoverable_credentials": "L'autenticatore non supporta le credenziali rilevabili",
"authenticator_does_not_support_resident_keys": "L'autenticatore non supporta le chiavi residenti",
"passkey_was_previously_registered": "Questa passkey è stata registrata in precedenza",
"authenticator_does_not_support_any_of_the_requested_algorithms": "L'autenticatore non supporta nessuno degli algoritmi richiesti",
"authenticator_timed_out": "L'autenticatore ha superato il tempo limite",
"critical_error_occurred_contact_administrator": "Si è verificato un errore critico. Contatta il tuo amministratore.",
"sign_in_to": "Accedi a {name}",
"client_not_found": "Client non trovato",
"client_wants_to_access_the_following_information": "<b>{client}</b> vuole accedere alle seguenti informazioni:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Vuoi accedere a <b>{client}</b> con il tuo account <b>{appName}</b>?",
"email": "Email",
"view_your_email_address": "Visualizza il tuo indirizzo email",
"profile": "Profilo",
"view_your_profile_information": "Visualizza le informazioni del tuo profilo",
"groups": "Gruppi",
"view_the_groups_you_are_a_member_of": "Visualizza i gruppi di cui sei membro",
"cancel": "Annulla",
"sign_in": "Accedi",
"try_again": "Riprova",
"client_logo": "Logo del client",
"sign_out": "Disconnetti",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Vuoi disconnetterti da Pocket ID con l'account <b>{username}</b>?",
"sign_in_to_appname": "Accedi a {appName}",
"please_try_to_sign_in_again": "Per favore, prova ad accedere di nuovo.",
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Autenticati con la tua passkey per accedere al pannello di amministrazione.",
"authenticate": "Autentica",
"appname_setup": "Configurazione di {appName}",
"please_try_again": "Per favore, riprova.",
"you_are_about_to_sign_in_to_the_initial_admin_account": "Stai per accedere all'account amministratore iniziale. Chiunque abbia questo link può accedere all'account finché non viene aggiunta una passkey. Configura una passkey il prima possibile per prevenire accessi non autorizzati.",
"continue": "Continua",
"alternative_sign_in": "Accesso Alternativo",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Se non hai accesso alla tua passkey, puoi accedere utilizzando uno dei seguenti metodi.",
"use_your_passkey_instead": "Usare invece la tua passkey?",
"email_login": "Accesso Email",
"enter_a_login_code_to_sign_in": "Inserisci un codice di accesso per accedere.",
"request_a_login_code_via_email": "Richiedi un codice di accesso via email.",
"go_back": "Torna indietro",
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "È stata inviata un'email all'indirizzo fornito, se esiste nel sistema.",
"enter_code": "Inserisci codice",
"enter_your_email_address_to_receive_an_email_with_a_login_code": "Inserisci il tuo indirizzo email per ricevere un'email con un codice di accesso.",
"your_email": "La tua email",
"submit": "Invia",
"enter_the_code_you_received_to_sign_in": "Inserisci il codice che hai ricevuto per accedere.",
"code": "Codice",
"invalid_redirect_url": "URL di reindirizzamento non valido",
"audit_log": "Registro attività",
"users": "Utenti",
"user_groups": "Gruppi di utenti",
"oidc_clients": "Client OIDC",
"api_keys": "Chiavi API",
"application_configuration": "Configurazione dell'applicazione",
"settings": "Impostazioni",
"update_pocket_id": "Aggiorna Pocket ID",
"powered_by": "Alimentato da",
"see_your_account_activities_from_the_last_3_months": "Visualizza le attività del tuo account degli ultimi 3 mesi.",
"time": "Ora",
"event": "Evento",
"approximate_location": "Posizione approssimativa",
"ip_address": "Indirizzo IP",
"device": "Dispositivo",
"client": "Client",
"unknown": "Sconosciuto",
"account_details_updated_successfully": "Dettagli dell'account aggiornati con successo",
"profile_picture_updated_successfully": "Immagine del profilo aggiornata con successo. Potrebbero essere necessari alcuni minuti per l'aggiornamento.",
"account_settings": "Impostazioni account",
"passkey_missing": "Passkey mancante",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Aggiungi una passkey per evitare di perdere l'accesso al tuo account.",
"single_passkey_configured": "Singola Passkey Configurata",
"it_is_recommended_to_add_more_than_one_passkey": "Si consiglia di aggiungere più di una passkey per evitare di perdere l'accesso al tuo account.",
"account_details": "Dettagli account",
"passkeys": "Passkey",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Gestisci le tue passkey che puoi utilizzare per autenticarti.",
"add_passkey": "Aggiungi Passkey",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Crea un codice di accesso monouso per accedere da un dispositivo diverso senza una passkey.",
"create": "Crea",
"first_name": "Nome",
"last_name": "Cognome",
"username": "Nome utente",
"save": "Salva",
"username_can_only_contain": "Il nome utente può contenere solo lettere minuscole, numeri, underscore, punti, trattini e simboli '@'",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Accedi utilizzando il seguente codice. Il codice scadrà tra 15 minuti.",
"or_visit": "o visita",
"added_on": "Aggiunto il",
"rename": "Rinomina",
"delete": "Elimina",
"are_you_sure_you_want_to_delete_this_passkey": "Sei sicuro di voler eliminare questa passkey?",
"passkey_deleted_successfully": "Passkey eliminata con successo",
"delete_passkey_name": "Elimina {passkeyName}",
"passkey_name_updated_successfully": "Nome della passkey aggiornato con successo",
"name_passkey": "Nome Passkey",
"name_your_passkey_to_easily_identify_it_later": "Dai un nome alla tua passkey per identificarla facilmente in seguito.",
"create_api_key": "Crea Chiave API",
"add_a_new_api_key_for_programmatic_access": "Aggiungi una nuova chiave API per l'accesso programmatico.",
"add_api_key": "Aggiungi Chiave API",
"manage_api_keys": "Gestisci Chiavi API",
"api_key_created": "Chiave API Creata",
"for_security_reasons_this_key_will_only_be_shown_once": "Per motivi di sicurezza, questa chiave verrà mostrata solo una volta. Conservala in modo sicuro.",
"description": "Descrizione",
"api_key": "Chiave API",
"close": "Chiudi",
"name_to_identify_this_api_key": "Nome per identificare questa chiave API.",
"expires_at": "Scade il",
"when_this_api_key_will_expire": "Quando scadrà questa chiave API.",
"optional_description_to_help_identify_this_keys_purpose": "Descrizione opzionale per aiutare a identificare lo scopo di questa chiave.",
"name_must_be_at_least_3_characters": "Il nome deve essere di almeno 3 caratteri",
"name_cannot_exceed_50_characters": "Il nome non può superare i 50 caratteri",
"expiration_date_must_be_in_the_future": "La data di scadenza deve essere nel futuro",
"revoke_api_key": "Revoca Chiave API",
"never": "Mai",
"revoke": "Revoca",
"api_key_revoked_successfully": "Chiave API revocata con successo",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Sei sicuro di voler revocare la chiave API \"{apiKeyName}\"? Questo comprometterà qualsiasi integrazione che utilizza questa chiave.",
"last_used": "Ultimo utilizzo",
"actions": "Azioni",
"images_updated_successfully": "Immagini aggiornate con successo",
"general": "Generale",
"configure_smtp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.",
"ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configura le impostazioni LDAP per sincronizzare utenti e gruppi da un server LDAP.",
"images": "Immagini",
"update": "Aggiorna",
"email_configuration_updated_successfully": "Configurazione email aggiornata con successo",
"save_changes_question": "Salvare le modifiche?",
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "Devi salvare le modifiche prima di inviare un'email di prova. Vuoi salvare ora?",
"save_and_send": "Salva e invia",
"test_email_sent_successfully": "Email di prova inviata con successo al tuo indirizzo email.",
"failed_to_send_test_email": "Impossibile inviare l'email di prova. Controlla i log del server per maggiori informazioni.",
"smtp_configuration": "Configurazione SMTP",
"smtp_host": "Host SMTP",
"smtp_port": "Porta SMTP",
"smtp_user": "Utente SMTP",
"smtp_password": "Password SMTP",
"smtp_from": "Da SMTP",
"smtp_tls_option": "Opzione TLS SMTP",
"email_tls_option": "Opzione TLS Email",
"skip_certificate_verification": "Salta Verifica Certificato",
"this_can_be_useful_for_selfsigned_certificates": "Questo può essere utile per i certificati autofirmati.",
"enabled_emails": "Email Abilitate",
"email_login_notification": "Notifica Accesso Email",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Invia un'email all'utente quando accede da un nuovo dispositivo.",
"emai_login_code_requested_by_user": "Email Login Code Requested by User",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
"email_login_code_from_admin": "Email Login Code from Admin",
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
"send_test_email": "Invia email di prova",
"application_configuration_updated_successfully": "Configurazione dell'applicazione aggiornata con successo",
"application_name": "Nome dell'applicazione",
"session_duration": "Durata della sessione",
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "La durata di una sessione in minuti prima che l'utente debba accedere nuovamente.",
"enable_self_account_editing": "Abilita modifica del proprio account",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Se gli utenti dovrebbero essere in grado di modificare i dettagli del proprio account.",
"emails_verified": "Email verificate",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Se l'email dell'utente deve essere contrassegnata come verificata per i client OIDC.",
"ldap_configuration_updated_successfully": "Configurazione LDAP aggiornata con successo",
"ldap_disabled_successfully": "LDAP disabilitato con successo",
"ldap_sync_finished": "Sincronizzazione LDAP completata",
"client_configuration": "Configurazione client",
"ldap_url": "URL LDAP",
"ldap_bind_dn": "LDAP Bind DN",
"ldap_bind_password": "Password LDAP Bind",
"ldap_base_dn": "LDAP Base DN",
"user_search_filter": "Filtro di ricerca utente",
"the_search_filter_to_use_to_search_or_sync_users": "Il filtro di ricerca da utilizzare per cercare/sincronizzare gli utenti.",
"groups_search_filter": "Filtro di ricerca gruppi",
"the_search_filter_to_use_to_search_or_sync_groups": "Il filtro di ricerca da utilizzare per cercare/sincronizzare i gruppi.",
"attribute_mapping": "Mappatura attributi",
"user_unique_identifier_attribute": "Attributo identificativo univoco utente",
"the_value_of_this_attribute_should_never_change": "Il valore di questo attributo non dovrebbe mai cambiare.",
"username_attribute": "Attributo nome utente",
"user_mail_attribute": "Attributo email utente",
"user_first_name_attribute": "Attributo nome utente",
"user_last_name_attribute": "Attributo cognome utente",
"user_profile_picture_attribute": "Attributo immagine profilo utente",
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "Il valore di questo attributo può essere un URL, un'immagine binaria o codificata in base64.",
"group_members_attribute": "Attributo membri del gruppo",
"the_attribute_to_use_for_querying_members_of_a_group": "L'attributo da utilizzare per interrogare i membri di un gruppo.",
"group_unique_identifier_attribute": "Attributo identificativo univoco gruppo",
"group_name_attribute": "Attributo nome gruppo",
"admin_group_name": "Nome gruppo amministratori",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "I membri di questo gruppo avranno privilegi di amministratore in Pocket ID.",
"disable": "Disabilita",
"sync_now": "Sincronizza ora",
"enable": "Abilita",
"user_created_successfully": "Utente creato con successo",
"create_user": "Crea Utente",
"add_a_new_user_to_appname": "Aggiungi un nuovo utente a {appName}",
"add_user": "Aggiungi Utente",
"manage_users": "Gestisci Utenti",
"admin_privileges": "Privilegi amministratore",
"admins_have_full_access_to_the_admin_panel": "Gli amministratori hanno pieno accesso al pannello di amministrazione.",
"delete_firstname_lastname": "Elimina {firstName} {lastName}",
"are_you_sure_you_want_to_delete_this_user": "Sei sicuro di voler eliminare questo utente?",
"user_deleted_successfully": "Utente eliminato con successo",
"role": "Ruolo",
"source": "Origine",
"admin": "Amministratore",
"user": "Utente",
"local": "Locale",
"toggle_menu": "Attiva/disattiva menu",
"edit": "Modifica",
"user_groups_updated_successfully": "Gruppi utente aggiornati con successo",
"user_updated_successfully": "Utente aggiornato con successo",
"custom_claims_updated_successfully": "Attributi personalizzati aggiornati con successo",
"back": "Indietro",
"user_details_firstname_lastname": "Dettagli utente {firstName} {lastName}",
"manage_which_groups_this_user_belongs_to": "Gestisci a quali gruppi appartiene questo utente.",
"custom_claims": "Attributi personalizzati",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Gli attributi personalizzati sono coppie chiave-valore che possono essere utilizzate per memorizzare informazioni aggiuntive su un utente. Questi attributi saranno inclusi nel token ID se viene richiesto lo scope 'profile'.",
"user_group_created_successfully": "Gruppo utente creato con successo",
"create_user_group": "Crea Gruppo Utente",
"create_a_new_group_that_can_be_assigned_to_users": "Crea un nuovo gruppo che può essere assegnato agli utenti.",
"add_group": "Aggiungi Gruppo",
"manage_user_groups": "Gestisci Gruppi Utente",
"friendly_name": "Nome amichevole",
"name_that_will_be_displayed_in_the_ui": "Nome che verrà visualizzato nell'interfaccia utente",
"name_that_will_be_in_the_groups_claim": "Nome che sarà nell'attributo \"groups\"",
"delete_name": "Elimina {name}",
"are_you_sure_you_want_to_delete_this_user_group": "Sei sicuro di voler eliminare questo gruppo utente?",
"user_group_deleted_successfully": "Gruppo utente eliminato con successo",
"user_count": "Numero utenti",
"user_group_updated_successfully": "Gruppo utente aggiornato con successo",
"users_updated_successfully": "Utenti aggiornati con successo",
"user_group_details_name": "Dettagli gruppo utente {name}",
"assign_users_to_this_group": "Assegna utenti a questo gruppo.",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Gli attributi personalizzati sono coppie chiave-valore che possono essere utilizzate per memorizzare informazioni aggiuntive su un utente. Questi attributi saranno inclusi nel token ID se viene richiesto lo scope 'profile'. Gli attributi personalizzati definiti sull'utente avranno priorità in caso di conflitti.",
"oidc_client_created_successfully": "Client OIDC creato con successo",
"create_oidc_client": "Crea Client OIDC",
"add_a_new_oidc_client_to_appname": "Aggiungi un nuovo client OIDC a {appName}.",
"add_oidc_client": "Aggiungi Client OIDC",
"manage_oidc_clients": "Gestisci Client OIDC",
"one_time_link": "Link monouso",
"use_this_link_to_sign_in_once": "Usa questo link per accedere una volta. Necessario per gli utenti che non hanno ancora aggiunto una passkey o l'hanno persa.",
"add": "Aggiungi",
"callback_urls": "URL di callback",
"logout_callback_urls": "URL di callback per il logout",
"public_client": "Client pubblico",
"public_clients_do_not_have_a_client_secret_and_use_pkce_instead": "I client pubblici non hanno un client secret e utilizzano PKCE. Abilita questa opzione se il tuo client è una SPA o un'app mobile.",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Il Public Key Code Exchange è una funzionalità di sicurezza per prevenire attacchi CSRF e intercettazione del codice di autorizzazione.",
"name_logo": "Logo di {name}",
"change_logo": "Cambia Logo",
"upload_logo": "Carica Logo",
"remove_logo": "Rimuovi Logo",
"are_you_sure_you_want_to_delete_this_oidc_client": "Sei sicuro di voler eliminare questo client OIDC?",
"oidc_client_deleted_successfully": "Client OIDC eliminato con successo",
"authorization_url": "URL di autorizzazione",
"oidc_discovery_url": "URL di discovery OIDC",
"token_url": "URL del token",
"userinfo_url": "URL delle informazioni utente",
"logout_url": "URL di logout",
"certificate_url": "URL del certificato",
"enabled": "Abilitato",
"disabled": "Disabilitato",
"oidc_client_updated_successfully": "Client OIDC aggiornato con successo",
"create_new_client_secret": "Crea nuovo client secret",
"are_you_sure_you_want_to_create_a_new_client_secret": "Sei sicuro di voler creare un nuovo client secret? Quello vecchio sarà invalidato.",
"generate": "Genera",
"new_client_secret_created_successfully": "Nuovo client secret creato con successo",
"allowed_user_groups_updated_successfully": "Gruppi utente consentiti aggiornati con successo",
"oidc_client_name": "Client OIDC {name}",
"client_id": "ID client",
"client_secret": "Client secret",
"show_more_details": "Mostra più dettagli",
"allowed_user_groups": "Gruppi utente consentiti",
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Aggiungi gruppi utente a questo client per limitare l'accesso agli utenti in questi gruppi. Se non viene selezionato alcun gruppo utente, tutti gli utenti avranno accesso a questo client.",
"favicon": "Favicon",
"light_mode_logo": "Logo modalità chiara",
"dark_mode_logo": "Logo modalità scura",
"background_image": "Immagine di sfondo",
"language": "Lingua",
"reset_profile_picture_question": "Reimpostare l'immagine del profilo?",
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "Questo rimuoverà l'immagine caricata e reimposterà l'immagine del profilo a quella predefinita. Vuoi continuare?",
"reset": "Reimposta",
"reset_to_default": "Ripristina valori predefiniti",
"profile_picture_has_been_reset": "L'immagine del profilo è stata reimpostata. Potrebbero essere necessari alcuni minuti per l'aggiornamento.",
"select_the_language_you_want_to_use": "Seleziona la lingua che desideri utilizzare. Alcune lingue potrebbero non essere completamente tradotte.",
"personal": "Personale",
"global": "Globale",
"all_users": "Tutti gli utenti",
"all_events": "Tutti gli eventi",
"all_clients": "Tutti i client",
"global_audit_log": "Registro attività globale",
"see_all_account_activities_from_the_last_3_months": "Visualizza tutte le attività degli utenti degli ultimi 3 mesi.",
"token_sign_in": "Accesso con token",
"client_authorization": "Autorizzazione client",
"new_client_authorization": "Nuova autorizzazione client",
"disable_animations": "Disabilita animazioni",
"turn_off_all_animations_throughout_the_admin_ui": "Disattiva tutte le animazioni nell'interfaccia di amministrazione.",
"user_disabled": "Account disabilitato",
"disabled_users_cannot_log_in_or_use_services": "Gli utenti disabilitati non possono accedere o utilizzare i servizi.",
"user_disabled_successfully": "Utente disabilitato con successo.",
"user_enabled_successfully": "Utente abilitato con successo.",
"status": "Stato",
"disable_firstname_lastname": "Disabilita {firstName} {lastName}",
"are_you_sure_you_want_to_disable_this_user": "Sei sicuro di voler disabilitare questo utente? Non sarà in grado di accedere o utilizzare qualsiasi servizio.",
"ldap_soft_delete_users": "Mantieni gli utenti disabilitati da LDAP.",
"ldap_soft_delete_users_description": "Se abilitato, gli utenti rimossi da LDAP saranno disabilitati invece che cancellati dal sistema.",
"login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email",
"show_code": "Show Code",
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API Key Expiration",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire."
}

View File

@@ -156,7 +156,7 @@
"actions": "Acties", "actions": "Acties",
"images_updated_successfully": "Afbeeldingen succesvol bijgewerkt", "images_updated_successfully": "Afbeeldingen succesvol bijgewerkt",
"general": "Algemeen", "general": "Algemeen",
"enable_email_notifications_to_alert_users_when_a_login_is_detected_from_a_new_device_or_location": "Schakel e-mailmeldingen in om gebruikers te waarschuwen wanneer er wordt ingelogd vanaf een nieuw apparaat of een nieuwe locatie.", "configure_smtp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.",
"ldap": "LDAP", "ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configureer LDAP-instellingen om gebruikers en groepen vanaf een LDAP-server te synchroniseren.", "configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configureer LDAP-instellingen om gebruikers en groepen vanaf een LDAP-server te synchroniseren.",
"images": "Afbeeldingen", "images": "Afbeeldingen",
@@ -180,7 +180,10 @@
"enabled_emails": "Ingeschakelde e-mails", "enabled_emails": "Ingeschakelde e-mails",
"email_login_notification": "E-mail-inlogmelding", "email_login_notification": "E-mail-inlogmelding",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Stuur een e-mail naar de gebruiker wanneer deze zich aanmeldt vanaf een nieuw apparaat.", "send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Stuur een e-mail naar de gebruiker wanneer deze zich aanmeldt vanaf een nieuw apparaat.",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Hiermee kunnen gebruikers inloggen met een inlogcode die naar hun e-mail is gestuurd. Dit vermindert de beveiliging aanzienlijk, omdat iedereen met toegang tot de e-mail van de gebruiker toegang kan krijgen.", "emai_login_code_requested_by_user": "Email Login Code Requested by User",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
"email_login_code_from_admin": "Email Login Code from Admin",
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
"send_test_email": "Test-e-mail verzenden", "send_test_email": "Test-e-mail verzenden",
"application_configuration_updated_successfully": "Toepassingsconfiguratie succesvol bijgewerkt", "application_configuration_updated_successfully": "Toepassingsconfiguratie succesvol bijgewerkt",
"application_name": "Toepassingsnaam", "application_name": "Toepassingsnaam",
@@ -322,5 +325,22 @@
"see_all_account_activities_from_the_last_3_months": "Bekijk alle gebruikersactiviteit van de afgelopen 3 maanden.", "see_all_account_activities_from_the_last_3_months": "Bekijk alle gebruikersactiviteit van de afgelopen 3 maanden.",
"token_sign_in": "Token Sign In", "token_sign_in": "Token Sign In",
"client_authorization": "Client autorisatie", "client_authorization": "Client autorisatie",
"new_client_authorization": "Nieuwe clientautorisatie" "new_client_authorization": "Nieuwe clientautorisatie",
"disable_animations": "Disable Animations",
"turn_off_all_animations_throughout_the_admin_ui": "Turn off all animations throughout the Admin UI.",
"user_disabled": "Account Disabled",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
"user_disabled_successfully": "User has been disabled successfully.",
"user_enabled_successfully": "User has been enabled successfully.",
"status": "Status",
"disable_firstname_lastname": "Disable {firstName} {lastName}",
"are_you_sure_you_want_to_disable_this_user": "Are you sure you want to disable this user? They will not be able to log in or access any services.",
"ldap_soft_delete_users": "Keep disabled users from LDAP.",
"ldap_soft_delete_users_description": "When enabled, users removed from LDAP will be disabled rather than deleted from the system.",
"login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email",
"show_code": "Show Code",
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API Key Expiration",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire."
} }

View File

@@ -156,7 +156,7 @@
"actions": "Ações", "actions": "Ações",
"images_updated_successfully": "Imagens atualizadas com sucesso", "images_updated_successfully": "Imagens atualizadas com sucesso",
"general": "Geral", "general": "Geral",
"enable_email_notifications_to_alert_users_when_a_login_is_detected_from_a_new_device_or_location": "Enable email notifications to alert users when a login is detected from a new device or location.", "configure_smtp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.",
"ldap": "LDAP", "ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configure LDAP settings to sync users and groups from an LDAP server.", "configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configure LDAP settings to sync users and groups from an LDAP server.",
"images": "Imagens", "images": "Imagens",
@@ -180,7 +180,10 @@
"enabled_emails": "Enabled Emails", "enabled_emails": "Enabled Emails",
"email_login_notification": "Email Login Notification", "email_login_notification": "Email Login Notification",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Send an email to the user when they log in from a new device.", "send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Send an email to the user when they log in from a new device.",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to sign in with a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.", "emai_login_code_requested_by_user": "Email Login Code Requested by User",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
"email_login_code_from_admin": "Email Login Code from Admin",
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
"send_test_email": "Send test email", "send_test_email": "Send test email",
"application_configuration_updated_successfully": "Application configuration updated successfully", "application_configuration_updated_successfully": "Application configuration updated successfully",
"application_name": "Application Name", "application_name": "Application Name",
@@ -322,5 +325,22 @@
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.", "see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
"token_sign_in": "Token Sign In", "token_sign_in": "Token Sign In",
"client_authorization": "Client Authorization", "client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization" "new_client_authorization": "New Client Authorization",
"disable_animations": "Disable Animations",
"turn_off_all_animations_throughout_the_admin_ui": "Turn off all animations throughout the Admin UI.",
"user_disabled": "Account Disabled",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
"user_disabled_successfully": "User has been disabled successfully.",
"user_enabled_successfully": "User has been enabled successfully.",
"status": "Status",
"disable_firstname_lastname": "Disable {firstName} {lastName}",
"are_you_sure_you_want_to_disable_this_user": "Are you sure you want to disable this user? They will not be able to log in or access any services.",
"ldap_soft_delete_users": "Keep disabled users from LDAP.",
"ldap_soft_delete_users_description": "When enabled, users removed from LDAP will be disabled rather than deleted from the system.",
"login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email",
"show_code": "Show Code",
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API Key Expiration",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire."
} }

View File

@@ -156,7 +156,7 @@
"actions": "Actions", "actions": "Actions",
"images_updated_successfully": "Images updated successfully", "images_updated_successfully": "Images updated successfully",
"general": "General", "general": "General",
"enable_email_notifications_to_alert_users_when_a_login_is_detected_from_a_new_device_or_location": "Enable email notifications to alert users when a login is detected from a new device or location.", "configure_smtp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.",
"ldap": "LDAP", "ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configure LDAP settings to sync users and groups from an LDAP server.", "configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configure LDAP settings to sync users and groups from an LDAP server.",
"images": "Images", "images": "Images",
@@ -180,7 +180,10 @@
"enabled_emails": "Enabled Emails", "enabled_emails": "Enabled Emails",
"email_login_notification": "Email Login Notification", "email_login_notification": "Email Login Notification",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Send an email to the user when they log in from a new device.", "send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Send an email to the user when they log in from a new device.",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to sign in with a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.", "emai_login_code_requested_by_user": "Email Login Code Requested by User",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
"email_login_code_from_admin": "Email Login Code from Admin",
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
"send_test_email": "Send test email", "send_test_email": "Send test email",
"application_configuration_updated_successfully": "Application configuration updated successfully", "application_configuration_updated_successfully": "Application configuration updated successfully",
"application_name": "Application Name", "application_name": "Application Name",
@@ -322,5 +325,22 @@
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.", "see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
"token_sign_in": "Token Sign In", "token_sign_in": "Token Sign In",
"client_authorization": "Client Authorization", "client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization" "new_client_authorization": "New Client Authorization",
"disable_animations": "Disable Animations",
"turn_off_all_animations_throughout_the_admin_ui": "Turn off all animations throughout the Admin UI.",
"user_disabled": "Account Disabled",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
"user_disabled_successfully": "User has been disabled successfully.",
"user_enabled_successfully": "User has been enabled successfully.",
"status": "Status",
"disable_firstname_lastname": "Disable {firstName} {lastName}",
"are_you_sure_you_want_to_disable_this_user": "Are you sure you want to disable this user? They will not be able to log in or access any services.",
"ldap_soft_delete_users": "Keep disabled users from LDAP.",
"ldap_soft_delete_users_description": "When enabled, users removed from LDAP will be disabled rather than deleted from the system.",
"login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email",
"show_code": "Show Code",
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API Key Expiration",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire."
} }

View File

@@ -156,7 +156,7 @@
"actions": "Действия", "actions": "Действия",
"images_updated_successfully": "Изображения успешно обновлены", "images_updated_successfully": "Изображения успешно обновлены",
"general": "Основное", "general": "Основное",
"enable_email_notifications_to_alert_users_when_a_login_is_detected_from_a_new_device_or_location": "Включить уведомления пользователей по электронной почте при обнаружении логина с нового устройства или локации.", "configure_smtp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.",
"ldap": "LDAP", "ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Настроить конфигурацию LDAP для синхронизации пользователей и групп с сервером LDAP.", "configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Настроить конфигурацию LDAP для синхронизации пользователей и групп с сервером LDAP.",
"images": "Изображения", "images": "Изображения",
@@ -180,7 +180,10 @@
"enabled_emails": "Отправляемые письма", "enabled_emails": "Отправляемые письма",
"email_login_notification": "Уведомление о логине по электронной почте", "email_login_notification": "Уведомление о логине по электронной почте",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Отправлять пользователю письмо при входе с нового устройства.", "send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Отправлять пользователю письмо при входе с нового устройства.",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Позволяет пользователям войти с помощью кода входа, отправленного на их электронную почту. Это значительно снижает безопасность так как любой человек, имеющий доступ к электронной почте пользователя, сможет получить доступ.", "emai_login_code_requested_by_user": "Email Login Code Requested by User",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
"email_login_code_from_admin": "Email Login Code from Admin",
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
"send_test_email": "Отправить тестовое письмо", "send_test_email": "Отправить тестовое письмо",
"application_configuration_updated_successfully": "Конфигурация приложения успешно обновлена", "application_configuration_updated_successfully": "Конфигурация приложения успешно обновлена",
"application_name": "Название приложения", "application_name": "Название приложения",
@@ -322,5 +325,22 @@
"see_all_account_activities_from_the_last_3_months": "Смотрите всю активность пользователей за последние 3 месяца.", "see_all_account_activities_from_the_last_3_months": "Смотрите всю активность пользователей за последние 3 месяца.",
"token_sign_in": "Вход с помощью токена", "token_sign_in": "Вход с помощью токена",
"client_authorization": "Авторизация в клиенте", "client_authorization": "Авторизация в клиенте",
"new_client_authorization": "Новая авторизация в клиенте" "new_client_authorization": "Новая авторизация в клиенте",
"disable_animations": "Отключить анимации",
"turn_off_all_animations_throughout_the_admin_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": "The login code has been sent to the user.",
"send_email": "Send Email",
"show_code": "Show Code",
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API Key Expiration",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire."
} }

View File

@@ -1,12 +1,12 @@
{ {
"name": "pocket-id-frontend", "name": "pocket-id-frontend",
"version": "0.45.0", "version": "0.46.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pocket-id-frontend", "name": "pocket-id-frontend",
"version": "0.45.0", "version": "0.46.0",
"dependencies": { "dependencies": {
"@simplewebauthn/browser": "^13.1.0", "@simplewebauthn/browser": "^13.1.0",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
@@ -17,6 +17,7 @@
"jose": "^5.9.6", "jose": "^5.9.6",
"lucide-svelte": "^0.487.0", "lucide-svelte": "^0.487.0",
"mode-watcher": "^0.5.1", "mode-watcher": "^0.5.1",
"qrcode": "^1.5.4",
"svelte-sonner": "^0.3.28", "svelte-sonner": "^0.3.28",
"sveltekit-superforms": "^2.23.1", "sveltekit-superforms": "^2.23.1",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
@@ -31,10 +32,11 @@
"@playwright/test": "^1.50.0", "@playwright/test": "^1.50.0",
"@sveltejs/adapter-auto": "^4.0.0", "@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/adapter-node": "^5.2.12", "@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.16.1", "@sveltejs/kit": "^2.20.7",
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/eslint": "^9.6.1", "@types/eslint": "^9.6.1",
"@types/node": "^22.10.10", "@types/node": "^22.10.10",
"@types/qrcode": "^1.5.5",
"bits-ui": "^0.22.0", "bits-ui": "^0.22.0",
"cmdk-sv": "^0.0.19", "cmdk-sv": "^0.0.19",
"eslint": "^9.19.0", "eslint": "^9.19.0",
@@ -1407,9 +1409,10 @@
} }
}, },
"node_modules/@sveltejs/kit": { "node_modules/@sveltejs/kit": {
"version": "2.16.1", "version": "2.20.7",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.16.1.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.20.7.tgz",
"integrity": "sha512-2pF5sgGJx9brYZ/9nNDYnh5KX0JguPF14dnvvtf/MqrvlWrDj/e7Rk3LBJPecFLLK1GRs6ZniD24gFPqZm/NFw==", "integrity": "sha512-dVbLMubpJJSLI4OYB+yWYNHGAhgc2bVevWuBjDj8jFUXIJOAnLwYP3vsmtcgoxNGUXoq0rHS5f7MFCsryb6nzg==",
"license": "MIT",
"dependencies": { "dependencies": {
"@types/cookie": "^0.6.0", "@types/cookie": "^0.6.0",
"cookie": "^0.6.0", "cookie": "^0.6.0",
@@ -1725,6 +1728,15 @@
"undici-types": "~6.20.0" "undici-types": "~6.20.0"
} }
}, },
"node_modules/@types/qrcode": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz",
"integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/resolve": { "node_modules/@types/resolve": {
"version": "1.20.2", "version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@@ -2036,11 +2048,19 @@
"url": "https://github.com/sponsors/epoberezkin" "url": "https://github.com/sponsors/epoberezkin"
} }
}, },
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": { "node_modules/ansi-styles": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"dependencies": { "dependencies": {
"color-convert": "^2.0.1" "color-convert": "^2.0.1"
}, },
@@ -2237,6 +2257,17 @@
"validator": "^13.9.0" "validator": "^13.9.0"
} }
}, },
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -2296,7 +2327,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
}, },
@@ -2307,8 +2337,7 @@
"node_modules/color-name": { "node_modules/color-name": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
"dev": true
}, },
"node_modules/combined-stream": { "node_modules/combined-stream": {
"version": "1.0.8", "version": "1.0.8",
@@ -2439,6 +2468,15 @@
} }
} }
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dedent": { "node_modules/dedent": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz",
@@ -2501,6 +2539,12 @@
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz",
"integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==" "integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw=="
}, },
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dlv": { "node_modules/dlv": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@@ -2516,6 +2560,12 @@
"fast-check": "^3.23.1" "fast-check": "^3.23.1"
} }
}, },
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
"version": "5.18.0", "version": "5.18.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.0.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.0.tgz",
@@ -3080,6 +3130,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/glob-parent": { "node_modules/glob-parent": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -3223,6 +3282,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-glob": { "node_modules/is-glob": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -3858,6 +3926,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -3874,7 +3951,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -3941,6 +4017,15 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.3", "version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
@@ -4227,6 +4312,22 @@
], ],
"optional": true "optional": true
}, },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -4276,6 +4377,21 @@
"node": ">=0.10" "node": ">=0.10"
} }
}, },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.10", "version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -4398,6 +4514,12 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-cookie-parser": { "node_modules/set-cookie-parser": {
"version": "2.7.1", "version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
@@ -4476,6 +4598,32 @@
"kysely": "*" "kysely": "*"
} }
}, },
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-json-comments": { "node_modules/strip-json-comments": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -5128,6 +5276,12 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -5137,6 +5291,26 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz",
@@ -5150,6 +5324,102 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs-parser/node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "pocket-id-frontend", "name": "pocket-id-frontend",
"version": "0.46.0", "version": "0.49.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -22,6 +22,7 @@
"jose": "^5.9.6", "jose": "^5.9.6",
"lucide-svelte": "^0.487.0", "lucide-svelte": "^0.487.0",
"mode-watcher": "^0.5.1", "mode-watcher": "^0.5.1",
"qrcode": "^1.5.4",
"svelte-sonner": "^0.3.28", "svelte-sonner": "^0.3.28",
"sveltekit-superforms": "^2.23.1", "sveltekit-superforms": "^2.23.1",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
@@ -36,10 +37,11 @@
"@playwright/test": "^1.50.0", "@playwright/test": "^1.50.0",
"@sveltejs/adapter-auto": "^4.0.0", "@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/adapter-node": "^5.2.12", "@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.16.1", "@sveltejs/kit": "^2.20.7",
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/eslint": "^9.6.1", "@types/eslint": "^9.6.1",
"@types/node": "^22.10.10", "@types/node": "^22.10.10",
"@types/qrcode": "^1.5.5",
"bits-ui": "^0.22.0", "bits-ui": "^0.22.0",
"cmdk-sv": "^0.0.19", "cmdk-sv": "^0.0.19",
"eslint": "^9.19.0", "eslint": "^9.19.0",

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://inlang.com/schema/project-settings", "$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en-US", "baseLocale": "en-US",
"locales": ["en-US", "nl-NL", "ru-RU", "de-DE", "fr-FR", "cs-CZ", "pt-BR"], "locales": ["en-US", "nl-NL", "ru-RU", "de-DE", "fr-FR", "cs-CZ", "pt-BR", "it-IT"],
"modules": [ "modules": [
"./node_modules/@inlang/plugin-message-format/dist/index.js", "./node_modules/@inlang/plugin-message-format/dist/index.js",
"./node_modules/@inlang/plugin-m-function-matcher/dist/index.js" "./node_modules/@inlang/plugin-m-function-matcher/dist/index.js"

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
let { let {
@@ -12,7 +13,7 @@
children: Snippet; children: Snippet;
} = $props(); } = $props();
let containerNode: HTMLElement; let containerNode: HTMLElement | null = $state(null);
$effect(() => { $effect(() => {
page.route; page.route;
@@ -53,6 +54,10 @@
</style> </style>
</svelte:head> </svelte:head>
<div class="fade-wrapper" bind:this={containerNode}> {#if $appConfigStore.disableAnimations}
{@render children()} {@render children()}
</div> {:else}
<div class="fade-wrapper" bind:this={containerNode}>
{@render children()}
</div>
{/if}

View File

@@ -1,13 +1,18 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import Qrcode from '$lib/components/qrcode/qrcode.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog'; import * as Dialog from '$lib/components/ui/dialog';
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte'; import Label from '$lib/components/ui/label/label.svelte';
import * as Select from '$lib/components/ui/select/index.js'; import * as Select from '$lib/components/ui/select/index.js';
import { Separator } from '$lib/components/ui/separator';
import { m } from '$lib/paraglide/messages'; import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
import { mode } from 'mode-watcher';
import { toast } from 'svelte-sonner';
let { let {
userId = $bindable() userId = $bindable()
@@ -18,6 +23,7 @@
const userService = new UserService(); const userService = new UserService();
let oneTimeLink: string | null = $state(null); let oneTimeLink: string | null = $state(null);
let code: string | null = $state(null);
let selectedExpiration: keyof typeof availableExpirations = $state(m.one_hour()); let selectedExpiration: keyof typeof availableExpirations = $state(m.one_hour());
let availableExpirations = { let availableExpirations = {
@@ -28,11 +34,22 @@
[m.one_month()]: 60 * 60 * 24 * 30 [m.one_month()]: 60 * 60 * 24 * 30
}; };
async function createOneTimeAccessToken() { async function createLoginCode() {
try { try {
const expiration = new Date(Date.now() + availableExpirations[selectedExpiration] * 1000); const expiration = new Date(Date.now() + availableExpirations[selectedExpiration] * 1000);
const token = await userService.createOneTimeAccessToken(expiration, userId!); code = await userService.createOneTimeAccessToken(expiration, userId!);
oneTimeLink = `${page.url.origin}/lc/${token}`; oneTimeLink = `${page.url.origin}/lc/${code}`;
} catch (e) {
axiosErrorToast(e);
}
}
async function sendLoginCodeEmail() {
try {
const expiration = new Date(Date.now() + availableExpirations[selectedExpiration] * 1000);
await userService.requestOneTimeAccessEmailAsAdmin(userId!, expiration);
toast.success(m.login_code_email_success());
onOpenChange(false);
} catch (e) { } catch (e) {
axiosErrorToast(e); axiosErrorToast(e);
} }
@@ -41,6 +58,7 @@
function onOpenChange(open: boolean) { function onOpenChange(open: boolean) {
if (!open) { if (!open) {
oneTimeLink = null; oneTimeLink = null;
code = null;
userId = null; userId = null;
} }
} }
@@ -54,6 +72,7 @@
>{m.create_a_login_code_to_sign_in_without_a_passkey_once()}</Dialog.Description >{m.create_a_login_code_to_sign_in_without_a_passkey_once()}</Dialog.Description
> >
</Dialog.Header> </Dialog.Header>
{#if oneTimeLink === null} {#if oneTimeLink === null}
<div> <div>
<Label for="expiration">{m.expiration()}</Label> <Label for="expiration">{m.expiration()}</Label>
@@ -65,7 +84,7 @@
onSelectedChange={(v) => onSelectedChange={(v) =>
(selectedExpiration = v!.value as keyof typeof availableExpirations)} (selectedExpiration = v!.value as keyof typeof availableExpirations)}
> >
<Select.Trigger class="h-9 "> <Select.Trigger class="h-9 w-full">
<Select.Value>{selectedExpiration}</Select.Value> <Select.Value>{selectedExpiration}</Select.Value>
</Select.Trigger> </Select.Trigger>
<Select.Content> <Select.Content>
@@ -75,12 +94,43 @@
</Select.Content> </Select.Content>
</Select.Root> </Select.Root>
</div> </div>
<Button onclick={() => createOneTimeAccessToken()} disabled={!selectedExpiration}> <Dialog.Footer class="mt-2">
{m.generate_code()} {#if $appConfigStore.emailOneTimeAccessAsAdminEnabled}
</Button> <Button
onclick={() => sendLoginCodeEmail()}
variant="secondary"
disabled={!selectedExpiration}
>
{m.send_email()}
</Button>
{/if}
<Button onclick={() => createLoginCode()} disabled={!selectedExpiration}
>{m.show_code()}</Button
>
</Dialog.Footer>
{:else} {:else}
<Label for="login-code" class="sr-only">{m.login_code()}</Label> <div class="flex flex-col items-center gap-2">
<Input id="login-code" value={oneTimeLink} readonly /> <CopyToClipboard value={code!}>
<p class="text-3xl font-semibold">{code}</p>
</CopyToClipboard>
<div class="text-muted-foreground my-2 flex items-center justify-center gap-3">
<Separator />
<p class="text-nowrap text-xs">{m.or_visit()}</p>
<Separator />
</div>
<Qrcode
class="mb-2"
value={oneTimeLink}
size={180}
color={$mode === 'dark' ? '#FFFFFF' : '#000000'}
backgroundColor={$mode === 'dark' ? '#000000' : '#FFFFFF'}
/>
<CopyToClipboard value={oneTimeLink!}>
<p data-testId="login-code-link">{oneTimeLink!}</p>
</CopyToClipboard>
</div>
{/if} {/if}
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import { cn } from '$lib/utils/style';
import QRCode from 'qrcode';
import { onMount } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
let canvasEl: HTMLCanvasElement | null;
let {
value,
size = 200,
color = '#000000',
backgroundColor = '#FFFFFF',
...restProps
}: HTMLAttributes<HTMLCanvasElement> & {
value: string | null;
size?: number;
color?: string;
backgroundColor?: string;
} = $props();
onMount(() => {
if (value && canvasEl) {
// Convert "transparent" to a valid value for the QR code library
const lightColor = backgroundColor === 'transparent' ? '#00000000' : backgroundColor;
const options = {
width: size,
margin: 0,
color: {
dark: color,
light: lightColor
}
};
QRCode.toCanvas(canvasEl, value, options).catch((error: Error) => {
console.error('Error generating QR Code:', error);
});
}
});
</script>
<canvas {...restProps} bind:this={canvasEl} class={cn('rounded-lg', restProps.class)}></canvas>

View File

@@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv"; import { Command as CommandPrimitive } from "cmdk-sv";
import { cn } from "$lib/utils/style.js"; import { cn } from "$lib/utils/style.js";
import type { ClassValue } from "svelte/elements";
type $$Props = CommandPrimitive.EmptyProps; type $$Props = CommandPrimitive.EmptyProps;
let className: string | undefined | null = undefined; let className: ClassValue | undefined | null = undefined;
export { className as class }; export { className as class };
</script> </script>

View File

@@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv"; import { Command as CommandPrimitive } from "cmdk-sv";
import { cn } from "$lib/utils/style.js"; import { cn } from "$lib/utils/style.js";
import type { ClassValue } from "svelte/elements";
type $$Props = CommandPrimitive.GroupProps; type $$Props = CommandPrimitive.GroupProps;
let className: string | undefined | null = undefined; let className: ClassValue | undefined | null = undefined;
export { className as class }; export { className as class };
</script> </script>

View File

@@ -1,12 +1,13 @@
<script lang="ts"> <script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv"; import { Command as CommandPrimitive } from "cmdk-sv";
import { cn } from "$lib/utils/style.js"; import { cn } from "$lib/utils/style.js";
import type { ClassValue } from "svelte/elements";
type $$Props = CommandPrimitive.ItemProps; type $$Props = CommandPrimitive.ItemProps;
export let asChild = false; export let asChild = false;
let className: string | undefined | null = undefined; let className: ClassValue | undefined | null = undefined;
export { className as class }; export { className as class };
</script> </script>

View File

@@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv"; import { Command as CommandPrimitive } from "cmdk-sv";
import { cn } from "$lib/utils/style.js"; import { cn } from "$lib/utils/style.js";
import type { ClassValue } from "svelte/elements";
type $$Props = CommandPrimitive.ListProps; type $$Props = CommandPrimitive.ListProps;
let className: string | undefined | null = undefined; let className: ClassValue | undefined | null = undefined;
export { className as class }; export { className as class };
</script> </script>

View File

@@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv"; import { Command as CommandPrimitive } from "cmdk-sv";
import { cn } from "$lib/utils/style.js"; import { cn } from "$lib/utils/style.js";
import type { ClassValue } from "svelte/elements";
type $$Props = CommandPrimitive.SeparatorProps; type $$Props = CommandPrimitive.SeparatorProps;
let className: string | undefined | null = undefined; let className: ClassValue | undefined | null = undefined;
export { className as class }; export { className as class };
</script> </script>

View File

@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from "svelte/elements"; import type { ClassValue, HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils/style.js"; import { cn } from "$lib/utils/style.js";
type $$Props = HTMLAttributes<HTMLSpanElement>; type $$Props = HTMLAttributes<HTMLSpanElement>;
let className: string | undefined | null = undefined; let className: ClassValue | undefined | null = undefined;
export { className as class }; export { className as class };
</script> </script>

View File

@@ -1,12 +1,13 @@
<script lang="ts"> <script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv"; import { Command as CommandPrimitive } from "cmdk-sv";
import { cn } from "$lib/utils/style.js"; import { cn } from "$lib/utils/style.js";
import type { ClassValue } from "svelte/elements";
type $$Props = CommandPrimitive.CommandProps; type $$Props = CommandPrimitive.CommandProps;
export let value: $$Props["value"] = undefined; export let value: $$Props["value"] = undefined;
let className: string | undefined | null = undefined; let className: ClassValue | undefined | null = undefined;
export { className as class }; export { className as class };
</script> </script>

View File

@@ -87,10 +87,14 @@ export default class UserService extends APIService {
return res.data as User; return res.data as User;
} }
async requestOneTimeAccessEmail(email: string, redirectPath?: string) { async requestOneTimeAccessEmailAsUnauthenticatedUser(email: string, redirectPath?: string) {
await this.api.post('/one-time-access-email', { email, redirectPath }); 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 updateUserGroups(id: string, userGroupIds: string[]) { async updateUserGroups(id: string, userGroupIds: string[]) {
const res = await this.api.put(`/users/${id}/user-groups`, { userGroupIds }); const res = await this.api.put(`/users/${id}/user-groups`, { userGroupIds });
return res.data as User; return res.data as User;

View File

@@ -1,8 +1,10 @@
export type AppConfig = { export type AppConfig = {
appName: string; appName: string;
allowOwnAccountEdit: boolean; allowOwnAccountEdit: boolean;
emailOneTimeAccessEnabled: boolean; emailOneTimeAccessAsUnauthenticatedEnabled: boolean;
emailOneTimeAccessAsAdminEnabled: boolean;
ldapEnabled: boolean; ldapEnabled: boolean;
disableAnimations: boolean;
}; };
export type AllAppConfig = AppConfig & { export type AllAppConfig = AppConfig & {
@@ -18,6 +20,7 @@ export type AllAppConfig = AppConfig & {
smtpTls: 'none' | 'starttls' | 'tls'; smtpTls: 'none' | 'starttls' | 'tls';
smtpSkipCertVerify: boolean; smtpSkipCertVerify: boolean;
emailLoginNotificationEnabled: boolean; emailLoginNotificationEnabled: boolean;
emailApiKeyExpirationEnabled: boolean;
// LDAP // LDAP
ldapUrl: string; ldapUrl: string;
ldapBindDn: string; ldapBindDn: string;
@@ -36,6 +39,7 @@ export type AllAppConfig = AppConfig & {
ldapAttributeGroupUniqueIdentifier: string; ldapAttributeGroupUniqueIdentifier: string;
ldapAttributeGroupName: string; ldapAttributeGroupName: string;
ldapAttributeAdminGroup: string; ldapAttributeAdminGroup: string;
ldapSoftDeleteUsers: boolean;
}; };
export type AppConfigRawResponse = { export type AppConfigRawResponse = {

View File

@@ -13,6 +13,7 @@ export type User = {
customClaims: CustomClaim[]; customClaims: CustomClaim[];
locale?: Locale; locale?: Locale;
ldapId?: string; ldapId?: string;
disabled?: boolean;
}; };
export type UserCreate = Omit<User, 'id' | 'customClaims' | 'ldapId' | 'userGroups'>; export type UserCreate = Omit<User, 'id' | 'customClaims' | 'ldapId' | 'userGroups'>;

View File

@@ -14,7 +14,10 @@ export function getAxiosErrorMessage(
return message; return message;
} }
export function axiosErrorToast(e: unknown, defaultMessage: string = m.an_unknown_error_occurred()) { export function axiosErrorToast(
e: unknown,
defaultMessage: string = m.an_unknown_error_occurred()
) {
const message = getAxiosErrorMessage(e, defaultMessage); const message = getAxiosErrorMessage(e, defaultMessage);
toast.error(message); toast.error(message);
} }
@@ -29,7 +32,8 @@ export function getWebauthnErrorMessage(e: unknown) {
m.authenticator_does_not_support_resident_keys(), m.authenticator_does_not_support_resident_keys(),
ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED: m.passkey_was_previously_registered(), ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED: m.passkey_was_previously_registered(),
ERROR_AUTHENTICATOR_NO_SUPPORTED_PUBKEYCREDPARAMS_ALG: ERROR_AUTHENTICATOR_NO_SUPPORTED_PUBKEYCREDPARAMS_ALG:
m.authenticator_does_not_support_any_of_the_requested_algorithms() m.authenticator_does_not_support_any_of_the_requested_algorithms(),
ERROR_USER_DISABLED_MSG: m.user_disabled()
}; };
let message = m.an_unknown_error_occurred(); let message = m.an_unknown_error_occurred();

View File

@@ -2,6 +2,7 @@
import SignInWrapper from '$lib/components/login-wrapper.svelte'; import SignInWrapper from '$lib/components/login-wrapper.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import OidcService from '$lib/services/oidc-service'; import OidcService from '$lib/services/oidc-service';
import WebAuthnService from '$lib/services/webauthn-service'; import WebAuthnService from '$lib/services/webauthn-service';
import appConfigStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
@@ -14,7 +15,6 @@
import type { PageData } from './$types'; import type { PageData } from './$types';
import ClientProviderImages from './components/client-provider-images.svelte'; import ClientProviderImages from './components/client-provider-images.svelte';
import ScopeItem from './components/scope-item.svelte'; import ScopeItem from './components/scope-item.svelte';
import { m } from '$lib/paraglide/messages';
const webauthnService = new WebAuthnService(); const webauthnService = new WebAuthnService();
const oidService = new OidcService(); const oidService = new OidcService();
@@ -84,7 +84,7 @@
{#if client == null} {#if client == null}
<p>{m.client_not_found()}</p> <p>{m.client_not_found()}</p>
{:else} {:else}
<SignInWrapper animate showAlternativeSignInMethodButton> <SignInWrapper animate={!$appConfigStore.disableAnimations} showAlternativeSignInMethodButton={$userStore == null}>
<ClientProviderImages {client} {success} error={!!errorMessage} /> <ClientProviderImages {client} {success} error={!!errorMessage} />
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl"> <h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">
{m.sign_in_to({ name: client.name })} {m.sign_in_to({ name: client.name })}

View File

@@ -36,7 +36,7 @@
<title>{m.sign_in()}</title> <title>{m.sign_in()}</title>
</svelte:head> </svelte:head>
<SignInWrapper animate showAlternativeSignInMethodButton> <SignInWrapper animate={!$appConfigStore.disableAnimations} showAlternativeSignInMethodButton>
<div class="flex justify-center"> <div class="flex justify-center">
<LoginLogoErrorSuccessIndicator error={!!error} /> <LoginLogoErrorSuccessIndicator error={!!error} />
</div> </div>

View File

@@ -17,7 +17,7 @@
} }
]; ];
if ($appConfigStore.emailOneTimeAccessEnabled) { if ($appConfigStore.emailOneTimeAccessAsUnauthenticatedEnabled) {
methods.push({ methods.push({
icon: LucideMail, icon: LucideMail,
title: m.email_login(), title: m.email_login(),
@@ -31,7 +31,7 @@
<title>{m.sign_in()}</title> <title>{m.sign_in()}</title>
</svelte:head> </svelte:head>
<SignInWrapper> <SignInWrapper animate={!$appConfigStore.disableAnimations}>
<div class="flex h-full flex-col justify-center"> <div class="flex h-full flex-col justify-center">
<div class="bg-muted mx-auto rounded-2xl p-3"> <div class="bg-muted mx-auto rounded-2xl p-3">
<Logo class="h-10 w-10" /> <Logo class="h-10 w-10" />

View File

@@ -20,7 +20,7 @@
async function requestEmail() { async function requestEmail() {
isLoading = true; isLoading = true;
await userService await userService
.requestOneTimeAccessEmail(email, data.redirect) .requestOneTimeAccessEmailAsUnauthenticatedUser(email, data.redirect)
.then(() => (success = true)) .then(() => (success = true))
.catch((e) => (error = e.response?.data.error || m.an_unknown_error_occurred())); .catch((e) => (error = e.response?.data.error || m.an_unknown_error_occurred()));

View File

@@ -29,7 +29,7 @@
} }
</script> </script>
<SignInWrapper> <SignInWrapper animate={!$appConfigStore.disableAnimations}>
<div class="flex justify-center"> <div class="flex justify-center">
<LoginLogoErrorSuccessIndicator error={!!error} /> <LoginLogoErrorSuccessIndicator error={!!error} />
</div> </div>

View File

@@ -5,6 +5,7 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages'; import { m } from '$lib/paraglide/messages';
import WebAuthnService from '$lib/services/webauthn-service'; import WebAuthnService from '$lib/services/webauthn-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store.js'; import userStore from '$lib/stores/user-store.js';
import { axiosErrorToast } from '$lib/utils/error-util.js'; import { axiosErrorToast } from '$lib/utils/error-util.js';
@@ -26,7 +27,7 @@
<title>{m.logout()}</title> <title>{m.logout()}</title>
</svelte:head> </svelte:head>
<SignInWrapper animate> <SignInWrapper animate={!$appConfigStore.disableAnimations}>
<div class="flex justify-center"> <div class="flex justify-center">
<div class="bg-muted rounded-2xl p-3"> <div class="bg-muted rounded-2xl p-3">
<Logo class="h-10 w-10" /> <Logo class="h-10 w-10" />

View File

@@ -2,7 +2,9 @@
import { page } from '$app/state'; import { page } from '$app/state';
import FadeWrapper from '$lib/components/fade-wrapper.svelte'; import FadeWrapper from '$lib/components/fade-wrapper.svelte';
import { m } from '$lib/paraglide/messages'; import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store'; import userStore from '$lib/stores/user-store';
import { cn } from '$lib/utils/style';
import { LucideExternalLink, LucideSettings } from 'lucide-svelte'; import { LucideExternalLink, LucideSettings } from 'lucide-svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { fade, fly } from 'svelte/transition'; import { fade, fly } from 'svelte/transition';
@@ -20,7 +22,7 @@
const links = [ const links = [
{ href: '/settings/account', label: m.my_account() }, { href: '/settings/account', label: m.my_account() },
{ href: '/settings/audit-log', label: m.audit_log() }, { href: '/settings/audit-log', label: m.audit_log() }
]; ];
const adminLinks = [ const adminLinks = [
@@ -54,11 +56,12 @@
{#each links as { href, label }, i} {#each links as { href, label }, i}
<a <a
{href} {href}
class={`animate-fade-in ${ class={cn(
!$appConfigStore.disableAnimations && 'animate-fade-in',
page.url.pathname.startsWith(href) page.url.pathname.startsWith(href)
? 'text-primary bg-card rounded-md px-3 py-1.5 font-medium shadow-sm transition-all' ? 'text-primary bg-card rounded-md px-3 py-1.5 font-medium shadow-sm transition-all'
: 'hover:text-foreground hover:bg-muted/70 rounded-md px-3 py-1.5 transition-all hover:-translate-y-[2px] hover:shadow-sm' : 'hover:text-foreground hover:bg-muted/70 rounded-md px-3 py-1.5 transition-all hover:-translate-y-[2px] hover:shadow-sm'
}`} )}
style={`animation-delay: ${150 + i * 75}ms;`} style={`animation-delay: ${150 + i * 75}ms;`}
> >
{label} {label}

View File

@@ -14,15 +14,16 @@
'fr-FR': 'Français', 'fr-FR': 'Français',
'nl-NL': 'Nederlands', 'nl-NL': 'Nederlands',
'pt-BR': 'Português brasileiro', 'pt-BR': 'Português brasileiro',
'ru-RU': 'Русский' 'ru-RU': 'Русский',
'it-IT': 'Italiano'
}; };
function updateLocale(locale: Locale) { async function updateLocale(locale: Locale) {
setLocale(locale); await userService.updateCurrent({
userService.updateCurrent({
...$userStore!, ...$userStore!,
locale locale
}); });
setLocale(locale);
} }
</script> </script>

View File

@@ -1,11 +1,13 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte'; import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import Qrcode from '$lib/components/qrcode/qrcode.svelte';
import * as Dialog from '$lib/components/ui/dialog'; import * as Dialog from '$lib/components/ui/dialog';
import { Separator } from '$lib/components/ui/separator'; import { Separator } from '$lib/components/ui/separator';
import { m } from '$lib/paraglide/messages'; import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
import { mode } from 'mode-watcher';
let { let {
show = $bindable() show = $bindable()
@@ -16,13 +18,17 @@
const userService = new UserService(); const userService = new UserService();
let code: string | null = $state(null); let code: string | null = $state(null);
let loginCodeLink: string | null = $state(null);
$effect(() => { $effect(() => {
if (show) { if (show) {
const expiration = new Date(Date.now() + 15 * 60 * 1000); const expiration = new Date(Date.now() + 15 * 60 * 1000);
userService userService
.createOneTimeAccessToken(expiration, 'me') .createOneTimeAccessToken(expiration, 'me')
.then((c) => (code = c)) .then((c) => {
code = c;
loginCodeLink = page.url.origin + '/lc/' + code;
})
.catch((e) => axiosErrorToast(e)); .catch((e) => axiosErrorToast(e));
} }
}); });
@@ -48,16 +54,22 @@
<CopyToClipboard value={code!}> <CopyToClipboard value={code!}>
<p class="text-3xl font-semibold">{code}</p> <p class="text-3xl font-semibold">{code}</p>
</CopyToClipboard> </CopyToClipboard>
<div class="text-muted-foreground flex items-center justify-center gap-3"> <div class="text-muted-foreground my-2 flex items-center justify-center gap-3">
<Separator /> <Separator />
<p class="text-nowrap text-xs">{m.or_visit()}</p> <p class="text-nowrap text-xs">{m.or_visit()}</p>
<Separator /> <Separator />
</div> </div>
<div>
<CopyToClipboard value={page.url.origin + '/lc/' + code!}> <Qrcode
<p data-testId="login-code-link">{page.url.origin + '/lc/' + code!}</p> class="mb-2"
</CopyToClipboard> value={loginCodeLink}
</div> size={180}
color={$mode === 'dark' ? '#FFFFFF' : '#000000'}
backgroundColor={$mode === 'dark' ? '#000000' : '#FFFFFF'}
/>
<CopyToClipboard value={loginCodeLink!}>
<p data-testId="login-code-link">{loginCodeLink!}</p>
</CopyToClipboard>
</div> </div>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>

View File

@@ -73,7 +73,7 @@
id="application-configuration-email" id="application-configuration-email"
icon={Mail} icon={Mail}
title={m.email()} title={m.email()}
description={m.enable_email_notifications_to_alert_users_when_a_login_is_detected_from_a_new_device_or_location()} description={m.configure_smtp_to_send_emails()}
> >
<AppConfigEmailForm {appConfig} callback={updateAppConfig} /> <AppConfigEmailForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard> </CollapsibleCard>

View File

@@ -11,7 +11,7 @@
label, label,
image = $bindable(), image = $bindable(),
imageURL, imageURL,
accept = 'image/png, image/jpeg, image/svg+xml', accept = 'image/png, image/jpeg, image/svg+xml, image/gif',
forceColorScheme, forceColorScheme,
...restProps ...restProps
}: HTMLAttributes<HTMLDivElement> & { }: HTMLAttributes<HTMLDivElement> & {

View File

@@ -39,8 +39,10 @@
smtpFrom: z.string().email(), smtpFrom: z.string().email(),
smtpTls: z.enum(['none', 'starttls', 'tls']), smtpTls: z.enum(['none', 'starttls', 'tls']),
smtpSkipCertVerify: z.boolean(), smtpSkipCertVerify: z.boolean(),
emailOneTimeAccessEnabled: z.boolean(), emailOneTimeAccessAsUnauthenticatedEnabled: z.boolean(),
emailLoginNotificationEnabled: z.boolean() emailOneTimeAccessAsAdminEnabled: z.boolean(),
emailLoginNotificationEnabled: z.boolean(),
emailApiKeyExpirationEnabled: z.boolean()
}); });
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, appConfig); const { inputs, ...form } = createForm<typeof formSchema>(formSchema, appConfig);
@@ -88,9 +90,7 @@
await appConfigService await appConfigService
.sendTestEmail() .sendTestEmail()
.then(() => toast.success(m.test_email_sent_successfully())) .then(() => toast.success(m.test_email_sent_successfully()))
.catch(() => .catch(() => toast.error(m.failed_to_send_test_email()))
toast.error(m.failed_to_send_test_email())
)
.finally(() => (isSendingTestEmail = false)); .finally(() => (isSendingTestEmail = false));
} }
</script> </script>
@@ -135,11 +135,24 @@
description={m.send_an_email_to_the_user_when_they_log_in_from_a_new_device()} description={m.send_an_email_to_the_user_when_they_log_in_from_a_new_device()}
bind:checked={$inputs.emailLoginNotificationEnabled.value} bind:checked={$inputs.emailLoginNotificationEnabled.value}
/> />
<CheckboxWithLabel <CheckboxWithLabel
id="email-login" id="email-login-admin"
label={m.email_login()} label={m.email_login_code_from_admin()}
description={m.allows_an_admin_to_send_a_login_code_to_the_user()}
bind:checked={$inputs.emailOneTimeAccessAsAdminEnabled.value}
/>
<CheckboxWithLabel
id="api-key-expiration"
label={m.api_key_expiration()}
description={m.send_an_email_to_the_user_when_their_api_key_is_about_to_expire()}
bind:checked={$inputs.emailApiKeyExpirationEnabled.value}
/>
<CheckboxWithLabel
id="email-login-user"
label={m.emai_login_code_requested_by_user()}
description={m.allow_users_to_sign_in_with_a_login_code_sent_to_their_email()} description={m.allow_users_to_sign_in_with_a_login_code_sent_to_their_email()}
bind:checked={$inputs.emailOneTimeAccessEnabled.value} bind:checked={$inputs.emailOneTimeAccessAsUnauthenticatedEnabled.value}
/> />
</div> </div>
</fieldset> </fieldset>

View File

@@ -24,14 +24,16 @@
appName: appConfig.appName, appName: appConfig.appName,
sessionDuration: appConfig.sessionDuration, sessionDuration: appConfig.sessionDuration,
emailsVerified: appConfig.emailsVerified, emailsVerified: appConfig.emailsVerified,
allowOwnAccountEdit: appConfig.allowOwnAccountEdit allowOwnAccountEdit: appConfig.allowOwnAccountEdit,
disableAnimations: appConfig.disableAnimations
}; };
const formSchema = z.object({ const formSchema = z.object({
appName: z.string().min(2).max(30), appName: z.string().min(2).max(30),
sessionDuration: z.number().min(1).max(43200), sessionDuration: z.number().min(1).max(43200),
emailsVerified: z.boolean(), emailsVerified: z.boolean(),
allowOwnAccountEdit: z.boolean() allowOwnAccountEdit: z.boolean(),
disableAnimations: z.boolean()
}); });
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig); const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
@@ -66,6 +68,12 @@
description={m.whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients()} description={m.whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients()}
bind:checked={$inputs.emailsVerified.value} bind:checked={$inputs.emailsVerified.value}
/> />
<CheckboxWithLabel
id="disable-animations"
label={m.disable_animations()}
description={m.turn_off_all_animations_throughout_the_admin_ui()}
bind:checked={$inputs.disableAnimations.value}
/>
</div> </div>
<div class="mt-5 flex justify-end"> <div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">{m.save()}</Button> <Button {isLoading} type="submit">{m.save()}</Button>

View File

@@ -43,7 +43,8 @@
ldapAttributeGroupMember: appConfig.ldapAttributeGroupMember, ldapAttributeGroupMember: appConfig.ldapAttributeGroupMember,
ldapAttributeGroupUniqueIdentifier: appConfig.ldapAttributeGroupUniqueIdentifier, ldapAttributeGroupUniqueIdentifier: appConfig.ldapAttributeGroupUniqueIdentifier,
ldapAttributeGroupName: appConfig.ldapAttributeGroupName, ldapAttributeGroupName: appConfig.ldapAttributeGroupName,
ldapAttributeAdminGroup: appConfig.ldapAttributeAdminGroup ldapAttributeAdminGroup: appConfig.ldapAttributeAdminGroup,
ldapSoftDeleteUsers: appConfig.ldapSoftDeleteUsers || true
}; };
const formSchema = z.object({ const formSchema = z.object({
@@ -63,7 +64,8 @@
ldapAttributeGroupMember: z.string(), ldapAttributeGroupMember: z.string(),
ldapAttributeGroupUniqueIdentifier: z.string().min(1), ldapAttributeGroupUniqueIdentifier: z.string().min(1),
ldapAttributeGroupName: z.string().min(1), ldapAttributeGroupName: z.string().min(1),
ldapAttributeAdminGroup: z.string() ldapAttributeAdminGroup: z.string(),
ldapSoftDeleteUsers: z.boolean()
}); });
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig); const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
@@ -116,7 +118,11 @@
placeholder="cn=people,dc=example,dc=com" placeholder="cn=people,dc=example,dc=com"
bind:input={$inputs.ldapBindDn} bind:input={$inputs.ldapBindDn}
/> />
<FormInput label={m.ldap_bind_password()} type="password" bind:input={$inputs.ldapBindPassword} /> <FormInput
label={m.ldap_bind_password()}
type="password"
bind:input={$inputs.ldapBindPassword}
/>
<FormInput <FormInput
label={m.ldap_base_dn()} label={m.ldap_base_dn()}
placeholder="dc=example,dc=com" placeholder="dc=example,dc=com"
@@ -140,6 +146,12 @@
description={m.this_can_be_useful_for_selfsigned_certificates()} description={m.this_can_be_useful_for_selfsigned_certificates()}
bind:checked={$inputs.ldapSkipCertVerify.value} bind:checked={$inputs.ldapSkipCertVerify.value}
/> />
<CheckboxWithLabel
id="ldap-soft-delete-users"
label={m.ldap_soft_delete_users()}
description={m.ldap_soft_delete_users_description()}
bind:checked={$inputs.ldapSoftDeleteUsers.value}
/>
</div> </div>
<h4 class="mt-10 text-lg font-semibold">{m.attribute_mapping()}</h4> <h4 class="mt-10 text-lg font-semibold">{m.attribute_mapping()}</h4>
<div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2"> <div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2">
@@ -203,7 +215,9 @@
<div class="mt-8 flex flex-wrap justify-end gap-3"> <div class="mt-8 flex flex-wrap justify-end gap-3">
{#if ldapEnabled} {#if ldapEnabled}
<Button variant="secondary" onclick={onDisable} disabled={uiConfigDisabled}>{m.disable()}</Button> <Button variant="secondary" onclick={onDisable} disabled={uiConfigDisabled}
>{m.disable()}</Button
>
<Button variant="secondary" onclick={syncLdap} isLoading={ldapSyncing}>{m.sync_now()}</Button> <Button variant="secondary" onclick={syncLdap} isLoading={ldapSyncing}>{m.sync_now()}</Button>
<Button type="submit" disabled={uiConfigDisabled}>{m.save()}</Button> <Button type="submit" disabled={uiConfigDisabled}>{m.save()}</Button>
{:else} {:else}

View File

@@ -20,12 +20,10 @@
allowEmpty?: boolean; allowEmpty?: boolean;
children?: Snippet; children?: Snippet;
} = $props(); } = $props();
const limit = 20;
</script> </script>
<div {...restProps}> <div {...restProps}>
<FormInput {label}> <FormInput {label} description={m.callback_url_description()}>
<div class="flex flex-col gap-y-2"> <div class="flex flex-col gap-y-2">
{#each callbackURLs as _, i} {#each callbackURLs as _, i}
<div class="flex gap-x-2"> <div class="flex gap-x-2">
@@ -46,15 +44,13 @@
{#if error} {#if error}
<p class="mt-1 text-sm text-red-500">{error}</p> <p class="mt-1 text-sm text-red-500">{error}</p>
{/if} {/if}
{#if callbackURLs.length < limit} <Button
<Button class="mt-2"
class="mt-2" variant="secondary"
variant="secondary" size="sm"
size="sm" on:click={() => (callbackURLs = [...callbackURLs, ''])}
on:click={() => (callbackURLs = [...callbackURLs, ''])} >
> <LucidePlus class="mr-1 h-4 w-4" />
<LucidePlus class="mr-1 h-4 w-4" /> {callbackURLs.length === 0 ? m.add() : m.add_another()}
{callbackURLs.length === 0 ? m.add() : m.add_another()} </Button>
</Button>
{/if}
</div> </div>

View File

@@ -4,6 +4,7 @@
import FormInput from '$lib/components/form/form-input.svelte'; import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import Label from '$lib/components/ui/label/label.svelte'; import Label from '$lib/components/ui/label/label.svelte';
import { m } from '$lib/paraglide/messages';
import type { import type {
OidcClient, OidcClient,
OidcClientCreate, OidcClientCreate,
@@ -12,7 +13,6 @@
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { z } from 'zod'; import { z } from 'zod';
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte'; import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
import { m } from '$lib/paraglide/messages';
let { let {
callback, callback,
@@ -38,8 +38,8 @@
const formSchema = z.object({ const formSchema = z.object({
name: z.string().min(2).max(50), name: z.string().min(2).max(50),
callbackURLs: z.array(z.string()).nonempty(), callbackURLs: z.array(z.string().nonempty()).nonempty(),
logoutCallbackURLs: z.array(z.string()), logoutCallbackURLs: z.array(z.string().nonempty()),
isPublic: z.boolean(), isPublic: z.boolean(),
pkceEnabled: z.boolean() pkceEnabled: z.boolean()
}); });
@@ -79,7 +79,7 @@
</script> </script>
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-7 sm:flex-row"> <div class="grid grid-cols-1 gap-x-3 gap-y-7 sm:flex-row md:grid-cols-2">
<FormInput label={m.name()} class="w-full" bind:input={$inputs.name} /> <FormInput label={m.name()} class="w-full" bind:input={$inputs.name} />
<div></div> <div></div>
<OidcCallbackUrlInput <OidcCallbackUrlInput
@@ -120,7 +120,7 @@
<img <img
class="m-auto max-h-full max-w-full object-contain" class="m-auto max-h-full max-w-full object-contain"
src={logoDataURL} src={logoDataURL}
alt={m.name_logo({name: $inputs.name.value})} alt={m.name_logo({ name: $inputs.name.value })}
/> />
</div> </div>
{/if} {/if}

View File

@@ -3,14 +3,13 @@
import { openConfirmDialog } from '$lib/components/confirm-dialog/'; import { openConfirmDialog } from '$lib/components/confirm-dialog/';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Table from '$lib/components/ui/table'; import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import OIDCService from '$lib/services/oidc-service'; import OIDCService from '$lib/services/oidc-service';
import type { OidcClient } from '$lib/types/oidc.type'; import type { OidcClient } from '$lib/types/oidc.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type'; import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
import { LucidePencil, LucideTrash } from 'lucide-svelte'; import { LucidePencil, LucideTrash } from 'lucide-svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import OneTimeLinkModal from './client-secret.svelte';
import { m } from '$lib/paraglide/messages';
let { let {
clients = $bindable(), clients = $bindable(),
@@ -20,8 +19,6 @@
requestOptions: SearchPaginationSortRequest; requestOptions: SearchPaginationSortRequest;
} = $props(); } = $props();
let oneTimeLink = $state<string | null>(null);
const oidcService = new OIDCService(); const oidcService = new OIDCService();
async function deleteClient(client: OidcClient) { async function deleteClient(client: OidcClient) {
@@ -86,5 +83,3 @@
</Table.Cell> </Table.Cell>
{/snippet} {/snippet}
</AdvancedTable> </AdvancedTable>
<OneTimeLinkModal {oneTimeLink} />

View File

@@ -24,7 +24,8 @@
lastName: existingUser?.lastName || '', lastName: existingUser?.lastName || '',
email: existingUser?.email || '', email: existingUser?.email || '',
username: existingUser?.username || '', username: existingUser?.username || '',
isAdmin: existingUser?.isAdmin || false isAdmin: existingUser?.isAdmin || false,
disabled: existingUser?.disabled || false
}; };
const formSchema = z.object({ const formSchema = z.object({
@@ -34,12 +35,10 @@
.string() .string()
.min(2) .min(2)
.max(30) .max(30)
.regex( .regex(/^[a-z0-9_@.-]+$/, m.username_can_only_contain()),
/^[a-z0-9_@.-]+$/,
m.username_can_only_contain()
),
email: z.string().email(), email: z.string().email(),
isAdmin: z.boolean() isAdmin: z.boolean(),
disabled: z.boolean()
}); });
type FormSchema = typeof formSchema; type FormSchema = typeof formSchema;
@@ -68,6 +67,12 @@
description={m.admins_have_full_access_to_the_admin_panel()} description={m.admins_have_full_access_to_the_admin_panel()}
bind:checked={$inputs.isAdmin.value} bind:checked={$inputs.isAdmin.value}
/> />
<CheckboxWithLabel
id="user-disabled"
label={m.user_disabled()}
description={m.disabled_users_cannot_log_in_or_use_services()}
bind:checked={$inputs.disabled.value}
/>
</div> </div>
<div class="mt-5 flex justify-end"> <div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">{m.save()}</Button> <Button {isLoading} type="submit">{m.save()}</Button>

View File

@@ -2,20 +2,26 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import AdvancedTable from '$lib/components/advanced-table.svelte'; import AdvancedTable from '$lib/components/advanced-table.svelte';
import { openConfirmDialog } from '$lib/components/confirm-dialog/'; import { openConfirmDialog } from '$lib/components/confirm-dialog/';
import OneTimeLinkModal from '$lib/components/one-time-link-modal.svelte';
import { Badge } from '$lib/components/ui/badge/index'; import { Badge } from '$lib/components/ui/badge/index';
import { buttonVariants } from '$lib/components/ui/button'; import { buttonVariants } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Table from '$lib/components/ui/table'; import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type'; import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { User } from '$lib/types/user.type'; import type { User } from '$lib/types/user.type';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideLink, LucidePencil, LucideTrash } from 'lucide-svelte'; import {
LucideLink,
LucidePencil,
LucideTrash,
LucideUserCheck,
LucideUserX
} from 'lucide-svelte';
import Ellipsis from 'lucide-svelte/icons/ellipsis'; import Ellipsis from 'lucide-svelte/icons/ellipsis';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import OneTimeLinkModal from '$lib/components/one-time-link-modal.svelte';
import { m } from '$lib/paraglide/messages';
let { let {
users = $bindable(), users = $bindable(),
@@ -28,7 +34,7 @@
async function deleteUser(user: User) { async function deleteUser(user: User) {
openConfirmDialog({ openConfirmDialog({
title: m.delete_firstname_lastname({firstName: user.firstName, lastName: user.lastName}), title: m.delete_firstname_lastname({ firstName: user.firstName, lastName: user.lastName }),
message: m.are_you_sure_you_want_to_delete_this_user(), message: m.are_you_sure_you_want_to_delete_this_user(),
confirm: { confirm: {
label: m.delete(), label: m.delete(),
@@ -45,6 +51,42 @@
} }
}); });
} }
async function enableUser(user: User) {
await userService
.update(user.id, {
...user,
disabled: false
})
.then(() => {
toast.success(m.user_enabled_successfully());
userService.list(requestOptions!).then((updatedUsers) => (users = updatedUsers));
})
.catch(axiosErrorToast);
}
async function disableUser(user: User) {
openConfirmDialog({
title: m.disable_firstname_lastname({ firstName: user.firstName, lastName: user.lastName }),
message: m.are_you_sure_you_want_to_disable_this_user(),
confirm: {
label: m.disable(),
destructive: true,
action: async () => {
try {
await userService.update(user.id, {
...user,
disabled: true
});
users = await userService.list(requestOptions!);
toast.success(m.user_disabled_successfully());
} catch (e) {
axiosErrorToast(e);
}
}
}
});
}
</script> </script>
<AdvancedTable <AdvancedTable
@@ -57,7 +99,8 @@
{ label: m.email(), sortColumn: 'email' }, { label: m.email(), sortColumn: 'email' },
{ label: m.username(), sortColumn: 'username' }, { label: m.username(), sortColumn: 'username' },
{ label: m.role(), sortColumn: 'isAdmin' }, { label: m.role(), sortColumn: 'isAdmin' },
...($appConfigStore.ldapEnabled ? [{ label: m.source()}] : []), { label: m.status(), sortColumn: 'disabled' },
...($appConfigStore.ldapEnabled ? [{ label: m.source() }] : []),
{ label: m.actions(), hidden: true } { label: m.actions(), hidden: true }
]} ]}
> >
@@ -69,9 +112,15 @@
<Table.Cell> <Table.Cell>
<Badge variant="outline">{item.isAdmin ? m.admin() : m.user()}</Badge> <Badge variant="outline">{item.isAdmin ? m.admin() : m.user()}</Badge>
</Table.Cell> </Table.Cell>
<Table.Cell>
<Badge variant={item.disabled ? 'destructive' : 'default'}>
{item.disabled ? m.disabled() : m.enabled()}
</Badge>
</Table.Cell>
{#if $appConfigStore.ldapEnabled} {#if $appConfigStore.ldapEnabled}
<Table.Cell> <Table.Cell>
<Badge variant={item.ldapId ? 'default' : 'outline'}>{item.ldapId ? m.ldap() : m.local()}</Badge <Badge variant={item.ldapId ? 'default' : 'outline'}
>{item.ldapId ? m.ldap() : m.local()}</Badge
> >
</Table.Cell> </Table.Cell>
{/if} {/if}
@@ -89,6 +138,17 @@
><LucidePencil class="mr-2 h-4 w-4" /> {m.edit()}</DropdownMenu.Item ><LucidePencil class="mr-2 h-4 w-4" /> {m.edit()}</DropdownMenu.Item
> >
{#if !item.ldapId || !$appConfigStore.ldapEnabled} {#if !item.ldapId || !$appConfigStore.ldapEnabled}
{#if item.disabled}
<DropdownMenu.Item onclick={() => enableUser(item)}
><LucideUserCheck class="mr-2 h-4 w-4" />{m.enable()}</DropdownMenu.Item
>
{:else}
<DropdownMenu.Item onclick={() => disableUser(item)}
><LucideUserX class="mr-2 h-4 w-4" />{m.disable()}</DropdownMenu.Item
>
{/if}
{/if}
{#if !item.ldapId || (item.ldapId && item.disabled)}
<DropdownMenu.Item <DropdownMenu.Item
class="text-red-500 focus:!text-red-700" class="text-red-500 focus:!text-red-700"
onclick={() => deleteUser(item)} onclick={() => deleteUser(item)}
@@ -101,4 +161,4 @@
{/snippet} {/snippet}
</AdvancedTable> </AdvancedTable>
<OneTimeLinkModal userId={userIdToCreateOneTimeLink} /> <OneTimeLinkModal bind:userId={userIdToCreateOneTimeLink} />

View File

@@ -1,7 +1,7 @@
import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants'; import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants';
import AuditLogService from '$lib/services/audit-log-service'; import AuditLogService from '$lib/services/audit-log-service';
import type { SearchPaginationSortRequest } from '$lib/types/pagination.type'; import type { SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { PageServerLoad } from '../../global-audit-log/$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => { export const load: PageServerLoad = async ({ cookies }) => {
const auditLogService = new AuditLogService(cookies.get(ACCESS_TOKEN_COOKIE_NAME)); const auditLogService = new AuditLogService(cookies.get(ACCESS_TOKEN_COOKIE_NAME));

View File

@@ -1,8 +1,8 @@
import test, { expect } from '@playwright/test'; import test, { expect } from '@playwright/test';
import { users } from './data'; import { users } from './data';
import authUtil from './utils/auth.util';
import { cleanupBackend } from './utils/cleanup.util'; import { cleanupBackend } from './utils/cleanup.util';
import passkeyUtil from './utils/passkey.util'; import passkeyUtil from './utils/passkey.util';
import authUtil from './utils/auth.util';
test.beforeEach(cleanupBackend); test.beforeEach(cleanupBackend);

View File

@@ -5,7 +5,7 @@ import { cleanupBackend } from './utils/cleanup.util';
test.describe('API Key Management', () => { test.describe('API Key Management', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await cleanupBackend() await cleanupBackend();
await page.goto('/settings/admin/api-keys'); await page.goto('/settings/admin/api-keys');
}); });

View File

@@ -32,7 +32,9 @@ test('Update email configuration', async ({ page }) => {
await page.getByLabel('SMTP Password').fill('password'); await page.getByLabel('SMTP Password').fill('password');
await page.getByLabel('SMTP From').fill('test@gmail.com'); await page.getByLabel('SMTP From').fill('test@gmail.com');
await page.getByLabel('Email Login Notification').click(); await page.getByLabel('Email Login Notification').click();
await page.getByLabel('Email Login', { exact: true }).click(); await page.getByLabel('Email Login Code Requested by User').click();
await page.getByLabel('Email Login Code from Admin').click();
await page.getByLabel('API Key Expiration').click();
await page.getByRole('button', { name: 'Save' }).nth(1).click(); await page.getByRole('button', { name: 'Save' }).nth(1).click();
@@ -46,7 +48,9 @@ test('Update email configuration', async ({ page }) => {
await expect(page.getByLabel('SMTP Password')).toHaveValue('password'); await expect(page.getByLabel('SMTP Password')).toHaveValue('password');
await expect(page.getByLabel('SMTP From')).toHaveValue('test@gmail.com'); await expect(page.getByLabel('SMTP From')).toHaveValue('test@gmail.com');
await expect(page.getByLabel('Email Login Notification')).toBeChecked(); await expect(page.getByLabel('Email Login Notification')).toBeChecked();
await expect(page.getByLabel('Email Login', { exact: true })).toBeChecked(); await expect(page.getByLabel('Email Login Code Requested by User')).toBeChecked();
await expect(page.getByLabel('Email Login Code from Admin')).toBeChecked();
await expect(page.getByLabel('API Key Expiration')).toBeChecked();
}); });
test('Update LDAP configuration', async ({ page }) => { test('Update LDAP configuration', async ({ page }) => {

View File

@@ -88,13 +88,13 @@ export const refreshTokens = [
export const idTokens = [ export const idTokens = [
{ {
token: token:
'eyJhbGciOiJSUzI1NiIsImtpZCI6Ijh1SER3M002cmY4IiwidHlwIjoiaWQrand0In0.eyJhdWQiOiIzNjU0YTc0Ni0zNWQ0LTQzMjEtYWM2MS0wYmRjZmYyYjQwNTUiLCJlbWFpbCI6InRpbS5jb29rQHRlc3QuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MTY5MDAwMDAwMSwiZmFtaWx5X25hbWUiOiJUaW0iLCJnaXZlbl9uYW1lIjoiQ29vayIsImlhdCI6MTY5MDAwMDAwMCwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdCIsIm5hbWUiOiJUaW0gQ29vayIsIm5vbmNlIjoib1cxQTFPNzhHUTE1RDczT3NIRXg3V1FLajdacXZITFp1XzM3bWRYSXFBUSIsInN1YiI6IjRiODlkYzItNjJmYi00NmJmLTlmNWYtYzM0ZjRlYWZlOTNlIn0.LHwNnp9WxFc_NbIVsBC41trA-1LUBxTfKwIqfgGP4WC5j39M2Rmc0G4rw7J96tfwyEobwgPFAP0YJ3BqMaZgHT4Zu0rYSenU-yv_CICWiLL4csyeojlqbqDKDiOD3Gsl4_ZUuo8UuN190RGz6HlxmTwxpmceerSFpx6dBtA6chYZfgnUf289DRWIgTsNrXnkohZRa8zWc8bjbw_hj1u7H6Ev9Yu3U2k4K0cHWZLFjQiPWt3JBaWNAldSEn2q7a3Rkyv17_Gx8Nwl5L4ugWKV8M1YkcHbEkYCJKaJCbZi13R89yH1E0EOfHYXK5Z0KqBq47eTYRGRUtFiP-uTlUDQUQ', 'eyJhbGciOiJSUzI1NiIsImtpZCI6Ijh1SER3M002cmY4IiwidHlwIjoiSldUIn0.eyJhdWQiOiIzNjU0YTc0Ni0zNWQ0LTQzMjEtYWM2MS0wYmRjZmYyYjQwNTUiLCJlbWFpbCI6InRpbS5jb29rQHRlc3QuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MTY5MDAwMDAwMSwiZmFtaWx5X25hbWUiOiJUaW0iLCJnaXZlbl9uYW1lIjoiQ29vayIsImlhdCI6MTY5MDAwMDAwMCwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdCIsIm5hbWUiOiJUaW0gQ29vayIsIm5vbmNlIjoib1cxQTFPNzhHUTE1RDczT3NIRXg3V1FLajdacXZITFp1XzM3bWRYSXFBUSIsInN1YiI6IjRiODlkYzItNjJmYi00NmJmLTlmNWYtYzM0ZjRlYWZlOTNlIiwidHlwZSI6ImlkLXRva2VuIn0.noxQ-sCNHh7f8EaySJT7oF0DlmjYcM-FdMPH45Yuuvt5-bTpLLkggN9aq8RILmkGL9xUVsfZbYkWV5EkGobxfIoXITE98xH54BQwtpOjLL_HZLF4kFXarUyGLGO3zeVJAQzyofVz_1rKfDlZdi5Zmm-91cO5OiOtshfluDqt1h1D-E5h4ShT0eN7apvSvQnD7806-3tfxP0GHE-HuerR1Qbv9p0uWmuhT0CkVIM-K2dKBHdhLtquRqxNp2EuD_T-HA3WJgvkTTWp-JZ6NqvWDMy3M-jB-_Bs9eABERlTSTp7H2XCMGbwRSBZDmSn-97LPwc-NO5JYEkgZOeVr_r6qg',
clientId: oidcClients.nextcloud.id, clientId: oidcClients.nextcloud.id,
expired: true expired: true
}, },
{ {
token: token:
'eyJhbGciOiJSUzI1NiIsImtpZCI6Ijh1SER3M002cmY4IiwidHlwIjoiaWQrand0In0.eyJhdWQiOiIzNjU0YTc0Ni0zNWQ0LTQzMjEtYWM2MS0wYmRjZmYyYjQwNTUiLCJlbWFpbCI6InRpbS5jb29rQHRlc3QuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MjY5MDAwMDAwMSwiZmFtaWx5X25hbWUiOiJUaW0iLCJnaXZlbl9uYW1lIjoiQ29vayIsImlhdCI6MTY5MDAwMDAwMCwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdCIsIm5hbWUiOiJUaW0gQ29vayIsIm5vbmNlIjoib1cxQTFPNzhHUTE1RDczT3NIRXg3V1FLajdacXZITFp1XzM3bWRYSXFBUSIsInN1YiI6IjRiODlkYzItNjJmYi00NmJmLTlmNWYtYzM0ZjRlYWZlOTNlIn0.yG21sES1HMyQg6GeJtd-6sUJ5a_QBS-hHq3mDTjRoMkL604RxprPvIJ-ypYhzcV5LwlTiD-7jJQ2Z95uUb82aNek55V5Pzq_rcLM5EtHh2bHSegt_1QXcpBzl8mWB1AIZBSRzFDaB1msnkyxGnndJk4VHpUVStvubcldxksH3e9v286x9ak4oTNoaLy4kMi4KAE8WCwrqsYc1iieLOSFTRHjpM9YxWa8X9hGNsikC85NJ0tj1pG9I4QTG62h4ZqJ4-jFWe5dogg_vd9Sk7tA3f9S779XSCG6hpj1V-sxQqLCy9uAmB2URP4N60jamKTn2TCxc1R7xgQ7M9Rc9ty68g', 'eyJhbGciOiJSUzI1NiIsImtpZCI6Ijh1SER3M002cmY4IiwidHlwIjoiSldUIn0.eyJhdWQiOiIzNjU0YTc0Ni0zNWQ0LTQzMjEtYWM2MS0wYmRjZmYyYjQwNTUiLCJlbWFpbCI6InRpbS5jb29rQHRlc3QuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MjY5MDAwMDAwMSwiZmFtaWx5X25hbWUiOiJUaW0iLCJnaXZlbl9uYW1lIjoiQ29vayIsImlhdCI6MTY5MDAwMDAwMCwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdCIsIm5hbWUiOiJUaW0gQ29vayIsIm5vbmNlIjoib1cxQTFPNzhHUTE1RDczT3NIRXg3V1FLajdacXZITFp1XzM3bWRYSXFBUSIsInN1YiI6IjRiODlkYzItNjJmYi00NmJmLTlmNWYtYzM0ZjRlYWZlOTNlIiwidHlwZSI6ImlkLXRva2VuIn0.ry7s3xP4-vvvZPzcRCvR1yBl32Pi09ryC6Z-67E1P4xChe8MaMoiQgImS5ZNbZiYzBN4cdkQsExXZK1FP-kMD019k3uNKPq0fIREBwrT9wXPqQJlLSBmN-tVkjLm90-b310SG5p65aajWvMkcPmJleG6y24_zoPFr3ISGI87vV6zdyoqG55pc-GkT7FwiEFIZJGQAzl7u1uOi7sQrda8Y6rF_SCC-f9I4PnHblnaTne8pfXe9jXKJeY1ZKj2Qh9dRPhWCLPHHV1YErUyoMP9oeMVzYpno-pBYVOiT9Ktl6CpG-jqB8smKqDEhZrSejgZ256h34f8jNL1SEhpM-4_cQ',
clientId: oidcClients.nextcloud.id, clientId: oidcClients.nextcloud.id,
expired: false expired: false
} }
@@ -103,13 +103,13 @@ export const idTokens = [
export const accessTokens = [ export const accessTokens = [
{ {
token: token:
'eyJhbGciOiJSUzI1NiIsImtpZCI6Ijh1SER3M002cmY4IiwidHlwIjoiYXQrand0In0.eyJhdWQiOlsiMzY1NGE3NDYtMzVkNC00MzIxLWFjNjEtMGJkY2ZmMmI0MDU1Il0sImV4cCI6MTc0Mzk3MjI4MywiaWF0IjoxNzQzOTY4NjgzLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0Iiwic3ViIjoiZjRiODlkYzItNjJmYi00NmJmLTlmNWYtYzM0ZjRlYWZlOTNlIn0.iwkQR96BKTJugh87_YOrDb7hXSWsv0RJXrqrqxHn3rwhcKNxwGnYAhTiQ12wyi-77-AFkzUlgs9E9pwgVi3_sE37QCVZ3YZzHjbg5crmT1EJ4f8gN8hw5cDqC3ny0R8rhgNzzirpZNe-i7SXzWCIySyEVh7MGFTPqNA-1ZlGh06FuOFRb22GVaHfrDkpE2RhkeZ-ZLlua9pbTcT1T9CihlCrW8JKTUwT2QspCwtnaJGs34iH77sHry31cTYVyOqd5q218tg_N4ky9iV6k7mK6b7uaPsjYHrtpfK1tp-9_MSp6Fqzw6wu_vrvg5WrIWwiREaz_wJj-SjIuBR5TlntdA', 'eyJhbGciOiJSUzI1NiIsImtpZCI6Ijh1SER3M002cmY4IiwidHlwIjoiSldUIn0.eyJhdWQiOlsiMzY1NGE3NDYtMzVkNC00MzIxLWFjNjEtMGJkY2ZmMmI0MDU1Il0sImV4cCI6MTc0Mzk3MjI4MywiaWF0IjoxNzQzOTY4NjgzLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0Iiwic3ViIjoiZjRiODlkYzItNjJmYi00NmJmLTlmNWYtYzM0ZjRlYWZlOTNlIiwidHlwZSI6Im9hdXRoLWFjY2Vzcy10b2tlbiJ9.REFSDFsGso9u7WxpyMmMVvjQMgulbidQNUft-kBRg7nw5LN9pOWhO0Zlr1tZnnrA1LenZRv0BvLIf0qekwGEC4FOPmJ6-As2ggIcoBIXpUR2A4Hhuy0FtqbCUgIkda1Dcx9w1Rmfzi0eHY_-1H_98rDgS5RxqweNA_YP3RsnJqBsc9GYhDarrf1nyCOplshGOEiyisUGoU2TaURI6DTcCiDzVOm_esZqokoZTpKlQw6ZugDDObro0eWYgROo97_3cqPRgRjSYBYRAGCHhZom3bFkjmz3wqpeoGmUNgL022x3-gl7QjurpJMQrKJ7wkFs0bh2uFnnngnh2w6m4j8-5w',
clientId: oidcClients.nextcloud.id, clientId: oidcClients.nextcloud.id,
expired: true expired: true
}, },
{ {
token: token:
'eyJhbGciOiJSUzI1NiIsImtpZCI6Ijh1SER3M002cmY4IiwidHlwIjoiYXQrand0In0.eyJhdWQiOlsiMzY1NGE3NDYtMzVkNC00MzIxLWFjNjEtMGJkY2ZmMmI0MDU1Il0sImV4cCI6Mjc0Mzk3MjI4MywiaWF0IjoxNzQzOTY4NjgzLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0Iiwic3ViIjoiZjRiODlkYzItNjJmYi00NmJmLTlmNWYtYzM0ZjRlYWZlOTNlIn0.lZMEohQeOi6oKDsKLKDDRYJIJNedUilvCLCi6XLADcHPtKlFJbPqH8IuQxuzryeIYAnTILsjvTkxkHAeRoQZCXQR7oS5BguGx6MtQYjgj--GpLBQ39r_nz-SEfhKtuMzEzPsN1raxOH8jWbnPM7zHxf5NIz7AHDKtCSWRA3JlE9kgAU7S-RRc6xP_BYVPDB97J6k-xuO5zxcdNTb92j8pZWbPPokv6CGG9CTPNzcrNHf-M98M6GE8SVM-8R2MAbpUCqTkTc_O46GHEexZzif2Wg8K5O-htiSQnwumoXXN08zKHCzCAvSdSa9JRMB-cgP7jsM7I6itUBXWxgvWDK3rA', 'eyJhbGciOiJSUzI1NiIsImtpZCI6Ijh1SER3M002cmY4IiwidHlwIjoiSldUIn0.eyJhdWQiOlsiMzY1NGE3NDYtMzVkNC00MzIxLWFjNjEtMGJkY2ZmMmI0MDU1Il0sImV4cCI6Mjc0Mzk3MjI4MywiaWF0IjoxNzQzOTY4NjgzLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0Iiwic3ViIjoiZjRiODlkYzItNjJmYi00NmJmLTlmNWYtYzM0ZjRlYWZlOTNlIiwidHlwZSI6Im9hdXRoLWFjY2Vzcy10b2tlbiJ9.FaFsHJS_8wbvQvctftNTPyzAe9IhbpJiHIkhg28RrFRFfnBMq0QycmTUh00MJPXkUfd_j5tcCnXybF1efHsq6WbP4AWFG_TJMUyz7a9SYt1lGR8dxo3eys0YAX5eJQ5YoVTKNrivSKrC37Rg3VlcZVWXp6KBAxRWVl3OUlquSC6q7HNKAKg8sbBJiGpUJ37wwanOTE2XhYGvFB2_gxS36tvOuSTV3CVg_7Fctej7gNhKMXBFMJiIFurxZaeNud8620xtv-vJX6ALa1Qu1SkWhhZN2Yx3WuODZNlni3rUps-THoEdqh62jNwItE9wB7C0fGEKuUqVIllaF9I_7i2s3w',
clientId: oidcClients.nextcloud.id, clientId: oidcClients.nextcloud.id,
expired: false expired: false
} }

View File

@@ -50,11 +50,13 @@ test('Create user fails with already taken username', async ({ page }) => {
await expect(page.getByRole('status')).toHaveText('Username is already in use'); await expect(page.getByRole('status')).toHaveText('Username is already in use');
}); });
test('Create one time access token', async ({ page }) => { test('Create one time access token', async ({ page, context }) => {
await page.goto('/settings/admin/users'); await page.goto('/settings/admin/users');
await page await page
.getByRole('row', { name: `${users.craig.firstname} ${users.craig.lastname}` }) .getByRole('row', {
name: `${users.craig.firstname} ${users.craig.lastname}`
})
.getByRole('button') .getByRole('button')
.click(); .click();
@@ -62,18 +64,22 @@ test('Create one time access token', async ({ page }) => {
await page.getByLabel('Login Code').getByRole('combobox').click(); await page.getByLabel('Login Code').getByRole('combobox').click();
await page.getByRole('option', { name: '12 hours' }).click(); await page.getByRole('option', { name: '12 hours' }).click();
await page.getByRole('button', { name: 'Generate Code' }).click(); await page.getByRole('button', { name: 'Show Code' }).click();
await expect(page.getByRole('textbox', { name: 'Login Code' })).toHaveValue( const link = await page.getByTestId('login-code-link').textContent();
/http:\/\/localhost\/lc\/.*/ await context.clearCookies();
);
await page.goto(link!);
await page.waitForURL('/settings/account');
}); });
test('Delete user', async ({ page }) => { test('Delete user', async ({ page }) => {
await page.goto('/settings/admin/users'); await page.goto('/settings/admin/users');
await page await page
.getByRole('row', { name: `${users.craig.firstname} ${users.craig.lastname}` }) .getByRole('row', {
name: `${users.craig.firstname} ${users.craig.lastname}`
})
.getByRole('button') .getByRole('button')
.click(); .click();
await page.getByRole('menuitem', { name: 'Delete' }).click(); await page.getByRole('menuitem', { name: 'Delete' }).click();
@@ -81,7 +87,9 @@ test('Delete user', async ({ page }) => {
await expect(page.getByRole('status')).toHaveText('User deleted successfully'); await expect(page.getByRole('status')).toHaveText('User deleted successfully');
await expect( await expect(
page.getByRole('row', { name: `${users.craig.firstname} ${users.craig.lastname}` }) page.getByRole('row', {
name: `${users.craig.firstname} ${users.craig.lastname}`
})
).not.toBeVisible(); ).not.toBeVisible();
}); });