mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-16 09:13:20 +03:00
feat: oidc client data preview (#624)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
@@ -48,6 +48,8 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
|
|||||||
group.DELETE("/oidc/clients/:id/logo", oc.deleteClientLogoHandler)
|
group.DELETE("/oidc/clients/:id/logo", oc.deleteClientLogoHandler)
|
||||||
group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler)
|
group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler)
|
||||||
|
|
||||||
|
group.GET("/oidc/clients/:id/preview/:userId", authMiddleware.Add(), oc.getClientPreviewHandler)
|
||||||
|
|
||||||
group.POST("/oidc/device/authorize", oc.deviceAuthorizationHandler)
|
group.POST("/oidc/device/authorize", oc.deviceAuthorizationHandler)
|
||||||
group.POST("/oidc/device/verify", authMiddleware.WithAdminNotRequired().Add(), oc.verifyDeviceCodeHandler)
|
group.POST("/oidc/device/verify", authMiddleware.WithAdminNotRequired().Add(), oc.verifyDeviceCodeHandler)
|
||||||
group.GET("/oidc/device/info", authMiddleware.WithAdminNotRequired().Add(), oc.getDeviceCodeInfoHandler)
|
group.GET("/oidc/device/info", authMiddleware.WithAdminNotRequired().Add(), oc.getDeviceCodeInfoHandler)
|
||||||
@@ -721,3 +723,43 @@ func (oc *OidcController) getDeviceCodeInfoHandler(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, deviceCodeInfo)
|
c.JSON(http.StatusOK, deviceCodeInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getClientPreviewHandler godoc
|
||||||
|
// @Summary Preview OIDC client data for user
|
||||||
|
// @Description Get a preview of the OIDC data (ID token, access token, userinfo) that would be sent to the client for a specific user
|
||||||
|
// @Tags OIDC
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Client ID"
|
||||||
|
// @Param userId path string true "User ID to preview data for"
|
||||||
|
// @Param scopes query string false "Scopes to include in the preview (comma-separated)"
|
||||||
|
// @Success 200 {object} dto.OidcClientPreviewDto "Preview data including ID token, access token, and userinfo payloads"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/oidc/clients/{id}/preview/{userId} [get]
|
||||||
|
func (oc *OidcController) getClientPreviewHandler(c *gin.Context) {
|
||||||
|
clientID := c.Param("id")
|
||||||
|
userID := c.Param("userId")
|
||||||
|
scopes := c.Query("scopes")
|
||||||
|
|
||||||
|
if clientID == "" {
|
||||||
|
_ = c.Error(&common.ValidationError{Message: "client ID is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if userID == "" {
|
||||||
|
_ = c.Error(&common.ValidationError{Message: "user ID is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if scopes == "" {
|
||||||
|
_ = c.Error(&common.ValidationError{Message: "scopes are required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
preview, err := oc.oidcService.GetClientPreview(c.Request.Context(), clientID, userID, scopes)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, preview)
|
||||||
|
}
|
||||||
|
|||||||
@@ -145,3 +145,9 @@ type AuthorizedOidcClientDto struct {
|
|||||||
Scope string `json:"scope"`
|
Scope string `json:"scope"`
|
||||||
Client OidcClientMetaDataDto `json:"client"`
|
Client OidcClientMetaDataDto `json:"client"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OidcClientPreviewDto struct {
|
||||||
|
IdToken map[string]interface{} `json:"idToken"`
|
||||||
|
AccessToken map[string]interface{} `json:"accessToken"`
|
||||||
|
UserInfo map[string]interface{} `json:"userInfo"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -234,7 +234,8 @@ func (s *JwtService) VerifyAccessToken(tokenString string) (jwt.Token, error) {
|
|||||||
return token, nil
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string, nonce string) (string, error) {
|
// BuildIDToken creates an ID token with all claims
|
||||||
|
func (s *JwtService) BuildIDToken(userClaims map[string]any, clientID string, nonce string) (jwt.Token, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
token, err := jwt.NewBuilder().
|
token, err := jwt.NewBuilder().
|
||||||
Expiration(now.Add(1 * time.Hour)).
|
Expiration(now.Add(1 * time.Hour)).
|
||||||
@@ -242,33 +243,43 @@ func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string,
|
|||||||
Issuer(common.EnvConfig.AppURL).
|
Issuer(common.EnvConfig.AppURL).
|
||||||
Build()
|
Build()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to build token: %w", err)
|
return nil, fmt.Errorf("failed to build token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = SetAudienceString(token, clientID)
|
err = SetAudienceString(token, clientID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err)
|
return nil, fmt.Errorf("failed to set 'aud' claim in token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = SetTokenType(token, IDTokenJWTType)
|
err = SetTokenType(token, IDTokenJWTType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to set 'type' claim in token: %w", err)
|
return nil, fmt.Errorf("failed to set 'type' claim in token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for k, v := range userClaims {
|
for k, v := range userClaims {
|
||||||
err = token.Set(k, v)
|
err = token.Set(k, v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to set claim '%s': %w", k, err)
|
return nil, fmt.Errorf("failed to set claim '%s': %w", k, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if nonce != "" {
|
if nonce != "" {
|
||||||
err = token.Set("nonce", nonce)
|
err = token.Set("nonce", nonce)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to set claim 'nonce': %w", err)
|
return nil, fmt.Errorf("failed to set claim 'nonce': %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateIDToken creates and signs an ID token
|
||||||
|
func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string, nonce string) (string, error) {
|
||||||
|
token, err := s.BuildIDToken(userClaims, clientID, nonce)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
alg, _ := s.privateKey.Algorithm()
|
alg, _ := s.privateKey.Algorithm()
|
||||||
signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey))
|
signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -311,7 +322,8 @@ func (s *JwtService) VerifyIdToken(tokenString string, acceptExpiredTokens bool)
|
|||||||
return token, nil
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) {
|
// BuildOauthAccessToken creates an OAuth access token with all claims
|
||||||
|
func (s *JwtService) BuildOauthAccessToken(user model.User, clientID string) (jwt.Token, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
token, err := jwt.NewBuilder().
|
token, err := jwt.NewBuilder().
|
||||||
Subject(user.ID).
|
Subject(user.ID).
|
||||||
@@ -320,17 +332,27 @@ func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string)
|
|||||||
Issuer(common.EnvConfig.AppURL).
|
Issuer(common.EnvConfig.AppURL).
|
||||||
Build()
|
Build()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to build token: %w", err)
|
return nil, fmt.Errorf("failed to build token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = SetAudienceString(token, clientID)
|
err = SetAudienceString(token, clientID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err)
|
return nil, fmt.Errorf("failed to set 'aud' claim in token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = SetTokenType(token, OAuthAccessTokenJWTType)
|
err = SetTokenType(token, OAuthAccessTokenJWTType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to set 'type' claim in token: %w", err)
|
return nil, fmt.Errorf("failed to set 'type' claim in token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateOauthAccessToken creates and signs an OAuth access token
|
||||||
|
func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) {
|
||||||
|
token, err := s.BuildOauthAccessToken(user, clientID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
alg, _ := s.privateKey.Algorithm()
|
alg, _ := s.privateKey.Algorithm()
|
||||||
|
|||||||
@@ -841,97 +841,6 @@ func (s *OidcService) DeleteClientLogo(ctx context.Context, clientID string) err
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OidcService) GetUserClaimsForClient(ctx context.Context, userID string, clientID string) (map[string]interface{}, error) {
|
|
||||||
tx := s.db.Begin()
|
|
||||||
defer func() {
|
|
||||||
tx.Rollback()
|
|
||||||
}()
|
|
||||||
|
|
||||||
claims, err := s.getUserClaimsForClientInternal(ctx, userID, clientID, s.db)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = tx.Commit().Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return claims, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *OidcService) getUserClaimsForClientInternal(ctx context.Context, userID string, clientID string, tx *gorm.DB) (map[string]interface{}, error) {
|
|
||||||
var authorizedOidcClient model.UserAuthorizedOidcClient
|
|
||||||
err := tx.
|
|
||||||
WithContext(ctx).
|
|
||||||
Preload("User.UserGroups").
|
|
||||||
First(&authorizedOidcClient, "user_id = ? AND client_id = ?", userID, clientID).
|
|
||||||
Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
user := authorizedOidcClient.User
|
|
||||||
scopes := strings.Split(authorizedOidcClient.Scope, " ")
|
|
||||||
|
|
||||||
claims := map[string]interface{}{
|
|
||||||
"sub": user.ID,
|
|
||||||
}
|
|
||||||
|
|
||||||
if slices.Contains(scopes, "email") {
|
|
||||||
claims["email"] = user.Email
|
|
||||||
claims["email_verified"] = s.appConfigService.GetDbConfig().EmailsVerified.IsTrue()
|
|
||||||
}
|
|
||||||
|
|
||||||
if slices.Contains(scopes, "groups") {
|
|
||||||
userGroups := make([]string, len(user.UserGroups))
|
|
||||||
for i, group := range user.UserGroups {
|
|
||||||
userGroups[i] = group.Name
|
|
||||||
}
|
|
||||||
claims["groups"] = userGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
profileClaims := map[string]interface{}{
|
|
||||||
"given_name": user.FirstName,
|
|
||||||
"family_name": user.LastName,
|
|
||||||
"name": user.FullName(),
|
|
||||||
"preferred_username": user.Username,
|
|
||||||
"picture": common.EnvConfig.AppURL + "/api/users/" + user.ID + "/profile-picture.png",
|
|
||||||
}
|
|
||||||
|
|
||||||
if slices.Contains(scopes, "profile") {
|
|
||||||
// Add profile claims
|
|
||||||
for k, v := range profileClaims {
|
|
||||||
claims[k] = v
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add custom claims
|
|
||||||
customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(ctx, userID, 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 interface{}
|
|
||||||
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 {
|
|
||||||
// Marshalling failed, so we store it as a string
|
|
||||||
claims[customClaim.Key] = customClaim.Value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if slices.Contains(scopes, "email") {
|
|
||||||
claims["email"] = user.Email
|
|
||||||
}
|
|
||||||
|
|
||||||
return claims, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *OidcService) UpdateAllowedUserGroups(ctx context.Context, id string, input dto.OidcUpdateAllowedUserGroupsDto) (client model.OidcClient, err error) {
|
func (s *OidcService) UpdateAllowedUserGroups(ctx context.Context, id string, input dto.OidcUpdateAllowedUserGroupsDto) (client model.OidcClient, err error) {
|
||||||
tx := s.db.Begin()
|
tx := s.db.Begin()
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -1519,3 +1428,168 @@ func (s *OidcService) verifyClientAssertionFromFederatedIdentities(ctx context.C
|
|||||||
// If we're here, the assertion is valid
|
// If we're here, the assertion is valid
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, userID string, scopes string) (*dto.OidcClientPreviewDto, error) {
|
||||||
|
tx := s.db.Begin()
|
||||||
|
defer func() {
|
||||||
|
tx.Rollback()
|
||||||
|
}()
|
||||||
|
|
||||||
|
client, err := s.getClientInternal(ctx, clientID, tx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var user model.User
|
||||||
|
err = tx.
|
||||||
|
WithContext(ctx).
|
||||||
|
Preload("UserGroups").
|
||||||
|
First(&user, "id = ?", userID).
|
||||||
|
Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.IsUserGroupAllowedToAuthorize(user, client) {
|
||||||
|
return nil, &common.OidcAccessDeniedError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
dummyAuthorizedClient := model.UserAuthorizedOidcClient{
|
||||||
|
UserID: userID,
|
||||||
|
ClientID: clientID,
|
||||||
|
Scope: scopes,
|
||||||
|
User: user,
|
||||||
|
}
|
||||||
|
|
||||||
|
userClaims, err := s.getUserClaimsFromAuthorizedClient(ctx, &dummyAuthorizedClient, tx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
idToken, err := s.jwtService.BuildIDToken(userClaims, clientID, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, err := s.jwtService.BuildOauthAccessToken(user, clientID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
idTokenPayload, err := utils.GetClaimsFromToken(idToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
accessTokenPayload, err := utils.GetClaimsFromToken(accessToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit().Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.OidcClientPreviewDto{
|
||||||
|
IdToken: idTokenPayload,
|
||||||
|
AccessToken: accessTokenPayload,
|
||||||
|
UserInfo: userClaims,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) GetUserClaimsForClient(ctx context.Context, userID string, clientID string) (map[string]interface{}, error) {
|
||||||
|
tx := s.db.Begin()
|
||||||
|
defer func() {
|
||||||
|
tx.Rollback()
|
||||||
|
}()
|
||||||
|
|
||||||
|
claims, err := s.getUserClaimsForClientInternal(ctx, userID, clientID, s.db)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit().Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) getUserClaimsForClientInternal(ctx context.Context, userID string, clientID string, tx *gorm.DB) (map[string]interface{}, error) {
|
||||||
|
var authorizedOidcClient model.UserAuthorizedOidcClient
|
||||||
|
err := tx.
|
||||||
|
WithContext(ctx).
|
||||||
|
Preload("User.UserGroups").
|
||||||
|
First(&authorizedOidcClient, "user_id = ? AND client_id = ?", userID, clientID).
|
||||||
|
Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.getUserClaimsFromAuthorizedClient(ctx, &authorizedOidcClient, tx)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) getUserClaimsFromAuthorizedClient(ctx context.Context, authorizedClient *model.UserAuthorizedOidcClient, tx *gorm.DB) (map[string]interface{}, error) {
|
||||||
|
user := authorizedClient.User
|
||||||
|
scopes := strings.Split(authorizedClient.Scope, " ")
|
||||||
|
|
||||||
|
claims := map[string]interface{}{
|
||||||
|
"sub": user.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if slices.Contains(scopes, "email") {
|
||||||
|
claims["email"] = user.Email
|
||||||
|
claims["email_verified"] = s.appConfigService.GetDbConfig().EmailsVerified.IsTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
if slices.Contains(scopes, "groups") {
|
||||||
|
userGroups := make([]string, len(user.UserGroups))
|
||||||
|
for i, group := range user.UserGroups {
|
||||||
|
userGroups[i] = group.Name
|
||||||
|
}
|
||||||
|
claims["groups"] = userGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
profileClaims := map[string]interface{}{
|
||||||
|
"given_name": user.FirstName,
|
||||||
|
"family_name": user.LastName,
|
||||||
|
"name": user.FullName(),
|
||||||
|
"preferred_username": user.Username,
|
||||||
|
"picture": common.EnvConfig.AppURL + "/api/users/" + user.ID + "/profile-picture.png",
|
||||||
|
}
|
||||||
|
|
||||||
|
if slices.Contains(scopes, "profile") {
|
||||||
|
// Add profile claims
|
||||||
|
for k, v := range profileClaims {
|
||||||
|
claims[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add custom claims
|
||||||
|
customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(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 interface{}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if slices.Contains(scopes, "email") {
|
||||||
|
claims["email"] = user.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|||||||
19
backend/internal/utils/jwt_util.go
Normal file
19
backend/internal/utils/jwt_util.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetClaimsFromToken(token jwt.Token) (map[string]any, error) {
|
||||||
|
claims := make(map[string]any)
|
||||||
|
for _, key := range token.Keys() {
|
||||||
|
var value any
|
||||||
|
if err := token.Get(key, &value); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get claim %s: %w", key, err)
|
||||||
|
}
|
||||||
|
claims[key] = value
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
@@ -353,5 +353,23 @@
|
|||||||
"oidc_allowed_group_count": "Allowed Group Count",
|
"oidc_allowed_group_count": "Allowed Group Count",
|
||||||
"unrestricted": "Unrestricted",
|
"unrestricted": "Unrestricted",
|
||||||
"show_advanced_options": "Show Advanced Options",
|
"show_advanced_options": "Show Advanced Options",
|
||||||
"hide_advanced_options": "Hide Advanced Options"
|
"hide_advanced_options": "Hide Advanced Options",
|
||||||
|
"oidc_data_preview": "OIDC Data Preview",
|
||||||
|
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
|
||||||
|
"id_token": "ID Token",
|
||||||
|
"access_token": "Access Token",
|
||||||
|
"userinfo": "Userinfo",
|
||||||
|
"id_token_payload": "ID Token Payload",
|
||||||
|
"access_token_payload": "Access Token Payload",
|
||||||
|
"userinfo_endpoint_response": "Userinfo Endpoint Response",
|
||||||
|
"copy": "Copy",
|
||||||
|
"no_preview_data_available": "No preview data available",
|
||||||
|
"copy_all": "Copy All",
|
||||||
|
"preview": "Preview",
|
||||||
|
"preview_for_user": "Preview for {name} ({email})",
|
||||||
|
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
|
||||||
|
"show": "Show",
|
||||||
|
"select_an_option": "Select an option",
|
||||||
|
"select_user": "Select User",
|
||||||
|
"error": "Error"
|
||||||
}
|
}
|
||||||
|
|||||||
56
frontend/src/lib/components/form/multi-select.svelte
Normal file
56
frontend/src/lib/components/form/multi-select.svelte
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||||
|
import { LucideChevronDown } from '@lucide/svelte';
|
||||||
|
import { Badge } from '../ui/badge';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
|
||||||
|
let {
|
||||||
|
items,
|
||||||
|
selectedItems = $bindable(),
|
||||||
|
onSelect,
|
||||||
|
autoClose = false
|
||||||
|
}: {
|
||||||
|
items: {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}[];
|
||||||
|
selectedItems: string[];
|
||||||
|
onSelect?: (value: string) => void;
|
||||||
|
autoClose?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function handleItemSelect(value: string) {
|
||||||
|
if (selectedItems.includes(value)) {
|
||||||
|
selectedItems = selectedItems.filter((item) => item !== value);
|
||||||
|
} else {
|
||||||
|
selectedItems = [...selectedItems, value];
|
||||||
|
}
|
||||||
|
onSelect?.(value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<Button {...props} variant="outline">
|
||||||
|
{#each items.filter((item) => selectedItems.includes(item.value)) as item}
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{item.label}
|
||||||
|
</Badge>
|
||||||
|
{/each}
|
||||||
|
<LucideChevronDown class="text-muted-foreground ml-2 size-4" />
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content align="start" class="w-[var(--bits-dropdown-menu-anchor-width)]">
|
||||||
|
{#each items as item}
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
checked={selectedItems.includes(item.value)}
|
||||||
|
onCheckedChange={() => handleItemSelect(item.value)}
|
||||||
|
closeOnSelect={autoClose}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
{/each}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
@@ -2,15 +2,19 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Command from '$lib/components/ui/command';
|
import * as Command from '$lib/components/ui/command';
|
||||||
import * as Popover from '$lib/components/ui/popover';
|
import * as Popover from '$lib/components/ui/popover';
|
||||||
|
import { m } from '$lib/paraglide/messages';
|
||||||
import { cn } from '$lib/utils/style';
|
import { cn } from '$lib/utils/style';
|
||||||
import { LucideCheck, LucideChevronDown } from '@lucide/svelte';
|
import { LoaderCircle, LucideCheck, LucideChevronDown } from '@lucide/svelte';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
import type { FormEventHandler, HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
items,
|
items,
|
||||||
value = $bindable(),
|
value = $bindable(),
|
||||||
onSelect,
|
onSelect,
|
||||||
|
oninput,
|
||||||
|
isLoading,
|
||||||
|
selectText = m.select_an_option(),
|
||||||
...restProps
|
...restProps
|
||||||
}: HTMLAttributes<HTMLButtonElement> & {
|
}: HTMLAttributes<HTMLButtonElement> & {
|
||||||
items: {
|
items: {
|
||||||
@@ -18,7 +22,10 @@
|
|||||||
label: string;
|
label: string;
|
||||||
}[];
|
}[];
|
||||||
value: string;
|
value: string;
|
||||||
|
oninput?: FormEventHandler<HTMLInputElement>;
|
||||||
onSelect?: (value: string) => void;
|
onSelect?: (value: string) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
selectText?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
@@ -53,21 +60,35 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Popover.Root bind:open {...restProps}>
|
<Popover.Root bind:open {...restProps}>
|
||||||
<Popover.Trigger class="w-full">
|
<Popover.Trigger>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
class={cn('justify-between', restProps.class)}
|
class={cn('justify-between', restProps.class)}
|
||||||
>
|
>
|
||||||
{items.find((item) => item.value === value)?.label || 'Select an option'}
|
{items.find((item) => item.value === value)?.label || selectText}
|
||||||
<LucideChevronDown class="ml-2 size-4 shrink-0 opacity-50" />
|
<LucideChevronDown class="ml-2 size-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
<Popover.Content class="p-0">
|
<Popover.Content class="p-0" sameWidth>
|
||||||
<Command.Root shouldFilter={false}>
|
<Command.Root shouldFilter={false}>
|
||||||
<Command.Input placeholder="Search..." oninput={(e: any) => filterItems(e.target.value)} />
|
<Command.Input
|
||||||
<Command.Empty>No results found.</Command.Empty>
|
placeholder={m.search()}
|
||||||
|
oninput={(e) => {
|
||||||
|
filterItems(e.currentTarget.value);
|
||||||
|
oninput?.(e);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Command.Empty>
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="flex w-full justify-center">
|
||||||
|
<LoaderCircle class="size-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{m.no_items_found()}
|
||||||
|
{/if}
|
||||||
|
</Command.Empty>
|
||||||
<Command.Group>
|
<Command.Group>
|
||||||
{#each filteredItems as item}
|
{#each filteredItems as item}
|
||||||
<Command.Item
|
<Command.Item
|
||||||
|
|||||||
@@ -103,6 +103,13 @@ class OidcService extends APIService {
|
|||||||
const response = await this.api.get(`/oidc/device/info?code=${userCode}`);
|
const response = await this.api.get(`/oidc/device/info?code=${userCode}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getClientPreview(id: string, userId: string, scopes: string) {
|
||||||
|
const response = await this.api.get(`/oidc/clients/${id}/preview/${userId}`, {
|
||||||
|
params: { scopes }
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default OidcService;
|
export default OidcService;
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
export function debounced<T extends (...args: any[]) => void>(func: T, delay: number) {
|
export function debounced<T extends (...args: any[]) => any>(
|
||||||
|
func: T,
|
||||||
|
delay: number,
|
||||||
|
onLoadingChange?: (loading: boolean) => void
|
||||||
|
) {
|
||||||
let debounceTimeout: ReturnType<typeof setTimeout>;
|
let debounceTimeout: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
return (...args: Parameters<T>) => {
|
return (...args: Parameters<T>) => {
|
||||||
@@ -6,8 +10,14 @@ export function debounced<T extends (...args: any[]) => void>(func: T, delay: nu
|
|||||||
clearTimeout(debounceTimeout);
|
clearTimeout(debounceTimeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
debounceTimeout = setTimeout(() => {
|
onLoadingChange?.(true);
|
||||||
func(...args);
|
|
||||||
|
debounceTimeout = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await func(...args);
|
||||||
|
} finally {
|
||||||
|
onLoadingChange?.(false);
|
||||||
|
}
|
||||||
}, delay);
|
}, delay);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,11 @@
|
|||||||
import clientSecretStore from '$lib/stores/client-secret-store';
|
import clientSecretStore from '$lib/stores/client-secret-store';
|
||||||
import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type';
|
import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type';
|
||||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
import { LucideChevronLeft, LucideRefreshCcw } from '@lucide/svelte';
|
import { LucideChevronLeft, LucideRefreshCcw, RectangleEllipsis } from '@lucide/svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import OidcForm from '../oidc-client-form.svelte';
|
import OidcForm from '../oidc-client-form.svelte';
|
||||||
|
import OidcClientPreviewModal from '../oidc-client-preview-modal.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
let client = $state({
|
let client = $state({
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
allowedUserGroupIds: data.allowedUserGroups.map((g) => g.id)
|
allowedUserGroupIds: data.allowedUserGroups.map((g) => g.id)
|
||||||
});
|
});
|
||||||
let showAllDetails = $state(false);
|
let showAllDetails = $state(false);
|
||||||
|
let showPreview = $state(false);
|
||||||
|
|
||||||
const oidcService = new OidcService();
|
const oidcService = new OidcService();
|
||||||
|
|
||||||
@@ -91,6 +93,12 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let previewUserId = $state<string | null>(null);
|
||||||
|
|
||||||
|
function handlePreview(userId: string) {
|
||||||
|
previewUserId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
beforeNavigate(() => {
|
beforeNavigate(() => {
|
||||||
clientSecretStore.clear();
|
clientSecretStore.clear();
|
||||||
});
|
});
|
||||||
@@ -180,3 +188,22 @@
|
|||||||
<Button onclick={() => updateUserGroupClients(client.allowedUserGroupIds)}>{m.save()}</Button>
|
<Button onclick={() => updateUserGroupClients(client.allowedUserGroupIds)}>{m.save()}</Button>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleCard>
|
</CollapsibleCard>
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<div class="flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center">
|
||||||
|
<div>
|
||||||
|
<Card.Title>
|
||||||
|
{m.oidc_data_preview()}
|
||||||
|
</Card.Title>
|
||||||
|
<Card.Description>
|
||||||
|
{m.preview_the_oidc_data_that_would_be_sent_for_different_users()}
|
||||||
|
</Card.Description>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="outline" onclick={() => (showPreview = true)}>
|
||||||
|
{m.show()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
</Card.Root>
|
||||||
|
<OidcClientPreviewModal bind:open={showPreview} clientId={client.id} />
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
|
||||||
|
import MultiSelect from '$lib/components/form/multi-select.svelte';
|
||||||
|
import SearchableSelect from '$lib/components/form/searchable-select.svelte';
|
||||||
|
import * as Alert from '$lib/components/ui/alert';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
|
import Label from '$lib/components/ui/label/label.svelte';
|
||||||
|
import * as Tabs from '$lib/components/ui/tabs';
|
||||||
|
import { m } from '$lib/paraglide/messages';
|
||||||
|
import OidcService from '$lib/services/oidc-service';
|
||||||
|
import UserService from '$lib/services/user-service';
|
||||||
|
import type { User } from '$lib/types/user.type';
|
||||||
|
import { debounced } from '$lib/utils/debounce-util';
|
||||||
|
import { getAxiosErrorMessage } from '$lib/utils/error-util';
|
||||||
|
import { LucideAlertTriangle } from '@lucide/svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(),
|
||||||
|
clientId
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
clientId: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const oidcService = new OidcService();
|
||||||
|
const userService = new UserService();
|
||||||
|
|
||||||
|
let previewData = $state<{
|
||||||
|
idToken?: any;
|
||||||
|
accessToken?: any;
|
||||||
|
userInfo?: any;
|
||||||
|
} | null>(null);
|
||||||
|
let loadingPreview = $state(false);
|
||||||
|
let isUserSearchLoading = $state(false);
|
||||||
|
let user: User | null = $state(null);
|
||||||
|
let users: User[] = $state([]);
|
||||||
|
let scopes: string[] = $state(['openid', 'email', 'profile']);
|
||||||
|
let errorMessage: string | null = $state(null);
|
||||||
|
|
||||||
|
async function loadPreviewData() {
|
||||||
|
errorMessage = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
previewData = await oidcService.getClientPreview(clientId, user!.id, scopes.join(' '));
|
||||||
|
} catch (e) {
|
||||||
|
const error = getAxiosErrorMessage(e);
|
||||||
|
errorMessage = error;
|
||||||
|
previewData = null;
|
||||||
|
} finally {
|
||||||
|
loadingPreview = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers(search?: string) {
|
||||||
|
users = (
|
||||||
|
await userService.list({
|
||||||
|
search,
|
||||||
|
pagination: { limit: 10, page: 1 }
|
||||||
|
})
|
||||||
|
).data;
|
||||||
|
if (!user) {
|
||||||
|
user = users[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onOpenChange(open: boolean) {
|
||||||
|
if (!open) {
|
||||||
|
previewData = null;
|
||||||
|
errorMessage = null;
|
||||||
|
} else {
|
||||||
|
loadingPreview = true;
|
||||||
|
await loadPreviewData().finally(() => {
|
||||||
|
loadingPreview = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUserSearch = debounced(
|
||||||
|
async (search: string) => await loadUsers(search),
|
||||||
|
300,
|
||||||
|
(loading) => (isUserSearchLoading = loading)
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (open) {
|
||||||
|
loadPreviewData();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadUsers();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open {onOpenChange}>
|
||||||
|
<Dialog.Content class="sm-min-w[500px] max-h-[90vh] min-w-[90vw] overflow-auto lg:min-w-[1000px]">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>{m.oidc_data_preview()}</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
{#if user}
|
||||||
|
{m.preview_for_user({ name: user.firstName + ' ' + user.lastName, email: user.email })}
|
||||||
|
{:else}
|
||||||
|
{m.preview_the_oidc_data_that_would_be_sent_for_this_user()}
|
||||||
|
{/if}
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<div class="overflow-auto px-4">
|
||||||
|
{#if loadingPreview}
|
||||||
|
<div class="flex items-center justify-center py-12">
|
||||||
|
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-gray-900"></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex justify-start gap-3">
|
||||||
|
<div>
|
||||||
|
<Label class="text-sm font-medium">{m.users()}</Label>
|
||||||
|
<div>
|
||||||
|
<SearchableSelect
|
||||||
|
class="w-48"
|
||||||
|
selectText={m.select_user()}
|
||||||
|
isLoading={isUserSearchLoading}
|
||||||
|
items={Object.values(users).map((user) => ({
|
||||||
|
value: user.id,
|
||||||
|
label: user.username
|
||||||
|
}))}
|
||||||
|
value={user?.id || ''}
|
||||||
|
oninput={(e) => onUserSearch(e.currentTarget.value)}
|
||||||
|
onSelect={(value) => {
|
||||||
|
user = users.find((u) => u.id === value) || null;
|
||||||
|
loadPreviewData();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label class="text-sm font-medium">Scopes</Label>
|
||||||
|
<MultiSelect
|
||||||
|
items={[
|
||||||
|
{ value: 'openid', label: 'openid' },
|
||||||
|
{ value: 'email', label: 'email' },
|
||||||
|
{ value: 'profile', label: 'profile' },
|
||||||
|
{ value: 'groups', label: 'groups' }
|
||||||
|
]}
|
||||||
|
bind:selectedItems={scopes}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if errorMessage && !loadingPreview}
|
||||||
|
<Alert.Root variant="destructive" class="mt-5 mb-6">
|
||||||
|
<LucideAlertTriangle class="h-4 w-4" />
|
||||||
|
<Alert.Title>{m.error()}</Alert.Title>
|
||||||
|
<Alert.Description>
|
||||||
|
{errorMessage}
|
||||||
|
</Alert.Description>
|
||||||
|
</Alert.Root>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if previewData && !loadingPreview}
|
||||||
|
<Tabs.Root value="id-token" class="mt-5 w-full">
|
||||||
|
<Tabs.List class="mb-6 grid w-full grid-cols-3">
|
||||||
|
<Tabs.Trigger value="id-token">{m.id_token()}</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="access-token">{m.access_token()}</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="userinfo">{m.userinfo()}</Tabs.Trigger>
|
||||||
|
</Tabs.List>
|
||||||
|
<Tabs.Content value="id-token">
|
||||||
|
{@render tabContent(previewData.idToken, m.id_token_payload())}
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
<Tabs.Content value="access-token" class="mt-4">
|
||||||
|
{@render tabContent(previewData.accessToken, m.access_token_payload())}
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
<Tabs.Content value="userinfo" class="mt-4">
|
||||||
|
{@render tabContent(previewData.userInfo, m.userinfo_endpoint_response())}
|
||||||
|
</Tabs.Content>
|
||||||
|
</Tabs.Root>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
|
{#snippet tabContent(data: any, title: string)}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<Label class="text-lg font-semibold">{title}</Label>
|
||||||
|
<CopyToClipboard value={JSON.stringify(data, null, 2)}>
|
||||||
|
<Button size="sm" variant="outline">{m.copy_all()}</Button>
|
||||||
|
</CopyToClipboard>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each Object.entries(data || {}) as [key, value]}
|
||||||
|
<div class="grid grid-cols-1 items-start gap-4 border-b pb-3 md:grid-cols-[200px_1fr]">
|
||||||
|
<Label class="pt-1 text-sm font-medium">{key}</Label>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<CopyToClipboard value={typeof value === 'string' ? value : JSON.stringify(value)}>
|
||||||
|
<div
|
||||||
|
class="text-muted-foreground bg-muted/30 hover:bg-muted/50 cursor-pointer rounded px-3 py-2 font-mono text-sm"
|
||||||
|
>
|
||||||
|
{typeof value === 'object' ? JSON.stringify(value, null, 2) : value}
|
||||||
|
</div>
|
||||||
|
</CopyToClipboard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
Reference in New Issue
Block a user