Compare commits

..

20 Commits

Author SHA1 Message Date
Elias Schneider
69afd9ad9f release: 0.24.0 2025-01-11 23:46:39 +01:00
Elias Schneider
fd69830c26 feat: add sorting for tables 2025-01-11 20:32:22 +01:00
Elias Schneider
61d18a9d1b fix: pkce state not correctly reflected in oidc client info 2025-01-10 09:32:51 +01:00
Elias Schneider
a649c4b4a5 fix: send test email to the user that has requested it 2025-01-10 09:25:26 +01:00
Elias Schneider
82e475a923 release: 0.23.0 2025-01-03 16:34:23 +01:00
Elias Schneider
2d31fc2cc9 feat: use same table component for OIDC client list as all other lists 2025-01-03 16:19:15 +01:00
Elias Schneider
adcf3ddc66 feat: add PKCE for non public clients 2025-01-03 16:15:10 +01:00
Elias Schneider
785200de61 chore: include static assets in binary 2025-01-03 15:12:07 +01:00
Elias Schneider
ee885fbff5 release: 0.22.0 2025-01-01 23:13:53 +01:00
Elias Schneider
333a1a18d5 fix: make user validation consistent between pages 2025-01-01 23:13:16 +01:00
Elias Schneider
1ff20caa3c fix: allow first and last name of user to be between 1 and 50 characters 2025-01-01 22:48:51 +01:00
Elias Schneider
f6f2736bba fix: hash in callback url is incorrectly appended 2025-01-01 22:46:59 +01:00
Elias Schneider
993330d932 Merge remote-tracking branch 'origin/main' 2025-01-01 22:46:29 +01:00
Jan-Philipp Fischer
204313aacf docs: add "groups" scope to the oauth2-proxy sample configuration (#85) 2024-12-31 11:31:39 +01:00
Elias Schneider
0729ce9e1a fix: passkey can't be added if PUBLIC_APP_URL includes a port 2024-12-31 10:42:54 +01:00
Elias Schneider
2d0bd8dcbf feat: add warning if passkeys missing 2024-12-23 09:59:12 +01:00
Elias Schneider
ff75322e7d docs: improve text in README 2024-12-20 08:20:40 +01:00
Elias Schneider
daced661c4 release: 0.21.0 2024-12-17 19:58:55 +01:00
Elias Schneider
0716c38fb8 feat: improve error state design for login page 2024-12-17 19:36:47 +01:00
Elias Schneider
789d9394a5 fix: OIDC client logo gets removed if other properties get updated 2024-12-17 19:00:33 +01:00
102 changed files with 705 additions and 352 deletions

View File

@@ -1 +1 @@
0.20.1
0.24.0

View File

@@ -1,3 +1,51 @@
## [](https://github.com/stonith404/pocket-id/compare/v0.23.0...v) (2025-01-11)
### Features
* add sorting for tables ([fd69830](https://github.com/stonith404/pocket-id/commit/fd69830c2681985e4fd3c5336a2b75c9fb7bc5d4))
### Bug Fixes
* pkce state not correctly reflected in oidc client info ([61d18a9](https://github.com/stonith404/pocket-id/commit/61d18a9d1b167ff59a59523ff00d00ca8f23258d))
* send test email to the user that has requested it ([a649c4b](https://github.com/stonith404/pocket-id/commit/a649c4b4a543286123f4d1f3c411fe1a7e2c6d71))
## [](https://github.com/stonith404/pocket-id/compare/v0.22.0...v) (2025-01-03)
### Features
* add PKCE for non public clients ([adcf3dd](https://github.com/stonith404/pocket-id/commit/adcf3ddc6682794e136a454ef9e69ddd130626a8))
* use same table component for OIDC client list as all other lists ([2d31fc2](https://github.com/stonith404/pocket-id/commit/2d31fc2cc9201bb93d296faae622f52c6dcdfebc))
## [](https://github.com/stonith404/pocket-id/compare/v0.21.0...v) (2025-01-01)
### Features
* add warning if passkeys missing ([2d0bd8d](https://github.com/stonith404/pocket-id/commit/2d0bd8dcbfb73650b7829cb66f40decb284bd73b))
### Bug Fixes
* allow first and last name of user to be between 1 and 50 characters ([1ff20ca](https://github.com/stonith404/pocket-id/commit/1ff20caa3ccd651f9fb30f958ffb807dfbbcbd8a))
* hash in callback url is incorrectly appended ([f6f2736](https://github.com/stonith404/pocket-id/commit/f6f2736bba65eee017f2d8cdaa70621574092869))
* make user validation consistent between pages ([333a1a1](https://github.com/stonith404/pocket-id/commit/333a1a18d59f675111f4ed106fa5614ef563c6f4))
* passkey can't be added if `PUBLIC_APP_URL` includes a port ([0729ce9](https://github.com/stonith404/pocket-id/commit/0729ce9e1a8dab9912900a01dcd0fbd892718a1a))
## [](https://github.com/stonith404/pocket-id/compare/v0.20.1...v) (2024-12-17)
### Features
* improve error state design for login page ([0716c38](https://github.com/stonith404/pocket-id/commit/0716c38fb8ce7fa719c7fe0df750bdb213786c21))
### Bug Fixes
* OIDC client logo gets removed if other properties get updated ([789d939](https://github.com/stonith404/pocket-id/commit/789d9394a533831e7e2fb8dc3f6b338787336ad8))
## [](https://github.com/stonith404/pocket-id/compare/v0.20.0...v) (2024-12-13)

View File

@@ -33,9 +33,6 @@ COPY --from=frontend-builder /app/frontend/node_modules ./frontend/node_modules
COPY --from=frontend-builder /app/frontend/package.json ./frontend/package.json
COPY --from=backend-builder /app/backend/pocket-id-backend ./backend/pocket-id-backend
COPY --from=backend-builder /app/backend/migrations ./backend/migrations
COPY --from=backend-builder /app/backend/email-templates ./backend/email-templates
COPY --from=backend-builder /app/backend/images ./backend/images
COPY ./scripts ./scripts
RUN chmod +x ./scripts/*.sh

View File

@@ -17,7 +17,7 @@ Additionally, what makes Pocket ID special is that it only supports [passkey](ht
### Before you start
Pocket ID requires a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts), meaning it must be served over HTTPS. This is necessary because Pocket ID uses the [WebAuthn API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API) which requires a secure context.
Pocket ID requires a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts), meaning it must be served over HTTPS. This is necessary because Pocket ID uses the [WebAuthn API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API).
### Installation with Docker (recommended)
@@ -78,14 +78,14 @@ Required tools:
# Optional: Start Caddy (You can use any other reverse proxy)
cd ..
pm2 start --name pocket-id-caddy caddy -- run --config Caddyfile
pm2 start --name pocket-id-caddy caddy -- run --config reverse-proxy/Caddyfile
```
You can now sign in with the admin account on `http://localhost/login/setup`.
### Nginx Reverse Proxy
To use Nginx in front of Pocket ID, add the following configuration to increase the header buffer size because, as SvelteKit generates larger headers.
To use Nginx as a reverse proxy for Pocket ID, update the configuration to increase the header buffer size. This adjustment is necessary because SvelteKit generates larger headers, which may exceed the default buffer limits.
```nginx
proxy_busy_buffers_size 512k;
@@ -95,7 +95,7 @@ proxy_buffer_size 256k;
## Proxy Services with Pocket ID
As the goal of Pocket ID is to stay simple, we don't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/oauth2-proxy) to add authentication to your services that don't support OIDC.
As the goal of Pocket ID is to stay simple, it doesn't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/oauth2-proxy) to add authentication to your services that don't support OIDC.
See the [guide](docs/proxy-services.md) for more information.
@@ -136,7 +136,7 @@ docker compose up -d
# Optional: Start Caddy (You can use any other reverse proxy)
cd ..
pm2 start caddy --name pocket-id-caddy -- run --config Caddyfile
pm2 start caddy --name pocket-id-caddy -- run --config reverse-proxy/Caddyfile
```
## Environment variables

View File

@@ -3,8 +3,10 @@ package bootstrap
import (
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/utils"
"github.com/stonith404/pocket-id/backend/resources"
"log"
"os"
"path"
"strings"
)
@@ -12,7 +14,7 @@ import (
func initApplicationImages() {
dirPath := common.EnvConfig.UploadPath + "/application-images"
sourceFiles, err := os.ReadDir("./images")
sourceFiles, err := resources.FS.ReadDir("images")
if err != nil && !os.IsNotExist(err) {
log.Fatalf("Error reading directory: %v", err)
}
@@ -27,10 +29,10 @@ func initApplicationImages() {
if sourceFile.IsDir() || imageAlreadyExists(sourceFile.Name(), destinationFiles) {
continue
}
srcFilePath := "./images/" + sourceFile.Name()
destFilePath := dirPath + "/" + sourceFile.Name()
srcFilePath := path.Join("images", sourceFile.Name())
destFilePath := path.Join(dirPath, sourceFile.Name())
err := utils.CopyFile(srcFilePath, destFilePath)
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
if err != nil {
log.Fatalf("Error copying file: %v", err)
}

View File

@@ -7,7 +7,9 @@ import (
"github.com/golang-migrate/migrate/v4/database"
postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres"
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/resources"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
@@ -42,20 +44,31 @@ func newDatabase() (db *gorm.DB) {
}
// Run migrations
m, err := migrate.NewWithDatabaseInstance(
"file://migrations/"+string(common.EnvConfig.DbProvider),
"pocket-id", driver,
)
if err := migrateDatabase(driver); err != nil {
log.Fatalf("failed to run migrations: %v", err)
}
return db
}
func migrateDatabase(driver database.Driver) error {
// Use the embedded migrations
source, err := iofs.New(resources.FS, "migrations/"+string(common.EnvConfig.DbProvider))
if err != nil {
log.Fatalf("failed to create migration instance: %v", err)
return fmt.Errorf("failed to create embedded migration source: %v", err)
}
m, err := migrate.NewWithInstance("iofs", source, "pocket-id", driver)
if err != nil {
return fmt.Errorf("failed to create migration instance: %v", err)
}
err = m.Up()
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
log.Fatalf("failed to apply migrations: %v", err)
return fmt.Errorf("failed to apply migrations: %v", err)
}
return db
return nil
}
func connectDatabase() (db *gorm.DB, err error) {

View File

@@ -2,7 +2,6 @@ package bootstrap
import (
"log"
"os"
"time"
"github.com/gin-gonic/gin"
@@ -29,8 +28,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
r.Use(gin.Logger())
// Initialize services
templateDir := os.DirFS(common.EnvConfig.EmailTemplatesPath)
emailService, err := service.NewEmailService(appConfigService, db, templateDir)
emailService, err := service.NewEmailService(appConfigService, db)
if err != nil {
log.Fatalf("Unable to create email service: %s", err)
}

View File

@@ -22,7 +22,6 @@ type EnvConfigSchema struct {
UploadPath string `env:"UPLOAD_PATH"`
Port string `env:"BACKEND_PORT"`
Host string `env:"HOST"`
EmailTemplatesPath string `env:"EMAIL_TEMPLATES_PATH"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
}
@@ -36,7 +35,6 @@ var EnvConfig = &EnvConfigSchema{
AppURL: "http://localhost",
Port: "8080",
Host: "localhost",
EmailTemplatesPath: "./email-templates",
MaxMindLicenseKey: "",
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
}

View File

@@ -183,7 +183,9 @@ func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, ol
}
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
err := acc.emailService.SendTestEmail()
userID := c.GetString("userID")
err := acc.emailService.SendTestEmail(userID)
if err != nil {
c.Error(err)
return

View File

@@ -3,8 +3,8 @@ package controller
import (
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/service"
@@ -23,12 +23,16 @@ type AuditLogController struct {
}
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err)
return
}
userID := c.GetString("userID")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
// Fetch audit logs for the user
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(userID, page, pageSize)
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(userID, sortedPaginationRequest)
if err != nil {
c.Error(err)
return

View File

@@ -5,8 +5,8 @@ import (
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http"
"strconv"
"strings"
)
@@ -153,11 +153,14 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
}
func (oc *OidcController) listClientsHandler(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err)
return
}
clients, pagination, err := oc.oidcService.ListClients(searchTerm, page, pageSize)
clients, pagination, err := oc.oidcService.ListClients(searchTerm, sortedPaginationRequest)
if err != nil {
c.Error(err)
return

View File

@@ -6,9 +6,9 @@ import (
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"golang.org/x/time/rate"
"net/http"
"strconv"
"time"
)
@@ -37,11 +37,14 @@ type UserController struct {
}
func (uc *UserController) listUsersHandler(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err)
return
}
users, pagination, err := uc.UserService.ListUsers(searchTerm, page, pageSize)
users, pagination, err := uc.UserService.ListUsers(searchTerm, sortedPaginationRequest)
if err != nil {
c.Error(err)
return

View File

@@ -1,13 +1,12 @@
package controller
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http"
)
func NewUserGroupController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, userGroupService *service.UserGroupService) {
@@ -28,16 +27,20 @@ type UserGroupController struct {
}
func (ugc *UserGroupController) list(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err)
return
}
groups, pagination, err := ugc.UserGroupService.List(searchTerm, page, pageSize)
groups, pagination, err := ugc.UserGroupService.List(searchTerm, sortedPaginationRequest)
if err != nil {
c.Error(err)
return
}
// Map the user groups to DTOs. The user count can't be mapped directly, so we have to do it manually.
var groupsDto = make([]dto.UserGroupDtoWithUserCount, len(groups))
for i, group := range groups {
var groupDto dto.UserGroupDtoWithUserCount

View File

@@ -10,6 +10,7 @@ type OidcClientDto struct {
PublicOidcClientDto
CallbackURLs []string `json:"callbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
CreatedBy UserDto `json:"createdBy"`
}
@@ -17,6 +18,7 @@ type OidcClientCreateDto struct {
Name string `json:"name" binding:"required,max=50"`
CallbackURLs []string `json:"callbackURLs" binding:"required,urlList"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
}
type AuthorizeOidcClientRequestDto struct {

View File

@@ -13,10 +13,10 @@ type UserDto struct {
}
type UserCreateDto struct {
Username string `json:"username" binding:"required,username,min=3,max=20"`
Username string `json:"username" binding:"required,username,min=2,max=50"`
Email string `json:"email" binding:"required,email"`
FirstName string `json:"firstName" binding:"required,min=3,max=30"`
LastName string `json:"lastName" binding:"required,min=3,max=30"`
FirstName string `json:"firstName" binding:"required,min=1,max=50"`
LastName string `json:"lastName" binding:"required,min=1,max=50"`
IsAdmin bool `json:"isAdmin"`
}

View File

@@ -9,11 +9,11 @@ import (
type AuditLog struct {
Base
Event AuditLogEvent
IpAddress string
Country string
City string
UserAgent string
Event AuditLogEvent `sortable:"true"`
IpAddress string `sortable:"true"`
Country string `sortable:"true"`
City string `sortable:"true"`
UserAgent string `sortable:"true"`
UserID string
Data AuditLogData
}

View File

@@ -9,8 +9,8 @@ import (
// Base contains common columns for all tables.
type Base struct {
ID string `gorm:"primaryKey;not null"`
CreatedAt model.DateTime
ID string `gorm:"primaryKey;not null"`
CreatedAt model.DateTime `sortable:"true"`
}
func (b *Base) BeforeCreate(_ *gorm.DB) (err error) {

View File

@@ -36,12 +36,13 @@ type OidcAuthorizationCode struct {
type OidcClient struct {
Base
Name string
Name string `sortable:"true"`
Secret string
CallbackURLs CallbackURLs
ImageType *string
HasLogo bool `gorm:"-"`
IsPublic bool
PkceEnabled bool
CreatedByID string
CreatedBy User

View File

@@ -9,11 +9,11 @@ import (
type User struct {
Base
Username string
Email string
FirstName string
LastName string
IsAdmin bool
Username string `sortable:"true"`
Email string `sortable:"true"`
FirstName string `sortable:"true"`
LastName string `sortable:"true"`
IsAdmin bool `sortable:"true"`
CustomClaims []CustomClaim
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`

View File

@@ -2,8 +2,8 @@ package model
type UserGroup struct {
Base
FriendlyName string
Name string `gorm:"unique"`
FriendlyName string `sortable:"true"`
Name string `gorm:"unique" sortable:"true"`
Users []User `gorm:"many2many:user_groups_users;"`
CustomClaims []CustomClaim
}

View File

@@ -84,11 +84,11 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID
}
// ListAuditLogsForUser retrieves all audit logs for a given user ID
func (s *AuditLogService) ListAuditLogsForUser(userID string, page int, pageSize int) ([]model.AuditLog, utils.PaginationResponse, error) {
func (s *AuditLogService) ListAuditLogsForUser(userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.AuditLog, utils.PaginationResponse, error) {
var logs []model.AuditLog
query := s.db.Model(&model.AuditLog{}).Where("user_id = ?", userID).Order("created_at desc")
query := s.db.Model(&model.AuditLog{}).Where("user_id = ?", userID)
pagination, err := utils.Paginate(page, pageSize, query, &logs)
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &logs)
return logs, pagination, err
}

View File

@@ -10,7 +10,6 @@ import (
"github.com/stonith404/pocket-id/backend/internal/utils/email"
"gorm.io/gorm"
htemplate "html/template"
"io/fs"
"mime/multipart"
"mime/quotedprintable"
"net"
@@ -26,13 +25,13 @@ type EmailService struct {
textTemplates map[string]*ttemplate.Template
}
func NewEmailService(appConfigService *AppConfigService, db *gorm.DB, templateDir fs.FS) (*EmailService, error) {
htmlTemplates, err := email.PrepareHTMLTemplates(templateDir, emailTemplatesPaths)
func NewEmailService(appConfigService *AppConfigService, db *gorm.DB) (*EmailService, error) {
htmlTemplates, err := email.PrepareHTMLTemplates(emailTemplatesPaths)
if err != nil {
return nil, fmt.Errorf("prepare html templates: %w", err)
}
textTemplates, err := email.PrepareTextTemplates(templateDir, emailTemplatesPaths)
textTemplates, err := email.PrepareTextTemplates(emailTemplatesPaths)
if err != nil {
return nil, fmt.Errorf("prepare html templates: %w", err)
}
@@ -45,9 +44,9 @@ func NewEmailService(appConfigService *AppConfigService, db *gorm.DB, templateDi
}, nil
}
func (srv *EmailService) SendTestEmail() error {
func (srv *EmailService) SendTestEmail(recipientUserId string) error {
var user model.User
if err := srv.db.First(&user).Error; err != nil {
if err := srv.db.First(&user, "id = ?", recipientUserId).Error; err != nil {
return err
}

View File

@@ -12,7 +12,6 @@ import (
"github.com/golang-jwt/jwt/v5"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/utils"
"log"
"math/big"
"os"
@@ -96,7 +95,7 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
Subject: user.ID,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(sessionDurationInMinutes) * time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Audience: jwt.ClaimStrings{utils.GetHostFromURL(common.EnvConfig.AppURL)},
Audience: jwt.ClaimStrings{common.EnvConfig.AppURL},
},
IsAdmin: user.IsAdmin,
}
@@ -125,7 +124,7 @@ func (s *JwtService) VerifyAccessToken(tokenString string) (*AccessTokenJWTClaim
return nil, errors.New("can't parse claims")
}
if !slices.Contains(claims.Audience, utils.GetHostFromURL(common.EnvConfig.AppURL)) {
if !slices.Contains(claims.Audience, common.EnvConfig.AppURL) {
return nil, errors.New("audience doesn't match")
}
return claims, nil

View File

@@ -131,8 +131,8 @@ func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret, code
return "", "", &common.OidcInvalidAuthorizationCodeError{}
}
// If the client is public, the code verifier must match the code challenge
if client.IsPublic {
// If the client is public or PKCE is enabled, the code verifier must match the code challenge
if client.IsPublic || client.PkceEnabled {
if !s.validateCodeVerifier(codeVerifier, *authorizationCodeMetaData.CodeChallenge, *authorizationCodeMetaData.CodeChallengeMethodSha256) {
return "", "", &common.OidcInvalidCodeVerifierError{}
}
@@ -167,7 +167,7 @@ func (s *OidcService) GetClient(clientID string) (model.OidcClient, error) {
return client, nil
}
func (s *OidcService) ListClients(searchTerm string, page int, pageSize int) ([]model.OidcClient, utils.PaginationResponse, error) {
func (s *OidcService) ListClients(searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.OidcClient, utils.PaginationResponse, error) {
var clients []model.OidcClient
query := s.db.Preload("CreatedBy").Model(&model.OidcClient{})
@@ -176,7 +176,7 @@ func (s *OidcService) ListClients(searchTerm string, page int, pageSize int) ([]
query = query.Where("name LIKE ?", searchPattern)
}
pagination, err := utils.Paginate(page, pageSize, query, &clients)
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &clients)
if err != nil {
return nil, utils.PaginationResponse{}, err
}
@@ -189,6 +189,8 @@ func (s *OidcService) CreateClient(input dto.OidcClientCreateDto, userID string)
Name: input.Name,
CallbackURLs: input.CallbackURLs,
CreatedByID: userID,
IsPublic: input.IsPublic,
PkceEnabled: input.IsPublic || input.PkceEnabled,
}
if err := s.db.Create(&client).Error; err != nil {
@@ -207,6 +209,7 @@ func (s *OidcService) UpdateClient(clientID string, input dto.OidcClientCreateDt
client.Name = input.Name
client.CallbackURLs = input.CallbackURLs
client.IsPublic = input.IsPublic
client.PkceEnabled = input.IsPublic || input.PkceEnabled
if err := s.db.Save(&client).Error; err != nil {
return model.OidcClient{}, err
@@ -406,6 +409,10 @@ func (s *OidcService) createAuthorizationCode(clientID string, userID string, sc
}
func (s *OidcService) validateCodeVerifier(codeVerifier, codeChallenge string, codeChallengeMethodSha256 bool) bool {
if codeVerifier == "" || codeChallenge == "" {
return false
}
if !codeChallengeMethodSha256 {
return codeVerifier == codeChallenge
}

View File

@@ -7,8 +7,10 @@ import (
"fmt"
"github.com/fxamacker/cbor/v2"
"github.com/stonith404/pocket-id/backend/internal/model/types"
"github.com/stonith404/pocket-id/backend/resources"
"log"
"os"
"path/filepath"
"time"
"github.com/go-webauthn/webauthn/protocol"
@@ -245,11 +247,21 @@ func (s *TestService) ResetApplicationImages() error {
return err
}
if err := utils.CopyDirectory("./images", common.EnvConfig.UploadPath+"/application-images"); err != nil {
log.Printf("Error copying directory: %v", err)
files, err := resources.FS.ReadDir("images")
if err != nil {
return err
}
for _, file := range files {
srcFilePath := filepath.Join("images", file.Name())
destFilePath := filepath.Join(common.EnvConfig.UploadPath, "application-images", file.Name())
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
if err != nil {
return err
}
}
return nil
}

View File

@@ -17,14 +17,26 @@ func NewUserGroupService(db *gorm.DB) *UserGroupService {
return &UserGroupService{db: db}
}
func (s *UserGroupService) List(name string, page int, pageSize int) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
func (s *UserGroupService) List(name string, sortedPaginationRequest utils.SortedPaginationRequest) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
query := s.db.Preload("CustomClaims").Model(&model.UserGroup{})
if name != "" {
query = query.Where("name LIKE ?", "%"+name+"%")
}
response, err = utils.Paginate(page, pageSize, query, &groups)
// As userCount is not a column we need to manually sort it
isValidSortDirection := sortedPaginationRequest.Sort.Direction == "asc" || sortedPaginationRequest.Sort.Direction == "desc"
if sortedPaginationRequest.Sort.Column == "userCount" && isValidSortDirection {
query = query.Select("user_groups.*, COUNT(user_groups_users.user_id)").
Joins("LEFT JOIN user_groups_users ON user_groups.id = user_groups_users.user_group_id").
Group("user_groups.id").
Order("COUNT(user_groups_users.user_id) " + sortedPaginationRequest.Sort.Direction)
response, err := utils.Paginate(sortedPaginationRequest.Pagination.Page, sortedPaginationRequest.Pagination.Limit, query, &groups)
return groups, response, err
}
response, err = utils.PaginateAndSort(sortedPaginationRequest, query, &groups)
return groups, response, err
}

View File

@@ -21,7 +21,7 @@ func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditL
return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService}
}
func (s *UserService) ListUsers(searchTerm string, page int, pageSize int) ([]model.User, utils.PaginationResponse, error) {
func (s *UserService) ListUsers(searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.User, utils.PaginationResponse, error) {
var users []model.User
query := s.db.Model(&model.User{})
@@ -30,7 +30,7 @@ func (s *UserService) ListUsers(searchTerm string, page int, pageSize int) ([]mo
query = query.Where("email LIKE ? OR first_name LIKE ? OR username LIKE ?", searchPattern, searchPattern, searchPattern)
}
pagination, err := utils.Paginate(page, pageSize, query, &users)
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &users)
return users, pagination, err
}

View File

@@ -23,7 +23,7 @@ type WebAuthnService struct {
func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, appConfigService *AppConfigService) *WebAuthnService {
webauthnConfig := &webauthn.Config{
RPDisplayName: appConfigService.DbConfig.AppName.Value,
RPID: utils.GetHostFromURL(common.EnvConfig.AppURL),
RPID: utils.GetHostnameFromURL(common.EnvConfig.AppURL),
RPOrigins: []string{common.EnvConfig.AppURL},
Timeouts: webauthn.TimeoutsConfig{
Login: webauthn.TimeoutConfig{

View File

@@ -2,6 +2,7 @@ package email
import (
"fmt"
"github.com/stonith404/pocket-id/backend/resources"
htemplate "html/template"
"io/fs"
"path"
@@ -35,36 +36,37 @@ type pareseable[V any] interface {
ParseFS(fs.FS, ...string) (V, error)
}
func prepareTemplate[V pareseable[V]](template string, rootTemplate clonable[V], templateDir fs.FS, suffix string) (V, error) {
func prepareTemplate[V pareseable[V]](templateFS fs.FS, template string, rootTemplate clonable[V], suffix string) (V, error) {
tmpl, err := rootTemplate.Clone()
if err != nil {
return *new(V), fmt.Errorf("clone root html template: %w", err)
return *new(V), fmt.Errorf("clone root template: %w", err)
}
filename := fmt.Sprintf("%s%s", template, suffix)
_, err = tmpl.ParseFS(templateDir, filename)
templatePath := path.Join("email-templates", filename)
_, err = tmpl.ParseFS(templateFS, templatePath)
if err != nil {
return *new(V), fmt.Errorf("parsing html template '%s': %w", template, err)
return *new(V), fmt.Errorf("parsing template '%s': %w", template, err)
}
return tmpl, nil
}
func PrepareTextTemplates(templateDir fs.FS, templates []string) (map[string]*ttemplate.Template, error) {
components := path.Join(templateComponentsDir, "*_text.tmpl")
rootTmpl, err := ttemplate.ParseFS(templateDir, components)
func PrepareTextTemplates(templates []string) (map[string]*ttemplate.Template, error) {
components := path.Join("email-templates", "components", "*_text.tmpl")
rootTmpl, err := ttemplate.ParseFS(resources.FS, components)
if err != nil {
return nil, fmt.Errorf("unable to parse templates '%s': %w", components, err)
}
var textTemplates = make(map[string]*ttemplate.Template, len(templates))
textTemplates := make(map[string]*ttemplate.Template, len(templates))
for _, tmpl := range templates {
rootTmplClone, err := rootTmpl.Clone()
if err != nil {
return nil, fmt.Errorf("clone root template: %w", err)
}
textTemplates[tmpl], err = prepareTemplate[*ttemplate.Template](tmpl, rootTmplClone, templateDir, "_text.tmpl")
textTemplates[tmpl], err = prepareTemplate[*ttemplate.Template](resources.FS, tmpl, rootTmplClone, "_text.tmpl")
if err != nil {
return nil, fmt.Errorf("parse '%s': %w", tmpl, err)
}
@@ -73,21 +75,21 @@ func PrepareTextTemplates(templateDir fs.FS, templates []string) (map[string]*tt
return textTemplates, nil
}
func PrepareHTMLTemplates(templateDir fs.FS, templates []string) (map[string]*htemplate.Template, error) {
components := path.Join(templateComponentsDir, "*_html.tmpl")
rootTmpl, err := htemplate.ParseFS(templateDir, components)
func PrepareHTMLTemplates(templates []string) (map[string]*htemplate.Template, error) {
components := path.Join("email-templates", "components", "*_html.tmpl")
rootTmpl, err := htemplate.ParseFS(resources.FS, components)
if err != nil {
return nil, fmt.Errorf("unable to parse templates '%s': %w", components, err)
}
var htmlTemplates = make(map[string]*htemplate.Template, len(templates))
htmlTemplates := make(map[string]*htemplate.Template, len(templates))
for _, tmpl := range templates {
rootTmplClone, err := rootTmpl.Clone()
if err != nil {
return nil, fmt.Errorf("clone root template: %w", err)
}
htmlTemplates[tmpl], err = prepareTemplate[*htemplate.Template](tmpl, rootTmplClone, templateDir, "_html.tmpl")
htmlTemplates[tmpl], err = prepareTemplate[*htemplate.Template](resources.FS, tmpl, rootTmplClone, "_html.tmpl")
if err != nil {
return nil, fmt.Errorf("parse '%s': %w", tmpl, err)
}

View File

@@ -1,6 +1,7 @@
package utils
import (
"github.com/stonith404/pocket-id/backend/resources"
"io"
"mime/multipart"
"os"
@@ -28,27 +29,8 @@ func GetImageMimeType(ext string) string {
}
}
func CopyDirectory(srcDir, destDir string) error {
files, err := os.ReadDir(srcDir)
if err != nil {
return err
}
for _, file := range files {
srcFilePath := filepath.Join(srcDir, file.Name())
destFilePath := filepath.Join(destDir, file.Name())
err := CopyFile(srcFilePath, destFilePath)
if err != nil {
return err
}
}
return nil
}
func CopyFile(srcFilePath, destFilePath string) error {
srcFile, err := os.Open(srcFilePath)
func CopyEmbeddedFileToDisk(srcFilePath, destFilePath string) error {
srcFile, err := resources.FS.Open(srcFilePath)
if err != nil {
return err
}

View File

@@ -2,6 +2,7 @@ package utils
import (
"gorm.io/gorm"
"reflect"
)
type PaginationResponse struct {
@@ -11,7 +12,36 @@ type PaginationResponse struct {
ItemsPerPage int `json:"itemsPerPage"`
}
func Paginate(page int, pageSize int, db *gorm.DB, result interface{}) (PaginationResponse, error) {
type SortedPaginationRequest struct {
Pagination struct {
Page int `form:"pagination[page]"`
Limit int `form:"pagination[limit]"`
} `form:"pagination"`
Sort struct {
Column string `form:"sort[column]"`
Direction string `form:"sort[direction]"`
} `form:"sort"`
}
func PaginateAndSort(sortedPaginationRequest SortedPaginationRequest, query *gorm.DB, result interface{}) (PaginationResponse, error) {
pagination := sortedPaginationRequest.Pagination
sort := sortedPaginationRequest.Sort
capitalizedSortColumn := CapitalizeFirstLetter(sort.Column)
sortField, sortFieldFound := reflect.TypeOf(result).Elem().Elem().FieldByName(capitalizedSortColumn)
isSortable := sortField.Tag.Get("sortable") == "true"
isValidSortOrder := sort.Direction == "asc" || sort.Direction == "desc"
if sortFieldFound && isSortable && isValidSortOrder {
query = query.Order(CamelCaseToSnakeCase(sort.Column) + " " + sort.Direction)
}
return Paginate(pagination.Page, pagination.Limit, query, result)
}
func Paginate(page int, pageSize int, query *gorm.DB, result interface{}) (PaginationResponse, error) {
if page < 1 {
page = 1
}
@@ -25,11 +55,11 @@ func Paginate(page int, pageSize int, db *gorm.DB, result interface{}) (Paginati
offset := (page - 1) * pageSize
var totalItems int64
if err := db.Count(&totalItems).Error; err != nil {
if err := query.Count(&totalItems).Error; err != nil {
return PaginationResponse{}, err
}
if err := db.Offset(offset).Limit(pageSize).Find(result).Error; err != nil {
if err := query.Offset(offset).Limit(pageSize).Find(result).Error; err != nil {
return PaginationResponse{}, err
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"math/big"
"net/url"
"unicode"
)
// GenerateRandomAlphanumericString generates a random alphanumeric string of the given length
@@ -29,15 +30,35 @@ func GenerateRandomAlphanumericString(length int) (string, error) {
return string(result), nil
}
func GetHostFromURL(rawURL string) string {
func GetHostnameFromURL(rawURL string) string {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return ""
}
return parsedURL.Host
return parsedURL.Hostname()
}
// StringPointer creates a string pointer from a string value
func StringPointer(s string) *string {
return &s
}
func CapitalizeFirstLetter(s string) string {
if s == "" {
return s
}
runes := []rune(s)
runes[0] = unicode.ToUpper(runes[0])
return string(runes)
}
func CamelCaseToSnakeCase(s string) string {
var result []rune
for i, r := range s {
if unicode.IsUpper(r) && i > 0 {
result = append(result, '_')
}
result = append(result, unicode.ToLower(r))
}
return string(result)
}

View File

@@ -0,0 +1,8 @@
package resources
import "embed"
// Embedded file systems for the project
//go:embed email-templates images migrations
var FS embed.FS

View File

Before

Width:  |  Height:  |  Size: 3.7 MiB

After

Width:  |  Height:  |  Size: 3.7 MiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 539 B

After

Width:  |  Height:  |  Size: 539 B

View File

Before

Width:  |  Height:  |  Size: 434 B

After

Width:  |  Height:  |  Size: 434 B

View File

Before

Width:  |  Height:  |  Size: 434 B

After

Width:  |  Height:  |  Size: 434 B

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients DROP COLUMN pkce_enabled;

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients ADD COLUMN pkce_enabled BOOLEAN DEFAULT FALSE;

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients DROP COLUMN pkce_enabled;

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients ADD COLUMN pkce_enabled BOOLEAN DEFAULT FALSE;

View File

@@ -45,7 +45,7 @@ upstreams="http://<service-to-be-proxied>:<port>"
# Additional Configuration
provider="oidc"
scope = "openid email profile"
scope = "openid email profile groups"
# If you are using a reverse proxy in front of OAuth2 Proxy
reverse_proxy = true

View File

@@ -1,6 +1,6 @@
{
"name": "pocket-id-frontend",
"version": "0.20.1",
"version": "0.24.0",
"private": true,
"scripts": {
"dev": "vite dev --port 3000",

View File

@@ -5,26 +5,44 @@
import * as Select from '$lib/components/ui/select';
import * as Table from '$lib/components/ui/table/index.js';
import Empty from '$lib/icons/empty.svelte';
import type { Paginated } from '$lib/types/pagination.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import { debounced } from '$lib/utils/debounce-util';
import { cn } from '$lib/utils/style';
import { ChevronDown } from 'lucide-svelte';
import type { Snippet } from 'svelte';
import Button from './ui/button/button.svelte';
let {
items,
requestOptions = $bindable(),
selectedIds = $bindable(),
withoutSearch = false,
fetchItems,
defaultSort,
onRefresh,
columns,
rows
}: {
items: Paginated<T>;
requestOptions?: SearchPaginationSortRequest;
selectedIds?: string[];
withoutSearch?: boolean;
fetchItems: (search: string, page: number, limit: number) => Promise<Paginated<T>>;
columns: (string | { label: string; hidden?: boolean })[];
defaultSort?: { column: string; direction: 'asc' | 'desc' };
onRefresh: (requestOptions: SearchPaginationSortRequest) => Promise<Paginated<T>>;
columns: { label: string; hidden?: boolean; sortColumn?: string }[];
rows: Snippet<[{ item: T }]>;
} = $props();
if (!requestOptions) {
requestOptions = {
search: '',
sort: defaultSort,
pagination: {
page: items.pagination.currentPage,
limit: items.pagination.itemsPerPage
}
};
}
let availablePageSizes: number[] = [10, 20, 50, 100];
let allChecked = $derived.by(() => {
@@ -38,7 +56,8 @@
});
const onSearch = debounced(async (searchValue: string) => {
items = await fetchItems(searchValue, 1, items.pagination.itemsPerPage);
requestOptions.search = searchValue;
onRefresh(requestOptions);
}, 300);
async function onAllCheck(checked: boolean) {
@@ -59,11 +78,20 @@
}
async function onPageChange(page: number) {
items = await fetchItems('', page, items.pagination.itemsPerPage);
requestOptions!.pagination = { limit: items.pagination.itemsPerPage, page };
onRefresh(requestOptions!);
}
async function onPageSizeChange(size: number) {
items = await fetchItems('', 1, size);
requestOptions!.pagination = { limit: size, page: 1 };
onRefresh(requestOptions!);
}
async function onSort(column?: string, direction: 'asc' | 'desc' = 'asc') {
if (!column) return;
requestOptions!.sort = { column, direction };
onRefresh(requestOptions!);
}
</script>
@@ -92,11 +120,31 @@
</Table.Head>
{/if}
{#each columns as column}
{#if typeof column === 'string'}
<Table.Head>{column}</Table.Head>
{:else}
<Table.Head class={column.hidden ? 'sr-only' : ''}>{column.label}</Table.Head>
{/if}
<Table.Head class={cn(column.hidden && 'sr-only', column.sortColumn && 'px-0')}>
{#if column.sortColumn}
<Button
variant="ghost"
class="flex items-center"
on:click={() =>
onSort(
column.sortColumn,
requestOptions.sort?.direction === 'desc' ? 'asc' : 'desc'
)}
>
{column.label}
{#if requestOptions.sort?.column === column.sortColumn}
<ChevronDown
class={cn(
'ml-2 h-4 w-4',
requestOptions.sort?.direction === 'asc' ? 'rotate-180' : ''
)}
/>
{/if}
</Button>
{:else}
{column.label}
{/if}
</Table.Head>
{/each}
</Table.Row>
</Table.Header>

View File

@@ -6,12 +6,26 @@
id,
checked = $bindable(),
label,
description
}: { id: string; checked: boolean; label: string; description?: string } = $props();
description,
disabled = false,
onCheckedChange
}: {
id: string;
checked: boolean;
label: string;
description?: string;
disabled?: boolean;
onCheckedChange?: (checked: boolean) => void;
} = $props();
</script>
<div class="items-top mt-5 flex space-x-2">
<Checkbox {id} bind:checked />
<Checkbox
{id}
{disabled}
onCheckedChange={(v) => onCheckedChange && onCheckedChange(v == true)}
bind:checked
/>
<div class="grid gap-1.5 leading-none">
<Label for={id} class="mb-0 text-sm font-medium leading-none">
{label}

View File

@@ -19,7 +19,7 @@
>
<div class="flex h-16 items-center">
{#if !isAuthPage}
<Logo class="mr-3 h-10 w-10" />
<Logo class="mr-3 h-8 w-8" />
<h1 class="text-lg font-medium" data-testid="application-name">
{$appConfigStore.appName}
</h1>

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils/style.js";
type $$Props = HTMLAttributes<HTMLDivElement>;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<div class={cn("text-sm [&_p]:leading-relaxed", className)} {...$$restProps}>
<slot />
</div>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import type { HeadingLevel } from "./index.js";
import { cn } from "$lib/utils/style.js";
type $$Props = HTMLAttributes<HTMLHeadingElement> & {
level?: HeadingLevel;
};
let className: $$Props["class"] = undefined;
export let level: $$Props["level"] = "h5";
export { className as class };
</script>
<svelte:element
this={level}
class={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...$$restProps}
>
<slot />
</svelte:element>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { type Variant, alertVariants } from "./index.js";
import { cn } from "$lib/utils/style.js";
type $$Props = HTMLAttributes<HTMLDivElement> & {
variant?: Variant;
};
let className: $$Props["class"] = undefined;
export let variant: $$Props["variant"] = "default";
export { className as class };
</script>
<div class={cn(alertVariants({ variant }), className)} {...$$restProps} role="alert">
<slot />
</div>

View File

@@ -0,0 +1,35 @@
import { type VariantProps, tv } from 'tailwind-variants';
import Description from './alert-description.svelte';
import Title from './alert-title.svelte';
import Root from './alert.svelte';
export const alertVariants = tv({
base: '[&>svg]:text-foreground relative w-full rounded-lg border p-4 [&:has(svg)]:pl-11 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4',
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive text-destructive dark:border-destructive [&>svg]:text-destructive',
warning:
'bg-amber-100 text-amber-900 dark:bg-amber-900 dark:text-amber-100 [&>svg]:text-amber-900 dark:[&>svg]:text-amber-100'
}
},
defaultVariants: {
variant: 'default'
}
});
export type Variant = VariantProps<typeof alertVariants>['variant'];
export type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
export {
//
Root as Alert,
Description as AlertDescription,
Title as AlertTitle,
Description,
Root,
Title
};

View File

@@ -1,11 +1,11 @@
import type { AuditLog } from '$lib/types/audit-log.type';
import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import APIService from './api-service';
class AuditLogService extends APIService {
async list(pagination?: PaginationRequest) {
async list(options?: SearchPaginationSortRequest) {
const res = await this.api.get('/audit-logs', {
params: pagination
params: options
});
return res.data as Paginated<AuditLog>;
}

View File

@@ -1,9 +1,16 @@
import type { AuthorizeResponse, OidcClient, OidcClientCreate } from '$lib/types/oidc.type';
import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import APIService from './api-service';
class OidcService extends APIService {
async authorize(clientId: string, scope: string, callbackURL: string, nonce?: string, codeChallenge?: string, codeChallengeMethod?: string) {
async authorize(
clientId: string,
scope: string,
callbackURL: string,
nonce?: string,
codeChallenge?: string,
codeChallengeMethod?: string
) {
const res = await this.api.post('/oidc/authorize', {
scope,
nonce,
@@ -16,7 +23,14 @@ class OidcService extends APIService {
return res.data as AuthorizeResponse;
}
async authorizeNewClient(clientId: string, scope: string, callbackURL: string, nonce?: string, codeChallenge?: string, codeChallengeMethod?: string) {
async authorizeNewClient(
clientId: string,
scope: string,
callbackURL: string,
nonce?: string,
codeChallenge?: string,
codeChallengeMethod?: string
) {
const res = await this.api.post('/oidc/authorize/new-client', {
scope,
nonce,
@@ -29,12 +43,9 @@ class OidcService extends APIService {
return res.data as AuthorizeResponse;
}
async listClients(search?: string, pagination?: PaginationRequest) {
async listClients(options?: SearchPaginationSortRequest) {
const res = await this.api.get('/oidc/clients', {
params: {
search,
...pagination
}
params: options
});
return res.data as Paginated<OidcClient>;
}

View File

@@ -1,4 +1,4 @@
import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type {
UserGroupCreate,
UserGroupWithUserCount,
@@ -7,12 +7,9 @@ import type {
import APIService from './api-service';
export default class UserGroupService extends APIService {
async list(search?: string, pagination?: PaginationRequest) {
async list(options?: SearchPaginationSortRequest) {
const res = await this.api.get('/user-groups', {
params: {
search,
...pagination
}
params: options
});
return res.data as Paginated<UserGroupWithUserCount>;
}

View File

@@ -1,14 +1,11 @@
import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { User, UserCreate } from '$lib/types/user.type';
import APIService from './api-service';
export default class UserService extends APIService {
async list(search?: string, pagination?: PaginationRequest) {
async list(options?: SearchPaginationSortRequest) {
const res = await this.api.get('/users', {
params: {
search,
...pagination
}
params: options
});
return res.data as Paginated<User>;
}

View File

@@ -5,12 +5,13 @@ export type OidcClient = {
callbackURLs: [string, ...string[]];
hasLogo: boolean;
isPublic: boolean;
pkceEnabled: boolean;
};
export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>;
export type OidcClientCreateWithLogo = OidcClientCreate & {
logo: File | null;
logo: File | null | undefined;
};
export type AuthorizeResponse = {

View File

@@ -3,6 +3,17 @@ export type PaginationRequest = {
limit: number;
};
export type SortRequest = {
column: string;
direction: "asc" | "desc";
};
export type SearchPaginationSortRequest = {
search?: string,
pagination?: PaginationRequest;
sort?: SortRequest;
}
export type PaginationResponse = {
totalPages: number;
totalItems: number;

View File

@@ -80,11 +80,19 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
});
}
function setValue(key: keyof z.infer<T>, value: z.infer<T>[keyof z.infer<T>]) {
inputsStore.update((inputs) => {
inputs[key].value = value;
return inputs;
});
}
return {
schema,
inputs: inputsStore,
data,
validate,
setValue,
reset
};
}

View File

@@ -24,7 +24,7 @@
let authorizationRequired = false;
export let data: PageData;
let { scope, nonce, client, state, callbackURL, codeChallenge, codeChallengeMethod } = data;
let { scope, nonce, client, state, callbackURL, codeChallenge, codeChallengeMethod } = data;
async function authorize() {
isLoading = true;
@@ -55,7 +55,14 @@
isLoading = true;
try {
await oidService
.authorizeNewClient(client!.id, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod)
.authorizeNewClient(
client!.id,
scope,
callbackURL,
nonce,
codeChallenge,
codeChallengeMethod
)
.then(async ({ code, callbackURL }) => {
onSuccess(code, callbackURL);
});
@@ -68,7 +75,11 @@
function onSuccess(code: string, callbackURL: string) {
success = true;
setTimeout(() => {
window.location.href = `${callbackURL}?code=${code}&state=${state}`;
const redirectURL = new URL(callbackURL);
redirectURL.searchParams.append('code', code);
redirectURL.searchParams.append('state', state);
window.location.href = redirectURL.toString();
}, 1000);
}
</script>

View File

@@ -1,19 +1,21 @@
<script>
<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 appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { startAuthentication } from '@simplewebauthn/browser';
import { toast } from 'svelte-sonner';
import { fade } from 'svelte/transition';
import LoginLogoErrorIndicator from './components/login-logo-error-indicator.svelte';
const webauthnService = new WebAuthnService();
let isLoading = $state(false);
let error: string | undefined = $state(undefined);
async function authenticate() {
error = undefined;
isLoading = true;
try {
const loginOptions = await webauthnService.getLoginOptions();
@@ -23,7 +25,7 @@
userStore.setUser(user);
goto('/settings');
} catch (e) {
toast.error(getWebauthnErrorMessage(e));
error = getWebauthnErrorMessage(e);
}
isLoading = false;
}
@@ -35,15 +37,21 @@
<SignInWrapper>
<div class="flex justify-center">
<div class="bg-muted rounded-2xl p-3">
<Logo class="h-10 w-10" />
</div>
<LoginLogoErrorIndicator error={!!error} />
</div>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">
Sign in to {$appConfigStore.appName}
</h1>
<p class="text-muted-foreground mt-2">
Authenticate yourself with your passkey to access the admin panel
</p>
<Button class="mt-5" {isLoading} on:click={authenticate}>Authenticate</Button>
{#if error}
<p class="text-muted-foreground mt-2" in:fade>
{error}. Please try to sign in again.
</p>
{:else}
<p class="text-muted-foreground mt-2" in:fade>
Authenticate yourself with your passkey to access the admin panel.
</p>
{/if}
<Button class="mt-10" {isLoading} on:click={authenticate}
>{error ? 'Try again' : 'Authenticate'}</Button
>
</SignInWrapper>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import Logo from '$lib/components/logo.svelte';
import CrossAnimated from '$lib/icons/cross-animated.svelte';
import { fade } from 'svelte/transition';
const {
error
}: {
error: boolean;
} = $props();
</script>
<div
class="rounded-2xl p-3 transition-[background-color] duration-300
{error ? 'bg-red-200' : 'bg-muted'}"
>
{#if error}
<div class="flex h-10 w-10 items-center justify-center">
<CrossAnimated class="h-5 w-5" />
</div>
{:else}
<div in:fade={{ duration: 300 }}>
<Logo class="h-10 w-10" />
</div>
{/if}
</div>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import * as Alert from '$lib/components/ui/alert';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import UserService from '$lib/services/user-service';
@@ -8,6 +9,7 @@
import type { UserCreate } from '$lib/types/user.type';
import { axiosErrorToast, getWebauthnErrorMessage } from '$lib/utils/error-util';
import { startRegistration } from '@simplewebauthn/browser';
import { LucideAlertTriangle } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import AccountForm from './account-form.svelte';
import PasskeyList from './passkey-list.svelte';
@@ -52,6 +54,16 @@
<title>Account Settings</title>
</svelte:head>
{#if passkeys.length == 0}
<Alert.Root variant="warning">
<LucideAlertTriangle class="size-4" />
<Alert.Title>Passkey missing</Alert.Title>
<Alert.Description
>Please add a passkey to prevent losing access to your account.</Alert.Description
>
</Alert.Root>
{/if}
{#if $appConfigStore.allowOwnAccountEdit}
<Card.Root>
<Card.Header>
@@ -77,7 +89,7 @@
</Card.Header>
{#if passkeys.length != 0}
<Card.Content>
<PasskeyList {passkeys} />
<PasskeyList bind:passkeys />
</Card.Content>
{/if}
</Card.Root>

View File

@@ -16,9 +16,16 @@
let isLoading = $state(false);
const formSchema = z.object({
firstName: z.string().min(2).max(50),
lastName: z.string().min(2).max(50),
username: z.string().min(2).max(50),
firstName: z.string().min(1).max(50),
lastName: z.string().min(1).max(50),
username: z
.string()
.min(2)
.max(30)
.regex(
/^[a-z0-9_@.-]+$/,
"Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols"
),
email: z.string().email(),
isAdmin: z.boolean()
});

View File

@@ -9,15 +9,10 @@
import { toast } from 'svelte-sonner';
import RenamePasskeyModal from './rename-passkey-modal.svelte';
let { passkeys: initialsPasskeys }: { passkeys: Passkey[] } = $props();
let passkeys = $state<Passkey[]>(initialsPasskeys);
let { passkeys = $bindable() }: { passkeys: Passkey[] } = $props();
const webauthnService = new WebauthnService();
$effect(() => {
passkeys = initialsPasskeys;
});
let passkeyToRename: Passkey | null = $state(null);
async function deletePasskey(passkey: Passkey) {

View File

@@ -21,21 +21,25 @@
const oidcService = new OidcService();
const setupDetails = {
const setupDetails = $state({
'Authorization URL': `https://${$page.url.hostname}/authorize`,
'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`,
'Token URL': `https://${$page.url.hostname}/api/oidc/token`,
'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`,
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`,
PKCE: client.isPublic ? 'Enabled' : 'Disabled'
};
PKCE: client.pkceEnabled ? 'Enabled' : 'Disabled'
});
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
let success = true;
const dataPromise = oidcService.updateClient(client.id, updatedClient);
const imagePromise = oidcService.updateClientLogo(client, updatedClient.logo);
const imagePromise =
updatedClient.logo !== undefined
? oidcService.updateClientLogo(client, updatedClient.logo)
: Promise.resolve();
client.isPublic = updatedClient.isPublic;
setupDetails.PKCE = updatedClient.pkceEnabled ? 'Enabled' : 'Disabled';
await Promise.all([dataPromise, imagePromise])
.then(() => {

View File

@@ -10,7 +10,7 @@
OidcClientCreateWithLogo
} from '$lib/types/oidc.type';
import { createForm } from '$lib/utils/form-util';
import { z } from 'zod';
import { set, z } from 'zod';
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
let {
@@ -22,7 +22,7 @@
} = $props();
let isLoading = $state(false);
let logo = $state<File | null>(null);
let logo = $state<File | null | undefined>();
let logoDataURL: string | null = $state(
existingClient?.hasLogo ? `/api/oidc/clients/${existingClient!.id}/logo` : null
);
@@ -30,13 +30,15 @@
const client: OidcClientCreate = {
name: existingClient?.name || '',
callbackURLs: existingClient?.callbackURLs || [''],
isPublic: existingClient?.isPublic || false
isPublic: existingClient?.isPublic || false,
pkceEnabled: existingClient?.isPublic == true || existingClient?.pkceEnabled || false
};
const formSchema = z.object({
name: z.string().min(2).max(50),
callbackURLs: z.array(z.string().url()).nonempty(),
isPublic: z.boolean()
isPublic: z.boolean(),
pkceEnabled: z.boolean()
});
type FormSchema = typeof formSchema;
@@ -85,8 +87,19 @@
id="public-client"
label="Public Client"
description="Public clients do not have a client secret and use PKCE instead. Enable this if your client is a SPA or mobile app."
onCheckedChange={(v) => {
console.log(v)
if (v == true) form.setValue('pkceEnabled', true);
}}
bind:checked={$inputs.isPublic.value}
/>
<CheckboxWithLabel
id="pkce"
label="PKCE"
description="Public Key Code Exchange is a security feature to prevent CSRF and authorization code interception attacks."
disabled={$inputs.isPublic.value}
bind:checked={$inputs.pkceEnabled.value}
/>
</div>
<div class="mt-8">
<Label for="logo">Logo</Label>
@@ -108,7 +121,7 @@
onchange={onLogoChange}
>
<Button variant="secondary">
{existingClient?.hasLogo ? 'Change Logo' : 'Upload Logo'}
{logoDataURL ? 'Change Logo' : 'Upload Logo'}
</Button>
</FileInput>
{#if logoDataURL}

View File

@@ -1,13 +1,11 @@
<script lang="ts">
import AdvancedTable from '$lib/components/advanced-table.svelte';
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import * as Pagination from '$lib/components/ui/pagination';
import * as Table from '$lib/components/ui/table';
import OIDCService from '$lib/services/oidc-service';
import type { OidcClient } from '$lib/types/oidc.type';
import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
import { debounced } from '$lib/utils/debounce-util';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucidePencil, LucideTrash } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
@@ -16,6 +14,7 @@
let { clients: initialClients }: { clients: Paginated<OidcClient> } = $props();
let clients = $state<Paginated<OidcClient>>(initialClients);
let oneTimeLink = $state<string | null>(null);
let requestOptions: SearchPaginationSortRequest | undefined = $state();
$effect(() => {
clients = initialClients;
@@ -23,16 +22,6 @@
const oidcService = new OIDCService();
let pagination = $state<PaginationRequest>({
page: 1,
limit: 10
});
let search = $state('');
const debouncedSearch = debounced(async (searchValue: string) => {
clients = await oidcService.listClients(searchValue, pagination);
}, 400);
async function deleteClient(client: OidcClient) {
openConfirmDialog({
title: `Delete ${client.name}`,
@@ -43,7 +32,7 @@
action: async () => {
try {
await oidcService.removeClient(client.id);
clients = await oidcService.listClients(search, pagination);
clients = await oidcService.listClients(requestOptions!);
toast.success('OIDC client deleted successfully');
} catch (e) {
axiosErrorToast(e);
@@ -54,96 +43,41 @@
}
</script>
<Input
type="search"
placeholder="Search clients"
bind:value={search}
on:input={(e) => debouncedSearch((e.target as HTMLInputElement).value)}
/>
<Table.Root>
<Table.Header class="sr-only">
<Table.Row>
<Table.Head>Logo</Table.Head>
<Table.Head>Name</Table.Head>
<Table.Head>Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if clients.data.length === 0}
<Table.Row>
<Table.Cell colspan={6} class="text-center">No OIDC clients found</Table.Cell>
</Table.Row>
{:else}
{#each clients.data as client}
<Table.Row>
<Table.Cell class="w-8 font-medium">
{#if client.hasLogo}
<div class="h-8 w-8">
<img
class="m-auto max-h-full max-w-full object-contain"
src="/api/oidc/clients/{client.id}/logo"
alt="{client.name} logo"
/>
</div>
{/if}
</Table.Cell>
<Table.Cell class="font-medium">{client.name}</Table.Cell>
<Table.Cell class="flex justify-end gap-1">
<Button
href="/settings/admin/oidc-clients/{client.id}"
size="sm"
variant="outline"
aria-label="Edit"><LucidePencil class="h-3 w-3 " /></Button
>
<Button
on:click={() => deleteClient(client)}
size="sm"
variant="outline"
aria-label="Delete"><LucideTrash class="h-3 w-3 text-red-500" /></Button
>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
{#if clients?.data?.length ?? 0 > 0}
<Pagination.Root
class="mt-5"
count={clients.pagination.totalItems}
perPage={pagination.limit}
onPageChange={async (p) =>
(clients = await oidcService.listClients(search, {
page: p,
limit: pagination.limit
}))}
bind:page={clients.pagination.currentPage}
let:pages
let:currentPage
>
<Pagination.Content class="flex justify-end">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type === 'ellipsis'}
<Pagination.Item>
<Pagination.Ellipsis />
</Pagination.Item>
{:else}
<Pagination.Item>
<Pagination.Link {page} isActive={clients.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
</Pagination.Root>
{/if}
<AdvancedTable
items={clients}
{requestOptions}
onRefresh={async(o) => clients = await oidcService.listClients(o)}
columns={[
{ label: 'Logo' },
{ label: 'Name', sortColumn: 'name' },
{ label: 'Actions', hidden: true }
]}
>
{#snippet rows({ item })}
<Table.Cell class="w-8 font-medium">
{#if item.hasLogo}
<div class="h-8 w-8">
<img
class="m-auto max-h-full max-w-full object-contain"
src="/api/oidc/clients/{item.id}/logo"
alt="{item.name} logo"
/>
</div>
{/if}
</Table.Cell>
<Table.Cell class="font-medium">{item.name}</Table.Cell>
<Table.Cell class="flex justify-end gap-1">
<Button
href="/settings/admin/oidc-clients/{item.id}"
size="sm"
variant="outline"
aria-label="Edit"><LucidePencil class="h-3 w-3 " /></Button
>
<Button on:click={() => deleteClient(item)} size="sm" variant="outline" aria-label="Delete"
><LucideTrash class="h-3 w-3 text-red-500" /></Button
>
</Table.Cell>
{/snippet}
</AdvancedTable>
<OneTimeLinkModal {oneTimeLink} />

View File

@@ -5,7 +5,7 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Table from '$lib/components/ui/table';
import UserGroupService from '$lib/services/user-group-service';
import type { Paginated } from '$lib/types/pagination.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { UserGroup, UserGroupWithUserCount } from '$lib/types/user-group.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucidePencil, LucideTrash } from 'lucide-svelte';
@@ -16,6 +16,7 @@
$props();
let userGroups = $state<Paginated<UserGroupWithUserCount>>(initialUserGroups);
let requestOptions: SearchPaginationSortRequest | undefined = $state();
const userGroupService = new UserGroupService();
@@ -29,7 +30,7 @@
action: async () => {
try {
await userGroupService.remove(userGroup.id);
userGroups = await userGroupService.list();
userGroups = await userGroupService.list(requestOptions!);
} catch (e) {
axiosErrorToast(e);
}
@@ -38,13 +39,19 @@
}
});
}
async function fetchItems(search: string, page: number, limit: number) {
return userGroupService.list(search, { page, limit });
}
</script>
<AdvancedTable items={userGroups} {fetchItems} columns={['Friendly Name', 'Name', 'User Count', {label: "Actions", hidden: true}]}>
<AdvancedTable
items={userGroups}
onRefresh={async (o) => (userGroups = await userGroupService.list(o))}
{requestOptions}
columns={[
{ label: 'Friendly Name', sortColumn: 'friendlyName' },
{ label: 'Name', sortColumn: 'name' },
{ label: 'User Count', sortColumn: 'userCount' },
{ label: 'Actions', hidden: true }
]}
>
{#snippet rows({ item })}
<Table.Cell>{item.friendlyName}</Table.Cell>
<Table.Cell>{item.name}</Table.Cell>

View File

@@ -13,16 +13,15 @@
const userService = new UserService();
let users = $state(initialUsers);
function fetchItems(search: string, page: number, limit: number) {
return userService.list(search, { page, limit });
}
</script>
<AdvancedTable
items={users}
{fetchItems}
columns={['Name', 'Email']}
onRefresh={async (o) => (users = await userService.list(o))}
columns={[
{ label: 'Name', sortColumn: 'name' },
{ label: 'Email', sortColumn: 'email' }
]}
bind:selectedIds={selectedUserIds}
>
{#snippet rows({ item })}

View File

@@ -25,8 +25,8 @@
};
const formSchema = z.object({
firstName: z.string().min(2).max(30),
lastName: z.string().min(2).max(30),
firstName: z.string().min(1).max(50),
lastName: z.string().min(1).max(50),
username: z
.string()
.min(2)

View File

@@ -7,7 +7,7 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Table from '$lib/components/ui/table';
import UserService from '$lib/services/user-service';
import type { Paginated } from '$lib/types/pagination.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { User } from '$lib/types/user.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideLink, LucidePencil, LucideTrash } from 'lucide-svelte';
@@ -15,20 +15,13 @@
import { toast } from 'svelte-sonner';
import OneTimeLinkModal from './one-time-link-modal.svelte';
let { users: initialUsers }: { users: Paginated<User> } = $props();
let users = $state<Paginated<User>>(initialUsers);
$effect(() => {
users = initialUsers;
});
let { users = $bindable() }: { users: Paginated<User> } = $props();
let requestOptions: SearchPaginationSortRequest | undefined = $state();
let userIdToCreateOneTimeLink: string | null = $state(null);;
let userIdToCreateOneTimeLink: string | null = $state(null);
const userService = new UserService();
function fetchItems(search: string, page: number, limit: number) {
return userService.list(search, { page, limit });
}
async function deleteUser(user: User) {
openConfirmDialog({
title: `Delete ${user.firstName} ${user.lastName}`,
@@ -39,7 +32,7 @@
action: async () => {
try {
await userService.remove(user.id);
users = await userService.list();
users = await userService.list(requestOptions!);
} catch (e) {
axiosErrorToast(e);
}
@@ -52,16 +45,34 @@
<AdvancedTable
items={users}
{fetchItems}
{requestOptions}
onRefresh={async (options) => (users = await userService.list(options))}
columns={[
'First name',
'Last name',
'Email',
'Username',
'Role',
{ label: 'Actions', hidden: true }
{
label: 'First name',
sortColumn: 'firstName'
},
{
label: 'Last name',
sortColumn: 'lastName'
},
{
label: 'Email',
sortColumn: 'email'
},
{
label: 'Username',
sortColumn: 'username'
},
{
label: 'Role',
sortColumn: 'isAdmin'
},
{
label: 'Actions',
hidden: true
}
]}
withoutSearch
>
{#snippet rows({ item })}
<Table.Cell>{item.firstName}</Table.Cell>

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