mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-03-28 10:16:37 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
339837bec4 | ||
|
|
39b46e99a9 | ||
|
|
dc9e64de3d | ||
|
|
6207e10279 | ||
|
|
7550333fe2 | ||
|
|
3de1301fa8 | ||
|
|
c3980d3d28 | ||
|
|
4d0fff821e | ||
|
|
2e66211b7f | ||
|
|
2071d002fc | ||
|
|
0d071694cd | ||
|
|
39e403d00f | ||
|
|
4e858420e9 | ||
|
|
2d78349b38 | ||
|
|
9ed2adb0f8 | ||
|
|
43790dc1be | ||
|
|
7fbc356d8d |
@@ -6,7 +6,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-22.04 # Using an older version because of https://github.com/actions/runner-images/issues/11471
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
@@ -20,7 +20,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
ghcr.io/${{ github.repository }}
|
ghcr.io/${{ github.repository }}
|
||||||
${{ github.repository }}
|
|
||||||
tags: |
|
tags: |
|
||||||
type=semver,pattern={{version}},prefix=v
|
type=semver,pattern={{version}},prefix=v
|
||||||
type=semver,pattern={{major}}.{{minor}},prefix=v
|
type=semver,pattern={{major}}.{{minor}},prefix=v
|
||||||
|
|||||||
42
CHANGELOG.md
42
CHANGELOG.md
@@ -1,3 +1,45 @@
|
|||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.33.0...v) (2025-02-16)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add LDAP group membership attribute ([#236](https://github.com/pocket-id/pocket-id/issues/236)) ([39b46e9](https://github.com/pocket-id/pocket-id/commit/39b46e99a9b930ea39cf640c3080530cfff5be6e))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.32.0...v) (2025-02-14)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add end session endpoint ([#232](https://github.com/pocket-id/pocket-id/issues/232)) ([7550333](https://github.com/pocket-id/pocket-id/commit/7550333fe2ff6424f3168f63c5179d76767532fd))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* alignment of OIDC client details ([c3980d3](https://github.com/pocket-id/pocket-id/commit/c3980d3d28a7158a4dc9369af41f185b891e485e))
|
||||||
|
* layout of OIDC client details page on mobile ([3de1301](https://github.com/pocket-id/pocket-id/commit/3de1301fa84b3ab4fff4242d827c7794d44910f2))
|
||||||
|
* show "Sync Now" and "Test Email" button even if UI config is disabled ([4d0fff8](https://github.com/pocket-id/pocket-id/commit/4d0fff821e2245050ce631b4465969510466dfae))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.31.0...v) (2025-02-13)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add ability to set custom Geolite DB URL ([2071d00](https://github.com/pocket-id/pocket-id/commit/2071d002fc5c3b5ff7a3fca6a5c99f5517196853))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.30.0...v) (2025-02-12)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add ability to override the UI configuration with environment variables ([4e85842](https://github.com/pocket-id/pocket-id/commit/4e858420e9d9713e19f3b35c45c882403717f72f))
|
||||||
|
* add warning for only having one passkey configured ([#220](https://github.com/pocket-id/pocket-id/issues/220)) ([39e403d](https://github.com/pocket-id/pocket-id/commit/39e403d00f3870f9e960427653a1d9697da27a6f))
|
||||||
|
* display source in user and group table ([#225](https://github.com/pocket-id/pocket-id/issues/225)) ([9ed2adb](https://github.com/pocket-id/pocket-id/commit/9ed2adb0f8da13725fd9a4ef6a7798c377d13513))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* user linking in ldap group sync ([#222](https://github.com/pocket-id/pocket-id/issues/222)) ([2d78349](https://github.com/pocket-id/pocket-id/commit/2d78349b381d7ca10f47d3c03cef685a576b1b49))
|
||||||
|
|
||||||
## [](https://github.com/pocket-id/pocket-id/compare/v0.29.0...v) (2025-02-08)
|
## [](https://github.com/pocket-id/pocket-id/compare/v0.29.0...v) (2025-02-08)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
|||||||
userService := service.NewUserService(db, jwtService, auditLogService, emailService, appConfigService)
|
userService := service.NewUserService(db, jwtService, auditLogService, emailService, appConfigService)
|
||||||
customClaimService := service.NewCustomClaimService(db)
|
customClaimService := service.NewCustomClaimService(db)
|
||||||
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
|
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
|
||||||
testService := service.NewTestService(db, appConfigService)
|
testService := service.NewTestService(db, appConfigService, jwtService)
|
||||||
userGroupService := service.NewUserGroupService(db, appConfigService)
|
userGroupService := service.NewUserGroupService(db, appConfigService)
|
||||||
ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService)
|
ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService)
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import (
|
|||||||
type DbProvider string
|
type DbProvider string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DbProviderSqlite DbProvider = "sqlite"
|
DbProviderSqlite DbProvider = "sqlite"
|
||||||
DbProviderPostgres DbProvider = "postgres"
|
DbProviderPostgres DbProvider = "postgres"
|
||||||
|
MaxMindGeoLiteCityUrl string = "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz"
|
||||||
)
|
)
|
||||||
|
|
||||||
type EnvConfigSchema struct {
|
type EnvConfigSchema struct {
|
||||||
@@ -25,6 +26,8 @@ type EnvConfigSchema struct {
|
|||||||
Host string `env:"HOST"`
|
Host string `env:"HOST"`
|
||||||
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
|
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
|
||||||
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
|
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
|
||||||
|
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
|
||||||
|
UiConfigDisabled bool `env:"PUBLIC_UI_CONFIG_DISABLED"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var EnvConfig = &EnvConfigSchema{
|
var EnvConfig = &EnvConfigSchema{
|
||||||
@@ -38,6 +41,8 @@ var EnvConfig = &EnvConfigSchema{
|
|||||||
Host: "0.0.0.0",
|
Host: "0.0.0.0",
|
||||||
MaxMindLicenseKey: "",
|
MaxMindLicenseKey: "",
|
||||||
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
|
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
|
||||||
|
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
|
||||||
|
UiConfigDisabled: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ type TokenInvalidOrExpiredError struct{}
|
|||||||
func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
|
func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
|
||||||
func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return 400 }
|
func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type TokenInvalidError struct{}
|
||||||
|
|
||||||
|
func (e *TokenInvalidError) Error() string {
|
||||||
|
return "Token is invalid"
|
||||||
|
}
|
||||||
|
func (e *TokenInvalidError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
type OidcMissingAuthorizationError struct{}
|
type OidcMissingAuthorizationError struct{}
|
||||||
|
|
||||||
func (e *OidcMissingAuthorizationError) Error() string { return "missing authorization" }
|
func (e *OidcMissingAuthorizationError) Error() string { return "missing authorization" }
|
||||||
@@ -182,5 +189,25 @@ type OidcAccessDeniedError struct{}
|
|||||||
func (e *OidcAccessDeniedError) Error() string {
|
func (e *OidcAccessDeniedError) Error() string {
|
||||||
return "You're not allowed to access this service"
|
return "You're not allowed to access this service"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *OidcAccessDeniedError) HttpStatusCode() int { return http.StatusForbidden }
|
func (e *OidcAccessDeniedError) HttpStatusCode() int { return http.StatusForbidden }
|
||||||
|
|
||||||
|
type OidcClientIdNotMatchingError struct{}
|
||||||
|
|
||||||
|
func (e *OidcClientIdNotMatchingError) Error() string {
|
||||||
|
return "Client id in request doesn't match client id in token"
|
||||||
|
}
|
||||||
|
func (e *OidcClientIdNotMatchingError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
|
type OidcNoCallbackURLError struct{}
|
||||||
|
|
||||||
|
func (e *OidcNoCallbackURLError) Error() string {
|
||||||
|
return "No callback URL provided"
|
||||||
|
}
|
||||||
|
func (e *OidcNoCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
|
type UiConfigDisabledError struct{}
|
||||||
|
|
||||||
|
func (e *UiConfigDisabledError) Error() string {
|
||||||
|
return "The configuration can't be changed since the UI configuration is disabled"
|
||||||
|
}
|
||||||
|
func (e *UiConfigDisabledError) HttpStatusCode() int { return http.StatusForbidden }
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -19,6 +23,8 @@ func NewOidcController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt
|
|||||||
|
|
||||||
group.POST("/oidc/token", oc.createTokensHandler)
|
group.POST("/oidc/token", oc.createTokensHandler)
|
||||||
group.GET("/oidc/userinfo", oc.userInfoHandler)
|
group.GET("/oidc/userinfo", oc.userInfoHandler)
|
||||||
|
group.POST("/oidc/end-session", oc.EndSessionHandler)
|
||||||
|
group.GET("/oidc/end-session", oc.EndSessionHandler)
|
||||||
|
|
||||||
group.GET("/oidc/clients", jwtAuthMiddleware.Add(true), oc.listClientsHandler)
|
group.GET("/oidc/clients", jwtAuthMiddleware.Add(true), oc.listClientsHandler)
|
||||||
group.POST("/oidc/clients", jwtAuthMiddleware.Add(true), oc.createClientHandler)
|
group.POST("/oidc/clients", jwtAuthMiddleware.Add(true), oc.createClientHandler)
|
||||||
@@ -122,6 +128,44 @@ func (oc *OidcController) userInfoHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, claims)
|
c.JSON(http.StatusOK, claims)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (oc *OidcController) EndSessionHandler(c *gin.Context) {
|
||||||
|
var input dto.OidcLogoutDto
|
||||||
|
|
||||||
|
// Bind query parameters to the struct
|
||||||
|
if c.Request.Method == http.MethodGet {
|
||||||
|
if err := c.ShouldBindQuery(&input); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if c.Request.Method == http.MethodPost {
|
||||||
|
// Bind form parameters to the struct
|
||||||
|
if err := c.ShouldBind(&input); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
callbackURL, err := oc.oidcService.ValidateEndSession(input, c.GetString("userID"))
|
||||||
|
if err != nil {
|
||||||
|
// If the validation fails, the user has to confirm the logout manually and doesn't get redirected
|
||||||
|
log.Printf("Error getting logout callback URL, the user has to confirm the logout manually: %v", err)
|
||||||
|
c.Redirect(http.StatusFound, common.EnvConfig.AppURL+"/logout")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The validation was successful, so we can log out and redirect the user to the callback URL without confirmation
|
||||||
|
cookie.AddAccessTokenCookie(c, 0, "")
|
||||||
|
|
||||||
|
logoutCallbackURL, _ := url.Parse(callbackURL)
|
||||||
|
if input.State != "" {
|
||||||
|
q := logoutCallbackURL.Query()
|
||||||
|
q.Set("state", input.State)
|
||||||
|
logoutCallbackURL.RawQuery = q.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Redirect(http.StatusFound, logoutCallbackURL.String())
|
||||||
|
}
|
||||||
|
|
||||||
func (oc *OidcController) getClientHandler(c *gin.Context) {
|
func (oc *OidcController) getClientHandler(c *gin.Context) {
|
||||||
clientId := c.Param("id")
|
clientId := c.Param("id")
|
||||||
client, err := oc.oidcService.GetClient(clientId)
|
client, err := oc.oidcService.GetClient(clientId)
|
||||||
|
|||||||
@@ -38,5 +38,7 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tc.TestService.SetJWTKeys()
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
|
|||||||
"authorization_endpoint": appUrl + "/authorize",
|
"authorization_endpoint": appUrl + "/authorize",
|
||||||
"token_endpoint": appUrl + "/api/oidc/token",
|
"token_endpoint": appUrl + "/api/oidc/token",
|
||||||
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
|
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
|
||||||
|
"end_session_endpoint": appUrl + "/api/oidc/end-session",
|
||||||
"jwks_uri": appUrl + "/.well-known/jwks.json",
|
"jwks_uri": appUrl + "/.well-known/jwks.json",
|
||||||
"scopes_supported": []string{"openid", "profile", "email"},
|
"scopes_supported": []string{"openid", "profile", "email"},
|
||||||
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username"},
|
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username"},
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ type AppConfigUpdateDto struct {
|
|||||||
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"`
|
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"`
|
||||||
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"`
|
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"`
|
||||||
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"`
|
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"`
|
||||||
|
LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"`
|
||||||
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`
|
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`
|
||||||
LdapAttributeGroupName string `json:"ldapAttributeGroupName"`
|
LdapAttributeGroupName string `json:"ldapAttributeGroupName"`
|
||||||
LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"`
|
LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"`
|
||||||
|
|||||||
@@ -8,24 +8,27 @@ type PublicOidcClientDto struct {
|
|||||||
|
|
||||||
type OidcClientDto struct {
|
type OidcClientDto struct {
|
||||||
PublicOidcClientDto
|
PublicOidcClientDto
|
||||||
CallbackURLs []string `json:"callbackURLs"`
|
CallbackURLs []string `json:"callbackURLs"`
|
||||||
IsPublic bool `json:"isPublic"`
|
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
||||||
PkceEnabled bool `json:"pkceEnabled"`
|
IsPublic bool `json:"isPublic"`
|
||||||
|
PkceEnabled bool `json:"pkceEnabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcClientWithAllowedUserGroupsDto struct {
|
type OidcClientWithAllowedUserGroupsDto struct {
|
||||||
PublicOidcClientDto
|
PublicOidcClientDto
|
||||||
CallbackURLs []string `json:"callbackURLs"`
|
CallbackURLs []string `json:"callbackURLs"`
|
||||||
IsPublic bool `json:"isPublic"`
|
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
||||||
PkceEnabled bool `json:"pkceEnabled"`
|
IsPublic bool `json:"isPublic"`
|
||||||
AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"`
|
PkceEnabled bool `json:"pkceEnabled"`
|
||||||
|
AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcClientCreateDto struct {
|
type OidcClientCreateDto struct {
|
||||||
Name string `json:"name" binding:"required,max=50"`
|
Name string `json:"name" binding:"required,max=50"`
|
||||||
CallbackURLs []string `json:"callbackURLs" binding:"required"`
|
CallbackURLs []string `json:"callbackURLs" binding:"required"`
|
||||||
IsPublic bool `json:"isPublic"`
|
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
||||||
PkceEnabled bool `json:"pkceEnabled"`
|
IsPublic bool `json:"isPublic"`
|
||||||
|
PkceEnabled bool `json:"pkceEnabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthorizeOidcClientRequestDto struct {
|
type AuthorizeOidcClientRequestDto struct {
|
||||||
@@ -58,3 +61,10 @@ type OidcCreateTokensDto struct {
|
|||||||
type OidcUpdateAllowedUserGroupsDto struct {
|
type OidcUpdateAllowedUserGroupsDto struct {
|
||||||
UserGroupIDs []string `json:"userGroupIds" binding:"required"`
|
UserGroupIDs []string `json:"userGroupIds" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OidcLogoutDto struct {
|
||||||
|
IdTokenHint string `form:"id_token_hint"`
|
||||||
|
ClientId string `form:"client_id"`
|
||||||
|
PostLogoutRedirectUri string `form:"post_logout_redirect_uri"`
|
||||||
|
State string `form:"state"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ type AppConfig struct {
|
|||||||
LdapAttributeUserEmail AppConfigVariable
|
LdapAttributeUserEmail AppConfigVariable
|
||||||
LdapAttributeUserFirstName AppConfigVariable
|
LdapAttributeUserFirstName AppConfigVariable
|
||||||
LdapAttributeUserLastName AppConfigVariable
|
LdapAttributeUserLastName AppConfigVariable
|
||||||
|
LdapAttributeGroupMember AppConfigVariable
|
||||||
LdapAttributeGroupUniqueIdentifier AppConfigVariable
|
LdapAttributeGroupUniqueIdentifier AppConfigVariable
|
||||||
LdapAttributeGroupName AppConfigVariable
|
LdapAttributeGroupName AppConfigVariable
|
||||||
LdapAttributeAdminGroup AppConfigVariable
|
LdapAttributeAdminGroup AppConfigVariable
|
||||||
|
|||||||
@@ -37,13 +37,14 @@ type OidcAuthorizationCode struct {
|
|||||||
type OidcClient struct {
|
type OidcClient struct {
|
||||||
Base
|
Base
|
||||||
|
|
||||||
Name string `sortable:"true"`
|
Name string `sortable:"true"`
|
||||||
Secret string
|
Secret string
|
||||||
CallbackURLs CallbackURLs
|
CallbackURLs UrlList
|
||||||
ImageType *string
|
LogoutCallbackURLs UrlList
|
||||||
HasLogo bool `gorm:"-"`
|
ImageType *string
|
||||||
IsPublic bool
|
HasLogo bool `gorm:"-"`
|
||||||
PkceEnabled bool
|
IsPublic bool
|
||||||
|
PkceEnabled bool
|
||||||
|
|
||||||
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
|
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
|
||||||
CreatedByID string
|
CreatedByID string
|
||||||
@@ -56,9 +57,9 @@ func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type CallbackURLs []string
|
type UrlList []string
|
||||||
|
|
||||||
func (cu *CallbackURLs) Scan(value interface{}) error {
|
func (cu *UrlList) Scan(value interface{}) error {
|
||||||
if v, ok := value.([]byte); ok {
|
if v, ok := value.([]byte); ok {
|
||||||
return json.Unmarshal(v, cu)
|
return json.Unmarshal(v, cu)
|
||||||
} else {
|
} else {
|
||||||
@@ -66,6 +67,6 @@ func (cu *CallbackURLs) Scan(value interface{}) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cu CallbackURLs) Value() (driver.Value, error) {
|
func (cu UrlList) Value() (driver.Value, error) {
|
||||||
return json.Marshal(cu)
|
return json.Marshal(cu)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,6 +173,11 @@ var defaultDbConfig = model.AppConfig{
|
|||||||
Key: "ldapAttributeUserLastName",
|
Key: "ldapAttributeUserLastName",
|
||||||
Type: "string",
|
Type: "string",
|
||||||
},
|
},
|
||||||
|
LdapAttributeGroupMember: model.AppConfigVariable{
|
||||||
|
Key: "ldapAttributeGroupMember",
|
||||||
|
Type: "string",
|
||||||
|
DefaultValue: "member",
|
||||||
|
},
|
||||||
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{
|
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{
|
||||||
Key: "ldapAttributeGroupUniqueIdentifier",
|
Key: "ldapAttributeGroupUniqueIdentifier",
|
||||||
Type: "string",
|
Type: "string",
|
||||||
@@ -188,12 +193,15 @@ var defaultDbConfig = model.AppConfig{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
|
func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
|
||||||
var savedConfigVariables []model.AppConfigVariable
|
if common.EnvConfig.UiConfigDisabled {
|
||||||
|
return nil, &common.UiConfigDisabledError{}
|
||||||
|
}
|
||||||
|
|
||||||
tx := s.db.Begin()
|
tx := s.db.Begin()
|
||||||
rt := reflect.ValueOf(input).Type()
|
rt := reflect.ValueOf(input).Type()
|
||||||
rv := reflect.ValueOf(input)
|
rv := reflect.ValueOf(input)
|
||||||
|
|
||||||
|
var savedConfigVariables []model.AppConfigVariable
|
||||||
for i := 0; i < rt.NumField(); i++ {
|
for i := 0; i < rt.NumField(); i++ {
|
||||||
field := rt.Field(i)
|
field := rt.Field(i)
|
||||||
key := field.Tag.Get("json")
|
key := field.Tag.Get("json")
|
||||||
@@ -254,9 +262,13 @@ func (s *AppConfigService) ListAppConfig(showAll bool) ([]model.AppConfigVariabl
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the value to the default value if it is empty
|
|
||||||
for i := range configuration {
|
for i := range configuration {
|
||||||
if configuration[i].Value == "" && configuration[i].DefaultValue != "" {
|
if common.EnvConfig.UiConfigDisabled {
|
||||||
|
// Set the value to the environment variable if the UI config is disabled
|
||||||
|
configuration[i].Value = s.getConfigVariableFromEnvironmentVariable(configuration[i].Key, configuration[i].DefaultValue)
|
||||||
|
|
||||||
|
} else if configuration[i].Value == "" && configuration[i].DefaultValue != "" {
|
||||||
|
// Set the value to the default value if it is empty
|
||||||
configuration[i].Value = configuration[i].DefaultValue
|
configuration[i].Value = configuration[i].DefaultValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -355,12 +367,25 @@ func (s *AppConfigService) LoadDbConfigFromDb() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if storedConfigVar.Value == "" && storedConfigVar.DefaultValue != "" {
|
if common.EnvConfig.UiConfigDisabled {
|
||||||
|
storedConfigVar.Value = s.getConfigVariableFromEnvironmentVariable(currentConfigVar.Key, storedConfigVar.DefaultValue)
|
||||||
|
} else if storedConfigVar.Value == "" && storedConfigVar.DefaultValue != "" {
|
||||||
storedConfigVar.Value = storedConfigVar.DefaultValue
|
storedConfigVar.Value = storedConfigVar.DefaultValue
|
||||||
}
|
}
|
||||||
|
|
||||||
dbConfigField.Set(reflect.ValueOf(storedConfigVar))
|
dbConfigField.Set(reflect.ValueOf(storedConfigVar))
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AppConfigService) getConfigVariableFromEnvironmentVariable(key, fallbackValue string) string {
|
||||||
|
environmentVariableName := utils.CamelCaseToScreamingSnakeCase(key)
|
||||||
|
|
||||||
|
if value, exists := os.LookupEnv(environmentVariableName); exists {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackValue
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type GeoLiteService struct {
|
type GeoLiteService struct {
|
||||||
mutex sync.Mutex
|
disableUpdater bool
|
||||||
|
mutex sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
var localhostIPNets = []*net.IPNet{
|
var localhostIPNets = []*net.IPNet{
|
||||||
@@ -43,6 +44,12 @@ var tailscaleIPNets = []*net.IPNet{
|
|||||||
func NewGeoLiteService() *GeoLiteService {
|
func NewGeoLiteService() *GeoLiteService {
|
||||||
service := &GeoLiteService{}
|
service := &GeoLiteService{}
|
||||||
|
|
||||||
|
if common.EnvConfig.MaxMindLicenseKey == "" && common.EnvConfig.GeoLiteDBUrl == common.MaxMindGeoLiteCityUrl {
|
||||||
|
// Warn the user, and disable the updater.
|
||||||
|
log.Println("MAXMIND_LICENSE_KEY environment variable is empty. The GeoLite2 City database won't be updated.")
|
||||||
|
service.disableUpdater = true
|
||||||
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if err := service.updateDatabase(); err != nil {
|
if err := service.updateDatabase(); err != nil {
|
||||||
log.Printf("Failed to update GeoLite2 City database: %v\n", err)
|
log.Printf("Failed to update GeoLite2 City database: %v\n", err)
|
||||||
@@ -104,18 +111,19 @@ func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string
|
|||||||
|
|
||||||
// UpdateDatabase checks the age of the database and updates it if it's older than 14 days.
|
// UpdateDatabase checks the age of the database and updates it if it's older than 14 days.
|
||||||
func (s *GeoLiteService) updateDatabase() error {
|
func (s *GeoLiteService) updateDatabase() error {
|
||||||
|
if s.disableUpdater {
|
||||||
|
// Avoid updating the GeoLite2 City database.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
if s.isDatabaseUpToDate() {
|
if s.isDatabaseUpToDate() {
|
||||||
log.Println("GeoLite2 City database is up-to-date.")
|
log.Println("GeoLite2 City database is up-to-date.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Updating GeoLite2 City database...")
|
log.Println("Updating GeoLite2 City database...")
|
||||||
|
downloadUrl := fmt.Sprintf(common.EnvConfig.GeoLiteDBUrl, common.EnvConfig.MaxMindLicenseKey)
|
||||||
|
|
||||||
// Download and extract the database
|
|
||||||
downloadUrl := fmt.Sprintf(
|
|
||||||
"https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz",
|
|
||||||
common.EnvConfig.MaxMindLicenseKey,
|
|
||||||
)
|
|
||||||
// Download the database tar.gz file
|
// Download the database tar.gz file
|
||||||
resp, err := http.Get(downloadUrl)
|
resp, err := http.Get(downloadUrl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
"math/big"
|
"math/big"
|
||||||
"os"
|
"os"
|
||||||
@@ -28,8 +27,8 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type JwtService struct {
|
type JwtService struct {
|
||||||
publicKey *rsa.PublicKey
|
PublicKey *rsa.PublicKey
|
||||||
privateKey *rsa.PrivateKey
|
PrivateKey *rsa.PrivateKey
|
||||||
appConfigService *AppConfigService
|
appConfigService *AppConfigService
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +71,7 @@ func (s *JwtService) loadOrGenerateKeys() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("can't read jwt private key: " + err.Error())
|
return errors.New("can't read jwt private key: " + err.Error())
|
||||||
}
|
}
|
||||||
s.privateKey, err = jwt.ParseRSAPrivateKeyFromPEM(privateKeyBytes)
|
s.PrivateKey, err = jwt.ParseRSAPrivateKeyFromPEM(privateKeyBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("can't parse jwt private key: " + err.Error())
|
return errors.New("can't parse jwt private key: " + err.Error())
|
||||||
}
|
}
|
||||||
@@ -81,7 +80,7 @@ func (s *JwtService) loadOrGenerateKeys() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("can't read jwt public key: " + err.Error())
|
return errors.New("can't read jwt public key: " + err.Error())
|
||||||
}
|
}
|
||||||
s.publicKey, err = jwt.ParseRSAPublicKeyFromPEM(publicKeyBytes)
|
s.PublicKey, err = jwt.ParseRSAPublicKeyFromPEM(publicKeyBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("can't parse jwt public key: " + err.Error())
|
return errors.New("can't parse jwt public key: " + err.Error())
|
||||||
}
|
}
|
||||||
@@ -101,7 +100,7 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
|
|||||||
IsAdmin: user.IsAdmin,
|
IsAdmin: user.IsAdmin,
|
||||||
}
|
}
|
||||||
|
|
||||||
kid, err := s.generateKeyID(s.publicKey)
|
kid, err := s.generateKeyID(s.PublicKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", errors.New("failed to generate key ID: " + err.Error())
|
return "", errors.New("failed to generate key ID: " + err.Error())
|
||||||
}
|
}
|
||||||
@@ -109,12 +108,12 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
|
|||||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
|
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
|
||||||
token.Header["kid"] = kid
|
token.Header["kid"] = kid
|
||||||
|
|
||||||
return token.SignedString(s.privateKey)
|
return token.SignedString(s.PrivateKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *JwtService) VerifyAccessToken(tokenString string) (*AccessTokenJWTClaims, error) {
|
func (s *JwtService) VerifyAccessToken(tokenString string) (*AccessTokenJWTClaims, error) {
|
||||||
token, err := jwt.ParseWithClaims(tokenString, &AccessTokenJWTClaims{}, func(token *jwt.Token) (interface{}, error) {
|
token, err := jwt.ParseWithClaims(tokenString, &AccessTokenJWTClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
return s.publicKey, nil
|
return s.PublicKey, nil
|
||||||
})
|
})
|
||||||
if err != nil || !token.Valid {
|
if err != nil || !token.Valid {
|
||||||
return nil, errors.New("couldn't handle this token")
|
return nil, errors.New("couldn't handle this token")
|
||||||
@@ -147,7 +146,7 @@ func (s *JwtService) GenerateIDToken(userClaims map[string]interface{}, clientID
|
|||||||
claims["nonce"] = nonce
|
claims["nonce"] = nonce
|
||||||
}
|
}
|
||||||
|
|
||||||
kid, err := s.generateKeyID(s.publicKey)
|
kid, err := s.generateKeyID(s.PublicKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", errors.New("failed to generate key ID: " + err.Error())
|
return "", errors.New("failed to generate key ID: " + err.Error())
|
||||||
}
|
}
|
||||||
@@ -155,7 +154,7 @@ func (s *JwtService) GenerateIDToken(userClaims map[string]interface{}, clientID
|
|||||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||||
token.Header["kid"] = kid
|
token.Header["kid"] = kid
|
||||||
|
|
||||||
return token.SignedString(s.privateKey)
|
return token.SignedString(s.PrivateKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) {
|
func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) {
|
||||||
@@ -167,7 +166,7 @@ func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string)
|
|||||||
Issuer: common.EnvConfig.AppURL,
|
Issuer: common.EnvConfig.AppURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
kid, err := s.generateKeyID(s.publicKey)
|
kid, err := s.generateKeyID(s.PublicKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", errors.New("failed to generate key ID: " + err.Error())
|
return "", errors.New("failed to generate key ID: " + err.Error())
|
||||||
}
|
}
|
||||||
@@ -175,12 +174,12 @@ func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string)
|
|||||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
|
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
|
||||||
token.Header["kid"] = kid
|
token.Header["kid"] = kid
|
||||||
|
|
||||||
return token.SignedString(s.privateKey)
|
return token.SignedString(s.PrivateKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *JwtService) VerifyOauthAccessToken(tokenString string) (*jwt.RegisteredClaims, error) {
|
func (s *JwtService) VerifyOauthAccessToken(tokenString string) (*jwt.RegisteredClaims, error) {
|
||||||
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
|
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
return s.publicKey, nil
|
return s.PublicKey, nil
|
||||||
})
|
})
|
||||||
if err != nil || !token.Valid {
|
if err != nil || !token.Valid {
|
||||||
return nil, errors.New("couldn't handle this token")
|
return nil, errors.New("couldn't handle this token")
|
||||||
@@ -194,13 +193,30 @@ func (s *JwtService) VerifyOauthAccessToken(tokenString string) (*jwt.Registered
|
|||||||
return claims, nil
|
return claims, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *JwtService) VerifyIdToken(tokenString string) (*jwt.RegisteredClaims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return s.PublicKey, nil
|
||||||
|
}, jwt.WithIssuer(common.EnvConfig.AppURL))
|
||||||
|
|
||||||
|
if err != nil && !errors.Is(err, jwt.ErrTokenExpired) {
|
||||||
|
return nil, errors.New("couldn't handle this token")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, isValid := token.Claims.(*jwt.RegisteredClaims)
|
||||||
|
if !isValid {
|
||||||
|
return nil, errors.New("can't parse claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetJWK returns the JSON Web Key (JWK) for the public key.
|
// GetJWK returns the JSON Web Key (JWK) for the public key.
|
||||||
func (s *JwtService) GetJWK() (JWK, error) {
|
func (s *JwtService) GetJWK() (JWK, error) {
|
||||||
if s.publicKey == nil {
|
if s.PublicKey == nil {
|
||||||
return JWK{}, errors.New("public key is not initialized")
|
return JWK{}, errors.New("public key is not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
kid, err := s.generateKeyID(s.publicKey)
|
kid, err := s.generateKeyID(s.PublicKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return JWK{}, err
|
return JWK{}, err
|
||||||
}
|
}
|
||||||
@@ -210,8 +226,8 @@ func (s *JwtService) GetJWK() (JWK, error) {
|
|||||||
Kty: "RSA",
|
Kty: "RSA",
|
||||||
Use: "sig",
|
Use: "sig",
|
||||||
Alg: "RS256",
|
Alg: "RS256",
|
||||||
N: base64.RawURLEncoding.EncodeToString(s.publicKey.N.Bytes()),
|
N: base64.RawURLEncoding.EncodeToString(s.PublicKey.N.Bytes()),
|
||||||
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(s.publicKey.E)).Bytes()),
|
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(s.PublicKey.E)).Bytes()),
|
||||||
}
|
}
|
||||||
|
|
||||||
return jwk, nil
|
return jwk, nil
|
||||||
@@ -246,14 +262,14 @@ func (s *JwtService) generateKeys() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("failed to generate private key: " + err.Error())
|
return errors.New("failed to generate private key: " + err.Error())
|
||||||
}
|
}
|
||||||
s.privateKey = privateKey
|
s.PrivateKey = privateKey
|
||||||
|
|
||||||
if err := s.savePEMKey(privateKeyPath, x509.MarshalPKCS1PrivateKey(privateKey), "RSA PRIVATE KEY"); err != nil {
|
if err := s.savePEMKey(privateKeyPath, x509.MarshalPKCS1PrivateKey(privateKey), "RSA PRIVATE KEY"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
publicKey := &privateKey.PublicKey
|
publicKey := &privateKey.PublicKey
|
||||||
s.publicKey = publicKey
|
s.PublicKey = publicKey
|
||||||
|
|
||||||
if err := s.savePEMKey(publicKeyPath, x509.MarshalPKCS1PublicKey(publicKey), "RSA PUBLIC KEY"); err != nil {
|
if err := s.savePEMKey(publicKeyPath, x509.MarshalPKCS1PublicKey(publicKey), "RSA PUBLIC KEY"); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -281,32 +297,3 @@ func (s *JwtService) savePEMKey(path string, keyBytes []byte, keyType string) er
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadKeys loads RSA keys from the given paths.
|
|
||||||
func (s *JwtService) loadKeys() error {
|
|
||||||
if _, err := os.Stat(privateKeyPath); os.IsNotExist(err) {
|
|
||||||
if err := s.generateKeys(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
privateKeyBytes, err := os.ReadFile(privateKeyPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("can't read jwt private key: %w", err)
|
|
||||||
}
|
|
||||||
s.privateKey, err = jwt.ParseRSAPrivateKeyFromPEM(privateKeyBytes)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("can't parse jwt private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
publicKeyBytes, err := os.ReadFile(publicKeyPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("can't read jwt public key: %w", err)
|
|
||||||
}
|
|
||||||
s.publicKey, err = jwt.ParseRSAPublicKeyFromPEM(publicKeyBytes)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("can't parse jwt public key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -70,12 +70,13 @@ func (s *LdapService) SyncGroups() error {
|
|||||||
baseDN := s.appConfigService.DbConfig.LdapBase.Value
|
baseDN := s.appConfigService.DbConfig.LdapBase.Value
|
||||||
nameAttribute := s.appConfigService.DbConfig.LdapAttributeGroupName.Value
|
nameAttribute := s.appConfigService.DbConfig.LdapAttributeGroupName.Value
|
||||||
uniqueIdentifierAttribute := s.appConfigService.DbConfig.LdapAttributeGroupUniqueIdentifier.Value
|
uniqueIdentifierAttribute := s.appConfigService.DbConfig.LdapAttributeGroupUniqueIdentifier.Value
|
||||||
|
groupMemberOfAttribute := s.appConfigService.DbConfig.LdapAttributeGroupMember.Value
|
||||||
filter := s.appConfigService.DbConfig.LdapUserGroupSearchFilter.Value
|
filter := s.appConfigService.DbConfig.LdapUserGroupSearchFilter.Value
|
||||||
|
|
||||||
searchAttrs := []string{
|
searchAttrs := []string{
|
||||||
nameAttribute,
|
nameAttribute,
|
||||||
uniqueIdentifierAttribute,
|
uniqueIdentifierAttribute,
|
||||||
"member",
|
groupMemberOfAttribute,
|
||||||
}
|
}
|
||||||
|
|
||||||
searchReq := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, 0, 0, 0, false, filter, searchAttrs, []ldap.Control{})
|
searchReq := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, 0, 0, 0, false, filter, searchAttrs, []ldap.Control{})
|
||||||
@@ -99,14 +100,15 @@ func (s *LdapService) SyncGroups() error {
|
|||||||
s.db.Where("ldap_id = ?", ldapId).First(&databaseGroup)
|
s.db.Where("ldap_id = ?", ldapId).First(&databaseGroup)
|
||||||
|
|
||||||
// Get group members and add to the correct Group
|
// Get group members and add to the correct Group
|
||||||
groupMembers := value.GetAttributeValues("member")
|
groupMembers := value.GetAttributeValues(groupMemberOfAttribute)
|
||||||
for _, member := range groupMembers {
|
for _, member := range groupMembers {
|
||||||
// Normal output of this would be CN=username,ou=people,dc=example,dc=com
|
// Normal output of this would be CN=username,ou=people,dc=example,dc=com
|
||||||
// Splitting at the "=" and "," then just grabbing the username for that string
|
// Splitting at the "=" and "," then just grabbing the username for that string
|
||||||
singleMember := strings.Split(strings.Split(member, "=")[1], ",")[0]
|
singleMember := strings.Split(strings.Split(member, "=")[1], ",")[0]
|
||||||
|
|
||||||
var databaseUser model.User
|
var databaseUser model.User
|
||||||
s.db.Where("username = ?", singleMember).First(&databaseUser)
|
s.db.Where("username = ?", singleMember).Where("ldap_id IS NOT NULL").First(&databaseUser)
|
||||||
|
|
||||||
membersUserId = append(membersUserId, databaseUser.ID)
|
membersUserId = append(membersUserId, databaseUser.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get the callback URL of the client. Return an error if the provided callback URL is not allowed
|
// Get the callback URL of the client. Return an error if the provided callback URL is not allowed
|
||||||
callbackURL, err := s.getCallbackURL(client, input.CallbackURL)
|
callbackURL, err := s.getCallbackURL(client.CallbackURLs, input.CallbackURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
@@ -228,11 +228,12 @@ func (s *OidcService) ListClients(searchTerm string, sortedPaginationRequest uti
|
|||||||
|
|
||||||
func (s *OidcService) CreateClient(input dto.OidcClientCreateDto, userID string) (model.OidcClient, error) {
|
func (s *OidcService) CreateClient(input dto.OidcClientCreateDto, userID string) (model.OidcClient, error) {
|
||||||
client := model.OidcClient{
|
client := model.OidcClient{
|
||||||
Name: input.Name,
|
Name: input.Name,
|
||||||
CallbackURLs: input.CallbackURLs,
|
CallbackURLs: input.CallbackURLs,
|
||||||
CreatedByID: userID,
|
LogoutCallbackURLs: input.LogoutCallbackURLs,
|
||||||
IsPublic: input.IsPublic,
|
CreatedByID: userID,
|
||||||
PkceEnabled: input.IsPublic || input.PkceEnabled,
|
IsPublic: input.IsPublic,
|
||||||
|
PkceEnabled: input.IsPublic || input.PkceEnabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.Create(&client).Error; err != nil {
|
if err := s.db.Create(&client).Error; err != nil {
|
||||||
@@ -250,6 +251,7 @@ func (s *OidcService) UpdateClient(clientID string, input dto.OidcClientCreateDt
|
|||||||
|
|
||||||
client.Name = input.Name
|
client.Name = input.Name
|
||||||
client.CallbackURLs = input.CallbackURLs
|
client.CallbackURLs = input.CallbackURLs
|
||||||
|
client.LogoutCallbackURLs = input.LogoutCallbackURLs
|
||||||
client.IsPublic = input.IsPublic
|
client.IsPublic = input.IsPublic
|
||||||
client.PkceEnabled = input.IsPublic || input.PkceEnabled
|
client.PkceEnabled = input.IsPublic || input.PkceEnabled
|
||||||
|
|
||||||
@@ -460,6 +462,46 @@ func (s *OidcService) UpdateAllowedUserGroups(id string, input dto.OidcUpdateAll
|
|||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateEndSession returns the logout callback URL for the client if all the validations pass
|
||||||
|
func (s *OidcService) ValidateEndSession(input dto.OidcLogoutDto, userID string) (string, error) {
|
||||||
|
// If no ID token hint is provided, return an error
|
||||||
|
if input.IdTokenHint == "" {
|
||||||
|
return "", &common.TokenInvalidError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the ID token hint is provided, verify the ID token
|
||||||
|
claims, err := s.jwtService.VerifyIdToken(input.IdTokenHint)
|
||||||
|
if err != nil {
|
||||||
|
return "", &common.TokenInvalidError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the client ID is provided check if the client ID in the ID token matches the client ID in the request
|
||||||
|
if input.ClientId != "" && claims.Audience[0] != input.ClientId {
|
||||||
|
return "", &common.OidcClientIdNotMatchingError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
clientId := claims.Audience[0]
|
||||||
|
|
||||||
|
// Check if the user has authorized the client before
|
||||||
|
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
|
||||||
|
if err := s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", clientId, userID).Error; err != nil {
|
||||||
|
return "", &common.OidcMissingAuthorizationError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the client has no logout callback URLs, return an error
|
||||||
|
if len(userAuthorizedOIDCClient.Client.LogoutCallbackURLs) == 0 {
|
||||||
|
return "", &common.OidcNoCallbackURLError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
callbackURL, err := s.getCallbackURL(userAuthorizedOIDCClient.Client.LogoutCallbackURLs, input.PostLogoutRedirectUri)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return callbackURL, nil
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
func (s *OidcService) createAuthorizationCode(clientID string, userID string, scope string, nonce string, codeChallenge string, codeChallengeMethod string) (string, error) {
|
func (s *OidcService) createAuthorizationCode(clientID string, userID string, scope string, nonce string, codeChallenge string, codeChallengeMethod string) (string, error) {
|
||||||
randomString, err := utils.GenerateRandomAlphanumericString(32)
|
randomString, err := utils.GenerateRandomAlphanumericString(32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -506,12 +548,12 @@ func (s *OidcService) validateCodeVerifier(codeVerifier, codeChallenge string, c
|
|||||||
return encodedVerifierHash == codeChallenge
|
return encodedVerifierHash == codeChallenge
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OidcService) getCallbackURL(client model.OidcClient, inputCallbackURL string) (callbackURL string, err error) {
|
func (s *OidcService) getCallbackURL(urls []string, inputCallbackURL string) (callbackURL string, err error) {
|
||||||
if inputCallbackURL == "" {
|
if inputCallbackURL == "" {
|
||||||
return client.CallbackURLs[0], nil
|
return urls[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, callbackPattern := range client.CallbackURLs {
|
for _, callbackPattern := range urls {
|
||||||
regexPattern := strings.ReplaceAll(regexp.QuoteMeta(callbackPattern), `\*`, ".*") + "$"
|
regexPattern := strings.ReplaceAll(regexp.QuoteMeta(callbackPattern), `\*`, ".*") + "$"
|
||||||
matched, err := regexp.MatchString(regexPattern, inputCallbackURL)
|
matched, err := regexp.MatchString(regexPattern, inputCallbackURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"crypto/ecdsa"
|
"crypto/ecdsa"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@@ -11,8 +12,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/pocket-id/pocket-id/backend/resources"
|
|
||||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/resources"
|
||||||
|
|
||||||
"github.com/go-webauthn/webauthn/protocol"
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
@@ -23,11 +24,12 @@ import (
|
|||||||
|
|
||||||
type TestService struct {
|
type TestService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
jwtService *JwtService
|
||||||
appConfigService *AppConfigService
|
appConfigService *AppConfigService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTestService(db *gorm.DB, appConfigService *AppConfigService) *TestService {
|
func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService) *TestService {
|
||||||
return &TestService{db: db, appConfigService: appConfigService}
|
return &TestService{db: db, appConfigService: appConfigService, jwtService: jwtService}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TestService) SeedDatabase() error {
|
func (s *TestService) SeedDatabase() error {
|
||||||
@@ -112,11 +114,12 @@ func (s *TestService) SeedDatabase() error {
|
|||||||
Base: model.Base{
|
Base: model.Base{
|
||||||
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
|
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
|
||||||
},
|
},
|
||||||
Name: "Nextcloud",
|
Name: "Nextcloud",
|
||||||
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
|
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
|
||||||
CallbackURLs: model.CallbackURLs{"http://nextcloud/auth/callback"},
|
CallbackURLs: model.UrlList{"http://nextcloud/auth/callback"},
|
||||||
ImageType: utils.StringPointer("png"),
|
LogoutCallbackURLs: model.UrlList{"http://nextcloud/auth/logout/callback"},
|
||||||
CreatedByID: users[0].ID,
|
ImageType: utils.StringPointer("png"),
|
||||||
|
CreatedByID: users[0].ID,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Base: model.Base{
|
Base: model.Base{
|
||||||
@@ -124,7 +127,7 @@ func (s *TestService) SeedDatabase() error {
|
|||||||
},
|
},
|
||||||
Name: "Immich",
|
Name: "Immich",
|
||||||
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
|
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
|
||||||
CallbackURLs: model.CallbackURLs{"http://immich/auth/callback"},
|
CallbackURLs: model.UrlList{"http://immich/auth/callback"},
|
||||||
CreatedByID: users[1].ID,
|
CreatedByID: users[1].ID,
|
||||||
AllowedUserGroups: []model.UserGroup{
|
AllowedUserGroups: []model.UserGroup{
|
||||||
userGroups[1],
|
userGroups[1],
|
||||||
@@ -288,6 +291,43 @@ func (s *TestService) ResetAppConfig() error {
|
|||||||
return s.appConfigService.LoadDbConfigFromDb()
|
return s.appConfigService.LoadDbConfigFromDb()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *TestService) SetJWTKeys() {
|
||||||
|
privateKeyString := `-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
MIIEpQIBAAKCAQEAyaeEL0VKoPBXIAaWXsUgmu05lAvEIIdJn0FX9lHh4JE5UY9B
|
||||||
|
83C5sCNdhs9iSWzpeP11EVjWp8i3Yv2CF7c7u50BXnVBGtxpZpFC+585UXacoJ0c
|
||||||
|
hUmarL9GRFJcM1nPHBTFu68aRrn1rIKNHUkNaaxFo0NFGl/4EDDTO8HwawTjwkPo
|
||||||
|
QlRzeByhlvGPVvwgB3Fn93B8QJ/cZhXKxJvjjrC/8Pk76heC/ntEMru71Ix77BoC
|
||||||
|
3j2TuyiN7m9RNBW8BU5q6lKoIdvIeZfTFLzi37iufyfvMrJTixp9zhNB1NxlLCeO
|
||||||
|
Zl2MXegtiGqd2H3cbAyqoOiv9ihUWTfXj7SxJwIDAQABAoIBAQCa8wNZJ08+9y6b
|
||||||
|
RzSIQcTaBuq1XY0oyYvCuX0ToruDyVNX3lJ48udb9vDIw9XsQans9CTeXXsjldGE
|
||||||
|
WPN7sapOcUg6ArMyJqc+zuO/YQu0EwYrTE48BOC7WIZvvTFnq9y+4R9HJjd0nTOv
|
||||||
|
iOlR1W5fAqbH2srgh1mfZ0UIp+9K6ymoinPXVGEXUAuuoMuTEZW/tnA2HT9WEllT
|
||||||
|
2FyMbmXrFzutAQqk9GRmnQh2OQZLxnQWyShVqJEhYBtm6JUUH1YJbyTVzMLgdBM8
|
||||||
|
ukgjTVtRDHaW51ubRSVdGBVT2m1RRtTsYAiZCpM5bwt88aSUS9yDOUiVH+irDg/3
|
||||||
|
IHEuL7IxAoGBAP2MpXPXtOwinajUQ9hKLDAtpq4axGvY+aGP5dNEMsuPo5ggOfUP
|
||||||
|
b4sqr73kaNFO3EbxQOQVoFjehhi4dQxt1/kAala9HZ5N7s26G2+eUWFF8jy7gWSN
|
||||||
|
qusNqGrG4g8D3WOyqZFb/x/m6SE0Jcg7zvIYbnAOq1Fexeik0Fc/DNzLAoGBAMua
|
||||||
|
d4XIfu4ydtU5AIaf1ZNXywgLg+LWxK8ELNqH/Y2vLAeIiTrOVp+hw9z+zHPD5cnu
|
||||||
|
6mix783PCOYNLTylrwtAz3fxSz14lsDFQM3ntzVF/6BniTTkKddctcPyqnTvamah
|
||||||
|
0hD2dzXBS/0mTBYIIMYTNbs0Yj87FTdJZw/+qa2VAoGBAKbzQkp54W6PCIMPabD0
|
||||||
|
fg4nMRZ5F5bv4seIKcunn068QPs9VQxQ4qCfNeLykDYqGA86cgD9YHzD4UZLxv6t
|
||||||
|
IUWbCWod0m/XXwPlpIUlmO5VEUD+MiAUzFNDxf6xAE7ku5UXImJNUjseX6l2Xd5v
|
||||||
|
yz9L6QQuFI5aujQKugiIwp5rAoGATtUVGCCkPNgfOLmkYXu7dxxUCV5kB01+xAEK
|
||||||
|
2OY0n0pG8vfDophH4/D/ZC7nvJ8J9uDhs/3JStexq1lIvaWtG99RNTChIEDzpdn6
|
||||||
|
GH9yaVcb/eB4uJjrNm64FhF8PGCCwxA+xMCZMaARKwhMB2/IOMkxUbWboL3gnhJ2
|
||||||
|
rDO/QO0CgYEA2Grt6uXHm61ji3xSdkBWNtUnj19vS1+7rFJp5SoYztVQVThf/W52
|
||||||
|
BAiXKBdYZDRVoItC/VS2NvAOjeJjhYO/xQ/q3hK7MdtuXfEPpLnyXKkmWo3lrJ26
|
||||||
|
wbeF6l05LexCkI7ShsOuSt+dsyaTJTszuKDIA6YOfWvfo3aVZmlWRaI=
|
||||||
|
-----END RSA PRIVATE KEY-----
|
||||||
|
`
|
||||||
|
|
||||||
|
block, _ := pem.Decode([]byte(privateKeyString))
|
||||||
|
privateKey, _ := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||||
|
|
||||||
|
s.jwtService.PrivateKey = privateKey
|
||||||
|
s.jwtService.PublicKey = &privateKey.PublicKey
|
||||||
|
}
|
||||||
|
|
||||||
// getCborPublicKey decodes a Base64 encoded public key and returns the CBOR encoded COSE key
|
// getCborPublicKey decodes a Base64 encoded public key and returns the CBOR encoded COSE key
|
||||||
func (s *TestService) getCborPublicKey(base64PublicKey string) ([]byte, error) {
|
func (s *TestService) getCborPublicKey(base64PublicKey string) ([]byte, error) {
|
||||||
decodedKey, err := base64.StdEncoding.DecodeString(base64PublicKey)
|
decodedKey, err := base64.StdEncoding.DecodeString(base64PublicKey)
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import (
|
|||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
||||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -62,3 +64,12 @@ func CamelCaseToSnakeCase(s string) string {
|
|||||||
}
|
}
|
||||||
return string(result)
|
return string(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CamelCaseToScreamingSnakeCase(s string) string {
|
||||||
|
// Insert underscores before uppercase letters (except the first one)
|
||||||
|
re := regexp.MustCompile(`([a-z0-9])([A-Z])`)
|
||||||
|
snake := re.ReplaceAllString(s, `${1}_${2}`)
|
||||||
|
|
||||||
|
// Convert to uppercase
|
||||||
|
return strings.ToUpper(snake)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE oidc_clients DROP COLUMN logout_callback_urls;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE oidc_clients ADD COLUMN logout_callback_urls JSONB;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE oidc_clients DROP COLUMN logout_callback_urls;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE oidc_clients ADD COLUMN logout_callback_urls BLOB;
|
||||||
9482
frontend/package-lock.json
generated
9482
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pocket-id-frontend",
|
"name": "pocket-id-frontend",
|
||||||
"version": "0.30.0",
|
"version": "0.34.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ process.env.INTERNAL_BACKEND_URL = env.INTERNAL_BACKEND_URL ?? 'http://localhost
|
|||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
const { isSignedIn, isAdmin } = verifyJwt(event.cookies.get(ACCESS_TOKEN_COOKIE_NAME));
|
const { isSignedIn, isAdmin } = verifyJwt(event.cookies.get(ACCESS_TOKEN_COOKIE_NAME));
|
||||||
|
|
||||||
if (event.url.pathname.startsWith('/settings') && !event.url.pathname.startsWith('/login')) {
|
const isUnauthenticatedOnlyPath = event.url.pathname.startsWith('/login');
|
||||||
|
const isPublicPath = ['/authorize', '/health'].includes(event.url.pathname);
|
||||||
|
const isAdminPath = event.url.pathname.startsWith('/settings/admin');
|
||||||
|
|
||||||
|
if (!isUnauthenticatedOnlyPath && !isPublicPath) {
|
||||||
if (!isSignedIn) {
|
if (!isSignedIn) {
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
@@ -21,14 +25,14 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.url.pathname.startsWith('/login') && isSignedIn) {
|
if (isUnauthenticatedOnlyPath && isSignedIn) {
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
headers: { location: '/settings' }
|
headers: { location: '/settings' }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.url.pathname.startsWith('/settings/admin') && !isAdmin) {
|
if (isAdminPath && !isAdmin) {
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
headers: { location: '/settings' }
|
headers: { location: '/settings' }
|
||||||
|
|||||||
@@ -27,15 +27,13 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button onclick={onClick}>
|
<Tooltip.Root closeOnPointerDown={false} {onOpenChange} {open}>
|
||||||
<Tooltip.Root closeOnPointerDown={false} {onOpenChange} {open}>
|
<Tooltip.Trigger class="text-start" onclick={onClick}>{@render children()}</Tooltip.Trigger>
|
||||||
<Tooltip.Trigger>{@render children()}</Tooltip.Trigger>
|
<Tooltip.Content onclick={copyToClipboard}>
|
||||||
<Tooltip.Content onclick={copyToClipboard}>
|
{#if copied}
|
||||||
{#if copied}
|
<span class="flex items-center"><LucideCheck class="mr-1 h-4 w-4" /> Copied</span>
|
||||||
<span class="flex items-center"><LucideCheck class="mr-1 h-4 w-4" /> Copied</span>
|
{:else}
|
||||||
{:else}
|
<span>Click to copy</span>
|
||||||
<span>Click to copy</span>
|
{/if}
|
||||||
{/if}
|
</Tooltip.Content>
|
||||||
</Tooltip.Content>
|
</Tooltip.Root>
|
||||||
</Tooltip.Root>
|
|
||||||
</button>
|
|
||||||
|
|||||||
@@ -5,10 +5,9 @@
|
|||||||
import Logo from '../logo.svelte';
|
import Logo from '../logo.svelte';
|
||||||
import HeaderAvatar from './header-avatar.svelte';
|
import HeaderAvatar from './header-avatar.svelte';
|
||||||
|
|
||||||
let isAuthPage = $derived(
|
const authUrls = ['/authorize', '/login', '/logout'];
|
||||||
!$page.error &&
|
let isAuthPage = $derived(!$page.error && authUrls.includes($page.url.pathname));
|
||||||
($page.url.pathname.startsWith('/authorize') || $page.url.pathname.startsWith('/login'))
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class=" w-full {isAuthPage ? 'absolute top-0 z-10 mt-4' : 'border-b'}">
|
<div class=" w-full {isAuthPage ? 'absolute top-0 z-10 mt-4' : 'border-b'}">
|
||||||
|
|||||||
@@ -1,17 +1,43 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { cn } from '$lib/utils/style.js';
|
||||||
|
import { LucideX } from 'lucide-svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import { type Variant, alertVariants } from './index.js';
|
import { type Variant, alertVariants } from './index.js';
|
||||||
import { cn } from '$lib/utils/style.js';
|
|
||||||
|
|
||||||
type $$Props = HTMLAttributes<HTMLDivElement> & {
|
type $$Props = HTMLAttributes<HTMLDivElement> & {
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
|
dismissibleId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
let className: $$Props['class'] = undefined;
|
let className: $$Props['class'] = undefined;
|
||||||
export let variant: $$Props['variant'] = 'default';
|
export let variant: $$Props['variant'] = 'default';
|
||||||
|
export let dismissibleId: $$Props['dismissibleId'] = undefined;
|
||||||
export { className as class };
|
export { className as class };
|
||||||
|
|
||||||
|
let isVisible = !dismissibleId;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (dismissibleId) {
|
||||||
|
const dismissedAlerts = JSON.parse(localStorage.getItem('dismissed-alerts') || '[]');
|
||||||
|
isVisible = !dismissedAlerts.includes(dismissibleId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
if (dismissibleId) {
|
||||||
|
const dismissedAlerts = JSON.parse(localStorage.getItem('dismissed-alerts') || '[]');
|
||||||
|
localStorage.setItem('dismissed-alerts', JSON.stringify([...dismissedAlerts, dismissibleId]));
|
||||||
|
isVisible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class={cn(alertVariants({ variant }), className)} {...$$restProps} role="alert">
|
{#if isVisible}
|
||||||
<slot />
|
<div class={cn(alertVariants({ variant }), className)} {...$$restProps} role="alert">
|
||||||
</div>
|
<slot />
|
||||||
|
{#if dismissibleId}
|
||||||
|
<button on:click={dismiss} class="absolute top-0 right-0 m-3 text-black dark:text-white"><LucideX class="w-4" /></button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export type AllAppConfig = AppConfig & {
|
|||||||
ldapAttributeUserEmail: string;
|
ldapAttributeUserEmail: string;
|
||||||
ldapAttributeUserFirstName: string;
|
ldapAttributeUserFirstName: string;
|
||||||
ldapAttributeUserLastName: string;
|
ldapAttributeUserLastName: string;
|
||||||
|
ldapAttributeGroupMember: string;
|
||||||
ldapAttributeGroupUniqueIdentifier: string;
|
ldapAttributeGroupUniqueIdentifier: string;
|
||||||
ldapAttributeGroupName: string;
|
ldapAttributeGroupName: string;
|
||||||
ldapAttributeAdminGroup: string;
|
ldapAttributeAdminGroup: string;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type OidcClient = {
|
|||||||
name: string;
|
name: string;
|
||||||
logoURL: string;
|
logoURL: string;
|
||||||
callbackURLs: [string, ...string[]];
|
callbackURLs: [string, ...string[]];
|
||||||
|
logoutCallbackURLs: string[];
|
||||||
hasLogo: boolean;
|
hasLogo: boolean;
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
pkceEnabled: boolean;
|
pkceEnabled: boolean;
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
import userStore from '$lib/stores/user-store';
|
import userStore from '$lib/stores/user-store';
|
||||||
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
|
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
|
||||||
import { startAuthentication } from '@simplewebauthn/browser';
|
import { startAuthentication } from '@simplewebauthn/browser';
|
||||||
import { AxiosError } from 'axios';
|
|
||||||
import { LucideMail, LucideUser, LucideUsers } from 'lucide-svelte';
|
import { LucideMail, LucideUser, LucideUsers } from 'lucide-svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
@@ -60,11 +59,7 @@
|
|||||||
onSuccess(code, callbackURL);
|
onSuccess(code, callbackURL);
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof AxiosError && e.response?.data.error === 'Missing authorization') {
|
errorMessage = getWebauthnErrorMessage(e);
|
||||||
authorizationRequired = true;
|
|
||||||
} else {
|
|
||||||
errorMessage = getWebauthnErrorMessage(e);
|
|
||||||
}
|
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
43
frontend/src/routes/logout/+page.svelte
Normal file
43
frontend/src/routes/logout/+page.svelte
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
||||||
|
import Logo from '$lib/components/logo.svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import WebAuthnService from '$lib/services/webauthn-service';
|
||||||
|
import userStore from '$lib/stores/user-store.js';
|
||||||
|
import { axiosErrorToast } from '$lib/utils/error-util.js';
|
||||||
|
|
||||||
|
let isLoading = $state(false);
|
||||||
|
|
||||||
|
const webauthnService = new WebAuthnService();
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
isLoading = true;
|
||||||
|
await webauthnService
|
||||||
|
.logout()
|
||||||
|
.then(() => goto('/'))
|
||||||
|
.catch(axiosErrorToast);
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Logout</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<SignInWrapper>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<div class="bg-muted rounded-2xl p-3">
|
||||||
|
<Logo class="h-10 w-10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 class="font-playfair mt-5 text-4xl font-bold">Sign out</h1>
|
||||||
|
|
||||||
|
<p class="text-muted-foreground mt-2">
|
||||||
|
Do you want to sign out of Pocket ID with the account <b>{$userStore?.username}</b>?
|
||||||
|
</p>
|
||||||
|
<div class="mt-10 flex w-full justify-stretch gap-2">
|
||||||
|
<Button class="w-full" variant="secondary" onclick={() => history.back()}>Cancel</Button>
|
||||||
|
<Button class="w-full" {isLoading} onclick={signOut}>Sign out</Button>
|
||||||
|
</div>
|
||||||
|
</SignInWrapper>
|
||||||
@@ -62,9 +62,20 @@
|
|||||||
>Please add a passkey to prevent losing access to your account.</Alert.Description
|
>Please add a passkey to prevent losing access to your account.</Alert.Description
|
||||||
>
|
>
|
||||||
</Alert.Root>
|
</Alert.Root>
|
||||||
|
{:else if passkeys.length == 1}
|
||||||
|
<Alert.Root variant="warning" dismissibleId="single-passkey">
|
||||||
|
<LucideAlertTriangle class="size-4" />
|
||||||
|
<Alert.Title>Single Passkey Configured</Alert.Title>
|
||||||
|
<Alert.Description
|
||||||
|
>It is recommended to add more than one passkey to avoid loosing access to your account.</Alert.Description
|
||||||
|
>
|
||||||
|
</Alert.Root>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<fieldset disabled={!$appConfigStore.allowOwnAccountEdit || (!!account.ldapId && $appConfigStore.ldapEnabled)}>
|
<fieldset
|
||||||
|
disabled={!$appConfigStore.allowOwnAccountEdit ||
|
||||||
|
(!!account.ldapId && $appConfigStore.ldapEnabled)}
|
||||||
|
>
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title>Account Details</Card.Title>
|
<Card.Title>Account Details</Card.Title>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { env } from '$env/dynamic/public';
|
||||||
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
|
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
|
||||||
import { openConfirmDialog } from '$lib/components/confirm-dialog';
|
import { openConfirmDialog } from '$lib/components/confirm-dialog';
|
||||||
import FormInput from '$lib/components/form-input.svelte';
|
import FormInput from '$lib/components/form-input.svelte';
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const appConfigService = new AppConfigService();
|
const appConfigService = new AppConfigService();
|
||||||
|
const uiConfigDisabled = env.PUBLIC_UI_CONFIG_DISABLED === 'true';
|
||||||
|
|
||||||
let isSendingTestEmail = $state(false);
|
let isSendingTestEmail = $state(false);
|
||||||
|
|
||||||
@@ -86,46 +88,47 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form onsubmit={onSubmit}>
|
<form onsubmit={onSubmit}>
|
||||||
<h4 class="text-lg font-semibold">SMTP Configuration</h4>
|
<fieldset disabled={uiConfigDisabled}>
|
||||||
<div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2">
|
<h4 class="text-lg font-semibold">SMTP Configuration</h4>
|
||||||
<FormInput label="SMTP Host" bind:input={$inputs.smtpHost} />
|
<div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2">
|
||||||
<FormInput label="SMTP Port" type="number" bind:input={$inputs.smtpPort} />
|
<FormInput label="SMTP Host" bind:input={$inputs.smtpHost} />
|
||||||
<FormInput label="SMTP User" bind:input={$inputs.smtpUser} />
|
<FormInput label="SMTP Port" type="number" bind:input={$inputs.smtpPort} />
|
||||||
<FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} />
|
<FormInput label="SMTP User" bind:input={$inputs.smtpUser} />
|
||||||
<FormInput label="SMTP From" bind:input={$inputs.smtpFrom} />
|
<FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} />
|
||||||
<CheckboxWithLabel
|
<FormInput label="SMTP From" bind:input={$inputs.smtpFrom} />
|
||||||
id="tls"
|
<CheckboxWithLabel
|
||||||
label="TLS"
|
id="tls"
|
||||||
description="Enable TLS for the SMTP connection."
|
label="TLS"
|
||||||
bind:checked={$inputs.smtpTls.value}
|
description="Enable TLS for the SMTP connection."
|
||||||
/>
|
bind:checked={$inputs.smtpTls.value}
|
||||||
<CheckboxWithLabel
|
/>
|
||||||
id="skip-cert-verify"
|
<CheckboxWithLabel
|
||||||
label="Skip Certificate Verification"
|
id="skip-cert-verify"
|
||||||
description="This can be useful for self-signed certificates."
|
label="Skip Certificate Verification"
|
||||||
bind:checked={$inputs.smtpSkipCertVerify.value}
|
description="This can be useful for self-signed certificates."
|
||||||
/>
|
bind:checked={$inputs.smtpSkipCertVerify.value}
|
||||||
</div>
|
/>
|
||||||
<h4 class="mt-10 text-lg font-semibold">Enabled Emails</h4>
|
</div>
|
||||||
<div class="mt-4 flex flex-col gap-5">
|
<h4 class="mt-10 text-lg font-semibold">Enabled Emails</h4>
|
||||||
<CheckboxWithLabel
|
<div class="mt-4 flex flex-col gap-5">
|
||||||
id="email-login-notification"
|
<CheckboxWithLabel
|
||||||
label="Email Login Notification"
|
id="email-login-notification"
|
||||||
description="Send an email to the user when they log in from a new device."
|
label="Email Login Notification"
|
||||||
bind:checked={$inputs.emailLoginNotificationEnabled.value}
|
description="Send an email to the user when they log in from a new device."
|
||||||
/>
|
bind:checked={$inputs.emailLoginNotificationEnabled.value}
|
||||||
<CheckboxWithLabel
|
/>
|
||||||
id="email-one-time-access"
|
<CheckboxWithLabel
|
||||||
label="Email One Time Access"
|
id="email-one-time-access"
|
||||||
description="Allows users to sign in with a link sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry."
|
label="Email One Time Access"
|
||||||
bind:checked={$inputs.emailOneTimeAccessEnabled.value}
|
description="Allows users to sign in with a link sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry."
|
||||||
/>
|
bind:checked={$inputs.emailOneTimeAccessEnabled.value}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
<div class="mt-8 flex flex-wrap justify-end gap-3">
|
<div class="mt-8 flex flex-wrap justify-end gap-3">
|
||||||
<Button isLoading={isSendingTestEmail} variant="secondary" onclick={onTestEmail}
|
<Button isLoading={isSendingTestEmail} variant="secondary" onclick={onTestEmail}
|
||||||
>Send test email</Button
|
>Send test email</Button
|
||||||
>
|
>
|
||||||
<Button type="submit">Save</Button>
|
<Button type="submit" disabled={uiConfigDisabled}>Save</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { env } from '$env/dynamic/public';
|
||||||
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
|
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
|
||||||
import FormInput from '$lib/components/form-input.svelte';
|
import FormInput from '$lib/components/form-input.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
@@ -15,6 +16,7 @@
|
|||||||
callback: (appConfig: Partial<AllAppConfig>) => Promise<void>;
|
callback: (appConfig: Partial<AllAppConfig>) => Promise<void>;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
const uiConfigDisabled = env.PUBLIC_UI_CONFIG_DISABLED === 'true';
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
|
|
||||||
const updatedAppConfig = {
|
const updatedAppConfig = {
|
||||||
@@ -42,28 +44,30 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form onsubmit={onSubmit}>
|
<form onsubmit={onSubmit}>
|
||||||
<div class="flex flex-col gap-5">
|
<fieldset class="flex flex-col gap-5" disabled={uiConfigDisabled}>
|
||||||
<FormInput label="Application Name" bind:input={$inputs.appName} />
|
<div class="flex flex-col gap-5">
|
||||||
<FormInput
|
<FormInput label="Application Name" bind:input={$inputs.appName} />
|
||||||
label="Session Duration"
|
<FormInput
|
||||||
type="number"
|
label="Session Duration"
|
||||||
description="The duration of a session in minutes before the user has to sign in again."
|
type="number"
|
||||||
bind:input={$inputs.sessionDuration}
|
description="The duration of a session in minutes before the user has to sign in again."
|
||||||
/>
|
bind:input={$inputs.sessionDuration}
|
||||||
<CheckboxWithLabel
|
/>
|
||||||
id="self-account-editing"
|
<CheckboxWithLabel
|
||||||
label="Enable Self-Account Editing"
|
id="self-account-editing"
|
||||||
description="Whether the users should be able to edit their own account details."
|
label="Enable Self-Account Editing"
|
||||||
bind:checked={$inputs.allowOwnAccountEdit.value}
|
description="Whether the users should be able to edit their own account details."
|
||||||
/>
|
bind:checked={$inputs.allowOwnAccountEdit.value}
|
||||||
<CheckboxWithLabel
|
/>
|
||||||
id="emails-verified"
|
<CheckboxWithLabel
|
||||||
label="Emails Verified"
|
id="emails-verified"
|
||||||
description="Whether the user's email should be marked as verified for the OIDC clients."
|
label="Emails Verified"
|
||||||
bind:checked={$inputs.emailsVerified.value}
|
description="Whether the user's email should be marked as verified for the OIDC clients."
|
||||||
/>
|
bind:checked={$inputs.emailsVerified.value}
|
||||||
</div>
|
/>
|
||||||
<div class="mt-5 flex justify-end">
|
</div>
|
||||||
<Button {isLoading} type="submit">Save</Button>
|
<div class="mt-5 flex justify-end">
|
||||||
</div>
|
<Button {isLoading} type="submit">Save</Button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { env } from '$env/dynamic/public';
|
||||||
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
|
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
|
||||||
import FormInput from '$lib/components/form-input.svelte';
|
import FormInput from '$lib/components/form-input.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const appConfigService = new AppConfigService();
|
const appConfigService = new AppConfigService();
|
||||||
|
const uiConfigDisabled = env.PUBLIC_UI_CONFIG_DISABLED === 'true';
|
||||||
|
|
||||||
let ldapEnabled = $state(appConfig.ldapEnabled);
|
let ldapEnabled = $state(appConfig.ldapEnabled);
|
||||||
let ldapSyncing = $state(false);
|
let ldapSyncing = $state(false);
|
||||||
@@ -36,6 +38,7 @@
|
|||||||
ldapAttributeUserEmail: appConfig.ldapAttributeUserEmail,
|
ldapAttributeUserEmail: appConfig.ldapAttributeUserEmail,
|
||||||
ldapAttributeUserFirstName: appConfig.ldapAttributeUserFirstName,
|
ldapAttributeUserFirstName: appConfig.ldapAttributeUserFirstName,
|
||||||
ldapAttributeUserLastName: appConfig.ldapAttributeUserLastName,
|
ldapAttributeUserLastName: appConfig.ldapAttributeUserLastName,
|
||||||
|
ldapAttributeGroupMember: appConfig.ldapAttributeGroupMember,
|
||||||
ldapAttributeGroupUniqueIdentifier: appConfig.ldapAttributeGroupUniqueIdentifier,
|
ldapAttributeGroupUniqueIdentifier: appConfig.ldapAttributeGroupUniqueIdentifier,
|
||||||
ldapAttributeGroupName: appConfig.ldapAttributeGroupName,
|
ldapAttributeGroupName: appConfig.ldapAttributeGroupName,
|
||||||
ldapAttributeAdminGroup: appConfig.ldapAttributeAdminGroup
|
ldapAttributeAdminGroup: appConfig.ldapAttributeAdminGroup
|
||||||
@@ -54,6 +57,7 @@
|
|||||||
ldapAttributeUserEmail: z.string().min(1),
|
ldapAttributeUserEmail: z.string().min(1),
|
||||||
ldapAttributeUserFirstName: z.string().min(1),
|
ldapAttributeUserFirstName: z.string().min(1),
|
||||||
ldapAttributeUserLastName: z.string().min(1),
|
ldapAttributeUserLastName: z.string().min(1),
|
||||||
|
ldapAttributeGroupMember: z.string(),
|
||||||
ldapAttributeGroupUniqueIdentifier: z.string().min(1),
|
ldapAttributeGroupUniqueIdentifier: z.string().min(1),
|
||||||
ldapAttributeGroupName: z.string().min(1),
|
ldapAttributeGroupName: z.string().min(1),
|
||||||
ldapAttributeAdminGroup: z.string()
|
ldapAttributeAdminGroup: z.string()
|
||||||
@@ -97,88 +101,104 @@
|
|||||||
|
|
||||||
<form onsubmit={onSubmit}>
|
<form onsubmit={onSubmit}>
|
||||||
<h4 class="text-lg font-semibold">Client Configuration</h4>
|
<h4 class="text-lg font-semibold">Client Configuration</h4>
|
||||||
<div class="mt-4 grid grid-cols-1 items-start gap-5 md:grid-cols-2">
|
<fieldset disabled={uiConfigDisabled}>
|
||||||
<FormInput label="LDAP URL" placeholder="ldap://example.com:389" bind:input={$inputs.ldapUrl} />
|
<div class="mt-4 grid grid-cols-1 items-start gap-5 md:grid-cols-2">
|
||||||
<FormInput
|
<FormInput
|
||||||
label="LDAP Bind DN"
|
label="LDAP URL"
|
||||||
placeholder="cn=people,dc=example,dc=com"
|
placeholder="ldap://example.com:389"
|
||||||
bind:input={$inputs.ldapBindDn}
|
bind:input={$inputs.ldapUrl}
|
||||||
/>
|
/>
|
||||||
<FormInput label="LDAP Bind Password" type="password" bind:input={$inputs.ldapBindPassword} />
|
<FormInput
|
||||||
<FormInput label="LDAP Base DN" placeholder="dc=example,dc=com" bind:input={$inputs.ldapBase} />
|
label="LDAP Bind DN"
|
||||||
<FormInput
|
placeholder="cn=people,dc=example,dc=com"
|
||||||
label="User Search Filter"
|
bind:input={$inputs.ldapBindDn}
|
||||||
description="The Search filter to use to search/sync users."
|
/>
|
||||||
placeholder="(objectClass=person)"
|
<FormInput label="LDAP Bind Password" type="password" bind:input={$inputs.ldapBindPassword} />
|
||||||
bind:input={$inputs.ldapUserSearchFilter}
|
<FormInput
|
||||||
/>
|
label="LDAP Base DN"
|
||||||
<FormInput
|
placeholder="dc=example,dc=com"
|
||||||
label="Groups Search Filter"
|
bind:input={$inputs.ldapBase}
|
||||||
description="The Search filter to use to search/sync groups."
|
/>
|
||||||
placeholder="(objectClass=groupOfNames)"
|
<FormInput
|
||||||
bind:input={$inputs.ldapUserGroupSearchFilter}
|
label="User Search Filter"
|
||||||
/>
|
description="The Search filter to use to search/sync users."
|
||||||
<CheckboxWithLabel
|
placeholder="(objectClass=person)"
|
||||||
id="skip-cert-verify"
|
bind:input={$inputs.ldapUserSearchFilter}
|
||||||
label="Skip Certificate Verification"
|
/>
|
||||||
description="This can be useful for self-signed certificates."
|
<FormInput
|
||||||
bind:checked={$inputs.ldapSkipCertVerify.value}
|
label="Groups Search Filter"
|
||||||
/>
|
description="The Search filter to use to search/sync groups."
|
||||||
</div>
|
placeholder="(objectClass=groupOfNames)"
|
||||||
<h4 class="mt-10 text-lg font-semibold">Attribute Mapping</h4>
|
bind:input={$inputs.ldapUserGroupSearchFilter}
|
||||||
<div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2">
|
/>
|
||||||
<FormInput
|
<CheckboxWithLabel
|
||||||
label="User Unique Identifier Attribute"
|
id="skip-cert-verify"
|
||||||
description="The value of this attribute should never change."
|
label="Skip Certificate Verification"
|
||||||
placeholder="uuid"
|
description="This can be useful for self-signed certificates."
|
||||||
bind:input={$inputs.ldapAttributeUserUniqueIdentifier}
|
bind:checked={$inputs.ldapSkipCertVerify.value}
|
||||||
/>
|
/>
|
||||||
<FormInput
|
</div>
|
||||||
label="Username Attribute"
|
<h4 class="mt-10 text-lg font-semibold">Attribute Mapping</h4>
|
||||||
placeholder="uid"
|
<div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2">
|
||||||
bind:input={$inputs.ldapAttributeUserUsername}
|
<FormInput
|
||||||
/>
|
label="User Unique Identifier Attribute"
|
||||||
<FormInput
|
description="The value of this attribute should never change."
|
||||||
label="User Mail Attribute"
|
placeholder="uuid"
|
||||||
placeholder="mail"
|
bind:input={$inputs.ldapAttributeUserUniqueIdentifier}
|
||||||
bind:input={$inputs.ldapAttributeUserEmail}
|
/>
|
||||||
/>
|
<FormInput
|
||||||
<FormInput
|
label="Username Attribute"
|
||||||
label="User First Name Attribute"
|
placeholder="uid"
|
||||||
placeholder="givenName"
|
bind:input={$inputs.ldapAttributeUserUsername}
|
||||||
bind:input={$inputs.ldapAttributeUserFirstName}
|
/>
|
||||||
/>
|
<FormInput
|
||||||
<FormInput
|
label="User Mail Attribute"
|
||||||
label="User Last Name Attribute"
|
placeholder="mail"
|
||||||
placeholder="sn"
|
bind:input={$inputs.ldapAttributeUserEmail}
|
||||||
bind:input={$inputs.ldapAttributeUserLastName}
|
/>
|
||||||
/>
|
<FormInput
|
||||||
<FormInput
|
label="User First Name Attribute"
|
||||||
label="Group Unique Identifier Attribute"
|
placeholder="givenName"
|
||||||
description="The value of this attribute should never change."
|
bind:input={$inputs.ldapAttributeUserFirstName}
|
||||||
placeholder="uuid"
|
/>
|
||||||
bind:input={$inputs.ldapAttributeGroupUniqueIdentifier}
|
<FormInput
|
||||||
/>
|
label="User Last Name Attribute"
|
||||||
<FormInput
|
placeholder="sn"
|
||||||
label="Group Name Attribute"
|
bind:input={$inputs.ldapAttributeUserLastName}
|
||||||
placeholder="cn"
|
/>
|
||||||
bind:input={$inputs.ldapAttributeGroupName}
|
<FormInput
|
||||||
/>
|
label="Group Members Attribute"
|
||||||
<FormInput
|
description="The attribute to use for querying members of a group."
|
||||||
label="Admin Group Name"
|
placeholder="member"
|
||||||
description="Members of this group will have Admin Privileges in Pocket ID."
|
bind:input={$inputs.ldapAttributeGroupMember}
|
||||||
placeholder="_admin_group_name"
|
/>
|
||||||
bind:input={$inputs.ldapAttributeAdminGroup}
|
<FormInput
|
||||||
/>
|
label="Group Unique Identifier Attribute"
|
||||||
</div>
|
description="The value of this attribute should never change."
|
||||||
|
placeholder="uuid"
|
||||||
|
bind:input={$inputs.ldapAttributeGroupUniqueIdentifier}
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
label="Group Name Attribute"
|
||||||
|
placeholder="cn"
|
||||||
|
bind:input={$inputs.ldapAttributeGroupName}
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
label="Admin Group Name"
|
||||||
|
description="Members of this group will have Admin Privileges in Pocket ID."
|
||||||
|
placeholder="_admin_group_name"
|
||||||
|
bind:input={$inputs.ldapAttributeAdminGroup}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<div class="mt-8 flex flex-wrap justify-end gap-3">
|
<div class="mt-8 flex flex-wrap justify-end gap-3">
|
||||||
{#if ldapEnabled}
|
{#if ldapEnabled}
|
||||||
<Button variant="secondary" onclick={onDisable}>Disable</Button>
|
<Button variant="secondary" onclick={onDisable} disabled={uiConfigDisabled}>Disable</Button>
|
||||||
<Button variant="secondary" onclick={syncLdap} isLoading={ldapSyncing}>Sync now</Button>
|
<Button variant="secondary" onclick={syncLdap} isLoading={ldapSyncing}>Sync now</Button>
|
||||||
<Button type="submit">Save</Button>
|
<Button type="submit" disabled={uiConfigDisabled}>Save</Button>
|
||||||
{:else}
|
{:else}
|
||||||
<Button onclick={onEnable}>Enable</Button>
|
<Button onclick={onEnable} disabled={uiConfigDisabled}>Enable</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { beforeNavigate } from '$app/navigation';
|
import { beforeNavigate } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
|
|
||||||
import { openConfirmDialog } from '$lib/components/confirm-dialog';
|
import { openConfirmDialog } from '$lib/components/confirm-dialog';
|
||||||
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
|
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
@@ -17,6 +16,7 @@
|
|||||||
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 UserGroupSelection from '../user-group-selection.svelte';
|
import UserGroupSelection from '../user-group-selection.svelte';
|
||||||
|
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
let client = $state({
|
let client = $state({
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`,
|
'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`,
|
||||||
'Token URL': `https://${$page.url.hostname}/api/oidc/token`,
|
'Token URL': `https://${$page.url.hostname}/api/oidc/token`,
|
||||||
'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`,
|
'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`,
|
||||||
|
'Logout URL': `https://${$page.url.hostname}/api/oidc/end-session`,
|
||||||
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`,
|
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`,
|
||||||
PKCE: client.pkceEnabled ? 'Enabled' : 'Disabled'
|
PKCE: client.pkceEnabled ? 'Enabled' : 'Disabled'
|
||||||
});
|
});
|
||||||
@@ -112,15 +113,15 @@
|
|||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div class="mb-2 flex">
|
<div class="mb-2 flex flex-col sm:flex-row sm:items-center">
|
||||||
<Label class="mb-0 w-44">Client ID</Label>
|
<Label class="mb-0 w-44">Client ID</Label>
|
||||||
<CopyToClipboard value={client.id}>
|
<CopyToClipboard value={client.id}>
|
||||||
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
|
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
|
||||||
</CopyToClipboard>
|
</CopyToClipboard>
|
||||||
</div>
|
</div>
|
||||||
{#if !client.isPublic}
|
{#if !client.isPublic}
|
||||||
<div class="mb-2 mt-1 flex items-center">
|
<div class="mb-2 mt-1 flex flex-col sm:flex-row sm:items-center">
|
||||||
<Label class="w-44">Client secret</Label>
|
<Label class="mb-0 w-44">Client secret</Label>
|
||||||
{#if $clientSecretStore}
|
{#if $clientSecretStore}
|
||||||
<CopyToClipboard value={$clientSecretStore}>
|
<CopyToClipboard value={$clientSecretStore}>
|
||||||
<span class="text-muted-foreground text-sm" data-testid="client-secret">
|
<span class="text-muted-foreground text-sm" data-testid="client-secret">
|
||||||
@@ -128,23 +129,25 @@
|
|||||||
</span>
|
</span>
|
||||||
</CopyToClipboard>
|
</CopyToClipboard>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-muted-foreground text-sm" data-testid="client-secret"
|
<div>
|
||||||
>••••••••••••••••••••••••••••••••</span
|
<span class="text-muted-foreground text-sm" data-testid="client-secret"
|
||||||
>
|
>••••••••••••••••••••••••••••••••</span
|
||||||
<Button
|
>
|
||||||
class="ml-2"
|
<Button
|
||||||
onclick={createClientSecret}
|
class="ml-2"
|
||||||
size="sm"
|
onclick={createClientSecret}
|
||||||
variant="ghost"
|
size="sm"
|
||||||
aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button
|
variant="ghost"
|
||||||
>
|
aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if showAllDetails}
|
{#if showAllDetails}
|
||||||
<div transition:slide>
|
<div transition:slide>
|
||||||
{#each Object.entries(setupDetails) as [key, value]}
|
{#each Object.entries(setupDetails) as [key, value]}
|
||||||
<div class="mb-5 flex">
|
<div class="mb-5 flex flex-col sm:flex-row sm:items-center">
|
||||||
<Label class="mb-0 w-44">{key}</Label>
|
<Label class="mb-0 w-44">{key}</Label>
|
||||||
<CopyToClipboard {value}>
|
<CopyToClipboard {value}>
|
||||||
<span class="text-muted-foreground text-sm">{value}</span>
|
<span class="text-muted-foreground text-sm">{value}</span>
|
||||||
|
|||||||
@@ -7,12 +7,16 @@
|
|||||||
import type { HTMLAttributes } from 'svelte/elements';
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
label,
|
||||||
callbackURLs = $bindable(),
|
callbackURLs = $bindable(),
|
||||||
error = $bindable(null),
|
error = $bindable(null),
|
||||||
|
allowEmpty = false,
|
||||||
...restProps
|
...restProps
|
||||||
}: HTMLAttributes<HTMLDivElement> & {
|
}: HTMLAttributes<HTMLDivElement> & {
|
||||||
|
label: string;
|
||||||
callbackURLs: string[];
|
callbackURLs: string[];
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
|
allowEmpty?: boolean;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -20,12 +24,12 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div {...restProps}>
|
<div {...restProps}>
|
||||||
<FormInput label="Callback URLs">
|
<FormInput {label}>
|
||||||
<div class="flex flex-col gap-y-2">
|
<div class="flex flex-col gap-y-2">
|
||||||
{#each callbackURLs as _, i}
|
{#each callbackURLs as _, i}
|
||||||
<div class="flex gap-x-2">
|
<div class="flex gap-x-2">
|
||||||
<Input data-testid={`callback-url-${i + 1}`} bind:value={callbackURLs[i]} />
|
<Input data-testid={`callback-url-${i + 1}`} bind:value={callbackURLs[i]} />
|
||||||
{#if callbackURLs.length > 1}
|
{#if callbackURLs.length > 1 || allowEmpty}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -49,7 +53,7 @@
|
|||||||
on:click={() => (callbackURLs = [...callbackURLs, ''])}
|
on:click={() => (callbackURLs = [...callbackURLs, ''])}
|
||||||
>
|
>
|
||||||
<LucidePlus class="mr-1 h-4 w-4" />
|
<LucidePlus class="mr-1 h-4 w-4" />
|
||||||
Add another
|
{callbackURLs.length === 0 ? 'Add' : 'Add another'}
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
OidcClientCreateWithLogo
|
OidcClientCreateWithLogo
|
||||||
} from '$lib/types/oidc.type';
|
} from '$lib/types/oidc.type';
|
||||||
import { createForm } from '$lib/utils/form-util';
|
import { createForm } from '$lib/utils/form-util';
|
||||||
import { set, z } from 'zod';
|
import { z } from 'zod';
|
||||||
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
|
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
const client: OidcClientCreate = {
|
const client: OidcClientCreate = {
|
||||||
name: existingClient?.name || '',
|
name: existingClient?.name || '',
|
||||||
callbackURLs: existingClient?.callbackURLs || [''],
|
callbackURLs: existingClient?.callbackURLs || [''],
|
||||||
|
logoutCallbackURLs: existingClient?.logoutCallbackURLs || [],
|
||||||
isPublic: existingClient?.isPublic || false,
|
isPublic: existingClient?.isPublic || false,
|
||||||
pkceEnabled: existingClient?.isPublic == true || existingClient?.pkceEnabled || false
|
pkceEnabled: existingClient?.isPublic == true || existingClient?.pkceEnabled || false
|
||||||
};
|
};
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().min(2).max(50),
|
name: z.string().min(2).max(50),
|
||||||
callbackURLs: z.array(z.string()).nonempty(),
|
callbackURLs: z.array(z.string()).nonempty(),
|
||||||
|
logoutCallbackURLs: z.array(z.string()),
|
||||||
isPublic: z.boolean(),
|
isPublic: z.boolean(),
|
||||||
pkceEnabled: z.boolean()
|
pkceEnabled: z.boolean()
|
||||||
});
|
});
|
||||||
@@ -76,13 +78,22 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form onsubmit={onSubmit}>
|
<form onsubmit={onSubmit}>
|
||||||
<div class="grid grid-cols-2 gap-x-3 gap-y-7 sm:flex-row">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-7 sm:flex-row">
|
||||||
<FormInput label="Name" class="w-full" bind:input={$inputs.name} />
|
<FormInput label="Name" class="w-full" bind:input={$inputs.name} />
|
||||||
|
<div></div>
|
||||||
<OidcCallbackUrlInput
|
<OidcCallbackUrlInput
|
||||||
|
label="Callback URLs"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
bind:callbackURLs={$inputs.callbackURLs.value}
|
bind:callbackURLs={$inputs.callbackURLs.value}
|
||||||
bind:error={$inputs.callbackURLs.error}
|
bind:error={$inputs.callbackURLs.error}
|
||||||
/>
|
/>
|
||||||
|
<OidcCallbackUrlInput
|
||||||
|
label="Logout Callback URLs"
|
||||||
|
class="w-full"
|
||||||
|
allowEmpty
|
||||||
|
bind:callbackURLs={$inputs.logoutCallbackURLs.value}
|
||||||
|
bind:error={$inputs.logoutCallbackURLs.error}
|
||||||
|
/>
|
||||||
<CheckboxWithLabel
|
<CheckboxWithLabel
|
||||||
id="public-client"
|
id="public-client"
|
||||||
label="Public Client"
|
label="Public Client"
|
||||||
@@ -104,7 +115,7 @@
|
|||||||
<Label for="logo">Logo</Label>
|
<Label for="logo">Logo</Label>
|
||||||
<div class="mt-2 flex items-end gap-3">
|
<div class="mt-2 flex items-end gap-3">
|
||||||
{#if logoDataURL}
|
{#if logoDataURL}
|
||||||
<div class="h-32 w-32 rounded-2xl bg-muted p-3">
|
<div class="bg-muted h-32 w-32 rounded-2xl p-3">
|
||||||
<img
|
<img
|
||||||
class="m-auto max-h-full max-w-full object-contain"
|
class="m-auto max-h-full max-w-full object-contain"
|
||||||
src={logoDataURL}
|
src={logoDataURL}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import AdvancedTable from '$lib/components/advanced-table.svelte';
|
import AdvancedTable from '$lib/components/advanced-table.svelte';
|
||||||
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
|
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
|
||||||
|
import { Badge } from '$lib/components/ui/badge/index';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||||
import * as Table from '$lib/components/ui/table';
|
import * as Table from '$lib/components/ui/table';
|
||||||
@@ -35,7 +36,7 @@
|
|||||||
toast.success('User group deleted successfully');
|
toast.success('User group deleted successfully');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
axiosErrorToast(e);
|
axiosErrorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -50,6 +51,7 @@
|
|||||||
{ label: 'Friendly Name', sortColumn: 'friendlyName' },
|
{ label: 'Friendly Name', sortColumn: 'friendlyName' },
|
||||||
{ label: 'Name', sortColumn: 'name' },
|
{ label: 'Name', sortColumn: 'name' },
|
||||||
{ label: 'User Count', sortColumn: 'userCount' },
|
{ label: 'User Count', sortColumn: 'userCount' },
|
||||||
|
...($appConfigStore.ldapEnabled ? [{ label: 'Source' }] : []),
|
||||||
{ label: 'Actions', hidden: true }
|
{ label: 'Actions', hidden: true }
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@@ -57,6 +59,12 @@
|
|||||||
<Table.Cell>{item.friendlyName}</Table.Cell>
|
<Table.Cell>{item.friendlyName}</Table.Cell>
|
||||||
<Table.Cell>{item.name}</Table.Cell>
|
<Table.Cell>{item.name}</Table.Cell>
|
||||||
<Table.Cell>{item.userCount}</Table.Cell>
|
<Table.Cell>{item.userCount}</Table.Cell>
|
||||||
|
{#if $appConfigStore.ldapEnabled}
|
||||||
|
<Table.Cell>
|
||||||
|
<Badge variant={item.ldapId ? 'default' : 'outline'}>{item.ldapId ? 'LDAP' : 'Local'}</Badge
|
||||||
|
>
|
||||||
|
</Table.Cell>
|
||||||
|
{/if}
|
||||||
<Table.Cell class="flex justify-end">
|
<Table.Cell class="flex justify-end">
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger asChild let:builder>
|
<DropdownMenu.Trigger asChild let:builder>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||||
import * as Table from '$lib/components/ui/table';
|
import * as Table from '$lib/components/ui/table';
|
||||||
import UserService from '$lib/services/user-service';
|
import UserService from '$lib/services/user-service';
|
||||||
|
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||||
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
||||||
import type { User } from '$lib/types/user.type';
|
import type { User } from '$lib/types/user.type';
|
||||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
@@ -14,7 +15,6 @@
|
|||||||
import Ellipsis from 'lucide-svelte/icons/ellipsis';
|
import Ellipsis from 'lucide-svelte/icons/ellipsis';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import OneTimeLinkModal from './one-time-link-modal.svelte';
|
import OneTimeLinkModal from './one-time-link-modal.svelte';
|
||||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
|
||||||
|
|
||||||
let { users = $bindable() }: { users: Paginated<User> } = $props();
|
let { users = $bindable() }: { users: Paginated<User> } = $props();
|
||||||
let requestOptions: SearchPaginationSortRequest | undefined = $state();
|
let requestOptions: SearchPaginationSortRequest | undefined = $state();
|
||||||
@@ -49,30 +49,13 @@
|
|||||||
{requestOptions}
|
{requestOptions}
|
||||||
onRefresh={async (options) => (users = await userService.list(options))}
|
onRefresh={async (options) => (users = await userService.list(options))}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{ label: 'First name', sortColumn: 'firstName' },
|
||||||
label: 'First name',
|
{ label: 'Last name', sortColumn: 'lastName' },
|
||||||
sortColumn: 'firstName'
|
{ label: 'Email', sortColumn: 'email' },
|
||||||
},
|
{ label: 'Username', sortColumn: 'username' },
|
||||||
{
|
{ label: 'Role', sortColumn: 'isAdmin' },
|
||||||
label: 'Last name',
|
...($appConfigStore.ldapEnabled ? [{ label: 'Source' }] : []),
|
||||||
sortColumn: 'lastName'
|
{ label: 'Actions', hidden: true }
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Email',
|
|
||||||
sortColumn: 'email'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Username',
|
|
||||||
sortColumn: 'username'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Role',
|
|
||||||
sortColumn: 'isAdmin'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Actions',
|
|
||||||
hidden: true
|
|
||||||
}
|
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{#snippet rows({ item })}
|
{#snippet rows({ item })}
|
||||||
@@ -80,9 +63,15 @@
|
|||||||
<Table.Cell>{item.lastName}</Table.Cell>
|
<Table.Cell>{item.lastName}</Table.Cell>
|
||||||
<Table.Cell>{item.email}</Table.Cell>
|
<Table.Cell>{item.email}</Table.Cell>
|
||||||
<Table.Cell>{item.username}</Table.Cell>
|
<Table.Cell>{item.username}</Table.Cell>
|
||||||
<Table.Cell class="hidden lg:table-cell">
|
<Table.Cell>
|
||||||
<Badge variant="outline">{item.isAdmin ? 'Admin' : 'User'}</Badge>
|
<Badge variant="outline">{item.isAdmin ? 'Admin' : 'User'}</Badge>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
{#if $appConfigStore.ldapEnabled}
|
||||||
|
<Table.Cell>
|
||||||
|
<Badge variant={item.ldapId ? 'default' : 'outline'}>{item.ldapId ? 'LDAP' : 'Local'}</Badge
|
||||||
|
>
|
||||||
|
</Table.Cell>
|
||||||
|
{/if}
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger class={buttonVariants({ variant: 'ghost', size: 'icon' })}>
|
<DropdownMenu.Trigger class={buttonVariants({ variant: 'ghost', size: 'icon' })}>
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ export const oidcClients = {
|
|||||||
nextcloud: {
|
nextcloud: {
|
||||||
id: '3654a746-35d4-4321-ac61-0bdcff2b4055',
|
id: '3654a746-35d4-4321-ac61-0bdcff2b4055',
|
||||||
name: 'Nextcloud',
|
name: 'Nextcloud',
|
||||||
callbackUrl: 'http://nextcloud/auth/callback'
|
callbackUrl: 'http://nextcloud/auth/callback',
|
||||||
|
logoutCallbackUrl: 'http://nextcloud/auth/logout/callback'
|
||||||
},
|
},
|
||||||
immich: {
|
immich: {
|
||||||
id: '606c7782-f2b1-49e5-8ea9-26eb1b06d018',
|
id: '606c7782-f2b1-49e5-8ea9-26eb1b06d018',
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ test('Edit OIDC client', async ({ page }) => {
|
|||||||
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
|
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
|
||||||
|
|
||||||
await page.getByLabel('Name').fill('Nextcloud updated');
|
await page.getByLabel('Name').fill('Nextcloud updated');
|
||||||
await page.getByTestId('callback-url-1').fill('http://nextcloud-updated/auth/callback');
|
await page.getByTestId('callback-url-1').first().fill('http://nextcloud-updated/auth/callback');
|
||||||
await page.getByLabel('logo').setInputFiles('tests/assets/nextcloud-logo.png');
|
await page.getByLabel('logo').setInputFiles('tests/assets/nextcloud-logo.png');
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
|
||||||
|
|||||||
@@ -89,10 +89,11 @@ test('Authorize new client fails with user group not allowed', async ({ page })
|
|||||||
|
|
||||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('paragraph').first()).toHaveText("You're not allowed to access this service.");
|
await expect(page.getByRole('paragraph').first()).toHaveText(
|
||||||
|
"You're not allowed to access this service."
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
function createUrlParams(oidcClient: { id: string; callbackUrl: string }) {
|
function createUrlParams(oidcClient: { id: string; callbackUrl: string }) {
|
||||||
return new URLSearchParams({
|
return new URLSearchParams({
|
||||||
client_id: oidcClient.id,
|
client_id: oidcClient.id,
|
||||||
@@ -103,3 +104,33 @@ function createUrlParams(oidcClient: { id: string; callbackUrl: string }) {
|
|||||||
nonce: 'P1gN3PtpKHJgKUVcLpLjm'
|
nonce: 'P1gN3PtpKHJgKUVcLpLjm'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('End session without id token hint shows confirmation page', async ({ page }) => {
|
||||||
|
await page.goto('/api/oidc/end-session');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL('/logout');
|
||||||
|
await page.getByRole('button', { name: 'Sign out' }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL('/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('End session with id token hint redirects to callback URL', async ({ page }) => {
|
||||||
|
const client = oidcClients.nextcloud;
|
||||||
|
const idToken =
|
||||||
|
'eyJhbGciOiJSUzI1NiIsImtpZCI6Ijh1SER3M002cmY4IiwidHlwIjoiSldUIn0.eyJhdWQiOiIzNjU0YTc0Ni0zNWQ0LTQzMjEtYWM2MS0wYmRjZmYyYjQwNTUiLCJlbWFpbCI6InRpbS5jb29rQHRlc3QuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MTY5MDAwMDAwMSwiZmFtaWx5X25hbWUiOiJUaW0iLCJnaXZlbl9uYW1lIjoiQ29vayIsImlhdCI6MTY5MDAwMDAwMCwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdCIsIm5hbWUiOiJUaW0gQ29vayIsIm5vbmNlIjoib1cxQTFPNzhHUTE1RDczT3NIRXg3V1FLajdacXZITFp1XzM3bWRYSXFBUSIsInN1YiI6IjRiODlkYzItNjJmYi00NmJmLTlmNWYtYzM0ZjRlYWZlOTNlIn0.ruYCyjA2BNjROpmLGPNHrhgUNLnpJMEuncvjDYVuv1dAZwvOPfG-Rn-OseAgJDJbV7wJ0qf6ZmBkGWiifwc_B9h--fgd4Vby9fefj0MiHbSDgQyaU5UmpvJU8OlvM-TueD6ICJL0NeT3DwoW5xpIWaHtt3JqJIdP__Q-lTONL2Zokq50kWm0IO-bIw2QrQviSfHNpv8A5rk1RTzpXCPXYNB-eJbm3oBqYQWzerD9HaNrSvrKA7mKG8Te1mI9aMirPpG9FvcAU-I3lY8ky1hJZDu42jHpVEUdWPAmUZPZafoX8iYtlPfkoklDnHj_cdg4aZBGN5bfjM6xf1Oe_rLDWg';
|
||||||
|
|
||||||
|
let redirectedCorrectly = false;
|
||||||
|
await page
|
||||||
|
.goto(
|
||||||
|
`/api/oidc/end-session?id_token_hint=${idToken}&post_logout_redirect_uri=${client.logoutCallbackUrl}`
|
||||||
|
)
|
||||||
|
.catch((e) => {
|
||||||
|
if (e.message.includes('net::ERR_NAME_NOT_RESOLVED')) {
|
||||||
|
redirectedCorrectly = true;
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(redirectedCorrectly).toBeTruthy();
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user