Compare commits

...

27 Commits

Author SHA1 Message Date
Elias Schneider
8cc9b159a5 release: 0.50.0 2025-04-27 21:58:28 +02:00
James Baker
990c8af3d1 Fix incorrectly swapped refreshToken and accessToken (#490) 2025-04-27 14:09:07 -05:00
Alessandro (Ale) Segala
4c33793678 fix: pass context to methods that were missing it (#487) 2025-04-26 12:32:42 -05:00
Elias Schneider
9e06f70380 chore(translations): update translations via Crowdin (#479)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-04-25 12:21:59 -05:00
Kyle Mendell
22f7d64bf0 feat: device authorization endpoint (#270)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-04-25 12:14:51 -05:00
Kyle Mendell
630327c979 feat: make family name optional (#476) 2025-04-25 09:52:09 -05:00
Alessandro (Ale) Segala
662506260e refactor: do not force redirects to happen on the server (#481) 2025-04-24 21:09:52 +02:00
Star_caorui
8e66af627a chore(translations): Add Simplified Chinese translation. (#473) 2025-04-23 18:39:37 +00:00
Alessandro (Ale) Segala
270c30334d fix: prevent deadlock when trying to delete LDAP users (#471) 2025-04-22 15:16:44 +02:00
Elias Schneider
c73c3ceb5e chore(translations): update translations via Crowdin (#468) 2025-04-21 23:02:10 -05:00
Alessandro (Ale) Segala
22725d30f4 fix: do not override XDG_DATA_HOME/XDG_CONFIG_HOME if they are already set (#472) 2025-04-21 22:58:32 -05:00
eiqnepm
76b753f9f2 fix: rootless Caddy data and configuration (#470) 2025-04-21 13:15:51 +02:00
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
100 changed files with 1922 additions and 422 deletions

2
.github/FUNDING.yml vendored
View File

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

View File

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

View File

@@ -1 +1 @@
0.48.0
0.50.0

View File

@@ -1,3 +1,37 @@
## [](https://github.com/pocket-id/pocket-id/compare/v0.49.0...v) (2025-04-27)
### Features
* device authorization endpoint ([#270](https://github.com/pocket-id/pocket-id/issues/270)) ([22f7d64](https://github.com/pocket-id/pocket-id/commit/22f7d64bf08a5a1ecbe5eee0052453b730f5c360))
* make family name optional ([#476](https://github.com/pocket-id/pocket-id/issues/476)) ([630327c](https://github.com/pocket-id/pocket-id/commit/630327c979de2f931b9d1f0ba0b4a4de1af3fc7c))
### Bug Fixes
* do not override XDG_DATA_HOME/XDG_CONFIG_HOME if they are already set ([#472](https://github.com/pocket-id/pocket-id/issues/472)) ([22725d3](https://github.com/pocket-id/pocket-id/commit/22725d30f4115ffe17625379f56affedfe116778))
* pass context to methods that were missing it ([#487](https://github.com/pocket-id/pocket-id/issues/487)) ([4c33793](https://github.com/pocket-id/pocket-id/commit/4c33793678709eb4981be2c1fd5803bace5f5939))
* prevent deadlock when trying to delete LDAP users ([#471](https://github.com/pocket-id/pocket-id/issues/471)) ([270c303](https://github.com/pocket-id/pocket-id/commit/270c30334dc36f215a67f873283a9d6fcd14d065))
* rootless Caddy data and configuration ([#470](https://github.com/pocket-id/pocket-id/issues/470)) ([76b753f](https://github.com/pocket-id/pocket-id/commit/76b753f9f2a6a4f1af09359530e30844b03ac39b))
## [](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)

View File

@@ -1,6 +1,6 @@
module github.com/pocket-id/pocket-id/backend
go 1.24
go 1.24.0
require (
github.com/caarlos0/env/v11 v11.3.1

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)
userGroupService := service.NewUserGroupService(db, appConfigService)
ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService)
apiKeyService := service.NewApiKeyService(db)
apiKeyService := service.NewApiKeyService(db, emailService)
rateLimitMiddleware := middleware.NewRateLimitMiddleware()
@@ -61,6 +61,7 @@ func initRouter(ctx context.Context, db *gorm.DB, appConfigService *service.AppC
job.RegisterLdapJobs(ctx, ldapService, appConfigService)
job.RegisterDbCleanupJobs(ctx, db)
job.RegisterFileCleanupJobs(ctx, db)
job.RegisterApiKeyExpiryJob(ctx, apiKeyService, appConfigService)
// Initialize middleware for specific routes
authMiddleware := middleware.NewAuthMiddleware(apiKeyService, userService, jwtService)

View File

@@ -296,3 +296,51 @@ func (e *UserDisabledError) Error() string {
func (e *UserDisabledError) HttpStatusCode() int {
return http.StatusForbidden
}
type ValidationError struct {
Message string
}
func (e *ValidationError) Error() string {
return e.Message
}
func (e *ValidationError) HttpStatusCode() int {
return http.StatusBadRequest
}
type OidcDeviceCodeExpiredError struct{}
func (e *OidcDeviceCodeExpiredError) Error() string {
return "device code has expired"
}
func (e *OidcDeviceCodeExpiredError) HttpStatusCode() int {
return http.StatusBadRequest
}
type OidcInvalidDeviceCodeError struct{}
func (e *OidcInvalidDeviceCodeError) Error() string {
return "invalid device code"
}
func (e *OidcInvalidDeviceCodeError) HttpStatusCode() int {
return http.StatusBadRequest
}
type OidcSlowDownError struct{}
func (e *OidcSlowDownError) Error() string {
return "polling too frequently"
}
func (e *OidcSlowDownError) HttpStatusCode() int {
return http.StatusTooManyRequests
}
type OidcAuthorizationPendingError struct{}
func (e *OidcAuthorizationPendingError) Error() string {
return "authorization is still pending"
}
func (e *OidcAuthorizationPendingError) HttpStatusCode() int {
return http.StatusBadRequest
}

View File

@@ -1,18 +1,20 @@
package controller
import (
"errors"
"log"
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
)
// NewOidcController creates a new controller for OIDC related endpoints
@@ -45,6 +47,10 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.GET("/oidc/clients/:id/logo", oc.getClientLogoHandler)
group.DELETE("/oidc/clients/:id/logo", oc.deleteClientLogoHandler)
group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler)
group.POST("/oidc/device/authorize", oc.deviceAuthorizationHandler)
group.POST("/oidc/device/verify", authMiddleware.WithAdminNotRequired().Add(), oc.verifyDeviceCodeHandler)
group.GET("/oidc/device/info", authMiddleware.WithAdminNotRequired().Add(), oc.getDeviceCodeInfoHandler)
}
type OidcController struct {
@@ -144,25 +150,26 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
return
}
clientID := input.ClientID
clientSecret := input.ClientSecret
// Client id and secret can also be passed over the Authorization header
if clientID == "" && clientSecret == "" {
clientID, clientSecret, _ = c.Request.BasicAuth()
if input.ClientID == "" && input.ClientSecret == "" {
input.ClientID, input.ClientSecret, _ = c.Request.BasicAuth()
}
idToken, accessToken, refreshToken, expiresIn, err := oc.oidcService.CreateTokens(
c.Request.Context(),
input.Code,
input.GrantType,
clientID,
clientSecret,
input.CodeVerifier,
input.RefreshToken,
)
idToken, accessToken, refreshToken, expiresIn, err :=
oc.oidcService.CreateTokens(c.Request.Context(), input)
if err != nil {
switch {
case errors.Is(err, &common.OidcAuthorizationPendingError{}):
c.JSON(http.StatusBadRequest, gin.H{
"error": "authorization_pending",
})
return
case errors.Is(err, &common.OidcSlowDownError{}):
c.JSON(http.StatusBadRequest, gin.H{
"error": "slow_down",
})
return
case err != nil:
_ = c.Error(err)
return
}
@@ -300,7 +307,6 @@ func (oc *OidcController) EndSessionHandlerPost(c *gin.Context) {
// @Success 200 {object} dto.OidcIntrospectionResponseDto "Response with the introspection result."
// @Router /api/oidc/introspect [post]
func (oc *OidcController) introspectTokenHandler(c *gin.Context) {
var input dto.OidcIntrospectDto
if err := c.ShouldBind(&input); err != nil {
_ = c.Error(err)
@@ -314,7 +320,7 @@ func (oc *OidcController) introspectTokenHandler(c *gin.Context) {
// and client_secret anyway).
clientID, clientSecret, _ := c.Request.BasicAuth()
response, err := oc.oidcService.IntrospectToken(clientID, clientSecret, input.Token)
response, err := oc.oidcService.IntrospectToken(c.Request.Context(), clientID, clientSecret, input.Token)
if err != nil {
_ = c.Error(err)
return
@@ -613,3 +619,60 @@ func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
c.JSON(http.StatusOK, oidcClientDto)
}
func (oc *OidcController) deviceAuthorizationHandler(c *gin.Context) {
var input dto.OidcDeviceAuthorizationRequestDto
if err := c.ShouldBind(&input); err != nil {
_ = c.Error(err)
return
}
// Client id and secret can also be passed over the Authorization header
if input.ClientID == "" && input.ClientSecret == "" {
input.ClientID, input.ClientSecret, _ = c.Request.BasicAuth()
}
response, err := oc.oidcService.CreateDeviceAuthorization(c.Request.Context(), input)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, response)
}
func (oc *OidcController) verifyDeviceCodeHandler(c *gin.Context) {
userCode := c.Query("code")
if userCode == "" {
_ = c.Error(&common.ValidationError{Message: "code is required"})
return
}
// Get IP address and user agent from the request context
ipAddress := c.ClientIP()
userAgent := c.Request.UserAgent()
err := oc.oidcService.VerifyDeviceCode(c.Request.Context(), userCode, c.GetString("userID"), ipAddress, userAgent)
if err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (oc *OidcController) getDeviceCodeInfoHandler(c *gin.Context) {
userCode := c.Query("code")
if userCode == "" {
_ = c.Error(&common.ValidationError{Message: "code is required"})
return
}
deviceCodeInfo, err := oc.oidcService.GetDeviceCodeInfo(c.Request.Context(), userCode, c.GetString("userID"))
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, deviceCodeInfo)
}

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/: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/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/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler)
@@ -356,18 +357,63 @@ func (uc *UserController) createOwnOneTimeAccessTokenHandler(c *gin.Context) {
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) {
uc.createOneTimeAccessTokenHandler(c, false)
}
func (uc *UserController) requestOneTimeAccessEmailHandler(c *gin.Context) {
var input dto.OneTimeAccessEmailDto
// RequestOneTimeAccessEmailAsUnauthenticatedUserHandler godoc
// @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 {
_ = c.Error(err)
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 {
_ = c.Error(err)
return

View File

@@ -75,8 +75,9 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
"end_session_endpoint": appUrl + "/api/oidc/end-session",
"introspection_endpoint": appUrl + "/api/oidc/introspect",
"device_authorization_endpoint": appUrl + "/api/oidc/device/authorize",
"jwks_uri": appUrl + "/.well-known/jwks.json",
"grant_types_supported": []string{"authorization_code", "refresh_token"},
"grant_types_supported": []string{"authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"},
"scopes_supported": []string{"openid", "profile", "email", "groups"},
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
"response_types_supported": []string{"code", "id_token"},

View File

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

View File

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

@@ -49,6 +49,7 @@ type AuthorizationRequiredDto struct {
type OidcCreateTokensDto struct {
GrantType string `form:"grant_type" binding:"required"`
Code string `form:"code"`
DeviceCode string `form:"device_code"`
ClientID string `form:"client_id"`
ClientSecret string `form:"client_secret"`
CodeVerifier string `form:"code_verifier"`
@@ -90,3 +91,32 @@ type OidcIntrospectionResponseDto struct {
Issuer string `json:"iss,omitempty"`
Identifier string `json:"jti,omitempty"`
}
type OidcDeviceAuthorizationRequestDto struct {
ClientID string `form:"client_id" binding:"required"`
Scope string `form:"scope" binding:"required"`
ClientSecret string `form:"client_secret"`
}
type OidcDeviceAuthorizationResponseDto struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
VerificationURIComplete string `json:"verification_uri_complete"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
RequiresAuthorization bool `json:"requires_authorization"`
}
type OidcDeviceTokenRequestDto struct {
GrantType string `form:"grant_type" binding:"required,eq=urn:ietf:params:oauth:grant-type:device_code"`
DeviceCode string `form:"device_code" binding:"required"`
ClientID string `form:"client_id"`
ClientSecret string `form:"client_secret"`
}
type DeviceCodeInfoDto struct {
Scope string `json:"scope"`
AuthorizationRequired bool `json:"authorizationRequired"`
Client OidcClientMetaDataDto `json:"client"`
}

View File

@@ -20,7 +20,7 @@ type UserCreateDto struct {
Username string `json:"username" binding:"required,username,min=2,max=50"`
Email string `json:"email" binding:"required,email"`
FirstName string `json:"firstName" binding:"required,min=1,max=50"`
LastName string `json:"lastName" binding:"required,min=1,max=50"`
LastName string `json:"lastName" binding:"max=50"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
Disabled bool `json:"disabled"`
@@ -32,11 +32,15 @@ type OneTimeAccessTokenCreateDto struct {
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
}
type OneTimeAccessEmailDto struct {
type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
Email string `json:"email" binding:"required,email"`
RedirectPath string `json:"redirectPath"`
}
type OneTimeAccessEmailAsAdminDto struct {
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
}
type UserUpdateUserGroupDto struct {
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

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

View File

@@ -41,15 +41,17 @@ type AppConfig struct {
LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal
LogoDarkImageType AppConfigVariable `key:"logoDarkImageType,internal"` // Internal
// Email
SmtpHost AppConfigVariable `key:"smtpHost"`
SmtpPort AppConfigVariable `key:"smtpPort"`
SmtpFrom AppConfigVariable `key:"smtpFrom"`
SmtpUser AppConfigVariable `key:"smtpUser"`
SmtpPassword AppConfigVariable `key:"smtpPassword"`
SmtpTls AppConfigVariable `key:"smtpTls"`
SmtpSkipCertVerify AppConfigVariable `key:"smtpSkipCertVerify"`
EmailLoginNotificationEnabled AppConfigVariable `key:"emailLoginNotificationEnabled"`
EmailOneTimeAccessEnabled AppConfigVariable `key:"emailOneTimeAccessEnabled,public"` // Public
SmtpHost AppConfigVariable `key:"smtpHost"`
SmtpPort AppConfigVariable `key:"smtpPort"`
SmtpFrom AppConfigVariable `key:"smtpFrom"`
SmtpUser AppConfigVariable `key:"smtpUser"`
SmtpPassword AppConfigVariable `key:"smtpPassword"`
SmtpTls AppConfigVariable `key:"smtpTls"`
SmtpSkipCertVerify AppConfigVariable `key:"smtpSkipCertVerify"`
EmailLoginNotificationEnabled AppConfigVariable `key:"emailLoginNotificationEnabled"`
EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigVariable `key:"emailOneTimeAccessAsUnauthenticatedEnabled,public"` // Public
EmailOneTimeAccessAsAdminEnabled AppConfigVariable `key:"emailOneTimeAccessAsAdminEnabled,public"` // Public
EmailApiKeyExpirationEnabled AppConfigVariable `key:"emailApiKeyExpirationEnabled"`
// LDAP
LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public
LdapUrl AppConfigVariable `key:"ldapUrl"`
@@ -77,7 +79,7 @@ func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable {
cfgValue := reflect.ValueOf(c).Elem()
cfgType := cfgValue.Type()
res := make([]AppConfigVariable, cfgType.NumField())
var res []AppConfigVariable
for i := range cfgType.NumField() {
field := cfgType.Field(i)
@@ -94,10 +96,12 @@ func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable {
fieldValue := cfgValue.Field(i)
res[i] = AppConfigVariable{
appConfigVariable := AppConfigVariable{
Key: key,
Value: fieldValue.FieldByName("Value").String(),
}
res = append(res, appConfigVariable)
}
return res

View File

@@ -26,10 +26,12 @@ type AuditLogData map[string]string //nolint:recvcheck
type AuditLogEvent string //nolint:recvcheck
const (
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
AuditLogEventDeviceCodeAuthorization AuditLogEvent = "DEVICE_CODE_AUTHORIZATION"
AuditLogEventNewDeviceCodeAuthorization AuditLogEvent = "NEW_DEVICE_CODE_AUTHORIZATION"
)
// Scan and Value methods for GORM to handle the custom type

View File

@@ -87,3 +87,17 @@ func (cu *UrlList) Scan(value interface{}) error {
func (cu UrlList) Value() (driver.Value, error) {
return json.Marshal(cu)
}
type OidcDeviceCode struct {
Base
DeviceCode string
UserCode string
Scope string
ExpiresAt datatype.DateTime
IsAuthorized bool
UserID *string
User User
ClientID string
Client OidcClient
}

View File

@@ -6,6 +6,7 @@ import (
"time"
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/dto"
@@ -16,11 +17,12 @@ import (
)
type ApiKeyService struct {
db *gorm.DB
db *gorm.DB
emailService *EmailService
}
func NewApiKeyService(db *gorm.DB) *ApiKeyService {
return &ApiKeyService{db: db}
func NewApiKeyService(db *gorm.DB, emailService *EmailService) *ApiKeyService {
return &ApiKeyService{db: db, emailService: emailService}
}
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
}
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

@@ -73,7 +73,9 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
SmtpTls: model.AppConfigVariable{Value: "none"},
SmtpSkipCertVerify: 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
LdapEnabled: model.AppConfigVariable{Value: "false"},
LdapUrl: model.AppConfigVariable{},
@@ -151,11 +153,6 @@ func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppCon
return nil, &common.UiConfigDisabledError{}
}
// If EmailLoginNotificationEnabled is set to false (explicitly), disable the EmailOneTimeAccessEnabled
if input.EmailLoginNotificationEnabled == "false" {
input.EmailOneTimeAccessEnabled = "false"
}
// Start the transaction
tx, err := s.updateAppConfigStartTransaction(ctx)
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) {
// Save the original state and restore it after the test
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{
Name: user.Username,
Name: user.FullName(),
Email: user.Email,
}, NewLoginTemplate, &NewLoginTemplateData{
IPAddress: ipAddress,

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)
// if the address does not have an @ (which would be unusual), we use hostname
from_address := dbConfig.SmtpFrom.Value
fromAddress := dbConfig.SmtpFrom.Value
domain := ""
if strings.Contains(from_address, "@") {
domain = strings.Split(from_address, "@")[1]
if strings.Contains(fromAddress, "@") {
domain = strings.Split(fromAddress, "@")[1]
} else {
hostname, err := os.Hostname()
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 {
IPAddress string
Country string
@@ -54,7 +61,14 @@ type OneTimeAccessTemplateData = struct {
Code string
LoginLink 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
var emailTemplatesPaths = []string{NewLoginTemplate.Path, OneTimeAccessTemplate.Path, TestTemplate.Path}
var emailTemplatesPaths = []string{NewLoginTemplate.Path, OneTimeAccessTemplate.Path, TestTemplate.Path, ApiKeyExpiringSoonTemplate.Path}

View File

@@ -366,17 +366,22 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
}
if dbConfig.LdapSoftDeleteUsers.IsTrue() {
err = s.userService.DisableUser(ctx, user.ID, tx)
err = s.userService.disableUserInternal(ctx, user.ID, tx)
if err != nil {
log.Printf("Failed to disable user %s: %v", user.Username, err)
continue
return fmt.Errorf("failed to disable user %s: %w", user.Username, err)
}
log.Printf("Disabled user '%s'", user.Username)
} else {
err = s.userService.DeleteUser(ctx, user.ID, true)
if err != nil {
log.Printf("Failed to delete user %s: %v", user.Username, err)
continue
err = s.userService.deleteUserInternal(ctx, user.ID, true, tx)
target := &common.LdapUserUpdateError{}
if errors.As(err, &target) {
return fmt.Errorf("failed to delete user %s: LDAP user must be disabled before deletion", user.Username)
} else if err != nil {
return fmt.Errorf("failed to delete user %s: %w", user.Username, err)
}
log.Printf("Deleted user '%s'", user.Username)
}
}

View File

@@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"mime/multipart"
"os"
"regexp"
@@ -180,45 +181,99 @@ func (s *OidcService) IsUserGroupAllowedToAuthorize(user model.User, client mode
return isAllowedToAuthorize
}
func (s *OidcService) CreateTokens(ctx context.Context, code, grantType, clientID, clientSecret, codeVerifier, refreshToken string) (idToken string, accessToken string, newRefreshToken string, exp int, err error) {
switch grantType {
func (s *OidcService) CreateTokens(ctx context.Context, input dto.OidcCreateTokensDto) (idToken string, accessToken string, newRefreshToken string, exp int, err error) {
switch input.GrantType {
case "authorization_code":
return s.createTokenFromAuthorizationCode(ctx, code, clientID, clientSecret, codeVerifier)
return s.createTokenFromAuthorizationCode(ctx, input.Code, input.ClientID, input.ClientSecret, input.CodeVerifier)
case "refresh_token":
accessToken, newRefreshToken, exp, err = s.createTokenFromRefreshToken(ctx, refreshToken, clientID, clientSecret)
accessToken, newRefreshToken, exp, err = s.createTokenFromRefreshToken(ctx, input.RefreshToken, input.ClientID, input.ClientSecret)
return "", accessToken, newRefreshToken, exp, err
case "urn:ietf:params:oauth:grant-type:device_code":
return s.createTokenFromDeviceCode(ctx, input.DeviceCode, input.ClientID, input.ClientSecret)
default:
return "", "", "", 0, &common.OidcGrantTypeNotSupportedError{}
}
}
func (s *OidcService) createTokenFromDeviceCode(ctx context.Context, deviceCode, clientID string, clientSecret string) (idToken string, accessToken string, refreshToken string, exp int, err error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
_, err = s.VerifyClientCredentials(ctx, clientID, clientSecret, tx)
if err != nil {
return "", "", "", 0, err
}
// Get the device authorization from database with explicit query conditions
var deviceAuth model.OidcDeviceCode
if err := tx.WithContext(ctx).Preload("User").Where("device_code = ? AND client_id = ?", deviceCode, clientID).First(&deviceAuth).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", "", "", 0, &common.OidcInvalidDeviceCodeError{}
}
return "", "", "", 0, err
}
// Check if device code has expired
if time.Now().After(deviceAuth.ExpiresAt.ToTime()) {
return "", "", "", 0, &common.OidcDeviceCodeExpiredError{}
}
// Check if device code has been authorized
if !deviceAuth.IsAuthorized || deviceAuth.UserID == nil {
return "", "", "", 0, &common.OidcAuthorizationPendingError{}
}
// Get user claims for the ID token - ensure UserID is not nil
if deviceAuth.UserID == nil {
return "", "", "", 0, &common.OidcAuthorizationPendingError{}
}
userClaims, err := s.getUserClaimsForClientInternal(ctx, *deviceAuth.UserID, clientID, tx)
if err != nil {
return "", "", "", 0, err
}
// Explicitly use the input clientID for the audience claim to ensure consistency
idToken, err = s.jwtService.GenerateIDToken(userClaims, clientID, "")
if err != nil {
return "", "", "", 0, err
}
refreshToken, err = s.createRefreshToken(ctx, clientID, *deviceAuth.UserID, deviceAuth.Scope, tx)
if err != nil {
return "", "", "", 0, err
}
accessToken, err = s.jwtService.GenerateOauthAccessToken(deviceAuth.User, clientID)
if err != nil {
return "", "", "", 0, err
}
// Delete the used device code
if err := tx.WithContext(ctx).Delete(&deviceAuth).Error; err != nil {
return "", "", "", 0, err
}
if err := tx.Commit().Error; err != nil {
return "", "", "", 0, err
}
return idToken, accessToken, refreshToken, 3600, nil
}
func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, code, clientID, clientSecret, codeVerifier string) (idToken string, accessToken string, refreshToken string, exp int, err error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var client model.OidcClient
err = tx.
WithContext(ctx).
First(&client, "id = ?", clientID).
Error
client, err := s.VerifyClientCredentials(ctx, clientID, clientSecret, tx)
if err != nil {
return "", "", "", 0, err
}
// Verify the client secret if the client is not public
if !client.IsPublic {
if clientID == "" || clientSecret == "" {
return "", "", "", 0, &common.OidcMissingClientCredentialsError{}
}
err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
if err != nil {
return "", "", "", 0, &common.OidcClientSecretInvalidError{}
}
}
var authorizationCodeMetaData model.OidcAuthorizationCode
err = tx.
WithContext(ctx).
@@ -287,28 +342,11 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, refreshTo
tx.Rollback()
}()
// Get the client to check if it's public
var client model.OidcClient
err = tx.
WithContext(ctx).
First(&client, "id = ?", clientID).
Error
_, err = s.VerifyClientCredentials(ctx, clientID, clientSecret, tx)
if err != nil {
return "", "", 0, err
}
// Verify the client secret if the client is not public
if !client.IsPublic {
if clientID == "" || clientSecret == "" {
return "", "", 0, &common.OidcMissingClientCredentialsError{}
}
err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
if err != nil {
return "", "", 0, &common.OidcClientSecretInvalidError{}
}
}
// Verify refresh token
var storedRefreshToken model.OidcRefreshToken
err = tx.
@@ -358,31 +396,21 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, refreshTo
return accessToken, newRefreshToken, 3600, nil
}
func (s *OidcService) IntrospectToken(clientID, clientSecret, tokenString string) (introspectDto dto.OidcIntrospectionResponseDto, err error) {
func (s *OidcService) IntrospectToken(ctx context.Context, clientID, clientSecret, tokenString string) (introspectDto dto.OidcIntrospectionResponseDto, err error) {
if clientID == "" || clientSecret == "" {
return introspectDto, &common.OidcMissingClientCredentialsError{}
}
// Get the client to check if we are authorized.
var client model.OidcClient
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
return introspectDto, &common.OidcClientSecretInvalidError{}
}
// Verify the client secret. This endpoint may not be used by public clients.
if client.IsPublic {
return introspectDto, &common.OidcClientSecretInvalidError{}
}
if err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret)); err != nil {
return introspectDto, &common.OidcClientSecretInvalidError{}
_, err = s.VerifyClientCredentials(ctx, clientID, clientSecret, s.db)
if err != nil {
return introspectDto, err
}
token, err := s.jwtService.VerifyOauthAccessToken(tokenString)
if err != nil {
if errors.Is(err, jwt.ParseError()) {
// It's apparently not a valid JWT token, so we check if it's a valid refresh_token.
return s.introspectRefreshToken(tokenString)
return s.introspectRefreshToken(ctx, tokenString)
}
// Every failure we get means the token is invalid. Nothing more to do with the error.
@@ -426,9 +454,11 @@ func (s *OidcService) IntrospectToken(clientID, clientSecret, tokenString string
return introspectDto, nil
}
func (s *OidcService) introspectRefreshToken(refreshToken string) (introspectDto dto.OidcIntrospectionResponseDto, err error) {
func (s *OidcService) introspectRefreshToken(ctx context.Context, refreshToken string) (introspectDto dto.OidcIntrospectionResponseDto, err error) {
var storedRefreshToken model.OidcRefreshToken
err = s.db.Preload("User").
err = s.db.
WithContext(ctx).
Preload("User").
Where("token = ? AND expires_at > ?", utils.CreateSha256Hash(refreshToken), datatype.DateTime(time.Now())).
First(&storedRefreshToken).
Error
@@ -968,6 +998,167 @@ func (s *OidcService) getCallbackURL(urls []string, inputCallbackURL string) (ca
return "", &common.OidcInvalidCallbackURLError{}
}
func (s *OidcService) CreateDeviceAuthorization(ctx context.Context, input dto.OidcDeviceAuthorizationRequestDto) (*dto.OidcDeviceAuthorizationResponseDto, error) {
client, err := s.VerifyClientCredentials(ctx, input.ClientID, input.ClientSecret, s.db)
if err != nil {
return nil, err
}
// Generate codes
deviceCode, err := utils.GenerateRandomAlphanumericString(32)
if err != nil {
return nil, err
}
userCode, err := utils.GenerateRandomAlphanumericString(8)
if err != nil {
return nil, err
}
// Create device authorization
deviceAuth := &model.OidcDeviceCode{
DeviceCode: deviceCode,
UserCode: userCode,
Scope: input.Scope,
ExpiresAt: datatype.DateTime(time.Now().Add(15 * time.Minute)),
IsAuthorized: false,
ClientID: client.ID,
}
if err := s.db.Create(deviceAuth).Error; err != nil {
return nil, err
}
return &dto.OidcDeviceAuthorizationResponseDto{
DeviceCode: deviceCode,
UserCode: userCode,
VerificationURI: common.EnvConfig.AppURL + "/device",
VerificationURIComplete: common.EnvConfig.AppURL + "/device?code=" + userCode,
ExpiresIn: 900, // 15 minutes
Interval: 5,
}, nil
}
func (s *OidcService) VerifyDeviceCode(ctx context.Context, userCode string, userID string, ipAddress string, userAgent string) error {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var deviceAuth model.OidcDeviceCode
if err := tx.WithContext(ctx).Preload("Client.AllowedUserGroups").First(&deviceAuth, "user_code = ?", userCode).Error; err != nil {
log.Printf("Error finding device code with user_code %s: %v", userCode, err)
return err
}
if time.Now().After(deviceAuth.ExpiresAt.ToTime()) {
return &common.OidcDeviceCodeExpiredError{}
}
// Check if the user group is allowed to authorize the client
var user model.User
if err := tx.WithContext(ctx).Preload("UserGroups").First(&user, "id = ?", userID).Error; err != nil {
return err
}
if !s.IsUserGroupAllowedToAuthorize(user, deviceAuth.Client) {
return &common.OidcAccessDeniedError{}
}
if err := tx.WithContext(ctx).Preload("Client").First(&deviceAuth, "user_code = ?", userCode).Error; err != nil {
log.Printf("Error finding device code with user_code %s: %v", userCode, err)
return err
}
if time.Now().After(deviceAuth.ExpiresAt.ToTime()) {
return &common.OidcDeviceCodeExpiredError{}
}
deviceAuth.UserID = &userID
deviceAuth.IsAuthorized = true
if err := tx.WithContext(ctx).Save(&deviceAuth).Error; err != nil {
log.Printf("Error saving device auth: %v", err)
return err
}
// Verify the update was successful
var verifiedAuth model.OidcDeviceCode
if err := tx.WithContext(ctx).First(&verifiedAuth, "device_code = ?", deviceAuth.DeviceCode).Error; err != nil {
log.Printf("Error verifying update: %v", err)
return err
}
// Create user authorization if needed
hasAuthorizedClient, err := s.hasAuthorizedClientInternal(ctx, deviceAuth.ClientID, userID, deviceAuth.Scope, tx)
if err != nil {
return err
}
if !hasAuthorizedClient {
userAuthorizedClient := model.UserAuthorizedOidcClient{
UserID: userID,
ClientID: deviceAuth.ClientID,
Scope: deviceAuth.Scope,
}
if err := tx.WithContext(ctx).Create(&userAuthorizedClient).Error; err != nil {
if !errors.Is(err, gorm.ErrDuplicatedKey) {
return err
}
// If duplicate, update scope
if err := tx.WithContext(ctx).Model(&model.UserAuthorizedOidcClient{}).
Where("user_id = ? AND client_id = ?", userID, deviceAuth.ClientID).
Update("scope", deviceAuth.Scope).Error; err != nil {
return err
}
}
s.auditLogService.Create(ctx, model.AuditLogEventNewDeviceCodeAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": deviceAuth.Client.Name}, tx)
} else {
s.auditLogService.Create(ctx, model.AuditLogEventDeviceCodeAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": deviceAuth.Client.Name}, tx)
}
return tx.Commit().Error
}
func (s *OidcService) GetDeviceCodeInfo(ctx context.Context, userCode string, userID string) (*dto.DeviceCodeInfoDto, error) {
var deviceAuth model.OidcDeviceCode
err := s.db.
WithContext(ctx).
Preload("Client").
First(&deviceAuth, "user_code = ?", userCode).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, &common.OidcInvalidDeviceCodeError{}
}
return nil, err
}
if time.Now().After(deviceAuth.ExpiresAt.ToTime()) {
return nil, &common.OidcDeviceCodeExpiredError{}
}
// Check if the user has already authorized this client with this scope
hasAuthorizedClient := false
if userID != "" {
var err error
hasAuthorizedClient, err = s.HasAuthorizedClient(ctx, deviceAuth.ClientID, userID, deviceAuth.Scope)
if err != nil {
return nil, err
}
}
return &dto.DeviceCodeInfoDto{
Client: dto.OidcClientMetaDataDto{
ID: deviceAuth.Client.ID,
Name: deviceAuth.Client.Name,
HasLogo: deviceAuth.Client.HasLogo,
},
Scope: deviceAuth.Scope,
AuthorizationRequired: !hasAuthorizedClient,
}, nil
}
func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, userID string, scope string, tx *gorm.DB) (string, error) {
refreshToken, err := utils.GenerateRandomAlphanumericString(40)
if err != nil {
@@ -996,3 +1187,25 @@ func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, u
return refreshToken, nil
}
func (s *OidcService) VerifyClientCredentials(ctx context.Context, clientID, clientSecret string, tx *gorm.DB) (model.OidcClient, error) {
if clientID == "" {
return model.OidcClient{}, &common.OidcMissingClientCredentialsError{}
}
var client model.OidcClient
err := tx.
WithContext(ctx).
First(&client, "id = ?", clientID).
Error
if err != nil {
return model.OidcClient{}, err
}
if !client.IsPublic {
if err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret)); err != nil {
return model.OidcClient{}, &common.OidcClientSecretInvalidError{}
}
}
return client, nil
}

View File

@@ -175,28 +175,9 @@ func (s *UserService) UpdateProfilePicture(userID string, file io.Reader) error
}
func (s *UserService) DeleteUser(ctx context.Context, userID string, allowLdapDelete bool) error {
tx := s.db.Begin()
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
return s.db.Transaction(func(tx *gorm.DB) error {
return s.deleteUserInternal(ctx, userID, allowLdapDelete, tx)
})
}
func (s *UserService) deleteUserInternal(ctx context.Context, userID string, allowLdapDelete bool, tx *gorm.DB) error {
@@ -348,23 +329,23 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
return user, nil
}
func (s *UserService) RequestOneTimeAccessEmail(ctx context.Context, emailAddress, redirectPath string) error {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessEnabled.IsTrue()
func (s *UserService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, userID string, expiration time.Time) error {
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsAdminEnabled.IsTrue()
if isDisabled {
return &common.OneTimeAccessDisabledError{}
}
var user model.User
err := tx.
WithContext(ctx).
Where("email = ?", emailAddress).
First(&user).
Error
return s.requestOneTimeAccessEmailInternal(ctx, userID, "", expiration)
}
func (s *UserService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, userID, redirectPath string) 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 {
// Do not return error if user not found to prevent email enumeration
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -374,7 +355,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 {
return err
}
@@ -399,12 +395,13 @@ func (s *UserService) RequestOneTimeAccessEmail(ctx context.Context, emailAddres
}
errInternal := SendEmail(innerCtx, s.emailService, email.Address{
Name: user.Username,
Name: user.FullName(),
Email: user.Email,
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
Code: oneTimeAccessToken,
LoginLink: link,
LoginLinkWithCode: linkWithCode,
ExpirationString: utils.DurationToString(time.Until(expiration).Round(time.Second)),
})
if errInternal != nil {
log.Printf("Failed to send email to '%s': %v\n", user.Email, errInternal)
@@ -632,8 +629,9 @@ func (s *UserService) ResetProfilePicture(userID string) error {
return nil
}
func (s *UserService) DisableUser(ctx context.Context, userID string, tx *gorm.DB) error {
return tx.WithContext(ctx).
func (s *UserService) disableUserInternal(ctx context.Context, userID string, tx *gorm.DB) error {
return tx.
WithContext(ctx).
Model(&model.User{}).
Where("id = ?", userID).
Update("disabled", true).

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

@@ -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">
<h2>Login Code</h2>
<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>
<div class="button-container">
<a class="button" href="{{ .Data.LoginLinkWithCode }}" class="button">Sign In</a>

View File

@@ -2,7 +2,7 @@
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 }}

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 @@
DROP TABLE oidc_device_codes;

View File

@@ -0,0 +1,12 @@
CREATE TABLE oidc_device_codes
(
id UUID NOT NULL PRIMARY KEY,
created_at TIMESTAMPTZ,
device_code TEXT NOT NULL UNIQUE,
user_code TEXT NOT NULL UNIQUE,
scope TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
is_authorized BOOLEAN NOT NULL DEFAULT FALSE,
user_id UUID REFERENCES users ON DELETE CASCADE,
client_id UUID NOT NULL REFERENCES oidc_clients ON DELETE CASCADE
);

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

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

View File

@@ -0,0 +1,12 @@
CREATE TABLE oidc_device_codes
(
id TEXT NOT NULL PRIMARY KEY,
created_at DATETIME,
device_code TEXT NOT NULL UNIQUE,
user_code TEXT NOT NULL UNIQUE,
scope TEXT NOT NULL,
expires_at DATETIME NOT NULL,
is_authorized BOOLEAN NOT NULL DEFAULT FALSE,
user_id TEXT REFERENCES users ON DELETE CASCADE,
client_id TEXT NOT NULL REFERENCES oidc_clients ON DELETE CASCADE
);

View File

@@ -2,3 +2,7 @@
package-lock.json
pnpm-lock.yaml
yarn.lock
# Compiled files
.svelte-kit/
build/

View File

@@ -36,7 +36,7 @@
"generate_code": "Vygenerovat kód",
"name": "Jméno",
"browser_unsupported": "Prohlížeč nepodporován",
"this_browser_does_not_support_passkeys": "This browser doesn't support passkeys. Please use an alternative sign in method.",
"this_browser_does_not_support_passkeys": "Tento prohlížeč nepodporuje přístupové klíče. Zkuste prosím alternativní způsob přihlášení.",
"an_unknown_error_occurred": "Došlo k neznámé chybě",
"authentication_process_was_aborted": "Proces přihlášení byl přerušen",
"error_occurred_with_authenticator": "Došlo k chybě s autentifikátorem",
@@ -71,7 +71,7 @@
"you_are_about_to_sign_in_to_the_initial_admin_account": "Chystáte se přihlásit k počátečnímu účtu správce. Kdokoli s tímto odkazem může přistupovat k účtu, dokud nebude přidán přístupový účet. Prosím nastavte přístupový klíč co nejdříve, abyste zabránili neoprávněnému přístupu.",
"continue": "Pokračovat",
"alternative_sign_in": "Alternativní přihlášení",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "If you don't have access to your passkey, you can sign in using one of the following methods.",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Pokud nemáte přístup k Vašemu přístupovému klíči, můžete se přihlášit pomocí jedné z následujících metod.",
"use_your_passkey_instead": "Namísto toho použít svůj přístupový klíč?",
"email_login": "Přihlášení e-mailem",
"enter_a_login_code_to_sign_in": "Pro přihlášení zadejte přihlašovací kód.",
@@ -156,7 +156,7 @@
"actions": "Akce",
"images_updated_successfully": "Obrázky úspěšně aktualizovány",
"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": "Povolte e-mailová oznámení pro upozornění uživatelů, pokud je zjištěno přihlášení z nového zařízení nebo lokace.",
"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.",
"images": "Obrázky",
@@ -180,7 +180,10 @@
"enabled_emails": "Povolené e-maily",
"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í.",
"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": "Přihlašovací kód e-mailu vyžádaný uživatelem",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Umožňuje uživatelům přihlásit se pomocí přihlašovacího kódu, který je odeslán na jejich e-mail. To výrazně snižuje bezpečnost, protože každý, kdo má přístup k e-mailu uživatele, může vstoupit.",
"email_login_code_from_admin": "Poslat e-mail přihlašovacímu kódu od administrátora",
"allows_an_admin_to_send_a_login_code_to_the_user": "Umožňuje administrátorovi odeslat přihlašovací kód uživateli e-mailem.",
"send_test_email": "Odeslat testovací e-mail",
"application_configuration_updated_successfully": "Nastavení aplikace bylo úspěšně aktualizováno",
"application_name": "Název aplikace",
@@ -268,7 +271,7 @@
"add_oidc_client": "Přidat OIDC klienta",
"manage_oidc_clients": "Spravovat OIDC klienty",
"one_time_link": "Jednorázový odkaz",
"use_this_link_to_sign_in_once": "Use this link to sign in once. This is needed for users who haven't added a passkey yet or have lost it.",
"use_this_link_to_sign_in_once": "Pomocí tohoto odkazu se přihlásíte jednou. Toto je to nutné pro uživatele, kteří ještě nepřidali přístupový klíč nebo jej ztratili.",
"add": "Přidat",
"callback_urls": "URL zpětného volání",
"logout_callback_urls": "URL zpětného volání při odhlášení",
@@ -313,25 +316,31 @@
"reset_to_default": "Obnovit výchozí",
"profile_picture_has_been_reset": "Profilový obrázek byl obnoven. Aktualizace může trvat několik minut.",
"select_the_language_you_want_to_use": "Vyberte jazyk, který chcete použít. Některé jazyky nemusí být plně přeloženy.",
"personal": "Personal",
"global": "Global",
"all_users": "All Users",
"all_events": "All Events",
"all_clients": "All Clients",
"global_audit_log": "Global Audit Log",
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
"token_sign_in": "Token Sign In",
"client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization",
"disable_animations": "Disable Animations",
"turn_off_all_animations_throughout_the_admin_ui": "Turn off all animations throughout the Admin UI.",
"user_disabled": "Account Disabled",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
"user_disabled_successfully": "User has been disabled successfully.",
"user_enabled_successfully": "User has been enabled successfully.",
"personal": "Osobní",
"global": "Globální",
"all_users": "Všichni uživatelé",
"all_events": "Všechny události",
"all_clients": "Všichni klienti",
"global_audit_log": "Globální protokol auditu",
"see_all_account_activities_from_the_last_3_months": "Zobrazit veškerou aktivitu uživatele za poslední 3 měsíce.",
"token_sign_in": "Přihlášení tokenem",
"client_authorization": "Autorizace klienta",
"new_client_authorization": "Nová autorizace klienta",
"disable_animations": "Zakázat animace",
"turn_off_all_animations_throughout_the_admin_ui": "Vypnout všechny animace v celém administrátorském rozhraní.",
"user_disabled": "Účet deaktivován",
"disabled_users_cannot_log_in_or_use_services": "Zakázaní uživatelé se nemohou přihlásit nebo používat služby.",
"user_disabled_successfully": "Uživatel byl úspěšně deaktivován.",
"user_enabled_successfully": "Uživatel byl úspěšně aktivován.",
"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."
"disable_firstname_lastname": "Deaktivovat {firstName} {lastName}",
"are_you_sure_you_want_to_disable_this_user": "Opravdu chcete zakázat tohoto uživatele? Nebude se moci přihlásit nebo přistupovat k žádným službám.",
"ldap_soft_delete_users": "Ponechat zakázané uživatele z LDAP.",
"ldap_soft_delete_users_description": "Pokud je povoleno, uživatelé odebráni z LDAP budou deaktivování namísto odstranění ze systému.",
"login_code_email_success": "Přihlašovací kód byl odeslán uživateli.",
"send_email": "Odeslat e-mail",
"show_code": "Zobrazit kód",
"callback_url_description": "URL poskytnuté klientem. Klientské zástupné znaky (*) jsou podporovány, ale raději se jim vyhýbejte, pro lepší bezpečnost.",
"api_key_expiration": "Vypršení platnosti API klíče",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Pošlete uživateli e-mail, jakmile jejich API klíč brzy vyprší."
}

View File

@@ -36,7 +36,7 @@
"generate_code": "Code erzeugen",
"name": "Name",
"browser_unsupported": "Browser nicht unterstützt",
"this_browser_does_not_support_passkeys": "This browser doesn't support passkeys. Please use an alternative sign in method.",
"this_browser_does_not_support_passkeys": "Dieser Browser unterstützt keine Passkeys. Bitte verwende eine alternative Anmeldemethode.",
"an_unknown_error_occurred": "Ein unbekannter Fehler ist aufgetreten",
"authentication_process_was_aborted": "Der Authentifizierungsprozess wurde abgebrochen",
"error_occurred_with_authenticator": "Beim Authentifikator ist ein Fehler aufgetreten",
@@ -71,7 +71,7 @@
"you_are_about_to_sign_in_to_the_initial_admin_account": "Du bist dabei, dich beim initialen Administratorkonto anzumelden. Jeder, der diesen Link hat, kann auf das Konto zugreifen, bis ein Passkey hinzugefügt wird. Bitte richte so schnell wie möglich einen Passkey ein, um unbefugten Zugriff zu verhindern.",
"continue": "Fortsetzen",
"alternative_sign_in": "Alternative Anmeldemethoden",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "If you don't have access to your passkey, you can sign in using one of the following methods.",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Wenn du keinen Zugang zu deinem Passkey hast, kannst du dich mit einer der folgenden Methoden anmelden.",
"use_your_passkey_instead": "Deinen Passkey stattdessen verwenden?",
"email_login": "E-Mail Anmeldung",
"enter_a_login_code_to_sign_in": "Gib einen Anmeldecode zum Anmelden ein.",
@@ -156,7 +156,7 @@
"actions": "Aktionen",
"images_updated_successfully": "Bild erfolgreich aktualisiert",
"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": "Aktiviere E-Mail Benachrichtigungen, um Benutzer zu informieren, wenn ein Login von einem neuen Gerät oder Standort erkannt wird.",
"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.",
"images": "Bilder",
@@ -180,7 +180,10 @@
"enabled_emails": "E-Mails aktivieren",
"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.",
"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": "E-Mail-Logincode angefordert vom Benutzer",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Ermöglicht Benutzern, den Passkey zu umgehen, indem sie das Senden eines Logincodes an ihre E-Mail-Adresse anfordern. Dies reduziert die Sicherheit erheblich, da jeder, der Zugriff auf die E-Mail des Benutzers hat, Zugang bekommen kann.",
"email_login_code_from_admin": "E-Mail-Logincode von Administratoren",
"allows_an_admin_to_send_a_login_code_to_the_user": "Erlaube Administratoren das Senden von Logincodes an den Nutzer via E-Mail.",
"send_test_email": "Test-E-Mail senden",
"application_configuration_updated_successfully": "Anwendungskonfiguration erfolgreich aktualisiert",
"application_name": "Anwendungsname",
@@ -268,7 +271,7 @@
"add_oidc_client": "OIDC Client hinzufügen",
"manage_oidc_clients": "OIDC Clients verwalten",
"one_time_link": "Einmallink",
"use_this_link_to_sign_in_once": "Use this link to sign in once. This is needed for users who haven't added a passkey yet or have lost it.",
"use_this_link_to_sign_in_once": "Benutze diesen Link, um dich einmal anzumelden. Dieser wird für Benutzer benötigt, die noch keinen Passkey hinzugefügt haben oder diesen verloren haben.",
"add": "Hinzufügen",
"callback_urls": "Callback URLs",
"logout_callback_urls": "Abmelde Callback URLs",
@@ -323,9 +326,9 @@
"token_sign_in": "Token-Anmeldung",
"client_authorization": "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",
"disable_animations": "Animationen deaktivieren",
"turn_off_all_animations_throughout_the_admin_ui": "Schalte alle Animationen im Admin UI aus.",
"user_disabled": "Account deaktiviert",
"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.",
@@ -333,5 +336,11 @@
"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."
"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": "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",
"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",
@@ -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": "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",
"application_configuration_updated_successfully": "Application configuration updated successfully",
"application_name": "Application Name",
@@ -333,5 +336,15 @@
"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."
"ldap_soft_delete_users_description": "When enabled, users removed from LDAP will be disabled rather than deleted from the system.",
"login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email",
"show_code": "Show Code",
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API Key Expiration",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
"authorize_device": "Authorize Device",
"the_device_has_been_authorized": "The device has been authorized.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
"authorize": "Authorize"
}

View File

@@ -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": "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",
"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",
@@ -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": "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",
"application_configuration_updated_successfully": "Application configuration updated successfully",
"application_name": "Application Name",
@@ -333,5 +336,11 @@
"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."
"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": "Image mise à jour avec succès",
"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",
"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",
@@ -180,7 +180,10 @@
"enabled_emails": "Emails activés",
"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.",
"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": "",
"application_configuration_updated_successfully": "Mise à jour de l'application avec succès",
"application_name": "Nom de l'application",
@@ -333,5 +336,11 @@
"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."
"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": "Azioni",
"images_updated_successfully": "Immagini aggiornate con successo",
"general": "Generale",
"enable_email_notifications_to_alert_users_when_a_login_is_detected_from_a_new_device_or_location": "Abilita le notifiche email per avvisare gli utenti quando viene rilevato un accesso da un nuovo dispositivo o posizione.",
"configure_smtp_to_send_emails": "Abilita le notifiche email per avvisare gli utenti quando viene rilevato un accesso da un nuovo dispositivo o posizione.",
"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",
@@ -180,7 +180,10 @@
"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": "Codice di accesso email richiesto dall'utente",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Consente agli utenti di accedere con un codice di accesso inviato alla loro email. Questo riduce significativamente la sicurezza poiché chiunque abbia accesso all'email dell'utente può ottenere l'accesso.",
"email_login_code_from_admin": "Codice di accesso email da Admin",
"allows_an_admin_to_send_a_login_code_to_the_user": "Consente ad un amministratore di inviare un codice di accesso all'utente via email.",
"send_test_email": "Invia email di prova",
"application_configuration_updated_successfully": "Configurazione dell'applicazione aggiornata con successo",
"application_name": "Nome dell'applicazione",
@@ -324,14 +327,20 @@
"client_authorization": "Autorizzazione client",
"new_client_authorization": "Nuova autorizzazione client",
"disable_animations": "Disabilita animazioni",
"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."
"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": "Il codice di accesso è stato inviato all'utente.",
"send_email": "Invia email",
"show_code": "Mostra codice",
"callback_url_description": "URL forniti dal tuo client. Wildcard (*) sono supportati, ma meglio evitarli per una migliore sicurezza.",
"api_key_expiration": "Scadenza Chiave API",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Invia un'email all'utente quando la sua chiave API sta per scadere."
}

View File

@@ -156,7 +156,7 @@
"actions": "Acties",
"images_updated_successfully": "Afbeeldingen succesvol bijgewerkt",
"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",
"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",
@@ -180,7 +180,10 @@
"enabled_emails": "Ingeschakelde e-mails",
"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.",
"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",
"application_configuration_updated_successfully": "Toepassingsconfiguratie succesvol bijgewerkt",
"application_name": "Toepassingsnaam",
@@ -333,5 +336,11 @@
"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."
"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",
"images_updated_successfully": "Imagens atualizadas com sucesso",
"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",
"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",
@@ -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": "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",
"application_configuration_updated_successfully": "Application configuration updated successfully",
"application_name": "Application Name",
@@ -333,5 +336,11 @@
"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."
"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": "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",
"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",
@@ -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": "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",
"application_configuration_updated_successfully": "Application configuration updated successfully",
"application_name": "Application Name",
@@ -333,5 +336,11 @@
"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."
"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": "Действия",
"images_updated_successfully": "Изображения успешно обновлены",
"general": "Основное",
"enable_email_notifications_to_alert_users_when_a_login_is_detected_from_a_new_device_or_location": "Включить уведомления пользователей по электронной почте при обнаружении логина с нового устройства или локации.",
"configure_smtp_to_send_emails": "Включить уведомления пользователей по электронной почте при обнаружении логина с нового устройства или локации.",
"ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Настроить конфигурацию LDAP для синхронизации пользователей и групп с сервером LDAP.",
"images": "Изображения",
@@ -180,7 +180,10 @@
"enabled_emails": "Отправляемые письма",
"email_login_notification": "Уведомление о логине по электронной почте",
"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": "Код входа по электронной почте, запрошенный пользователем",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Позволяет пользователям обходить вход через passkey, запросив код входа, отправляемый на их электронную почту. Это значительно снижает безопасность так как любой человек, имеющий доступ к электронной почте пользователя, может получить доступ.",
"email_login_code_from_admin": "Код входа по электронной почте от администратора",
"allows_an_admin_to_send_a_login_code_to_the_user": "Позволяет администратору отправлять код входа пользователю по электронной почте.",
"send_test_email": "Отправить тестовое письмо",
"application_configuration_updated_successfully": "Конфигурация приложения успешно обновлена",
"application_name": "Название приложения",
@@ -324,14 +327,20 @@
"client_authorization": "Авторизация в клиенте",
"new_client_authorization": "Новая авторизация в клиенте",
"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."
"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": "Код входа был отправлен пользователю.",
"send_email": "Отправить письмо",
"show_code": "Показать код",
"callback_url_description": "URL-адреса, предоставленные клиентом. Поддерживаются wildcard-адреса (*), но лучше всего избегать их для лучшей безопасности.",
"api_key_expiration": "Истечение срока действия API ключа",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Отправлять пользователю письмо, когда истечет срок действия API ключа."
}

View File

@@ -0,0 +1,346 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"my_account": "账户",
"logout": "登出",
"confirm": "确认",
"key": "Key",
"value": "Value",
"remove_custom_claim": "移除自定义声明",
"add_custom_claim": "添加自定义声明",
"add_another": "添加另一个",
"select_a_date": "选择日期",
"select_file": "选择文件",
"profile_picture": "头像",
"profile_picture_is_managed_by_ldap_server": "头像由 LDAP 服务器管理,无法在此处更改。",
"click_profile_picture_to_upload_custom": "点击头像来从文件中上传您的自定义头像。",
"image_should_be_in_format": "图片应为 PNG 或 JPEG 格式。",
"items_per_page": "每页条数",
"no_items_found": "🌱 这里暂时空空如也。",
"search": "搜索...",
"expand_card": "展开卡片",
"copied": "已复制",
"click_to_copy": "点击复制",
"something_went_wrong": "出了点问题",
"go_back_to_home": "返回首页",
"dont_have_access_to_your_passkey": "无法使用您的通行密钥?试试其他登录方式。",
"login_background": "登录页背景图",
"logo": "Logo",
"login_code": "临时登录码",
"create_a_login_code_to_sign_in_without_a_passkey_once": "创建一个临时登录码,用户可以使用它一次性登录而无需通行密钥。",
"one_hour": "1 小时",
"twelve_hours": "12 小时",
"one_day": "1 天",
"one_week": "1 周",
"one_month": "1 个月",
"expiration": "到期时间",
"generate_code": "生成代码",
"name": "名称",
"browser_unsupported": "浏览器不支持",
"this_browser_does_not_support_passkeys": "此浏览器不支持通行密钥。请使用其他登录方式。",
"an_unknown_error_occurred": "发生未知错误",
"authentication_process_was_aborted": "认证过程被中止",
"error_occurred_with_authenticator": "认证器发生错误",
"authenticator_does_not_support_discoverable_credentials": "认证器不支持可发现的凭据",
"authenticator_does_not_support_resident_keys": "认证器不支持常驻密钥",
"passkey_was_previously_registered": "此通行密钥之前已注册",
"authenticator_does_not_support_any_of_the_requested_algorithms": "认证器不支持任何请求的算法",
"authenticator_timed_out": "认证器超时",
"critical_error_occurred_contact_administrator": "发生严重错误。请联系您的管理员。",
"sign_in_to": "登录到 {name}",
"client_not_found": "客户端未找到",
"client_wants_to_access_the_following_information": "<b>{client}</b> 希望访问以下信息:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "您是否希望使用您的 <b>{appName}</b> 账户登录到 <b>{client}</b>",
"email": "电子邮件",
"view_your_email_address": "查看您的电子邮件地址",
"profile": "个人资料",
"view_your_profile_information": "查看您的个人资料信息",
"groups": "群组",
"view_the_groups_you_are_a_member_of": "查看您所属的群组",
"cancel": "取消",
"sign_in": "登录",
"try_again": "重试",
"client_logo": "客户端标志",
"sign_out": "登出",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "您是否希望使用账户 <b>{username}</b> 登出 Pocket ID",
"sign_in_to_appname": "登录到 {appName}",
"please_try_to_sign_in_again": "请尝试重新登录。",
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "使用通行密钥或通过临时登录码进行登录",
"authenticate": "登录",
"appname_setup": "{appName} 设置",
"please_try_again": "请重试。",
"you_are_about_to_sign_in_to_the_initial_admin_account": "您即将登录到初始管理员账户。在此添加通行密钥之前,任何拥有此链接的人都可以访问该账户。请尽快设置通行密钥以防止未经授权的访问。",
"continue": "继续",
"alternative_sign_in": "替代登录方式",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "如果您无法访问您的通行密钥,可以使用以下方法之一登录。",
"use_your_passkey_instead": "改用您的通行密钥?",
"email_login": "电子邮件登录",
"enter_a_login_code_to_sign_in": "输入一次性登录码以登录。",
"request_a_login_code_via_email": "通过电子邮件请求登录代码。",
"go_back": "返回",
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "如果系统中存在提供的电子邮件地址,则已发送一封电子邮件。",
"enter_code": "输入登录码",
"enter_your_email_address_to_receive_an_email_with_a_login_code": "输入您的电子邮件地址以接收包含登录代码的电子邮件。",
"your_email": "您的电子邮件",
"submit": "提交",
"enter_the_code_you_received_to_sign_in": "输入您收到的登录码以登录。",
"code": "Code",
"invalid_redirect_url": "无效的重定向 URL",
"audit_log": "日志",
"users": "用户",
"user_groups": "用户组",
"oidc_clients": "OIDC 客户端",
"api_keys": "API 密钥",
"application_configuration": "设置",
"settings": "设置",
"update_pocket_id": "更新 Pocket ID",
"powered_by": "Powered by",
"see_your_account_activities_from_the_last_3_months": "查看过去 3 个月的账户活动。",
"time": "时间",
"event": "事件",
"approximate_location": "大致位置",
"ip_address": "IP 地址",
"device": "设备",
"client": "客户端",
"unknown": "未知",
"account_details_updated_successfully": "账户详细信息更新成功",
"profile_picture_updated_successfully": "头像更新成功。可能需要几分钟才能更新。",
"account_settings": "账户设置",
"passkey_missing": "尚未绑定通行密钥",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "请添加通行密钥以防止失去对账户的访问。",
"single_passkey_configured": "已添加一个通行密钥",
"it_is_recommended_to_add_more_than_one_passkey": "建议添加多个通行密钥以避免失去对账户的访问。",
"account_details": "账户详情",
"passkeys": "通行密钥",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "管理您可以用来进行身份验证的通行密钥。",
"add_passkey": "添加通行密钥",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "创建一次性登录码,以便从不同设备登录而无需通行密钥。",
"create": "创建",
"first_name": "名字",
"last_name": "姓氏",
"username": "用户名",
"save": "保存",
"username_can_only_contain": "用户名只能包含小写字母、数字、下划线、点、连字符和 '@' 符号",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "使用以下代码登录。代码将在 15 分钟后过期。",
"or_visit": "或访问",
"added_on": "添加于",
"rename": "重命名",
"delete": "删除",
"are_you_sure_you_want_to_delete_this_passkey": "您确定要删除此通行密钥吗?",
"passkey_deleted_successfully": "通行密钥删除成功",
"delete_passkey_name": "删除 {passkeyName}",
"passkey_name_updated_successfully": "通行密钥名称更新成功",
"name_passkey": "重命名通行密钥",
"name_your_passkey_to_easily_identify_it_later": "为您的通行密钥命名,以便以后轻松识别。",
"create_api_key": "创建 API 密钥",
"add_a_new_api_key_for_programmatic_access": "添加一个新的 API 密钥用于编程访问。",
"add_api_key": "添加 API 密钥",
"manage_api_keys": "管理 API 密钥",
"api_key_created": "API 密钥已创建",
"for_security_reasons_this_key_will_only_be_shown_once": "出于安全原因,此密钥只会显示一次。请妥善保存。",
"description": "描述",
"api_key": "API 密钥",
"close": "关闭",
"name_to_identify_this_api_key": "用于识别此 API 密钥的名称。",
"expires_at": "过期时间",
"when_this_api_key_will_expire": "此 API 密钥的过期时间。",
"optional_description_to_help_identify_this_keys_purpose": "可选描述,帮助识别此密钥的用途。",
"name_must_be_at_least_3_characters": "名称必须至少为 3 个字符",
"name_cannot_exceed_50_characters": "名称不能超过 50 个字符",
"expiration_date_must_be_in_the_future": "过期日期必须是未来的日期",
"revoke_api_key": "撤销 API 密钥",
"never": "永不",
"revoke": "撤销",
"api_key_revoked_successfully": "API 密钥撤销成功",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "您确定要撤销 API 密钥 \"{apiKeyName}\" 吗?这将中断使用此密钥的任何集成。",
"last_used": "最后使用",
"actions": "操作",
"images_updated_successfully": "图片更新成功",
"general": "常规",
"configure_smtp_to_send_emails": "启用电子邮件通知,以便在新设备或位置检测到登录时提醒用户。",
"ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "配置 LDAP 设置以从 LDAP 服务器同步用户和群组。",
"images": "图片",
"update": "更新",
"email_configuration_updated_successfully": "电子邮件配置更新成功",
"save_changes_question": "保存更改?",
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "在发送测试电子邮件之前,您必须保存更改。是否现在保存?",
"save_and_send": "保存并发送",
"test_email_sent_successfully": "测试电子邮件已成功发送到您的电子邮件地址。",
"failed_to_send_test_email": "发送测试电子邮件失败。请检查服务器日志以获取更多信息。",
"smtp_configuration": "SMTP 配置",
"smtp_host": "SMTP 主机",
"smtp_port": "SMTP 端口",
"smtp_user": "SMTP 用户",
"smtp_password": "SMTP 密码",
"smtp_from": "SMTP 发件人",
"smtp_tls_option": "SMTP TLS 选项",
"email_tls_option": "电子邮件 TLS 选项",
"skip_certificate_verification": "跳过证书验证",
"this_can_be_useful_for_selfsigned_certificates": "这对于自签名证书很有用。",
"enabled_emails": "启用的电子邮件",
"email_login_notification": "电子邮件登录通知",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "当用户从新设备登录时,向其发送电子邮件。",
"emai_login_code_requested_by_user": "用户请求的电子邮件登录代码",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "允许用户通过发送到其电子邮件的登录代码登录。这会显著降低安全性,因为任何有权访问用户电子邮件的人都可以进入。",
"email_login_code_from_admin": "管理员发送的电子邮件登录代码",
"allows_an_admin_to_send_a_login_code_to_the_user": "允许管理员通过电子邮件向用户发送登录代码。",
"send_test_email": "发送测试电子邮件",
"application_configuration_updated_successfully": "应用配置更新成功",
"application_name": "应用名称",
"session_duration": "会话持续时间",
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "用户需要再次登录之前的会话持续时间(分钟)。",
"enable_self_account_editing": "启用自助账户编辑",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "用户是否应能够编辑自己的账户详细信息。",
"emails_verified": "已验证的邮箱地址",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "用户的电子邮件是否应标记为已验证,适用于 OIDC 客户端。",
"ldap_configuration_updated_successfully": "LDAP 配置更新成功",
"ldap_disabled_successfully": "LDAP 禁用成功",
"ldap_sync_finished": "LDAP 同步完成",
"client_configuration": "客户端配置",
"ldap_url": "LDAP URL",
"ldap_bind_dn": "LDAP Bind DN",
"ldap_bind_password": "LDAP Bind Password",
"ldap_base_dn": "LDAP Base DN",
"user_search_filter": "User Search Filter",
"the_search_filter_to_use_to_search_or_sync_users": "用于搜索/同步用户的搜索过滤器。",
"groups_search_filter": "Groups Search Filter",
"the_search_filter_to_use_to_search_or_sync_groups": "用于搜索/同步群组的搜索过滤器。",
"attribute_mapping": "属性映射",
"user_unique_identifier_attribute": "User Unique Identifier Attribute",
"the_value_of_this_attribute_should_never_change": "此属性的值不应更改。",
"username_attribute": "Username Attribute",
"user_mail_attribute": "User Mail Attribute",
"user_first_name_attribute": "User First Name Attribute",
"user_last_name_attribute": "User Last Name Attribute",
"user_profile_picture_attribute": "User Profile Picture Attribute",
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "此属性的值可以是 URL、二进制或 base64 编码的图像。",
"group_members_attribute": "Group Members Attribute",
"the_attribute_to_use_for_querying_members_of_a_group": "用于查询群组成员的属性。",
"group_unique_identifier_attribute": "Group Unique Identifier Attribute",
"group_name_attribute": "Group Name Attribute",
"admin_group_name": "Admin Group Name",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "此群组的成员将在 Pocket ID 中拥有管理员权限。",
"disable": "禁用",
"sync_now": "立即同步",
"enable": "启用",
"user_created_successfully": "用户创建成功",
"create_user": "创建用户",
"add_a_new_user_to_appname": "向 {appName} 添加新用户",
"add_user": "添加用户",
"manage_users": "管理用户",
"admin_privileges": "管理员权限",
"admins_have_full_access_to_the_admin_panel": "管理员拥有管理面板的完全访问权限。",
"delete_firstname_lastname": "删除 {firstName} {lastName}",
"are_you_sure_you_want_to_delete_this_user": "您确定要删除此用户吗?",
"user_deleted_successfully": "用户删除成功",
"role": "角色",
"source": "来源",
"admin": "管理员",
"user": "用户",
"local": "本地",
"toggle_menu": "切换菜单",
"edit": "编辑",
"user_groups_updated_successfully": "用户组更新成功",
"user_updated_successfully": "用户更新成功",
"custom_claims_updated_successfully": "自定义声明更新成功",
"back": "返回",
"user_details_firstname_lastname": "用户详情 {firstName} {lastName}",
"manage_which_groups_this_user_belongs_to": "管理此用户所属的群组。",
"custom_claims": "自定义声明",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "自定义声明是键值对,可用于存储有关用户的额外信息。如果请求了 \"profile\" 范围,这些声明将包含在 ID Token 中。",
"user_group_created_successfully": "用户组创建成功",
"create_user_group": "创建用户组",
"create_a_new_group_that_can_be_assigned_to_users": "创建一个可以分配给用户的新群组。",
"add_group": "添加群组",
"manage_user_groups": "管理用户组",
"friendly_name": "显示名称",
"name_that_will_be_displayed_in_the_ui": "将在用户界面中显示的名称",
"name_that_will_be_in_the_groups_claim": "将在 \"groups\" 声明中显示的名称",
"delete_name": "删除 {name}",
"are_you_sure_you_want_to_delete_this_user_group": "您确定要删除此用户组吗?",
"user_group_deleted_successfully": "用户组删除成功",
"user_count": "用户数",
"user_group_updated_successfully": "用户组更新成功",
"users_updated_successfully": "用户更新成功",
"user_group_details_name": "用户组详情 {name}",
"assign_users_to_this_group": "将用户分配到此群组。",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "自定义声明是键值对,可用于存储有关用户的额外信息。如果请求了 'profile' 范围,这些声明将包含在 ID 令牌中。如果存在冲突,用户上定义的自定义声明将优先。",
"oidc_client_created_successfully": "OIDC 客户端创建成功",
"create_oidc_client": "创建 OIDC 客户端",
"add_a_new_oidc_client_to_appname": "向 {appName} 添加新的 OIDC 客户端。",
"add_oidc_client": "添加 OIDC 客户端",
"manage_oidc_clients": "管理 OIDC 客户端",
"one_time_link": "一次性链接",
"use_this_link_to_sign_in_once": "使用此链接一次性登录。这对于尚未添加通行密钥或丢失通行密钥的用户是必要的。",
"add": "添加",
"callback_urls": "Callback URL",
"logout_callback_urls": "Logout Callback URL",
"public_client": "公共客户端",
"public_clients_do_not_have_a_client_secret_and_use_pkce_instead": "公共客户端没有客户端密钥,而是使用 PKCE。如果您的客户端是 SPA 或移动应用,请启用此选项。",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "公钥代码交换是一种安全功能,可防止 CSRF 和授权代码拦截攻击。",
"name_logo": "{name} Logo",
"change_logo": "更改 Logo",
"upload_logo": "上传 Logo",
"remove_logo": "移除 Logo",
"are_you_sure_you_want_to_delete_this_oidc_client": "您确定要删除此 OIDC 客户端吗?",
"oidc_client_deleted_successfully": "OIDC 客户端删除成功",
"authorization_url": "Authorization URL",
"oidc_discovery_url": "OIDC Discovery URL",
"token_url": "Token URL",
"userinfo_url": "Userinfo URL",
"logout_url": "Logout URL",
"certificate_url": "Certificate URL",
"enabled": "已启用",
"disabled": "已禁用",
"oidc_client_updated_successfully": "OIDC 客户端更新成功",
"create_new_client_secret": "创建新的客户端密钥",
"are_you_sure_you_want_to_create_a_new_client_secret": "您确定要创建新的客户端密钥吗?旧的密钥将被失效。",
"generate": "生成",
"new_client_secret_created_successfully": "新客户端密钥创建成功",
"allowed_user_groups_updated_successfully": "允许的用户组更新成功",
"oidc_client_name": "OIDC 客户端 {name}",
"client_id": "客户端 ID",
"client_secret": "客户端密钥",
"show_more_details": "显示更多详情",
"allowed_user_groups": "允许的用户组",
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "将用户组添加到此客户端以限制访问,仅允许这些组中的用户。如果未选择用户组,所有用户都将有权访问此客户端。",
"favicon": "网站图标",
"light_mode_logo": "浅色模式 Logo",
"dark_mode_logo": "深色模式 Logo",
"background_image": "背景图片",
"language": "语言",
"reset_profile_picture_question": "重置头像?",
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "这将移除已上传的图片,并将头像重置为默认值。您是否要继续?",
"reset": "重置",
"reset_to_default": "重置为默认",
"profile_picture_has_been_reset": "头像已重置。可能需要几分钟才能更新。",
"select_the_language_you_want_to_use": "选择您要使用的语言。某些语言可能未完全翻译。",
"personal": "个人",
"global": "全局",
"all_users": "所有用户",
"all_events": "所有事件",
"all_clients": "所有客户端",
"global_audit_log": "全局日志",
"see_all_account_activities_from_the_last_3_months": "查看过去 3 个月的所有用户活动。",
"token_sign_in": "Token 登录",
"client_authorization": "客户端授权",
"new_client_authorization": "首次客户端授权",
"disable_animations": "禁用动画",
"turn_off_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": "登录代码已发送给用户。",
"send_email": "发送电子邮件",
"show_code": "显示登录码",
"callback_url_description": "由您的客户端提供的 URL。支持通配符 (*),但为了更好的安全性最好避免使用。",
"api_key_expiration": "API 密钥过期",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "当用户的 API 密钥即将过期时,向其发送电子邮件。"
}

View File

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

View File

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

View File

@@ -24,16 +24,14 @@ const authenticationHandle: Handle = async ({ event, resolve }) => {
const { isSignedIn, isAdmin } = verifyJwt(event.cookies.get(ACCESS_TOKEN_COOKIE_NAME));
const isUnauthenticatedOnlyPath = event.url.pathname.startsWith('/login') || event.url.pathname.startsWith('/lc')
const isPublicPath = ['/authorize', '/health'].includes(event.url.pathname);
const isPublicPath = ['/authorize', '/device', '/health'].includes(event.url.pathname);
const isAdminPath = event.url.pathname.startsWith('/settings/admin');
if (!isUnauthenticatedOnlyPath && !isPublicPath) {
if (!isSignedIn) {
return new Response(null, {
status: 302,
headers: { location: '/login' }
});
}
if (!isUnauthenticatedOnlyPath && !isPublicPath && !isSignedIn) {
return new Response(null, {
status: 302,
headers: { location: '/login' }
});
}
if (isUnauthenticatedOnlyPath && isSignedIn) {
@@ -81,7 +79,7 @@ function verifyJwt(accessToken: string | undefined) {
const jwtPayload = decodeJwt<{ isAdmin: boolean }>(accessToken);
if (jwtPayload?.exp && jwtPayload.exp * 1000 > Date.now()) {
isSignedIn = true;
isAdmin = jwtPayload?.isAdmin || false;
isAdmin = !!(jwtPayload?.isAdmin);
}
}

View File

@@ -5,7 +5,7 @@
import Logo from '../logo.svelte';
import HeaderAvatar from './header-avatar.svelte';
const authUrls = [/^\/authorize$/, /^\/login(?:\/.*)?$/, /^\/logout$/];
const authUrls = [/^\/authorize$/, /^\/device$/, /^\/login(?:\/.*)?$/, /^\/logout$/];
let isAuthPage = $derived(
!page.error && authUrls.some((pattern) => pattern.test(page.url.pathname))

View File

@@ -9,8 +9,10 @@
import { Separator } from '$lib/components/ui/separator';
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import { axiosErrorToast } from '$lib/utils/error-util';
import { mode } from 'mode-watcher';
import { toast } from 'svelte-sonner';
let {
userId = $bindable()
@@ -32,7 +34,7 @@
[m.one_month()]: 60 * 60 * 24 * 30
};
async function createOneTimeAccessToken() {
async function createLoginCode() {
try {
const expiration = new Date(Date.now() + availableExpirations[selectedExpiration] * 1000);
code = await userService.createOneTimeAccessToken(expiration, userId!);
@@ -42,6 +44,17 @@
}
}
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) {
axiosErrorToast(e);
}
}
function onOpenChange(open: boolean) {
if (!open) {
oneTimeLink = null;
@@ -81,13 +94,20 @@
</Select.Content>
</Select.Root>
</div>
<Button
onclick={() => createOneTimeAccessToken()}
disabled={!selectedExpiration}
class="mt-2 w-full"
>
{m.generate_code()}
</Button>
<Dialog.Footer class="mt-2">
{#if $appConfigStore.emailOneTimeAccessAsAdminEnabled}
<Button
onclick={() => sendLoginCodeEmail()}
variant="secondary"
disabled={!selectedExpiration}
>
{m.send_email()}
</Button>
{/if}
<Button onclick={() => createLoginCode()} disabled={!selectedExpiration}
>{m.show_code()}</Button
>
</Dialog.Footer>
{:else}
<div class="flex flex-col items-center gap-2">
<CopyToClipboard value={code!}>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages';
import { LucideMail, LucideUser, LucideUsers } from 'lucide-svelte';
import ScopeItem from './scope-item.svelte';
let { scope }: { scope: string } = $props();
</script>
<div class="flex flex-col gap-3" data-testid="scopes">
{#if scope!.includes('email')}
<ScopeItem icon={LucideMail} name={m.email()} description={m.view_your_email_address()} />
{/if}
{#if scope!.includes('profile')}
<ScopeItem
icon={LucideUser}
name={m.profile()}
description={m.view_your_profile_information()}
/>
{/if}
{#if scope!.includes('groups')}
<ScopeItem
icon={LucideUsers}
name={m.groups()}
description={m.view_the_groups_you_are_a_member_of()}
/>
{/if}
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import type {
AuthorizeResponse,
OidcDeviceCodeInfo,
OidcClient,
OidcClientCreate,
OidcClientMetaData,
@@ -8,6 +9,7 @@ import type {
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import APIService from './api-service';
class OidcService extends APIService {
async authorize(
clientId: string,
@@ -92,6 +94,15 @@ class OidcService extends APIService {
const res = await this.api.put(`/oidc/clients/${id}/allowed-user-groups`, { userGroupIds });
return res.data as OidcClientWithAllowedUserGroups;
}
async verifyDeviceCode(userCode: string) {
return await this.api.post(`/oidc/device/verify?code=${userCode}`);
}
async getDeviceCodeInfo(userCode: string): Promise<OidcDeviceCodeInfo> {
const response = await this.api.get(`/oidc/device/info?code=${userCode}`);
return response.data;
}
}
export default OidcService;

View File

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

View File

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

View File

@@ -23,6 +23,12 @@ export type OidcClientCreateWithLogo = OidcClientCreate & {
logo: File | null | undefined;
};
export type OidcDeviceCodeInfo = {
scope: string;
authorizationRequired: boolean;
client: OidcClientMetaData;
};
export type AuthorizeResponse = {
code: string;
callbackURL: string;

View File

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

View File

@@ -1,6 +0,0 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
return redirect(302, '/login');
};

View File

@@ -0,0 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load: PageLoad = async () => {
return redirect(302, '/login');
};

View File

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

View File

@@ -1,7 +1,9 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => {
const code = url.searchParams.get('code');
return {
redirect: url.searchParams.get('redirect') || undefined
code
};
};

View File

@@ -0,0 +1,124 @@
<script lang="ts">
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import ScopeList from '$lib/components/scope-list.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { m } from '$lib/paraglide/messages';
import OIDCService from '$lib/services/oidc-service';
import WebAuthnService from '$lib/services/webauthn-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import type { OidcDeviceCodeInfo } from '$lib/types/oidc.type';
import { getAxiosErrorMessage } from '$lib/utils/error-util';
import { startAuthentication } from '@simplewebauthn/browser';
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import ClientProviderImages from '../authorize/components/client-provider-images.svelte';
import LoginLogoErrorSuccessIndicator from '../login/components/login-logo-error-success-indicator.svelte';
let { data } = $props();
const oidcService = new OIDCService();
const webauthnService = new WebAuthnService();
let userCode = $state(data.code || '');
let isLoading = $state(false);
let deviceInfo: OidcDeviceCodeInfo | undefined = $state();
let success = $state(false);
let errorMessage: string | null = $state(null);
let authorizationRequired = $state(false);
onMount(() => {
if (data.code && $userStore) {
authorize();
}
});
async function authorize() {
isLoading = true;
try {
// Get access token if not signed in
if (!$userStore) {
const loginOptions = await webauthnService.getLoginOptions();
const authResponse = await startAuthentication(loginOptions);
const user = await webauthnService.finishLogin(authResponse);
userStore.setUser(user);
}
const info = await oidcService.getDeviceCodeInfo(userCode);
deviceInfo = info;
if (info.authorizationRequired && !authorizationRequired) {
authorizationRequired = true;
isLoading = false;
return;
}
await oidcService.verifyDeviceCode(userCode);
success = true;
} catch (e) {
errorMessage = getAxiosErrorMessage(e);
} finally {
isLoading = false;
}
}
</script>
<svelte:head>
<title>{m.authorize_device()}</title>
</svelte:head>
<SignInWrapper
animate={!$appConfigStore.disableAnimations}
showAlternativeSignInMethodButton={$userStore == null}
>
<div class="flex justify-center">
{#if deviceInfo?.client}
<ClientProviderImages client={deviceInfo.client} {success} error={!!errorMessage} />
{:else}
<LoginLogoErrorSuccessIndicator {success} error={!!errorMessage} />
{/if}
</div>
<h1 class="font-playfair mt-5 text-4xl font-bold">{m.authorize_device()}</h1>
{#if errorMessage}
<p class="text-muted-foreground mt-2">
{errorMessage}. {m.please_try_again()}
</p>
{:else if success}
<p class="text-muted-foreground mt-2">{m.the_device_has_been_authorized()}</p>
{:else if authorizationRequired}
<div transition:slide={{ duration: 300 }}>
<Card.Root class="mt-6">
<Card.Header class="pb-5">
<p class="text-muted-foreground text-start">
{@html m.client_wants_to_access_the_following_information({
client: deviceInfo!.client.name
})}
</p>
</Card.Header>
<Card.Content data-testid="scopes">
<ScopeList scope={deviceInfo!.scope} />
</Card.Content>
</Card.Root>
</div>
{:else}
<p class="text-muted-foreground mt-2">{m.enter_code_displayed_in_previous_step()}</p>
<form id="device-code-form" onsubmit={authorize} class="w-full max-w-[450px]">
<Input id="user-code" class="mt-7" placeholder={m.code()} bind:value={userCode} type="text" />
</form>
{/if}
{#if !success}
<div class="mt-10 flex w-full justify-stretch gap-2">
<Button href="/" class="w-full" variant="secondary">{m.cancel()}</Button>
{#if !errorMessage}
<Button form="device-code-form" class="w-full" onclick={authorize} {isLoading}
>{m.authorize()}</Button
>
{:else}
<Button class="w-full" on:click={() => (errorMessage = null)}>{m.try_again()}</Button>
{/if}
</div>
{/if}
</SignInWrapper>

View File

@@ -1,7 +1,8 @@
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
// Alias for /login/alternative/code
export function GET({ url }) {
export const load: PageLoad = async ({ url }) => {
let targetPath = '/login/alternative/code';
if (url.searchParams.has('redirect')) {
targetPath += `?redirect=${encodeURIComponent(url.searchParams.get('redirect')!)}`;

View File

@@ -1,7 +1,8 @@
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
// Alias for /login/alternative/code?code=...
export function GET({ url, params }) {
export const load: PageLoad = async ({ url, params }) => {
const targetPath = '/login/alternative/code';
const searchParams = new URLSearchParams();

View File

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

View File

@@ -1,6 +1,6 @@
import type { PageServerLoad } from './$types';
import type { PageLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => {
export const load: PageLoad = async ({ url }) => {
return {
code: url.searchParams.get('code'),
redirect: url.searchParams.get('redirect') || '/settings'

View File

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

View File

@@ -0,0 +1,7 @@
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ url }) => {
return {
redirect: url.searchParams.get('redirect') || undefined
};
};

View File

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

View File

@@ -1,5 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export function load() {
export const load: PageLoad = async () => {
throw redirect(307, '/settings/account');
}

View File

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

View File

@@ -73,7 +73,7 @@
id="application-configuration-email"
icon={Mail}
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} />
</CollapsibleCard>

View File

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

View File

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

View File

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

View File

@@ -30,7 +30,7 @@
const formSchema = z.object({
firstName: z.string().min(1).max(50),
lastName: z.string().min(1).max(50),
lastName: z.string().max(50),
username: z
.string()
.min(2)

View File

@@ -161,4 +161,4 @@
{/snippet}
</AdvancedTable>
<OneTimeLinkModal userId={userIdToCreateOneTimeLink} />
<OneTimeLinkModal bind:userId={userIdToCreateOneTimeLink} />

View File

@@ -1,7 +1,7 @@
import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants';
import AuditLogService from '$lib/services/audit-log-service';
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 }) => {
const auditLogService = new AuditLogService(cookies.get(ACCESS_TOKEN_COOKIE_NAME));

View File

@@ -32,7 +32,9 @@ test('Update email configuration', async ({ page }) => {
await page.getByLabel('SMTP Password').fill('password');
await page.getByLabel('SMTP From').fill('test@gmail.com');
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();
@@ -46,7 +48,9 @@ test('Update email configuration', async ({ page }) => {
await expect(page.getByLabel('SMTP Password')).toHaveValue('password');
await expect(page.getByLabel('SMTP From')).toHaveValue('test@gmail.com');
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 }) => {

View File

@@ -33,7 +33,7 @@ export const oidcClients = {
id: '606c7782-f2b1-49e5-8ea9-26eb1b06d018',
name: 'Immich',
callbackUrl: 'http://immich/auth/callback',
secret: 'PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x'
secret: 'PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x',
},
pingvinShare: {
name: 'Pingvin Share',

View File

@@ -1,6 +1,7 @@
import test, { expect } from '@playwright/test';
import { accessTokens, idTokens, oidcClients, refreshTokens, users } from './data';
import { cleanupBackend } from './utils/cleanup.util';
import oidcUtil from './utils/oidc.util';
import passkeyUtil from './utils/passkey.util';
test.beforeEach(cleanupBackend);
@@ -277,3 +278,99 @@ test.describe('Introspection endpoint', () => {
expect(introspectionResponse.status()).toBe(400);
});
});
test('Authorize new client with device authorization flow', async ({ page }) => {
const client = oidcClients.immich;
const userCode = await oidcUtil.getUserCode(page, client.id, client.secret);
await page.goto(`/device?code=${userCode}`);
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Email' })).toBeVisible();
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Profile' })).toBeVisible();
await page.getByRole('button', { name: 'Authorize' }).click();
await expect(
page.getByRole('paragraph').filter({ hasText: 'The device has been authorized.' })
).toBeVisible();
});
test('Authorize new client with device authorization flow while not signed in', async ({
page
}) => {
await page.context().clearCookies();
const client = oidcClients.immich;
const userCode = await oidcUtil.getUserCode(page, client.id, client.secret);
await page.goto(`/device?code=${userCode}`);
await (await passkeyUtil.init(page)).addPasskey();
await page.getByRole('button', { name: 'Authorize' }).click();
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Email' })).toBeVisible();
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Profile' })).toBeVisible();
await page.getByRole('button', { name: 'Authorize' }).click();
await expect(
page.getByRole('paragraph').filter({ hasText: 'The device has been authorized.' })
).toBeVisible();
});
test('Authorize existing client with device authorization flow', async ({ page }) => {
const client = oidcClients.nextcloud;
const userCode = await oidcUtil.getUserCode(page, client.id, client.secret);
await page.goto(`/device?code=${userCode}`);
await expect(
page.getByRole('paragraph').filter({ hasText: 'The device has been authorized.' })
).toBeVisible();
});
test('Authorize existing client with device authorization flow while not signed in', async ({
page
}) => {
await page.context().clearCookies();
const client = oidcClients.nextcloud;
const userCode = await oidcUtil.getUserCode(page, client.id, client.secret);
await page.goto(`/device?code=${userCode}`);
await (await passkeyUtil.init(page)).addPasskey();
await page.getByRole('button', { name: 'Authorize' }).click();
await expect(
page.getByRole('paragraph').filter({ hasText: 'The device has been authorized.' })
).toBeVisible();
});
test('Authorize client with device authorization flow with invalid code', async ({ page }) => {
await page.goto('/device?code=invalid-code');
await expect(
page.getByRole('paragraph').filter({ hasText: 'Invalid device code.' })
).toBeVisible();
});
test('Authorize new client with device authorization with user group not allowed', async ({
page
}) => {
await page.context().clearCookies();
const client = oidcClients.immich;
const userCode = await oidcUtil.getUserCode(page, client.id, client.secret);
await page.goto(`/device?code=${userCode}`);
await (await passkeyUtil.init(page)).addPasskey('craig');
await page.getByRole('button', { name: 'Authorize' }).click();
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Email' })).toBeVisible();
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Profile' })).toBeVisible();
await page.getByRole('button', { name: 'Authorize' }).click();
await expect(
page.getByRole('paragraph').filter({ hasText: "You're not allowed to access this service." })
).toBeVisible();
});

View File

@@ -64,7 +64,7 @@ test('Create one time access token', async ({ page, context }) => {
await page.getByLabel('Login Code').getByRole('combobox').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();
const link = await page.getByTestId('login-code-link').textContent();
await context.clearCookies();

View File

@@ -0,0 +1,22 @@
import type { Page } from '@playwright/test';
async function getUserCode(page: Page, clientId: string, clientSecret: string) {
const response = await page.request
.post('/api/oidc/device/authorize', {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
form: {
client_id: clientId,
client_secret: clientSecret,
scope: 'openid profile email'
}
})
.then((r) => r.json());
return response.user_code;
}
export default {
getUserCode
};

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "pocket-id",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -7,6 +7,11 @@ cd backend && ./pocket-id-backend &
if [ "$CADDY_DISABLED" != "true" ]; then
echo "Starting Caddy..."
# https://caddyserver.com/docs/conventions#data-directory
export XDG_DATA_HOME=${XDG_DATA_HOME:-/app/backend/data/.local/share}
# https://caddyserver.com/docs/conventions#configuration-directory
export XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-/app/backend/data/.config}
# Check if TRUST_PROXY is set to true and use the appropriate Caddyfile
if [ "$TRUST_PROXY" = "true" ]; then
caddy run --adapter caddyfile --config /etc/caddy/Caddyfile.trust-proxy &