Compare commits

...

13 Commits

Author SHA1 Message Date
Elias Schneider
abdfaac1b0 Merge remote-tracking branch 'origin/main' into feat/custom-fields 2026-05-31 16:12:20 +02:00
Elias Schneider
0616abaf7f refactor: run formatter 2026-05-31 16:11:21 +02:00
Elias Schneider
9ad2bfc7b3 tests(e2e): don't compare hashes of profile pictures 2026-05-29 13:43:21 +02:00
Elias Schneider
bc4f75c44f refactor: fix linter issues 2026-05-29 12:25:06 +02:00
Elias Schneider
a71219637a fix: add support for unix socket mode in healthcheck 2026-05-29 12:11:26 +02:00
Elias Schneider
b3d40a476b feat: improve design trough the whole application 2026-05-29 11:37:34 +02:00
Elias Schneider
272d1479bd fix: scope confirmation wasn't shown if account selection was prompted 2026-05-29 10:04:56 +02:00
Elias Schneider
f13424720b tests(e2e): use custom Playwright route for callback URL checks 2026-05-29 09:44:37 +02:00
Elias Schneider
6aefe6c8e1 chore(translations): update translations via Crowdin (#1490) 2026-05-29 09:18:59 +02:00
V
dd77bb0f32 feat: add support for systemd socket activation (#1479) 2026-05-29 09:18:29 +02:00
Elias Schneider
0c95b7c3cc feat: add support for response_mode=fragment 2026-05-29 09:14:44 +02:00
github-actions[bot]
f4a20e3ef9 chore: update AAGUIDs (#1487)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2026-05-25 09:20:32 -05:00
Elias Schneider
3428fb35d7 feat!: replace custom claims with custom fields 2026-05-23 16:07:37 +02:00
176 changed files with 4216 additions and 1581 deletions

View File

@@ -10,6 +10,7 @@ require (
github.com/aws/smithy-go v1.25.1
github.com/caarlos0/env/v11 v11.4.1
github.com/cenkalti/backoff/v5 v5.0.3
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
github.com/disintegration/imaging v1.6.2
github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1

View File

@@ -65,6 +65,8 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU=
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

View File

@@ -15,6 +15,7 @@ import (
"sync/atomic"
"time"
"github.com/coreos/go-systemd/activation"
"github.com/fsnotify/fsnotify"
sloggin "github.com/gin-contrib/slog"
"github.com/gin-gonic/gin"
@@ -131,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)
@@ -163,24 +163,26 @@ func initServer(r *gin.Engine) (*serverConfig, error) {
return nil, err
}
network, addr := listenerNetworkAndAddr()
listener, err := net.Listen(network, addr) //nolint:noctx
if err != nil {
return nil, fmt.Errorf("failed to create %s listener: %w", network, err)
var socketFn func() (*socket, error)
switch {
case common.EnvConfig.SystemdSocket:
socketFn = systemdSocket
case common.EnvConfig.UnixSocket != "":
socketFn = unixSocket
default:
socketFn = tcpSocket
}
if err := setUnixSocketMode(network, addr); err != nil {
listener.Close()
socket, err := socketFn()
if err != nil {
return nil, err
}
return &serverConfig{
addr: addr,
certProvider: certProvider,
listener: listener,
server: newHTTPServer(r, protocols),
tlsConfig: tlsConfig,
}, nil
addr := socket.addr
listener := socket.listener
server := newHTTPServer(r, protocols)
return &serverConfig{addr, certProvider, listener, server, tlsConfig}, nil
}
func initServerProtocols() (*http.Protocols, *tls.Config, *tlsCertProvider, error) {
@@ -208,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,
@@ -227,33 +287,6 @@ func newHTTPServer(r *gin.Engine, protocols *http.Protocols) *http.Server {
}
}
func listenerNetworkAndAddr() (string, string) {
if common.EnvConfig.UnixSocket == "" {
return "tcp", net.JoinHostPort(common.EnvConfig.Host, common.EnvConfig.Port)
}
addr := common.EnvConfig.UnixSocket
os.Remove(addr) // remove dangling the socket file to avoid file-exist error
return "unix", addr
}
func setUnixSocketMode(network, addr string) error {
if network != "unix" || common.EnvConfig.UnixSocketMode == "" {
return nil
}
mode, err := strconv.ParseUint(common.EnvConfig.UnixSocketMode, 8, 32)
if err != nil {
return fmt.Errorf("failed to parse UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
}
if err := os.Chmod(addr, os.FileMode(mode)); err != nil {
return fmt.Errorf("failed to set UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
}
return nil
}
func runServer(ctx context.Context, config *serverConfig) error {
slog.Info("Server listening", slog.String("addr", config.addr), slog.Bool("tls", config.tlsConfig != nil))

View File

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

View File

@@ -2,9 +2,12 @@ package cmds
import (
"context"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"strings"
"time"
"github.com/spf13/cobra"
@@ -13,8 +16,14 @@ import (
)
type healthcheckFlags struct {
Endpoint string
Verbose bool
Endpoint string
UnixSocket string
Verbose bool
}
type healthcheckResult struct {
StatusCode int
URL string
}
func init() {
@@ -29,47 +38,26 @@ func init() {
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
defer cancel()
url := flags.Endpoint + "/healthz"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if flags.UnixSocket == "" && !cmd.Flags().Changed("endpoint") {
flags.UnixSocket = common.EnvConfig.UnixSocket
}
result, err := healthcheck(ctx, flags)
if err != nil {
slog.ErrorContext(ctx,
"Failed to create request object",
"Healthcheck failed",
"error", err,
"url", url,
"ms", time.Since(start).Milliseconds(),
)
os.Exit(1)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
slog.ErrorContext(ctx,
"Failed to perform request",
"error", err,
"url", url,
"ms", time.Since(start).Milliseconds(),
)
os.Exit(1)
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
if err != nil {
slog.ErrorContext(ctx,
"Healthcheck failed",
"status", res.StatusCode,
"url", url,
"ms", time.Since(start).Milliseconds(),
)
os.Exit(1)
}
}
if flags.Verbose {
slog.InfoContext(ctx,
"Healthcheck succeeded",
"status", res.StatusCode,
"url", url,
"status", result.StatusCode,
"url", result.URL,
"unixSocket", flags.UnixSocket,
"ms", time.Since(start).Milliseconds(),
)
}
@@ -77,7 +65,42 @@ func init() {
}
healthcheckCmd.Flags().StringVarP(&flags.Endpoint, "endpoint", "e", "http://localhost:"+common.EnvConfig.Port, "Endpoint for Pocket ID")
healthcheckCmd.Flags().StringVar(&flags.UnixSocket, "unix-socket", "", "UNIX socket path for Pocket ID")
healthcheckCmd.Flags().BoolVarP(&flags.Verbose, "verbose", "v", false, "Enable verbose mode")
rootCmd.AddCommand(healthcheckCmd)
}
func healthcheck(ctx context.Context, flags healthcheckFlags) (*healthcheckResult, error) {
url := strings.TrimRight(flags.Endpoint, "/") + "/healthz"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request object for %q: %w", url, err)
}
client := http.DefaultClient
if flags.UnixSocket != "" {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
dialer := net.Dialer{}
return dialer.DialContext(ctx, "unix", flags.UnixSocket)
}
client = &http.Client{Transport: transport}
}
res, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to perform request to %q: %w", url, err)
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, fmt.Errorf("unexpected status %d from %q", res.StatusCode, url)
}
return &healthcheckResult{
StatusCode: res.StatusCode,
URL: url,
}, nil
}

View File

@@ -0,0 +1,79 @@
package cmds
import (
"context"
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHealthcheckTCPSuccess(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/healthz", r.URL.Path)
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
result, err := healthcheck(t.Context(), healthcheckFlags{
Endpoint: server.URL,
})
require.NoError(t, err)
require.Equal(t, http.StatusNoContent, result.StatusCode)
require.Equal(t, server.URL+"/healthz", result.URL)
}
func TestHealthcheckFailsOnUnexpectedStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
_, err := healthcheck(t.Context(), healthcheckFlags{
Endpoint: server.URL,
})
require.Error(t, err)
require.ErrorContains(t, err, "unexpected status 500")
}
func TestHealthcheckUnixSocket(t *testing.T) {
socketPath := filepath.Join(t.TempDir(), "pocket-id.sock")
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "unix", socketPath)
require.NoError(t, err)
server := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/healthz", r.URL.Path)
w.WriteHeader(http.StatusNoContent)
}),
ReadHeaderTimeout: time.Second,
}
errCh := make(chan error, 1)
go func() {
errCh <- server.Serve(listener)
}()
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
require.NoError(t, server.Shutdown(ctx))
require.ErrorIs(t, <-errCh, http.ErrServerClosed)
})
result, err := healthcheck(t.Context(), healthcheckFlags{
Endpoint: "http://localhost:1411",
UnixSocket: socketPath,
})
require.NoError(t, err)
require.Equal(t, http.StatusNoContent, result.StatusCode)
require.Equal(t, "http://localhost:1411/healthz", result.URL)
}

View File

@@ -68,6 +68,7 @@ type EnvConfigSchema struct {
Host string `env:"HOST" options:"toLower"`
UnixSocket string `env:"UNIX_SOCKET"`
UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
SystemdSocket bool `env:"SYSTEMD_SOCKET"`
LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"`
TLSCertFile string `env:"TLS_CERT" options:"file"`
@@ -153,6 +154,9 @@ func ValidateEnvConfig(config *EnvConfigSchema) error {
if err := validateFileBackend(config); err != nil {
return err
}
if config.SystemdSocket && config.UnixSocket != "" {
return errors.New("SYSTEMD_SOCKET and UNIX_SOCKET are mutually exclusive")
}
if err := validateLocalIPv6Ranges(config.LocalIPv6Ranges); err != nil {
return err
}

View File

@@ -171,23 +171,30 @@ type MissingSessionIdError struct{}
func (e MissingSessionIdError) Error() string { return "Missing session id" }
func (e MissingSessionIdError) HttpStatusCode() int { return http.StatusBadRequest }
type ReservedClaimError struct {
type ReservedCustomFieldError struct {
Key string
}
func (e ReservedClaimError) Error() string {
return fmt.Sprintf("Claim %s is reserved and can't be used", e.Key)
func (e ReservedCustomFieldError) Error() string {
return fmt.Sprintf("Custom field %s is reserved and can't be used", e.Key)
}
func (e ReservedClaimError) HttpStatusCode() int { return http.StatusBadRequest }
func (e ReservedCustomFieldError) HttpStatusCode() int { return http.StatusBadRequest }
type DuplicateClaimError struct {
type DuplicateCustomFieldError struct {
Key string
}
func (e DuplicateClaimError) Error() string {
return fmt.Sprintf("Claim %s is already defined", e.Key)
func (e DuplicateCustomFieldError) Error() string {
return fmt.Sprintf("Custom field %s is already defined", e.Key)
}
func (e DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest }
func (e DuplicateCustomFieldError) HttpStatusCode() int { return http.StatusBadRequest }
type CustomFieldValidationError struct {
Message string
}
func (e CustomFieldValidationError) Error() string { return e.Message }
func (e CustomFieldValidationError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcInvalidCodeVerifierError struct{}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import (
"crypto/elliptic"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"path"
@@ -17,6 +18,7 @@ import (
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
@@ -563,6 +565,56 @@ func (s *TestService) ResetAppConfig(ctx context.Context) error {
return err
}
// Add custom fields
customFields := []dto.CustomFieldDto{
{
ID: "189356b1-57f3-4c14-bd59-3ae1132a36d1",
Key: "department",
Type: "string",
UserEditable: true,
DisplayName: "Department",
Target: "user",
ValidationRegex: "^[A-Za-z]+$",
ValidationErrorMessage: "Department must only contain letters",
},
{
ID: "0b68d19a-bb72-4750-84b4-2f0992f5200c",
Key: "nickname",
Type: "string",
UserEditable: true,
Required: true,
DisplayName: "Nickname",
Target: "user",
DefaultValue: "to-remove",
},
{
ID: "8d081fd8-6a51-45a1-8051-04c3b043f5bd",
Key: "elevatedRights",
Type: "boolean",
DisplayName: "Elevated Rights",
Target: "group",
},
{
ID: "3d7c6054-e146-48cb-b2d3-7d7897dcbc51",
Key: "internalId",
Type: "number",
DefaultValue: "0",
UserEditable: false,
DisplayName: "Internal ID",
Target: "both",
Required: true,
},
}
customFieldsJSON, err := json.Marshal(&customFields)
if err != nil {
return err
}
err = s.appConfigService.UpdateAppConfigValues(ctx, "customFields", string(customFieldsJSON))
if err != nil {
return err
}
// Reload the app config from the database after resetting the values
err = s.appConfigService.LoadDbConfig(ctx)
if err != nil {

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ import (
"crypto/subtle"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
@@ -51,13 +50,13 @@ const (
)
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
@@ -70,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
@@ -2043,23 +2042,51 @@ func (s *OidcService) getUserClaims(ctx context.Context, user *model.User, scope
}
if slices.Contains(scopes, "profile") {
// Add custom claims
customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(ctx, user.ID, tx)
// We need to fetch the user and group fields first because we need the key of the custom key later
userFields, err := s.customFieldValueService.GetConfiguredCustomFieldsForTarget(UserID)
if err != nil {
return nil, err
}
userFieldsByID := make(map[string]dto.CustomFieldDto, len(userFields))
for _, customField := range userFields {
userFieldsByID[customField.ID] = customField
}
groupFields, err := s.customFieldValueService.GetConfiguredCustomFieldsForTarget(UserGroupID)
if err != nil {
return nil, err
}
groupFieldsByID := make(map[string]dto.CustomFieldDto, len(groupFields))
for _, customField := range groupFields {
groupFieldsByID[customField.ID] = customField
}
// Fetch the actual values of the custom fields
customFieldValues, err := s.customFieldValueService.GetCustomFieldValuesForUserWithUserGroups(ctx, user.ID, tx)
if err != nil {
return nil, err
}
for _, customClaim := range customClaims {
// The value of the custom claim can be a JSON object or a string
var jsonValue any
err := json.Unmarshal([]byte(customClaim.Value), &jsonValue)
if err == nil {
// It's JSON, so we store it as an object
claims[customClaim.Key] = jsonValue
} else {
// Marshaling failed, so we store it as a string
claims[customClaim.Key] = customClaim.Value
for _, customFieldValue := range customFieldValues {
var customField *dto.CustomFieldDto
var claimKey string
if customFieldValue.UserID != nil {
if field, ok := userFieldsByID[customFieldValue.CustomFieldID]; ok {
customField = &field
claimKey = field.Key
}
} else if customFieldValue.UserGroupID != nil {
if field, ok := groupFieldsByID[customFieldValue.CustomFieldID]; ok {
customField = &field
claimKey = field.Key
}
}
value, err := customFieldValueTokenValue(customFieldValue, customField)
if err != nil {
return nil, err
}
claims[claimKey] = value
}
// Add profile claims

View File

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

View File

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

View File

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

View File

@@ -54,7 +54,7 @@ func CreateDefaultProfilePicture(initials string) (*bytes.Buffer, error) {
img := imaging.New(profilePictureSize, profilePictureSize, color.RGBA{R: 255, G: 255, B: 255, A: 255})
// Load the font
fontBytes, err := resources.FS.ReadFile("fonts/PlayfairDisplay-Bold.ttf")
fontBytes, err := resources.FS.ReadFile("fonts/Gloock.ttf")
if err != nil {
return nil, fmt.Errorf("failed to read font file: %w", err)
}
@@ -84,7 +84,7 @@ func CreateDefaultProfilePicture(initials string) (*bytes.Buffer, error) {
// Center the initials
x := (profilePictureSize - font.MeasureString(face, initials).Ceil()) / 2
y := (profilePictureSize-face.Metrics().Height.Ceil())/2 + face.Metrics().Ascent.Ceil() - 10
y := (profilePictureSize-face.Metrics().Height.Ceil())/2 + face.Metrics().Ascent.Ceil()
drawer.Dot = fixed.P(x, y)
// Draw the initials

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}.",
@@ -289,9 +279,7 @@
"requires_reauthentication": "Requires Re-Authentication",
"requires_users_to_authenticate_again_on_each_authorization": "Requires users to authenticate again on each authorization, even if already signed in",
"name_logo": "{name} logo",
"change_logo": "Change Logo",
"upload_logo": "Upload Logo",
"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",
@@ -373,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",
@@ -385,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",
@@ -409,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.",
@@ -436,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",
@@ -526,5 +508,34 @@
"email_verification_sent": "Verification email sent successfully.",
"emails_verified_by_default": "Emails verified by default",
"emails_verified_by_default_description": "When enabled, users' email addresses will be marked as verified by default upon signup or when their email address is changed.",
"user_has_no_passkeys_yet": "This user has no passkeys yet."
"user_has_no_passkeys_yet": "This user has no passkeys yet.",
"custom_fields": "Custom Fields",
"configure_custom_fields": "Configure custom fields that can be assigned to users and user groups.",
"custom_fields_updated_successfully": "Custom fields updated successfully",
"add_custom_field": "Add custom field",
"delete_custom_field_name": "Delete {name}?",
"delete_custom_field_description": "Are you sure you want to delete the custom field {name}? This will remove the field from all users and groups.",
"custom_field_already_exists": "Custom field already exists",
"available_for": "Available for",
"users_and_groups": "Users and groups",
"field_is_required": "Field is required",
"must_be_a_number": "Must be a number",
"value_must_be_true_or_false": "Value must be true or false",
"default_value": "Default value",
"no_default": "No default",
"invalid_regex": "Invalid regex",
"validation_regex": "Validation regex",
"validation_regex_description": "A regular expression that the value of this field must match.",
"must_be_a_valid_email_address": "Must be a valid email address",
"validation_error_message": "Validation error message",
"validation_error_message_description": "The error message that will be shown when the value does not match the validation regex.",
"value_does_not_match_required_format": "Value does not match the required format",
"user_editable": "User editable",
"user_editable_description": "Allow users to update this field on their own account.",
"text": "Text",
"number": "Number",
"boolean": "Boolean",
"type": "Type",
"required": "Required",
"required_custom_field_description": "Whether this field should be marked as required in the form"
}

View File

@@ -249,7 +249,7 @@
"edit": "Düzenle",
"user_groups_updated_successfully": "Kullanıcı grupları başarıyla güncellendi",
"user_updated_successfully": "Kullanıcı başarıyla güncellendi",
"custom_claims_updated_successfully": "Özel alanlar başarıyla güncellendi",
"custom_claims_updated_successfully": "Özel yetkiler başarıyla güncellendi",
"back": "Geri",
"user_details_firstname_lastname": "Kullanıcı Detayları {firstName} {lastName}",
"manage_which_groups_this_user_belongs_to": "Bu kullanıcının ait olduğu grupları yönetin.",

View File

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

View File

@@ -1,5 +1,6 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import '@fontsource/gloock';
@variant dark (&:where(.dark, .dark *));
@@ -22,7 +23,7 @@
}
:root {
--radius: 0.625rem;
--radius: 1rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
@@ -133,13 +134,8 @@
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
/* Animations */
--animate-accordion-up: accordion-up 0.2s ease-out;
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-caret-blink: caret-blink 1.25s ease-out infinite;
/* Font */
--font-playfair: 'Playfair Display', serif;
--font-glooock: 'glooock ', serif;
}
@layer base {
@@ -154,60 +150,6 @@
button {
@apply cursor-pointer;
}
@font-face {
font-family: 'Playfair Display';
font-weight: 400;
src: url('/fonts/PlayfairDisplay-Regular.woff') format('woff');
}
@font-face {
font-family: 'Playfair Display';
font-weight: 500;
src: url('/fonts/PlayfairDisplay-Medium.woff') format('woff');
}
@font-face {
font-family: 'Playfair Display';
font-weight: 600;
src: url('/fonts/PlayfairDisplay-SemiBold.woff') format('woff');
}
@font-face {
font-family: 'Playfair Display';
font-weight: 700;
src: url('/fonts/PlayfairDisplay-Bold.woff') format('woff');
}
}
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--bits-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--bits-accordion-content-height);
}
to {
height: 0;
}
}
@keyframes caret-blink {
0%,
70%,
100% {
opacity: 1;
}
20%,
50% {
opacity: 0;
}
}
.animate-fade-in {
@@ -220,6 +162,7 @@
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
@@ -230,6 +173,7 @@
0% {
transform: scale(1.3);
}
100% {
transform: scale(1);
}
@@ -246,6 +190,7 @@
40% {
opacity: 0;
}
100% {
opacity: 1;
}

View File

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

View File

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

View File

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

View File

@@ -81,18 +81,20 @@
variant="outline"
role="combobox"
aria-expanded={open}
class="h-auto min-h-10 w-full justify-between"
class="h-auto min-h-10 w-full bg-input/30!"
>
<div class="flex flex-wrap items-center gap-1">
{#if selectedItems.length > 0}
{#each selectedLabels as label}
<Badge variant="secondary">{label}</Badge>
{/each}
{:else}
<span class="text-muted-foreground font-normal">{m.select_items()}</span>
{/if}
<div class="flex items-center justify-between w-full">
<div class="flex flex-wrap items-center gap-1">
{#if selectedItems.length > 0}
{#each selectedLabels as label}
<Badge variant="secondary">{label}</Badge>
{/each}
{:else}
<span class="text-muted-foreground font-normal">{m.select_items()}</span>
{/if}
</div>
<LucideChevronDown class="ml-2 size-4 shrink-0 opacity-50" />
</div>
<LucideChevronDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>

View File

@@ -70,10 +70,12 @@
role="combobox"
aria-expanded={open}
{...props}
class={cn('justify-between', restProps.class)}
class={restProps.class}
>
{items.find((item) => item.value === value)?.label || selectText}
<LucideChevronDown class="ml-2 size-4 shrink-0 opacity-50" />
<span class="flex justify-between w-full">
{items.find((item) => item.value === value)?.label || selectText}
<LucideChevronDown class="size-4 shrink-0 opacity-50" />
</span>
</Button>
{/snippet}
</Popover.Trigger>

View File

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

View File

@@ -1,8 +1,9 @@
<script lang="ts">
import { page } from '$app/state';
import appConfigStore from '$lib/stores/application-configuration-store';
import { m } from '$lib/paraglide/messages';
import userStore from '$lib/stores/user-store';
import Logo from '../logo.svelte';
import Separator from '../ui/separator/separator.svelte';
import HeaderAvatar from './header-avatar.svelte';
import ModeSwitcher from './mode-switcher.svelte';
@@ -19,7 +20,11 @@
);
</script>
<div class=" w-full {isAuthPage ? 'absolute top-0 z-10 mt-3 lg:mt-8 pr-2 lg:pr-3' : 'border-b'}">
<div
class=" w-full {isAuthPage
? 'absolute top-0 z-10 mt-3 lg:mt-8 pr-2 lg:pr-3'
: 'pt-3 bg-muted/40 dark:bg-background '}"
>
<div
class="{!isAuthPage
? 'max-w-410'
@@ -27,10 +32,11 @@
>
<div class="flex h-16 items-center">
{#if !isAuthPage}
<a href="/" class="flex items-center gap-3 transition-opacity hover:opacity-80">
<a href="/" class="flex items-center transition-opacity hover:opacity-80">
<Logo class="size-8" />
<h1 class="text-lg font-semibold tracking-tight" data-testid="application-name">
{$appConfigStore.appName}
<Separator orientation="vertical" class="h-5! bg-neutral-600 ml-2 mr-3" />
<h1 class="text-2xl font-glooock" data-testid="application-name">
{m.settings()}
</h1>
</a>
{/if}

View File

@@ -23,7 +23,7 @@
</script>
<Item.Root variant="transparent" class="hover:bg-muted transition-colors py-3 px-0 sm:px-4">
<Item.Media class="bg-primary/10 text-primary rounded-lg p-2">
<Item.Media class="bg-primary/10 text-primary rounded-full p-3">
{#if icon}{@const Icon = icon}
<Icon class="size-5" />
{/if}

View File

@@ -9,7 +9,7 @@
value,
size = 200,
color = '#000000',
backgroundColor = '#FFFFFF',
backgroundColor = 'transparent',
...restProps
}: HTMLAttributes<HTMLCanvasElement> & {
value: string | null;
@@ -39,4 +39,4 @@
});
</script>
<canvas {...restProps} bind:this={canvasEl} class={cn('rounded-lg', restProps.class)}></canvas>
<canvas {...restProps} bind:this={canvasEl} class={cn('rounded', restProps.class)}></canvas>

View File

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

View File

@@ -187,7 +187,6 @@
value={signupLink}
size={180}
color={mode.current === 'dark' ? '#FFFFFF' : '#000000'}
backgroundColor={mode.current === 'dark' ? '#000000' : '#FFFFFF'}
/>
<CopyToClipboard value={signupLink!}>
<p data-testId="signup-token-link" class="px-2 text-center text-sm break-all">

View File

@@ -16,7 +16,7 @@
class={buttonVariants({
variant: 'outline',
size: 'sm',
class: 'ml-auto h-8'
class: 'ml-auto'
})}
>
<Settings2Icon />

View File

@@ -38,11 +38,13 @@
{...props}
variant="outline"
size="sm"
class="h-8 border-dashed"
class="order-dashed"
data-testid={`facet-${title.toLowerCase()}-trigger`}
>
<ListFilterIcon />
{title}
<span class="flex gap-2 items-center">
<ListFilterIcon />
{title}
</span>
{#if selectedValues.size > 0}
<Separator orientation="vertical" class="mx-2 h-4" />
<Badge variant="secondary" class="rounded-sm px-1 font-normal lg:hidden">

View File

@@ -25,6 +25,7 @@
selectedIds = $bindable(),
withoutSearch = false,
selectionDisabled = false,
paginationDisabled = false,
rowSelectionDisabled,
fetchCallback,
defaultSort,
@@ -35,6 +36,7 @@
selectedIds?: string[];
withoutSearch?: boolean;
selectionDisabled?: boolean;
paginationDisabled?: boolean;
rowSelectionDisabled?: (item: T) => boolean;
fetchCallback: (requestOptions: ListRequestOptions) => Promise<Paginated<T>>;
defaultSort?: SortRequest;
@@ -178,8 +180,10 @@
export async function refresh() {
items = await fetchCallback(requestOptions);
changePageState(items.pagination.currentPage);
updateListLength(items.pagination.totalItems);
if (!paginationDisabled) {
changePageState(items.pagination.currentPage);
updateListLength(items.pagination.totalItems);
}
}
</script>
@@ -239,7 +243,7 @@
class="h-12 w-full justify-start px-4 font-medium hover:bg-transparent"
onclick={() => onSort(column.column)}
>
<span class="flex items-center">
<span class="flex items-center w-full">
{column.label}
<ChevronDown
class={cn(
@@ -310,9 +314,7 @@
<DropdownMenu.Item
onclick={() => action.onClick(item)}
disabled={action.disabled}
class={action.variant === 'danger'
? 'text-red-500 focus:!text-red-700'
: ''}
class={action.variant === 'danger' ? 'text-red-500!' : ''}
>
{#if action.icon}
{@const Icon = action.icon}
@@ -331,51 +333,52 @@
</Table.Root>
</div>
{/if}
<div class="mt-5 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
<div class="flex items-center space-x-2">
<p class="text-sm font-medium">{m.items_per_page()}</p>
<Select.Root
type="single"
value={items?.pagination.itemsPerPage.toString()}
onValueChange={(v) => onPageSizeChange(Number(v))}
{#if !paginationDisabled}
<div class="mt-5 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
<div class="flex items-center space-x-2">
<p class="text-sm font-medium">{m.items_per_page()}</p>
<Select.Root
type="single"
value={items?.pagination.itemsPerPage.toString()}
onValueChange={(v) => onPageSizeChange(Number(v))}
>
<Select.Trigger class="w-20">
{items?.pagination.itemsPerPage}
</Select.Trigger>
<Select.Content>
{#each availablePageSizes as size}
<Select.Item value={size.toString()}>{size}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Pagination.Root
class="mx-0 w-auto"
count={items?.pagination.totalItems || 0}
perPage={items?.pagination.itemsPerPage}
{onPageChange}
page={items?.pagination.currentPage}
>
<Select.Trigger class="w-20">
{items?.pagination.itemsPerPage}
</Select.Trigger>
<Select.Content>
{#each availablePageSizes as size}
<Select.Item value={size.toString()}>{size}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#snippet children({ pages })}
<Pagination.Content class="flex justify-end">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type !== 'ellipsis' && page.value != 0}
<Pagination.Item>
<Pagination.Link {page} isActive={items?.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
</div>
<Pagination.Root
class="mx-0 w-auto"
count={items?.pagination.totalItems || 0}
perPage={items?.pagination.itemsPerPage}
{onPageChange}
page={items?.pagination.currentPage}
>
{#snippet children({ pages })}
<Pagination.Content class="flex justify-end">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type !== 'ellipsis' && page.value != 0}
<Pagination.Item>
<Pagination.Link {page} isActive={items?.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
</div>
{/if}
{/if}

View File

@@ -1,18 +1,27 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { buttonVariants } from '$lib/components/ui/button/index.js';
import {
buttonVariants,
type ButtonVariant,
type ButtonSize
} from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils/style.js';
let {
ref = $bindable(null),
class: className,
variant = 'default',
size = 'default',
...restProps
}: AlertDialogPrimitive.ActionProps = $props();
}: AlertDialogPrimitive.ActionProps & {
variant?: ButtonVariant;
size?: ButtonSize;
} = $props();
</script>
<AlertDialogPrimitive.Action
bind:ref
data-slot="alert-dialog-action"
class={cn(buttonVariants(), className)}
class={cn(buttonVariants({ variant, size }), 'cn-alert-dialog-action', className)}
{...restProps}
/>

View File

@@ -1,18 +1,27 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import { buttonVariants } from '$lib/components/ui/button/index.js';
import {
buttonVariants,
type ButtonVariant,
type ButtonSize
} from '$lib/components/ui/button/index.js';
import { cn } from '$lib/utils/style.js';
let {
ref = $bindable(null),
class: className,
variant = 'outline',
size = 'default',
...restProps
}: AlertDialogPrimitive.CancelProps = $props();
}: AlertDialogPrimitive.CancelProps & {
variant?: ButtonVariant;
size?: ButtonSize;
} = $props();
</script>
<AlertDialogPrimitive.Cancel
bind:ref
data-slot="alert-dialog-cancel"
class={cn(buttonVariants({ variant: 'outline' }), className)}
class={cn(buttonVariants({ variant, size }), 'cn-alert-dialog-cancel', className)}
{...restProps}
/>

View File

@@ -1,27 +1,32 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import AlertDialogPortal from './alert-dialog-portal.svelte';
import AlertDialogOverlay from './alert-dialog-overlay.svelte';
import { cn, type WithoutChild, type WithoutChildrenOrChild } from '$lib/utils/style.js';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
size = 'default',
portalProps,
...restProps
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<AlertDialogPrimitive.PortalProps>;
size?: 'default' | 'sm';
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof AlertDialogPortal>>;
} = $props();
</script>
<AlertDialogPrimitive.Portal {...portalProps}>
<AlertDialogPortal {...portalProps}>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
bind:ref
data-slot="alert-dialog-content"
data-size={size}
class={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-popover text-popover-foreground ring-foreground/10 gap-4 rounded-4xl p-5 sm:p-8 sm:pb-6 ring-1 duration-100 data-[size=default]:max-w-sm data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-md group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none',
className
)}
{...restProps}
/>
</AlertDialogPrimitive.Portal>
</AlertDialogPortal>

View File

@@ -12,6 +12,9 @@
<AlertDialogPrimitive.Description
bind:ref
data-slot="alert-dialog-description"
class={cn('text-muted-foreground text-sm', className)}
class={cn(
'text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3',
className
)}
{...restProps}
/>

View File

@@ -13,7 +13,10 @@
<div
bind:this={ref}
data-slot="alert-dialog-footer"
class={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
class={cn(
'-mx-4 -mb-4 rounded-b-xl p-4 flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end',
className
)}
{...restProps}
>
{@render children?.()}

View File

@@ -13,7 +13,10 @@
<div
bind:this={ref}
data-slot="alert-dialog-header"
class={cn('flex flex-col gap-2 text-center sm:text-left', className)}
class={cn(
'grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]',
className
)}
{...restProps}
>
{@render children?.()}

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils/style.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-media"
class={cn(
"bg-muted mb-2 inline-flex size-10 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -13,7 +13,7 @@
bind:ref
data-slot="alert-dialog-overlay"
class={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50',
className
)}
{...restProps}

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let { ...restProps }: AlertDialogPrimitive.PortalProps = $props();
</script>
<AlertDialogPrimitive.Portal {...restProps} />

View File

@@ -12,6 +12,9 @@
<AlertDialogPrimitive.Title
bind:ref
data-slot="alert-dialog-title"
class={cn('text-lg font-semibold', className)}
class={cn(
'text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2',
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
let { open = $bindable(false), ...restProps }: AlertDialogPrimitive.RootProps = $props();
</script>
<AlertDialogPrimitive.Root bind:open {...restProps} />

View File

@@ -1,4 +1,5 @@
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
import Root from './alert-dialog.svelte';
import Portal from './alert-dialog-portal.svelte';
import Trigger from './alert-dialog-trigger.svelte';
import Title from './alert-dialog-title.svelte';
import Action from './alert-dialog-action.svelte';
@@ -8,29 +9,32 @@ import Header from './alert-dialog-header.svelte';
import Overlay from './alert-dialog-overlay.svelte';
import Content from './alert-dialog-content.svelte';
import Description from './alert-dialog-description.svelte';
const Root = AlertDialogPrimitive.Root;
import Media from './alert-dialog-media.svelte';
export {
Root,
Title,
Action,
Cancel,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
Media,
//
Root as AlertDialog,
Title as AlertDialogTitle,
Action as AlertDialogAction,
Cancel as AlertDialogCancel,
Portal as AlertDialogPortal,
Footer as AlertDialogFooter,
Header as AlertDialogHeader,
Trigger as AlertDialogTrigger,
Overlay as AlertDialogOverlay,
Content as AlertDialogContent,
Description as AlertDialogDescription
Description as AlertDialogDescription,
Media as AlertDialogMedia
};

View File

@@ -2,7 +2,7 @@
import { tv, type VariantProps } from 'tailwind-variants';
export const alertVariants = tv({
base: 'relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
base: 'relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-3xl border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
variants: {
variant: {
default: 'bg-card text-card-foreground',

View File

@@ -2,15 +2,16 @@
import { type VariantProps, tv } from 'tailwind-variants';
export const badgeVariants = tv({
base: 'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-md border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
base: 'h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap transition-colors focus-visible:ring-[3px] [&>svg]:pointer-events-none',
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent',
secondary:
'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent',
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
destructive:
'bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white',
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground'
'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20',
outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
link: 'text-primary underline-offset-4 hover:underline'
}
},
defaultVariants: {

View File

@@ -4,22 +4,24 @@
import { tv, type VariantProps } from 'tailwind-variants';
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:shrink-0 transition active:scale-95",
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
outline:
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
'border border-border bg-background hover:bg-muted hover:text-foreground dark:hover:bg-input/30 aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-transparent',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost:
'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
destructive:
'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-8 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
sm: 'h-9 px-3 py-2',
lg: 'h-11 px-8',
icon: 'h-10 w-10'
}
},
@@ -96,9 +98,6 @@
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{#if isLoading}
<Spinner />
{/if}
{@render children?.()}
</a>
{:else}
@@ -111,9 +110,15 @@
onclick={handleOnClick}
{...restProps}
>
{#if isLoading}
<Spinner />
{/if}
{@render children?.()}
<span class="flex items-center w-full justify-center">
<Spinner
class={cn(
'grid overflow-hidden transition-[width,opacity,margin-right] duration-400 ease-[linear(0,0.897_14.4%,1.311_31.2%,1.338_46%,1.054_80.4%,1)]',
isLoading ? 'w-4 opacity-100 mr-2' : 'w-0 opacity-0'
)}
aria-hidden={!isLoading}
/>
{@render children?.()}
</span>
</button>
{/if}

View File

@@ -14,7 +14,7 @@
bind:this={ref}
data-slot="card"
class={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
'bg-card text-card-foreground flex flex-col gap-6 rounded-4xl border py-6 shadow-sm',
className
)}
{...restProps}

View File

@@ -23,14 +23,14 @@
bind:ref
data-slot="dialog-content"
class={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
'bg-popover data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-4xl border p-6 shadow-lg duration-200 sm:max-w-lg',
className
)}
{...restProps}
>
{@render children?.()}
<DialogPrimitive.Close
class="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
class="ring-offset-popover focus:ring-ring absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span class="sr-only">Close</span>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
value = $bindable([]),
...restProps
}: DropdownMenuPrimitive.CheckboxGroupProps = $props();
</script>
<DropdownMenuPrimitive.CheckboxGroup
bind:ref
bind:value
data-slot="dropdown-menu-checkbox-group"
{...restProps}
/>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import CheckIcon from '@lucide/svelte/icons/check';
import MinusIcon from '@lucide/svelte/icons/minus';
import CheckIcon from '@lucide/svelte/icons/check';
import { cn, type WithoutChildrenOrChild } from '$lib/utils/style.js';
import type { Snippet } from 'svelte';
@@ -23,17 +23,20 @@
bind:indeterminate
data-slot="dropdown-menu-checkbox-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1.5 pr-9 pl-2 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<span
class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-checkbox-item-indicator"
>
{#if indeterminate}
<MinusIcon class="size-4" />
{:else}
<CheckIcon class={cn('size-4', !checked && 'text-transparent')} />
<MinusIcon />
{:else if checked}
<CheckIcon />
{/if}
</span>
{@render childrenProp?.()}

View File

@@ -1,27 +1,31 @@
<script lang="ts">
import { cn } from '$lib/utils/style.js';
import { cn, type WithoutChildrenOrChild } from '$lib/utils/style.js';
import DropdownMenuPortal from './dropdown-menu-portal.svelte';
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
sideOffset = 4,
align = 'start',
portalProps,
class: className,
...restProps
}: DropdownMenuPrimitive.ContentProps & {
portalProps?: DropdownMenuPrimitive.PortalProps;
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DropdownMenuPortal>>;
} = $props();
</script>
<DropdownMenuPrimitive.Portal {...portalProps}>
<DropdownMenuPortal {...portalProps}>
<DropdownMenuPrimitive.Content
bind:ref
data-slot="dropdown-menu-content"
{sideOffset}
{align}
class={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 overflow-x-hidden overflow-y-auto outline-none data-closed:overflow-hidden',
className
)}
{...restProps}
/>
</DropdownMenuPrimitive.Portal>
</DropdownMenuPortal>

View File

@@ -17,6 +17,6 @@
bind:ref
data-slot="dropdown-menu-group-heading"
data-inset={inset}
class={cn('px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8', className)}
class={cn('px-2 py-1.5 text-sm font-semibold data-[inset]:ps-8', className)}
{...restProps}
/>

View File

@@ -20,7 +20,7 @@
data-inset={inset}
data-variant={variant}
class={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 data-[variant=destructive]:data-highlighted:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-2 py-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-pointer items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}

View File

@@ -17,7 +17,10 @@
bind:this={ref}
data-slot="dropdown-menu-label"
data-inset={inset}
class={cn('px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8', className)}
class={cn(
'text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7 data-[inset]:pl-8',
className
)}
{...restProps}
>
{@render children?.()}

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let { ...restProps }: DropdownMenuPrimitive.PortalProps = $props();
</script>
<DropdownMenuPrimitive.Portal {...restProps} />

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import CircleIcon from '@lucide/svelte/icons/circle';
import CheckIcon from '@lucide/svelte/icons/check';
import { cn, type WithoutChild } from '$lib/utils/style.js';
let {
@@ -15,15 +15,18 @@
bind:ref
data-slot="dropdown-menu-radio-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<span
class="absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
{#if checked}
<CircleIcon class="size-2 fill-current" />
<CheckIcon />
{/if}
</span>
{@render childrenProp?.({ checked })}

View File

@@ -13,7 +13,10 @@
<span
bind:this={ref}
data-slot="dropdown-menu-shortcut"
class={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
class={cn(
'text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest',
className
)}
{...restProps}
>
{@render children?.()}

View File

@@ -13,7 +13,7 @@
bind:ref
data-slot="dropdown-menu-sub-content"
class={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-[96px] rounded-lg p-1 shadow-lg ring-1 duration-100 w-auto',
className
)}
{...restProps}

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { cn } from '$lib/utils/style.js';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { cn } from '$lib/utils/style.js';
let {
ref = $bindable(null),
@@ -19,11 +19,11 @@
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
class={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronRightIcon class="ml-auto size-4" />
<ChevronRightIcon class="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let { open = $bindable(false), ...restProps }: DropdownMenuPrimitive.SubProps = $props();
</script>
<DropdownMenuPrimitive.Sub bind:open {...restProps} />

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let { open = $bindable(false), ...restProps }: DropdownMenuPrimitive.RootProps = $props();
</script>
<DropdownMenuPrimitive.Root bind:open {...restProps} />

View File

@@ -1,4 +1,6 @@
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import Root from './dropdown-menu.svelte';
import Sub from './dropdown-menu-sub.svelte';
import CheckboxGroup from './dropdown-menu-checkbox-group.svelte';
import CheckboxItem from './dropdown-menu-checkbox-item.svelte';
import Content from './dropdown-menu-content.svelte';
import Group from './dropdown-menu-group.svelte';
@@ -12,15 +14,18 @@ import Trigger from './dropdown-menu-trigger.svelte';
import SubContent from './dropdown-menu-sub-content.svelte';
import SubTrigger from './dropdown-menu-sub-trigger.svelte';
import GroupHeading from './dropdown-menu-group-heading.svelte';
const Sub = DropdownMenuPrimitive.Sub;
const Root = DropdownMenuPrimitive.Root;
import Portal from './dropdown-menu-portal.svelte';
export {
CheckboxGroup,
CheckboxItem,
Content,
Portal,
Root as DropdownMenu,
CheckboxGroup as DropdownMenuCheckboxGroup,
CheckboxItem as DropdownMenuCheckboxItem,
Content as DropdownMenuContent,
Portal as DropdownMenuPortal,
Group as DropdownMenuGroup,
Item as DropdownMenuItem,
Label as DropdownMenuLabel,

View File

@@ -24,7 +24,7 @@
bind:this={ref}
data-slot="input"
class={cn(
'selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-full border bg-transparent px-3 py-2 text-base font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className
@@ -39,7 +39,7 @@
bind:this={ref}
data-slot="input"
class={cn(
'border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-full border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className

View File

@@ -2,7 +2,7 @@
import { tv, type VariantProps } from 'tailwind-variants';
export const itemVariants = tv({
base: 'group/item focus-visible:border-ring focus-visible:ring-ring/50 flex flex-wrap items-center rounded-xl border border-transparent text-sm outline-none transition-colors duration-100 focus-visible:ring-[3px]',
base: 'group/item focus-visible:border-ring focus-visible:ring-ring/50 flex flex-wrap items-center rounded-4xl border border-transparent text-sm outline-none transition-colors duration-100 focus-visible:ring-[3px]',
variants: {
variant: {
default: 'bg-transparent [a&]:hover:bg-accent/50 [a&]:transition-colors',
@@ -12,8 +12,8 @@
transparent: 'bg-transparent'
},
size: {
default: 'gap-4 p-4',
sm: 'gap-2.5 px-4 py-3'
default: 'gap-4 p-6',
sm: 'gap-2.5 px-4 py-4'
}
},
defaultVariants: {

View File

@@ -1,17 +1,28 @@
import { Popover as PopoverPrimitive } from 'bits-ui';
import Root from './popover.svelte';
import Close from './popover-close.svelte';
import Content from './popover-content.svelte';
import Description from './popover-description.svelte';
import Header from './popover-header.svelte';
import Title from './popover-title.svelte';
import Trigger from './popover-trigger.svelte';
const Root = PopoverPrimitive.Root;
const Close = PopoverPrimitive.Close;
import Portal from './popover-portal.svelte';
export {
Root,
Content,
Description,
Header,
Title,
Trigger,
Close,
Portal,
//
Root as Popover,
Content as PopoverContent,
Description as PopoverDescription,
Header as PopoverHeader,
Title as PopoverTitle,
Trigger as PopoverTrigger,
Close as PopoverClose
Close as PopoverClose,
Portal as PopoverPortal
};

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
</script>
<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} />

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import { cn } from '$lib/utils/style.js';
import { cn, type WithoutChildrenOrChild } from '$lib/utils/style.js';
import { Popover as PopoverPrimitive } from 'bits-ui';
import type { ComponentProps } from 'svelte';
import PopoverPortal from './popover-portal.svelte';
let {
ref = $bindable(null),
@@ -11,22 +13,22 @@
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
portalProps?: PopoverPrimitive.PortalProps;
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
sameWidth?: boolean;
} = $props();
</script>
<PopoverPrimitive.Portal {...portalProps}>
<PopoverPortal {...portalProps}>
<PopoverPrimitive.Content
bind:ref
data-slot="popover-content"
{sideOffset}
{align}
class={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 flex flex-col gap-2.5 rounded-sm p-2.5 text-sm shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 w-72 origin-(--transform-origin) outline-hidden',
sameWidth && 'w-[var(--bits-popover-anchor-width)]',
className
)}
{...restProps}
/>
</PopoverPrimitive.Portal>
</PopoverPortal>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils/style.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="popover-description"
class={cn('text-muted-foreground', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils/style.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="popover-header"
class={cn('flex flex-col gap-0.5 text-sm', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from 'bits-ui';
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
</script>
<PopoverPrimitive.Portal {...restProps} />

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