mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-18 13:53:49 +03:00
Compare commits
2 Commits
breaking/v
...
feat/custo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abdfaac1b0 | ||
|
|
3428fb35d7 |
3
.github/ISSUE_TEMPLATE/feature.yml
vendored
3
.github/ISSUE_TEMPLATE/feature.yml
vendored
@@ -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
|
||||
|
||||
12
.github/pull_request_template.md
vendored
12
.github/pull_request_template.md
vendored
@@ -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).
|
||||
2
.github/workflows/pr-quality.yml
vendored
2
.github/workflows/pr-quality.yml
vendored
@@ -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
|
||||
|
||||
|
||||
43
CHANGELOG.md
43
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
42
backend/internal/dto/custom_field_dto.go
Normal file
42
backend/internal/dto/custom_field_dto.go
Normal 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"`
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package model
|
||||
|
||||
type CustomClaim struct {
|
||||
Base
|
||||
|
||||
Key string
|
||||
Value string
|
||||
|
||||
UserID *string
|
||||
UserGroupID *string
|
||||
}
|
||||
11
backend/internal/model/custom_field_value.go
Normal file
11
backend/internal/model/custom_field_value.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package model
|
||||
|
||||
type CustomFieldValue struct {
|
||||
Base
|
||||
|
||||
CustomFieldID string
|
||||
Value string
|
||||
|
||||
UserID *string
|
||||
UserGroupID *string
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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;"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
566
backend/internal/service/custom_field_service.go
Normal file
566
backend/internal/service/custom_field_service.go
Normal 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
|
||||
}
|
||||
183
backend/internal/service/custom_field_service_test.go
Normal file
183
backend/internal/service/custom_field_service_test.go
Normal 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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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';
|
||||
@@ -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');
|
||||
@@ -1,3 +0,0 @@
|
||||
DROP TABLE IF EXISTS oidc_pushed_authorization_requests;
|
||||
|
||||
ALTER TABLE oidc_clients DROP COLUMN requires_pushed_authorization_requests;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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');
|
||||
@@ -1,3 +0,0 @@
|
||||
DROP TABLE IF EXISTS oidc_pushed_authorization_requests;
|
||||
|
||||
ALTER TABLE oidc_clients DROP COLUMN requires_pushed_authorization_requests;
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 d’interception de code d’autorisation.",
|
||||
"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",
|
||||
|
||||
@@ -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": "列の表示/非表示を切り替える",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
/* Font */
|
||||
--font-gloock: 'gloock', serif;
|
||||
--font-glooock: 'glooock ', serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export type CustomClaim = {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
24
frontend/src/lib/types/custom-field.type.ts
Normal file
24
frontend/src/lib/types/custom-field.type.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user