Compare commits

..

2 Commits

Author SHA1 Message Date
Elias Schneider
abdfaac1b0 Merge remote-tracking branch 'origin/main' into feat/custom-fields 2026-05-31 16:12:20 +02:00
Elias Schneider
3428fb35d7 feat!: replace custom claims with custom fields 2026-05-23 16:07:37 +02:00
118 changed files with 3467 additions and 2300 deletions

View File

@@ -1,8 +1,7 @@
name: 🚀 Feature
description: "Submit a proposal for a new feature"
title: "🚀 Feature: "
type: "Feature"
labels: ["needs more upvotes"]
type: 'Feature'
body:
- type: textarea
id: feature-description

View File

@@ -1,12 +0,0 @@
## What does this PR do?
## Screenshots
## AI Usage
## Contributing Guidelines
Have you read the [Contributing Guidelines](https://github.com/pocket-id/pocket-id/blob/main/CONTRIBUTING.md)?
- [ ] If I'm implementing a new feature, I have opened an issue first, or commented on an existing one, to discuss the implementation details.
- [ ] I have read the [AI Usage Guidelines](https://github.com/pocket-id/pocket-id/blob/main/CONTRIBUTING.md#ai-usage-policy).

View File

@@ -42,7 +42,7 @@ jobs:
# PR Template Checks
require-pr-template: true
strict-pr-template-sections: "Contributing Guidelines"
strict-pr-template-sections: ""
optional-pr-template-sections: "Issues"
max-additional-pr-template-sections: 3

View File

@@ -1 +1 @@
2.8.0
2.7.0

View File

@@ -1,46 +1,3 @@
## v2.8.0
### Bug Fixes
- delete refresh tokens on end-session to prevent reuse after logout ([#1458](https://github.com/pocket-id/pocket-id/pull/1458) by @wucm667)
- return 404 status code for `.well-known` routes if not found ([714b5b3](https://github.com/pocket-id/pocket-id/commit/714b5b3307bb012b94e66e8dcb1de93a2d62eceb) by @stonith404)
- add `email_verified` to reserved claims list ([8b22fca](https://github.com/pocket-id/pocket-id/commit/8b22fcaaa475984bb4c42306487035073d7c47e3) by @stonith404)
- reject unknown PKCE code challenge methods ([ce6bdb9](https://github.com/pocket-id/pocket-id/commit/ce6bdb9d7e6c0f1eaaa21ddb6d808e41088a38b8) by @stonith404)
- make stream of downloaded logos seekable for S3 checksum calculation ([cc9163f](https://github.com/pocket-id/pocket-id/commit/cc9163f577280d7c278dc744e20d0505dbe83ede) by @stonith404)
- scope confirmation wasn't shown if account selection was prompted ([272d147](https://github.com/pocket-id/pocket-id/commit/272d1479bd64b4a068bfe4dfa5ba4cd3c5e90505) by @stonith404)
- add support for unix socket mode in healthcheck ([a712196](https://github.com/pocket-id/pocket-id/commit/a71219637af1746e9faba5274b6095da676ec555) by @stonith404)
- restore cross-platform binary builds ([7027296](https://github.com/pocket-id/pocket-id/commit/7027296632ab82cd9930f2fbe60054c2421688c4) by @stonith404)
### Documentation
- update SECURITY.md ([bb5a111](https://github.com/pocket-id/pocket-id/commit/bb5a111e3d51ad56fc074df7083022e3d4e25369) by @stonith404)
### Features
- delete OAuth refresh token on RP initiated logout ([#1480](https://github.com/pocket-id/pocket-id/pull/1480) by @stonith404)
- remove EXIF/XMP metadata from uploaded images ([#1477](https://github.com/pocket-id/pocket-id/pull/1477) by @stonith404)
- add support for `response_mode=fragment` ([0c95b7c](https://github.com/pocket-id/pocket-id/commit/0c95b7c3cc171ed77b51f236009b5ff659da0bec) by @stonith404)
- add support for systemd socket activation ([#1479](https://github.com/pocket-id/pocket-id/pull/1479) by @deviant)
- improve design trough the whole application ([b3d40a4](https://github.com/pocket-id/pocket-id/commit/b3d40a476b35cfa2451749e9a3d307b7babaeb6b) by @stonith404)
### Other
- update AAGUIDs ([#1476](https://github.com/pocket-id/pocket-id/pull/1476) by @github-actions[bot])
- upgrade dependencies ([91c2ea2](https://github.com/pocket-id/pocket-id/commit/91c2ea2a6616a500922365d1789f1fa1ba66c112) by @stonith404)
- remove deprecated http2 package ([e56dc12](https://github.com/pocket-id/pocket-id/commit/e56dc124cee4d3c176c89e7d574ddfde089bcbd1) by @stonith404)
- apply go 1.26.0 syntax updated ([8ad95b8](https://github.com/pocket-id/pocket-id/commit/8ad95b8af17ba8cbb62168ed0bb54c87e3df379c) by @stonith404)
- delete refresh tokens on end-session to prevent reuse after logout ([b27a52a](https://github.com/pocket-id/pocket-id/commit/b27a52a5915228383dbdbf0a2c353452cd351d0f) by @stonith404)
- use dependabot for automatic dependency upgrades ([b9fdd53](https://github.com/pocket-id/pocket-id/commit/b9fdd530c0f370f2fccb6251d700aa6f9bdf4124) by @stonith404)
- fix invalid schema ([e8c398f](https://github.com/pocket-id/pocket-id/commit/e8c398ffbd6545f76c983f01556bb3b73d40a728) by @stonith404)
- update AAGUIDs ([#1487](https://github.com/pocket-id/pocket-id/pull/1487) by @github-actions[bot])
- use custom Playwright route for callback URL checks ([f134247](https://github.com/pocket-id/pocket-id/commit/f13424720b7f5da3978988f0f9169cf303971a13) by @stonith404)
- fix linter issues ([bc4f75c](https://github.com/pocket-id/pocket-id/commit/bc4f75c44f12a71380042ad4c140d41f4dddc7dd) by @stonith404)
- don't compare hashes of profile pictures ([9ad2bfc](https://github.com/pocket-id/pocket-id/commit/9ad2bfc7b30e0eb2d7ae1406874b8caba5a1f121) by @stonith404)
- run formatter ([0616aba](https://github.com/pocket-id/pocket-id/commit/0616abaf7f079d0a35543258e13aa5b4acb4adc0) by @stonith404)
- use fixed minor version of Go ([e046a03](https://github.com/pocket-id/pocket-id/commit/e046a03364f00001f8699aaa7e902b33c2661118) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.7.0...v2.8.0
## v2.7.0
### Bug Fixes

View File

@@ -1,35 +1,12 @@
# Contributing
We are happy that you want to contribute to Pocket ID and help to make it better!
## Before you start
Before starting to work on a new feature, please open an issue first, or comment on an existing one, to discuss the implementation details. This saves you time and avoids the disappointment of having a PR closed later because the change doesn't fit the project's direction.
### AI Usage Policy
We have nothing against using AI tools to assist your development. But we've seen a growing number of pull requests that appear to be fully or largely AI-generated and submitted without genuine human review. These are difficult and time-consuming for maintainers to review, and they often waste everyone's time.
To keep contributions reviewable and high-quality, please follow these guidelines when using AI tools.
#### Guidelines for Using AI Tools
1. **Understand every line.** You must be able to explain what your code does and why, in your own words. "The AI wrote it" is not an acceptable answer to a reviewer's question.
2. **Test before submitting.** Review and test all code manually, as a human, before opening a PR. Don't trust the AI's claim that it works.
3. **Write your own words.** Don't paste AI-generated text into issues, comments, or PR descriptions. Walls of generated text make discussions harder, not easier.
4. **Disclose your usage.** Note in the PR description how you used AI (see below).
PRs that appear to be low effort AI output may be closed without a detailed review.
#### Example disclosure
> Used GitHub Copilot for autocomplete, and asked an LLM to help draft the test cases in `parser_test.go`. I reviewed, edited, and tested everything myself and understand how it works.
> Used an LLM to generate the initial implementation of the new API endpoint, but I manually reviewed and tested it before submitting.
I am happy that you want to contribute to Pocket ID and help to make it better! All contributions are welcome, including issues, suggestions, pull requests and more.
## Getting started
### Submit a Pull Request
You've found a bug, have suggestion or something else, just create an issue on GitHub and we can get in touch.
## Submit a Pull Request
Before you submit the pull request for review please ensure that
@@ -52,15 +29,15 @@ Before you submit the pull request for review please ensure that
- Your pull request has a detailed description
- You run `pnpm format` to format the code
### Development Environment
## Development Environment
Pocket ID consists of a frontend and backend. In production the frontend gets statically served by the backend, but in development they run as separate processes to enable hot reloading.
There are two ways to get the development environment setup:
#### 1. Install required tools
### 1. Install required tools
##### With Dev Containers
#### With Dev Containers
If you use [Dev Containers](https://code.visualstudio.com/docs/remote/containers) in VS Code, you don't need to install anything manually, just follow the steps below.
@@ -69,7 +46,7 @@ If you use [Dev Containers](https://code.visualstudio.com/docs/remote/containers
3. VS Code will detect .devcontainer and will prompt you to open the folder in devcontainer
4. If the auto prompt does not work, hit `F1` and select `Dev Containers: Open Folder in Container.`, then select the pocket-id repo root folder and it'll open in container.
##### Without Dev Containers
#### Without Dev Containers
If you don't use Dev Containers, you need to install the following tools manually:
@@ -77,9 +54,9 @@ If you don't use Dev Containers, you need to install the following tools manuall
- [Go](https://golang.org/doc/install) >= 1.26
- [Git](https://git-scm.com/downloads)
#### 2. Setup
### 2. Setup
##### Backend
#### Backend
The backend is built with [Gin](https://gin-gonic.com) and written in Go. To set it up, follow these steps:
@@ -87,7 +64,7 @@ The backend is built with [Gin](https://gin-gonic.com) and written in Go. To set
2. Copy the `.env.development-example` file to `.env` and edit the variables as needed
3. Start the backend with `go run -tags exclude_frontend ./cmd`
##### Frontend
### Frontend
The frontend is built with [SvelteKit](https://kit.svelte.dev) and written in TypeScript. To set it up, follow these steps:
@@ -100,13 +77,9 @@ You're all set! The application is now listening on `localhost:3000`. The backen
### Testing
If you are contributing to a new feature please ensure that you add tests for it.
#### End-to-end tests
We are using [Playwright](https://playwright.dev) for end-to-end testing.
The tests are located in the `tests` folder at the root of the project.
If you are contributing to a new feature please ensure that you add tests for it. The tests are located in the `tests` folder at the root of the project.
The tests can be run like this:
@@ -120,9 +93,3 @@ The tests can be run like this:
5. Run the tests with `pnpm dlx playwright test` or from the root project folder `pnpm test`
If you make any changes to the application, you have to rebuild the test environment by running `docker compose up -d --build` again.
#### Unit tests
In the backend we are using unit tests with the built-in Go testing framework. The tests are located in the same folder as the code they are testing and have the `_test.go` suffix.
To run the tests, simply run `go test ./...` from the root of the `backend` folder.

View File

@@ -5,7 +5,7 @@
It's recommended to always use the latest version of Pocket ID. We will provide security updates for the latest version.
> [!NOTE]
> Updates can be automated with e.g [Watchtower](https://github.com/nicholas-fedor/watchtower/). Upgrading between non major versions is safe but you shouldn't upgrade between major versions before checking the release notes.
> Updates can be automated with e.g [Watchtower](https://github.com/containrrr/watchtower). Upgrading between non major versions is safe but you shouldn't upgrade between major versions before checking the release notes.
## Reporting a Vulnerability

View File

@@ -6,7 +6,4 @@ APP_ENV=development
# In the development environment the backend gets proxied by the frontend
APP_URL=http://localhost:3000
PORT=1411
MAXMIND_LICENSE_KEY=your_license_key
# Set the ENCRYPTION_KEY to a random sequence of characters, for example generated with `openssl rand -base64 32`
ENCRYPTION_KEY=
MAXMIND_LICENSE_KEY=your_license_key

View File

@@ -87,13 +87,10 @@ func RegisterFrontend(router *gin.Engine, oidcService *service.OidcService) erro
if isSPARequest(path, distFS) {
nonce := middleware.GetCSPNonce(c)
// For an authorization request that responds via response_mode=form_post, the
// consent page auto-submits a form to the client's callback, so that callback
// must be allowed in the CSP form-action directive
isAuthPostRequest, redirectURI := isOAuth2AuthorizationPostRequest(c, oidcService)
if isAuthPostRequest {
clientID := c.Query("client_id")
if isOAuth2AuthorizationPostRequest(c) {
// In that case, we need to validate and allow form submissions to the redirect_uri
redirectURI := c.Query("redirect_uri")
clientID := c.Query("client_id")
validatedRedirectURI, err := oidcService.ResolveAllowedCallbackURL(c.Request.Context(), clientID, redirectURI)
if err == nil {
c.Header("Content-Security-Policy", middleware.BuildCSP(nonce, validatedRedirectURI))
@@ -116,21 +113,17 @@ func RegisterFrontend(router *gin.Engine, oidcService *service.OidcService) erro
}
rateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(300*time.Millisecond), 50)
router.NoRoute(rateLimitOnlyForOAuth2AuthorizationPostRequest(rateLimitMiddleware, oidcService, distFS), handler)
router.NoRoute(rateLimitOnlyForOAuth2AuthorizationPostRequest(rateLimitMiddleware, distFS), handler)
return nil
}
func rateLimitOnlyForOAuth2AuthorizationPostRequest(rateLimitMiddleware gin.HandlerFunc, oidcService *service.OidcService, distFS fs.FS) gin.HandlerFunc {
func rateLimitOnlyForOAuth2AuthorizationPostRequest(rateLimitMiddleware gin.HandlerFunc, distFS fs.FS) gin.HandlerFunc {
return func(c *gin.Context) {
path := strings.TrimPrefix(c.Request.URL.Path, "/")
if isSPARequest(path, distFS) {
isAuthPostRequest, _ := isOAuth2AuthorizationPostRequest(c, oidcService)
if isAuthPostRequest {
rateLimitMiddleware(c)
return
}
if isSPARequest(path, distFS) && isOAuth2AuthorizationPostRequest(c) {
rateLimitMiddleware(c)
return
}
c.Next()
@@ -139,29 +132,12 @@ func rateLimitOnlyForOAuth2AuthorizationPostRequest(rateLimitMiddleware gin.Hand
// isOAuth2AuthorizationRequest checks if this is an OAuth2 authorization request with response_mode=form_post
// In that case, we need to validate and allow form submissions to the redirect_uri
func isOAuth2AuthorizationPostRequest(c *gin.Context, oidcService *service.OidcService) (bool, string) {
func isOAuth2AuthorizationPostRequest(c *gin.Context) bool {
responseMode := c.Query("response_mode")
redirectURI := c.Query("redirect_uri")
clientID := c.Query("client_id")
requestUri := c.Query("request_uri")
if c.Request.URL.Path != "/authorize" {
return false, ""
}
if requestUri != "" && clientID != "" {
par, err := oidcService.GetPushedAuthorizationRequest(c.Request.Context(), clientID, requestUri)
if err != nil {
return false, ""
}
responseMode = par.Parameters.ResponseMode
redirectURI = par.Parameters.RedirectURI
}
ok := responseMode == "form_post" && redirectURI != "" && clientID != ""
return ok, redirectURI
return responseMode == "form_post" && redirectURI != "" && clientID != ""
}
func isSPARequest(path string, distFS fs.FS) bool {

View File

@@ -43,7 +43,7 @@ func TestRateLimitOnlyForOAuth2AuthorizationPostRequest(t *testing.T) {
middleware := rateLimitOnlyForOAuth2AuthorizationPostRequest(func(c *gin.Context) {
rateLimited = true
c.Abort()
}, nil, distFS)
}, distFS)
router := gin.New()
router.NoRoute(
@@ -67,7 +67,7 @@ func TestRateLimitOnlyForOAuth2AuthorizationPostRequest(t *testing.T) {
middleware := rateLimitOnlyForOAuth2AuthorizationPostRequest(func(c *gin.Context) {
rateLimited = true
c.Abort()
}, nil, distFS)
}, distFS)
router := gin.New()
router.NoRoute(
@@ -91,7 +91,7 @@ func TestRateLimitOnlyForOAuth2AuthorizationPostRequest(t *testing.T) {
middleware := rateLimitOnlyForOAuth2AuthorizationPostRequest(func(c *gin.Context) {
rateLimited = true
c.Abort()
}, nil, distFS)
}, distFS)
router := gin.New()
router.NoRoute(
@@ -108,30 +108,4 @@ func TestRateLimitOnlyForOAuth2AuthorizationPostRequest(t *testing.T) {
assert.False(t, rateLimited)
assert.True(t, nextCalled)
})
t.Run("does not rate limit non-authorize spa path with form_post params", func(t *testing.T) {
rateLimited := false
nextCalled := false
// oidcService is nil: a non-/authorize path is rejected by the path guard
// before the request_uri branch that would dereference it.
middleware := rateLimitOnlyForOAuth2AuthorizationPostRequest(func(c *gin.Context) {
rateLimited = true
c.Abort()
}, nil, distFS)
router := gin.New()
router.NoRoute(
middleware,
func(c *gin.Context) {
nextCalled = true
},
)
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/settings?response_mode=form_post&client_id=test&redirect_uri=https://example.com/callback", nil)
router.ServeHTTP(recorder, req)
assert.False(t, rateLimited)
assert.True(t, nextCalled)
})
}

View File

@@ -1,6 +1,6 @@
module github.com/pocket-id/pocket-id/backend
go 1.26
go 1.26.0
require (
github.com/aws/aws-sdk-go-v2 v1.41.7

View File

@@ -9,11 +9,13 @@ import (
"net"
"net/http"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/coreos/go-systemd/activation"
"github.com/fsnotify/fsnotify"
sloggin "github.com/gin-contrib/slog"
"github.com/gin-gonic/gin"
@@ -130,7 +132,6 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services) error {
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
controller.NewVersionController(apiGroup, authMiddleware, svc.versionService)
controller.NewScimController(apiGroup, authMiddleware, svc.scimService)
controller.NewUserSignupController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userSignUpService, svc.appConfigService)
@@ -209,6 +210,64 @@ func initServerProtocols() (*http.Protocols, *tls.Config, *tlsCertProvider, erro
return protocols, tlsConfig, certProvider, nil
}
type socket struct {
addr string
listener net.Listener
}
func systemdSocket() (*socket, error) {
listeners, err := activation.Listeners()
if err != nil {
return nil, fmt.Errorf("failed to receive socket from systemd: %w", err)
}
if len(listeners) == 0 {
return nil, errors.New("did not receive any sockets from systemd")
}
if len(listeners) > 1 {
return nil, errors.New("received too many sockets from systemd")
}
return &socket{"(systemd)", listeners[0]}, nil
}
func unixSocket() (*socket, error) {
addr := common.EnvConfig.UnixSocket
os.Remove(addr) // remove dangling the socket file to avoid file-exist error
listener, err := net.Listen("unix", addr) //nolint:noctx
if err != nil {
return nil, fmt.Errorf("failed to create UNIX socket: %w", err)
}
if common.EnvConfig.UnixSocketMode != "" {
mode, err := strconv.ParseUint(common.EnvConfig.UnixSocketMode, 8, 32)
if err != nil {
listener.Close()
return nil, fmt.Errorf("failed to parse UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
}
if err := os.Chmod(addr, os.FileMode(mode)); err != nil {
listener.Close()
return nil, fmt.Errorf("failed to set UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
}
}
return &socket{addr, listener}, nil
}
func tcpSocket() (*socket, error) {
addr := net.JoinHostPort(common.EnvConfig.Host, common.EnvConfig.Port)
listener, err := net.Listen("tcp", addr) //nolint:noctx
if err != nil {
return nil, fmt.Errorf("failed to create TCP socket: %w", err)
}
return &socket{addr, listener}, nil
}
func newHTTPServer(r *gin.Engine, protocols *http.Protocols) *http.Server {
return &http.Server{
MaxHeaderBytes: 1 << 20,

View File

@@ -13,25 +13,25 @@ import (
)
type services struct {
appConfigService *service.AppConfigService
appImagesService *service.AppImagesService
emailService *service.EmailService
geoLiteService *service.GeoLiteService
auditLogService *service.AuditLogService
jwtService *service.JwtService
webauthnService *service.WebAuthnService
scimService *service.ScimService
userService *service.UserService
customClaimService *service.CustomClaimService
oidcService *service.OidcService
userGroupService *service.UserGroupService
ldapService *service.LdapService
apiKeyService *service.ApiKeyService
versionService *service.VersionService
fileStorage storage.FileStorage
appLockService *service.AppLockService
userSignUpService *service.UserSignUpService
oneTimeAccessService *service.OneTimeAccessService
appConfigService *service.AppConfigService
appImagesService *service.AppImagesService
emailService *service.EmailService
geoLiteService *service.GeoLiteService
auditLogService *service.AuditLogService
jwtService *service.JwtService
webauthnService *service.WebAuthnService
scimService *service.ScimService
userService *service.UserService
customFieldValueService *service.CustomFieldValueService
oidcService *service.OidcService
userGroupService *service.UserGroupService
ldapService *service.LdapService
apiKeyService *service.ApiKeyService
versionService *service.VersionService
fileStorage storage.FileStorage
appLockService *service.AppLockService
userSignUpService *service.UserSignUpService
oneTimeAccessService *service.OneTimeAccessService
}
// Initializes all services
@@ -59,7 +59,7 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
return nil, fmt.Errorf("failed to create JWT service: %w", err)
}
svc.customClaimService = service.NewCustomClaimService(db)
svc.customFieldValueService = service.NewCustomFieldValueService(db, svc.appConfigService)
svc.webauthnService, err = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService)
if err != nil {
return nil, fmt.Errorf("failed to create WebAuthn service: %w", err)
@@ -67,13 +67,13 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
svc.scimService = service.NewScimService(db, scheduler, httpClient)
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService, svc.webauthnService, svc.scimService, httpClient, fileStorage)
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customFieldValueService, svc.webauthnService, svc.scimService, httpClient, fileStorage)
if err != nil {
return nil, fmt.Errorf("failed to create OIDC service: %w", err)
}
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService, svc.scimService)
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService, svc.appImagesService, svc.scimService, fileStorage)
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService, svc.customFieldValueService, svc.scimService)
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customFieldValueService, svc.appImagesService, svc.scimService, fileStorage)
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService, fileStorage)
svc.apiKeyService, err = service.NewApiKeyService(ctx, db, svc.emailService)

View File

@@ -1,51 +0,0 @@
package bootstrap
import (
"fmt"
"net"
"os"
"strconv"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
type socket struct {
addr string
listener net.Listener
}
func unixSocket() (*socket, error) {
addr := common.EnvConfig.UnixSocket
os.Remove(addr) // remove dangling the socket file to avoid file-exist error
listener, err := net.Listen("unix", addr) //nolint:noctx
if err != nil {
return nil, fmt.Errorf("failed to create UNIX socket: %w", err)
}
if common.EnvConfig.UnixSocketMode != "" {
mode, err := strconv.ParseUint(common.EnvConfig.UnixSocketMode, 8, 32)
if err != nil {
listener.Close()
return nil, fmt.Errorf("failed to parse UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
}
if err := os.Chmod(addr, os.FileMode(mode)); err != nil {
listener.Close()
return nil, fmt.Errorf("failed to set UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
}
}
return &socket{addr, listener}, nil
}
func tcpSocket() (*socket, error) {
addr := net.JoinHostPort(common.EnvConfig.Host, common.EnvConfig.Port)
listener, err := net.Listen("tcp", addr) //nolint:noctx
if err != nil {
return nil, fmt.Errorf("failed to create TCP socket: %w", err)
}
return &socket{addr, listener}, nil
}

View File

@@ -1,27 +0,0 @@
//go:build linux
package bootstrap
import (
"errors"
"fmt"
"github.com/coreos/go-systemd/activation"
)
func systemdSocket() (*socket, error) {
listeners, err := activation.Listeners()
if err != nil {
return nil, fmt.Errorf("failed to receive socket from systemd: %w", err)
}
if len(listeners) == 0 {
return nil, errors.New("did not receive any sockets from systemd")
}
if len(listeners) > 1 {
return nil, errors.New("received too many sockets from systemd")
}
return &socket{"(systemd)", listeners[0]}, nil
}

View File

@@ -1,9 +0,0 @@
//go:build !linux
package bootstrap
import "errors"
func systemdSocket() (*socket, error) {
return nil, errors.New("systemd socket activation is only supported on Linux")
}

View File

@@ -171,23 +171,30 @@ type MissingSessionIdError struct{}
func (e MissingSessionIdError) Error() string { return "Missing session id" }
func (e MissingSessionIdError) HttpStatusCode() int { return http.StatusBadRequest }
type ReservedClaimError struct {
type ReservedCustomFieldError struct {
Key string
}
func (e ReservedClaimError) Error() string {
return fmt.Sprintf("Claim %s is reserved and can't be used", e.Key)
func (e ReservedCustomFieldError) Error() string {
return fmt.Sprintf("Custom field %s is reserved and can't be used", e.Key)
}
func (e ReservedClaimError) HttpStatusCode() int { return http.StatusBadRequest }
func (e ReservedCustomFieldError) HttpStatusCode() int { return http.StatusBadRequest }
type DuplicateClaimError struct {
type DuplicateCustomFieldError struct {
Key string
}
func (e DuplicateClaimError) Error() string {
return fmt.Sprintf("Claim %s is already defined", e.Key)
func (e DuplicateCustomFieldError) Error() string {
return fmt.Sprintf("Custom field %s is already defined", e.Key)
}
func (e DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest }
func (e DuplicateCustomFieldError) HttpStatusCode() int { return http.StatusBadRequest }
type CustomFieldValidationError struct {
Message string
}
func (e CustomFieldValidationError) Error() string { return e.Message }
func (e CustomFieldValidationError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcInvalidCodeVerifierError struct{}
@@ -354,29 +361,6 @@ func (e ImageNotFoundError) Error() string { return "Image not found" }
func (e ImageNotFoundError) HttpStatusCode() int { return http.StatusNotFound }
type OidcPARNotSupportedForPublicClientsError struct{}
func (e OidcPARNotSupportedForPublicClientsError) Error() string {
return "pushed authorization requests are not supported for public clients"
}
func (e OidcPARNotSupportedForPublicClientsError) HttpStatusCode() int {
return http.StatusBadRequest
}
type OidcInvalidRequestURIError struct{}
func (e OidcInvalidRequestURIError) Error() string {
return "invalid or expired request_uri"
}
func (e OidcInvalidRequestURIError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcPARRequiredError struct{}
func (e OidcPARRequiredError) Error() string {
return "this client requires pushed authorization requests"
}
func (e OidcPARRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
type InvalidEmailVerificationTokenError struct{}
func (e InvalidEmailVerificationTokenError) Error() string { return "Invalid email verification token" }

View File

@@ -1,115 +0,0 @@
package controller
import (
"net/http"
"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"
)
// NewCustomClaimController creates a new controller for custom claim management
// @Summary Custom claim management controller
// @Description Initializes all custom claim-related API endpoints
// @Tags Custom Claims
func NewCustomClaimController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, customClaimService *service.CustomClaimService) {
wkc := &CustomClaimController{customClaimService: customClaimService}
customClaimsGroup := group.Group("/custom-claims")
customClaimsGroup.Use(authMiddleware.Add())
{
customClaimsGroup.GET("/suggestions", wkc.getSuggestionsHandler)
customClaimsGroup.PUT("/user/:userId", wkc.UpdateCustomClaimsForUserHandler)
customClaimsGroup.PUT("/user-group/:userGroupId", wkc.UpdateCustomClaimsForUserGroupHandler)
}
}
type CustomClaimController struct {
customClaimService *service.CustomClaimService
}
// getSuggestionsHandler godoc
// @Summary Get custom claim suggestions
// @Description Get a list of suggested custom claim names
// @Tags Custom Claims
// @Produce json
// @Success 200 {array} string "List of suggested custom claim names"
// @Router /api/custom-claims/suggestions [get]
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
claims, err := ccc.customClaimService.GetSuggestions(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, claims)
}
// UpdateCustomClaimsForUserHandler godoc
// @Summary Update custom claims for a user
// @Description Update or create custom claims for a specific user
// @Tags Custom Claims
// @Accept json
// @Produce json
// @Param userId path string true "User ID"
// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user"
// @Success 200 {array} dto.CustomClaimDto "Updated custom claims"
// @Router /api/custom-claims/user/{userId} [put]
func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) {
var input []dto.CustomClaimCreateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
userId := c.Param("userId")
claims, err := ccc.customClaimService.UpdateCustomClaimsForUser(c.Request.Context(), userId, input)
if err != nil {
_ = c.Error(err)
return
}
var customClaimsDto []dto.CustomClaimDto
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, customClaimsDto)
}
// UpdateCustomClaimsForUserGroupHandler godoc
// @Summary Update custom claims for a user group
// @Description Update or create custom claims for a specific user group
// @Tags Custom Claims
// @Accept json
// @Produce json
// @Param userGroupId path string true "User Group ID"
// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user group"
// @Success 200 {array} dto.CustomClaimDto "Updated custom claims"
// @Router /api/custom-claims/user-group/{userGroupId} [put]
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
var input []dto.CustomClaimCreateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
userGroupId := c.Param("userGroupId")
claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(c.Request.Context(), userGroupId, input)
if err != nil {
_ = c.Error(err)
return
}
var customClaimsDto []dto.CustomClaimDto
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, customClaimsDto)
}

View File

@@ -33,10 +33,8 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.POST("/oidc/authorize", authMiddleware.WithAdminNotRequired().Add(), oc.authorizeHandler)
group.POST("/oidc/authorization-required", authMiddleware.WithAdminNotRequired().Add(), oc.authorizationConfirmationRequiredHandler)
group.GET("/oidc/par-request-info", authMiddleware.WithAdminNotRequired().Add(), oc.parRequestInfoHandler)
group.POST("/oidc/token", oc.createTokensHandler)
group.POST("/oidc/par", oc.pushedAuthorizationRequestHandler)
group.GET("/oidc/userinfo", oc.userInfoHandler)
group.POST("/oidc/userinfo", oc.userInfoHandler)
group.POST("/oidc/end-session", authMiddleware.WithAdminNotRequired().WithSuccessOptional().Add(), oc.EndSessionHandler)
@@ -156,50 +154,13 @@ func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Contex
return
}
authorizationRequired, scope, err := oc.oidcService.AuthorizationRequired(c.Request.Context(), input.ClientID, c.GetString("userID"), input.Scope, input.RequestURI)
hasAuthorizedClient, err := oc.oidcService.HasAuthorizedClient(c.Request.Context(), input.ClientID, c.GetString("userID"), input.Scope)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{"authorizationRequired": authorizationRequired, "scope": scope})
}
// parRequestInfoHandler godoc
// @Summary Resolve stored authorization request parameters
// @Description Resolve the parameters of a stored request_uri request so the consent page can render the requested scope and build the final redirect. Does not consume the request.
// @Tags OIDC
// @Produce json
// @Param client_id query string true "Client ID"
// @Param request_uri query string true "Request URI returned from the PAR endpoint"
// @Success 200 {object} dto.OidcAuthorizeRequestInfoDto "Resolved authorization request parameters"
// @Router /api/oidc/par-request-info [get]
func (oc *OidcController) parRequestInfoHandler(c *gin.Context) {
clientID := c.Query("client_id")
requestURI := c.Query("request_uri")
if clientID == "" {
_ = c.Error(&common.ValidationError{Message: "client_id is required"})
return
}
if requestURI == "" {
_ = c.Error(&common.ValidationError{Message: "request_uri is required"})
return
}
info, err := oc.oidcService.GetPushedAuthorizationRequest(c.Request.Context(), clientID, requestURI)
if err != nil {
_ = c.Error(err)
return
}
var infoDto dto.OidcAuthorizeRequestInfoDto
err = dto.MapStruct(info.Parameters, &infoDto)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, infoDto)
c.JSON(http.StatusOK, gin.H{"authorizationRequired": !hasAuthorizedClient})
}
// createTokensHandler godoc
@@ -271,49 +232,6 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
})
}
// pushedAuthorizationRequestHandler godoc
// @Summary Pushed Authorization Request (PAR)
// @Description RFC 9126: Push authorization request parameters and receive a request_uri. Only confidential clients may use this endpoint.
// @Tags OIDC
// @Accept application/x-www-form-urlencoded
// @Produce json
// @Success 201 {object} dto.OidcPARResponseDto
// @Router /api/oidc/par [post]
func (oc *OidcController) pushedAuthorizationRequestHandler(c *gin.Context) {
// Per RFC 9126, parameters MUST be passed in the request body
c.Request.URL.RawQuery = ""
var input dto.OidcPARRequestDto
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, _ = utils.OAuthClientBasicAuth(c.Request)
}
creds := service.ClientAuthCredentials{
ClientID: input.ClientID,
ClientSecret: input.ClientSecret,
ClientAssertion: input.ClientAssertion,
ClientAssertionType: input.ClientAssertionType,
}
requestURI, expiresIn, err := oc.oidcService.CreatePushedAuthorizationRequest(c.Request.Context(), creds, input)
if err != nil {
_ = c.Error(err)
return
}
// RFC 9126 §2.2 requires HTTP 201 Created for successful PAR responses
c.JSON(http.StatusCreated, dto.OidcPARResponseDto{
RequestURI: requestURI,
ExpiresIn: expiresIn,
})
}
// userInfoHandler godoc
// @Summary Get user information
// @Description Get user information based on the access token

View File

@@ -92,9 +92,7 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
"authorization_response_iss_parameter_supported": true,
"code_challenge_methods_supported": []string{"plain", "S256"},
"prompt_values_supported": []string{"none", "login", "consent", "select_account"},
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "private_key_jwt", "none"},
"pushed_authorization_request_endpoint": internalAppUrl + "/api/oidc/par",
"require_pushed_authorization_requests": false,
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "none"},
}
return json.Marshal(config)
}

View File

@@ -20,7 +20,7 @@ type AppConfigUpdateDto struct {
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
AllowUserSignups string `json:"allowUserSignups" binding:"required,oneof=disabled withToken open"`
SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs" binding:"omitempty,json"`
SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" binding:"omitempty,json"`
CustomFields string `json:"customFields" binding:"omitempty,json"`
AccentColor string `json:"accentColor"`
RequireUserEmail string `json:"requireUserEmail" binding:"required"`
SmtpHost string `json:"smtpHost"`

View File

@@ -1,11 +0,0 @@
package dto
type CustomClaimDto struct {
Key string `json:"key"`
Value string `json:"value"`
}
type CustomClaimCreateDto struct {
Key string `json:"key" binding:"required" unorm:"nfc"`
Value string `json:"value" binding:"required" unorm:"nfc"`
}

View File

@@ -0,0 +1,42 @@
package dto
type CustomFieldValueDto struct {
CustomFieldID string `json:"customFieldId"`
Key string `json:"key,omitempty"`
Value string `json:"value"`
}
type CustomFieldValueCreateDto struct {
CustomFieldID string `json:"customFieldId" binding:"required_without=Key" unorm:"nfc"`
Key string `json:"key,omitempty" unorm:"nfc"`
Value string `json:"value" unorm:"nfc"`
}
type CustomFieldType string
const (
CustomFieldTypeString CustomFieldType = "string"
CustomFieldTypeNumber CustomFieldType = "number"
CustomFieldTypeBoolean CustomFieldType = "boolean"
)
type CustomFieldTarget string
const (
CustomFieldTargetUser CustomFieldTarget = "user"
CustomFieldTargetGroup CustomFieldTarget = "group"
CustomFieldTargetBoth CustomFieldTarget = "both"
)
type CustomFieldDto struct {
ID string `json:"id" binding:"required,uuid"`
Key string `json:"key" binding:"required" unorm:"nfc"`
DisplayName string `json:"displayName" binding:"required" unorm:"nfc"`
Type CustomFieldType `json:"type" binding:"required,oneof=string number boolean"`
Target CustomFieldTarget `json:"target" binding:"required,oneof=user group both"`
Required bool `json:"required"`
UserEditable bool `json:"userEditable"`
DefaultValue string `json:"defaultValue" unorm:"nfc"`
ValidationRegex string `json:"validationRegex" binding:"regex" unorm:"nfc"`
ValidationErrorMessage string `json:"validationErrorMessage" unorm:"nfc"`
}

View File

@@ -13,13 +13,12 @@ type OidcClientMetaDataDto struct {
type OidcClientDto struct {
OidcClientMetaDataDto
CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
RequiresPushedAuthorizationRequests bool `json:"requiresPushedAuthorizationRequests"`
Credentials OidcClientCredentialsDto `json:"credentials"`
IsGroupRestricted bool `json:"isGroupRestricted"`
CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
Credentials OidcClientCredentialsDto `json:"credentials"`
IsGroupRestricted bool `json:"isGroupRestricted"`
}
type OidcClientWithAllowedUserGroupsDto struct {
@@ -33,20 +32,19 @@ type OidcClientWithAllowedGroupsCountDto struct {
}
type OidcClientUpdateDto struct {
Name string `json:"name" binding:"required,max=50" unorm:"nfc"`
CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url_pattern"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url_pattern"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
RequiresReauthentication bool `json:"requiresReauthentication"`
RequiresPushedAuthorizationRequests bool `json:"requiresPushedAuthorizationRequests"`
Credentials OidcClientCredentialsDto `json:"credentials"`
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
HasLogo bool `json:"hasLogo"`
HasDarkLogo bool `json:"hasDarkLogo"`
LogoURL *string `json:"logoUrl"`
DarkLogoURL *string `json:"darkLogoUrl"`
IsGroupRestricted bool `json:"isGroupRestricted"`
Name string `json:"name" binding:"required,max=50" unorm:"nfc"`
CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url_pattern"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url_pattern"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
RequiresReauthentication bool `json:"requiresReauthentication"`
Credentials OidcClientCredentialsDto `json:"credentials"`
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
HasLogo bool `json:"hasLogo"`
HasDarkLogo bool `json:"hasDarkLogo"`
LogoURL *string `json:"logoUrl"`
DarkLogoURL *string `json:"darkLogoUrl"`
IsGroupRestricted bool `json:"isGroupRestricted"`
}
type OidcClientCreateDto struct {
@@ -67,36 +65,14 @@ type OidcClientFederatedIdentityDto struct {
type AuthorizeOidcClientRequestDto struct {
ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope" binding:"required_without=RequestURI"`
CallbackURL string `json:"callbackURL"`
Scope string `json:"scope" binding:"required"`
CallbackURL string `json:"callbackURL" binding:"omitempty,callback_url"`
Nonce string `json:"nonce"`
CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"`
ReauthenticationToken string `json:"reauthenticationToken"`
Prompt string `json:"prompt"`
ResponseMode string `json:"responseMode" binding:"omitempty,response_mode"`
RequestURI string `json:"requestURI"`
}
type OidcPARRequestDto struct {
ClientID string `form:"client_id"`
ClientSecret string `form:"client_secret"`
ClientAssertion string `form:"client_assertion"`
ClientAssertionType string `form:"client_assertion_type"`
ResponseType string `form:"response_type" binding:"required"`
Scope string `form:"scope" binding:"required"`
RedirectURI string `form:"redirect_uri"`
State string `form:"state"`
Nonce string `form:"nonce"`
CodeChallenge string `form:"code_challenge"`
CodeChallengeMethod string `form:"code_challenge_method"`
Prompt string `form:"prompt"`
ResponseMode string `form:"response_mode" binding:"omitempty,response_mode"`
}
type OidcPARResponseDto struct {
RequestURI string `json:"request_uri"`
ExpiresIn int `json:"expires_in"`
}
type AuthorizeOidcClientResponseDto struct {
@@ -106,18 +82,8 @@ type AuthorizeOidcClientResponseDto struct {
}
type AuthorizationRequiredDto struct {
ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope"`
RequestURI string `json:"requestURI"`
}
type OidcAuthorizeRequestInfoDto struct {
Scope string `json:"scope"`
RedirectURI string `json:"redirectURI"`
State string `json:"state,omitempty"`
Nonce string `json:"nonce,omitempty"`
ResponseMode string `json:"responseMode,omitempty"`
Prompt string `json:"prompt,omitempty"`
ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope" binding:"required"`
}
type OidcCreateTokensDto struct {

View File

@@ -1,9 +1,10 @@
package dto
type SignUpDto struct {
Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"`
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
Token string `json:"token"`
Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"`
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
Token string `json:"token"`
CustomFieldValues []CustomFieldValueCreateDto `json:"customFieldValues"`
}

View File

@@ -7,33 +7,34 @@ import (
)
type UserDto struct {
ID string `json:"id"`
Username string `json:"username"`
Email *string `json:"email"`
EmailVerified bool `json:"emailVerified"`
FirstName string `json:"firstName"`
LastName *string `json:"lastName"`
DisplayName string `json:"displayName"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
CustomClaims []CustomClaimDto `json:"customClaims"`
UserGroups []UserGroupMinimalDto `json:"userGroups"`
LdapID *string `json:"ldapId"`
Disabled bool `json:"disabled"`
ID string `json:"id"`
Username string `json:"username"`
Email *string `json:"email"`
EmailVerified bool `json:"emailVerified"`
FirstName string `json:"firstName"`
LastName *string `json:"lastName"`
DisplayName string `json:"displayName"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
CustomFieldValues []CustomFieldValueDto `json:"customFieldValues"`
UserGroups []UserGroupMinimalDto `json:"userGroups"`
LdapID *string `json:"ldapId"`
Disabled bool `json:"disabled"`
}
type UserCreateDto struct {
Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"`
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
EmailVerified bool `json:"emailVerified"`
FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
DisplayName string `json:"displayName" binding:"max=100" unorm:"nfc"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
Disabled bool `json:"disabled"`
UserGroupIds []string `json:"userGroupIds"`
LdapID string `json:"-"`
Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"`
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
EmailVerified bool `json:"emailVerified"`
FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
DisplayName string `json:"displayName" binding:"max=100" unorm:"nfc"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
Disabled bool `json:"disabled"`
UserGroupIds []string `json:"userGroupIds"`
CustomFieldValues []CustomFieldValueCreateDto `json:"customFieldValues"`
LdapID string `json:"-"`
}
func (u UserCreateDto) Validate() error {

View File

@@ -11,7 +11,7 @@ type UserGroupDto struct {
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"`
CustomFieldValues []CustomFieldValueDto `json:"customFieldValues"`
LdapID *string `json:"ldapId"`
CreatedAt datatype.DateTime `json:"createdAt"`
Users []UserDto `json:"users"`
@@ -22,7 +22,6 @@ type UserGroupMinimalDto struct {
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"`
UserCount int64 `json:"userCount"`
LdapID *string `json:"ldapId"`
CreatedAt datatype.DateTime `json:"createdAt"`
@@ -33,9 +32,10 @@ type UserGroupUpdateAllowedOidcClientsDto struct {
}
type UserGroupCreateDto struct {
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50" unorm:"nfc"`
Name string `json:"name" binding:"required,min=2,max=255" unorm:"nfc"`
LdapID string `json:"-"`
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50" unorm:"nfc"`
Name string `json:"name" binding:"required,min=2,max=255" unorm:"nfc"`
CustomFieldValues []CustomFieldValueCreateDto `json:"customFieldValues"`
LdapID string `json:"-"`
}
func (g UserGroupCreateDto) Validate() error {

View File

@@ -1,11 +1,13 @@
package dto
import (
"errors"
"net/url"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/gin-gonic/gin/binding"
@@ -33,6 +35,12 @@ func init() {
"client_id": func(fl validator.FieldLevel) bool {
return ValidateClientID(fl.Field().String())
},
"regex": func(fl validator.FieldLevel) bool {
return ValidateRegex(fl.Field().String())
},
"uuid": func(fl validator.FieldLevel) bool {
return ValidateUUID(fl.Field().String())
},
"ttl": func(fl validator.FieldLevel) bool {
ttl, ok := fl.Field().Interface().(utils.JSONDuration)
if !ok {
@@ -59,6 +67,16 @@ func init() {
}
}
func ValidateStruct(input any) error {
e, ok := binding.Validator.Engine().(interface {
Struct(any) error
})
if !ok {
return errors.New("validator does not implement the expected interface")
}
return e.Struct(input)
}
// ValidateUsername validates username inputs
func ValidateUsername(username string) bool {
return validateUsernameRegex.MatchString(username)
@@ -69,6 +87,20 @@ func ValidateClientID(clientID string) bool {
return validateClientIDRegex.MatchString(clientID)
}
// ValidateRegex validates that the input is either empty or a compilable regular expression.
func ValidateRegex(value string) bool {
if value == "" {
return true
}
_, err := regexp.Compile(value)
return err == nil
}
// ValidateUUID validates UUID inputs.
func ValidateUUID(value string) bool {
return uuid.Validate(value) == nil
}
// ValidateCallbackURL validates the input callback URL
func ValidateCallbackURL(str string) bool {
// Ensure the URL is a valid one and that the protocol is not "javascript:" or "data:"

View File

@@ -58,6 +58,42 @@ func TestValidateClientID(t *testing.T) {
}
}
func TestValidateRegex(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{"empty", "", true},
{"valid", "^EMP-[0-9]+$", true},
{"invalid", "[", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, ValidateRegex(tt.input))
})
}
}
func TestValidateUUID(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{"valid", "89bc9c8f-2cd8-4cfd-82c5-5fa14e874f03", true},
{"invalid", "field-1", false},
{"empty", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, ValidateUUID(tt.input))
})
}
}
func TestValidateResponseMode(t *testing.T) {
tests := []struct {
name string

View File

@@ -38,7 +38,6 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro
s.RegisterJob(ctx, "ClearOidcRefreshTokens", jobDefWithJitter(24*time.Hour), jobs.clearOidcRefreshTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearReauthenticationTokens", jobDefWithJitter(24*time.Hour), jobs.clearReauthenticationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearAuditLogs", jobDefWithJitter(24*time.Hour), jobs.clearAuditLogs, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearOidcPushedAuthorizationRequests", jobDefWithJitter(24*time.Hour), jobs.clearOidcPushedAuthorizationRequests, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
)
}
@@ -147,20 +146,6 @@ func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
return nil
}
// clearOidcPushedAuthorizationRequests deletes PAR records that have expired without being consumed
func (j *DbCleanupJobs) clearOidcPushedAuthorizationRequests(ctx context.Context) error {
st := j.db.
WithContext(ctx).
Delete(&model.OidcPushedAuthorizationRequest{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired pushed authorization requests: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired pushed authorization requests", slog.Int64("count", st.RowsAffected))
return nil
}
// ClearEmailVerificationTokens deletes email verification tokens that have expired
func (j *DbCleanupJobs) clearEmailVerificationTokens(ctx context.Context) error {
st := j.db.

View File

@@ -37,7 +37,9 @@ func TestWithApiKeyAuthDisabled(t *testing.T) {
jwtService, err := service.NewJwtService(t.Context(), db, appConfigService)
require.NoError(t, err)
userService := service.NewUserService(db, jwtService, nil, nil, appConfigService, nil, nil, nil, nil)
customFieldsValueService := service.NewCustomFieldValueService(db, appConfigService)
userService := service.NewUserService(db, jwtService, nil, nil, appConfigService, customFieldsValueService, nil, nil, nil)
apiKeyService, err := service.NewApiKeyService(t.Context(), db, nil)
require.NoError(t, err)

View File

@@ -43,7 +43,7 @@ type AppConfig struct {
AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public
AllowUserSignups AppConfigVariable `key:"allowUserSignups,public"` // Public
SignupDefaultUserGroupIDs AppConfigVariable `key:"signupDefaultUserGroupIDs"`
SignupDefaultCustomClaims AppConfigVariable `key:"signupDefaultCustomClaims"`
CustomFields AppConfigVariable `key:"customFields,public"` // Public
// Internal
InstanceID AppConfigVariable `key:"instanceId,internal"` // Internal
// Email

View File

@@ -1,11 +0,0 @@
package model
type CustomClaim struct {
Base
Key string
Value string
UserID *string
UserGroupID *string
}

View File

@@ -0,0 +1,11 @@
package model
type CustomFieldValue struct {
Base
CustomFieldID string
Value string
UserID *string
UserGroupID *string
}

View File

@@ -48,19 +48,18 @@ type OidcAuthorizationCode struct {
type OidcClient struct {
Base
Name string `sortable:"true"`
Secret string
CallbackURLs UrlList
LogoutCallbackURLs UrlList
ImageType *string
DarkImageType *string
IsPublic bool
PkceEnabled bool `sortable:"true" filterable:"true"`
RequiresReauthentication bool `sortable:"true" filterable:"true"`
RequiresPushedAuthorizationRequests bool `sortable:"true" filterable:"true"`
Credentials OidcClientCredentials
LaunchURL *string
IsGroupRestricted bool `sortable:"true" filterable:"true"`
Name string `sortable:"true"`
Secret string
CallbackURLs UrlList
LogoutCallbackURLs UrlList
ImageType *string
DarkImageType *string
IsPublic bool
PkceEnabled bool `sortable:"true" filterable:"true"`
RequiresReauthentication bool `sortable:"true" filterable:"true"`
Credentials OidcClientCredentials
LaunchURL *string
IsGroupRestricted bool `sortable:"true" filterable:"true"`
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
CreatedByID *string
@@ -158,32 +157,3 @@ type OidcDeviceCode struct {
ClientID string
Client OidcClient
}
type OidcPushedAuthorizationRequest struct {
Base
RequestURI string
ClientID string
Parameters OidcAuthorizationRequestParameters
ExpiresAt datatype.DateTime
}
type OidcAuthorizationRequestParameters struct { //nolint:recvcheck
Scope string `json:"scope,omitempty"`
RedirectURI string `json:"redirect_uri,omitempty"`
State string `json:"state,omitempty"`
Nonce string `json:"nonce,omitempty"`
CodeChallenge string `json:"code_challenge,omitempty"`
CodeChallengeMethod string `json:"code_challenge_method,omitempty"`
ResponseType string `json:"response_type,omitempty"`
Prompt string `json:"prompt,omitempty"`
ResponseMode string `json:"response_mode,omitempty"`
}
func (p *OidcAuthorizationRequestParameters) Scan(value any) error {
return utils.UnmarshalJSONFromDatabase(p, value)
}
func (p OidcAuthorizationRequestParameters) Value() (driver.Value, error) {
return json.Marshal(p)
}

View File

@@ -26,9 +26,9 @@ type User struct {
Disabled bool `sortable:"true" filterable:"true"`
UpdatedAt *datatype.DateTime
CustomClaims []CustomClaim
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
Credentials []WebauthnCredential
CustomFieldValues []CustomFieldValue
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
Credentials []WebauthnCredential
}
func (u User) WebAuthnID() []byte { return []byte(u.ID) }

View File

@@ -13,7 +13,7 @@ type UserGroup struct {
LdapID *string
UpdatedAt *datatype.DateTime
Users []User `gorm:"many2many:user_groups_users;"`
CustomClaims []CustomClaim
CustomFieldValues []CustomFieldValue
AllowedOidcClients []OidcClient `gorm:"many2many:oidc_clients_allowed_user_groups;"`
}

View File

@@ -2,6 +2,7 @@ package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
@@ -67,7 +68,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
AllowUserSignups: model.AppConfigVariable{Value: "disabled"},
SignupDefaultUserGroupIDs: model.AppConfigVariable{Value: "[]"},
SignupDefaultCustomClaims: model.AppConfigVariable{Value: "[]"},
CustomFields: model.AppConfigVariable{Value: "[]"},
AccentColor: model.AppConfigVariable{Value: "default"},
// Internal
InstanceID: model.AppConfigVariable{Value: ""},
@@ -158,6 +159,87 @@ func (s *AppConfigService) updateAppConfigUpdateDatabase(ctx context.Context, tx
return nil
}
func (s *AppConfigService) normalizeCustomFieldConfigUpdate(ctx context.Context, tx *gorm.DB, oldValue, newValue string) (string, error) {
if newValue == "" {
return "", nil
}
oldFields, err := ParseCustomFieldDefinitions(oldValue)
if err != nil {
return "", err
}
newFields, err := ParseCustomFieldDefinitions(newValue)
if err != nil {
return "", err
}
oldFieldsByID := make(map[string]dto.CustomFieldDto, len(oldFields))
for _, oldField := range oldFields {
oldFieldsByID[oldField.ID] = oldField
}
newFieldsByID := make(map[string]dto.CustomFieldDto, len(newFields))
for _, newField := range newFields {
newFieldsByID[newField.ID] = newField
oldField, ok := oldFieldsByID[newField.ID]
if !ok {
continue
}
if oldField.Type != newField.Type {
return "", &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s type can't be changed", oldField.Key)}
}
// If the field existed before, but the appliesTo was changed so that it no longer applies to users/groups,
// then we need to delete the corresponding values on users/groups
for _, idType := range []idType{UserID, UserGroupID} {
oldApplies := customFieldAppliesTo(oldField, idType)
newApplies := customFieldAppliesTo(newField, idType)
if oldApplies && !newApplies {
if err := s.deleteCustomFieldValuesForFieldID(ctx, tx, idType, oldField.ID); err != nil {
return "", err
}
}
}
}
// Check if any fields were removed, and if so delete the corresponding values on users/groups
for _, oldField := range oldFields {
if _, ok := newFieldsByID[oldField.ID]; ok {
continue
}
for _, idType := range []idType{UserID, UserGroupID} {
if !customFieldAppliesTo(oldField, idType) {
continue
}
if err := s.deleteCustomFieldValuesForFieldID(ctx, tx, idType, oldField.ID); err != nil {
return "", err
}
}
}
normalizedValue, err := json.Marshal(newFields)
if err != nil {
return "", fmt.Errorf("failed to normalize custom fields JSON: %w", err)
}
return string(normalizedValue), nil
}
func (s *AppConfigService) deleteCustomFieldValuesForFieldID(ctx context.Context, tx *gorm.DB, idType idType, customFieldID string) error {
query := tx.WithContext(ctx).Model(&model.CustomFieldValue{})
switch idType {
case UserID:
query = query.Where("user_id IS NOT NULL")
case UserGroupID:
query = query.Where("user_group_id IS NOT NULL")
}
if err := query.Where("custom_field_id = ?", customFieldID).Delete(&model.CustomFieldValue{}).Error; err != nil {
return fmt.Errorf("failed to delete custom field values for field %s: %w", customFieldID, err)
}
return nil
}
func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
if common.EnvConfig.UiConfigDisabled {
return nil, &common.UiConfigDisabledError{}
@@ -179,6 +261,11 @@ func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppCon
return nil, fmt.Errorf("failed to reload config from database: %w", err)
}
input.CustomFields, err = s.normalizeCustomFieldConfigUpdate(ctx, tx, cfg.CustomFields.Value, input.CustomFields)
if err != nil {
return nil, err
}
defaultCfg := s.getDefaultDbConfig()
// Iterate through all the fields to update

View File

@@ -1,6 +1,7 @@
package service
import (
"encoding/json"
"sync/atomic"
"testing"
@@ -243,7 +244,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
// Verify database was updated
var count int64
db.Model(&model.AppConfigVariable{}).Count(&count)
require.Equal(t, int64(3), count)
require.GreaterOrEqual(t, count, int64(3))
var appName, sessionDuration, smtpHost model.AppConfigVariable
err = db.Where("key = ?", "appName").First(&appName).Error
@@ -470,4 +471,98 @@ func TestUpdateAppConfig(t *testing.T) {
var uiConfigDisabledErr *common.UiConfigDisabledError
require.ErrorAs(t, err, &uiConfigDisabledErr)
})
t.Run("keeps custom field values when custom field key changes", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
fieldID := "d20db690-c6fb-4b5b-8288-ac68eb80c6f4"
oldCustomFields := `[{"id":"d20db690-c6fb-4b5b-8288-ac68eb80c6f4","key":"department","displayName":"Department","type":"string","target":"user","required":false}]`
err := db.Model(&model.AppConfigVariable{}).Where("key = ?", "customFields").Update("value", oldCustomFields).Error
require.NoError(t, err)
user := model.User{Username: "test-user"}
err = db.Create(&user).Error
require.NoError(t, err)
err = db.Create(&model.CustomFieldValue{
CustomFieldID: fieldID,
Value: "Engineering",
UserID: &user.ID,
}).Error
require.NoError(t, err)
service := &AppConfigService{db: db}
err = service.LoadDbConfig(t.Context())
require.NoError(t, err)
newCustomFields := `[{"id":"d20db690-c6fb-4b5b-8288-ac68eb80c6f4","key":"team","displayName":"Team","type":"string","target":"user","required":false}]`
_, err = service.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{
CustomFields: newCustomFields,
})
require.NoError(t, err)
var customFieldValue model.CustomFieldValue
err = db.Where("user_id = ?", user.ID).First(&customFieldValue).Error
require.NoError(t, err)
require.Equal(t, fieldID, customFieldValue.CustomFieldID)
require.Equal(t, "Engineering", customFieldValue.Value)
var storedConfig model.AppConfigVariable
err = db.Where("key = ?", "customFields").First(&storedConfig).Error
require.NoError(t, err)
var storedFields []dto.CustomFieldDto
err = json.Unmarshal([]byte(storedConfig.Value), &storedFields)
require.NoError(t, err)
require.Len(t, storedFields, 1)
require.Equal(t, fieldID, storedFields[0].ID)
require.Equal(t, "team", storedFields[0].Key)
})
t.Run("rejects custom field type changes", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
oldCustomFields := `[{"id":"cda85ff5-9a22-40cc-8490-7b88593f6422","key":"department","displayName":"Department","type":"string","target":"user","required":false}]`
err := db.Model(&model.AppConfigVariable{}).Where("key = ?", "customFields").Update("value", oldCustomFields).Error
require.NoError(t, err)
service := &AppConfigService{db: db}
err = service.LoadDbConfig(t.Context())
require.NoError(t, err)
newCustomFields := `[{"id":"cda85ff5-9a22-40cc-8490-7b88593f6422","key":"department","displayName":"Department","type":"number","target":"user","required":false}]`
_, err = service.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{
CustomFields: newCustomFields,
})
require.Error(t, err)
require.Contains(t, err.Error(), "type can't be changed")
})
t.Run("deletes custom field values when custom field is removed", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
fieldID := "a99f05e6-57a0-468d-a8fe-98bd637cbf98"
oldCustomFields := `[{"id":"a99f05e6-57a0-468d-a8fe-98bd637cbf98","key":"department","displayName":"Department","type":"string","target":"user","required":false}]`
err := db.Model(&model.AppConfigVariable{}).Where("key = ?", "customFields").Update("value", oldCustomFields).Error
require.NoError(t, err)
user := model.User{Username: "test-user"}
err = db.Create(&user).Error
require.NoError(t, err)
err = db.Create(&model.CustomFieldValue{
CustomFieldID: fieldID,
Value: "Engineering",
UserID: &user.ID,
}).Error
require.NoError(t, err)
service := &AppConfigService{db: db}
err = service.LoadDbConfig(t.Context())
require.NoError(t, err)
_, err = service.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{
CustomFields: "[]",
})
require.NoError(t, err)
var count int64
err = db.Model(&model.CustomFieldValue{}).Where("user_id = ?", user.ID).Count(&count).Error
require.NoError(t, err)
require.Zero(t, count)
})
}

View File

@@ -1,261 +0,0 @@
package service
import (
"context"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
"gorm.io/gorm"
)
type CustomClaimService struct {
db *gorm.DB
}
func NewCustomClaimService(db *gorm.DB) *CustomClaimService {
return &CustomClaimService{db: db}
}
// isReservedClaim checks if a claim key is reserved e.g. email, preferred_username
func isReservedClaim(key string) bool {
switch key {
case "given_name",
"family_name",
"name",
"email",
"email_verified",
"preferred_username",
"display_name",
"groups",
TokenTypeClaim,
"sub",
"iss",
"aud",
"exp",
"iat",
"auth_time",
"nonce",
"acr",
"amr",
"azp",
"nbf",
"jti":
return true
default:
return false
}
}
// idType is the type of the id used to identify the user or user group
type idType string
const (
UserID idType = "user_id"
UserGroupID idType = "user_group_id"
)
// UpdateCustomClaimsForUser updates the custom claims for a user
func (s *CustomClaimService) UpdateCustomClaimsForUser(ctx context.Context, userID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
updatedClaims, err := s.updateCustomClaimsInternal(ctx, UserID, userID, claims, tx)
if err != nil {
return nil, err
}
err = tx.Commit().Error
if err != nil {
return nil, err
}
return updatedClaims, nil
}
// UpdateCustomClaimsForUserGroup updates the custom claims for a user group
func (s *CustomClaimService) UpdateCustomClaimsForUserGroup(ctx context.Context, userGroupID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
updatedClaims, err := s.updateCustomClaimsInternal(ctx, UserGroupID, userGroupID, claims, tx)
if err != nil {
return nil, err
}
err = tx.Commit().Error
if err != nil {
return nil, err
}
return updatedClaims, nil
}
// updateCustomClaimsInternal updates the custom claims for a user or user group within a transaction
func (s *CustomClaimService) updateCustomClaimsInternal(ctx context.Context, idType idType, value string, claims []dto.CustomClaimCreateDto, tx *gorm.DB) ([]model.CustomClaim, error) {
// Check for duplicate keys in the claims slice
seenKeys := make(map[string]struct{})
for _, claim := range claims {
if _, ok := seenKeys[claim.Key]; ok {
return nil, &common.DuplicateClaimError{Key: claim.Key}
}
seenKeys[claim.Key] = struct{}{}
}
var existingClaims []model.CustomClaim
err := tx.
WithContext(ctx).
Where(string(idType), value).
Find(&existingClaims).
Error
if err != nil {
return nil, err
}
// Delete claims that are not in the new list
for _, existingClaim := range existingClaims {
found := false
for _, claim := range claims {
if claim.Key == existingClaim.Key {
found = true
break
}
}
if !found {
err = tx.
WithContext(ctx).
Delete(&existingClaim).
Error
if err != nil {
return nil, err
}
}
}
// Add or update claims
for _, claim := range claims {
if isReservedClaim(claim.Key) {
return nil, &common.ReservedClaimError{Key: claim.Key}
}
customClaim := model.CustomClaim{
Key: claim.Key,
Value: claim.Value,
}
switch idType {
case UserID:
customClaim.UserID = &value
case UserGroupID:
customClaim.UserGroupID = &value
}
// Update the claim if it already exists or create a new one
err = tx.
WithContext(ctx).
Where(string(idType)+" = ? AND key = ?", value, claim.Key).
Assign(&customClaim).
FirstOrCreate(&model.CustomClaim{}).
Error
if err != nil {
return nil, err
}
}
// Get the updated claims
var updatedClaims []model.CustomClaim
err = tx.
WithContext(ctx).
Where(string(idType)+" = ?", value).
Find(&updatedClaims).
Error
if err != nil {
return nil, err
}
return updatedClaims, nil
}
func (s *CustomClaimService) GetCustomClaimsForUser(ctx context.Context, userID string, tx *gorm.DB) ([]model.CustomClaim, error) {
var customClaims []model.CustomClaim
err := tx.
WithContext(ctx).
Where("user_id = ?", userID).
Find(&customClaims).
Error
return customClaims, err
}
func (s *CustomClaimService) GetCustomClaimsForUserGroup(ctx context.Context, userGroupID string, tx *gorm.DB) ([]model.CustomClaim, error) {
var customClaims []model.CustomClaim
err := tx.
WithContext(ctx).
Where("user_group_id = ?", userGroupID).
Find(&customClaims).
Error
return customClaims, err
}
// GetCustomClaimsForUserWithUserGroups returns the custom claims of a user and all user groups the user is a member of,
// prioritizing the user's claims over user group claims with the same key.
func (s *CustomClaimService) GetCustomClaimsForUserWithUserGroups(ctx context.Context, userID string, tx *gorm.DB) ([]model.CustomClaim, error) {
// Get the custom claims of the user
customClaims, err := s.GetCustomClaimsForUser(ctx, userID, tx)
if err != nil {
return nil, err
}
// Store user's claims in a map to prioritize and prevent duplicates
claimsMap := make(map[string]model.CustomClaim)
for _, claim := range customClaims {
claimsMap[claim.Key] = claim
}
// Get all user groups of the user
var userGroupsOfUser []model.UserGroup
err = tx.
WithContext(ctx).
Preload("CustomClaims").
Joins("JOIN user_groups_users ON user_groups_users.user_group_id = user_groups.id").
Where("user_groups_users.user_id = ?", userID).
Find(&userGroupsOfUser).Error
if err != nil {
return nil, err
}
// Add only non-duplicate custom claims from user groups
for _, userGroup := range userGroupsOfUser {
for _, groupClaim := range userGroup.CustomClaims {
// Only add claim if it does not exist in the user's claims
if _, exists := claimsMap[groupClaim.Key]; !exists {
claimsMap[groupClaim.Key] = groupClaim
}
}
}
// Convert the claimsMap back to a slice
finalClaims := make([]model.CustomClaim, 0, len(claimsMap))
for _, claim := range claimsMap {
finalClaims = append(finalClaims, claim)
}
return finalClaims, nil
}
// GetSuggestions returns a list of custom claim keys that have been used before
func (s *CustomClaimService) GetSuggestions(ctx context.Context) ([]string, error) {
var customClaimsKeys []string
err := s.db.
WithContext(ctx).
Model(&model.CustomClaim{}).
Group("key").
Order("COUNT(*) DESC").
Pluck("key", &customClaimsKeys).Error
return customClaimsKeys, err
}

View File

@@ -0,0 +1,566 @@
package service
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
"gorm.io/gorm"
)
type CustomFieldValueService struct {
db *gorm.DB
appConfigService *AppConfigService
}
func NewCustomFieldValueService(db *gorm.DB, appConfigService *AppConfigService) *CustomFieldValueService {
return &CustomFieldValueService{db: db, appConfigService: appConfigService}
}
func customFieldAppliesTo(field dto.CustomFieldDto, idType idType) bool {
switch field.Target {
case dto.CustomFieldTargetBoth:
return true
case dto.CustomFieldTargetUser:
return idType == UserID
case dto.CustomFieldTargetGroup:
return idType == UserGroupID
default:
return false
}
}
// isReservedOIDCClaim checks if a key is reserved by standard OIDC claims, e.g. email or preferred_username.
func isReservedOIDCClaim(key string) bool {
switch key {
case "given_name",
"family_name",
"name",
"email",
"email_verified",
"preferred_username",
"display_name",
"groups",
TokenTypeClaim,
"sub",
"iss",
"aud",
"exp",
"iat",
"auth_time",
"nonce",
"acr",
"amr",
"azp",
"nbf",
"jti":
return true
default:
return false
}
}
// idType is the type of the id used to identify the user or user group
type idType string
const (
UserID idType = "user_id"
UserGroupID idType = "user_group_id"
)
// UpdateCustomFieldValuesForUser updates the custom field values for a user.
func (s *CustomFieldValueService) UpdateCustomFieldValuesForUser(ctx context.Context, userID string, customFieldValues []dto.CustomFieldValueCreateDto) ([]model.CustomFieldValue, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
updatedCustomFieldValues, err := s.updateCustomFieldValuesInternal(ctx, UserID, userID, customFieldValues, tx)
if err != nil {
return nil, err
}
err = tx.Commit().Error
if err != nil {
return nil, err
}
return updatedCustomFieldValues, nil
}
// updateSelfEditableCustomFieldValuesForUser updates only the custom fields a user is allowed to edit themselves.
func (s *CustomFieldValueService) updateSelfEditableCustomFieldValuesForUser(ctx context.Context, userID string, customFieldValues []dto.CustomFieldValueCreateDto, tx *gorm.DB) ([]model.CustomFieldValue, error) {
fields, err := s.GetConfiguredCustomFieldsForTarget(UserID)
if err != nil {
return nil, err
}
editableFields := make([]dto.CustomFieldDto, 0, len(fields))
for _, field := range fields {
if !field.UserEditable {
continue
}
editableFields = append(editableFields, field)
}
return s.updateCustomFieldValuesForFields(ctx, UserID, userID, customFieldValues, editableFields, tx)
}
func (s *CustomFieldValueService) updateCustomFieldValuesForFields(ctx context.Context, idType idType, ownerID string, customFieldValues []dto.CustomFieldValueCreateDto, fields []dto.CustomFieldDto, tx *gorm.DB) ([]model.CustomFieldValue, error) {
normalizedCustomFieldValues, err := validateCustomFieldValuesAgainstFields(customFieldValues, fields)
if err != nil {
return nil, err
}
fieldIDs := make([]string, 0, len(fields))
for _, field := range fields {
fieldIDs = append(fieldIDs, field.ID)
}
valuesByFieldID := make(map[string]dto.CustomFieldValueCreateDto, len(normalizedCustomFieldValues))
fieldIDsToKeep := make([]string, 0, len(normalizedCustomFieldValues))
for _, customFieldValue := range normalizedCustomFieldValues {
valuesByFieldID[customFieldValue.CustomFieldID] = customFieldValue
fieldIDsToKeep = append(fieldIDsToKeep, customFieldValue.CustomFieldID)
}
if len(fieldIDs) > 0 {
deleteQuery := tx.WithContext(ctx).
Where(string(idType)+" = ? AND custom_field_id IN ?", ownerID, fieldIDs)
if len(fieldIDsToKeep) > 0 {
deleteQuery = deleteQuery.Where("custom_field_id NOT IN ?", fieldIDsToKeep)
}
if err := deleteQuery.Delete(&model.CustomFieldValue{}).Error; err != nil {
return nil, err
}
}
for _, customFieldValue := range valuesByFieldID {
value := model.CustomFieldValue{
CustomFieldID: customFieldValue.CustomFieldID,
Value: customFieldValue.Value,
}
switch idType {
case UserID:
value.UserID = &ownerID
case UserGroupID:
value.UserGroupID = &ownerID
}
if err := tx.
WithContext(ctx).
Where(string(idType)+" = ? AND custom_field_id = ?", ownerID, customFieldValue.CustomFieldID).
Assign(&value).
FirstOrCreate(&model.CustomFieldValue{}).
Error; err != nil {
return nil, err
}
}
switch idType {
case UserID:
return s.GetCustomFieldValuesForUser(ctx, ownerID, tx)
case UserGroupID:
return s.GetCustomFieldValuesForUserGroup(ctx, ownerID, tx)
default:
return nil, nil
}
}
// UpdateCustomFieldValuesForUserGroup updates the custom field values for a user group.
func (s *CustomFieldValueService) UpdateCustomFieldValuesForUserGroup(ctx context.Context, userGroupID string, customFieldValues []dto.CustomFieldValueCreateDto) ([]model.CustomFieldValue, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
updatedCustomFieldValues, err := s.updateCustomFieldValuesInternal(ctx, UserGroupID, userGroupID, customFieldValues, tx)
if err != nil {
return nil, err
}
err = tx.Commit().Error
if err != nil {
return nil, err
}
return updatedCustomFieldValues, nil
}
// updateCustomFieldValuesInternal updates the custom field values for a user or user group within a transaction.
func (s *CustomFieldValueService) updateCustomFieldValuesInternal(ctx context.Context, idType idType, value string, customFieldValues []dto.CustomFieldValueCreateDto, tx *gorm.DB) ([]model.CustomFieldValue, error) {
fields, err := s.GetConfiguredCustomFieldsForTarget(idType)
if err != nil {
return nil, err
}
customFieldValues, err = validateCustomFieldValuesAgainstFields(customFieldValues, fields)
if err != nil {
return nil, err
}
var existingCustomFieldValues []model.CustomFieldValue
err = tx.
WithContext(ctx).
Where(string(idType), value).
Find(&existingCustomFieldValues).
Error
if err != nil {
return nil, err
}
// Delete values that are not in the new list.
for _, existingCustomFieldValue := range existingCustomFieldValues {
found := false
for _, customFieldValue := range customFieldValues {
if customFieldValue.CustomFieldID == existingCustomFieldValue.CustomFieldID {
found = true
break
}
}
if !found {
err = tx.
WithContext(ctx).
Delete(&existingCustomFieldValue).
Error
if err != nil {
return nil, err
}
}
}
// Add or update custom field values.
for _, inputCustomFieldValue := range customFieldValues {
customFieldValue := model.CustomFieldValue{
CustomFieldID: inputCustomFieldValue.CustomFieldID,
Value: inputCustomFieldValue.Value,
}
switch idType {
case UserID:
customFieldValue.UserID = &value
case UserGroupID:
customFieldValue.UserGroupID = &value
}
// Update the value if it already exists or create a new one.
err = tx.
WithContext(ctx).
Where(string(idType)+" = ? AND custom_field_id = ?", value, inputCustomFieldValue.CustomFieldID).
Assign(&customFieldValue).
FirstOrCreate(&model.CustomFieldValue{}).
Error
if err != nil {
return nil, err
}
}
// Get the updated custom field values.
var updatedCustomFieldValues []model.CustomFieldValue
err = tx.
WithContext(ctx).
Where(string(idType)+" = ?", value).
Find(&updatedCustomFieldValues).
Error
if err != nil {
return nil, err
}
return updatedCustomFieldValues, nil
}
func (s *CustomFieldValueService) GetCustomFieldValuesForUser(ctx context.Context, userID string, tx *gorm.DB) ([]model.CustomFieldValue, error) {
var customFieldValues []model.CustomFieldValue
err := tx.
WithContext(ctx).
Where("user_id = ?", userID).
Find(&customFieldValues).
Error
if err != nil {
return nil, err
}
return s.applyDefaultCustomFieldValues(UserID, userID, customFieldValues)
}
func (s *CustomFieldValueService) GetCustomFieldValuesForUserGroup(ctx context.Context, userGroupID string, tx *gorm.DB) ([]model.CustomFieldValue, error) {
var customFieldValues []model.CustomFieldValue
err := tx.
WithContext(ctx).
Where("user_group_id = ?", userGroupID).
Find(&customFieldValues).
Error
if err != nil {
return nil, err
}
return s.applyDefaultCustomFieldValues(UserGroupID, userGroupID, customFieldValues)
}
// GetCustomFieldValuesForUserWithUserGroups returns the custom field values of a user and all user groups the user is a member of,
// prioritizing the user's values over user group values for the same custom field.
func (s *CustomFieldValueService) GetCustomFieldValuesForUserWithUserGroups(ctx context.Context, userID string, tx *gorm.DB) ([]model.CustomFieldValue, error) {
customFieldValues, err := s.GetCustomFieldValuesForUser(ctx, userID, tx)
if err != nil {
return nil, err
}
valuesByFieldID := make(map[string]model.CustomFieldValue)
for _, customFieldValue := range customFieldValues {
valuesByFieldID[customFieldValue.CustomFieldID] = customFieldValue
}
// Get all user groups of the user
var userGroupsOfUser []model.UserGroup
err = tx.
WithContext(ctx).
Preload("CustomFieldValues").
Joins("JOIN user_groups_users ON user_groups_users.user_group_id = user_groups.id").
Where("user_groups_users.user_id = ?", userID).
Find(&userGroupsOfUser).Error
if err != nil {
return nil, err
}
// Add only non-duplicate custom fields from user groups
for _, userGroup := range userGroupsOfUser {
groupCustomFieldValues, err := s.applyDefaultCustomFieldValues(UserGroupID, userGroup.ID, userGroup.CustomFieldValues)
if err != nil {
return nil, err
}
for _, groupCustomFieldValue := range groupCustomFieldValues {
if _, exists := valuesByFieldID[groupCustomFieldValue.CustomFieldID]; !exists {
valuesByFieldID[groupCustomFieldValue.CustomFieldID] = groupCustomFieldValue
}
}
}
finalCustomFieldValues := make([]model.CustomFieldValue, 0, len(valuesByFieldID))
for _, customFieldValue := range valuesByFieldID {
finalCustomFieldValues = append(finalCustomFieldValues, customFieldValue)
}
return finalCustomFieldValues, nil
}
func (s *CustomFieldValueService) applyDefaultCustomFieldValues(idType idType, ownerID string, customFieldValues []model.CustomFieldValue) ([]model.CustomFieldValue, error) {
fields, err := s.GetConfiguredCustomFieldsForTarget(idType)
if err != nil {
return nil, err
}
valuesByFieldID := make(map[string]struct{}, len(customFieldValues))
for _, customFieldValue := range customFieldValues {
valuesByFieldID[customFieldValue.CustomFieldID] = struct{}{}
}
effectiveCustomFieldValues := append([]model.CustomFieldValue{}, customFieldValues...)
for _, field := range fields {
if field.DefaultValue == "" {
continue
}
if _, ok := valuesByFieldID[field.ID]; ok {
continue
}
customFieldValue := model.CustomFieldValue{
CustomFieldID: field.ID,
Value: field.DefaultValue,
}
switch idType {
case UserID:
customFieldValue.UserID = &ownerID
case UserGroupID:
customFieldValue.UserGroupID = &ownerID
}
effectiveCustomFieldValues = append(effectiveCustomFieldValues, customFieldValue)
}
return effectiveCustomFieldValues, nil
}
func (s *CustomFieldValueService) GetConfiguredCustomFieldsForTarget(idType idType) ([]dto.CustomFieldDto, error) {
fields, err := ParseCustomFieldDefinitions(s.appConfigService.GetDbConfig().CustomFields.Value)
if err != nil {
return nil, err
}
filteredFields := make([]dto.CustomFieldDto, 0, len(fields))
for _, field := range fields {
if customFieldAppliesTo(field, idType) {
filteredFields = append(filteredFields, field)
}
}
return filteredFields, nil
}
func ParseCustomFieldDefinitions(value string) ([]dto.CustomFieldDto, error) {
if value == "" {
return nil, nil
}
var fields []dto.CustomFieldDto
if err := json.Unmarshal([]byte(value), &fields); err != nil {
return nil, &common.CustomFieldValidationError{Message: fmt.Sprintf("invalid custom fields JSON: %v", err)}
}
seenIDs := make(map[string]struct{}, len(fields))
seenKeys := make(map[string]struct{}, len(fields))
for i, field := range fields {
field.Key = strings.TrimSpace(field.Key)
fields[i].Key = field.Key
if err := dto.ValidateStruct(field); err != nil {
return nil, &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s is invalid: %v", field.Key, err)}
}
if _, ok := seenIDs[field.ID]; ok {
return nil, &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field id %s is already defined", field.ID)}
}
seenIDs[field.ID] = struct{}{}
if isReservedOIDCClaim(field.Key) {
return nil, &common.ReservedCustomFieldError{Key: field.Key}
}
if _, ok := seenKeys[field.Key]; ok {
return nil, &common.DuplicateCustomFieldError{Key: field.Key}
}
seenKeys[field.Key] = struct{}{}
if field.ValidationRegex != "" {
if field.Type != dto.CustomFieldTypeString {
return nil, &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s can only use regex validation for text values", field.Key)}
}
}
if field.Required && field.DefaultValue == "" {
return nil, &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s requires a default value", field.Key)}
}
if field.DefaultValue != "" {
if err := validateCustomFieldValue(dto.CustomFieldValueCreateDto{CustomFieldID: field.ID, Value: field.DefaultValue}, field); err != nil {
return nil, err
}
}
}
return fields, nil
}
func validateCustomFieldValuesAgainstFields(customFieldValues []dto.CustomFieldValueCreateDto, fields []dto.CustomFieldDto) ([]dto.CustomFieldValueCreateDto, error) {
fieldsByID := make(map[string]dto.CustomFieldDto, len(fields))
for _, field := range fields {
fieldsByID[field.ID] = field
}
valuesByFieldID := make(map[string]dto.CustomFieldValueCreateDto, len(customFieldValues))
for _, customFieldValue := range customFieldValues {
field, ok := fieldsByID[customFieldValue.CustomFieldID]
if !ok {
continue
}
customFieldValue.CustomFieldID = field.ID
customFieldValue.Key = field.Key
if _, ok := valuesByFieldID[customFieldValue.CustomFieldID]; ok {
return nil, &common.DuplicateCustomFieldError{Key: field.Key}
}
if field.Type != dto.CustomFieldTypeBoolean && customFieldValue.Value == "" && !field.Required {
continue
}
if err := validateCustomFieldValue(customFieldValue, field); err != nil {
return nil, err
}
valuesByFieldID[customFieldValue.CustomFieldID] = customFieldValue
}
normalizedCustomFieldValues := make([]dto.CustomFieldValueCreateDto, 0, len(valuesByFieldID))
for _, field := range fields {
customFieldValue, ok := valuesByFieldID[field.ID]
if ok {
normalizedCustomFieldValues = append(normalizedCustomFieldValues, customFieldValue)
continue
}
if field.DefaultValue != "" {
normalizedCustomFieldValues = append(normalizedCustomFieldValues, dto.CustomFieldValueCreateDto{
CustomFieldID: field.ID,
Key: field.Key,
Value: field.DefaultValue,
})
continue
}
if field.Required {
return nil, &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s is required", field.Key)}
}
}
return normalizedCustomFieldValues, nil
}
func validateCustomFieldValue(customFieldValue dto.CustomFieldValueCreateDto, field dto.CustomFieldDto) error {
if field.Required && field.Type != dto.CustomFieldTypeBoolean && customFieldValue.Value == "" {
return &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s is required", field.Key)}
}
switch field.Type {
case dto.CustomFieldTypeString:
if field.ValidationRegex != "" {
matches, err := regexp.MatchString(field.ValidationRegex, customFieldValue.Value)
if err != nil {
return &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s has invalid validation regex: %v", field.Key, err)}
}
if !matches {
if field.ValidationErrorMessage != "" {
return &common.CustomFieldValidationError{Message: field.ValidationErrorMessage}
}
return &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s does not match the required format", field.Key)}
}
}
return nil
case dto.CustomFieldTypeNumber:
if _, err := strconv.ParseFloat(customFieldValue.Value, 64); err != nil {
return &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s must be a number", field.Key)}
}
case dto.CustomFieldTypeBoolean:
if _, err := strconv.ParseBool(customFieldValue.Value); err != nil {
return &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s must be a boolean", field.Key)}
}
}
return nil
}
func customFieldValueTokenValue(customFieldValue model.CustomFieldValue, field *dto.CustomFieldDto) (any, error) {
if field != nil {
switch field.Type {
case dto.CustomFieldTypeString:
return customFieldValue.Value, nil
case dto.CustomFieldTypeNumber:
value, err := strconv.ParseFloat(customFieldValue.Value, 64)
if err != nil {
return nil, err
}
return value, nil
case dto.CustomFieldTypeBoolean:
value, err := strconv.ParseBool(customFieldValue.Value)
if err != nil {
return nil, err
}
return value, nil
}
}
var jsonValue any
if err := json.Unmarshal([]byte(customFieldValue.Value), &jsonValue); err == nil {
return jsonValue, nil
}
return customFieldValue.Value, nil
}

View File

@@ -0,0 +1,183 @@
package service
import (
"testing"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseCustomFieldDefinitionsValidatesRegex(t *testing.T) {
_, err := ParseCustomFieldDefinitions(`[{"id":"89bc9c8f-2cd8-4cfd-82c5-5fa14e874f03","key":"department","displayName":"Department","type":"string","target":"user","required":false,"validationRegex":"["}]`)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid validation regex")
_, err = ParseCustomFieldDefinitions(`[{"id":"353555d9-7de8-4320-a10f-5ca4c122a363","key":"age","displayName":"Age","type":"number","target":"user","required":false,"validationRegex":"^[0-9]+$"}]`)
require.Error(t, err)
assert.Contains(t, err.Error(), "can only use regex validation for text values")
_, err = ParseCustomFieldDefinitions(`[{"id":"fe2bc740-6193-4ef2-b1e6-2408a691a98c","key":"department","displayName":"Department","type":"string","target":"user","required":true}]`)
require.Error(t, err)
assert.Contains(t, err.Error(), "requires a default value")
_, err = ParseCustomFieldDefinitions(`[{"id":"be42096c-3dc0-4a9c-8074-086b9f866286","key":"department","displayName":"Department","type":"string","target":"user","required":true,"validationRegex":"^ENG-[0-9]+$","defaultValue":"Sales"}]`)
require.Error(t, err)
assert.Contains(t, err.Error(), "does not match the required format")
}
func TestParseCustomFieldDefinitionsValidatesKey(t *testing.T) {
fields, err := ParseCustomFieldDefinitions(`[{"id":"36c1e786-c9e9-4daf-ab51-502ab8efc9ea","key":" department ","displayName":"Department","type":"string","target":"user","required":false}]`)
require.NoError(t, err)
require.Len(t, fields, 1)
assert.Equal(t, "department", fields[0].Key)
_, err = ParseCustomFieldDefinitions(`[{"id":"c0e41fb3-59c7-488a-8edb-57e94e9f15ac","key":" ","displayName":"Empty","type":"string","target":"user","required":false}]`)
require.Error(t, err)
assert.Contains(t, err.Error(), "custom field key is required")
_, err = ParseCustomFieldDefinitions(`[{"id":"8b2ff8eb-bcf5-4866-b690-1a5b6f9da56c","key":"email","displayName":"Email","type":"string","target":"user","required":false}]`)
require.Error(t, err)
assert.Contains(t, err.Error(), "reserved")
}
func TestValidateCustomFieldValuesAgainstFieldsAppliesRegex(t *testing.T) {
fields := []dto.CustomFieldDto{
{
ID: "4ca0513e-e223-4900-8c5e-303acac4d021",
Key: "employee_id",
DisplayName: "Employee ID",
Type: dto.CustomFieldTypeString,
ValidationRegex: "^EMP-[0-9]+$",
ValidationErrorMessage: "Employee ID must start with EMP-",
},
}
_, err := validateCustomFieldValuesAgainstFields([]dto.CustomFieldValueCreateDto{
{CustomFieldID: "4ca0513e-e223-4900-8c5e-303acac4d021", Value: "INVALID"},
}, fields)
require.Error(t, err)
assert.Equal(t, "Employee ID must start with EMP-", err.Error())
values, err := validateCustomFieldValuesAgainstFields([]dto.CustomFieldValueCreateDto{
{CustomFieldID: "4ca0513e-e223-4900-8c5e-303acac4d021", Value: "EMP-123"},
}, fields)
require.NoError(t, err)
require.Len(t, values, 1)
assert.Equal(t, "4ca0513e-e223-4900-8c5e-303acac4d021", values[0].CustomFieldID)
assert.Equal(t, "EMP-123", values[0].Value)
}
func TestValidateCustomFieldValuesAgainstFieldsUsesDefaultValue(t *testing.T) {
fields := []dto.CustomFieldDto{
{
ID: "4225f448-f189-47d5-97d6-90292cc5bf9e",
Key: "department",
DisplayName: "Department",
Type: dto.CustomFieldTypeString,
Required: true,
DefaultValue: "Engineering",
},
{
ID: "398c23a4-c2e7-4b87-b6df-ed6bf1810579",
Key: "active",
DisplayName: "Active",
Type: dto.CustomFieldTypeBoolean,
Required: true,
DefaultValue: "false",
},
}
values, err := validateCustomFieldValuesAgainstFields(nil, fields)
require.NoError(t, err)
require.Len(t, values, 2)
assert.Equal(t, dto.CustomFieldValueCreateDto{CustomFieldID: "4225f448-f189-47d5-97d6-90292cc5bf9e", Key: "department", Value: "Engineering"}, values[0])
assert.Equal(t, dto.CustomFieldValueCreateDto{CustomFieldID: "398c23a4-c2e7-4b87-b6df-ed6bf1810579", Key: "active", Value: "false"}, values[1])
}
func TestGetCustomFieldValuesAppliesDefaultValues(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
user := model.User{Username: "alice", FirstName: "Alice", DisplayName: "Alice"}
require.NoError(t, db.Create(&user).Error)
group := model.UserGroup{Name: "engineering", FriendlyName: "Engineering"}
require.NoError(t, db.Create(&group).Error)
require.NoError(t, db.Model(&user).Association("UserGroups").Append(&group))
appConfigService := NewTestAppConfigService(&model.AppConfig{
CustomFields: model.AppConfigVariable{Value: `[
{"id":"81b3c82a-46c8-49c0-9559-a31df8586ef1","key":"department","displayName":"Department","type":"string","target":"user","required":false,"defaultValue":"Engineering"},
{"id":"b064d601-bc94-4ecf-a5cb-b783f3de0281","key":"group_label","displayName":"Group label","type":"string","target":"group","required":false,"defaultValue":"Employee"}
]`},
})
service := NewCustomFieldValueService(db, appConfigService)
userValues, err := service.GetCustomFieldValuesForUser(t.Context(), user.ID, db)
require.NoError(t, err)
require.Len(t, userValues, 1)
assert.Equal(t, "81b3c82a-46c8-49c0-9559-a31df8586ef1", userValues[0].CustomFieldID)
assert.Equal(t, "Engineering", userValues[0].Value)
require.NotNil(t, userValues[0].UserID)
groupValues, err := service.GetCustomFieldValuesForUserGroup(t.Context(), group.ID, db)
require.NoError(t, err)
require.Len(t, groupValues, 1)
assert.Equal(t, "b064d601-bc94-4ecf-a5cb-b783f3de0281", groupValues[0].CustomFieldID)
assert.Equal(t, "Employee", groupValues[0].Value)
require.NotNil(t, groupValues[0].UserGroupID)
combinedValues, err := service.GetCustomFieldValuesForUserWithUserGroups(t.Context(), user.ID, db)
require.NoError(t, err)
require.Len(t, combinedValues, 2)
valuesByFieldID := map[string]string{}
for _, value := range combinedValues {
valuesByFieldID[value.CustomFieldID] = value.Value
}
assert.Equal(t, "Engineering", valuesByFieldID["81b3c82a-46c8-49c0-9559-a31df8586ef1"])
assert.Equal(t, "Employee", valuesByFieldID["b064d601-bc94-4ecf-a5cb-b783f3de0281"])
}
func TestUpdateSelfEditableCustomFieldValuesForUser(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
user := model.User{Username: "alice", FirstName: "Alice", DisplayName: "Alice"}
require.NoError(t, db.Create(&user).Error)
require.NoError(t, db.Create([]model.CustomFieldValue{
{UserID: &user.ID, CustomFieldID: "608a9c35-2330-433d-bf33-c46f065d5d06", Value: "old"},
{UserID: &user.ID, CustomFieldID: "8501e000-09bb-428c-8be3-b0d3b0c682fd", Value: "admin"},
}).Error)
appConfigService := NewTestAppConfigService(&model.AppConfig{
CustomFields: model.AppConfigVariable{Value: `[
{"id":"608a9c35-2330-433d-bf33-c46f065d5d06","key":"nickname","displayName":"Nickname","type":"string","target":"user","required":false,"userEditable":true},
{"id":"8501e000-09bb-428c-8be3-b0d3b0c682fd","key":"cost_center","displayName":"Cost center","type":"string","target":"user","required":false,"userEditable":false}
]`},
})
service := NewCustomFieldValueService(db, appConfigService)
tx := db.Begin()
updatedValues, err := service.updateSelfEditableCustomFieldValuesForUser(t.Context(), user.ID, []dto.CustomFieldValueCreateDto{
{CustomFieldID: "608a9c35-2330-433d-bf33-c46f065d5d06", Value: "new"},
}, tx)
require.NoError(t, err)
require.NoError(t, tx.Commit().Error)
valuesByFieldID := map[string]string{}
for _, value := range updatedValues {
valuesByFieldID[value.CustomFieldID] = value.Value
}
assert.Equal(t, "new", valuesByFieldID["608a9c35-2330-433d-bf33-c46f065d5d06"])
assert.Equal(t, "admin", valuesByFieldID["8501e000-09bb-428c-8be3-b0d3b0c682fd"])
tx = db.Begin()
_, err = service.updateSelfEditableCustomFieldValuesForUser(t.Context(), user.ID, []dto.CustomFieldValueCreateDto{
{CustomFieldID: "invalid", Value: "user"},
}, tx)
require.Error(t, err)
assert.Contains(t, err.Error(), "not configured")
tx.Rollback()
var costCenter model.CustomFieldValue
require.NoError(t, db.Where("user_id = ? AND custom_field_id = ?", user.ID, "8501e000-09bb-428c-8be3-b0d3b0c682fd").First(&costCenter).Error)
assert.Equal(t, "admin", costCenter.Value)
}

View File

@@ -8,6 +8,7 @@ import (
"crypto/elliptic"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"path"
@@ -17,6 +18,7 @@ import (
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
@@ -236,15 +238,6 @@ func (s *TestService) SeedDatabase(baseURL string) error {
userGroups[1],
},
},
{
Base: model.Base{
ID: "a1b2c3d4-e5f6-7890-abcd-ef0000000001",
},
Name: "PAR Test Client",
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
CallbackURLs: model.UrlList{"http://par-client/auth/callback"},
CreatedByID: new(users[0].ID),
},
}
for _, client := range oidcClients {
if err := tx.Create(&client).Error; err != nil {
@@ -319,12 +312,6 @@ func (s *TestService) SeedDatabase(baseURL string) error {
ClientID: oidcClients[3].ID,
LastUsedAt: datatype.DateTime(time.Date(2025, 8, 12, 12, 0, 0, 0, time.UTC)),
},
{
Scope: "openid profile email",
UserID: users[0].ID,
ClientID: oidcClients[5].ID,
LastUsedAt: datatype.DateTime(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)),
},
}
for _, userAuthorizedClient := range userAuthorizedClients {
if err := tx.Create(&userAuthorizedClient).Error; err != nil {
@@ -578,6 +565,56 @@ func (s *TestService) ResetAppConfig(ctx context.Context) error {
return err
}
// Add custom fields
customFields := []dto.CustomFieldDto{
{
ID: "189356b1-57f3-4c14-bd59-3ae1132a36d1",
Key: "department",
Type: "string",
UserEditable: true,
DisplayName: "Department",
Target: "user",
ValidationRegex: "^[A-Za-z]+$",
ValidationErrorMessage: "Department must only contain letters",
},
{
ID: "0b68d19a-bb72-4750-84b4-2f0992f5200c",
Key: "nickname",
Type: "string",
UserEditable: true,
Required: true,
DisplayName: "Nickname",
Target: "user",
DefaultValue: "to-remove",
},
{
ID: "8d081fd8-6a51-45a1-8051-04c3b043f5bd",
Key: "elevatedRights",
Type: "boolean",
DisplayName: "Elevated Rights",
Target: "group",
},
{
ID: "3d7c6054-e146-48cb-b2d3-7d7897dcbc51",
Key: "internalId",
Type: "number",
DefaultValue: "0",
UserEditable: false,
DisplayName: "Internal ID",
Target: "both",
Required: true,
},
}
customFieldsJSON, err := json.Marshal(&customFields)
if err != nil {
return err
}
err = s.appConfigService.UpdateAppConfigValues(ctx, "customFields", string(customFieldsJSON))
if err != nil {
return err
}
// Reload the app config from the database after resetting the values
err = s.appConfigService.LoadDbConfig(ctx)
if err != nil {

View File

@@ -216,8 +216,69 @@ func (s *LdapService) applyAdminGroupMembership(desiredUsers []ldapDesiredUser,
}
}
func (s *LdapService) getLDAPCustomFields(idType idType) ([]dto.CustomFieldDto, error) {
fields, err := ParseCustomFieldDefinitions(s.appConfigService.GetDbConfig().CustomFields.Value)
if err != nil {
return nil, err
}
ldapFields := make([]dto.CustomFieldDto, 0, len(fields))
for _, field := range fields {
if !customFieldAppliesTo(field, idType) {
continue
}
ldapFields = append(ldapFields, field)
}
return ldapFields, nil
}
func appendLDAPCustomFieldAttributes(searchAttrs []string, fields []dto.CustomFieldDto) []string {
seenAttrs := make(map[string]struct{}, len(searchAttrs)+len(fields))
for _, attr := range searchAttrs {
if attr == "" {
continue
}
seenAttrs[attr] = struct{}{}
}
for _, field := range fields {
if _, ok := seenAttrs[field.Key]; ok {
continue
}
searchAttrs = append(searchAttrs, field.Key)
seenAttrs[field.Key] = struct{}{}
}
return searchAttrs
}
func customFieldValuesFromLDAPEntry(entry *ldap.Entry, fields []dto.CustomFieldDto) []dto.CustomFieldValueCreateDto {
if len(fields) == 0 {
return nil
}
customFieldValues := make([]dto.CustomFieldValueCreateDto, 0, len(fields))
for _, field := range fields {
value := entry.GetAttributeValue(field.Key)
if value == "" {
continue
}
customFieldValues = append(customFieldValues, dto.CustomFieldValueCreateDto{
CustomFieldID: field.ID,
Value: value,
})
}
return customFieldValues
}
func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient, usernamesByDN map[string]string) (desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}, err error) {
dbConfig := s.appConfigService.GetDbConfig()
customFields, err := s.getLDAPCustomFields(UserGroupID)
if err != nil {
return nil, nil, err
}
// Query LDAP for all groups we want to manage
searchAttrs := []string{
@@ -225,6 +286,7 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient
dbConfig.LdapAttributeGroupUniqueIdentifier.Value,
dbConfig.LdapAttributeGroupMember.Value,
}
searchAttrs = appendLDAPCustomFieldAttributes(searchAttrs, customFields)
searchReq := ldap.NewSearchRequest(
dbConfig.LdapBase.Value,
@@ -267,9 +329,10 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient
}
syncGroup := dto.UserGroupCreateDto{
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
LdapID: ldapID,
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
LdapID: ldapID,
CustomFieldValues: customFieldValuesFromLDAPEntry(value, customFields),
}
dto.Normalize(&syncGroup)
@@ -291,6 +354,10 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient
func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient) (desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}, usernamesByDN map[string]string, err error) {
dbConfig := s.appConfigService.GetDbConfig()
customFields, err := s.getLDAPCustomFields(UserID)
if err != nil {
return nil, nil, nil, err
}
// Query LDAP for all users we want to manage
searchAttrs := []string{
@@ -304,6 +371,7 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient)
dbConfig.LdapAttributeUserProfilePicture.Value,
dbConfig.LdapAttributeUserDisplayName.Value,
}
searchAttrs = appendLDAPCustomFieldAttributes(searchAttrs, customFields)
// Filters must start and finish with ()!
searchReq := ldap.NewSearchRequest(
@@ -342,12 +410,13 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient)
ldapUserIDs[ldapID] = struct{}{}
newUser := dto.UserCreateDto{
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)),
EmailVerified: true,
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)),
EmailVerified: true,
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
CustomFieldValues: customFieldValuesFromLDAPEntry(value, customFields),
// Admin status is computed after groups are loaded so it can use the
// configured group member attribute instead of a hard-coded memberOf.
IsAdmin: false,
@@ -423,6 +492,11 @@ func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client lda
}
func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}) error {
customFields, err := s.getLDAPCustomFields(UserGroupID)
if err != nil {
return err
}
// Load the current LDAP-managed state from the database
ldapGroupsInDB, ldapGroupsByID, err := s.loadLDAPGroupsInDB(ctx, tx)
if err != nil {
@@ -448,28 +522,38 @@ func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredG
}
databaseGroup := ldapGroupsByID[desiredGroup.ldapID]
var groupID string
if databaseGroup.ID == "" {
newGroup, err := s.groupService.createInternal(ctx, desiredGroup.input, tx)
if err != nil {
return fmt.Errorf("failed to create group '%s': %w", desiredGroup.input.Name, err)
}
ldapGroupsByID[desiredGroup.ldapID] = newGroup
groupID = newGroup.ID
_, err = s.groupService.updateUsersInternal(ctx, newGroup.ID, memberUserIDs, tx)
if err != nil {
return fmt.Errorf("failed to sync users for group '%s': %w", desiredGroup.input.Name, err)
}
continue
} else {
groupID = databaseGroup.ID
_, err = s.groupService.updateInternal(ctx, databaseGroup.ID, desiredGroup.input, true, tx)
if err != nil {
return fmt.Errorf("failed to update group '%s': %w", desiredGroup.input.Name, err)
}
_, err = s.groupService.updateUsersInternal(ctx, databaseGroup.ID, memberUserIDs, tx)
if err != nil {
return fmt.Errorf("failed to sync users for group '%s': %w", desiredGroup.input.Name, err)
}
}
_, err = s.groupService.updateInternal(ctx, databaseGroup.ID, desiredGroup.input, true, tx)
if err != nil {
return fmt.Errorf("failed to update group '%s': %w", desiredGroup.input.Name, err)
}
_, err = s.groupService.updateUsersInternal(ctx, databaseGroup.ID, memberUserIDs, tx)
if err != nil {
return fmt.Errorf("failed to sync users for group '%s': %w", desiredGroup.input.Name, err)
if len(customFields) > 0 {
_, err = s.groupService.customFieldValueService.updateCustomFieldValuesForFields(ctx, UserGroupID, groupID, desiredGroup.input.CustomFieldValues, customFields, tx)
if err != nil {
return fmt.Errorf("failed to sync custom fields for group '%s': %w", desiredGroup.input.Name, err)
}
}
}
@@ -500,6 +584,10 @@ func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredG
//nolint:gocognit
func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}) (savePictures []savePicture, deleteFiles []string, err error) {
dbConfig := s.appConfigService.GetDbConfig()
customFields, err := s.getLDAPCustomFields(UserID)
if err != nil {
return nil, nil, err
}
// Load the current LDAP-managed state from the database
ldapUsersInDB, ldapUsersByID, _, err := s.loadLDAPUsersInDB(ctx, tx)
@@ -551,6 +639,11 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs
}
}
_, err = s.userService.customFieldValueService.updateCustomFieldValuesForFields(ctx, UserID, userID, desiredUser.input.CustomFieldValues, customFields, tx)
if err != nil {
return nil, nil, fmt.Errorf("failed to sync custom fields for user '%s': %w", desiredUser.input.Username, err)
}
if desiredUser.picture != "" {
savePictures = append(savePictures, savePicture{
userID: userID,

View File

@@ -141,6 +141,51 @@ func TestLdapServiceSyncAllReconcilesUsersAndGroups(t *testing.T) {
assert.ElementsMatch(t, []string{"alice", "bob"}, usernames(team.Users))
}
func TestLdapServiceSyncAllImportsCustomFieldsFromLDAP(t *testing.T) {
appCfg := defaultTestLDAPAppConfig()
appCfg.CustomFields = model.AppConfigVariable{Value: `[
{"id":"5b6f0cb7-2865-4c2e-9795-4c81e3725f21","key":"quota","displayName":"Quota","type":"string","target":"user","required":false},
{"id":"5085ac6f-a1d4-4cb8-bd6b-40d68b8f0644","key":"mailboxTemplate","displayName":"Mailbox template","type":"string","target":"user","required":false},
{"id":"9a98fcfb-1d0b-46a3-b028-3c43694b1771","key":"nextcloudQuota","displayName":"Group quota","type":"string","target":"group","required":false}
]`}
service, db := newTestLdapServiceWithAppConfig(t, appCfg, newFakeLDAPClient(
ldapSearchResult(
ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-alice"},
"uid": {"alice"},
"mail": {"alice@example.com"},
"givenName": {"Alice"},
"sn": {"Jones"},
"displayName": {""},
"quota": {"10 GB"},
"mailboxTemplate": {"standard"},
}),
),
ldapSearchResult(
ldapEntry("cn=team,ou=groups,dc=example,dc=com", map[string][]string{
"entryUUID": {"g-team"},
"cn": {"team"},
"member": {"uid=alice,ou=people,dc=example,dc=com"},
"nextcloudQuota": {"100 GB"},
}),
),
))
require.NoError(t, service.SyncAll(t.Context()))
var alice model.User
require.NoError(t, db.Preload("CustomFieldValues").First(&alice, "ldap_id = ?", "u-alice").Error)
userValues := customFieldValuesByID(alice.CustomFieldValues)
assert.Equal(t, "10 GB", userValues["5b6f0cb7-2865-4c2e-9795-4c81e3725f21"])
assert.Equal(t, "standard", userValues["5085ac6f-a1d4-4cb8-bd6b-40d68b8f0644"])
var group model.UserGroup
require.NoError(t, db.Preload("CustomFieldValues").First(&group, "ldap_id = ?", "g-team").Error)
groupValues := customFieldValuesByID(group.CustomFieldValues)
assert.Equal(t, "100 GB", groupValues["9a98fcfb-1d0b-46a3-b028-3c43694b1771"])
}
// Regression: posixGroup uses memberUid (bare uid values), not member DNs — issue #1408.
func TestLdapServiceSyncAllMapsPosixGroupMemberUid(t *testing.T) {
appCfg := defaultTestLDAPAppConfig()
@@ -318,14 +363,15 @@ func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *model.AppConf
appConfig := NewTestAppConfigService(appConfigModel)
groupService := NewUserGroupService(db, appConfig, nil)
customFieldValueService := NewCustomFieldValueService(db, appConfig)
groupService := NewUserGroupService(db, appConfig, customFieldValueService, nil)
userService := NewUserService(
db,
nil,
nil,
nil,
appConfig,
NewCustomClaimService(db),
customFieldValueService,
NewAppImagesService(map[string]string{}, fileStorage),
nil,
fileStorage,
@@ -405,6 +451,15 @@ func usernames(users []model.User) []string {
return result
}
func customFieldValuesByID(values []model.CustomFieldValue) map[string]string {
result := make(map[string]string, len(values))
for _, value := range values {
result[value.CustomFieldID] = value.Value
}
return result
}
func TestGetDNProperty(t *testing.T) {
tests := []struct {
name string

View File

@@ -6,7 +6,6 @@ import (
"crypto/subtle"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
@@ -48,19 +47,16 @@ const (
AccessTokenDuration = time.Hour
RefreshTokenDuration = 30 * 24 * time.Hour // 30 days
DeviceCodeDuration = 15 * time.Minute
PARDuration = 90 * time.Second
parRequestURIPrefix = "urn:ietf:params:oauth:request_uri:"
)
type OidcService struct {
db *gorm.DB
jwtService *JwtService
appConfigService *AppConfigService
auditLogService *AuditLogService
customClaimService *CustomClaimService
webAuthnService *WebAuthnService
scimService *ScimService
db *gorm.DB
jwtService *JwtService
appConfigService *AppConfigService
auditLogService *AuditLogService
customFieldValueService *CustomFieldValueService
webAuthnService *WebAuthnService
scimService *ScimService
httpClient *http.Client
jwkCache *jwk.Cache
@@ -73,22 +69,22 @@ func NewOidcService(
jwtService *JwtService,
appConfigService *AppConfigService,
auditLogService *AuditLogService,
customClaimService *CustomClaimService,
customFieldValueService *CustomFieldValueService,
webAuthnService *WebAuthnService,
scimService *ScimService,
httpClient *http.Client,
fileStorage storage.FileStorage,
) (s *OidcService, err error) {
s = &OidcService{
db: db,
jwtService: jwtService,
appConfigService: appConfigService,
auditLogService: auditLogService,
customClaimService: customClaimService,
webAuthnService: webAuthnService,
scimService: scimService,
httpClient: httpClient,
fileStorage: fileStorage,
db: db,
jwtService: jwtService,
appConfigService: appConfigService,
auditLogService: auditLogService,
customFieldValueService: customFieldValueService,
webAuthnService: webAuthnService,
scimService: scimService,
httpClient: httpClient,
fileStorage: fileStorage,
}
// Note: we don't pass the HTTP Client with OTel instrumented to this because requests are always made in background and not tied to a specific trace
@@ -141,18 +137,6 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
return "", "", err
}
// If the client requires PAR, a request_uri must be provided
if client.RequiresPushedAuthorizationRequests && input.RequestURI == "" {
return "", "", &common.OidcPARRequiredError{}
}
// If a request_uri is provided, consume the stored PAR and overwrite input fields
if input.RequestURI != "" {
if err := s.applyPushedAuthorizationRequest(ctx, tx, &input); err != nil {
return "", "", err
}
}
// If the client is not public, the code challenge must be provided
if client.IsPublic && input.CodeChallenge == "" {
return "", "", &common.OidcMissingCodeChallengeError{}
@@ -256,48 +240,11 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
return code, callbackURL, nil
}
// applyPushedAuthorizationRequest consumes the stored PAR for the given request_uri
// and overwrites the corresponding fields on input.
func (s *OidcService) applyPushedAuthorizationRequest(ctx context.Context, tx *gorm.DB, input *dto.AuthorizeOidcClientRequestDto) error {
parMeta, err := s.getAndConsumePushedAuthorizationRequest(ctx, tx, input.ClientID, input.RequestURI)
if err != nil {
return err
}
par := parMeta.Parameters
input.Scope = par.Scope
input.CallbackURL = par.RedirectURI
input.Nonce = par.Nonce
input.Prompt = par.Prompt
input.CodeChallenge = par.CodeChallenge
input.CodeChallengeMethod = par.CodeChallengeMethod
return nil
}
// HasAuthorizedClient checks if the user has already authorized the client with the given scope
func (s *OidcService) HasAuthorizedClient(ctx context.Context, clientID, userID, scope string) (bool, error) {
return s.hasAuthorizedClientInternal(ctx, clientID, userID, scope, s.db)
}
// AuthorizationRequired reports whether the user must confirm authorization for the client.
func (s *OidcService) AuthorizationRequired(ctx context.Context, clientID, userID, scope, requestURI string) (required bool, resolvedScope string, err error) {
if requestURI != "" {
par, err := s.getPushedAuthorizationRequestInternal(ctx, s.db, clientID, requestURI)
if err != nil {
return false, "", err
}
scope = par.Parameters.Scope
}
hasAuthorized, err := s.hasAuthorizedClientInternal(ctx, clientID, userID, scope, s.db)
if err != nil {
return false, "", err
}
return !hasAuthorized, scope, nil
}
func (s *OidcService) hasAuthorizedClientInternal(ctx context.Context, clientID, userID, scope string, tx *gorm.DB) (bool, error) {
var userAuthorizedOidcClient model.UserAuthorizedOidcClient
err := tx.
@@ -977,8 +924,6 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien
// PKCE is required for public clients
client.PkceEnabled = input.IsPublic || input.PkceEnabled
client.RequiresReauthentication = input.RequiresReauthentication
// PAR is not available for public clients, so ignore the flag if the client is public
client.RequiresPushedAuthorizationRequests = !input.IsPublic && input.RequiresPushedAuthorizationRequests
client.LaunchURL = input.LaunchURL
client.IsGroupRestricted = input.IsGroupRestricted
@@ -1372,116 +1317,6 @@ func codeChallengeMethodIsSha256(codeChallengeMethod string) (bool, error) {
}
}
// CreatePushedAuthorizationRequest validates and stores authorization parameters for PAR (RFC 9126).
// Only confidential clients (non-public) may use this endpoint.
func (s *OidcService) CreatePushedAuthorizationRequest(ctx context.Context, creds ClientAuthCredentials, input dto.OidcPARRequestDto) (requestURI string, expiresIn int, err error) {
// Public clients are not allowed, but we allow them in this step for better error messages
client, err := s.verifyClientCredentialsInternal(ctx, s.db, creds, true)
if err != nil {
return "", 0, err
}
// Reject public clients here
if client.IsPublic {
return "", 0, &common.OidcPARNotSupportedForPublicClientsError{}
}
if input.ResponseType != "code" {
return "", 0, common.NewOidcInvalidRequestError("unsupported response_type: only 'code' is supported")
}
// Validate redirect_uri at push time (BCP requirement)
if _, err = s.getCallbackURL(client, input.RedirectURI, s.db, ctx); err != nil {
return "", 0, err
}
randomSuffix, err := utils.GenerateRandomAlphanumericString(32)
if err != nil {
return "", 0, fmt.Errorf("failed to generate request URI: %w", err)
}
requestURI = parRequestURIPrefix + randomSuffix
par := model.OidcPushedAuthorizationRequest{
RequestURI: requestURI,
ClientID: client.ID,
ExpiresAt: datatype.DateTime(time.Now().Add(PARDuration)),
Parameters: model.OidcAuthorizationRequestParameters{
Scope: input.Scope,
RedirectURI: input.RedirectURI,
State: input.State,
Nonce: input.Nonce,
CodeChallenge: input.CodeChallenge,
CodeChallengeMethod: input.CodeChallengeMethod,
ResponseType: input.ResponseType,
Prompt: input.Prompt,
ResponseMode: input.ResponseMode,
},
}
if err = s.db.WithContext(ctx).Create(&par).Error; err != nil {
return "", 0, fmt.Errorf("failed to store pushed authorization request: %w", err)
}
return requestURI, int(PARDuration.Seconds()), nil
}
// GetPushedAuthorizationRequest retrieves a PAR record without consuming it.
func (s *OidcService) GetPushedAuthorizationRequest(ctx context.Context, clientID, requestURI string) (model.OidcPushedAuthorizationRequest, error) {
return s.getPushedAuthorizationRequestInternal(ctx, s.db, clientID, requestURI)
}
// getPushedAuthorizationRequestInternal retrieves a PAR record without consuming it.
func (s *OidcService) getPushedAuthorizationRequestInternal(ctx context.Context, tx *gorm.DB, clientID, requestURI string) (model.OidcPushedAuthorizationRequest, error) {
var par model.OidcPushedAuthorizationRequest
err := tx.
WithContext(ctx).
Where(
"request_uri = ? AND client_id = ? AND expires_at > ?",
requestURI,
clientID,
datatype.DateTime(time.Now()),
).
First(&par).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return par, &common.OidcInvalidRequestURIError{}
}
return par, err
}
return par, nil
}
// getAndConsumePushedAuthorizationRequest atomically retrieves and deletes a PAR record.
// Returns OidcInvalidRequestURIError if the record is not found, expired, or belongs to a different client.
func (s *OidcService) getAndConsumePushedAuthorizationRequest(ctx context.Context, tx *gorm.DB, clientID, requestURI string) (model.OidcPushedAuthorizationRequest, error) {
var par model.OidcPushedAuthorizationRequest
err := tx.
WithContext(ctx).
Clauses(clause.Returning{}).
Where(
"request_uri = ? AND client_id = ? AND expires_at > ?",
requestURI,
clientID,
datatype.DateTime(time.Now()),
).
Delete(&par).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return par, &common.OidcInvalidRequestURIError{}
}
return par, err
}
// After DELETE … RETURNING, check if a row was actually deleted
if par.ID == "" {
return par, &common.OidcInvalidRequestURIError{}
}
return par, nil
}
func validateCodeVerifier(codeVerifier, codeChallenge string, codeChallengeMethodSha256 bool) bool {
if codeVerifier == "" || codeChallenge == "" {
return false
@@ -2207,23 +2042,51 @@ func (s *OidcService) getUserClaims(ctx context.Context, user *model.User, scope
}
if slices.Contains(scopes, "profile") {
// Add custom claims
customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(ctx, user.ID, tx)
// We need to fetch the user and group fields first because we need the key of the custom key later
userFields, err := s.customFieldValueService.GetConfiguredCustomFieldsForTarget(UserID)
if err != nil {
return nil, err
}
userFieldsByID := make(map[string]dto.CustomFieldDto, len(userFields))
for _, customField := range userFields {
userFieldsByID[customField.ID] = customField
}
groupFields, err := s.customFieldValueService.GetConfiguredCustomFieldsForTarget(UserGroupID)
if err != nil {
return nil, err
}
groupFieldsByID := make(map[string]dto.CustomFieldDto, len(groupFields))
for _, customField := range groupFields {
groupFieldsByID[customField.ID] = customField
}
// Fetch the actual values of the custom fields
customFieldValues, err := s.customFieldValueService.GetCustomFieldValuesForUserWithUserGroups(ctx, user.ID, tx)
if err != nil {
return nil, err
}
for _, customClaim := range customClaims {
// The value of the custom claim can be a JSON object or a string
var jsonValue any
err := json.Unmarshal([]byte(customClaim.Value), &jsonValue)
if err == nil {
// It's JSON, so we store it as an object
claims[customClaim.Key] = jsonValue
} else {
// Marshaling failed, so we store it as a string
claims[customClaim.Key] = customClaim.Value
for _, customFieldValue := range customFieldValues {
var customField *dto.CustomFieldDto
var claimKey string
if customFieldValue.UserID != nil {
if field, ok := userFieldsByID[customFieldValue.CustomFieldID]; ok {
customField = &field
claimKey = field.Key
}
} else if customFieldValue.UserGroupID != nil {
if field, ok := groupFieldsByID[customFieldValue.CustomFieldID]; ok {
customField = &field
claimKey = field.Key
}
}
value, err := customFieldValueTokenValue(customFieldValue, customField)
if err != nil {
return nil, err
}
claims[claimKey] = value
}
// Add profile claims

View File

@@ -15,19 +15,24 @@ import (
)
type UserGroupService struct {
db *gorm.DB
scimService *ScimService
appConfigService *AppConfigService
db *gorm.DB
scimService *ScimService
appConfigService *AppConfigService
customFieldValueService *CustomFieldValueService
}
func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService, scimService *ScimService) *UserGroupService {
return &UserGroupService{db: db, appConfigService: appConfigService, scimService: scimService}
func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService, customFieldValueService *CustomFieldValueService, scimService *ScimService) *UserGroupService {
return &UserGroupService{db: db, appConfigService: appConfigService, customFieldValueService: customFieldValueService, scimService: scimService}
}
func (s *UserGroupService) List(ctx context.Context, name string, listRequestOptions utils.ListRequestOptions) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
query := s.db.
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
query := tx.
WithContext(ctx).
Preload("CustomClaims").
Model(&model.UserGroup{})
if name != "" {
@@ -43,6 +48,17 @@ func (s *UserGroupService) List(ctx context.Context, name string, listRequestOpt
}
response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &groups)
if err != nil {
return nil, utils.PaginationResponse{}, err
}
for i := range groups {
groups[i].CustomFieldValues, err = s.customFieldValueService.GetCustomFieldValuesForUserGroup(ctx, groups[i].ID, tx)
if err != nil {
return nil, utils.PaginationResponse{}, err
}
}
return groups, response, err
}
@@ -54,11 +70,19 @@ func (s *UserGroupService) getInternal(ctx context.Context, id string, tx *gorm.
err = tx.
WithContext(ctx).
Where("id = ?", id).
Preload("CustomClaims").
Preload("Users").
Preload("AllowedOidcClients").
First(&group).
Error
if err != nil {
return model.UserGroup{}, err
}
group.CustomFieldValues, err = s.customFieldValueService.GetCustomFieldValuesForUserGroup(ctx, group.ID, tx)
if err != nil {
return model.UserGroup{}, err
}
return group, err
}
@@ -104,7 +128,22 @@ func (s *UserGroupService) Delete(ctx context.Context, id string) error {
}
func (s *UserGroupService) Create(ctx context.Context, input dto.UserGroupCreateDto) (group model.UserGroup, err error) {
return s.createInternal(ctx, input, s.db)
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
group, err = s.createInternal(ctx, input, tx)
if err != nil {
return model.UserGroup{}, err
}
err = tx.Commit().Error
if err != nil {
return model.UserGroup{}, err
}
return group, nil
}
func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGroupCreateDto, tx *gorm.DB) (group model.UserGroup, err error) {
@@ -129,6 +168,12 @@ func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGro
return model.UserGroup{}, err
}
if input.LdapID == "" {
if group.CustomFieldValues, err = s.customFieldValueService.updateCustomFieldValuesInternal(ctx, UserGroupID, group.ID, input.CustomFieldValues, tx); err != nil {
return model.UserGroup{}, err
}
}
if s.scimService != nil {
s.scimService.ScheduleSync()
}
@@ -181,6 +226,12 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input
return model.UserGroup{}, err
}
if input.CustomFieldValues != nil {
if _, err := s.customFieldValueService.updateCustomFieldValuesInternal(ctx, UserGroupID, group.ID, input.CustomFieldValues, tx); err != nil {
return model.UserGroup{}, err
}
}
if s.scimService != nil {
s.scimService.ScheduleSync()
}

View File

@@ -27,37 +27,41 @@ import (
)
type UserService struct {
db *gorm.DB
jwtService *JwtService
auditLogService *AuditLogService
emailService *EmailService
appConfigService *AppConfigService
customClaimService *CustomClaimService
appImagesService *AppImagesService
scimService *ScimService
fileStorage storage.FileStorage
db *gorm.DB
jwtService *JwtService
auditLogService *AuditLogService
emailService *EmailService
appConfigService *AppConfigService
customFieldValueService *CustomFieldValueService
appImagesService *AppImagesService
scimService *ScimService
fileStorage storage.FileStorage
}
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService, customClaimService *CustomClaimService, appImagesService *AppImagesService, scimService *ScimService, fileStorage storage.FileStorage) *UserService {
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService, customFieldValueService *CustomFieldValueService, appImagesService *AppImagesService, scimService *ScimService, fileStorage storage.FileStorage) *UserService {
return &UserService{
db: db,
jwtService: jwtService,
auditLogService: auditLogService,
emailService: emailService,
appConfigService: appConfigService,
customClaimService: customClaimService,
appImagesService: appImagesService,
scimService: scimService,
fileStorage: fileStorage,
db: db,
jwtService: jwtService,
auditLogService: auditLogService,
emailService: emailService,
appConfigService: appConfigService,
customFieldValueService: customFieldValueService,
appImagesService: appImagesService,
scimService: scimService,
fileStorage: fileStorage,
}
}
func (s *UserService) ListUsers(ctx context.Context, searchTerm string, listRequestOptions utils.ListRequestOptions) ([]model.User, utils.PaginationResponse, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var users []model.User
query := s.db.WithContext(ctx).
query := tx.WithContext(ctx).
Model(&model.User{}).
Preload("UserGroups").
Preload("CustomClaims")
Preload("UserGroups")
if searchTerm != "" {
searchPattern := "%" + searchTerm + "%"
@@ -67,6 +71,16 @@ func (s *UserService) ListUsers(ctx context.Context, searchTerm string, listRequ
}
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &users)
if err != nil {
return nil, utils.PaginationResponse{}, err
}
for i := range users {
users[i].CustomFieldValues, err = s.customFieldValueService.GetCustomFieldValuesForUser(ctx, users[i].ID, tx)
if err != nil {
return nil, utils.PaginationResponse{}, err
}
}
return users, pagination, err
}
@@ -80,10 +94,17 @@ func (s *UserService) getUserInternal(ctx context.Context, userID string, tx *go
err := tx.
WithContext(ctx).
Preload("UserGroups").
Preload("CustomClaims").
Where("id = ?", userID).
First(&user).
Error
if err != nil {
return model.User{}, err
}
user.CustomFieldValues, err = s.customFieldValueService.GetCustomFieldValuesForUser(ctx, user.ID, tx)
if err != nil {
return model.User{}, err
}
return user, err
}
@@ -301,15 +322,17 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
return model.User{}, err
}
// Apply default groups and claims for new non-LDAP users
// Apply default groups and custom fields for new non-LDAP users.
if !isLdapSync {
if len(input.UserGroupIds) == 0 {
if err := s.applyDefaultGroups(ctx, &user, tx); err != nil {
return model.User{}, err
}
}
if err := s.applyDefaultCustomClaims(ctx, &user, tx); err != nil {
}
if !isLdapSync {
user.CustomFieldValues, err = s.customFieldValueService.updateCustomFieldValuesInternal(ctx, UserID, user.ID, input.CustomFieldValues, tx)
if err != nil {
return model.User{}, err
}
}
@@ -353,27 +376,6 @@ func (s *UserService) applyDefaultGroups(ctx context.Context, user *model.User,
return nil
}
func (s *UserService) applyDefaultCustomClaims(ctx context.Context, user *model.User, tx *gorm.DB) error {
config := s.appConfigService.GetDbConfig()
var claims []dto.CustomClaimCreateDto
v := config.SignupDefaultCustomClaims.Value
if v != "" && v != "[]" {
err := json.Unmarshal([]byte(v), &claims)
if err != nil {
return fmt.Errorf("invalid SignupDefaultCustomClaims JSON: %w", err)
}
if len(claims) > 0 {
_, err = s.customClaimService.updateCustomClaimsInternal(ctx, UserID, user.ID, claims, tx)
if err != nil {
return fmt.Errorf("failed to apply default custom claims: %w", err)
}
}
}
return nil
}
func (s *UserService) UpdateUser(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool) (model.User, error) {
tx := s.db.Begin()
defer func() {
@@ -463,6 +465,15 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
return user, err
}
if updateOwnUser {
user.CustomFieldValues, err = s.customFieldValueService.updateSelfEditableCustomFieldValuesForUser(ctx, user.ID, updatedUser.CustomFieldValues, tx)
} else {
user.CustomFieldValues, err = s.customFieldValueService.updateCustomFieldValuesInternal(ctx, UserID, user.ID, updatedUser.CustomFieldValues, tx)
}
if err != nil {
return user, err
}
if s.scimService != nil {
s.scimService.ScheduleSync()
}

View File

@@ -72,14 +72,20 @@ func (s *UserSignUpService) SignUp(ctx context.Context, signupData dto.SignUpDto
}
}
customFieldValues, err := s.filterSignupCustomFieldValues(signupData.CustomFieldValues)
if err != nil {
return model.User{}, "", err
}
userToCreate := dto.UserCreateDto{
Username: signupData.Username,
Email: signupData.Email,
FirstName: signupData.FirstName,
LastName: signupData.LastName,
DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
UserGroupIds: userGroupIDs,
EmailVerified: s.appConfigService.GetDbConfig().EmailsVerified.IsTrue(),
Username: signupData.Username,
Email: signupData.Email,
FirstName: signupData.FirstName,
LastName: signupData.LastName,
DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
UserGroupIds: userGroupIDs,
EmailVerified: s.appConfigService.GetDbConfig().EmailsVerified.IsTrue(),
CustomFieldValues: customFieldValues,
}
user, err := s.userService.createUserInternal(ctx, userToCreate, false, tx)
@@ -132,13 +138,19 @@ func (s *UserSignUpService) SignUpInitialAdmin(ctx context.Context, signUpData d
return model.User{}, "", &common.SetupNotAvailableError{}
}
customFieldValues, err := s.filterSignupCustomFieldValues(signUpData.CustomFieldValues)
if err != nil {
return model.User{}, "", err
}
userToCreate := dto.UserCreateDto{
FirstName: signUpData.FirstName,
LastName: signUpData.LastName,
DisplayName: strings.TrimSpace(signUpData.FirstName + " " + signUpData.LastName),
Username: signUpData.Username,
Email: signUpData.Email,
IsAdmin: true,
FirstName: signUpData.FirstName,
LastName: signUpData.LastName,
DisplayName: strings.TrimSpace(signUpData.FirstName + " " + signUpData.LastName),
Username: signUpData.Username,
Email: signUpData.Email,
IsAdmin: true,
CustomFieldValues: customFieldValues,
}
user, err := s.userService.createUserInternal(ctx, userToCreate, false, tx)
@@ -159,6 +171,34 @@ func (s *UserSignUpService) SignUpInitialAdmin(ctx context.Context, signUpData d
return user, token, nil
}
func (s *UserSignUpService) filterSignupCustomFieldValues(customFieldValues []dto.CustomFieldValueCreateDto) ([]dto.CustomFieldValueCreateDto, error) {
fields, err := ParseCustomFieldDefinitions(s.appConfigService.GetDbConfig().CustomFields.Value)
if err != nil {
return nil, err
}
allowedFieldIDs := make(map[string]struct{}, len(fields))
allowedFieldKeys := make(map[string]struct{}, len(fields))
for _, field := range fields {
if !customFieldAppliesTo(field, UserID) || (!field.Required && !field.UserEditable) {
continue
}
allowedFieldIDs[field.ID] = struct{}{}
allowedFieldKeys[field.Key] = struct{}{}
}
filteredCustomFieldValues := make([]dto.CustomFieldValueCreateDto, 0, len(customFieldValues))
for _, customFieldValue := range customFieldValues {
_, idAllowed := allowedFieldIDs[customFieldValue.CustomFieldID]
_, keyAllowed := allowedFieldKeys[customFieldValue.Key]
if idAllowed || keyAllowed {
filteredCustomFieldValues = append(filteredCustomFieldValues, customFieldValue)
}
}
return filteredCustomFieldValues, nil
}
func (s *UserSignUpService) IsInitialAdminSetupCompleted(ctx context.Context) (bool, error) {
return s.isInitialAdminSetupCompleted(ctx, s.db)
}

View File

@@ -96,7 +96,7 @@ func stripWEBPMetadata(data []byte) []byte {
// WEBP image can max be 4GB in size
riffSize := out.Len() - 8
if riffSize < 0 || uint64(riffSize) > uint64(maxRIFFSize) {
if riffSize < 0 || riffSize > int(maxRIFFSize) {
return data
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,31 @@
CREATE TEMP TABLE custom_field_migration_map AS
SELECT
field.value->>'id' AS custom_field_id,
field.value->>'key' AS key
FROM app_config_variables
CROSS JOIN LATERAL jsonb_array_elements(app_config_variables.value::jsonb) AS field(value)
WHERE app_config_variables.key = 'customFields';
ALTER TABLE custom_field_values RENAME CONSTRAINT custom_field_values_unique TO custom_field_values_custom_field_id_unique;
ALTER TABLE custom_field_values ADD COLUMN key VARCHAR(255);
UPDATE custom_field_values
SET key = custom_field_migration_map.key
FROM custom_field_migration_map
WHERE custom_field_migration_map.custom_field_id = custom_field_values.custom_field_id;
UPDATE custom_field_values
SET key = custom_field_id
WHERE key IS NULL;
ALTER TABLE custom_field_values ALTER COLUMN key SET NOT NULL;
ALTER TABLE custom_field_values DROP CONSTRAINT custom_field_values_custom_field_id_unique;
ALTER TABLE custom_field_values DROP COLUMN custom_field_id;
ALTER TABLE custom_field_values ADD CONSTRAINT custom_claims_unique UNIQUE (key, user_id, user_group_id);
ALTER TABLE custom_field_values RENAME TO custom_claims;
DROP TABLE custom_field_migration_map;
DELETE FROM app_config_variables WHERE key = 'customFields';

View File

@@ -0,0 +1,61 @@
ALTER TABLE custom_claims RENAME TO custom_field_values;
ALTER TABLE custom_field_values RENAME CONSTRAINT custom_claims_unique TO custom_field_values_key_unique;
ALTER TABLE custom_field_values ADD COLUMN custom_field_id VARCHAR(255);
CREATE TEMP TABLE custom_field_migration_map AS
SELECT
key,
SUBSTRING(md5(key) FROM 1 FOR 8) || '-' ||
SUBSTRING(md5(key) FROM 9 FOR 4) || '-' ||
'4' || SUBSTRING(md5(key) FROM 14 FOR 3) || '-' ||
'8' || SUBSTRING(md5(key) FROM 18 FOR 3) || '-' ||
SUBSTRING(md5(key) FROM 21 FOR 12) AS custom_field_id,
BOOL_OR(user_id IS NOT NULL) AS has_user_values,
BOOL_OR(user_group_id IS NOT NULL) AS has_group_values
FROM custom_field_values
GROUP BY key;
INSERT INTO app_config_variables (key, value)
SELECT
'customFields',
COALESCE(
jsonb_agg(
jsonb_build_object(
'id', custom_field_id,
'key', key,
'displayName', key,
'type', 'string',
'target',
CASE
WHEN has_user_values AND has_group_values THEN 'both'
WHEN has_user_values THEN 'user'
ELSE 'group'
END,
'required', false,
'userEditable', false,
'defaultValue', '',
'validationRegex', '',
'validationErrorMessage', ''
)
ORDER BY key
)::TEXT,
'[]'
)
FROM custom_field_migration_map
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
WHERE app_config_variables.value = '' OR app_config_variables.value = '[]';
UPDATE custom_field_values
SET custom_field_id = custom_field_migration_map.custom_field_id
FROM custom_field_migration_map
WHERE custom_field_migration_map.key = custom_field_values.key;
ALTER TABLE custom_field_values ALTER COLUMN custom_field_id SET NOT NULL;
ALTER TABLE custom_field_values DROP CONSTRAINT custom_field_values_key_unique;
ALTER TABLE custom_field_values DROP COLUMN key;
ALTER TABLE custom_field_values ADD CONSTRAINT custom_field_values_unique UNIQUE (custom_field_id, user_id, user_group_id);
DROP TABLE custom_field_migration_map;
DELETE FROM app_config_variables WHERE key IN ('userCustomFields', 'userGroupCustomFields');

View File

@@ -1,3 +0,0 @@
DROP TABLE IF EXISTS oidc_pushed_authorization_requests;
ALTER TABLE oidc_clients DROP COLUMN requires_pushed_authorization_requests;

View File

@@ -1,12 +0,0 @@
CREATE TABLE oidc_pushed_authorization_requests (
id UUID NOT NULL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL,
request_uri TEXT NOT NULL UNIQUE,
client_id TEXT NOT NULL REFERENCES oidc_clients(id) ON DELETE CASCADE,
parameters JSONB NOT NULL DEFAULT '{}',
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_oidc_par_expires_at ON oidc_pushed_authorization_requests (expires_at);
ALTER TABLE oidc_clients ADD COLUMN requires_pushed_authorization_requests BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -0,0 +1,40 @@
CREATE TEMP TABLE custom_field_migration_map AS
SELECT
json_extract(json_each.value, '$.id') AS custom_field_id,
json_extract(json_each.value, '$.key') AS key
FROM app_config_variables, json_each(app_config_variables.value)
WHERE app_config_variables.key = 'customFields';
ALTER TABLE custom_field_values RENAME TO custom_field_values_old;
CREATE TABLE custom_claims
(
id TEXT NOT NULL PRIMARY KEY,
created_at DATETIME,
key TEXT NOT NULL,
value TEXT NOT NULL,
user_id TEXT,
user_group_id TEXT,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (user_group_id) REFERENCES user_groups (id) ON DELETE CASCADE,
CONSTRAINT custom_claims_unique UNIQUE (key, user_id, user_group_id),
CHECK (user_id IS NOT NULL OR user_group_id IS NOT NULL)
);
INSERT INTO custom_claims (id, created_at, key, value, user_id, user_group_id)
SELECT
old.id,
old.created_at,
COALESCE(map.key, old.custom_field_id),
old.value,
old.user_id,
old.user_group_id
FROM custom_field_values_old old
LEFT JOIN custom_field_migration_map map ON map.custom_field_id = old.custom_field_id;
DROP TABLE custom_field_values_old;
DROP TABLE custom_field_migration_map;
DELETE FROM app_config_variables WHERE key = 'customFields';

View File

@@ -0,0 +1,73 @@
ALTER TABLE custom_claims RENAME TO custom_field_values_old;
CREATE TEMP TABLE custom_field_migration_map AS
SELECT
key,
lower(hex(randomblob(4))) || '-' ||
lower(hex(randomblob(2))) || '-' ||
'4' || substr(lower(hex(randomblob(2))), 2) || '-' ||
substr('89ab', abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))), 2) || '-' ||
lower(hex(randomblob(6))) AS custom_field_id,
MAX(user_id IS NOT NULL) AS has_user_values,
MAX(user_group_id IS NOT NULL) AS has_group_values
FROM custom_field_values_old
GROUP BY key;
INSERT INTO app_config_variables (key, value)
VALUES (
'customFields',
COALESCE(
(
SELECT json_group_array(
json_object(
'id', custom_field_id,
'key', key,
'displayName', key,
'type', 'string',
'target',
CASE
WHEN has_user_values = 1 AND has_group_values = 1 THEN 'both'
WHEN has_user_values = 1 THEN 'user'
ELSE 'group'
END,
'required', json('false'),
'userEditable', json('false'),
'defaultValue', '',
'validationRegex', '',
'validationErrorMessage', ''
)
)
FROM custom_field_migration_map
ORDER BY key
),
'[]'
)
)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
WHERE app_config_variables.value = '' OR app_config_variables.value = '[]';
CREATE TABLE custom_field_values
(
id TEXT NOT NULL PRIMARY KEY,
created_at DATETIME,
custom_field_id TEXT NOT NULL,
value TEXT NOT NULL,
user_id TEXT,
user_group_id TEXT,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (user_group_id) REFERENCES user_groups (id) ON DELETE CASCADE,
CONSTRAINT custom_field_values_unique UNIQUE (custom_field_id, user_id, user_group_id),
CHECK (user_id IS NOT NULL OR user_group_id IS NOT NULL)
);
INSERT INTO custom_field_values (id, created_at, custom_field_id, value, user_id, user_group_id)
SELECT old.id, old.created_at, map.custom_field_id, old.value, old.user_id, old.user_group_id
FROM custom_field_values_old old
JOIN custom_field_migration_map map ON map.key = old.key;
DROP TABLE custom_field_values_old;
DROP TABLE custom_field_migration_map;
DELETE FROM app_config_variables WHERE key IN ('userCustomFields', 'userGroupCustomFields');

View File

@@ -1,3 +0,0 @@
DROP TABLE IF EXISTS oidc_pushed_authorization_requests;
ALTER TABLE oidc_clients DROP COLUMN requires_pushed_authorization_requests;

View File

@@ -1,12 +0,0 @@
CREATE TABLE oidc_pushed_authorization_requests (
id TEXT NOT NULL PRIMARY KEY,
created_at INTEGER NOT NULL,
request_uri TEXT NOT NULL UNIQUE,
client_id TEXT NOT NULL REFERENCES oidc_clients(id) ON DELETE CASCADE,
parameters TEXT NOT NULL DEFAULT '{}',
expires_at INTEGER NOT NULL
);
CREATE INDEX idx_oidc_par_expires_at ON oidc_pushed_authorization_requests (expires_at);
ALTER TABLE oidc_clients ADD COLUMN requires_pushed_authorization_requests BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -5,9 +5,6 @@
"confirm": "Confirm",
"docs": "Docs",
"key": "Key",
"value": "Value",
"remove_custom_claim": "Remove custom claim",
"add_custom_claim": "Add custom claim",
"add_another": "Add another",
"select_a_date": "Select a date",
"select_file": "Select File",
@@ -35,7 +32,6 @@
"one_week": "1 week",
"one_month": "1 month",
"expiration": "Expiration",
"generate_code": "Generate Code",
"name": "Name",
"browser_unsupported": "Browser unsupported",
"this_browser_does_not_support_passkeys": "This browser doesn't support passkeys. Please use an alternative sign in method.",
@@ -75,7 +71,6 @@
"authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
"authenticate": "Authenticate",
"please_try_again": "Please try again.",
"continue": "Continue",
"alternative_sign_in": "Alternative Sign In",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "If you don't have access to your passkey, you can sign in using one of the following methods.",
"use_your_passkey_instead": "Use your passkey instead?",
@@ -169,7 +164,6 @@
"ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configure LDAP settings to sync users and groups from an LDAP server.",
"images": "Images",
"update": "Update",
"email_configuration_updated_successfully": "Email configuration updated successfully",
"save_changes_question": "Save changes?",
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "You have to save the changes before sending a test email. Do you want to save now?",
@@ -249,12 +243,9 @@
"edit": "Edit",
"user_groups_updated_successfully": "User groups updated successfully",
"user_updated_successfully": "User updated successfully",
"custom_claims_updated_successfully": "Custom claims updated successfully",
"back": "Back",
"user_details_firstname_lastname": "User Details {firstName} {lastName}",
"manage_which_groups_this_user_belongs_to": "Manage which groups this user belongs to.",
"custom_claims": "Custom Claims",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested.",
"user_group_created_successfully": "User group created successfully",
"create_user_group": "Create User Group",
"create_a_new_group_that_can_be_assigned_to_users": "Create a new group that can be assigned to users.",
@@ -271,7 +262,6 @@
"users_updated_successfully": "Users updated successfully",
"user_group_details_name": "User Group Details {name}",
"assign_users_to_this_group": "Assign users to this group.",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested. Custom claims defined on the user will be prioritized if there are conflicts.",
"oidc_client_created_successfully": "OIDC client created successfully",
"create_oidc_client": "Create OIDC Client",
"add_a_new_oidc_client_to_appname": "Add a new OIDC client to {appName}.",
@@ -288,13 +278,8 @@
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange is a security feature to prevent CSRF and authorization code interception attacks.",
"requires_reauthentication": "Requires Re-Authentication",
"requires_users_to_authenticate_again_on_each_authorization": "Requires users to authenticate again on each authorization, even if already signed in",
"par": "PAR",
"requires_pushed_authorization_requests": "Requires Pushed Authorization Requests",
"requires_pushed_authorization_requests_description": "Requires clients to use the PAR endpoint (/api/oidc/par) to pre-register authorization parameters before initiating the flow. Not available for public clients.",
"name_logo": "{name} logo",
"change_logo": "Change Logo",
"upload_logo": "Upload Logo",
"remove_logo": "Remove Logo",
"are_you_sure_you_want_to_delete_this_oidc_client": "Are you sure you want to delete this OIDC client?",
"oidc_client_deleted_successfully": "OIDC client deleted successfully",
"authorization_url": "Authorization URL",
@@ -376,7 +361,6 @@
"add_federated_client_credential": "Add Federated Client Credential",
"add_another_federated_client_credential": "Add another federated client credential",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted",
"show_advanced_options": "Show Advanced Options",
"hide_advanced_options": "Hide Advanced Options",
"oidc_data_preview": "OIDC Data Preview",
@@ -388,9 +372,7 @@
"access_token_payload": "Access Token Payload",
"userinfo_endpoint_response": "Userinfo Endpoint Response",
"copy": "Copy",
"no_preview_data_available": "No preview data available",
"copy_all": "Copy All",
"preview": "Preview",
"preview_for_user": "Preview for {name}",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
"show": "Show",
@@ -412,11 +394,9 @@
"user_creation": "User Creation",
"configure_user_creation": "Manage user creation settings, including signup methods and default permissions for new users.",
"user_creation_groups_description": "Assign these groups automatically to new users upon signup.",
"user_creation_claims_description": "Assign these custom claims automatically to new users upon signup.",
"user_creation_updated_successfully": "User creation settings updated successfully.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.",
"signup_requires_valid_token": "A valid signup token is required to create an account",
"validating_signup_token": "Validating signup token",
"go_to_login": "Go to login",
"signup_to_appname": "Sign Up to {appName}",
"create_your_account_to_get_started": "Create your account to get started.",
@@ -439,7 +419,6 @@
"usage": "Usage",
"created": "Created",
"token": "Token",
"loading": "Loading",
"delete_signup_token": "Delete Signup Token",
"are_you_sure_you_want_to_delete_this_signup_token": "Are you sure you want to delete this signup token? This action cannot be undone.",
"signup_with_token": "Signup with token",
@@ -529,5 +508,34 @@
"email_verification_sent": "Verification email sent successfully.",
"emails_verified_by_default": "Emails verified by default",
"emails_verified_by_default_description": "When enabled, users' email addresses will be marked as verified by default upon signup or when their email address is changed.",
"user_has_no_passkeys_yet": "This user has no passkeys yet."
"user_has_no_passkeys_yet": "This user has no passkeys yet.",
"custom_fields": "Custom Fields",
"configure_custom_fields": "Configure custom fields that can be assigned to users and user groups.",
"custom_fields_updated_successfully": "Custom fields updated successfully",
"add_custom_field": "Add custom field",
"delete_custom_field_name": "Delete {name}?",
"delete_custom_field_description": "Are you sure you want to delete the custom field {name}? This will remove the field from all users and groups.",
"custom_field_already_exists": "Custom field already exists",
"available_for": "Available for",
"users_and_groups": "Users and groups",
"field_is_required": "Field is required",
"must_be_a_number": "Must be a number",
"value_must_be_true_or_false": "Value must be true or false",
"default_value": "Default value",
"no_default": "No default",
"invalid_regex": "Invalid regex",
"validation_regex": "Validation regex",
"validation_regex_description": "A regular expression that the value of this field must match.",
"must_be_a_valid_email_address": "Must be a valid email address",
"validation_error_message": "Validation error message",
"validation_error_message_description": "The error message that will be shown when the value does not match the validation regex.",
"value_does_not_match_required_format": "Value does not match the required format",
"user_editable": "User editable",
"user_editable_description": "Allow users to update this field on their own account.",
"text": "Text",
"number": "Number",
"boolean": "Boolean",
"type": "Type",
"required": "Required",
"required_custom_field_description": "Whether this field should be marked as required in the form"
}

View File

@@ -48,7 +48,7 @@
"authenticator_does_not_support_any_of_the_requested_algorithms": "L'authentificateur ne supporte aucun des algorithmes requis",
"webauthn_error_invalid_rp_id": "L'ID de la partie de confiance configurée n'est pas valide.",
"webauthn_error_invalid_domain": "Le domaine configuré n'est pas valide.",
"contact_administrator_to_fix": "Contactez votre administrateur pour régler ce problème.",
"contact_administrator_to_fix": "Contacte ton administrateur pour régler ce problème.",
"webauthn_operation_not_allowed_or_timed_out": "L'opération n'a pas été autorisée ou a expiré.",
"webauthn_not_supported_by_browser": "Les clés d'accès ne sont pas prises en charge par ce navigateur. Essaie une autre méthode de connexion.",
"critical_error_occurred_contact_administrator": "Une erreur critique s'est produite. Veuillez contacter votre administrateur.",
@@ -288,9 +288,6 @@
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Le Public Key Code Exchange est une fonctionnalité de sécurité conçue pour prévenir les attaques CSRF et dinterception de code dautorisation.",
"requires_reauthentication": "Nécessite une nouvelle authentification",
"requires_users_to_authenticate_again_on_each_authorization": "Demande aux utilisateurs de se connecter à nouveau à chaque autorisation, même s'ils sont déjà connectés.",
"par": "PAR",
"requires_pushed_authorization_requests": "Nécessite Pushed Authorization Requests (PAR)",
"requires_pushed_authorization_requests_description": "Les clients doivent utiliser le point de terminaison PAR (/api/oidc/par) pour préenregistrer les paramètres d'autorisation avant de démarrer le flux. Non disponible pour les clients publics.",
"name_logo": "Logo {name}",
"change_logo": "Changer le logo",
"upload_logo": "Télécharger un logo",

View File

@@ -108,14 +108,14 @@
"ip_address": "IPアドレス",
"device": "デバイス",
"client": "クライアント",
"actor": "実行者",
"actor": "俳優",
"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": "シングルパスキーが設定済み",
"single_passkey_configured": "Single Passkey Configured",
"it_is_recommended_to_add_more_than_one_passkey": "アカウントへのアクセスを失わないように、複数のパスキーを追加することをお勧めします。",
"account_details": "アカウントの詳細",
"passkeys": "パスキー",
@@ -132,7 +132,7 @@
"username_must_start_with": "ユーザー名は英数字で始まる必要があります",
"username_must_end_with": "ユーザー名は英数字で終わる必要があります",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "以下のコードを使用してサインインしてください。コードは15分後に失効します。",
"or_visit": "または次を訪問",
"or_visit": "or visit",
"added_on": "追加日",
"rename": "名前を変更",
"delete": "削除",
@@ -162,7 +162,7 @@
"api_key_revoked_successfully": "API キーが正常に削除されました",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "APIキー \"{apiKeyName}\" を本当に削除しますかこのAPI キーを使用しているすべての連携が停止します。",
"last_used": "最終使用日",
"actions": "操作",
"actions": "Actions",
"images_updated_successfully": "画像の更新が正常に完了しました。更新には数分かかる場合があります。",
"general": "一般",
"configure_smtp_to_send_emails": "新しいデバイスや場所からのログインが検出された際にユーザーに警告するメール通知を有効にします。",
@@ -205,15 +205,15 @@
"ldap_sync_finished": "LDAP同期が完了しました",
"client_configuration": "クライアントの設定",
"ldap_url": "LDAP URL",
"ldap_bind_dn": "LDAP バインド DN",
"ldap_bind_password": "LDAP バインドパスワード",
"ldap_base_dn": "LDAP ベース DN",
"ldap_bind_dn": "LDAP Bind DN",
"ldap_bind_password": "LDAP Bind Password",
"ldap_base_dn": "LDAP Base DN",
"user_search_filter": "ユーザー検索フィルター",
"the_search_filter_to_use_to_search_or_sync_users": "ユーザーの検索、同期に使用する検索フィルター。",
"groups_search_filter": "グループ検索フィルター",
"the_search_filter_to_use_to_search_or_sync_groups": "グループの検索、同期に使用する検索フィルター。",
"attribute_mapping": "属性マッピング",
"user_unique_identifier_attribute": "ユーザー固有識別子属性",
"user_unique_identifier_attribute": "User Unique Identifier Attribute",
"the_value_of_this_attribute_should_never_change": "この属性の値は決して変更されてはなりません。",
"username_attribute": "ユーザー名属性",
"user_mail_attribute": "ユーザーメール属性",
@@ -245,7 +245,7 @@
"admin": "管理者",
"user": "ユーザー",
"local": "ローカル",
"toggle_menu": "メニューの表示/非表示を切り替える",
"toggle_menu": "Toggle menu",
"edit": "編集",
"user_groups_updated_successfully": "ユーザーグループが正常に更新されました",
"user_updated_successfully": "ユーザーは正常に更新されました",
@@ -261,7 +261,7 @@
"add_group": "グループを追加",
"manage_user_groups": "ユーザーグループの管理",
"friendly_name": "Friendly Name",
"name_that_will_be_displayed_in_the_ui": "UIに表示される名前",
"name_that_will_be_displayed_in_the_ui": "Name that will be displayed in the UI",
"name_that_will_be_in_the_groups_claim": "Name that will be in the \"groups\" claim",
"delete_name": "{name} を削除",
"are_you_sure_you_want_to_delete_this_user_group": "このユーザーグループを削除しますか?",
@@ -282,12 +282,12 @@
"add": "追加",
"callback_urls": "Callback URLs",
"logout_callback_urls": "Logout Callback URLs",
"public_client": "パブリッククライアント",
"public_clients_description": "パブリッククライアントにはクライアントシークレットがありません。これらは、シークレットを安全に保存できないモバイル、Web、およびネイティブアプリケーション向けに設計されています。",
"public_client": "Public Client",
"public_clients_description": "Public clients do not have a client secret. They are designed for mobile, web, and native applications where secrets cannot be securely stored.",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "公開鍵コード交換は、CSRFおよび認証コード傍受攻撃を防ぐためのセキュリティ機能です。",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange is a security feature to prevent CSRF and authorization code interception attacks.",
"requires_reauthentication": "再認証が必要",
"requires_users_to_authenticate_again_on_each_authorization": "すでにログイン済みの場合でも、認証のたびにユーザーに再認証を求めます",
"requires_users_to_authenticate_again_on_each_authorization": "Requires users to authenticate again on each authorization, even if already signed in",
"name_logo": "{name} ロゴ",
"change_logo": "ロゴの変更",
"upload_logo": "ロゴをアップロード",
@@ -305,7 +305,7 @@
"oidc_client_updated_successfully": "OIDC クライアントが正常に更新されました",
"create_new_client_secret": "新しいクライアントシークレットを作成する",
"are_you_sure_you_want_to_create_a_new_client_secret": "新しいクライアントシークレットを作成してもよろしいですか?古いシークレットは無効化されます。",
"generate": "生成",
"generate": "Generate",
"new_client_secret_created_successfully": "新しいクライアントシークレットが正常に作成されました",
"oidc_client_name": "OIDC クライアント {name}",
"client_id": "Client ID",
@@ -331,8 +331,8 @@
"profile_picture_has_been_reset": "プロフィール画像がリセットされました。更新には数分かかる場合があります。",
"select_the_language_you_want_to_use": "使用する言語を選択してください。一部のテキストは自動翻訳により、不正確な場合がありますのでご注意ください。",
"contribute_to_translation": "問題が見つかった場合は、 <link href='https://crowdin.com/project/pocket-id'>Crowdin</link> で翻訳に貢献してください。",
"personal": "個人",
"global": "グローバル",
"personal": "Personal",
"global": "Global",
"all_users": "すべてのユーザー",
"all_events": "すべてのイベント",
"all_clients": "すべてのクライアント",
@@ -340,21 +340,21 @@
"global_audit_log": "グローバル監査ログ",
"see_all_recent_account_activities": "設定された保持期間中の全ユーザーのアカウント活動を閲覧する。",
"token_sign_in": "トークンサインイン",
"client_authorization": "クライアント認証",
"new_client_authorization": "新規クライアント認証",
"client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization",
"device_code_authorization": "デバイスコード認証",
"new_device_code_authorization": "新規デバイス認証コード",
"passkey_added": "パスキー追加",
"passkey_removed": "パスキー削除済み",
"disable_animations": "アニメーションの無効化",
"turn_off_ui_animations": "UI全体のアニメーションを無効にします。",
"turn_off_ui_animations": "Turn off animations throughout the UI.",
"user_disabled": "アカウントの無効化",
"disabled_users_cannot_log_in_or_use_services": "無効化されたユーザーはログインやサービスを利用できません。",
"user_disabled_successfully": "ユーザーが正常に無効化されました。",
"user_enabled_successfully": "ユーザーが正常に有効化されました。",
"status": "ステータス",
"status": "Status",
"disable_firstname_lastname": "無効化 {firstName} {lastName}",
"are_you_sure_you_want_to_disable_this_user": "このユーザーを無効化してもよろしいですか?無効化すると、そのユーザーはログインやサービスの利用ができなくなります。",
"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": "LDAPから無効なユーザーを保持します。",
"ldap_soft_delete_users_description": "有効にすると、LDAPから削除されたユーザーはシステムから削除されるのではなく、無効化されます。",
"login_code_email_success": "ログインコードがユーザーに送信されました。",
@@ -367,38 +367,38 @@
"authorize_device": "デバイスの認証",
"the_device_has_been_authorized": "デバイスは認証されました。",
"enter_code_displayed_in_previous_step": "前のステップで表示されたコードを入力してください。",
"authorize": "承認",
"authorize": "Authorize",
"federated_client_credentials": "連携クライアントの資格情報",
"federated_client_credentials_description": "フェデレーテッドクライアント認証情報は、長期にわたるシークレットを管理せずにOIDCクライアントを認証することを可能にします。これらは、クライアントアサーションワークロードIDトークンのためにサードパーティ機関が発行するJWTトークンを活用します。",
"add_federated_client_credential": "連携クライアントの資格情報を追加",
"add_another_federated_client_credential": "他の連携クライアントの資格情報を追加",
"add_federated_client_credential": "Add Federated Client Credential",
"add_another_federated_client_credential": "Add another federated client credential",
"oidc_allowed_group_count": "許可されたグループ数",
"unrestricted": "制限なし",
"show_advanced_options": "詳細設定を表示",
"hide_advanced_options": "詳細設定を隠す",
"oidc_data_preview": "OIDC データプレビュー",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "異なるユーザーに対して送信されるOIDCデータのプレビュー",
"id_token": "IDトークン",
"access_token": "アクセストークン",
"userinfo": "ユーザー情報",
"id_token_payload": "IDトークンのペイロード",
"access_token_payload": "アクセストークンのペイロード",
"userinfo_endpoint_response": "ユーザー情報エンドポイントのレスポンス",
"oidc_data_preview": "OIDC Data Preview",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
"id_token": "ID Token",
"access_token": "Access Token",
"userinfo": "Userinfo",
"id_token_payload": "ID Token Payload",
"access_token_payload": "Access Token Payload",
"userinfo_endpoint_response": "Userinfo Endpoint Response",
"copy": "コピー",
"no_preview_data_available": "利用可能なプレビューデータがありません",
"no_preview_data_available": "No preview data available",
"copy_all": "すべてコピー",
"preview": "プレビュー",
"preview_for_user": "{name} のプレビュー",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "このユーザーに対して送信されるOIDCデータのプレビュー",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
"show": "表示",
"select_an_option": "オプションを選択",
"select_an_option": "Select an option",
"select_user": "ユーザーを選択",
"error": "エラー",
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "アクセントカラーを選択して、Pocket IDの外観をカスタマイズしてください。",
"accent_color": "アクセントカラー",
"custom_accent_color": "カスタムアクセントカラー",
"custom_accent_color_description": "有効な CSS カラーフォーマット (例: hex, rgb, hsl) を使用してカスタムカラーを入力します。",
"color_value": "カラー値",
"color_value": "Color Value",
"apply": "適用",
"signup_token": "サインアップトークン",
"create_a_signup_token_to_allow_new_user_registration": "新規ユーザー登録を許可するためのサインアップトークンを作成する。",
@@ -432,7 +432,7 @@
"signup_token_deleted_successfully": "サインアップトークンが正常に削除されました。",
"expired": "Expired",
"used_up": "Used Up",
"active": "有効",
"active": "Active",
"usage": "Usage",
"created": "Created",
"token": "トークン",
@@ -452,24 +452,24 @@
"launch": "起動",
"client_launch_url": "Client Launch URL",
"client_launch_url_description": "ユーザーが「マイアプリ」ページからアプリを起動した際に開かれるURL。",
"client_name_description": "Pocket ID UIに表示されるクライアント名。",
"client_name_description": "The name of the client that shows in the Pocket ID UI.",
"revoke_access": "アクセスを取り消す",
"revoke_access_description": "<b>{clientName}</b>へのアクセスを取り消します。 <b>{clientName}</b> はあなたのアカウント情報にアクセスできなくなります。",
"revoke_access_successful": "{clientName} へのアクセスは正常に取り消されました。",
"last_signed_in_ago": "最終ログイン {time} ",
"invalid_client_id": "クライアントIDには、英字、数字、アンダースコア、ハイフンのみを含めることができます",
"custom_client_id_description": "アプリケーションで必要とされる場合は、カスタムクライアントIDを設定してください。必要ない場合は、空白のままにするとランダムなIDが生成されます。",
"last_signed_in_ago": "Last signed in {time} ago",
"invalid_client_id": "Client ID can only contain letters, numbers, underscores, and hyphens",
"custom_client_id_description": "Set a custom client ID if this is required by your application. Otherwise, leave it blank to generate a random one.",
"generated": "Generated",
"administration": "管理",
"group_rdn_attribute_description": "グループの識別名DNで使用される属性。",
"display_name_attribute": "表示名属性",
"administration": "Administration",
"group_rdn_attribute_description": "The attribute used in the groups distinguished name (DN).",
"display_name_attribute": "Display Name Attribute",
"display_name": "表示名",
"configure_application_images": "アプリケーションの画像を設定",
"ui_config_disabled_info_title": "UI設定が無効になっています",
"ui_config_disabled_info_description": "アプリケーションの設定は環境変数を通じて管理されているため、UI設定は無効になっています。一部の設定は編集できない場合があります。",
"ui_config_disabled_info_title": "UI Configuration Disabled",
"ui_config_disabled_info_description": "The UI configuration is disabled because the application configuration settings are managed through environment variables. Some settings may not be editable.",
"logo_from_url_description": "画像の直接のURL (svg, png, webp) を貼り付けます。アイコンは <link href=\"https://selfh.st/icons\">Selfh.st Icons</link> か <link href=\"https://dashboardicons.com\">Dashboard Icons</link> で探せます。",
"invalid_url": "無効な URL",
"require_user_email": "メールアドレスを必須にする",
"require_user_email": "Require Email Address",
"require_user_email_description": "ユーザーにメールアドレスの登録を必須とします。無効にした場合、メールアドレスを持たないユーザーはメールアドレスが必要な機能を利用できなくなります。",
"view": "表示",
"toggle_columns": "列の表示/非表示を切り替える",

View File

@@ -1,65 +1,65 @@
{
"name": "pocket-id-frontend",
"version": "2.8.0",
"private": true,
"type": "module",
"scripts": {
"preinstall": "npx only-allow pnpm",
"dev": "vite dev --port 3000",
"build": "vite build",
"preview": "vite preview --port 3000",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"dependencies": {
"@fontsource/gloock": "^5.2.8",
"@simplewebauthn/browser": "^13.3.0",
"@tailwindcss/vite": "^4.3.0",
"axios": "^1.16.1",
"clsx": "^2.1.1",
"date-fns": "^4.2.1",
"jose": "^6.2.3",
"qrcode": "^1.5.4",
"runed": "^0.37.1",
"sveltekit-superforms": "^2.30.1",
"tailwind-merge": "^3.6.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@inlang/paraglide-js": "^2.18.0",
"@inlang/plugin-m-function-matcher": "^2.2.6",
"@inlang/plugin-message-format": "^4.4.0",
"@internationalized/date": "^3.12.1",
"@lucide/svelte": "^1.16.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.60.1",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/eslint": "^9.6.1",
"@types/node": "^25.9.0",
"@types/qrcode": "^1.5.6",
"bits-ui": "^2.18.1",
"eslint": "^10.4.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.17.1",
"formsnap": "^2.0.1",
"globals": "^17.6.0",
"mode-watcher": "^1.1.0",
"prettier": "^3.8.3",
"prettier-plugin-svelte": "^3.5.2",
"prettier-plugin-tailwindcss": "^0.8.0",
"rollup": "^4.60.4",
"svelte": "^5.55.8",
"svelte-check": "^4.4.8",
"svelte-sonner": "^1.1.1",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.3.0",
"tslib": "^2.8.1",
"tw-animate-css": "^1.4.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.4",
"vite": "^8.0.13",
"vite-plugin-compression": "^0.5.1"
}
"name": "pocket-id-frontend",
"version": "2.7.0",
"private": true,
"type": "module",
"scripts": {
"preinstall": "npx only-allow pnpm",
"dev": "vite dev --port 3000",
"build": "vite build",
"preview": "vite preview --port 3000",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"dependencies": {
"@fontsource/gloock": "^5.2.8",
"@simplewebauthn/browser": "^13.3.0",
"@tailwindcss/vite": "^4.3.0",
"axios": "^1.16.1",
"clsx": "^2.1.1",
"date-fns": "^4.2.1",
"jose": "^6.2.3",
"qrcode": "^1.5.4",
"runed": "^0.37.1",
"sveltekit-superforms": "^2.30.1",
"tailwind-merge": "^3.6.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@inlang/paraglide-js": "^2.18.0",
"@inlang/plugin-m-function-matcher": "^2.2.6",
"@inlang/plugin-message-format": "^4.4.0",
"@internationalized/date": "^3.12.1",
"@lucide/svelte": "^1.16.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.60.1",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/eslint": "^9.6.1",
"@types/node": "^25.9.0",
"@types/qrcode": "^1.5.6",
"bits-ui": "^2.18.1",
"eslint": "^10.4.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.17.1",
"formsnap": "^2.0.1",
"globals": "^17.6.0",
"mode-watcher": "^1.1.0",
"prettier": "^3.8.3",
"prettier-plugin-svelte": "^3.5.2",
"prettier-plugin-tailwindcss": "^0.8.0",
"rollup": "^4.60.4",
"svelte": "^5.55.8",
"svelte-check": "^4.4.8",
"svelte-sonner": "^1.1.1",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.3.0",
"tslib": "^2.8.1",
"tw-animate-css": "^1.4.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.4",
"vite": "^8.0.13",
"vite-plugin-compression": "^0.5.1"
}
}

View File

@@ -135,7 +135,7 @@
--color-sidebar-ring: var(--sidebar-ring);
/* Font */
--font-gloock: 'gloock', serif;
--font-glooock: 'glooock ', serif;
}
@layer base {

View File

@@ -13,6 +13,7 @@
title,
description,
defaultExpanded = false,
noHeaderMargin = false,
forcedExpanded,
button,
icon,
@@ -22,6 +23,7 @@
title: string;
description?: string;
defaultExpanded?: boolean;
noHeaderMargin?: boolean;
forcedExpanded?: boolean;
icon?: typeof IconType;
button?: Snippet;
@@ -60,7 +62,11 @@
});
</script>
<Card.Root>
<Card.Root
class={{
'gap-0': noHeaderMargin
}}
>
<Card.Header class="bg-card cursor-pointer" onclick={toggleExpanded}>
<div class="flex items-center justify-between">
<div>

View File

@@ -1,76 +0,0 @@
<script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import CustomClaimService from '$lib/services/custom-claim-service';
import type { CustomClaim } from '$lib/types/custom-claim.type';
import { LucideMinus, LucidePlus } from '@lucide/svelte';
import { onMount, type Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import AutoCompleteInput from './auto-complete-input.svelte';
import { m } from '$lib/paraglide/messages';
let {
customClaims = $bindable(),
error = $bindable(null),
...restProps
}: HTMLAttributes<HTMLDivElement> & {
customClaims: CustomClaim[];
error?: string | null;
children?: Snippet;
} = $props();
const limit = 20;
const customClaimService = new CustomClaimService();
let suggestions: string[] = $state([]);
let filteredSuggestions: string[] = $derived(
suggestions.filter(
(suggestion) => !customClaims.some((customClaim) => customClaim.key === suggestion)
)
);
onMount(() => {
customClaimService.getSuggestions().then((data) => (suggestions = data));
});
</script>
<div {...restProps}>
<FormInput>
<div class="flex flex-col gap-y-2">
{#each customClaims as _, i}
<div class="flex gap-x-2">
<AutoCompleteInput
placeholder={m.key()}
suggestions={filteredSuggestions}
bind:value={customClaims[i].key}
/>
<Input placeholder={m.value()} bind:value={customClaims[i].value} />
<Button
variant="outline"
size="sm"
aria-label={m.remove_custom_claim()}
onclick={() => (customClaims = customClaims.filter((_, index) => index !== i))}
>
<LucideMinus class="size-4" />
</Button>
</div>
{/each}
</div>
</FormInput>
{#if error}
<p class="text-destructive mt-1 text-xs">{error}</p>
{/if}
{#if customClaims.length < limit}
<Button
class="mt-2"
variant="secondary"
size="sm"
onclick={() => (customClaims = [...customClaims, { key: '', value: '' }])}
>
<LucidePlus class="mr-1 size-4" />
{customClaims.length === 0 ? m.add_custom_claim() : m.add_another()}
</Button>
{/if}
</div>

View File

@@ -0,0 +1,110 @@
<script lang="ts">
import * as Field from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
import { Switch } from '$lib/components/ui/switch/index.js';
import { m } from '$lib/paraglide/messages';
import type { CustomField, CustomFieldValue } from '$lib/types/custom-field.type';
import type { HTMLAttributes } from 'svelte/elements';
let {
customFieldValues = $bindable(),
customFields = []
}: HTMLAttributes<HTMLDivElement> & {
customFieldValues: CustomFieldValue[];
customFields?: CustomField[];
} = $props();
let errors = $state<Record<string, string>>({});
function getValue(field: CustomField) {
const customFieldValue = customFieldValues.find(
(customFieldValue) => customFieldValue.customFieldId === field.id
);
if (customFieldValue) return customFieldValue.value;
return field.defaultValue ?? '';
}
function setValue(field: CustomField, value: string) {
errors = { ...errors, [field.key]: '' };
const nextCustomFieldValues = customFieldValues.filter(
(customFieldValue) => customFieldValue.customFieldId !== field.id
);
if (field.type !== 'boolean' && !field.required && value === '') {
customFieldValues = nextCustomFieldValues;
return;
}
customFieldValues = [...nextCustomFieldValues, { customFieldId: field.id, value }];
}
function normalizeCustomFieldValues() {
const normalizedCustomFieldValues: CustomFieldValue[] = [];
for (const field of customFields) {
const value = field.type === 'boolean' ? getValue(field) || 'false' : getValue(field);
if (field.type !== 'boolean' && !field.required && value === '') continue;
normalizedCustomFieldValues.push({ customFieldId: field.id, value });
}
customFieldValues = normalizedCustomFieldValues;
}
export function validate() {
normalizeCustomFieldValues();
const nextErrors: Record<string, string> = {};
for (const field of customFields) {
const value = getValue(field);
if (field.required && field.type !== 'boolean' && value === '') {
nextErrors[field.key] = m.field_is_required();
continue;
}
if (field.type === 'number' && value !== '' && Number.isNaN(Number(value))) {
nextErrors[field.key] = m.must_be_a_number();
continue;
}
if (field.type === 'string' && field.validationRegex && value !== '') {
let regex: RegExp;
try {
regex = new RegExp(field.validationRegex);
} catch {
nextErrors[field.key] = m.invalid_regex();
continue;
}
if (!regex.test(value)) {
nextErrors[field.key] =
field.validationErrorMessage || m.value_does_not_match_required_format();
}
}
}
errors = nextErrors;
return Object.keys(nextErrors).length === 0;
}
</script>
{#each customFields as field (field.key)}
<Field.Field>
<Field.Label required={field.required} for={`custom-field-${field.key}`}>
{field.displayName || field.key}
</Field.Label>
{#if field.type === 'boolean'}
<div class="flex h-9 items-center">
<Switch
id={`custom-field-${field.key}`}
checked={getValue(field) === 'true'}
onCheckedChange={(checked) => setValue(field, checked ? 'true' : 'false')}
/>
</div>
{:else}
<Input
id={`custom-field-${field.key}`}
type={field.type === 'number' ? 'number' : 'text'}
value={getValue(field)}
aria-invalid={!!errors[field.key]}
oninput={(e) => setValue(field, e.currentTarget.value)}
/>
{/if}
{#if errors[field.key]}
<Field.Error class="text-start">{errors[field.key]}</Field.Error>
{/if}
</Field.Field>
{/each}

View File

@@ -1,9 +1,11 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch/index.js';
import { cn } from '$lib/utils/style';
let {
id,
class: className,
checked = $bindable(),
label,
description,
@@ -11,6 +13,7 @@
onCheckedChange
}: {
id: string;
class?: string;
checked: boolean;
label: string;
description?: string;
@@ -19,7 +22,7 @@
} = $props();
</script>
<div class="items-top flex space-x-2">
<div class={cn("items-top flex space-x-2", className)}>
<Switch
{id}
{disabled}

View File

@@ -35,7 +35,7 @@
<a href="/" class="flex items-center transition-opacity hover:opacity-80">
<Logo class="size-8" />
<Separator orientation="vertical" class="h-5! bg-neutral-600 ml-2 mr-3" />
<h1 class="text-2xl font-gloock" data-testid="application-name">
<h1 class="text-2xl font-glooock" data-testid="application-name">
{m.settings()}
</h1>
</a>

View File

@@ -1,7 +1,9 @@
<script lang="ts">
import CustomFieldValuesInput from '$lib/components/form/custom-field-values-input.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import { customFieldAppliesTo, type CustomFieldValue } from '$lib/types/custom-field.type';
import type { UserSignUp } from '$lib/types/user.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
@@ -35,16 +37,23 @@
const { inputs, ...form } = createForm<FormSchema>(formSchema, initialData);
let userData: UserSignUp | null = $state(null);
let customFieldValues = $state<CustomFieldValue[]>([]);
let customFieldValuesInputRef = $state<CustomFieldValuesInput>();
let customFields = $derived(
$appConfigStore.customFields.filter(
(field) => customFieldAppliesTo(field, 'user') && (field.required && field.userEditable)
)
);
async function onSubmit() {
const data = form.validate();
if (!data) return;
if (customFieldValuesInputRef && !customFieldValuesInputRef.validate()) return;
isLoading = true;
const result = await tryCatch(callback(data));
const result = await tryCatch(callback({ ...data, customFieldValues }));
if (result.data) {
userData = data;
userData = { ...data, customFieldValues };
isLoading = false;
}
}
@@ -58,6 +67,11 @@
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormInput label={m.first_name()} bind:input={$inputs.firstName} />
<FormInput label={m.last_name()} bind:input={$inputs.lastName} />
<CustomFieldValuesInput
bind:this={customFieldValuesInputRef}
bind:customFieldValues
{customFields}
/>
</div>
</div>
</form>

View File

@@ -25,6 +25,7 @@
selectedIds = $bindable(),
withoutSearch = false,
selectionDisabled = false,
paginationDisabled = false,
rowSelectionDisabled,
fetchCallback,
defaultSort,
@@ -35,6 +36,7 @@
selectedIds?: string[];
withoutSearch?: boolean;
selectionDisabled?: boolean;
paginationDisabled?: boolean;
rowSelectionDisabled?: (item: T) => boolean;
fetchCallback: (requestOptions: ListRequestOptions) => Promise<Paginated<T>>;
defaultSort?: SortRequest;
@@ -178,8 +180,10 @@
export async function refresh() {
items = await fetchCallback(requestOptions);
changePageState(items.pagination.currentPage);
updateListLength(items.pagination.totalItems);
if (!paginationDisabled) {
changePageState(items.pagination.currentPage);
updateListLength(items.pagination.totalItems);
}
}
</script>
@@ -329,51 +333,52 @@
</Table.Root>
</div>
{/if}
<div class="mt-5 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
<div class="flex items-center space-x-2">
<p class="text-sm font-medium">{m.items_per_page()}</p>
<Select.Root
type="single"
value={items?.pagination.itemsPerPage.toString()}
onValueChange={(v) => onPageSizeChange(Number(v))}
{#if !paginationDisabled}
<div class="mt-5 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
<div class="flex items-center space-x-2">
<p class="text-sm font-medium">{m.items_per_page()}</p>
<Select.Root
type="single"
value={items?.pagination.itemsPerPage.toString()}
onValueChange={(v) => onPageSizeChange(Number(v))}
>
<Select.Trigger class="w-20">
{items?.pagination.itemsPerPage}
</Select.Trigger>
<Select.Content>
{#each availablePageSizes as size}
<Select.Item value={size.toString()}>{size}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Pagination.Root
class="mx-0 w-auto"
count={items?.pagination.totalItems || 0}
perPage={items?.pagination.itemsPerPage}
{onPageChange}
page={items?.pagination.currentPage}
>
<Select.Trigger class="w-20">
{items?.pagination.itemsPerPage}
</Select.Trigger>
<Select.Content>
{#each availablePageSizes as size}
<Select.Item value={size.toString()}>{size}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#snippet children({ pages })}
<Pagination.Content class="flex justify-end">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type !== 'ellipsis' && page.value != 0}
<Pagination.Item>
<Pagination.Link {page} isActive={items?.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
</div>
<Pagination.Root
class="mx-0 w-auto"
count={items?.pagination.totalItems || 0}
perPage={items?.pagination.itemsPerPage}
{onPageChange}
page={items?.pagination.currentPage}
>
{#snippet children({ pages })}
<Pagination.Content class="flex justify-end">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type !== 'ellipsis' && page.value != 0}
<Pagination.Item>
<Pagination.Link {page} isActive={items?.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
</div>
{/if}
{/if}

View File

@@ -1,18 +1,36 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from 'bits-ui';
import { page } from '$app/state';
import { cn } from '$lib/utils/style.js';
import { Tabs as TabsPrimitive } from 'bits-ui';
import { onMount } from 'svelte';
let {
ref = $bindable(null),
value = $bindable(''),
useHash = false,
class: className,
...restProps
}: TabsPrimitive.RootProps = $props();
}: TabsPrimitive.RootProps & {
useHash?: boolean;
} = $props();
onMount(() => {
if (useHash && page.url.hash) {
value = page.url.hash.substring(1);
}
});
function onTabChange(newValue: string) {
if (useHash && page.url.hash !== newValue) {
window.location.hash = newValue;
}
}
</script>
<TabsPrimitive.Root
bind:ref
bind:value
onValueChange={onTabChange}
data-slot="tabs"
class={cn('gap-2 group/tabs flex data-[orientation=horizontal]:flex-col', className)}
{...restProps}

View File

@@ -7,7 +7,7 @@
<div class="bg-muted mx-auto rounded-2xl p-3">
<Logo class="size-10" />
</div>
<p class="font-gloock mt-5 text-3xl font-bold sm:text-4xl">{m.browser_unsupported()}</p>
<p class="font-glooock mt-5 text-3xl font-bold sm:text-4xl">{m.browser_unsupported()}</p>
<p class="text-muted-foreground mt-3">
{m.this_browser_does_not_support_passkeys()}
</p>

View File

@@ -1,19 +0,0 @@
import type { CustomClaim } from '$lib/types/custom-claim.type';
import APIService from './api-service';
export default class CustomClaimService extends APIService {
getSuggestions = async () => {
const res = await this.api.get('/custom-claims/suggestions');
return res.data as string[];
};
updateUserCustomClaims = async (userId: string, claims: CustomClaim[]) => {
const res = await this.api.put(`/custom-claims/user/${userId}`, claims);
return res.data as CustomClaim[];
};
updateUserGroupCustomClaims = async (userGroupId: string, claims: CustomClaim[]) => {
const res = await this.api.put(`/custom-claims/user-group/${userGroupId}`, claims);
return res.data as CustomClaim[];
};
}

View File

@@ -8,8 +8,7 @@ import type {
OidcClientUpdate,
OidcClientWithAllowedUserGroups,
OidcClientWithAllowedUserGroupsCount,
OidcDeviceCodeInfo,
OidcAuthorizeRequestInfo
OidcDeviceCodeInfo
} from '$lib/types/oidc.type';
import type { ScimServiceProvider } from '$lib/types/scim.type';
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
@@ -25,8 +24,7 @@ class OidcService extends APIService {
codeChallengeMethod?: string,
reauthenticationToken?: string,
responseMode?: string,
prompt?: string,
requestURI?: string
prompt?: string
) => {
const res = await this.api.post('/oidc/authorize', {
scope,
@@ -37,29 +35,19 @@ class OidcService extends APIService {
codeChallengeMethod,
reauthenticationToken,
responseMode,
prompt,
requestURI
prompt
});
return res.data as AuthorizeResponse;
};
isAuthorizationRequired = async (clientId: string, scope: string, requestURI?: string) => {
isAuthorizationRequired = async (clientId: string, scope: string) => {
const res = await this.api.post('/oidc/authorization-required', {
scope,
clientId,
requestURI
clientId
});
return res.data as { authorizationRequired: boolean; scope: string };
};
getParRequestInfo = async (clientId: string, requestURI: string) => {
const res = await this.api.get('/oidc/par-request-info', {
params: { client_id: clientId, request_uri: requestURI }
});
return res.data as OidcAuthorizeRequestInfo;
return res.data.authorizationRequired as boolean;
};
listClients = async (options?: ListRequestOptions) => {

View File

@@ -1,4 +1,4 @@
import type { CustomClaim } from './custom-claim.type';
import type { CustomFieldValue, CustomField } from './custom-field.type';
export type AppConfig = {
appName: string;
@@ -13,6 +13,7 @@ export type AppConfig = {
uiConfigDisabled: boolean;
accentColor: string;
requireUserEmail: boolean;
customFields: CustomField[];
};
export type AllAppConfig = AppConfig & {
@@ -20,7 +21,6 @@ export type AllAppConfig = AppConfig & {
sessionDuration: number;
emailsVerified: boolean;
signupDefaultUserGroupIDs: string[];
signupDefaultCustomClaims: CustomClaim[];
// Email
smtpHost: string;
smtpPort: string;

View File

@@ -1,4 +0,0 @@
export type CustomClaim = {
key: string;
value: string;
};

View File

@@ -0,0 +1,24 @@
export type CustomFieldValue = {
customFieldId: string;
value: string;
};
export type CustomFieldType = 'string' | 'number' | 'boolean';
export type CustomFieldTarget = 'user' | 'group' | 'both';
export type CustomField = {
id: string;
key: string;
displayName: string;
type: CustomFieldType;
target: CustomFieldTarget;
required: boolean;
userEditable: boolean;
defaultValue?: string;
validationRegex?: string;
validationErrorMessage?: string;
};
export function customFieldAppliesTo(field: CustomField, target: 'user' | 'group') {
return field.target === 'both' || field.target === target;
}

View File

@@ -26,7 +26,6 @@ export type OidcClient = OidcClientMetaData & {
isPublic: boolean;
pkceEnabled: boolean;
requiresReauthentication: boolean;
requiresPushedAuthorizationRequests: boolean;
credentials?: OidcClientCredentials;
launchURL?: string;
isGroupRestricted: boolean;
@@ -73,12 +72,3 @@ export type AuthorizeResponse = {
export type AccessibleOidcClient = OidcClientMetaData & {
lastUsedAt: Date | null;
};
export type OidcAuthorizeRequestInfo = {
scope: string;
redirectURI: string;
state?: string;
nonce?: string;
responseMode?: string;
prompt?: string;
};

View File

@@ -1,4 +1,4 @@
import type { CustomClaim } from './custom-claim.type';
import type { CustomFieldValue } from './custom-field.type';
import type { OidcClientMetaData } from './oidc.type';
import type { User } from './user.type';
@@ -7,7 +7,7 @@ export type UserGroup = {
friendlyName: string;
name: string;
createdAt: string;
customClaims: CustomClaim[];
customFieldValues: CustomFieldValue[];
ldapId?: string;
users: User[];
allowedOidcClients: OidcClientMetaData[];
@@ -17,4 +17,6 @@ export type UserGroupMinimal = Omit<UserGroup, 'users' | 'allowedOidcClients'> &
userCount: number;
};
export type UserGroupCreate = Pick<UserGroup, 'friendlyName' | 'name' | 'ldapId'>;
export type UserGroupCreate = Pick<UserGroup, 'friendlyName' | 'name' | 'ldapId'> & {
customFieldValues?: CustomFieldValue[];
};

View File

@@ -1,5 +1,5 @@
import type { Locale } from '$lib/paraglide/runtime';
import type { CustomClaim } from './custom-claim.type';
import type { CustomFieldValue } from './custom-field.type';
import type { UserGroup } from './user-group.type';
export type User = {
@@ -12,15 +12,20 @@ export type User = {
displayName: string;
isAdmin: boolean;
userGroups: UserGroup[];
customClaims: CustomClaim[];
customFieldValues: CustomFieldValue[];
locale?: Locale;
ldapId?: string;
disabled?: boolean;
};
export type UserCreate = Omit<User, 'id' | 'customClaims' | 'ldapId' | 'userGroups'>;
export type UserCreate = Omit<User, 'id' | 'customFieldValues' | 'ldapId' | 'userGroups'> & {
customFieldValues?: CustomFieldValue[];
};
export type AccountUpdate = Omit<UserCreate, 'isAdmin' | 'disabled' | 'emailVerified'>;
export type AccountUpdate = Omit<
UserCreate,
'isAdmin' | 'disabled' | 'emailVerified'
>;
export type UserSignUp = Omit<
UserCreate,

View File

@@ -25,16 +25,16 @@
let { data }: PageProps = $props();
let {
client,
scope,
callbackURL,
nonce,
codeChallenge,
codeChallengeMethod,
authorizeState,
prompt,
responseMode,
requestURI
responseMode
} = data;
let scope = $state(data.scope);
let isLoading = $state(false);
let success = $state(false);
let errorMessage: string | null = $state(null);
@@ -112,16 +112,7 @@
}
if (!authorizationConfirmed) {
const authRequired = await oidService.isAuthorizationRequired(
client!.id,
scope,
requestURI
);
authorizationRequired = authRequired.authorizationRequired;
if (requestURI) {
scope = authRequired.scope;
}
authorizationRequired = await oidService.isAuthorizationRequired(client!.id, scope);
// If prompt=consent, always show consent UI
if (hasPromptConsent) {
@@ -162,8 +153,7 @@
codeChallengeMethod,
reauthToken,
responseMode,
prompt,
requestURI
prompt
);
// Check if backend returned a redirect error
@@ -275,7 +265,7 @@
{:else}
<SignInWrapper showAlternativeSignInMethodButton={$userStore == null}>
<ClientProviderImages {client} {success} error={!!errorMessage} />
<h1 class="font-gloock mt-5 text-3xl font-bold sm:text-4xl">
<h1 class="font-glooock mt-5 text-3xl font-bold sm:text-4xl">
{m.sign_in_to({ name: client.name })}
</h1>
{#if errorMessage}

View File

@@ -3,24 +3,19 @@ import type { PageLoad } from './$types';
export const load: PageLoad = async ({ url }) => {
const clientId = url.searchParams.get('client_id');
const requestURI = url.searchParams.get('request_uri') || undefined;
const oidcService = new OidcService();
const [client, parInfo] = await Promise.all([
oidcService.getClientMetaData(clientId!),
requestURI ? oidcService.getParRequestInfo(clientId!, requestURI) : undefined
]);
const client = await oidcService.getClientMetaData(clientId!);
return {
scope: parInfo?.scope ?? url.searchParams.get('scope')!,
nonce: parInfo?.nonce ?? url.searchParams.get('nonce') ?? undefined,
authorizeState: parInfo?.state ?? url.searchParams.get('state')!,
callbackURL: parInfo?.redirectURI ?? url.searchParams.get('redirect_uri')!,
scope: url.searchParams.get('scope')!,
nonce: url.searchParams.get('nonce') || undefined,
authorizeState: url.searchParams.get('state')!,
callbackURL: url.searchParams.get('redirect_uri')!,
client,
codeChallenge: url.searchParams.get('code_challenge')!,
codeChallengeMethod: url.searchParams.get('code_challenge_method')!,
prompt: parInfo?.prompt ?? url.searchParams.get('prompt') ?? undefined,
responseMode: parInfo?.responseMode ?? url.searchParams.get('response_mode') ?? undefined,
requestURI
prompt: url.searchParams.get('prompt') || undefined,
responseMode: url.searchParams.get('response_mode') || undefined
};
};

View File

@@ -79,7 +79,7 @@
<LoginLogoErrorSuccessIndicator {success} error={!!errorMessage} />
{/if}
</div>
<h1 class="font-gloock mt-5 text-4xl font-bold">{m.authorize_device()}</h1>
<h1 class="font-glooock mt-5 text-4xl font-bold">{m.authorize_device()}</h1>
{#if errorMessage}
<p class="text-muted-foreground mt-2">
{errorMessage}. {m.please_try_again()}

View File

@@ -44,7 +44,7 @@
<div class="flex justify-center">
<LoginLogoErrorSuccessIndicator error={!!error} />
</div>
<h1 class="font-gloock mt-5 text-3xl font-bold sm:text-4xl">
<h1 class="font-glooock mt-5 text-3xl font-bold sm:text-4xl">
{m.sign_in_to_appname({ appName: $appConfigStore.appName })}
</h1>
{#if error}

View File

@@ -35,7 +35,7 @@
<div class="bg-muted mx-auto rounded-2xl p-3">
<Logo class="size-10" />
</div>
<h1 class="font-gloock mt-5 text-3xl font-bold sm:text-4xl">{m.alternative_sign_in()}</h1>
<h1 class="font-glooock mt-5 text-3xl font-bold sm:text-4xl">{m.alternative_sign_in()}</h1>
<p class="text-muted-foreground mt-3">
{m.if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods()}
</p>

View File

@@ -59,7 +59,7 @@
<div class="flex justify-center">
<LoginLogoErrorSuccessIndicator error={!!error} />
</div>
<h1 class="font-gloock mt-5 text-4xl font-bold">{m.login_code()}</h1>
<h1 class="font-glooock mt-5 text-4xl font-bold">{m.login_code()}</h1>
{#if error}
<p class="text-muted-foreground mt-2">
{error}. {m.please_try_again()}

View File

@@ -37,7 +37,7 @@
<div class="flex justify-center">
<LoginLogoErrorSuccessIndicator {success} error={!!error} />
</div>
<h1 class="font-gloock mt-5 text-3xl font-bold sm:text-4xl">{m.email_login()}</h1>
<h1 class="font-glooock mt-5 text-3xl font-bold sm:text-4xl">{m.email_login()}</h1>
{#if error}
<p class="text-muted-foreground mt-2" in:fade>
{error}. {m.please_try_again()}

View File

@@ -34,7 +34,7 @@
<Logo class="size-10" />
</div>
</div>
<h1 class="font-gloock mt-5 text-4xl">{m.sign_out()}</h1>
<h1 class="font-glooock mt-5 text-4xl">{m.sign_out()}</h1>
<p class="text-muted-foreground mt-2">
<FormattedMessage

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import CustomFieldValuesInput from '$lib/components/form/custom-field-values-input.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import ProfilePictureSettings from '$lib/components/form/profile-picture-settings.svelte';
import { Button } from '$lib/components/ui/button';
@@ -6,6 +7,7 @@
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import { customFieldAppliesTo } from '$lib/types/custom-field.type';
import type { AccountUpdate } from '$lib/types/user.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { preventDefault } from '$lib/utils/event-util';
@@ -31,6 +33,13 @@
let isLoading = $state(false);
let hasManualDisplayNameEdit = $state(!!account.displayName);
let customFieldValues = $state(account.customFieldValues || []);
let customFieldValuesInputRef = $state<CustomFieldValuesInput>();
let customFields = $derived(
$appConfigStore.customFields.filter(
(field) => customFieldAppliesTo(field, 'user') && field.userEditable
)
);
const userService = new UserService();
@@ -59,8 +68,13 @@
async function onSubmit() {
const data = form.validate();
if (!data) return;
if (!customFieldValuesInputRef?.validate()) return;
isLoading = true;
await callback(data);
await callback({
...data,
customFieldValues
});
isLoading = false;
}
@@ -100,10 +114,15 @@
bind:input={$inputs.displayName}
onInput={() => (hasManualDisplayNameEdit = true)}
/>
<CustomFieldValuesInput
bind:this={customFieldValuesInputRef}
bind:customFieldValues
{customFields}
/>
</Field.Group>
<div class="flex justify-end pt-4">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
</fieldset>
<div class="flex justify-end pt-4">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
</form>

View File

@@ -1,12 +1,14 @@
<script lang="ts">
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
import * as Alert from '$lib/components/ui/alert';
import * as Tabs from '$lib/components/ui/tabs';
import { m } from '$lib/paraglide/messages';
import AppConfigService from '$lib/services/app-config-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllAppConfig } from '$lib/types/application-configuration.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import {
ListChecks,
LucideImage,
LucideInfo,
Mail,
@@ -15,6 +17,7 @@
Users
} from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import CustomFieldsTable from './custom-fields-table.svelte';
import AppConfigEmailForm from './forms/app-config-email-form.svelte';
import AppConfigGeneralForm from './forms/app-config-general-form.svelte';
import AppConfigLdapForm from './forms/app-config-ldap-form.svelte';
@@ -101,57 +104,67 @@
</Alert.Description>
</Alert.Root>
{/if}
<div>
<CollapsibleCard
id="application-configuration-general"
icon={SlidersHorizontal}
title={m.general()}
defaultExpanded
>
<AppConfigGeneralForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
</div>
<div>
<CollapsibleCard
id="application-configuration-signup-defaults"
icon={Users}
title={m.user_creation()}
description={m.configure_user_creation()}
>
<AppConfigSignupDefaultsForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
</div>
<Tabs.Root value="general" useHash>
<Tabs.List class="mb-3">
<Tabs.Trigger value="general">{m.general()}</Tabs.Trigger>
<Tabs.Trigger value="user-and-groups">{m.users_and_groups()}</Tabs.Trigger>
<Tabs.Trigger value="email">{m.email()}</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="general" class="flex flex-col gap-5">
<CollapsibleCard
id="application-configuration-general"
icon={SlidersHorizontal}
title={m.general()}
defaultExpanded
>
<AppConfigGeneralForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
<CollapsibleCard
id="application-configuration-images"
icon={LucideImage}
title={m.images()}
description={m.configure_application_images()}
>
<UpdateApplicationImages callback={updateImages} />
</CollapsibleCard>
</Tabs.Content>
<Tabs.Content value="user-and-groups" class="flex flex-col gap-5">
<CollapsibleCard
id="application-configuration-signup-defaults"
icon={Users}
title={m.user_creation()}
description={m.configure_user_creation()}
>
<AppConfigSignupDefaultsForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
<div>
<CollapsibleCard
id="application-configuration-email"
icon={Mail}
title={m.email()}
description={m.configure_smtp_to_send_emails()}
>
<AppConfigEmailForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
</div>
<div>
<CollapsibleCard
id="application-configuration-ldap"
icon={UserSearch}
title={m.ldap()}
description={m.configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server()}
>
<AppConfigLdapForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
</div>
<div>
<CollapsibleCard
id="application-configuration-images"
icon={LucideImage}
title={m.images()}
description={m.configure_application_images()}
>
<UpdateApplicationImages callback={updateImages} />
</CollapsibleCard>
</div>
<CollapsibleCard
id="application-configuration-custom-fields"
icon={ListChecks}
title={m.custom_fields()}
description={m.configure_custom_fields()}
noHeaderMargin
>
<CustomFieldsTable {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
<CollapsibleCard
id="application-configuration-ldap"
icon={UserSearch}
title={m.ldap()}
description={m.configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server()}
>
<AppConfigLdapForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
</Tabs.Content>
<Tabs.Content value="email">
<CollapsibleCard
id="application-configuration-email"
icon={Mail}
title={m.email()}
description={m.configure_smtp_to_send_emails()}
>
<AppConfigEmailForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard>
</Tabs.Content>
</Tabs.Root>

View File

@@ -0,0 +1,213 @@
<script lang="ts">
import { openConfirmDialog } from '$lib/components/confirm-dialog';
import AdvancedTable from '$lib/components/table/advanced-table.svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import type {
AdvancedTableColumn,
CreateAdvancedTableActions
} from '$lib/types/advanced-table.type';
import type { AllAppConfig } from '$lib/types/application-configuration.type';
import type { CustomField } from '$lib/types/custom-field.type';
import type { ListRequestOptions } from '$lib/types/list-request.type';
import {
LucideCaseSensitive,
LucideCheck,
LucideHash,
LucidePencil,
LucidePlus,
LucideToggleLeft,
LucideTrash,
LucideX
} from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import AppConfigCustomFieldsDialog from './forms/app-config-custom-fields-dialog.svelte';
let {
appConfig,
callback
}: {
appConfig: AllAppConfig;
callback: (updatedConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props();
let customFields = $state<CustomField[]>(appConfig.customFields);
let selectedCustomField = $state<CustomField>();
let showCustomFieldsDialog = $state(false);
let table = $state<any>();
const columns: AdvancedTableColumn<CustomField>[] = [
{
label: m.display_name(),
column: 'displayName',
sortable: true
},
{
label: m.key(),
column: 'key',
sortable: true,
},
{
label: m.type(),
column: 'type',
hidden: true,
sortable: true,
cell: TypeCell
},
{
label: m.available_for(),
column: 'target',
sortable: true,
cell: TargetCell
},
{
label: m.user_editable(),
column: 'userEditable',
sortable: true,
cell: BoolCell
},
{
label: m.required(),
column: 'required',
sortable: true,
hidden: true,
cell: BoolCell
}
];
const actions: CreateAdvancedTableActions<CustomField> = (_) => [
{
label: m.edit(),
primary: true,
icon: LucidePencil,
onClick: (field) => {
selectedCustomField = field;
showCustomFieldsDialog = true;
}
},
{
label: m.delete(),
icon: LucideTrash,
variant: 'danger',
onClick: (field) =>
openConfirmDialog({
title: m.delete_custom_field_name({ name: field.displayName }),
message: m.delete_custom_field_description({ name: field.displayName }),
confirm: {
label: m.delete(),
destructive: true,
action: () => deleteCustomField(field)
}
})
}
];
function openCreateDialog() {
selectedCustomField = undefined;
showCustomFieldsDialog = true;
}
async function updateCustomField(customField: CustomField) {
const existingIndex = customFields.findIndex((f) => f.id === customField.id);
const nextCustomFields = [...customFields];
if (existingIndex !== -1) {
nextCustomFields[existingIndex] = customField;
} else {
nextCustomFields.push(customField);
}
await callback({ customFields: nextCustomFields });
customFields = nextCustomFields;
await table?.refresh();
}
async function deleteCustomField(customField: CustomField) {
const nextCustomFields = customFields.filter((field) => field.id !== customField.id);
await callback({ customFields: nextCustomFields });
customFields = nextCustomFields;
toast.success(m.custom_fields_updated_successfully());
await table?.refresh();
}
function fetchCallback(requestOptions: ListRequestOptions) {
const data = [...customFields].sort((a, b) => {
const column = requestOptions.sort?.column || 'displayName';
const direction = requestOptions.sort?.direction === 'asc' ? 1 : -1;
const aValue = String((a as Record<string, unknown>)[column] ?? '');
const bValue = String((b as Record<string, unknown>)[column] ?? '');
if (aValue < bValue) return -1 * direction;
if (aValue > bValue) return 1 * direction;
return 0;
});
return Promise.resolve({
data,
pagination: {
currentPage: 1,
totalPages: 1,
itemsPerPage: data.length,
totalItems: data.length
}
});
}
$effect(() => {
customFields = appConfig.customFields;
});
</script>
{#snippet TypeCell({ item }: { item: CustomField })}
<span class="flex gap-1">
{#if item.type === 'boolean'}
<LucideToggleLeft class="size-4 my-auto" />
{:else if item.type == 'number'}
<LucideHash class="size-3 my-auto" />
{:else}
<LucideCaseSensitive class="size-4 my-auto" />
{/if}
</span>
{/snippet}
{#snippet TargetCell({ item }: { item: CustomField })}
{#if item.target === 'both'}
<Badge variant="secondary">{m.users_and_groups()}</Badge>
{:else if item.target === 'group'}
<Badge variant="outline">{m.groups()}</Badge>
{:else}
<Badge variant="outline">{m.users()}</Badge>
{/if}
{/snippet}
{#snippet BoolCell({ item }: { item: CustomField })}
{#if item.userEditable}
<LucideCheck class="size-4" />
{:else}
<LucideX class="size-4 text-muted-foreground" />
{/if}
{/snippet}
<div class="flex flex-col gap-3">
<AdvancedTable
bind:this={table}
id="custom-fields-table"
{fetchCallback}
{actions}
defaultSort={{ column: 'displayName', direction: 'desc' }}
paginationDisabled
withoutSearch
{columns}
/>
<div class="flex justify-end mt-5">
<Button onclick={openCreateDialog}>
<LucidePlus class="mr-1 size-4" />
{m.add_custom_field()}
</Button>
</div>
</div>
<AppConfigCustomFieldsDialog
bind:show={showCustomFieldsDialog}
existingCustomField={selectedCustomField}
callback={updateCustomField}
/>

View File

@@ -0,0 +1,281 @@
<script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte';
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import * as Dialog from '$lib/components/ui/dialog';
import * as Field from '$lib/components/ui/field';
import * as Select from '$lib/components/ui/select';
import { m } from '$lib/paraglide/messages';
import type {
CustomField,
CustomFieldTarget,
CustomFieldType
} from '$lib/types/custom-field.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner';
import z from 'zod';
let {
show = $bindable(),
existingCustomField,
callback
}: {
show?: boolean;
existingCustomField?: CustomField;
callback: (updatedField: CustomField) => Promise<void>;
} = $props();
let isLoading = $state(false);
type CustomFieldForm = CustomField & {
defaultValue: string;
validationRegex: string;
validationErrorMessage: string;
};
const customField: CustomFieldForm = {
id: '',
key: '',
displayName: '',
type: 'string',
target: 'user',
required: false,
userEditable: false,
defaultValue: '',
validationRegex: '',
validationErrorMessage: ''
};
const fieldTypes: { value: CustomFieldType; label: string }[] = [
{ value: 'string', label: m.text() },
{ value: 'number', label: m.number() },
{ value: 'boolean', label: m.boolean() }
];
const fieldTargets: { value: CustomFieldTarget; label: string }[] = [
{ value: 'user', label: m.users() },
{ value: 'group', label: m.groups() },
{ value: 'both', label: m.users_and_groups() }
];
const formSchema = z
.object({
id: z.string().min(1),
key: z.string().trim().min(1, { message: m.field_is_required() }),
displayName: z.string().trim().min(1, { message: m.field_is_required() }),
type: z.enum(['string', 'number', 'boolean']),
target: z.enum(['user', 'group', 'both']),
required: z.boolean(),
userEditable: z.boolean(),
defaultValue: z.preprocess(
(value) => (value === null || value === undefined ? '' : String(value)),
z.string()
),
validationRegex: z.string().refine(
(value) => {
if (!value) return true;
try {
new RegExp(value);
return true;
} catch {
return false;
}
},
{ message: m.invalid_regex() }
),
validationErrorMessage: z.string()
})
.superRefine((data, ctx) => {
if (data.required && !data.defaultValue) {
ctx.addIssue({
code: 'custom',
path: ['defaultValue'],
message: m.field_is_required()
});
return;
}
if (!data.defaultValue) return;
if (data.type === 'number' && Number.isNaN(Number(data.defaultValue))) {
ctx.addIssue({
code: 'custom',
path: ['defaultValue'],
message: m.must_be_a_number()
});
}
if (data.type === 'string' && data.validationRegex) {
try {
const regex = new RegExp(data.validationRegex);
if (!regex.test(data.defaultValue)) {
ctx.addIssue({
code: 'custom',
path: ['defaultValue'],
message: data.validationErrorMessage || m.value_does_not_match_required_format()
});
}
} catch {}
}
});
type FormSchema = typeof formSchema;
const { inputs, ...form } = createForm<FormSchema>(formSchema, customField);
$effect(() => {
if (!show) return;
const field = existingCustomField ?? {
...customField,
id: crypto.randomUUID()
};
form.setValue('id', field.id);
form.setValue('key', field.key);
form.setValue('displayName', field.displayName);
form.setValue('type', field.type);
form.setValue('target', field.target || 'both');
form.setValue('required', field.required);
form.setValue('userEditable', !!field.userEditable);
form.setValue('defaultValue', field.defaultValue || '');
form.setValue('validationRegex', field.validationRegex || '');
form.setValue('validationErrorMessage', field.validationErrorMessage || '');
});
async function onSubmit() {
isLoading = true;
try {
const data = form.validate();
if (!data) return;
await callback(data);
toast.success(m.custom_fields_updated_successfully());
show = false;
} finally {
isLoading = false;
}
}
</script>
<Dialog.Root open={show} onOpenChange={(open) => (show = open)}>
<Dialog.Content class="lg:min-w-3xl md:min-w-2xl" onOpenAutoFocus={(e) => e.preventDefault()}>
<Dialog.Header>
<Dialog.Title>{existingCustomField ? m.edit() : m.add_custom_field()}</Dialog.Title>
</Dialog.Header>
<form onsubmit={preventDefault(onSubmit)}>
<div class="grid grid-cols-1 items-center gap-5 md:grid-cols-2">
<FormInput label={m.key()} bind:input={$inputs.key} />
<FormInput label={m.display_name()} bind:input={$inputs.displayName} />
<Field.Field>
<Field.Label>{m.type()}</Field.Label>
<Select.Root
type="single"
disabled={!!existingCustomField}
value={$inputs.type.value}
onValueChange={(value) => {
$inputs.type.value = value as any;
if (value !== 'string') {
$inputs.validationRegex.value = '';
$inputs.validationErrorMessage.value = '';
}
}}
>
<Select.Trigger class="w-full" aria-label={m.type()}>
{fieldTypes.find((option) => option.value === $inputs.type.value)?.label}
</Select.Trigger>
<Select.Content>
{#each fieldTypes as option}
<Select.Item value={option.value}>{option.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Field.Field>
<Field.Field>
<Field.Label>{m.available_for()}</Field.Label>
<Select.Root
type="single"
value={$inputs.target.value}
onValueChange={(value) => ($inputs.target.value = value as CustomFieldTarget)}
>
<Select.Trigger class="w-full" aria-label={m.available_for()}>
{fieldTargets.find((option) => option.value === $inputs.target.value)?.label}
</Select.Trigger>
<Select.Content>
{#each fieldTargets as option}
<Select.Item value={option.value}>{option.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Field.Field>
<SwitchWithLabel
id="required"
class="items-center"
label={m.required()}
description={m.required_custom_field_description()}
bind:checked={$inputs.required.value}
/>
{#if $inputs.target.value != 'group'}
<SwitchWithLabel
id="user-editable"
class="items-center"
label={m.user_editable()}
description={m.user_editable_description()}
bind:checked={$inputs.userEditable.value}
/>
{:else}
<div class="hidden md:flex"></div>
{/if}
{#if $inputs.type.value === 'boolean'}
<Field.Field>
<Field.Label required={$inputs.required.value}>{m.default_value()}</Field.Label>
<Select.Root
type="single"
value={$inputs.defaultValue.value || ''}
onValueChange={(value) => ($inputs.defaultValue.value = value)}
>
<Select.Trigger class="w-full" aria-label={m.default_value()}>
{#if $inputs.defaultValue.value === 'true'}
{m.yes()}
{:else if $inputs.defaultValue.value === 'false'}
{m.no()}
{:else}
{m.no_default()}
{/if}
</Select.Trigger>
<Select.Content>
<Select.Item value="">{m.no_default()}</Select.Item>
<Select.Item value="true">{m.yes()}</Select.Item>
<Select.Item value="false">{m.no()}</Select.Item>
</Select.Content>
</Select.Root>
{#if $inputs.defaultValue.error}
<Field.Error class="text-start">{$inputs.defaultValue.error}</Field.Error>
{/if}
</Field.Field>
{:else}
<FormInput
label={m.default_value()}
type={$inputs.type.value === 'number' ? 'number' : 'text'}
bind:input={$inputs.defaultValue}
/>
{/if}
{#if $inputs.type.value === 'string'}
<FormInput
label={m.validation_regex()}
placeholder="^\S+@\S+\.\S+$"
bind:input={$inputs.validationRegex}
description={m.validation_regex_description()}
/>
<FormInput
label={m.validation_error_message()}
placeholder={m.must_be_a_valid_email_address()}
bind:input={$inputs.validationErrorMessage}
description={m.validation_error_message_description()}
/>
{/if}
</div>
<Dialog.Footer class="mt-10 md:mt-3">
<Button onclick={() => (show = false)} variant="secondary">{m.cancel()}</Button>
<Button {isLoading} type="submit">{m.save()}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import CustomClaimsInput from '$lib/components/form/custom-claims-input.svelte';
import UserGroupInput from '$lib/components/form/user-group-input.svelte';
import { Button } from '$lib/components/ui/button';
import * as Field from '$lib/components/ui/field';
@@ -19,7 +18,6 @@
} = $props();
let selectedGroupIds = $state<string[]>(appConfig.signupDefaultUserGroupIDs || []);
let customClaims = $state(appConfig.signupDefaultCustomClaims || []);
let allowUserSignups = $state(appConfig.allowUserSignups);
let isLoading = $state(false);
@@ -42,15 +40,13 @@
isLoading = true;
await callback({
allowUserSignups: allowUserSignups,
signupDefaultUserGroupIDs: selectedGroupIds,
signupDefaultCustomClaims: customClaims
signupDefaultUserGroupIDs: selectedGroupIds
});
toast.success(m.user_creation_updated_successfully());
isLoading = false;
}
$effect(() => {
customClaims = appConfig.signupDefaultCustomClaims || [];
allowUserSignups = appConfig.allowUserSignups;
});
</script>
@@ -113,13 +109,6 @@
</Field.Description>
<UserGroupInput bind:selectedGroupIds />
</Field.Field>
<Field.Field>
<Field.Label>{m.custom_claims()}</Field.Label>
<Field.Description>
{m.user_creation_claims_description()}
</Field.Description>
<CustomClaimsInput bind:customClaims />
</Field.Field>
<div class="flex justify-end pt-2">
<Button {isLoading} type="submit">{m.save()}</Button>

View File

@@ -47,10 +47,7 @@
[m.logout_url()]: `https://${page.url.host}/api/oidc/end-session`,
[m.certificate_url()]: `https://${page.url.host}/.well-known/jwks.json`,
[m.pkce()]: client.pkceEnabled ? m.enabled() : m.disabled(),
[m.requires_reauthentication()]: client.requiresReauthentication ? m.enabled() : m.disabled(),
[m.requires_pushed_authorization_requests()]: client.requiresPushedAuthorizationRequests
? m.enabled()
: m.disabled()
[m.requires_reauthentication()]: client.requiresReauthentication ? m.enabled() : m.disabled()
});
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
@@ -74,8 +71,6 @@
await Promise.all([dataPromise, imagePromise, darkImagePromise])
.then(() => {
setupDetails[m.requires_pushed_authorization_requests()] =
updatedClient.requiresPushedAuthorizationRequests ? m.enabled() : m.disabled();
if (updatedClient.logoUrl) {
cachedOidcClientLogo.bustCache(client.id, true);
}

View File

@@ -49,8 +49,6 @@
isPublic: existingClient?.isPublic || false,
pkceEnabled: existingClient?.pkceEnabled || false,
requiresReauthentication: existingClient?.requiresReauthentication || false,
requiresPushedAuthorizationRequests:
existingClient?.requiresPushedAuthorizationRequests || false,
launchURL: existingClient?.launchURL || '',
credentials: {
federatedIdentities: existingClient?.credentials?.federatedIdentities || []
@@ -76,7 +74,6 @@
isPublic: z.boolean(),
pkceEnabled: z.boolean(),
requiresReauthentication: z.boolean(),
requiresPushedAuthorizationRequests: z.boolean(),
launchURL: optionalUrl,
logoUrl: optionalUrl,
darkLogoUrl: optionalUrl,
@@ -208,7 +205,6 @@
onCheckedChange={(v) => {
if (v) {
$inputs.pkceEnabled.value = true;
$inputs.requiresPushedAuthorizationRequests.value = false;
}
}}
bind:checked={$inputs.isPublic.value}
@@ -274,13 +270,6 @@
{#if showAdvancedOptions}
<div class="mt-7 flex flex-col gap-y-7 md:col-span-2" transition:slide={{ duration: 200 }}>
<SwitchWithLabel
id="requires-par"
label={m.requires_pushed_authorization_requests()}
description={m.requires_pushed_authorization_requests_description()}
disabled={$inputs.isPublic.value}
bind:checked={$inputs.requiresPushedAuthorizationRequests.value}
/>
{#if mode == 'create'}
<FormInput
label={m.client_id()}

View File

@@ -59,13 +59,6 @@
sortable: true,
filterableValues: booleanFilterValues
},
{
label: m.par(),
column: 'requiresPushedAuthorizationRequests',
sortable: true,
hidden: true,
filterableValues: booleanFilterValues
},
{
label: m.client_launch_url(),
column: 'launchURL',

View File

@@ -1,11 +1,9 @@
<script lang="ts">
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
import CustomClaimsInput from '$lib/components/form/custom-claims-input.svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import CustomClaimService from '$lib/services/custom-claim-service';
import UserGroupService from '$lib/services/user-group-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { UserGroupCreate } from '$lib/types/user-group.type';
@@ -27,7 +25,6 @@
let oidcClientSelectionRef: OidcClientSelection;
const userGroupService = new UserGroupService();
const customClaimService = new CustomClaimService();
const backNavigation = backNavigate('/settings/admin/user-groups');
async function updateUserGroup(updatedUserGroup: UserGroupCreate) {
@@ -52,15 +49,6 @@
});
}
async function updateCustomClaims() {
await customClaimService
.updateUserGroupCustomClaims(userGroup.id, userGroup.customClaims)
.then(() => toast.success(m.custom_claims_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
});
}
async function updateAllowedOidcClients(allowedClients: string[]) {
await userGroupService
.updateAllowedOidcClients(userGroup.id, allowedClients)
@@ -116,17 +104,6 @@
</Card.Content>
</Card.Root>
<CollapsibleCard
id="user-group-custom-claims"
title={m.custom_claims()}
description={m.custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized()}
>
<CustomClaimsInput bind:customClaims={userGroup.customClaims} />
<div class="mt-5 flex justify-end">
<Button onclick={updateCustomClaims} type="submit">{m.save()}</Button>
</div>
</CollapsibleCard>
<CollapsibleCard
id="user-group-oidc-clients"
title={m.allowed_oidc_clients()}

View File

@@ -1,8 +1,10 @@
<script lang="ts">
import CustomFieldValuesInput from '$lib/components/form/custom-field-values-input.svelte';
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import { customFieldAppliesTo } from '$lib/types/custom-field.type';
import type { UserGroupCreate } from '$lib/types/user-group.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
@@ -19,6 +21,11 @@
let isLoading = $state(false);
let inputDisabled = $derived(!!existingUserGroup?.ldapId && $appConfigStore.ldapEnabled);
let hasManualNameEdit = $state(!!existingUserGroup?.friendlyName);
let customFieldValues = $state(existingUserGroup?.customFieldValues || []);
let customFieldValuesInputRef = $state<CustomFieldValuesInput>();
let customFields = $derived(
$appConfigStore.customFields.filter((field) => customFieldAppliesTo(field, 'group'))
);
const userGroup = {
name: existingUserGroup?.name || '',
@@ -46,12 +53,18 @@
async function onSubmit() {
const data = form.validate();
if (!data) return;
if (!customFieldValuesInputRef?.validate()) return;
isLoading = true;
const success = await callback(data);
const success = await callback({
...data,
customFieldValues
});
// Reset form if user group was successfully created
if (success && !existingUserGroup) {
form.reset();
hasManualNameEdit = false;
customFieldValues = [];
}
isLoading = false;
}
@@ -59,24 +72,26 @@
<form onsubmit={preventDefault(onSubmit)}>
<fieldset disabled={inputDisabled}>
<div class="flex flex-col gap-3 sm:flex-row">
<div class="w-full">
<FormInput
label={m.friendly_name()}
description={m.name_that_will_be_displayed_in_the_ui()}
bind:input={$inputs.friendlyName}
onInput={onFriendlyNameInput}
/>
</div>
<div class="w-full">
<FormInput
label={m.name()}
description={m.name_that_will_be_in_the_groups_claim()}
bind:input={$inputs.name}
onInput={onNameInput}
/>
</div>
<div class="grid grid-cols-1 items-start gap-5 md:grid-cols-2">
<FormInput
label={m.friendly_name()}
description={m.name_that_will_be_displayed_in_the_ui()}
bind:input={$inputs.friendlyName}
onInput={onFriendlyNameInput}
/>
<FormInput
label={m.name()}
description={m.name_that_will_be_in_the_groups_claim()}
bind:input={$inputs.name}
onInput={onNameInput}
/>
<CustomFieldValuesInput
bind:this={customFieldValuesInputRef}
bind:customFieldValues
{customFields}
/>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
import CustomClaimsInput from '$lib/components/form/custom-claims-input.svelte';
import ProfilePictureSettings from '$lib/components/form/profile-picture-settings.svelte';
import Badge from '$lib/components/ui/badge/badge.svelte';
import { Button } from '$lib/components/ui/button';
@@ -8,7 +7,6 @@
import * as Item from '$lib/components/ui/item/index.js';
import UserGroupSelection from '$lib/components/user-group-selection.svelte';
import { m } from '$lib/paraglide/messages';
import CustomClaimService from '$lib/services/custom-claim-service';
import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { Passkey } from '$lib/types/passkey.type';
@@ -28,7 +26,6 @@
let passkeys: Passkey[] = $state(data.passkeys);
const userService = new UserService();
const customClaimService = new CustomClaimService();
const backNavigation = backNavigate('/settings/admin/users');
async function updateUserGroups(userIds: string[]) {
@@ -53,15 +50,6 @@
return success;
}
async function updateCustomClaims() {
await customClaimService
.updateUserCustomClaims(user.id, user.customClaims)
.then(() => toast.success(m.custom_claims_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
});
}
async function updateProfilePicture(image: File) {
await userService
.updateProfilePicture(user.id, image)
@@ -150,14 +138,3 @@
<AdminPasskeyList userId={user.id} bind:passkeys />
{/if}
</Item.Group>
<CollapsibleCard
id="user-custom-claims"
title={m.custom_claims()}
description={m.custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user()}
>
<CustomClaimsInput bind:customClaims={user.customClaims} />
<div class="mt-5 flex justify-end">
<Button onclick={updateCustomClaims} type="submit">{m.save()}</Button>
</div>
</CollapsibleCard>

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