mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-02-04 11:36:46 +00:00
feat: user application dashboard (#727)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
@@ -57,6 +57,9 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
|
|||||||
|
|
||||||
group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAuthorizedClientsHandler)
|
group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAuthorizedClientsHandler)
|
||||||
group.GET("/oidc/users/:id/clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler)
|
group.GET("/oidc/users/:id/clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler)
|
||||||
|
|
||||||
|
group.DELETE("/oidc/users/me/clients/:clientId", authMiddleware.WithAdminNotRequired().Add(), oc.revokeOwnClientAuthorizationHandler)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcController struct {
|
type OidcController struct {
|
||||||
@@ -704,6 +707,27 @@ func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// revokeOwnClientAuthorizationHandler godoc
|
||||||
|
// @Summary Revoke authorization for an OIDC client
|
||||||
|
// @Description Revoke the authorization for a specific OIDC client for the current user
|
||||||
|
// @Tags OIDC
|
||||||
|
// @Param clientId path string true "Client ID to revoke authorization for"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /api/oidc/users/me/clients/{clientId} [delete]
|
||||||
|
func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) {
|
||||||
|
clientID := c.Param("clientId")
|
||||||
|
|
||||||
|
userID := c.GetString("userID")
|
||||||
|
|
||||||
|
err := oc.oidcService.RevokeAuthorizedClient(c.Request.Context(), userID, clientID)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
func (oc *OidcController) verifyDeviceCodeHandler(c *gin.Context) {
|
func (oc *OidcController) verifyDeviceCodeHandler(c *gin.Context) {
|
||||||
userCode := c.Query("code")
|
userCode := c.Query("code")
|
||||||
if userCode == "" {
|
if userCode == "" {
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ import (
|
|||||||
|
|
||||||
type ApiKeyCreateDto struct {
|
type ApiKeyCreateDto struct {
|
||||||
Name string `json:"name" binding:"required,min=3,max=50" unorm:"nfc"`
|
Name string `json:"name" binding:"required,min=3,max=50" unorm:"nfc"`
|
||||||
Description string `json:"description" unorm:"nfc"`
|
Description *string `json:"description" unorm:"nfc"`
|
||||||
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
|
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiKeyDto struct {
|
type ApiKeyDto struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description *string `json:"description"`
|
||||||
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
||||||
LastUsedAt *datatype.DateTime `json:"lastUsedAt"`
|
LastUsedAt *datatype.DateTime `json:"lastUsedAt"`
|
||||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
|
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
|
||||||
type OidcClientMetaDataDto struct {
|
type OidcClientMetaDataDto struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
HasLogo bool `json:"hasLogo"`
|
HasLogo bool `json:"hasLogo"`
|
||||||
|
LaunchURL *string `json:"launchURL"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcClientDto struct {
|
type OidcClientDto struct {
|
||||||
@@ -32,6 +35,7 @@ type OidcClientCreateDto struct {
|
|||||||
IsPublic bool `json:"isPublic"`
|
IsPublic bool `json:"isPublic"`
|
||||||
PkceEnabled bool `json:"pkceEnabled"`
|
PkceEnabled bool `json:"pkceEnabled"`
|
||||||
Credentials OidcClientCredentialsDto `json:"credentials"`
|
Credentials OidcClientCredentialsDto `json:"credentials"`
|
||||||
|
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcClientCredentialsDto struct {
|
type OidcClientCredentialsDto struct {
|
||||||
@@ -145,8 +149,9 @@ type DeviceCodeInfoDto struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AuthorizedOidcClientDto struct {
|
type AuthorizedOidcClientDto struct {
|
||||||
Scope string `json:"scope"`
|
Scope string `json:"scope"`
|
||||||
Client OidcClientMetaDataDto `json:"client"`
|
Client OidcClientMetaDataDto `json:"client"`
|
||||||
|
LastUsedAt datatype.DateTime `json:"lastUsedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcClientPreviewDto struct {
|
type OidcClientPreviewDto struct {
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type UserAuthorizedOidcClient struct {
|
type UserAuthorizedOidcClient struct {
|
||||||
Scope string
|
Scope string
|
||||||
|
LastUsedAt datatype.DateTime `sortable:"true"`
|
||||||
|
|
||||||
UserID string `gorm:"primary_key;"`
|
UserID string `gorm:"primary_key;"`
|
||||||
User User
|
User User
|
||||||
|
|
||||||
@@ -47,6 +49,7 @@ type OidcClient struct {
|
|||||||
IsPublic bool
|
IsPublic bool
|
||||||
PkceEnabled bool
|
PkceEnabled bool
|
||||||
Credentials OidcClientCredentials
|
Credentials OidcClientCredentials
|
||||||
|
LaunchURL *string
|
||||||
|
|
||||||
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
|
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
|
||||||
CreatedByID string
|
CreatedByID string
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ func (s *ApiKeyService) CreateApiKey(ctx context.Context, userID string, input d
|
|||||||
apiKey := model.ApiKey{
|
apiKey := model.ApiKey{
|
||||||
Name: input.Name,
|
Name: input.Name,
|
||||||
Key: utils.CreateSha256Hash(token), // Hash the token for storage
|
Key: utils.CreateSha256Hash(token), // Hash the token for storage
|
||||||
Description: &input.Description,
|
Description: input.Description,
|
||||||
ExpiresAt: datatype.DateTime(input.ExpiresAt),
|
ExpiresAt: input.ExpiresAt,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
|||||||
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
|
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
|
||||||
},
|
},
|
||||||
Name: "Nextcloud",
|
Name: "Nextcloud",
|
||||||
|
LaunchURL: utils.Ptr("https://nextcloud.local"),
|
||||||
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
|
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
|
||||||
CallbackURLs: model.UrlList{"http://nextcloud/auth/callback"},
|
CallbackURLs: model.UrlList{"http://nextcloud/auth/callback"},
|
||||||
LogoutCallbackURLs: model.UrlList{"http://nextcloud/auth/logout/callback"},
|
LogoutCallbackURLs: model.UrlList{"http://nextcloud/auth/logout/callback"},
|
||||||
@@ -172,6 +173,16 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
|||||||
userGroups[1],
|
userGroups[1],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{
|
||||||
|
ID: "7c21a609-96b5-4011-9900-272b8d31a9d1",
|
||||||
|
},
|
||||||
|
Name: "Tailscale",
|
||||||
|
Secret: "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a", // n4VfQeXlTzA6yKpWbR9uJcMdSx2qH0Lo
|
||||||
|
CallbackURLs: model.UrlList{"http://tailscale/auth/callback"},
|
||||||
|
LogoutCallbackURLs: model.UrlList{"http://tailscale/auth/logout/callback"},
|
||||||
|
CreatedByID: users[0].ID,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Base: model.Base{
|
Base: model.Base{
|
||||||
ID: "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
|
ID: "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
|
||||||
@@ -245,14 +256,22 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
|||||||
|
|
||||||
userAuthorizedClients := []model.UserAuthorizedOidcClient{
|
userAuthorizedClients := []model.UserAuthorizedOidcClient{
|
||||||
{
|
{
|
||||||
Scope: "openid profile email",
|
Scope: "openid profile email",
|
||||||
UserID: users[0].ID,
|
UserID: users[0].ID,
|
||||||
ClientID: oidcClients[0].ID,
|
ClientID: oidcClients[0].ID,
|
||||||
|
LastUsedAt: datatype.DateTime(time.Date(2025, 8, 1, 13, 0, 0, 0, time.UTC)),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Scope: "openid profile email",
|
Scope: "openid profile email",
|
||||||
UserID: users[1].ID,
|
UserID: users[0].ID,
|
||||||
ClientID: oidcClients[2].ID,
|
ClientID: oidcClients[2].ID,
|
||||||
|
LastUsedAt: datatype.DateTime(time.Date(2025, 8, 10, 14, 0, 0, 0, time.UTC)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Scope: "openid profile email",
|
||||||
|
UserID: users[1].ID,
|
||||||
|
ClientID: oidcClients[3].ID,
|
||||||
|
LastUsedAt: datatype.DateTime(time.Date(2025, 8, 12, 12, 0, 0, 0, time.UTC)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, userAuthorizedClient := range userAuthorizedClients {
|
for _, userAuthorizedClient := range userAuthorizedClients {
|
||||||
|
|||||||
@@ -149,20 +149,11 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
|
|||||||
return "", "", &common.OidcAccessDeniedError{}
|
return "", "", &common.OidcAccessDeniedError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the user has already authorized the client with the given scope
|
hasAlreadyAuthorizedClient, err := s.createAuthorizedClientInternal(ctx, userID, input.ClientID, input.Scope, tx)
|
||||||
hasAuthorizedClient, err := s.hasAuthorizedClientInternal(ctx, input.ClientID, userID, input.Scope, tx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the user has not authorized the client, create a new authorization in the database
|
|
||||||
if !hasAuthorizedClient {
|
|
||||||
err := s.createAuthorizedClientInternal(ctx, userID, input.ClientID, input.Scope, tx)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the authorization code
|
// Create the authorization code
|
||||||
code, err := s.createAuthorizationCode(ctx, input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod, tx)
|
code, err := s.createAuthorizationCode(ctx, input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod, tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -170,7 +161,7 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log the authorization event
|
// Log the authorization event
|
||||||
if hasAuthorizedClient {
|
if hasAlreadyAuthorizedClient {
|
||||||
s.auditLogService.Create(ctx, model.AuditLogEventClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name}, tx)
|
s.auditLogService.Create(ctx, model.AuditLogEventClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name}, tx)
|
||||||
} else {
|
} else {
|
||||||
s.auditLogService.Create(ctx, model.AuditLogEventNewClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name}, tx)
|
s.auditLogService.Create(ctx, model.AuditLogEventNewClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name}, tx)
|
||||||
@@ -724,6 +715,7 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien
|
|||||||
client.IsPublic = input.IsPublic
|
client.IsPublic = input.IsPublic
|
||||||
// PKCE is required for public clients
|
// PKCE is required for public clients
|
||||||
client.PkceEnabled = input.IsPublic || input.PkceEnabled
|
client.PkceEnabled = input.IsPublic || input.PkceEnabled
|
||||||
|
client.LaunchURL = input.LaunchURL
|
||||||
|
|
||||||
// Credentials
|
// Credentials
|
||||||
if len(input.Credentials.FederatedIdentities) > 0 {
|
if len(input.Credentials.FederatedIdentities) > 0 {
|
||||||
@@ -1231,22 +1223,16 @@ func (s *OidcService) VerifyDeviceCode(ctx context.Context, userCode string, use
|
|||||||
return fmt.Errorf("error saving device auth: %w", err)
|
return fmt.Errorf("error saving device auth: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create user authorization if needed
|
hasAlreadyAuthorizedClient, err := s.createAuthorizedClientInternal(ctx, userID, deviceAuth.ClientID, deviceAuth.Scope, tx)
|
||||||
hasAuthorizedClient, err := s.hasAuthorizedClientInternal(ctx, deviceAuth.ClientID, userID, deviceAuth.Scope, tx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
auditLogData := model.AuditLogData{"clientName": deviceAuth.Client.Name}
|
auditLogData := model.AuditLogData{"clientName": deviceAuth.Client.Name}
|
||||||
if !hasAuthorizedClient {
|
if hasAlreadyAuthorizedClient {
|
||||||
err = s.createAuthorizedClientInternal(ctx, userID, deviceAuth.ClientID, deviceAuth.Scope, tx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
s.auditLogService.Create(ctx, model.AuditLogEventNewDeviceCodeAuthorization, ipAddress, userAgent, userID, auditLogData, tx)
|
|
||||||
} else {
|
|
||||||
s.auditLogService.Create(ctx, model.AuditLogEventDeviceCodeAuthorization, ipAddress, userAgent, userID, auditLogData, tx)
|
s.auditLogService.Create(ctx, model.AuditLogEventDeviceCodeAuthorization, ipAddress, userAgent, userID, auditLogData, tx)
|
||||||
|
} else {
|
||||||
|
s.auditLogService.Create(ctx, model.AuditLogEventNewDeviceCodeAuthorization, ipAddress, userAgent, userID, auditLogData, tx)
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.Commit().Error
|
return tx.Commit().Error
|
||||||
@@ -1322,6 +1308,34 @@ func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string,
|
|||||||
return authorizedClients, response, err
|
return authorizedClients, response, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) RevokeAuthorizedClient(ctx context.Context, userID string, clientID string) error {
|
||||||
|
tx := s.db.Begin()
|
||||||
|
defer func() {
|
||||||
|
tx.Rollback()
|
||||||
|
}()
|
||||||
|
|
||||||
|
var authorizedClient model.UserAuthorizedOidcClient
|
||||||
|
err := tx.
|
||||||
|
WithContext(ctx).
|
||||||
|
Where("user_id = ? AND client_id = ?", userID, clientID).
|
||||||
|
First(&authorizedClient).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.WithContext(ctx).Delete(&authorizedClient).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit().Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, userID string, scope string, tx *gorm.DB) (string, error) {
|
func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, userID string, scope string, tx *gorm.DB) (string, error) {
|
||||||
refreshToken, err := utils.GenerateRandomAlphanumericString(40)
|
refreshToken, err := utils.GenerateRandomAlphanumericString(40)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1357,14 +1371,37 @@ func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, u
|
|||||||
return signed, nil
|
return signed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OidcService) createAuthorizedClientInternal(ctx context.Context, userID string, clientID string, scope string, tx *gorm.DB) error {
|
func (s *OidcService) createAuthorizedClientInternal(ctx context.Context, userID string, clientID string, scope string, tx *gorm.DB) (hasAlreadyAuthorizedClient bool, err error) {
|
||||||
userAuthorizedClient := model.UserAuthorizedOidcClient{
|
|
||||||
UserID: userID,
|
// Check if the user has already authorized the client with the given scope
|
||||||
ClientID: clientID,
|
hasAlreadyAuthorizedClient, err = s.hasAuthorizedClientInternal(ctx, clientID, userID, scope, tx)
|
||||||
Scope: scope,
|
if err != nil {
|
||||||
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err := tx.WithContext(ctx).
|
if hasAlreadyAuthorizedClient {
|
||||||
|
err = tx.
|
||||||
|
WithContext(ctx).
|
||||||
|
Model(&model.UserAuthorizedOidcClient{}).
|
||||||
|
Where("user_id = ? AND client_id = ?", userID, clientID).
|
||||||
|
Update("last_used_at", datatype.DateTime(time.Now())).
|
||||||
|
Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return hasAlreadyAuthorizedClient, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasAlreadyAuthorizedClient, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
userAuthorizedClient := model.UserAuthorizedOidcClient{
|
||||||
|
UserID: userID,
|
||||||
|
ClientID: clientID,
|
||||||
|
Scope: scope,
|
||||||
|
LastUsedAt: datatype.DateTime(time.Now()),
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.WithContext(ctx).
|
||||||
Clauses(clause.OnConflict{
|
Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "user_id"}, {Name: "client_id"}},
|
Columns: []clause.Column{{Name: "user_id"}, {Name: "client_id"}},
|
||||||
DoUpdates: clause.AssignmentColumns([]string{"scope"}),
|
DoUpdates: clause.AssignmentColumns([]string{"scope"}),
|
||||||
@@ -1372,7 +1409,7 @@ func (s *OidcService) createAuthorizedClientInternal(ctx context.Context, userID
|
|||||||
Create(&userAuthorizedClient).
|
Create(&userAuthorizedClient).
|
||||||
Error
|
Error
|
||||||
|
|
||||||
return err
|
return hasAlreadyAuthorizedClient, err
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClientAuthCredentials struct {
|
type ClientAuthCredentials struct {
|
||||||
@@ -1704,3 +1741,19 @@ func (s *OidcService) getUserClaimsFromAuthorizedClient(ctx context.Context, aut
|
|||||||
|
|
||||||
return claims, nil
|
return claims, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *OidcService) IsClientAccessibleToUser(ctx context.Context, clientID string, userID string) (bool, error) {
|
||||||
|
var user model.User
|
||||||
|
err := s.db.WithContext(ctx).Preload("UserGroups").First(&user, "id = ?", userID).Error
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var client model.OidcClient
|
||||||
|
err = s.db.WithContext(ctx).Preload("AllowedUserGroups").First(&client, "id = ?", clientID).Error
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.IsUserGroupAllowedToAuthorize(user, client), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE oidc_clients DROP COLUMN launch_url;
|
||||||
|
|
||||||
|
ALTER TABLE user_authorized_oidc_clients DROP COLUMN last_used_at;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE oidc_clients ADD COLUMN launch_url TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE user_authorized_oidc_clients ADD COLUMN last_used_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE oidc_clients DROP COLUMN launch_url;
|
||||||
|
|
||||||
|
ALTER TABLE user_authorized_oidc_clients DROP COLUMN created_at;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
ALTER TABLE oidc_clients ADD COLUMN launch_url TEXT;
|
||||||
|
|
||||||
|
CREATE TABLE user_authorized_oidc_clients_new
|
||||||
|
(
|
||||||
|
scope TEXT,
|
||||||
|
user_id TEXT,
|
||||||
|
client_id TEXT REFERENCES oidc_clients,
|
||||||
|
last_used_at DATETIME NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, client_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO user_authorized_oidc_clients_new (scope, user_id, client_id, last_used_at)
|
||||||
|
SELECT scope, user_id, client_id, unixepoch() FROM user_authorized_oidc_clients;
|
||||||
|
|
||||||
|
DROP TABLE user_authorized_oidc_clients;
|
||||||
|
ALTER TABLE user_authorized_oidc_clients_new RENAME TO user_authorized_oidc_clients;
|
||||||
@@ -419,5 +419,15 @@
|
|||||||
"signup_open_description": "Anyone can create a new account without restrictions.",
|
"signup_open_description": "Anyone can create a new account without restrictions.",
|
||||||
"of": "of",
|
"of": "of",
|
||||||
"skip_passkey_setup": "Skip Passkey Setup",
|
"skip_passkey_setup": "Skip Passkey Setup",
|
||||||
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
|
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires.",
|
||||||
|
"my_apps": "My Apps",
|
||||||
|
"no_apps_available": "No apps available",
|
||||||
|
"contact_your_administrator_for_app_access": "Contact your administrator to get access to applications.",
|
||||||
|
"launch": "Launch",
|
||||||
|
"client_launch_url": "Client Launch URL",
|
||||||
|
"client_launch_url_description": "The URL that will be opened when a user launches the app from the My Apps page.",
|
||||||
|
"client_name_description": "The name of the client that shows in the Pocket ID UI.",
|
||||||
|
"revoke_access": "Revoke Access",
|
||||||
|
"revoke_access_description": "Revoke access to <b>{clientName}</b>. <b>{clientName}</b> will no longer be able to access your account information.",
|
||||||
|
"revoke_access_successful": "The access to {clientName} has been successfully revoked."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||||
import { confirmDialogStore } from '.';
|
import { confirmDialogStore } from '.';
|
||||||
|
import FormattedMessage from '../formatted-message.svelte';
|
||||||
import Button from '../ui/button/button.svelte';
|
import Button from '../ui/button/button.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@
|
|||||||
<AlertDialog.Header>
|
<AlertDialog.Header>
|
||||||
<AlertDialog.Title>{$confirmDialogStore.title}</AlertDialog.Title>
|
<AlertDialog.Title>{$confirmDialogStore.title}</AlertDialog.Title>
|
||||||
<AlertDialog.Description>
|
<AlertDialog.Description>
|
||||||
{$confirmDialogStore.message}
|
<FormattedMessage m={$confirmDialogStore.message} />
|
||||||
</AlertDialog.Description>
|
</AlertDialog.Description>
|
||||||
</AlertDialog.Header>
|
</AlertDialog.Header>
|
||||||
<AlertDialog.Footer>
|
<AlertDialog.Footer>
|
||||||
|
|||||||
@@ -8,53 +8,66 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
interface MessagePart {
|
interface MessagePart {
|
||||||
type: 'text' | 'link';
|
type: 'text' | 'link' | 'bold';
|
||||||
content: string;
|
content: string;
|
||||||
href?: string;
|
href?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extracts attribute value from a tag's attribute string
|
||||||
|
function getAttr(attrs: string, name: string): string | undefined {
|
||||||
|
const re = new RegExp(`\\b${name}\\s*=\\s*(["'])(.*?)\\1`, 'i');
|
||||||
|
const m = re.exec(attrs ?? '');
|
||||||
|
return m?.[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlers: Record<string, (attrs: string, inner: string) => MessagePart | null> = {
|
||||||
|
link: (attrs, inner) => {
|
||||||
|
const href = getAttr(attrs, 'href');
|
||||||
|
if (!href) return { type: 'text', content: inner };
|
||||||
|
return { type: 'link', content: inner, href };
|
||||||
|
},
|
||||||
|
b: (_attrs, inner) => ({ type: 'bold', content: inner })
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildTokenRegex(): RegExp {
|
||||||
|
const keys = Object.keys(handlers).join('|');
|
||||||
|
// Matches: <tag attrs>inner</tag> for allowed tags only
|
||||||
|
return new RegExp(`<(${keys})\\b([^>]*)>(.*?)<\\/\\1>`, 'g');
|
||||||
|
}
|
||||||
|
|
||||||
function parseMessage(content: string): MessagePart[] | string {
|
function parseMessage(content: string): MessagePart[] | string {
|
||||||
// Regex to match only <link href="url">text</link> format
|
const tokenRegex = buildTokenRegex();
|
||||||
const linkRegex = /<link\s+href=(['"])(.*?)\1>(.*?)<\/link>/g;
|
if (!tokenRegex.test(content)) return content;
|
||||||
|
// Reset lastIndex for reuse
|
||||||
if (!linkRegex.test(content)) {
|
tokenRegex.lastIndex = 0;
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset regex lastIndex for reuse
|
|
||||||
linkRegex.lastIndex = 0;
|
|
||||||
|
|
||||||
const parts: MessagePart[] = [];
|
const parts: MessagePart[] = [];
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
let match;
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
while ((match = linkRegex.exec(content)) !== null) {
|
while ((match = tokenRegex.exec(content)) !== null) {
|
||||||
// Add text before the link
|
// Add text before the matched token
|
||||||
if (match.index > lastIndex) {
|
if (match.index > lastIndex) {
|
||||||
const textContent = content.slice(lastIndex, match.index);
|
const textContent = content.slice(lastIndex, match.index);
|
||||||
if (textContent) {
|
if (textContent) parts.push({ type: 'text', content: textContent });
|
||||||
parts.push({ type: 'text', content: textContent });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const href = match[2];
|
const tag = match[1];
|
||||||
const linkText = match[3];
|
const attrs = match[2] ?? '';
|
||||||
|
const inner = match[3] ?? '';
|
||||||
parts.push({
|
const handler = handlers[tag];
|
||||||
type: 'link',
|
const part: MessagePart | null = handler
|
||||||
content: linkText,
|
? handler(attrs, inner)
|
||||||
href: href
|
: { type: 'text', content: inner };
|
||||||
});
|
if (part) parts.push(part);
|
||||||
|
|
||||||
lastIndex = match.index + match[0].length;
|
lastIndex = match.index + match[0].length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add remaining text after the last link
|
// Add remaining text after the last token
|
||||||
if (lastIndex < content.length) {
|
if (lastIndex < content.length) {
|
||||||
const remainingText = content.slice(lastIndex);
|
const remainingText = content.slice(lastIndex);
|
||||||
if (remainingText) {
|
if (remainingText) parts.push({ type: 'text', content: remainingText });
|
||||||
parts.push({ type: 'text', content: remainingText });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts;
|
return parts;
|
||||||
@@ -69,6 +82,10 @@
|
|||||||
{#each parsedContent as part}
|
{#each parsedContent as part}
|
||||||
{#if part.type === 'text'}
|
{#if part.type === 'text'}
|
||||||
{part.content}
|
{part.content}
|
||||||
|
{:else if part.type === 'bold'}
|
||||||
|
<b>
|
||||||
|
{part.content}
|
||||||
|
</b>
|
||||||
{:else if part.type === 'link'}
|
{:else if part.type === 'link'}
|
||||||
<a
|
<a
|
||||||
class="text-black underline dark:text-white"
|
class="text-black underline dark:text-white"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import WebAuthnService from '$lib/services/webauthn-service';
|
import WebAuthnService from '$lib/services/webauthn-service';
|
||||||
import userStore from '$lib/stores/user-store';
|
import userStore from '$lib/stores/user-store';
|
||||||
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
|
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
|
||||||
import { LucideLogOut, LucideUser } from '@lucide/svelte';
|
import { LayoutDashboard, LucideLogOut, LucideUser } from '@lucide/svelte';
|
||||||
|
|
||||||
const webauthnService = new WebAuthnService();
|
const webauthnService = new WebAuthnService();
|
||||||
|
|
||||||
@@ -34,6 +34,9 @@
|
|||||||
</DropdownMenu.Label>
|
</DropdownMenu.Label>
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Group>
|
<DropdownMenu.Group>
|
||||||
|
<DropdownMenu.Item onclick={() => goto('/settings/apps')}
|
||||||
|
><LayoutDashboard class="mr-2 size-4" /> {m.my_apps()}</DropdownMenu.Item
|
||||||
|
>
|
||||||
<DropdownMenu.Item onclick={() => goto('/settings/account')}
|
<DropdownMenu.Item onclick={() => goto('/settings/account')}
|
||||||
><LucideUser class="mr-2 size-4" /> {m.my_account()}</DropdownMenu.Item
|
><LucideUser class="mr-2 size-4" /> {m.my_account()}</DropdownMenu.Item
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AuthorizedOidcClient,
|
||||||
AuthorizeResponse,
|
AuthorizeResponse,
|
||||||
OidcClient,
|
OidcClient,
|
||||||
OidcClientCreate,
|
OidcClientCreate,
|
||||||
@@ -113,6 +114,24 @@ class OidcService extends APIService {
|
|||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listAuthorizedClients(options?: SearchPaginationSortRequest) {
|
||||||
|
const res = await this.api.get('/oidc/users/me/clients', {
|
||||||
|
params: options
|
||||||
|
});
|
||||||
|
return res.data as Paginated<AuthorizedOidcClient>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAuthorizedClientsForUser(userId: string, options?: SearchPaginationSortRequest) {
|
||||||
|
const res = await this.api.get(`/oidc/users/${userId}/clients`, {
|
||||||
|
params: options
|
||||||
|
});
|
||||||
|
return res.data as Paginated<AuthorizedOidcClient>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeOwnAuthorizedClient(clientId: string) {
|
||||||
|
await this.api.delete(`/oidc/users/me/clients/${clientId}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default OidcService;
|
export default OidcService;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type OidcClientMetaData = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
hasLogo: boolean;
|
hasLogo: boolean;
|
||||||
|
launchURL?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OidcClientFederatedIdentity = {
|
export type OidcClientFederatedIdentity = {
|
||||||
@@ -23,6 +24,7 @@ export type OidcClient = OidcClientMetaData & {
|
|||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
pkceEnabled: boolean;
|
pkceEnabled: boolean;
|
||||||
credentials?: OidcClientCredentials;
|
credentials?: OidcClientCredentials;
|
||||||
|
launchURL?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OidcClientWithAllowedUserGroups = OidcClient & {
|
export type OidcClientWithAllowedUserGroups = OidcClient & {
|
||||||
@@ -50,3 +52,8 @@ export type AuthorizeResponse = {
|
|||||||
callbackURL: string;
|
callbackURL: string;
|
||||||
issuer: string;
|
issuer: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AuthorizedOidcClient = {
|
||||||
|
scope: string;
|
||||||
|
client: OidcClientMetaData;
|
||||||
|
};
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
|
|||||||
inputs[input as keyof z.infer<T>].error = null;
|
inputs[input as keyof z.infer<T>].error = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Update the input values with the parsed data
|
||||||
|
for (const key in result.data) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(inputs, key)) {
|
||||||
|
inputs[key as keyof z.infer<T>].value = result.data[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return inputs;
|
return inputs;
|
||||||
});
|
});
|
||||||
return success ? data() : null;
|
return success ? data() : null;
|
||||||
|
|||||||
11
frontend/src/lib/utils/zod-util.ts
Normal file
11
frontend/src/lib/utils/zod-util.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import z from 'zod/v4';
|
||||||
|
|
||||||
|
export const optionalString = z
|
||||||
|
.string()
|
||||||
|
.transform((v) => (v === '' ? undefined : v))
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
export const optionalUrl = z
|
||||||
|
.url()
|
||||||
|
.optional()
|
||||||
|
.or(z.literal('').transform(() => undefined));
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import FormattedMessage from '$lib/components/formatted-message.svelte';
|
||||||
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
||||||
import ScopeItem from '$lib/components/scope-item.svelte';
|
import ScopeItem from '$lib/components/scope-item.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
@@ -98,17 +99,21 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if !authorizationRequired && !errorMessage}
|
{#if !authorizationRequired && !errorMessage}
|
||||||
<p class="text-muted-foreground mt-2 mb-10">
|
<p class="text-muted-foreground mt-2 mb-10">
|
||||||
{@html m.do_you_want_to_sign_in_to_client_with_your_app_name_account({
|
<FormattedMessage
|
||||||
client: client.name,
|
m={m.do_you_want_to_sign_in_to_client_with_your_app_name_account({
|
||||||
appName: $appConfigStore.appName
|
client: client.name,
|
||||||
})}
|
appName: $appConfigStore.appName
|
||||||
|
})}
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
{:else if authorizationRequired}
|
{:else if authorizationRequired}
|
||||||
<div class="w-full max-w-[450px]" transition:slide={{ duration: 300 }}>
|
<div class="w-full max-w-[450px]" transition:slide={{ duration: 300 }}>
|
||||||
<Card.Root class="mt-6 mb-10">
|
<Card.Root class="mt-6 mb-10">
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<p class="text-muted-foreground text-start">
|
<p class="text-muted-foreground text-start">
|
||||||
{@html m.client_wants_to_access_the_following_information({ client: client.name })}
|
<FormattedMessage
|
||||||
|
m={m.client_wants_to_access_the_following_information({ client: client.name })}
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content data-testid="scopes">
|
<Card.Content data-testid="scopes">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import FormattedMessage from '$lib/components/formatted-message.svelte';
|
||||||
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
||||||
import ScopeList from '$lib/components/scope-list.svelte';
|
import ScopeList from '$lib/components/scope-list.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
@@ -94,9 +95,11 @@
|
|||||||
<Card.Root class="mt-6">
|
<Card.Root class="mt-6">
|
||||||
<Card.Header class="pb-5">
|
<Card.Header class="pb-5">
|
||||||
<p class="text-muted-foreground text-start">
|
<p class="text-muted-foreground text-start">
|
||||||
{@html m.client_wants_to_access_the_following_information({
|
<FormattedMessage
|
||||||
client: deviceInfo!.client.name
|
m={m.client_wants_to_access_the_following_information({
|
||||||
})}
|
client: deviceInfo!.client.name
|
||||||
|
})}
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content data-testid="scopes">
|
<Card.Content data-testid="scopes">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import FormattedMessage from '$lib/components/formatted-message.svelte';
|
||||||
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
||||||
import Logo from '$lib/components/logo.svelte';
|
import Logo from '$lib/components/logo.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
@@ -36,10 +37,12 @@
|
|||||||
<h1 class="font-playfair mt-5 text-4xl font-bold">{m.sign_out()}</h1>
|
<h1 class="font-playfair mt-5 text-4xl font-bold">{m.sign_out()}</h1>
|
||||||
|
|
||||||
<p class="text-muted-foreground mt-2">
|
<p class="text-muted-foreground mt-2">
|
||||||
{@html m.do_you_want_to_sign_out_of_pocketid_with_the_account({
|
<FormattedMessage
|
||||||
username: $userStore?.username ?? '',
|
m={m.do_you_want_to_sign_out_of_pocketid_with_the_account({
|
||||||
appName: $appConfigStore.appName
|
username: $userStore?.username ?? '',
|
||||||
})}
|
appName: $appConfigStore.appName
|
||||||
|
})}
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-10 flex w-full justify-stretch gap-2">
|
<div class="mt-10 flex w-full justify-stretch gap-2">
|
||||||
<Button class="flex-1" variant="secondary" onclick={() => history.back()}>{m.cancel()}</Button>
|
<Button class="flex-1" variant="secondary" onclick={() => history.back()}>{m.cancel()}</Button>
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
{ href: '/settings/audit-log', label: m.audit_log() }
|
{ href: '/settings/audit-log', label: m.audit_log() }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const nonAdminLinks = [{ href: '/settings/apps', label: m.my_apps() }];
|
||||||
|
|
||||||
const adminLinks = [
|
const adminLinks = [
|
||||||
{ href: '/settings/admin/users', label: m.users() },
|
{ href: '/settings/admin/users', label: m.users() },
|
||||||
{ href: '/settings/admin/user-groups', label: m.user_groups() },
|
{ href: '/settings/admin/user-groups', label: m.user_groups() },
|
||||||
@@ -35,6 +37,8 @@
|
|||||||
|
|
||||||
if (user?.isAdmin || $userStore?.isAdmin) {
|
if (user?.isAdmin || $userStore?.isAdmin) {
|
||||||
links.push(...adminLinks);
|
links.push(...adminLinks);
|
||||||
|
} else {
|
||||||
|
links.push(...nonAdminLinks);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import type { ApiKeyCreate } from '$lib/types/api-key.type';
|
import type { ApiKeyCreate } from '$lib/types/api-key.type';
|
||||||
import { preventDefault } from '$lib/utils/event-util';
|
import { preventDefault } from '$lib/utils/event-util';
|
||||||
import { createForm } from '$lib/utils/form-util';
|
import { createForm } from '$lib/utils/form-util';
|
||||||
|
import { optionalString } from '$lib/utils/zod-util';
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -27,7 +28,7 @@
|
|||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().min(3).max(50),
|
name: z.string().min(3).max(50),
|
||||||
description: z.string().default(''),
|
description: optionalString,
|
||||||
expiresAt: z.date().min(new Date(), m.expiration_date_must_be_in_the_future())
|
expiresAt: z.date().min(new Date(), m.expiration_date_must_be_in_the_future())
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
import FederatedIdentitiesInput from './federated-identities-input.svelte';
|
import FederatedIdentitiesInput from './federated-identities-input.svelte';
|
||||||
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
|
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
|
||||||
|
import { optionalUrl } from '$lib/utils/zod-util';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
callback,
|
callback,
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
logoutCallbackURLs: existingClient?.logoutCallbackURLs || [],
|
logoutCallbackURLs: existingClient?.logoutCallbackURLs || [],
|
||||||
isPublic: existingClient?.isPublic || false,
|
isPublic: existingClient?.isPublic || false,
|
||||||
pkceEnabled: existingClient?.pkceEnabled || false,
|
pkceEnabled: existingClient?.pkceEnabled || false,
|
||||||
|
launchURL: existingClient?.launchURL || '',
|
||||||
credentials: {
|
credentials: {
|
||||||
federatedIdentities: existingClient?.credentials?.federatedIdentities || []
|
federatedIdentities: existingClient?.credentials?.federatedIdentities || []
|
||||||
}
|
}
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
logoutCallbackURLs: z.array(z.string().nonempty()),
|
logoutCallbackURLs: z.array(z.string().nonempty()),
|
||||||
isPublic: z.boolean(),
|
isPublic: z.boolean(),
|
||||||
pkceEnabled: z.boolean(),
|
pkceEnabled: z.boolean(),
|
||||||
|
launchURL: optionalUrl,
|
||||||
credentials: z.object({
|
credentials: z.object({
|
||||||
federatedIdentities: z.array(
|
federatedIdentities: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -106,8 +109,18 @@
|
|||||||
|
|
||||||
<form onsubmit={preventDefault(onSubmit)}>
|
<form onsubmit={preventDefault(onSubmit)}>
|
||||||
<div class="grid grid-cols-1 gap-x-3 gap-y-7 sm:flex-row md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-x-3 gap-y-7 sm:flex-row md:grid-cols-2">
|
||||||
<FormInput label={m.name()} class="w-full" bind:input={$inputs.name} />
|
<FormInput
|
||||||
<div></div>
|
label={m.name()}
|
||||||
|
class="w-full"
|
||||||
|
description={m.client_name_description()}
|
||||||
|
bind:input={$inputs.name}
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
label={m.client_launch_url()}
|
||||||
|
description={m.client_launch_url_description()}
|
||||||
|
class="w-full"
|
||||||
|
bind:input={$inputs.launchURL}
|
||||||
|
/>
|
||||||
<OidcCallbackUrlInput
|
<OidcCallbackUrlInput
|
||||||
label={m.callback_urls()}
|
label={m.callback_urls()}
|
||||||
description={m.callback_url_description()}
|
description={m.callback_url_description()}
|
||||||
|
|||||||
121
frontend/src/routes/settings/apps/+page.svelte
Normal file
121
frontend/src/routes/settings/apps/+page.svelte
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { openConfirmDialog } from '$lib/components/confirm-dialog';
|
||||||
|
import * as Pagination from '$lib/components/ui/pagination';
|
||||||
|
import { m } from '$lib/paraglide/messages';
|
||||||
|
import OIDCService from '$lib/services/oidc-service';
|
||||||
|
import type { AuthorizedOidcClient, OidcClientMetaData } from '$lib/types/oidc.type';
|
||||||
|
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
||||||
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
|
import { LayoutDashboard } from '@lucide/svelte';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { default as AuthorizedOidcClientCard } from './authorized-oidc-client-card.svelte';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
let authorizedClients: Paginated<AuthorizedOidcClient> = $state(data.authorizedClients);
|
||||||
|
let requestOptions: SearchPaginationSortRequest = $state(data.appRequestOptions);
|
||||||
|
|
||||||
|
const oidcService = new OIDCService();
|
||||||
|
|
||||||
|
async function onRefresh(options: SearchPaginationSortRequest) {
|
||||||
|
authorizedClients = await oidcService.listAuthorizedClients(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPageChange(page: number) {
|
||||||
|
requestOptions.pagination = { limit: authorizedClients.pagination.itemsPerPage, page };
|
||||||
|
onRefresh(requestOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeAuthorizedClient(client: OidcClientMetaData) {
|
||||||
|
openConfirmDialog({
|
||||||
|
title: m.revoke_access(),
|
||||||
|
message: m.revoke_access_description({
|
||||||
|
clientName: client.name
|
||||||
|
}),
|
||||||
|
confirm: {
|
||||||
|
label: m.revoke(),
|
||||||
|
destructive: true,
|
||||||
|
action: async () => {
|
||||||
|
try {
|
||||||
|
await oidcService.revokeOwnAuthorizedClient(client.id);
|
||||||
|
onRefresh(requestOptions);
|
||||||
|
toast.success(
|
||||||
|
m.revoke_access_successful({
|
||||||
|
clientName: client.name
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
axiosErrorToast(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{m.my_apps()}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="flex items-center gap-2 text-2xl font-bold">
|
||||||
|
<LayoutDashboard class="text-primary/80 size-6" />
|
||||||
|
{m.my_apps()}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if authorizedClients.data.length === 0}
|
||||||
|
<div class="py-16 text-center">
|
||||||
|
<LayoutDashboard class="text-muted-foreground mx-auto mb-4 size-16" />
|
||||||
|
<h3 class="text-muted-foreground mb-2 text-lg font-medium">
|
||||||
|
{m.no_apps_available()}
|
||||||
|
</h3>
|
||||||
|
<p class="text-muted-foreground mx-auto max-w-md text-sm">
|
||||||
|
{m.contact_your_administrator_for_app_access()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-8">
|
||||||
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{#each authorizedClients.data as authorizedClient}
|
||||||
|
<AuthorizedOidcClientCard {authorizedClient} onRevoke={revokeAuthorizedClient} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if authorizedClients.pagination.totalPages > 1}
|
||||||
|
<div class="border-border flex items-center justify-center border-t pt-3">
|
||||||
|
<Pagination.Root
|
||||||
|
class="mx-0 w-auto"
|
||||||
|
count={authorizedClients.pagination.totalItems}
|
||||||
|
perPage={authorizedClients.pagination.itemsPerPage}
|
||||||
|
{onPageChange}
|
||||||
|
page={authorizedClients.pagination.currentPage}
|
||||||
|
>
|
||||||
|
{#snippet children({ pages })}
|
||||||
|
<Pagination.Content class="flex justify-center">
|
||||||
|
<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={authorizedClients.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}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
22
frontend/src/routes/settings/apps/+page.ts
Normal file
22
frontend/src/routes/settings/apps/+page.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import OIDCService from '$lib/services/oidc-service';
|
||||||
|
import type { SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageLoad = async () => {
|
||||||
|
const oidcService = new OIDCService();
|
||||||
|
|
||||||
|
const appRequestOptions: SearchPaginationSortRequest = {
|
||||||
|
pagination: {
|
||||||
|
page: 1,
|
||||||
|
limit: 20
|
||||||
|
},
|
||||||
|
sort: {
|
||||||
|
column: 'lastUsedAt',
|
||||||
|
direction: 'desc'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const authorizedClients = await oidcService.listAuthorizedClients(appRequestOptions);
|
||||||
|
|
||||||
|
return { authorizedClients, appRequestOptions };
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import ImageBox from '$lib/components/image-box.svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||||
|
import { m } from '$lib/paraglide/messages';
|
||||||
|
import userStore from '$lib/stores/user-store';
|
||||||
|
import type { AuthorizedOidcClient, OidcClientMetaData } from '$lib/types/oidc.type';
|
||||||
|
import { cachedApplicationLogo, cachedOidcClientLogo } from '$lib/utils/cached-image-util';
|
||||||
|
import {
|
||||||
|
LucideBan,
|
||||||
|
LucideEllipsisVertical,
|
||||||
|
LucideExternalLink,
|
||||||
|
LucidePencil
|
||||||
|
} from '@lucide/svelte';
|
||||||
|
import { mode } from 'mode-watcher';
|
||||||
|
|
||||||
|
let {
|
||||||
|
authorizedClient,
|
||||||
|
onRevoke
|
||||||
|
}: {
|
||||||
|
authorizedClient: AuthorizedOidcClient;
|
||||||
|
onRevoke: (client: OidcClientMetaData) => Promise<void>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const isLightMode = $derived(mode.current === 'light');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card.Root
|
||||||
|
class="border-muted group h-[140px] p-5 transition-all duration-200 hover:shadow-md"
|
||||||
|
data-testid="authorized-oidc-client-card"
|
||||||
|
>
|
||||||
|
<Card.Content class=" p-0">
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<div class="aspect-square h-[56px]">
|
||||||
|
<ImageBox
|
||||||
|
class="grow rounded-lg object-contain"
|
||||||
|
src={authorizedClient.client.hasLogo
|
||||||
|
? cachedOidcClientLogo.getUrl(authorizedClient.client.id)
|
||||||
|
: cachedApplicationLogo.getUrl(isLightMode)}
|
||||||
|
alt={m.name_logo({ name: authorizedClient.client.name })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex w-full justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="mb-1 flex items-start gap-2">
|
||||||
|
<h3
|
||||||
|
class="text-foreground line-clamp-2 leading-tight font-semibold break-words break-all text-ellipsis"
|
||||||
|
>
|
||||||
|
{authorizedClient.client.name}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{#if authorizedClient.client.launchURL}
|
||||||
|
<p
|
||||||
|
class="text-muted-foreground line-clamp-1 text-xs break-words break-all text-ellipsis"
|
||||||
|
>
|
||||||
|
{new URL(authorizedClient.client.launchURL).hostname}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
<LucideEllipsisVertical class="size-4" />
|
||||||
|
<span class="sr-only">{m.toggle_menu()}</span>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content align="end">
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onclick={() => goto(`/settings/admin/oidc-clients/${authorizedClient.client.id}`)}
|
||||||
|
><LucidePencil class="mr-2 size-4" /> {m.edit()}</DropdownMenu.Item
|
||||||
|
>
|
||||||
|
{#if $userStore?.isAdmin}
|
||||||
|
<DropdownMenu.Item
|
||||||
|
class="text-red-500 focus:!text-red-700"
|
||||||
|
onclick={() => onRevoke(authorizedClient.client)}
|
||||||
|
><LucideBan class="mr-2 size-4" />{m.revoke()}</DropdownMenu.Item
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 flex justify-end">
|
||||||
|
<Button
|
||||||
|
href={authorizedClient.client.launchURL}
|
||||||
|
target="_blank"
|
||||||
|
size="sm"
|
||||||
|
class="h-8 text-xs"
|
||||||
|
disabled={!authorizedClient.client.launchURL}
|
||||||
|
>
|
||||||
|
{m.launch()}
|
||||||
|
<LucideExternalLink class="ml-1 size-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
</style>
|
||||||
@@ -27,7 +27,8 @@ export const oidcClients = {
|
|||||||
name: 'Nextcloud',
|
name: 'Nextcloud',
|
||||||
callbackUrl: 'http://nextcloud/auth/callback',
|
callbackUrl: 'http://nextcloud/auth/callback',
|
||||||
logoutCallbackUrl: 'http://nextcloud/auth/logout/callback',
|
logoutCallbackUrl: 'http://nextcloud/auth/logout/callback',
|
||||||
secret: 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY'
|
secret: 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY',
|
||||||
|
launchURL: 'https://nextcloud.local'
|
||||||
},
|
},
|
||||||
immich: {
|
immich: {
|
||||||
id: '606c7782-f2b1-49e5-8ea9-26eb1b06d018',
|
id: '606c7782-f2b1-49e5-8ea9-26eb1b06d018',
|
||||||
@@ -35,6 +36,12 @@ export const oidcClients = {
|
|||||||
callbackUrl: 'http://immich/auth/callback',
|
callbackUrl: 'http://immich/auth/callback',
|
||||||
secret: 'PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x'
|
secret: 'PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x'
|
||||||
},
|
},
|
||||||
|
tailscale: {
|
||||||
|
id: '7c21a609-96b5-4011-9900-272b8d31a9d1',
|
||||||
|
name: 'Tailscale',
|
||||||
|
callbackUrl: 'http://tailscale/auth/callback',
|
||||||
|
secret: 'n4VfQeXlTzA6yKpWbR9uJcMdSx2qH0Lo',
|
||||||
|
},
|
||||||
federated: {
|
federated: {
|
||||||
id: 'c48232ff-ff65-45ed-ae96-7afa8a9b443b',
|
id: 'c48232ff-ff65-45ed-ae96-7afa8a9b443b',
|
||||||
name: 'Federated',
|
name: 'Federated',
|
||||||
@@ -49,7 +56,8 @@ export const oidcClients = {
|
|||||||
pingvinShare: {
|
pingvinShare: {
|
||||||
name: 'Pingvin Share',
|
name: 'Pingvin Share',
|
||||||
callbackUrl: 'http://pingvin.share/auth/callback',
|
callbackUrl: 'http://pingvin.share/auth/callback',
|
||||||
secondCallbackUrl: 'http://pingvin.share/auth/callback2'
|
secondCallbackUrl: 'http://pingvin.share/auth/callback2',
|
||||||
|
launchURL: 'https://pingvin-share.local'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
59
tests/specs/apps-dashboard.spec.ts
Normal file
59
tests/specs/apps-dashboard.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import test, { expect } from '@playwright/test';
|
||||||
|
import { oidcClients } from '../data';
|
||||||
|
import { cleanupBackend } from '../utils/cleanup.util';
|
||||||
|
|
||||||
|
test.beforeEach(() => cleanupBackend());
|
||||||
|
|
||||||
|
test('Dashboard shows all authorized clients in the correct order', async ({ page }) => {
|
||||||
|
const client1 = oidcClients.tailscale;
|
||||||
|
const client2 = oidcClients.nextcloud;
|
||||||
|
|
||||||
|
await page.goto('/settings/apps');
|
||||||
|
|
||||||
|
await expect(page.getByTestId('authorized-oidc-client-card')).toHaveCount(2);
|
||||||
|
|
||||||
|
// Should be first
|
||||||
|
const card1 = page.getByTestId('authorized-oidc-client-card').first();
|
||||||
|
|
||||||
|
await expect(card1.getByRole('heading')).toHaveText(client1.name);
|
||||||
|
|
||||||
|
const card2 = page.getByTestId('authorized-oidc-client-card').nth(1);
|
||||||
|
await expect(card2.getByRole('heading', { name: client2.name })).toBeVisible();
|
||||||
|
await expect(card2.getByText(new URL(client2.launchURL).hostname)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Revoke authorized client', async ({ page }) => {
|
||||||
|
const client = oidcClients.tailscale;
|
||||||
|
|
||||||
|
await page.goto('/settings/apps');
|
||||||
|
|
||||||
|
page
|
||||||
|
.getByTestId('authorized-oidc-client-card')
|
||||||
|
.first()
|
||||||
|
.getByRole('button', { name: 'Toggle menu' })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await page.getByRole('menuitem', { name: 'Revoke' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Revoke' }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||||
|
`The access to ${client.name} has been successfully revoked.`
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(page.getByTestId('authorized-oidc-client-card')).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Launch authorized client', async ({ page }) => {
|
||||||
|
const client = oidcClients.nextcloud;
|
||||||
|
|
||||||
|
await page.goto('/settings/apps');
|
||||||
|
|
||||||
|
const card1 = page.getByTestId('authorized-oidc-client-card').first();
|
||||||
|
await expect(card1.getByRole('button', { name: 'Launch' })).toBeDisabled();
|
||||||
|
|
||||||
|
const card2 = page.getByTestId('authorized-oidc-client-card').nth(1);
|
||||||
|
await expect(card2.getByRole('link', { name: 'Launch' })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
client.launchURL
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -11,6 +11,8 @@ test('Create OIDC client', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: 'Add OIDC Client' }).click();
|
await page.getByRole('button', { name: 'Add OIDC Client' }).click();
|
||||||
await page.getByLabel('Name').fill(oidcClient.name);
|
await page.getByLabel('Name').fill(oidcClient.name);
|
||||||
|
|
||||||
|
await page.getByLabel('Client Launch URL').fill(oidcClient.launchURL);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Add' }).nth(1).click();
|
await page.getByRole('button', { name: 'Add' }).nth(1).click();
|
||||||
await page.getByTestId('callback-url-1').fill(oidcClient.callbackUrl);
|
await page.getByTestId('callback-url-1').fill(oidcClient.callbackUrl);
|
||||||
await page.getByRole('button', { name: 'Add another' }).click();
|
await page.getByRole('button', { name: 'Add another' }).click();
|
||||||
@@ -42,6 +44,7 @@ test('Edit OIDC client', async ({ page }) => {
|
|||||||
await page.getByLabel('Name').fill('Nextcloud updated');
|
await page.getByLabel('Name').fill('Nextcloud updated');
|
||||||
await page.getByTestId('callback-url-1').first().fill('http://nextcloud-updated/auth/callback');
|
await page.getByTestId('callback-url-1').first().fill('http://nextcloud-updated/auth/callback');
|
||||||
await page.getByLabel('logo').setInputFiles('assets/nextcloud-logo.png');
|
await page.getByLabel('logo').setInputFiles('assets/nextcloud-logo.png');
|
||||||
|
await page.getByLabel('Client Launch URL').fill(oidcClient.launchURL);
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
|
||||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||||
|
|||||||
Reference in New Issue
Block a user