Compare commits

..

25 Commits

Author SHA1 Message Date
Elias Schneider
3f29325f45 release: 0.14.0 2024-11-11 18:26:15 +01:00
Elias Schneider
aca2240a50 feat: add audit log event for one time access token sign in 2024-11-11 18:25:57 +01:00
Elias Schneider
de45398903 fix: overflow of pagination control on mobile 2024-11-11 18:09:17 +01:00
Elias Schneider
3d3fb4d855 fix: time displayed incorrectly in audit log 2024-11-11 18:02:19 +01:00
Elias Schneider
725388fcc7 chore: fix build warnings 2024-11-02 00:04:27 +01:00
Elias Schneider
ad1d3560f9 release: 0.13.1 2024-11-01 23:52:30 +01:00
Elias Schneider
becfc0004a feat: add list empty indicator 2024-11-01 23:52:01 +01:00
Elias Schneider
376d747616 fix: errors in middleware do not abort the request 2024-11-01 23:41:57 +01:00
Elias Schneider
5b9f4d7326 fix: typo in Self-Account Editing description 2024-11-01 23:33:50 +01:00
Elias Schneider
0de4b55dc4 release: 0.13.0 2024-10-31 18:13:54 +01:00
Elias Schneider
78c88f5339 docs: add nginx configuration to README 2024-10-31 18:13:18 +01:00
Elias Schneider
60e7dafa01 Revert "fix: bad gateway error if nginx reverse proxy is in front"
This reverts commit 590cb02f6c.
2024-10-31 17:50:52 +01:00
Elias Schneider
2ccabf835c feat: add ability to define expiration of one time link 2024-10-31 17:22:58 +01:00
Elias Schneider
590cb02f6c fix: bad gateway error if nginx reverse proxy is in front 2024-10-31 14:15:57 +01:00
Elias Schneider
8c96ab9574 Merge branch 'main' of https://github.com/stonith404/pocket-id 2024-10-30 11:53:44 +01:00
Elias Schneider
3484daf870 chore: change default port in dockerfile 2024-10-30 11:53:36 +01:00
Kevin Cayouette
cfbc0d6d35 docs: add Jellyfin Integration Guide (#51) 2024-10-28 18:55:16 +01:00
Elias Schneider
939601b6a4 release: 0.12.0 2024-10-28 18:51:17 +01:00
Elias Schneider
b9daa5d757 tests: fix custom claims test data 2024-10-28 18:50:55 +01:00
Elias Schneider
8304065652 feat: add option to disable self-account editing 2024-10-28 18:45:27 +01:00
Elias Schneider
7bfc3f43a5 feat: add validation to custom claim input 2024-10-28 18:34:25 +01:00
Elias Schneider
c056089c60 feat: custom claims (#53) 2024-10-28 18:11:54 +01:00
Elias Schneider
3350398abc tests: correctly reset app config in tests 2024-10-26 00:15:31 +02:00
Elias Schneider
0b0a6781ff ci/cd: fix html reporting of playwright 2024-10-26 00:15:01 +02:00
Elias Schneider
735dc70d5f tests: fix flaky playwright tests 2024-10-25 22:48:46 +02:00
79 changed files with 1589 additions and 517 deletions

View File

@@ -15,12 +15,13 @@ jobs:
node-version: lts/* node-version: lts/*
cache: 'npm' cache: 'npm'
cache-dependency-path: frontend/package-lock.json cache-dependency-path: frontend/package-lock.json
- name: Create dummy GeoLite2 City database - name: Create dummy GeoLite2 City database
run: touch ./backend/GeoLite2-City.mmdb run: touch ./backend/GeoLite2-City.mmdb
- name: Build Docker Image - name: Build Docker Image
run: docker build -t stonith404/pocket-id . run: docker build -t stonith404/pocket-id .
- name: Run Docker Container - name: Run Docker Container
run: docker run -d --name pocket-id -p 80:80 --env-file .env.test stonith404/pocket-id run: docker run -d --name pocket-id -p 80:80 --env-file .env.test stonith404/pocket-id
@@ -36,13 +37,10 @@ jobs:
working-directory: ./frontend working-directory: ./frontend
run: npx playwright test run: npx playwright test
- name: Get container logs
if: always()
run: docker logs pocket-id
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
if: always() if: always()
with: with:
name: playwright-report name: playwright-report
path: frontend/tests/.output path: frontend/tests/.report
include-hidden-files: true
retention-days: 15 retention-days: 15

1
.gitignore vendored
View File

@@ -34,5 +34,6 @@ vite.config.ts.timestamp-*
# Application specific # Application specific
data data
/frontend/tests/.auth /frontend/tests/.auth
/frontend/tests/.report
pocket-id-backend pocket-id-backend
/backend/GeoLite2-City.mmdb /backend/GeoLite2-City.mmdb

View File

@@ -1 +1 @@
0.11.0 0.14.0

View File

@@ -1,3 +1,45 @@
## [](https://github.com/stonith404/pocket-id/compare/v0.13.1...v) (2024-11-11)
### Features
* add audit log event for one time access token sign in ([aca2240](https://github.com/stonith404/pocket-id/commit/aca2240a50a12e849cfb6e1aa56390b000aebae0))
### Bug Fixes
* overflow of pagination control on mobile ([de45398](https://github.com/stonith404/pocket-id/commit/de4539890349153c467013c24c4d6b30feb8fed8))
* time displayed incorrectly in audit log ([3d3fb4d](https://github.com/stonith404/pocket-id/commit/3d3fb4d855ef510f2292e98fcaaaf83debb5d3e0))
## [](https://github.com/stonith404/pocket-id/compare/v0.13.0...v) (2024-11-01)
### Features
* add list empty indicator ([becfc00](https://github.com/stonith404/pocket-id/commit/becfc0004a87c01e18eb92ac85bf4e33f105b6a3))
### Bug Fixes
* errors in middleware do not abort the request ([376d747](https://github.com/stonith404/pocket-id/commit/376d747616b1e835f252d20832c5ae42b8b0b737))
* typo in Self-Account Editing description ([5b9f4d7](https://github.com/stonith404/pocket-id/commit/5b9f4d732615f428c13d3317da96a86c5daebd89))
## [](https://github.com/stonith404/pocket-id/compare/v0.12.0...v) (2024-10-31)
### Features
* add ability to define expiration of one time link ([2ccabf8](https://github.com/stonith404/pocket-id/commit/2ccabf835c2c923d6986d9cafb4e878f5110b91a))
## [](https://github.com/stonith404/pocket-id/compare/v0.11.0...v) (2024-10-28)
### Features
* add option to disable self-account editing ([8304065](https://github.com/stonith404/pocket-id/commit/83040656525cf7b6c8f2acf416c5f8f3288f3d48))
* add validation to custom claim input ([7bfc3f4](https://github.com/stonith404/pocket-id/commit/7bfc3f43a591287c038187ed5e782de6b9dd738b))
* custom claims ([#53](https://github.com/stonith404/pocket-id/issues/53)) ([c056089](https://github.com/stonith404/pocket-id/commit/c056089c6043a825aaaaecf0c57454892a108f1d))
## [](https://github.com/stonith404/pocket-id/compare/v0.10.0...v) (2024-10-25) ## [](https://github.com/stonith404/pocket-id/compare/v0.10.0...v) (2024-10-25)

View File

@@ -38,7 +38,7 @@ COPY --from=backend-builder /app/backend/images ./backend/images
COPY ./scripts ./scripts COPY ./scripts ./scripts
RUN chmod +x ./scripts/*.sh RUN chmod +x ./scripts/*.sh
EXPOSE 3000 EXPOSE 80
ENV APP_ENV=production ENV APP_ENV=production
# Use a shell form to run both the frontend and backend # Use a shell form to run both the frontend and backend

View File

@@ -85,28 +85,23 @@ Required tools:
You can now sign in with the admin account on `http://localhost/login/setup`. You can now sign in with the admin account on `http://localhost/login/setup`.
### Add Pocket ID as an OIDC provider ### Nginx Reverse Proxy
You can add a new OIDC client on `https://<your-domain>/settings/admin/oidc-clients` To use Nginx in front of Pocket ID, add the following configuration to increase the header buffer size because, as SvelteKit generates larger headers.
After you have added the client, you can obtain the client ID and client secret. ```nginx
proxy_busy_buffers_size 512k;
proxy_buffers 4 512k;
proxy_buffer_size 256k;
```
You may need the following information: ## Proxy Services with Pocket ID
- **Authorization URL**: `https://<your-domain>/authorize`
- **Token URL**: `https://<your-domain>/api/oidc/token`
- **Userinfo URL**: `https://<your-domain>/api/oidc/userinfo`
- **Certificate URL**: `https://<your-domain>/.well-known/jwks.json`
- **OIDC Discovery URL**: `https://<your-domain>/.well-known/openid-configuration`
- **Scopes**: At least `openid email`. Optionally you can add `profile` and `groups`.
### 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/) to add authentication to your services that don't support OIDC. 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/) to add authentication to your services that don't support OIDC.
See the [guide](docs/proxy-services.md) for more information. See the [guide](docs/proxy-services.md) for more information.
### Update ## Update
#### Docker #### Docker
@@ -149,7 +144,7 @@ docker compose up -d
pm2 start caddy --name pocket-id-caddy -- run --config Caddyfile pm2 start caddy --name pocket-id-caddy -- run --config Caddyfile
``` ```
### Environment variables ## Environment variables
| Variable | Default Value | Recommended to change | Description | | Variable | Default Value | Recommended to change | Description |
| ---------------------- | ----------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------- | ----------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |

View File

@@ -19,6 +19,7 @@ func newDatabase() (db *gorm.DB) {
log.Fatalf("failed to connect to database: %v", err) log.Fatalf("failed to connect to database: %v", err)
} }
sqlDb, err := db.DB() sqlDb, err := db.DB()
sqlDb.SetMaxOpenConns(1)
if err != nil { if err != nil {
log.Fatalf("failed to get sql.DB: %v", err) log.Fatalf("failed to get sql.DB: %v", err)
} }

View File

@@ -38,12 +38,14 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
auditLogService := service.NewAuditLogService(db, appConfigService, emailService) auditLogService := service.NewAuditLogService(db, appConfigService, emailService)
jwtService := service.NewJwtService(appConfigService) jwtService := service.NewJwtService(appConfigService)
webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService) webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
userService := service.NewUserService(db, jwtService) userService := service.NewUserService(db, jwtService, auditLogService)
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService) customClaimService := service.NewCustomClaimService(db)
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
testService := service.NewTestService(db, appConfigService) testService := service.NewTestService(db, appConfigService)
userGroupService := service.NewUserGroupService(db) userGroupService := service.NewUserGroupService(db)
r.Use(middleware.NewCorsMiddleware().Add()) r.Use(middleware.NewCorsMiddleware().Add())
r.Use(middleware.NewErrorHandlerMiddleware().Add())
r.Use(middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60)) r.Use(middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60))
r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false)) r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false))
@@ -55,10 +57,11 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
apiGroup := r.Group("/api") apiGroup := r.Group("/api")
controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService) controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService)
controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService) controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService) controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService)
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService) controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService)
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware) controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService) controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService)
controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService)
// Add test controller in non-production environments // Add test controller in non-production environments
if common.EnvConfig.AppEnv != "production" { if common.EnvConfig.AppEnv != "production" {

View File

@@ -1,19 +1,148 @@
package common package common
import "errors" import (
"fmt"
var ( "net/http"
ErrUsernameTaken = errors.New("username is already taken")
ErrEmailTaken = errors.New("email is already taken")
ErrSetupAlreadyCompleted = errors.New("setup already completed")
ErrTokenInvalidOrExpired = errors.New("token is invalid or expired")
ErrOidcMissingAuthorization = errors.New("missing authorization")
ErrOidcGrantTypeNotSupported = errors.New("grant type not supported")
ErrOidcMissingClientCredentials = errors.New("client id or secret not provided")
ErrOidcClientSecretInvalid = errors.New("invalid client secret")
ErrOidcInvalidAuthorizationCode = errors.New("invalid authorization code")
ErrOidcInvalidCallbackURL = errors.New("invalid callback URL")
ErrFileTypeNotSupported = errors.New("file type not supported")
ErrInvalidCredentials = errors.New("no user found with provided credentials")
ErrNameAlreadyInUse = errors.New("name is already in use")
) )
type AppError interface {
error
HttpStatusCode() int
}
// Custom error types for various conditions
type AlreadyInUseError struct {
Property string
}
func (e *AlreadyInUseError) Error() string {
return fmt.Sprintf("%s is already in use", e.Property)
}
func (e *AlreadyInUseError) HttpStatusCode() int { return 400 }
type SetupAlreadyCompletedError struct{}
func (e *SetupAlreadyCompletedError) Error() string { return "setup already completed" }
func (e *SetupAlreadyCompletedError) HttpStatusCode() int { return 400 }
type TokenInvalidOrExpiredError struct{}
func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return 400 }
type OidcMissingAuthorizationError struct{}
func (e *OidcMissingAuthorizationError) Error() string { return "missing authorization" }
func (e *OidcMissingAuthorizationError) HttpStatusCode() int { return http.StatusForbidden }
type OidcGrantTypeNotSupportedError struct{}
func (e *OidcGrantTypeNotSupportedError) Error() string { return "grant type not supported" }
func (e *OidcGrantTypeNotSupportedError) HttpStatusCode() int { return 400 }
type OidcMissingClientCredentialsError struct{}
func (e *OidcMissingClientCredentialsError) Error() string { return "client id or secret not provided" }
func (e *OidcMissingClientCredentialsError) HttpStatusCode() int { return 400 }
type OidcClientSecretInvalidError struct{}
func (e *OidcClientSecretInvalidError) Error() string { return "invalid client secret" }
func (e *OidcClientSecretInvalidError) HttpStatusCode() int { return 400 }
type OidcInvalidAuthorizationCodeError struct{}
func (e *OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" }
func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return 400 }
type OidcInvalidCallbackURLError struct{}
func (e *OidcInvalidCallbackURLError) Error() string { return "invalid callback URL" }
func (e *OidcInvalidCallbackURLError) HttpStatusCode() int { return 400 }
type FileTypeNotSupportedError struct{}
func (e *FileTypeNotSupportedError) Error() string { return "file type not supported" }
func (e *FileTypeNotSupportedError) HttpStatusCode() int { return 400 }
type InvalidCredentialsError struct{}
func (e *InvalidCredentialsError) Error() string { return "no user found with provided credentials" }
func (e *InvalidCredentialsError) HttpStatusCode() int { return 400 }
type FileTooLargeError struct {
MaxSize string
}
func (e *FileTooLargeError) Error() string {
return fmt.Sprintf("The file can't be larger than %s", e.MaxSize)
}
func (e *FileTooLargeError) HttpStatusCode() int { return http.StatusRequestEntityTooLarge }
type NotSignedInError struct{}
func (e *NotSignedInError) Error() string { return "You are not signed in" }
func (e *NotSignedInError) HttpStatusCode() int { return http.StatusUnauthorized }
type MissingPermissionError struct{}
func (e *MissingPermissionError) Error() string {
return "You don't have permission to perform this action"
}
func (e *MissingPermissionError) HttpStatusCode() int { return http.StatusForbidden }
type TooManyRequestsError struct{}
func (e *TooManyRequestsError) Error() string {
return "Too many requests. Please wait a while before trying again."
}
func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests }
type ClientIdOrSecretNotProvidedError struct{}
func (e *ClientIdOrSecretNotProvidedError) Error() string {
return "Client id and secret not provided"
}
func (e *ClientIdOrSecretNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
type WrongFileTypeError struct {
ExpectedFileType string
}
func (e *WrongFileTypeError) Error() string {
return fmt.Sprintf("File must be of type %s", e.ExpectedFileType)
}
func (e *WrongFileTypeError) HttpStatusCode() int { return http.StatusBadRequest }
type MissingSessionIdError struct{}
func (e *MissingSessionIdError) Error() string {
return "Missing session id"
}
func (e *MissingSessionIdError) HttpStatusCode() int { return http.StatusBadRequest }
type ReservedClaimError struct {
Key string
}
func (e *ReservedClaimError) Error() string {
return fmt.Sprintf("Claim %s is reserved and can't be used", e.Key)
}
func (e *ReservedClaimError) HttpStatusCode() int { return http.StatusBadRequest }
type DuplicateClaimError struct {
Key string
}
func (e *DuplicateClaimError) Error() string {
return fmt.Sprintf("Claim %s is already defined", e.Key)
}
func (e *DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest }
type AccountEditNotAllowedError struct{}
func (e *AccountEditNotAllowedError) Error() string {
return "You are not allowed to edit your account"
}
func (e *AccountEditNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }

View File

@@ -1,7 +1,6 @@
package controller package controller
import ( import (
"errors"
"fmt" "fmt"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/common" "github.com/stonith404/pocket-id/backend/internal/common"
@@ -39,13 +38,13 @@ type AppConfigController struct {
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) { func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListAppConfig(false) configuration, err := acc.appConfigService.ListAppConfig(false)
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
var configVariablesDto []dto.PublicAppConfigVariableDto var configVariablesDto []dto.PublicAppConfigVariableDto
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil { if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -55,13 +54,13 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) { func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListAppConfig(true) configuration, err := acc.appConfigService.ListAppConfig(true)
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
var configVariablesDto []dto.AppConfigVariableDto var configVariablesDto []dto.AppConfigVariableDto
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil { if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -71,19 +70,19 @@ func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) { func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
var input dto.AppConfigUpdateDto var input dto.AppConfigUpdateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(input) savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(input)
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
var configVariablesDto []dto.AppConfigVariableDto var configVariablesDto []dto.AppConfigVariableDto
if err := dto.MapStructList(savedConfigVariables, &configVariablesDto); err != nil { if err := dto.MapStructList(savedConfigVariables, &configVariablesDto); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -136,13 +135,13 @@ func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) { func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
file, err := c.FormFile("file") file, err := c.FormFile("file")
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
fileType := utils.GetFileExtension(file.Filename) fileType := utils.GetFileExtension(file.Filename)
if fileType != "ico" { if fileType != "ico" {
utils.CustomControllerError(c, http.StatusBadRequest, "File must be of type .ico") c.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"})
return return
} }
acc.updateImage(c, "favicon", "ico") acc.updateImage(c, "favicon", "ico")
@@ -164,17 +163,13 @@ func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType
func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, oldImageType string) { func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, oldImageType string) {
file, err := c.FormFile("file") file, err := c.FormFile("file")
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
err = acc.appConfigService.UpdateImage(file, imageName, oldImageType) err = acc.appConfigService.UpdateImage(file, imageName, oldImageType)
if err != nil { if err != nil {
if errors.Is(err, common.ErrFileTypeNotSupported) { c.Error(err)
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
} else {
utils.ControllerError(c, err)
}
return return
} }

View File

@@ -8,7 +8,6 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/service" "github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
) )
func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.AuditLogService, jwtAuthMiddleware *middleware.JwtAuthMiddleware) { func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.AuditLogService, jwtAuthMiddleware *middleware.JwtAuthMiddleware) {
@@ -31,7 +30,7 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
// Fetch audit logs for the user // Fetch audit logs for the user
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(userID, page, pageSize) logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(userID, page, pageSize)
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -39,7 +38,7 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
var logsDtos []dto.AuditLogDto var logsDtos []dto.AuditLogDto
err = dto.MapStructList(logs, &logsDtos) err = dto.MapStructList(logs, &logsDtos)
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }

View File

@@ -0,0 +1,78 @@
package controller
import (
"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"
"net/http"
)
func NewCustomClaimController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, customClaimService *service.CustomClaimService) {
wkc := &CustomClaimController{customClaimService: customClaimService}
group.GET("/custom-claims/suggestions", jwtAuthMiddleware.Add(true), wkc.getSuggestionsHandler)
group.PUT("/custom-claims/user/:userId", jwtAuthMiddleware.Add(true), wkc.UpdateCustomClaimsForUserHandler)
group.PUT("/custom-claims/user-group/:userGroupId", jwtAuthMiddleware.Add(true), wkc.UpdateCustomClaimsForUserGroupHandler)
}
type CustomClaimController struct {
customClaimService *service.CustomClaimService
}
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
claims, err := ccc.customClaimService.GetSuggestions()
if err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, claims)
}
func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) {
var input []dto.CustomClaimCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
userId := c.Param("userId")
claims, err := ccc.customClaimService.UpdateCustomClaimsForUser(userId, input)
if err != nil {
c.Error(err)
return
}
var customClaimsDto []dto.CustomClaimDto
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, customClaimsDto)
}
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
var input []dto.CustomClaimCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
userId := c.Param("userGroupId")
claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(userId, input)
if err != nil {
c.Error(err)
return
}
var customClaimsDto []dto.CustomClaimDto
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, customClaimsDto)
}

View File

@@ -1,13 +1,11 @@
package controller package controller
import ( import (
"errors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/common" "github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto" "github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware" "github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/service" "github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -42,19 +40,13 @@ type OidcController struct {
func (oc *OidcController) authorizeHandler(c *gin.Context) { func (oc *OidcController) authorizeHandler(c *gin.Context) {
var input dto.AuthorizeOidcClientRequestDto var input dto.AuthorizeOidcClientRequestDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
code, callbackURL, err := oc.oidcService.Authorize(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent()) code, callbackURL, err := oc.oidcService.Authorize(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
if err != nil { if err != nil {
if errors.Is(err, common.ErrOidcMissingAuthorization) { c.Error(err)
utils.CustomControllerError(c, http.StatusForbidden, err.Error())
} else if errors.Is(err, common.ErrOidcInvalidCallbackURL) {
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
} else {
utils.ControllerError(c, err)
}
return return
} }
@@ -69,17 +61,13 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) { func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
var input dto.AuthorizeOidcClientRequestDto var input dto.AuthorizeOidcClientRequestDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
code, callbackURL, err := oc.oidcService.AuthorizeNewClient(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent()) code, callbackURL, err := oc.oidcService.AuthorizeNewClient(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
if err != nil { if err != nil {
if errors.Is(err, common.ErrOidcInvalidCallbackURL) { c.Error(err)
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
} else {
utils.ControllerError(c, err)
}
return return
} }
@@ -95,7 +83,7 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
var input dto.OidcIdTokenDto var input dto.OidcIdTokenDto
if err := c.ShouldBind(&input); err != nil { if err := c.ShouldBind(&input); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -107,21 +95,14 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
var ok bool var ok bool
clientID, clientSecret, ok = c.Request.BasicAuth() clientID, clientSecret, ok = c.Request.BasicAuth()
if !ok { if !ok {
utils.CustomControllerError(c, http.StatusBadRequest, "Client id and secret not provided") c.Error(&common.ClientIdOrSecretNotProvidedError{})
return return
} }
} }
idToken, accessToken, err := oc.oidcService.CreateTokens(input.Code, input.GrantType, clientID, clientSecret) idToken, accessToken, err := oc.oidcService.CreateTokens(input.Code, input.GrantType, clientID, clientSecret)
if err != nil { if err != nil {
if errors.Is(err, common.ErrOidcGrantTypeNotSupported) || c.Error(err)
errors.Is(err, common.ErrOidcMissingClientCredentials) ||
errors.Is(err, common.ErrOidcClientSecretInvalid) ||
errors.Is(err, common.ErrOidcInvalidAuthorizationCode) {
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
} else {
utils.ControllerError(c, err)
}
return return
} }
@@ -132,14 +113,14 @@ func (oc *OidcController) userInfoHandler(c *gin.Context) {
token := strings.Split(c.GetHeader("Authorization"), " ")[1] token := strings.Split(c.GetHeader("Authorization"), " ")[1]
jwtClaims, err := oc.jwtService.VerifyOauthAccessToken(token) jwtClaims, err := oc.jwtService.VerifyOauthAccessToken(token)
if err != nil { if err != nil {
utils.CustomControllerError(c, http.StatusUnauthorized, common.ErrTokenInvalidOrExpired.Error()) c.Error(err)
return return
} }
userID := jwtClaims.Subject userID := jwtClaims.Subject
clientId := jwtClaims.Audience[0] clientId := jwtClaims.Audience[0]
claims, err := oc.oidcService.GetUserClaimsForClient(userID, clientId) claims, err := oc.oidcService.GetUserClaimsForClient(userID, clientId)
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -150,7 +131,7 @@ 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)
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -171,7 +152,7 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
} }
} }
utils.ControllerError(c, err) c.Error(err)
} }
func (oc *OidcController) listClientsHandler(c *gin.Context) { func (oc *OidcController) listClientsHandler(c *gin.Context) {
@@ -181,13 +162,13 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
clients, pagination, err := oc.oidcService.ListClients(searchTerm, page, pageSize) clients, pagination, err := oc.oidcService.ListClients(searchTerm, page, pageSize)
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
var clientsDto []dto.OidcClientDto var clientsDto []dto.OidcClientDto
if err := dto.MapStructList(clients, &clientsDto); err != nil { if err := dto.MapStructList(clients, &clientsDto); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -200,19 +181,19 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
func (oc *OidcController) createClientHandler(c *gin.Context) { func (oc *OidcController) createClientHandler(c *gin.Context) {
var input dto.OidcClientCreateDto var input dto.OidcClientCreateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
client, err := oc.oidcService.CreateClient(input, c.GetString("userID")) client, err := oc.oidcService.CreateClient(input, c.GetString("userID"))
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
var clientDto dto.OidcClientDto var clientDto dto.OidcClientDto
if err := dto.MapStruct(client, &clientDto); err != nil { if err := dto.MapStruct(client, &clientDto); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -222,7 +203,7 @@ func (oc *OidcController) createClientHandler(c *gin.Context) {
func (oc *OidcController) deleteClientHandler(c *gin.Context) { func (oc *OidcController) deleteClientHandler(c *gin.Context) {
err := oc.oidcService.DeleteClient(c.Param("id")) err := oc.oidcService.DeleteClient(c.Param("id"))
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -232,19 +213,19 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) {
func (oc *OidcController) updateClientHandler(c *gin.Context) { func (oc *OidcController) updateClientHandler(c *gin.Context) {
var input dto.OidcClientCreateDto var input dto.OidcClientCreateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
client, err := oc.oidcService.UpdateClient(c.Param("id"), input) client, err := oc.oidcService.UpdateClient(c.Param("id"), input)
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
var clientDto dto.OidcClientDto var clientDto dto.OidcClientDto
if err := dto.MapStruct(client, &clientDto); err != nil { if err := dto.MapStruct(client, &clientDto); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -254,7 +235,7 @@ func (oc *OidcController) updateClientHandler(c *gin.Context) {
func (oc *OidcController) createClientSecretHandler(c *gin.Context) { func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
secret, err := oc.oidcService.CreateClientSecret(c.Param("id")) secret, err := oc.oidcService.CreateClientSecret(c.Param("id"))
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -264,7 +245,7 @@ func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
func (oc *OidcController) getClientLogoHandler(c *gin.Context) { func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Param("id")) imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Param("id"))
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -275,17 +256,13 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) { func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
file, err := c.FormFile("file") file, err := c.FormFile("file")
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
err = oc.oidcService.UpdateClientLogo(c.Param("id"), file) err = oc.oidcService.UpdateClientLogo(c.Param("id"), file)
if err != nil { if err != nil {
if errors.Is(err, common.ErrFileTypeNotSupported) { c.Error(err)
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
} else {
utils.ControllerError(c, err)
}
return return
} }
@@ -295,7 +272,7 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) { func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
err := oc.oidcService.DeleteClientLogo(c.Param("id")) err := oc.oidcService.DeleteClientLogo(c.Param("id"))
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }

View File

@@ -3,7 +3,6 @@ package controller
import ( import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/service" "github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http" "net/http"
) )
@@ -19,17 +18,22 @@ type TestController struct {
func (tc *TestController) resetAndSeedHandler(c *gin.Context) { func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
if err := tc.TestService.ResetDatabase(); err != nil { if err := tc.TestService.ResetDatabase(); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
if err := tc.TestService.ResetApplicationImages(); err != nil { if err := tc.TestService.ResetApplicationImages(); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
if err := tc.TestService.SeedDatabase(); err != nil { if err := tc.TestService.SeedDatabase(); err != nil {
utils.ControllerError(c, err) c.Error(err)
return
}
if err := tc.TestService.ResetAppConfig(); err != nil {
c.Error(err)
return return
} }

View File

@@ -1,22 +1,21 @@
package controller package controller
import ( import (
"errors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/common" "github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto" "github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware" "github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/service" "github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"golang.org/x/time/rate" "golang.org/x/time/rate"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
) )
func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService) { func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, appConfigService *service.AppConfigService) {
uc := UserController{ uc := UserController{
UserService: userService, UserService: userService,
AppConfigService: appConfigService,
} }
group.GET("/users", jwtAuthMiddleware.Add(true), uc.listUsersHandler) group.GET("/users", jwtAuthMiddleware.Add(true), uc.listUsersHandler)
@@ -33,7 +32,8 @@ func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt
} }
type UserController struct { type UserController struct {
UserService *service.UserService UserService *service.UserService
AppConfigService *service.AppConfigService
} }
func (uc *UserController) listUsersHandler(c *gin.Context) { func (uc *UserController) listUsersHandler(c *gin.Context) {
@@ -43,13 +43,13 @@ func (uc *UserController) listUsersHandler(c *gin.Context) {
users, pagination, err := uc.UserService.ListUsers(searchTerm, page, pageSize) users, pagination, err := uc.UserService.ListUsers(searchTerm, page, pageSize)
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
var usersDto []dto.UserDto var usersDto []dto.UserDto
if err := dto.MapStructList(users, &usersDto); err != nil { if err := dto.MapStructList(users, &usersDto); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -62,13 +62,13 @@ func (uc *UserController) listUsersHandler(c *gin.Context) {
func (uc *UserController) getUserHandler(c *gin.Context) { func (uc *UserController) getUserHandler(c *gin.Context) {
user, err := uc.UserService.GetUser(c.Param("id")) user, err := uc.UserService.GetUser(c.Param("id"))
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
var userDto dto.UserDto var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil { if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -78,13 +78,13 @@ func (uc *UserController) getUserHandler(c *gin.Context) {
func (uc *UserController) getCurrentUserHandler(c *gin.Context) { func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
user, err := uc.UserService.GetUser(c.GetString("userID")) user, err := uc.UserService.GetUser(c.GetString("userID"))
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
var userDto dto.UserDto var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil { if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -93,7 +93,7 @@ func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
func (uc *UserController) deleteUserHandler(c *gin.Context) { func (uc *UserController) deleteUserHandler(c *gin.Context) {
if err := uc.UserService.DeleteUser(c.Param("id")); err != nil { if err := uc.UserService.DeleteUser(c.Param("id")); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -103,23 +103,19 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) {
func (uc *UserController) createUserHandler(c *gin.Context) { func (uc *UserController) createUserHandler(c *gin.Context) {
var input dto.UserCreateDto var input dto.UserCreateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
user, err := uc.UserService.CreateUser(input) user, err := uc.UserService.CreateUser(input)
if err != nil { if err != nil {
if errors.Is(err, common.ErrEmailTaken) || errors.Is(err, common.ErrUsernameTaken) { c.Error(err)
utils.CustomControllerError(c, http.StatusConflict, err.Error())
} else {
utils.ControllerError(c, err)
}
return return
} }
var userDto dto.UserDto var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil { if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -131,19 +127,23 @@ func (uc *UserController) updateUserHandler(c *gin.Context) {
} }
func (uc *UserController) updateCurrentUserHandler(c *gin.Context) { func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
if uc.AppConfigService.DbConfig.AllowOwnAccountEdit.Value != "true" {
c.Error(&common.AccountEditNotAllowedError{})
return
}
uc.updateUser(c, true) uc.updateUser(c, true)
} }
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) { func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
var input dto.OneTimeAccessTokenCreateDto var input dto.OneTimeAccessTokenCreateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
token, err := uc.UserService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt) token, err := uc.UserService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt, c.ClientIP(), c.Request.UserAgent())
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -153,17 +153,13 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) { func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
user, token, err := uc.UserService.ExchangeOneTimeAccessToken(c.Param("token")) user, token, err := uc.UserService.ExchangeOneTimeAccessToken(c.Param("token"))
if err != nil { if err != nil {
if errors.Is(err, common.ErrTokenInvalidOrExpired) { c.Error(err)
utils.CustomControllerError(c, http.StatusUnauthorized, err.Error())
} else {
utils.ControllerError(c, err)
}
return return
} }
var userDto dto.UserDto var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil { if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -174,17 +170,13 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) { func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
user, token, err := uc.UserService.SetupInitialAdmin() user, token, err := uc.UserService.SetupInitialAdmin()
if err != nil { if err != nil {
if errors.Is(err, common.ErrSetupAlreadyCompleted) { c.Error(err)
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
} else {
utils.ControllerError(c, err)
}
return return
} }
var userDto dto.UserDto var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil { if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -195,7 +187,7 @@ func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) { func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
var input dto.UserCreateDto var input dto.UserCreateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -208,17 +200,13 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
user, err := uc.UserService.UpdateUser(userID, input, updateOwnUser) user, err := uc.UserService.UpdateUser(userID, input, updateOwnUser)
if err != nil { if err != nil {
if errors.Is(err, common.ErrEmailTaken) || errors.Is(err, common.ErrUsernameTaken) { c.Error(err)
utils.CustomControllerError(c, http.StatusConflict, err.Error())
} else {
utils.ControllerError(c, err)
}
return return
} }
var userDto dto.UserDto var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil { if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }

View File

@@ -1,16 +1,13 @@
package controller package controller
import ( import (
"errors"
"net/http" "net/http"
"strconv" "strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto" "github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware" "github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/service" "github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
) )
func NewUserGroupController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, userGroupService *service.UserGroupService) { func NewUserGroupController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, userGroupService *service.UserGroupService) {
@@ -37,7 +34,7 @@ func (ugc *UserGroupController) list(c *gin.Context) {
groups, pagination, err := ugc.UserGroupService.List(searchTerm, page, pageSize) groups, pagination, err := ugc.UserGroupService.List(searchTerm, page, pageSize)
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -45,12 +42,12 @@ func (ugc *UserGroupController) list(c *gin.Context) {
for i, group := range groups { for i, group := range groups {
var groupDto dto.UserGroupDtoWithUserCount var groupDto dto.UserGroupDtoWithUserCount
if err := dto.MapStruct(group, &groupDto); err != nil { if err := dto.MapStruct(group, &groupDto); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(group.ID) groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(group.ID)
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
groupsDto[i] = groupDto groupsDto[i] = groupDto
@@ -65,13 +62,13 @@ func (ugc *UserGroupController) list(c *gin.Context) {
func (ugc *UserGroupController) get(c *gin.Context) { func (ugc *UserGroupController) get(c *gin.Context) {
group, err := ugc.UserGroupService.Get(c.Param("id")) group, err := ugc.UserGroupService.Get(c.Param("id"))
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
var groupDto dto.UserGroupDtoWithUsers var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil { if err := dto.MapStruct(group, &groupDto); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -81,23 +78,19 @@ func (ugc *UserGroupController) get(c *gin.Context) {
func (ugc *UserGroupController) create(c *gin.Context) { func (ugc *UserGroupController) create(c *gin.Context) {
var input dto.UserGroupCreateDto var input dto.UserGroupCreateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
group, err := ugc.UserGroupService.Create(input) group, err := ugc.UserGroupService.Create(input)
if err != nil { if err != nil {
if errors.Is(err, common.ErrNameAlreadyInUse) { c.Error(err)
utils.CustomControllerError(c, http.StatusConflict, err.Error())
} else {
utils.ControllerError(c, err)
}
return return
} }
var groupDto dto.UserGroupDtoWithUsers var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil { if err := dto.MapStruct(group, &groupDto); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -107,23 +100,19 @@ func (ugc *UserGroupController) create(c *gin.Context) {
func (ugc *UserGroupController) update(c *gin.Context) { func (ugc *UserGroupController) update(c *gin.Context) {
var input dto.UserGroupCreateDto var input dto.UserGroupCreateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
group, err := ugc.UserGroupService.Update(c.Param("id"), input) group, err := ugc.UserGroupService.Update(c.Param("id"), input)
if err != nil { if err != nil {
if errors.Is(err, common.ErrNameAlreadyInUse) { c.Error(err)
utils.CustomControllerError(c, http.StatusConflict, err.Error())
} else {
utils.ControllerError(c, err)
}
return return
} }
var groupDto dto.UserGroupDtoWithUsers var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil { if err := dto.MapStruct(group, &groupDto); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -132,7 +121,7 @@ func (ugc *UserGroupController) update(c *gin.Context) {
func (ugc *UserGroupController) delete(c *gin.Context) { func (ugc *UserGroupController) delete(c *gin.Context) {
if err := ugc.UserGroupService.Delete(c.Param("id")); err != nil { if err := ugc.UserGroupService.Delete(c.Param("id")); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -142,19 +131,19 @@ func (ugc *UserGroupController) delete(c *gin.Context) {
func (ugc *UserGroupController) updateUsers(c *gin.Context) { func (ugc *UserGroupController) updateUsers(c *gin.Context) {
var input dto.UserGroupUpdateUsersDto var input dto.UserGroupUpdateUsersDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
group, err := ugc.UserGroupService.UpdateUsers(c.Param("id"), input) group, err := ugc.UserGroupService.UpdateUsers(c.Param("id"), input)
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
var groupDto dto.UserGroupDtoWithUsers var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil { if err := dto.MapStruct(group, &groupDto); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }

View File

@@ -1,17 +1,15 @@
package controller package controller
import ( import (
"errors"
"github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto" "github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware" "github.com/stonith404/pocket-id/backend/internal/middleware"
"net/http" "net/http"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/service" "github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"golang.org/x/time/rate" "golang.org/x/time/rate"
) )
@@ -38,7 +36,7 @@ func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
userID := c.GetString("userID") userID := c.GetString("userID")
options, err := wc.webAuthnService.BeginRegistration(userID) options, err := wc.webAuthnService.BeginRegistration(userID)
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -49,20 +47,20 @@ func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) { func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
sessionID, err := c.Cookie("session_id") sessionID, err := c.Cookie("session_id")
if err != nil { if err != nil {
utils.CustomControllerError(c, http.StatusBadRequest, "Session ID missing") c.Error(&common.MissingSessionIdError{})
return return
} }
userID := c.GetString("userID") userID := c.GetString("userID")
credential, err := wc.webAuthnService.VerifyRegistration(sessionID, userID, c.Request) credential, err := wc.webAuthnService.VerifyRegistration(sessionID, userID, c.Request)
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
var credentialDto dto.WebauthnCredentialDto var credentialDto dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &credentialDto); err != nil { if err := dto.MapStruct(credential, &credentialDto); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -72,7 +70,7 @@ func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
func (wc *WebauthnController) beginLoginHandler(c *gin.Context) { func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
options, err := wc.webAuthnService.BeginLogin() options, err := wc.webAuthnService.BeginLogin()
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -83,13 +81,13 @@ func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) { func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
sessionID, err := c.Cookie("session_id") sessionID, err := c.Cookie("session_id")
if err != nil { if err != nil {
utils.CustomControllerError(c, http.StatusBadRequest, "Session ID missing") c.Error(&common.MissingSessionIdError{})
return return
} }
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body) credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -97,17 +95,13 @@ func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
user, token, err := wc.webAuthnService.VerifyLogin(sessionID, userID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent()) user, token, err := wc.webAuthnService.VerifyLogin(sessionID, userID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
if err != nil { if err != nil {
if errors.Is(err, common.ErrInvalidCredentials) { c.Error(err)
utils.CustomControllerError(c, http.StatusUnauthorized, err.Error())
} else {
utils.ControllerError(c, err)
}
return return
} }
var userDto dto.UserDto var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil { if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -119,13 +113,13 @@ func (wc *WebauthnController) listCredentialsHandler(c *gin.Context) {
userID := c.GetString("userID") userID := c.GetString("userID")
credentials, err := wc.webAuthnService.ListCredentials(userID) credentials, err := wc.webAuthnService.ListCredentials(userID)
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
var credentialDtos []dto.WebauthnCredentialDto var credentialDtos []dto.WebauthnCredentialDto
if err := dto.MapStructList(credentials, &credentialDtos); err != nil { if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -138,7 +132,7 @@ func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) {
err := wc.webAuthnService.DeleteCredential(userID, credentialID) err := wc.webAuthnService.DeleteCredential(userID, credentialID)
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
@@ -151,19 +145,19 @@ func (wc *WebauthnController) updateCredentialHandler(c *gin.Context) {
var input dto.WebauthnCredentialUpdateDto var input dto.WebauthnCredentialUpdateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
credential, err := wc.webAuthnService.UpdateCredential(userID, credentialID, input.Name) credential, err := wc.webAuthnService.UpdateCredential(userID, credentialID, input.Name)
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }
var credentialDto dto.WebauthnCredentialDto var credentialDto dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &credentialDto); err != nil { if err := dto.MapStruct(credential, &credentialDto); err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }

View File

@@ -4,7 +4,6 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/common" "github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/service" "github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http" "net/http"
) )
@@ -21,7 +20,7 @@ type WellKnownController struct {
func (wkc *WellKnownController) jwksHandler(c *gin.Context) { func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
jwk, err := wkc.jwtService.GetJWK() jwk, err := wkc.jwtService.GetJWK()
if err != nil { if err != nil {
utils.ControllerError(c, err) c.Error(err)
return return
} }

View File

@@ -12,13 +12,14 @@ type AppConfigVariableDto struct {
} }
type AppConfigUpdateDto struct { type AppConfigUpdateDto struct {
AppName string `json:"appName" binding:"required,min=1,max=30"` AppName string `json:"appName" binding:"required,min=1,max=30"`
SessionDuration string `json:"sessionDuration" binding:"required"` SessionDuration string `json:"sessionDuration" binding:"required"`
EmailsVerified string `json:"emailsVerified" binding:"required"` EmailsVerified string `json:"emailsVerified" binding:"required"`
EmailEnabled string `json:"emailEnabled" binding:"required"` AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
SmtHost string `json:"smtpHost"` EmailEnabled string `json:"emailEnabled" binding:"required"`
SmtpPort string `json:"smtpPort"` SmtHost string `json:"smtpHost"`
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"` SmtpPort string `json:"smtpPort"`
SmtpUser string `json:"smtpUser"` SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
SmtpPassword string `json:"smtpPassword"` SmtpUser string `json:"smtpUser"`
SmtpPassword string `json:"smtpPassword"`
} }

View File

@@ -2,12 +2,12 @@ package dto
import ( import (
"github.com/stonith404/pocket-id/backend/internal/model" "github.com/stonith404/pocket-id/backend/internal/model"
"time" datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
) )
type AuditLogDto struct { type AuditLogDto struct {
ID string `json:"id"` ID string `json:"id"`
CreatedAt time.Time `json:"createdAt"` CreatedAt datatype.DateTime `json:"createdAt"`
Event model.AuditLogEvent `json:"event"` Event model.AuditLogEvent `json:"event"`
IpAddress string `json:"ipAddress"` IpAddress string `json:"ipAddress"`

View File

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

View File

@@ -3,12 +3,13 @@ package dto
import "time" import "time"
type UserDto struct { type UserDto struct {
ID string `json:"id"` ID string `json:"id"`
Username string `json:"username"` Username string `json:"username"`
Email string `json:"email" ` Email string `json:"email" `
FirstName string `json:"firstName"` FirstName string `json:"firstName"`
LastName string `json:"lastName"` LastName string `json:"lastName"`
IsAdmin bool `json:"isAdmin"` IsAdmin bool `json:"isAdmin"`
CustomClaims []CustomClaimDto `json:"customClaims"`
} }
type UserCreateDto struct { type UserCreateDto struct {

View File

@@ -1,21 +1,25 @@
package dto package dto
import "time" import (
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
)
type UserGroupDtoWithUsers struct { type UserGroupDtoWithUsers struct {
ID string `json:"id"` ID string `json:"id"`
FriendlyName string `json:"friendlyName"` FriendlyName string `json:"friendlyName"`
Name string `json:"name"` Name string `json:"name"`
Users []UserDto `json:"users"` CustomClaims []CustomClaimDto `json:"customClaims"`
CreatedAt time.Time `json:"createdAt"` Users []UserDto `json:"users"`
CreatedAt datatype.DateTime `json:"createdAt"`
} }
type UserGroupDtoWithUserCount struct { type UserGroupDtoWithUserCount struct {
ID string `json:"id"` ID string `json:"id"`
FriendlyName string `json:"friendlyName"` FriendlyName string `json:"friendlyName"`
Name string `json:"name"` Name string `json:"name"`
UserCount int64 `json:"userCount"` CustomClaims []CustomClaimDto `json:"customClaims"`
CreatedAt time.Time `json:"createdAt"` UserCount int64 `json:"userCount"`
CreatedAt datatype.DateTime `json:"createdAt"`
} }
type UserGroupCreateDto struct { type UserGroupCreateDto struct {

View File

@@ -29,8 +29,15 @@ var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
} }
var validateUserGroupName validator.Func = func(fl validator.FieldLevel) bool { var validateUserGroupName validator.Func = func(fl validator.FieldLevel) bool {
// [a-z0-9_] : The group name can only contain lowercase letters, numbers, and underscores // The string can only contain lowercase letters, numbers, and underscores
regex := "^[a-z0-9_]+$" regex := "^[a-z0-9_]*$"
matched, _ := regexp.MatchString(regex, fl.Field().String())
return matched
}
var validateClaimKey validator.Func = func(fl validator.FieldLevel) bool {
// The string can only contain letters and numbers
regex := "^[A-Za-z0-9]*$"
matched, _ := regexp.MatchString(regex, fl.Field().String()) matched, _ := regexp.MatchString(regex, fl.Field().String())
return matched return matched
} }
@@ -52,4 +59,10 @@ func init() {
log.Fatalf("Failed to register custom validation: %v", err) log.Fatalf("Failed to register custom validation: %v", err)
} }
} }
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
if err := v.RegisterValidation("claimKey", validateClaimKey); err != nil {
log.Fatalf("Failed to register custom validation: %v", err)
}
}
} }

View File

@@ -2,7 +2,7 @@ package dto
import ( import (
"github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
"time" datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
) )
type WebauthnCredentialDto struct { type WebauthnCredentialDto struct {
@@ -15,7 +15,7 @@ type WebauthnCredentialDto struct {
BackupEligible bool `json:"backupEligible"` BackupEligible bool `json:"backupEligible"`
BackupState bool `json:"backupState"` BackupState bool `json:"backupState"`
CreatedAt time.Time `json:"createdAt"` CreatedAt datatype.DateTime `json:"createdAt"`
} }
type WebauthnCredentialUpdateDto struct { type WebauthnCredentialUpdateDto struct {

View File

@@ -1,37 +1,67 @@
package utils package middleware
import ( import (
"errors" "errors"
"fmt"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/stonith404/pocket-id/backend/internal/common"
"gorm.io/gorm" "gorm.io/gorm"
"log"
"net/http" "net/http"
"strings" "strings"
) )
import ( type ErrorHandlerMiddleware struct{}
"fmt"
)
func ControllerError(c *gin.Context, err error) { func NewErrorHandlerMiddleware() *ErrorHandlerMiddleware {
// Check for record not found errors return &ErrorHandlerMiddleware{}
if errors.Is(err, gorm.ErrRecordNotFound) { }
CustomControllerError(c, http.StatusNotFound, "Record not found")
return func (m *ErrorHandlerMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
for _, err := range c.Errors {
// Check for record not found errors
if errors.Is(err, gorm.ErrRecordNotFound) {
errorResponse(c, http.StatusNotFound, "Record not found")
return
}
// Check for validation errors
var validationErrors validator.ValidationErrors
if errors.As(err, &validationErrors) {
message := handleValidationError(validationErrors)
errorResponse(c, http.StatusBadRequest, message)
return
}
// Check for slice validation errors
var sliceValidationErrors binding.SliceValidationError
if errors.As(err, &sliceValidationErrors) {
if errors.As(sliceValidationErrors[0], &validationErrors) {
message := handleValidationError(validationErrors)
errorResponse(c, http.StatusBadRequest, message)
return
}
}
var appErr common.AppError
if errors.As(err, &appErr) {
errorResponse(c, appErr.HttpStatusCode(), appErr.Error())
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Something went wrong"})
}
} }
}
// Check for validation errors func errorResponse(c *gin.Context, statusCode int, message string) {
var validationErrors validator.ValidationErrors // Capitalize the first letter of the message
if errors.As(err, &validationErrors) { message = strings.ToUpper(message[:1]) + message[1:]
message := handleValidationError(validationErrors) c.JSON(statusCode, gin.H{"error": message})
CustomControllerError(c, http.StatusBadRequest, message)
return
}
log.Println(err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Something went wrong"})
} }
func handleValidationError(validationErrors validator.ValidationErrors) string { func handleValidationError(validationErrors validator.ValidationErrors) string {
@@ -67,9 +97,3 @@ func handleValidationError(validationErrors validator.ValidationErrors) string {
return combinedErrors return combinedErrors
} }
func CustomControllerError(c *gin.Context, statusCode int, message string) {
// Capitalize the first letter of the message
message = strings.ToUpper(message[:1]) + message[1:]
c.JSON(statusCode, gin.H{"error": message})
}

View File

@@ -3,7 +3,7 @@ package middleware
import ( import (
"fmt" "fmt"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/utils" "github.com/stonith404/pocket-id/backend/internal/common"
"net/http" "net/http"
) )
@@ -17,7 +17,8 @@ func (m *FileSizeLimitMiddleware) Add(maxSize int64) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize) c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize)
if err := c.Request.ParseMultipartForm(maxSize); err != nil { if err := c.Request.ParseMultipartForm(maxSize); err != nil {
utils.CustomControllerError(c, http.StatusRequestEntityTooLarge, fmt.Sprintf("The file can't be larger than %s bytes", formatFileSize(maxSize))) err = &common.FileTooLargeError{MaxSize: formatFileSize(maxSize)}
c.Error(err)
c.Abort() c.Abort()
return return
} }

View File

@@ -2,9 +2,8 @@ package middleware
import ( import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/service" "github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http"
"strings" "strings"
) )
@@ -29,7 +28,7 @@ func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc {
c.Next() c.Next()
return return
} else { } else {
utils.CustomControllerError(c, http.StatusUnauthorized, "You're not signed in") c.Error(&common.NotSignedInError{})
c.Abort() c.Abort()
return return
} }
@@ -40,14 +39,14 @@ func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc {
c.Next() c.Next()
return return
} else if err != nil { } else if err != nil {
utils.CustomControllerError(c, http.StatusUnauthorized, "You're not signed in") c.Error(&common.NotSignedInError{})
c.Abort() c.Abort()
return return
} }
// Check if the user is an admin // Check if the user is an admin
if adminOnly && !claims.IsAdmin { if adminOnly && !claims.IsAdmin {
utils.CustomControllerError(c, http.StatusForbidden, "You don't have permission to access this resource") c.Error(&common.MissingPermissionError{})
c.Abort() c.Abort()
return return
} }

View File

@@ -2,8 +2,6 @@ package middleware
import ( import (
"github.com/stonith404/pocket-id/backend/internal/common" "github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http"
"sync" "sync"
"time" "time"
@@ -33,7 +31,7 @@ func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
limiter := getLimiter(ip, limit, burst) limiter := getLimiter(ip, limit, burst)
if !limiter.Allow() { if !limiter.Allow() {
utils.CustomControllerError(c, http.StatusTooManyRequests, "Too many requests. Please wait a while before trying again.") c.Error(&common.TooManyRequestsError{})
c.Abort() c.Abort()
return return
} }

View File

@@ -1,20 +1,23 @@
package model package model
type AppConfigVariable struct { type AppConfigVariable struct {
Key string `gorm:"primaryKey;not null"` Key string `gorm:"primaryKey;not null"`
Type string Type string
IsPublic bool IsPublic bool
IsInternal bool IsInternal bool
Value string Value string
DefaultValue string
} }
type AppConfig struct { type AppConfig struct {
AppName AppConfigVariable AppName AppConfigVariable
SessionDuration AppConfigVariable
EmailsVerified AppConfigVariable
AllowOwnAccountEdit AppConfigVariable
BackgroundImageType AppConfigVariable BackgroundImageType AppConfigVariable
LogoLightImageType AppConfigVariable LogoLightImageType AppConfigVariable
LogoDarkImageType AppConfigVariable LogoDarkImageType AppConfigVariable
SessionDuration AppConfigVariable
EmailsVerified AppConfigVariable
EmailEnabled AppConfigVariable EmailEnabled AppConfigVariable
SmtpHost AppConfigVariable SmtpHost AppConfigVariable

View File

@@ -23,9 +23,10 @@ type AuditLogData map[string]string
type AuditLogEvent string type AuditLogEvent string
const ( const (
AuditLogEventSignIn AuditLogEvent = "SIGN_IN" AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION" AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION" AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
) )
// Scan and Value methods for GORM to handle the custom type // Scan and Value methods for GORM to handle the custom type

View File

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

View File

@@ -15,8 +15,9 @@ type User struct {
LastName string LastName string
IsAdmin bool IsAdmin bool
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"` CustomClaims []CustomClaim
Credentials []WebauthnCredential UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
Credentials []WebauthnCredential
} }
func (u User) WebAuthnID() []byte { return []byte(u.ID) } func (u User) WebAuthnID() []byte { return []byte(u.ID) }

View File

@@ -5,4 +5,5 @@ type UserGroup struct {
FriendlyName string FriendlyName string
Name string `gorm:"unique"` Name string `gorm:"unique"`
Users []User `gorm:"many2many:user_groups_users;"` Users []User `gorm:"many2many:user_groups_users;"`
CustomClaims []CustomClaim
} }

View File

@@ -31,43 +31,49 @@ func NewAppConfigService(db *gorm.DB) *AppConfigService {
var defaultDbConfig = model.AppConfig{ var defaultDbConfig = model.AppConfig{
AppName: model.AppConfigVariable{ AppName: model.AppConfigVariable{
Key: "appName", Key: "appName",
Type: "string", Type: "string",
IsPublic: true, IsPublic: true,
Value: "Pocket ID", DefaultValue: "Pocket ID",
}, },
SessionDuration: model.AppConfigVariable{ SessionDuration: model.AppConfigVariable{
Key: "sessionDuration", Key: "sessionDuration",
Type: "number", Type: "number",
Value: "60", DefaultValue: "60",
}, },
EmailsVerified: model.AppConfigVariable{ EmailsVerified: model.AppConfigVariable{
Key: "emailsVerified", Key: "emailsVerified",
Type: "bool", Type: "bool",
Value: "false", DefaultValue: "false",
},
AllowOwnAccountEdit: model.AppConfigVariable{
Key: "allowOwnAccountEdit",
Type: "bool",
IsPublic: true,
DefaultValue: "true",
}, },
BackgroundImageType: model.AppConfigVariable{ BackgroundImageType: model.AppConfigVariable{
Key: "backgroundImageType", Key: "backgroundImageType",
Type: "string", Type: "string",
IsInternal: true, IsInternal: true,
Value: "jpg", DefaultValue: "jpg",
}, },
LogoLightImageType: model.AppConfigVariable{ LogoLightImageType: model.AppConfigVariable{
Key: "logoLightImageType", Key: "logoLightImageType",
Type: "string", Type: "string",
IsInternal: true, IsInternal: true,
Value: "svg", DefaultValue: "svg",
}, },
LogoDarkImageType: model.AppConfigVariable{ LogoDarkImageType: model.AppConfigVariable{
Key: "logoDarkImageType", Key: "logoDarkImageType",
Type: "string", Type: "string",
IsInternal: true, IsInternal: true,
Value: "svg", DefaultValue: "svg",
}, },
EmailEnabled: model.AppConfigVariable{ EmailEnabled: model.AppConfigVariable{
Key: "emailEnabled", Key: "emailEnabled",
Type: "bool", Type: "bool",
Value: "false", DefaultValue: "false",
}, },
SmtpHost: model.AppConfigVariable{ SmtpHost: model.AppConfigVariable{
Key: "smtpHost", Key: "smtpHost",
@@ -120,7 +126,7 @@ func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]mode
tx.Commit() tx.Commit()
if err := s.loadDbConfigFromDb(); err != nil { if err := s.LoadDbConfigFromDb(); err != nil {
return nil, err return nil, err
} }
@@ -134,7 +140,7 @@ func (s *AppConfigService) UpdateImageType(imageName string, fileType string) er
return err return err
} }
return s.loadDbConfigFromDb() return s.LoadDbConfigFromDb()
} }
func (s *AppConfigService) ListAppConfig(showAll bool) ([]model.AppConfigVariable, error) { func (s *AppConfigService) ListAppConfig(showAll bool) ([]model.AppConfigVariable, error) {
@@ -151,6 +157,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 {
if configuration[i].Value == "" && configuration[i].DefaultValue != "" {
configuration[i].Value = configuration[i].DefaultValue
}
}
return configuration, nil return configuration, nil
} }
@@ -158,7 +171,7 @@ func (s *AppConfigService) UpdateImage(uploadedFile *multipart.FileHeader, image
fileType := utils.GetFileExtension(uploadedFile.Filename) fileType := utils.GetFileExtension(uploadedFile.Filename)
mimeType := utils.GetImageMimeType(fileType) mimeType := utils.GetImageMimeType(fileType)
if mimeType == "" { if mimeType == "" {
return common.ErrFileTypeNotSupported return &common.FileTypeNotSupportedError{}
} }
// Delete the old image if it has a different file type // Delete the old image if it has a different file type
@@ -206,10 +219,11 @@ func (s *AppConfigService) InitDbConfig() error {
} }
// Update existing configuration if it differs from the default // Update existing configuration if it differs from the default
if storedConfigVar.Type != defaultConfigVar.Type || storedConfigVar.IsPublic != defaultConfigVar.IsPublic || storedConfigVar.IsInternal != defaultConfigVar.IsInternal { if storedConfigVar.Type != defaultConfigVar.Type || storedConfigVar.IsPublic != defaultConfigVar.IsPublic || storedConfigVar.IsInternal != defaultConfigVar.IsInternal || storedConfigVar.DefaultValue != defaultConfigVar.DefaultValue {
storedConfigVar.Type = defaultConfigVar.Type storedConfigVar.Type = defaultConfigVar.Type
storedConfigVar.IsPublic = defaultConfigVar.IsPublic storedConfigVar.IsPublic = defaultConfigVar.IsPublic
storedConfigVar.IsInternal = defaultConfigVar.IsInternal storedConfigVar.IsInternal = defaultConfigVar.IsInternal
storedConfigVar.DefaultValue = defaultConfigVar.DefaultValue
if err := s.db.Save(&storedConfigVar).Error; err != nil { if err := s.db.Save(&storedConfigVar).Error; err != nil {
return err return err
} }
@@ -229,10 +243,11 @@ func (s *AppConfigService) InitDbConfig() error {
} }
} }
} }
return s.loadDbConfigFromDb() return s.LoadDbConfigFromDb()
} }
func (s *AppConfigService) loadDbConfigFromDb() error { // LoadDbConfigFromDb loads the configuration values from the database into the DbConfig struct.
func (s *AppConfigService) LoadDbConfigFromDb() error {
dbConfigReflectValue := reflect.ValueOf(s.DbConfig).Elem() dbConfigReflectValue := reflect.ValueOf(s.DbConfig).Elem()
for i := 0; i < dbConfigReflectValue.NumField(); i++ { for i := 0; i < dbConfigReflectValue.NumField(); i++ {
@@ -243,6 +258,10 @@ func (s *AppConfigService) loadDbConfigFromDb() error {
return err return err
} }
if storedConfigVar.Value == "" && storedConfigVar.DefaultValue != "" {
storedConfigVar.Value = storedConfigVar.DefaultValue
}
dbConfigField.Set(reflect.ValueOf(storedConfigVar)) dbConfigField.Set(reflect.ValueOf(storedConfigVar))
} }

View File

@@ -48,8 +48,8 @@ func (s *AuditLogService) Create(event model.AuditLogEvent, ipAddress, userAgent
} }
// CreateNewSignInWithEmail creates a new audit log entry in the database and sends an email if the device hasn't been used before // CreateNewSignInWithEmail creates a new audit log entry in the database and sends an email if the device hasn't been used before
func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID string, data model.AuditLogData) model.AuditLog { func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID string) model.AuditLog {
createdAuditLog := s.Create(model.AuditLogEventSignIn, ipAddress, userAgent, userID, data) createdAuditLog := s.Create(model.AuditLogEventSignIn, ipAddress, userAgent, userID, model.AuditLogData{})
// Count the number of times the user has logged in from the same device // Count the number of times the user has logged in from the same device
var count int64 var count int64

View File

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

View File

@@ -18,18 +18,20 @@ import (
) )
type OidcService struct { type OidcService struct {
db *gorm.DB db *gorm.DB
jwtService *JwtService jwtService *JwtService
appConfigService *AppConfigService appConfigService *AppConfigService
auditLogService *AuditLogService auditLogService *AuditLogService
customClaimService *CustomClaimService
} }
func NewOidcService(db *gorm.DB, jwtService *JwtService, appConfigService *AppConfigService, auditLogService *AuditLogService) *OidcService { func NewOidcService(db *gorm.DB, jwtService *JwtService, appConfigService *AppConfigService, auditLogService *AuditLogService, customClaimService *CustomClaimService) *OidcService {
return &OidcService{ return &OidcService{
db: db, db: db,
jwtService: jwtService, jwtService: jwtService,
appConfigService: appConfigService, appConfigService: appConfigService,
auditLogService: auditLogService, auditLogService: auditLogService,
customClaimService: customClaimService,
} }
} }
@@ -38,7 +40,7 @@ func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID,
s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", input.ClientID, userID) s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", input.ClientID, userID)
if userAuthorizedOIDCClient.Scope != input.Scope { if userAuthorizedOIDCClient.Scope != input.Scope {
return "", "", common.ErrOidcMissingAuthorization return "", "", &common.OidcMissingAuthorizationError{}
} }
callbackURL, err := getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL) callbackURL, err := getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL)
@@ -93,11 +95,11 @@ func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret string) (string, string, error) { func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret string) (string, string, error) {
if grantType != "authorization_code" { if grantType != "authorization_code" {
return "", "", common.ErrOidcGrantTypeNotSupported return "", "", &common.OidcGrantTypeNotSupportedError{}
} }
if clientID == "" || clientSecret == "" { if clientID == "" || clientSecret == "" {
return "", "", common.ErrOidcMissingClientCredentials return "", "", &common.OidcMissingClientCredentialsError{}
} }
var client model.OidcClient var client model.OidcClient
@@ -107,17 +109,17 @@ func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret strin
err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret)) err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
if err != nil { if err != nil {
return "", "", common.ErrOidcClientSecretInvalid return "", "", &common.OidcClientSecretInvalidError{}
} }
var authorizationCodeMetaData model.OidcAuthorizationCode var authorizationCodeMetaData model.OidcAuthorizationCode
err = s.db.Preload("User").First(&authorizationCodeMetaData, "code = ?", code).Error err = s.db.Preload("User").First(&authorizationCodeMetaData, "code = ?", code).Error
if err != nil { if err != nil {
return "", "", common.ErrOidcInvalidAuthorizationCode return "", "", &common.OidcInvalidAuthorizationCodeError{}
} }
if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) { if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
return "", "", common.ErrOidcInvalidAuthorizationCode return "", "", &common.OidcInvalidAuthorizationCodeError{}
} }
userClaims, err := s.GetUserClaimsForClient(authorizationCodeMetaData.UserID, clientID) userClaims, err := s.GetUserClaimsForClient(authorizationCodeMetaData.UserID, clientID)
@@ -249,7 +251,7 @@ func (s *OidcService) GetClientLogo(clientID string) (string, string, error) {
func (s *OidcService) UpdateClientLogo(clientID string, file *multipart.FileHeader) error { func (s *OidcService) UpdateClientLogo(clientID string, file *multipart.FileHeader) error {
fileType := utils.GetFileExtension(file.Filename) fileType := utils.GetFileExtension(file.Filename)
if mimeType := utils.GetImageMimeType(fileType); mimeType == "" { if mimeType := utils.GetImageMimeType(fileType); mimeType == "" {
return common.ErrFileTypeNotSupported return &common.FileTypeNotSupportedError{}
} }
imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, clientID, fileType) imagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, clientID, fileType)
@@ -334,9 +336,20 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
} }
if strings.Contains(scope, "profile") { if strings.Contains(scope, "profile") {
// Add profile claims
for k, v := range profileClaims { for k, v := range profileClaims {
claims[k] = v claims[k] = v
} }
// Add custom claims
customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(userID)
if err != nil {
return nil, err
}
for _, customClaim := range customClaims {
claims[customClaim.Key] = customClaim.Value
}
} }
if strings.Contains(scope, "email") { if strings.Contains(scope, "email") {
claims["email"] = user.Email claims["email"] = user.Email
@@ -375,5 +388,5 @@ func getCallbackURL(client model.OidcClient, inputCallbackURL string) (callbackU
return inputCallbackURL, nil return inputCallbackURL, nil
} }
return "", common.ErrOidcInvalidCallbackURL return "", &common.OidcInvalidCallbackURLError{}
} }

View File

@@ -138,8 +138,8 @@ func (s *TestService) SeedDatabase() error {
return err return err
} }
publicKey1, err := getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwcOo5KV169KR67QEHrcYkeXE3CCxv2BgwnSq4VYTQxyLtdmKxegexa8JdwFKhKXa2BMI9xaN15BoL6wSCRFJhg==") publicKey1, err := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwcOo5KV169KR67QEHrcYkeXE3CCxv2BgwnSq4VYTQxyLtdmKxegexa8JdwFKhKXa2BMI9xaN15BoL6wSCRFJhg==")
publicKey2, err := getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESq/wR8QbBu3dKnpaw/v0mDxFFDwnJ/L5XHSg2tAmq5x1BpSMmIr3+DxCbybVvGRmWGh8kKhy7SMnK91M6rFHTA==") publicKey2, err := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESq/wR8QbBu3dKnpaw/v0mDxFFDwnJ/L5XHSg2tAmq5x1BpSMmIr3+DxCbybVvGRmWGh8kKhy7SMnK91M6rFHTA==")
if err != nil { if err != nil {
return err return err
} }
@@ -187,17 +187,16 @@ func (s *TestService) ResetDatabase() error {
return err return err
} }
// Delete all rows from all tables
for _, table := range tables { for _, table := range tables {
if err := tx.Exec("DELETE FROM " + table).Error; err != nil { if err := tx.Exec("DELETE FROM " + table).Error; err != nil {
return err return err
} }
} }
return nil return nil
}) })
if err != nil {
return err
}
err = s.appConfigService.InitDbConfig()
return err return err
} }
@@ -215,8 +214,23 @@ func (s *TestService) ResetApplicationImages() error {
return nil return nil
} }
func (s *TestService) ResetAppConfig() error {
// Reseed the config variables
if err := s.appConfigService.InitDbConfig(); err != nil {
return err
}
// Reset all app config variables to their default values
if err := s.db.Session(&gorm.Session{AllowGlobalUpdate: true}).Model(&model.AppConfigVariable{}).Update("value", "").Error; err != nil {
return err
}
// Reload the app config from the database after resetting the values
return s.appConfigService.LoadDbConfigFromDb()
}
// 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 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)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to decode base64 key: %w", err) return nil, fmt.Errorf("failed to decode base64 key: %w", err)

View File

@@ -18,7 +18,7 @@ func NewUserGroupService(db *gorm.DB) *UserGroupService {
} }
func (s *UserGroupService) List(name string, page int, pageSize int) (groups []model.UserGroup, response utils.PaginationResponse, err error) { func (s *UserGroupService) List(name string, page int, pageSize int) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
query := s.db.Model(&model.UserGroup{}) query := s.db.Preload("CustomClaims").Model(&model.UserGroup{})
if name != "" { if name != "" {
query = query.Where("name LIKE ?", "%"+name+"%") query = query.Where("name LIKE ?", "%"+name+"%")
@@ -29,7 +29,7 @@ func (s *UserGroupService) List(name string, page int, pageSize int) (groups []m
} }
func (s *UserGroupService) Get(id string) (group model.UserGroup, err error) { func (s *UserGroupService) Get(id string) (group model.UserGroup, err error) {
err = s.db.Where("id = ?", id).Preload("Users").First(&group).Error err = s.db.Where("id = ?", id).Preload("CustomClaims").Preload("Users").First(&group).Error
return group, err return group, err
} }
@@ -50,7 +50,7 @@ func (s *UserGroupService) Create(input dto.UserGroupCreateDto) (group model.Use
if err := s.db.Preload("Users").Create(&group).Error; err != nil { if err := s.db.Preload("Users").Create(&group).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) { if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.UserGroup{}, common.ErrNameAlreadyInUse return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
} }
return model.UserGroup{}, err return model.UserGroup{}, err
} }
@@ -68,7 +68,7 @@ func (s *UserGroupService) Update(id string, input dto.UserGroupCreateDto) (grou
if err := s.db.Preload("Users").Save(&group).Error; err != nil { if err := s.db.Preload("Users").Save(&group).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) { if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.UserGroup{}, common.ErrNameAlreadyInUse return model.UserGroup{}, &common.AlreadyInUseError{Property: "name"}
} }
return model.UserGroup{}, err return model.UserGroup{}, err
} }

View File

@@ -12,12 +12,13 @@ import (
) )
type UserService struct { type UserService struct {
db *gorm.DB db *gorm.DB
jwtService *JwtService jwtService *JwtService
auditLogService *AuditLogService
} }
func NewUserService(db *gorm.DB, jwtService *JwtService) *UserService { func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService) *UserService {
return &UserService{db: db, jwtService: jwtService} 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, page int, pageSize int) ([]model.User, utils.PaginationResponse, error) {
@@ -35,7 +36,7 @@ func (s *UserService) ListUsers(searchTerm string, page int, pageSize int) ([]mo
func (s *UserService) GetUser(userID string) (model.User, error) { func (s *UserService) GetUser(userID string) (model.User, error) {
var user model.User var user model.User
err := s.db.Where("id = ?", userID).First(&user).Error err := s.db.Preload("CustomClaims").Where("id = ?", userID).First(&user).Error
return user, err return user, err
} }
@@ -88,7 +89,7 @@ func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, u
return user, nil return user, nil
} }
func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time) (string, error) { func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time, ipAddress, userAgent string) (string, error) {
randomString, err := utils.GenerateRandomAlphanumericString(16) randomString, err := utils.GenerateRandomAlphanumericString(16)
if err != nil { if err != nil {
return "", err return "", err
@@ -104,6 +105,8 @@ func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Tim
return "", err return "", err
} }
s.auditLogService.Create(model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, userID, model.AuditLogData{})
return oneTimeAccessToken.Token, nil return oneTimeAccessToken.Token, nil
} }
@@ -111,7 +114,7 @@ func (s *UserService) ExchangeOneTimeAccessToken(token string) (model.User, stri
var oneTimeAccessToken model.OneTimeAccessToken var oneTimeAccessToken model.OneTimeAccessToken
if err := s.db.Where("token = ? AND expires_at > ?", token, time.Now().Unix()).Preload("User").First(&oneTimeAccessToken).Error; err != nil { if err := s.db.Where("token = ? AND expires_at > ?", token, time.Now().Unix()).Preload("User").First(&oneTimeAccessToken).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, "", common.ErrTokenInvalidOrExpired return model.User{}, "", &common.TokenInvalidOrExpiredError{}
} }
return model.User{}, "", err return model.User{}, "", err
} }
@@ -133,7 +136,7 @@ func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
return model.User{}, "", err return model.User{}, "", err
} }
if userCount > 1 { if userCount > 1 {
return model.User{}, "", common.ErrSetupAlreadyCompleted return model.User{}, "", &common.SetupAlreadyCompletedError{}
} }
user := model.User{ user := model.User{
@@ -149,7 +152,7 @@ func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
} }
if len(user.Credentials) > 0 { if len(user.Credentials) > 0 {
return model.User{}, "", common.ErrSetupAlreadyCompleted return model.User{}, "", &common.SetupAlreadyCompletedError{}
} }
token, err := s.jwtService.GenerateAccessToken(user) token, err := s.jwtService.GenerateAccessToken(user)
@@ -163,11 +166,11 @@ func (s *UserService) SetupInitialAdmin() (model.User, string, error) {
func (s *UserService) checkDuplicatedFields(user model.User) error { func (s *UserService) checkDuplicatedFields(user model.User) error {
var existingUser model.User var existingUser model.User
if s.db.Where("id != ? AND email = ?", user.ID, user.Email).First(&existingUser).Error == nil { if s.db.Where("id != ? AND email = ?", user.ID, user.Email).First(&existingUser).Error == nil {
return common.ErrEmailTaken return &common.AlreadyInUseError{Property: "email"}
} }
if s.db.Where("id != ? AND username = ?", user.ID, user.Username).First(&existingUser).Error == nil { if s.db.Where("id != ? AND username = ?", user.ID, user.Username).First(&existingUser).Error == nil {
return common.ErrUsernameTaken return &common.AlreadyInUseError{Property: "username"}
} }
return nil return nil

View File

@@ -165,7 +165,7 @@ func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssert
return model.User{}, "", err return model.User{}, "", err
} }
s.auditLogService.CreateNewSignInWithEmail(ipAddress, userAgent, user.ID, model.AuditLogData{}) s.auditLogService.CreateNewSignInWithEmail(ipAddress, userAgent, user.ID)
return *user, token, nil return *user, token, nil
} }

View File

@@ -0,0 +1 @@
ALTER TABLE app_config_variables DROP COLUMN default_value;

View File

@@ -0,0 +1 @@
ALTER TABLE app_config_variables ADD COLUMN default_value TEXT;

View File

@@ -0,0 +1 @@
DROP TABLE custom_claims;

View File

@@ -0,0 +1,15 @@
CREATE TABLE custom_claims
(
id TEXT NOT NULL PRIMARY KEY,
created_at DATETIME,
key TEXT NOT NULL,
value TEXT NOT NULL,
user_id TEXT,
user_group_id TEXT,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (user_group_id) REFERENCES user_groups (id) ON DELETE CASCADE,
CONSTRAINT custom_claims_unique UNIQUE (key, user_id, user_group_id),
CHECK (user_id IS NOT NULL OR user_group_id IS NOT NULL)
);

BIN
docs/imgs/jelly_fin_img.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

55
docs/jellyfin.md Normal file
View File

@@ -0,0 +1,55 @@
# Jellyfin SSO Integration Guide
> Due to the current limitations of the Jellyfin SSO plugin, this integration will only work in a browser. When tested, the Jellyfin app did not work and displayed an error, even when custom menu buttons were created.
> To view the original references and a full list of capabilities, please visit the [Jellyfin SSO OpenID Section](https://github.com/9p4/jellyfin-plugin-sso?tab=readme-ov-file#openid).
### Requirements
- [Jellyfin Server](https://jellyfin.org/downloads/server)
- [Jellyfin SSO Plugin](https://github.com/9p4/jellyfin-plugin-sso)
- HTTPS connection to your Jellyfin server
### OIDC - Pocket ID Setup
To start, we need to create a new SSO resource in our Jellyfin application.
> Replace the `JELLYFINDOMAIN` and `PROVIDER` elements in the URL.
1. Log into the admin panel, and go to OIDC Clients -> Add OIDC Client.
2. **Name**: Jellyfin (or any name you prefer)
3. **Callback URL**: `https://JELLYFINDOMAIN.com/sso/OID/redirect/PROVIDER`
4. For this example, well be using the provider named "test_resource."
5. Click **Save**. Keep the page open, as we will need the OID client ID and OID secret.
### OIDC Client - Jellyfin SSO Resource
1. Visit the plugin page (<i>Administration Dashboard -> My Plugins -> SSO-Auth</i>).
2. Enter the <i>OID Provider Name (we used "test_resource" as our name in the callback URL), Open ID, OID Secret, and mark it as enabled.</i>
3. The following steps are optional based on your needs. In this guide, well be managing only regular users, not admins.
![img.png](imgs/jelly_fin_img.png)
> To manage user access through groups, follow steps **4, 5, and 6**. Otherwise, leave it blank and skip to step 7.
![img2.png](imgs/jelly_fin_img2.png)
4. Under <i>Roles</i>, type the name of the group you want to use. **Note:** This must be the group name, not the label. Double-check in Pocket ID, as an incorrect name will lock users out.
5. Skip every field until you reach the **Role Claim** field, and type `groups`.
> This step is crucial if you want to manage users through groups.
6. Repeat the above step under **Request Additional Scopes**. This will pull the group scope during the sign-in process; otherwise, the previous steps wont work.
![img3.png](imgs/jelly_fin_img3.png)
7. Skip the remaining fields until you reach **Scheme Override**. Enter `https` here. If omitted, it will attempt to use HTTP first, which will break as WebAuthn requires an HTTPS connection.
8. Click **Save** and restart Jellyfin.
### Optional Step - Custom Home Button
Follow the [guide to create a login button on the login page](https://github.com/9p4/jellyfin-plugin-sso?tab=readme-ov-file#creating-a-login-button-on-the-main-page) to add a custom button on your sign-in page. This step is optional, as you could also provide the sign-in URL via a bookmark or other means.
### Signing into Your Jellyfin Instance
Done! You have successfully set up SSO for your Jellyfin instance using Pocket ID.
> **Note:** Sometimes there may be a brief delay when using the custom menu option. This is related to the Jellyfin plugin and not Pocket ID.
If your users already have accounts, as long as their Pocket ID username matches their Jellyfin ID, they will be logged in automatically. Otherwise, a new user will be created with access to all of your folders. Of course, you can modify this in your configuration as desired.
This setup will only work if sign-in is performed using the `https://jellyfin.example.com/sso/OID/start/PROVIDER` URL. This URL initiates the SSO plugin and applies all the configurations we completed above.

View File

@@ -1,12 +1,12 @@
{ {
"name": "pocket-id-frontend", "name": "pocket-id-frontend",
"version": "0.9.0", "version": "0.10.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pocket-id-frontend", "name": "pocket-id-frontend",
"version": "0.9.0", "version": "0.10.0",
"dependencies": { "dependencies": {
"@simplewebauthn/browser": "^10.0.0", "@simplewebauthn/browser": "^10.0.0",
"axios": "^1.7.7", "axios": "^1.7.7",

View File

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

View File

@@ -12,8 +12,8 @@ export default defineConfig({
retries: process.env.CI ? 1 : 0, retries: process.env.CI ? 1 : 0,
workers: 1, workers: 1,
reporter: process.env.CI reporter: process.env.CI
? [['html'], ['github']] ? [['html', { outputFolder: 'tests/.report' }], ['github']]
: [['line'], ['html', { open: 'never', outputFolder: 'tests/.output' }]], : [['line'], ['html', { open: 'never', outputFolder: 'tests/.report' }]],
use: { use: {
baseURL: 'http://localhost', baseURL: 'http://localhost',
video: 'retain-on-failure', video: 'retain-on-failure',

View File

@@ -4,6 +4,7 @@
import * as Pagination from '$lib/components/ui/pagination'; import * as Pagination from '$lib/components/ui/pagination';
import * as Select from '$lib/components/ui/select'; import * as Select from '$lib/components/ui/select';
import * as Table from '$lib/components/ui/table/index.js'; 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 } from '$lib/types/pagination.type';
import { debounced } from '$lib/utils/debounce-util'; import { debounced } from '$lib/utils/debounce-util';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
@@ -66,93 +67,104 @@
} }
</script> </script>
<div class="w-full"> {#if items.data.length === 0}
{#if !withoutSearch} <div class="my-5 flex flex-col items-center">
<Input <Empty class="text-muted-foreground h-20" />
class="mb-4 max-w-sm" <p class="text-muted-foreground mt-3 text-sm">No items found</p>
placeholder={'Search...'}
type="text"
oninput={(e) => onSearch((e.target as HTMLInputElement).value)}
/>
{/if}
<Table.Root>
<Table.Header>
<Table.Row>
{#if selectedIds}
<Table.Head>
<Checkbox checked={allChecked} onCheckedChange={(c) => onAllCheck(c as boolean)} />
</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}
{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#each items.data as item}
<Table.Row class={selectedIds?.includes(item.id) ? 'bg-muted/20' : ''}>
{#if selectedIds}
<Table.Cell>
<Checkbox
checked={selectedIds.includes(item.id)}
onCheckedChange={(c) => onCheck(c as boolean, item.id)}
/>
</Table.Cell>
{/if}
{@render rows({ item })}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
<div class="mt-5 flex items-center justify-between space-x-2">
<div class="flex items-center space-x-2">
<p class="text-sm font-medium">Items per page</p>
<Select.Root
selected={{
label: items.pagination.itemsPerPage.toString(),
value: items.pagination.itemsPerPage
}}
onSelectedChange={(v) => onPageSizeChange(v?.value as number)}
>
<Select.Trigger class="h-9 w-[80px]">
<Select.Value>{items.pagination.itemsPerPage}</Select.Value>
</Select.Trigger>
<Select.Content>
{#each availablePageSizes as size}
<Select.Item value={size}>{size}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Pagination.Root
class="mx-0 w-auto"
count={items.pagination.totalItems}
perPage={items.pagination.itemsPerPage}
{onPageChange}
page={items.pagination.currentPage}
let:pages
>
<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.Link {page} isActive={items.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
</Pagination.Root>
</div> </div>
</div> {:else}
<div class="w-full">
{#if !withoutSearch}
<Input
class="mb-4 max-w-sm"
placeholder={'Search...'}
type="text"
oninput={(e) => onSearch((e.target as HTMLInputElement).value)}
/>
{/if}
<Table.Root>
<Table.Header>
<Table.Row>
{#if selectedIds}
<Table.Head>
<Checkbox checked={allChecked} onCheckedChange={(c) => onAllCheck(c as boolean)} />
</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}
{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#each items.data as item}
<Table.Row class={selectedIds?.includes(item.id) ? 'bg-muted/20' : ''}>
{#if selectedIds}
<Table.Cell>
<Checkbox
checked={selectedIds.includes(item.id)}
onCheckedChange={(c) => onCheck(c as boolean, item.id)}
/>
</Table.Cell>
{/if}
{@render rows({ item })}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
<div
class="mt-5 flex flex-col-reverse items-center justify-between gap-3 space-x-2 sm:flex-row"
>
<div class="flex items-center space-x-2">
<p class="text-sm font-medium">Items per page</p>
<Select.Root
selected={{
label: items.pagination.itemsPerPage.toString(),
value: items.pagination.itemsPerPage
}}
onSelectedChange={(v) => onPageSizeChange(v?.value as number)}
>
<Select.Trigger class="h-9 w-[80px]">
<Select.Value>{items.pagination.itemsPerPage}</Select.Value>
</Select.Trigger>
<Select.Content>
{#each availablePageSizes as size}
<Select.Item value={size}>{size}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Pagination.Root
class="mx-0 w-auto"
count={items.pagination.totalItems}
perPage={items.pagination.itemsPerPage}
{onPageChange}
page={items.pagination.currentPage}
let:pages
>
<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.Link {page} isActive={items.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
</Pagination.Root>
</div>
</div>
{/if}

View File

@@ -0,0 +1,116 @@
<script lang="ts">
import Input from '$lib/components/ui/input/input.svelte';
import * as Popover from '$lib/components/ui/popover/index.js';
let {
value = $bindable(''),
placeholder,
suggestionLimit = 5,
suggestions
}: {
value: string;
placeholder: string;
suggestionLimit?: number;
suggestions: string[];
} = $props();
let filteredSuggestions: string[] = $state(suggestions.slice(0, suggestionLimit));
let selectedIndex = $state(-1);
let keyError: string | undefined = $state();
let isInputFocused = $state(false);
function handleSuggestionClick(suggestion: (typeof suggestions)[0]) {
value = suggestion;
filteredSuggestions = [];
}
function handleOnInput() {
if (value.length > 0 && !/^[A-Za-z0-9]*$/.test(value)) {
keyError = 'Only alphanumeric characters are allowed';
return;
} else {
keyError = undefined;
}
filteredSuggestions = suggestions
.filter((s) => s.includes(value.toLowerCase()))
.slice(0, suggestionLimit);
}
function handleKeydown(e: KeyboardEvent) {
if (!isOpen) return;
switch (e.key) {
case 'ArrowDown':
selectedIndex = Math.min(selectedIndex + 1, filteredSuggestions.length - 1);
break;
case 'ArrowUp':
selectedIndex = Math.max(selectedIndex - 1, -1);
break;
case 'Enter':
if (selectedIndex >= 0) {
handleSuggestionClick(filteredSuggestions[selectedIndex]);
}
break;
case 'Escape':
isInputFocused = false;
break;
}
}
let isOpen = $derived(filteredSuggestions.length > 0 && isInputFocused);
$effect(() => {
// Reset selection when suggestions change
if (filteredSuggestions) {
selectedIndex = -1;
}
});
</script>
<div
class="grid w-full"
role="combobox"
onkeydown={handleKeydown}
aria-controls="suggestion-list"
aria-expanded={isOpen}
tabindex="-1"
>
<Input
{placeholder}
bind:value
oninput={handleOnInput}
onfocus={() => (isInputFocused = true)}
onblur={() => (isInputFocused = false)}
/>
{#if keyError}
<p class="mt-1 text-sm text-red-500">{keyError}</p>
{/if}
<Popover.Root
open={isOpen}
disableFocusTrap
openFocus={() => {}}
closeOnOutsideClick={false}
closeOnEscape={false}
>
<Popover.Trigger tabindex={-1} class="h-0 w-full" aria-hidden />
<Popover.Content class="p-0" sideOffset={5} sameWidth>
{#each filteredSuggestions as suggestion, index}
<div
role="button"
tabindex="0"
onmousedown={() => handleSuggestionClick(suggestion)}
onkeydown={(e) => {
if (e.key === 'Enter') handleSuggestionClick(suggestion);
}}
class="hover:bg-accent hover:text-accent-foreground relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 {selectedIndex ===
index
? 'bg-accent text-accent-foreground'
: ''}"
>
{suggestion}
</div>
{/each}
</Popover.Content>
</Popover.Root>
</div>

View File

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

View File

@@ -16,7 +16,7 @@
...restProps ...restProps
}: HTMLAttributes<HTMLDivElement> & { }: HTMLAttributes<HTMLDivElement> & {
input?: FormInput<string | boolean | number>; input?: FormInput<string | boolean | number>;
label: string; label?: string;
description?: string; description?: string;
disabled?: boolean; disabled?: boolean;
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox'; type?: 'text' | 'password' | 'email' | 'number' | 'checkbox';
@@ -24,15 +24,17 @@
children?: Snippet; children?: Snippet;
} = $props(); } = $props();
const id = label.toLowerCase().replace(/ /g, '-'); const id = label?.toLowerCase().replace(/ /g, '-');
</script> </script>
<div {...restProps}> <div {...restProps}>
<Label class="mb-0" for={id}>{label}</Label> {#if label}
<Label class="mb-0" for={id}>{label}</Label>
{/if}
{#if description} {#if description}
<p class="text-muted-foreground mt-1 text-xs">{description}</p> <p class="text-muted-foreground mt-1 text-xs">{description}</p>
{/if} {/if}
<div class="mt-2"> <div class={label || description ? 'mt-2' : ''}>
{#if children} {#if children}
{@render children()} {@render children()}
{:else if input} {:else if input}

View File

@@ -2,7 +2,7 @@ import { type VariantProps, tv } from "tailwind-variants";
export { default as Badge } from "./badge.svelte"; export { default as Badge } from "./badge.svelte";
export const badgeVariants = tv({ export const badgeVariants = tv({
base: "inline-flex select-none items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", base: "inline-flex select-none items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 break-keep whitespace-nowrap",
variants: { variants: {
variant: { variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",

View File

@@ -10,7 +10,7 @@
</script> </script>
<span <span
aria-hidden aria-hidden="true"
class={cn("flex h-9 w-9 items-center justify-center", className)} class={cn("flex h-9 w-9 items-center justify-center", className)}
{...$$restProps} {...$$restProps}
> >

View File

@@ -0,0 +1,17 @@
import { Popover as PopoverPrimitive } from "bits-ui";
import Content from "./popover-content.svelte";
const Root = PopoverPrimitive.Root;
const Trigger = PopoverPrimitive.Trigger;
const Close = PopoverPrimitive.Close;
export {
Root,
Content,
Trigger,
Close,
//
Root as Popover,
Content as PopoverContent,
Trigger as PopoverTrigger,
Close as PopoverClose,
};

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
import { cn, flyAndScale } from "$lib/utils/style.js";
type $$Props = PopoverPrimitive.ContentProps;
let className: $$Props["class"] = undefined;
export let transition: $$Props["transition"] = flyAndScale;
export let transitionConfig: $$Props["transitionConfig"] = undefined;
export { className as class };
</script>
<PopoverPrimitive.Content
{transition}
{transitionConfig}
class={cn(
"bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-md outline-none",
className
)}
{...$$restProps}
>
<slot />
</PopoverPrimitive.Content>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
let {
class: className
}: {
class?: string;
} = $props();
</script>
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 336.19673868301203 129.38671875"
class={className}
>
<g stroke-linecap="round" transform="translate(10 10) rotate(0 158.09836934150601 54.693359375)">
<path
d="M27.35 0 C121.36 -0.62, 208.79 0.52, 288.85 0 M288.85 0 C305.5 3.32, 316.8 6.14, 316.2 27.35 M316.2 27.35 C315.58 42.15, 314.92 54.54, 316.2 82.04 M316.2 82.04 C313.79 100.68, 304.9 110.1, 288.85 109.39 M288.85 109.39 C192.86 108.68, 93.17 110.07, 27.35 109.39 M27.35 109.39 C13.09 109.46, -1.61 102.22, 0 82.04 M0 82.04 C-0.35 60.8, -1.11 41.01, 0 27.35 M0 27.35 C1.94 9.62, 8.6 1.41, 27.35 0"
stroke="#A1A1AA"
stroke-width="4.5"
fill="none"
stroke-dasharray="8 12"
></path>
</g>
</svg>

View File

@@ -73,8 +73,8 @@ export default class AppConfigService extends APIService {
return true; return true;
} else if (value === 'false') { } else if (value === 'false') {
return false; return false;
} else if (!isNaN(Number(value))) { } else if (!isNaN(parseFloat(value))) {
return Number(value); return parseFloat(value);
} else { } else {
return value; return value;
} }

View File

@@ -0,0 +1,19 @@
import type { CustomClaim } from '$lib/types/custom-claim.type';
import APIService from './api-service';
export default class CustomClaimService extends APIService {
async getSuggestions() {
const res = await this.api.get('/custom-claims/suggestions');
return res.data as string[];
}
async updateUserCustomClaims(userId: string, claims: CustomClaim[]) {
const res = await this.api.put(`/custom-claims/user/${userId}`, claims);
return res.data as CustomClaim[];
}
async updateUserGroupCustomClaims(userGroupId: string, claims: CustomClaim[]) {
const res = await this.api.put(`/custom-claims/user-group/${userGroupId}`, claims);
return res.data as CustomClaim[];
}
}

View File

@@ -42,10 +42,10 @@ export default class UserService extends APIService {
await this.api.delete(`/users/${id}`); await this.api.delete(`/users/${id}`);
} }
async createOneTimeAccessToken(userId: string) { async createOneTimeAccessToken(userId: string, expiresAt: Date) {
const res = await this.api.post(`/users/${userId}/one-time-access-token`, { const res = await this.api.post(`/users/${userId}/one-time-access-token`, {
userId, userId,
expiresAt: new Date(Date.now() + 1000 * 60 * 5).toISOString() expiresAt
}); });
return res.data.token; return res.data.token;
} }

View File

@@ -1,5 +1,6 @@
export type AppConfig = { export type AppConfig = {
appName: string; appName: string;
allowOwnAccountEdit: boolean;
}; };
export type AllAppConfig = AppConfig & { export type AllAppConfig = AppConfig & {

View File

@@ -0,0 +1,4 @@
export type CustomClaim = {
key: string;
value: string;
};

View File

@@ -1,3 +1,4 @@
import type { CustomClaim } from './custom-claim.type';
import type { User } from './user.type'; import type { User } from './user.type';
export type UserGroup = { export type UserGroup = {
@@ -5,6 +6,7 @@ export type UserGroup = {
friendlyName: string; friendlyName: string;
name: string; name: string;
createdAt: string; createdAt: string;
customClaims: CustomClaim[];
}; };
export type UserGroupWithUsers = UserGroup & { export type UserGroupWithUsers = UserGroup & {

View File

@@ -1,3 +1,5 @@
import type { CustomClaim } from './custom-claim.type';
export type User = { export type User = {
id: string; id: string;
username: string; username: string;
@@ -5,6 +7,7 @@ export type User = {
firstName: string; firstName: string;
lastName: string; lastName: string;
isAdmin: boolean; isAdmin: boolean;
customClaims: CustomClaim[];
}; };
export type UserCreate = Omit<User, 'id'>; export type UserCreate = Omit<User, 'id' | 'customClaims'>;

View File

@@ -22,6 +22,7 @@
if ($userStore?.isAdmin) { if ($userStore?.isAdmin) {
links = [ links = [
// svelte-ignore state_referenced_locally
...links, ...links,
{ href: '/settings/admin/users', label: 'Users' }, { href: '/settings/admin/users', label: 'Users' },
{ href: '/settings/admin/user-groups', label: 'User Groups' }, { href: '/settings/admin/user-groups', label: 'User Groups' },

View File

@@ -3,6 +3,7 @@
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import WebAuthnService from '$lib/services/webauthn-service'; import WebAuthnService from '$lib/services/webauthn-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { Passkey } from '$lib/types/passkey.type'; import type { Passkey } from '$lib/types/passkey.type';
import type { UserCreate } from '$lib/types/user.type'; import type { UserCreate } from '$lib/types/user.type';
import { axiosErrorToast, getWebauthnErrorMessage } from '$lib/utils/error-util'; import { axiosErrorToast, getWebauthnErrorMessage } from '$lib/utils/error-util';
@@ -51,14 +52,16 @@
<title>Account Settings</title> <title>Account Settings</title>
</svelte:head> </svelte:head>
<Card.Root> {#if $appConfigStore.allowOwnAccountEdit}
<Card.Header> <Card.Root>
<Card.Title>Account Details</Card.Title> <Card.Header>
</Card.Header> <Card.Title>Account Details</Card.Title>
<Card.Content> </Card.Header>
<AccountForm {account} callback={updateAccount} /> <Card.Content>
</Card.Content> <AccountForm {account} callback={updateAccount} />
</Card.Root> </Card.Content>
</Card.Root>
{/if}
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>

View File

@@ -21,13 +21,15 @@
const updatedAppConfig = { const updatedAppConfig = {
appName: appConfig.appName, appName: appConfig.appName,
sessionDuration: appConfig.sessionDuration, sessionDuration: appConfig.sessionDuration,
emailsVerified: appConfig.emailsVerified emailsVerified: appConfig.emailsVerified,
allowOwnAccountEdit: appConfig.allowOwnAccountEdit
}; };
const formSchema = z.object({ const formSchema = z.object({
appName: z.string().min(2).max(30), appName: z.string().min(2).max(30),
sessionDuration: z.number().min(1).max(43200), sessionDuration: z.number().min(1).max(43200),
emailsVerified: z.boolean() emailsVerified: z.boolean(),
allowOwnAccountEdit: z.boolean()
}); });
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig); const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
@@ -49,6 +51,17 @@
description="The duration of a session in minutes before the user has to sign in again." description="The duration of a session in minutes before the user has to sign in again."
bind:input={$inputs.sessionDuration} bind:input={$inputs.sessionDuration}
/> />
<div class="items-top mt-5 flex space-x-2">
<Checkbox id="admin-privileges" bind:checked={$inputs.allowOwnAccountEdit.value} />
<div class="grid gap-1.5 leading-none">
<Label for="admin-privileges" class="mb-0 text-sm font-medium leading-none">
Enable Self-Account Editing
</Label>
<p class="text-muted-foreground text-[0.8rem]">
Whether the users should be able to edit their own account details.
</p>
</div>
</div>
<div class="items-top mt-5 flex space-x-2"> <div class="items-top mt-5 flex space-x-2">
<Checkbox id="admin-privileges" bind:checked={$inputs.emailsVerified.value} /> <Checkbox id="admin-privileges" bind:checked={$inputs.emailsVerified.value} />
<div class="grid gap-1.5 leading-none"> <div class="grid gap-1.5 leading-none">

View File

@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import CustomClaimsInput from '$lib/components/custom-claims-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import CustomClaimService from '$lib/services/custom-claim-service';
import UserGroupService from '$lib/services/user-group-service'; import UserGroupService from '$lib/services/user-group-service';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import type { UserGroupCreate } from '$lib/types/user-group.type'; import type { UserGroupCreate } from '$lib/types/user-group.type';
@@ -18,6 +20,7 @@
const userGroupService = new UserGroupService(); const userGroupService = new UserGroupService();
const userService = new UserService(); const userService = new UserService();
const customClaimService = new CustomClaimService();
async function updateUserGroup(updatedUserGroup: UserGroupCreate) { async function updateUserGroup(updatedUserGroup: UserGroupCreate) {
let success = true; let success = true;
@@ -40,6 +43,15 @@
axiosErrorToast(e); axiosErrorToast(e);
}); });
} }
async function updateCustomClaims() {
await customClaimService
.updateUserGroupCustomClaims(userGroup.id, userGroup.customClaims)
.then(() => toast.success('Custom claims updated successfully'))
.catch((e) => {
axiosErrorToast(e);
});
}
</script> </script>
<svelte:head> <svelte:head>
@@ -53,7 +65,7 @@
</div> </div>
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
<Card.Title>Meta data</Card.Title> <Card.Title>General</Card.Title>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
@@ -76,3 +88,20 @@
</div> </div>
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>Custom Claims</Card.Title>
<Card.Description>
Custom claims are key-value pairs that can be used to store additional information about a
user. These claims will be included in the ID token if the scope "profile" is requested.
Custom claims defined on the user will be prioritized if there are conflicts.
</Card.Description>
</Card.Header>
<Card.Content>
<CustomClaimsInput bind:customClaims={userGroup.customClaims} />
<div class="mt-5 flex justify-end">
<Button onclick={updateCustomClaims} type="submit">Save</Button>
</div>
</Card.Content>
</Card.Root>

View File

@@ -1,16 +1,20 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import CustomClaimService from '$lib/services/custom-claim-service';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import type { UserCreate } from '$lib/types/user.type'; import type { UserCreate } from '$lib/types/user.type';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideChevronLeft } from 'lucide-svelte'; import { LucideChevronLeft } from 'lucide-svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import CustomClaimsInput from '../../../../../lib/components/custom-claims-input.svelte';
import UserForm from '../user-form.svelte'; import UserForm from '../user-form.svelte';
let { data } = $props(); let { data } = $props();
let user = $state(data); let user = $state(data);
const userService = new UserService(); const userService = new UserService();
const customClaimService = new CustomClaimService();
async function updateUser(updatedUser: UserCreate) { async function updateUser(updatedUser: UserCreate) {
let success = true; let success = true;
@@ -24,6 +28,15 @@
return success; return success;
} }
async function updateCustomClaims() {
await customClaimService
.updateUserCustomClaims(user.id, user.customClaims)
.then(() => toast.success('Custom claims updated successfully'))
.catch((e) => {
axiosErrorToast(e);
});
}
</script> </script>
<svelte:head> <svelte:head>
@@ -37,10 +50,25 @@
</div> </div>
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
<Card.Title>{user.firstName} {user.lastName}</Card.Title> <Card.Title>General</Card.Title>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<UserForm existingUser={user} callback={updateUser} /> <UserForm existingUser={user} callback={updateUser} />
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>Custom Claims</Card.Title>
<Card.Description>
Custom claims are key-value pairs that can be used to store additional information about a
user. These claims will be included in the ID token if the scope "profile" is requested.
</Card.Description>
</Card.Header>
<Card.Content>
<CustomClaimsInput bind:customClaims={user.customClaims} />
<div class="mt-5 flex justify-end">
<Button onclick={updateCustomClaims} type="submit">Save</Button>
</div>
</Card.Content>
</Card.Root>

View File

@@ -1,22 +1,51 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog'; import * as Dialog from '$lib/components/ui/dialog';
import Input from '$lib/components/ui/input/input.svelte'; import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte'; import Label from '$lib/components/ui/label/label.svelte';
import * as Select from '$lib/components/ui/select/index.js';
import UserService from '$lib/services/user-service';
import { axiosErrorToast } from '$lib/utils/error-util';
let { let {
oneTimeLink = $bindable() userId = $bindable()
}: { }: {
oneTimeLink: string | null; userId: string | null;
} = $props(); } = $props();
const userService = new UserService();
let oneTimeLink: string | null = $state(null);
let selectedExpiration: keyof typeof availableExpirations = $state('1 hour');
let availableExpirations = {
'1 hour': 60 * 60,
'12 hours': 60 * 60 * 12,
'1 day': 60 * 60 * 24,
'1 week': 60 * 60 * 24 * 7,
'1 month': 60 * 60 * 24 * 30
};
async function createOneTimeAccessToken() {
try {
const expiration = new Date(Date.now() + availableExpirations[selectedExpiration] * 1000);
const token = await userService.createOneTimeAccessToken(userId!, expiration);
oneTimeLink = `${$page.url.origin}/login/${token}`;
} catch (e) {
axiosErrorToast(e);
}
}
function onOpenChange(open: boolean) { function onOpenChange(open: boolean) {
if (!open) { if (!open) {
oneTimeLink = null; oneTimeLink = null;
userId = null;
} }
} }
</script> </script>
<Dialog.Root open={!!oneTimeLink} {onOpenChange}> <Dialog.Root open={!!userId} {onOpenChange}>
<Dialog.Content class="max-w-md"> <Dialog.Content class="max-w-md">
<Dialog.Header> <Dialog.Header>
<Dialog.Title>One Time Link</Dialog.Title> <Dialog.Title>One Time Link</Dialog.Title>
@@ -25,9 +54,36 @@
have lost it.</Dialog.Description have lost it.</Dialog.Description
> >
</Dialog.Header> </Dialog.Header>
<div> {#if oneTimeLink === null}
<Label for="one-time-link">One Time Link</Label> <div>
<Label for="expiration">Expiration</Label>
<Select.Root
selected={{
label: Object.keys(availableExpirations)[0],
value: Object.keys(availableExpirations)[0]
}}
onSelectedChange={(v) =>
(selectedExpiration = v!.value as keyof typeof availableExpirations)}
>
<Select.Trigger class="h-9 ">
<Select.Value>{selectedExpiration}</Select.Value>
</Select.Trigger>
<Select.Content>
{#each Object.keys(availableExpirations) as key}
<Select.Item value={key}>{key}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Button
onclick={() => createOneTimeAccessToken()}
disabled={!selectedExpiration}
>
Generate Link
</Button>
{:else}
<Label for="one-time-link" class="sr-only">One Time Link</Label>
<Input id="one-time-link" value={oneTimeLink} readonly /> <Input id="one-time-link" value={oneTimeLink} readonly />
</div> {/if}
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>

View File

@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { goto } from '$app/navigation';
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 { Badge } from '$lib/components/ui/badge/index';
import { Button } from '$lib/components/ui/button'; import { buttonVariants } 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';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
@@ -21,7 +21,7 @@
users = initialUsers; users = initialUsers;
}); });
let oneTimeLink = $state<string | null>(null); let userIdToCreateOneTimeLink: string | null = $state(null);;
const userService = new UserService(); const userService = new UserService();
@@ -48,15 +48,6 @@
} }
}); });
} }
async function createOneTimeAccessToken(userId: string) {
try {
const token = await userService.createOneTimeAccessToken(userId);
oneTimeLink = `${$page.url.origin}/login/${token}`;
} catch (e) {
axiosErrorToast(e);
}
}
</script> </script>
<AdvancedTable <AdvancedTable
@@ -82,22 +73,20 @@
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger asChild let:builder> <DropdownMenu.Trigger class={buttonVariants({ variant: 'ghost', size: 'icon' })}>
<Button aria-haspopup="true" size="icon" variant="ghost" builders={[builder]}> <Ellipsis class="h-4 w-4" />
<Ellipsis class="h-4 w-4" /> <span class="sr-only">Toggle menu</span>
<span class="sr-only">Toggle menu</span>
</Button>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content align="end"> <DropdownMenu.Content align="end">
<DropdownMenu.Item on:click={() => createOneTimeAccessToken(item.id)} <DropdownMenu.Item onclick={() => (userIdToCreateOneTimeLink = item.id)}
><LucideLink class="mr-2 h-4 w-4" />One-time link</DropdownMenu.Item ><LucideLink class="mr-2 h-4 w-4" />One-time link</DropdownMenu.Item
> >
<DropdownMenu.Item href="/settings/admin/users/{item.id}" <DropdownMenu.Item onclick={() => goto(`/settings/admin/users/${item.id}`)}
><LucidePencil class="mr-2 h-4 w-4" /> Edit</DropdownMenu.Item ><LucidePencil class="mr-2 h-4 w-4" /> Edit</DropdownMenu.Item
> >
<DropdownMenu.Item <DropdownMenu.Item
class="text-red-500 focus:!text-red-700" class="text-red-500 focus:!text-red-700"
on:click={() => deleteUser(item)} onclick={() => deleteUser(item)}
><LucideTrash class="mr-2 h-4 w-4" />Delete</DropdownMenu.Item ><LucideTrash class="mr-2 h-4 w-4" />Delete</DropdownMenu.Item
> >
</DropdownMenu.Content> </DropdownMenu.Content>
@@ -106,4 +95,4 @@
{/snippet} {/snippet}
</AdvancedTable> </AdvancedTable>
<OneTimeLinkModal {oneTimeLink} /> <OneTimeLinkModal userId={userIdToCreateOneTimeLink} />

View File

@@ -24,7 +24,7 @@ test('Update account details fails with already taken email', async ({ page }) =
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('status')).toHaveText('Email is already taken'); await expect(page.getByRole('status')).toHaveText('Email is already in use');
}); });
test('Update account details fails with already taken username', async ({ page }) => { test('Update account details fails with already taken username', async ({ page }) => {
@@ -34,7 +34,7 @@ test('Update account details fails with already taken username', async ({ page }
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('status')).toHaveText('Username is already taken'); await expect(page.getByRole('status')).toHaveText('Username is already in use');
}); });
test('Add passkey to an account', async ({ page }) => { test('Add passkey to an account', async ({ page }) => {

View File

@@ -4,7 +4,7 @@ import { cleanupBackend } from './utils/cleanup.util';
test.beforeEach(cleanupBackend); test.beforeEach(cleanupBackend);
test('Create user group', async ({ page, baseURL }) => { test('Create user group', async ({ page }) => {
await page.goto('/settings/admin/user-groups'); await page.goto('/settings/admin/user-groups');
const group = userGroups.humanResources; const group = userGroups.humanResources;
@@ -15,8 +15,7 @@ test('Create user group', async ({ page, baseURL }) => {
await expect(page.getByRole('status')).toHaveText('User group created successfully'); await expect(page.getByRole('status')).toHaveText('User group created successfully');
const expectedRoute = new RegExp(`${baseURL}/settings/admin/user-groups/[a-f0-9-]+`); await page.waitForURL('/settings/admin/user-groups/*');
expect(page.url()).toMatch(expectedRoute);
await expect(page.getByLabel('Friendly Name')).toHaveValue(group.friendlyName); await expect(page.getByLabel('Friendly Name')).toHaveValue(group.friendlyName);
await expect(page.getByLabel('Name', { exact: true })).toHaveValue(group.name); await expect(page.getByLabel('Name', { exact: true })).toHaveValue(group.name);
@@ -74,3 +73,39 @@ test('Delete user group', async ({ page }) => {
await expect(page.getByRole('status')).toHaveText('User group deleted successfully'); await expect(page.getByRole('status')).toHaveText('User group deleted successfully');
await expect(page.getByRole('row', { name: group.name })).not.toBeVisible(); await expect(page.getByRole('row', { name: group.name })).not.toBeVisible();
}); });
test('Update user group custom claims', async ({ page }) => {
await page.goto(`/settings/admin/user-groups/${userGroups.designers.id}`);
// Add two custom claims
await page.getByRole('button', { name: 'Add custom claim' }).click();
await page.getByPlaceholder('Key').fill('customClaim1');
await page.getByPlaceholder('Value').fill('customClaim1_value');
await page.getByRole('button', { name: 'Add another' }).click();
await page.getByPlaceholder('Key').nth(1).fill('customClaim2');
await page.getByPlaceholder('Value').nth(1).fill('customClaim2_value');
await page.getByRole('button', { name: 'Save' }).nth(2).click();
await expect(page.getByRole('status')).toHaveText('Custom claims updated successfully');
await page.reload();
// Check if custom claims are saved
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim1');
await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim1_value');
await expect(page.getByPlaceholder('Key').nth(1)).toHaveValue('customClaim2');
await expect(page.getByPlaceholder('Value').nth(1)).toHaveValue('customClaim2_value');
// Remove one custom claim
await page.getByLabel('Remove custom claim').first().click();
await page.getByRole('button', { name: 'Save' }).nth(2).click();
await page.reload();
// Check if custom claim is removed
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim2');
await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim2_value');
});

View File

@@ -32,7 +32,7 @@ test('Create user fails with already taken email', async ({ page }) => {
await page.getByLabel('Username').fill(user.username); await page.getByLabel('Username').fill(user.username);
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('status')).toHaveText('Email is already taken'); await expect(page.getByRole('status')).toHaveText('Email is already in use');
}); });
test('Create user fails with already taken username', async ({ page }) => { test('Create user fails with already taken username', async ({ page }) => {
@@ -47,7 +47,7 @@ test('Create user fails with already taken username', async ({ page }) => {
await page.getByLabel('Username').fill(users.tim.username); await page.getByLabel('Username').fill(users.tim.username);
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('status')).toHaveText('Username is already taken'); await expect(page.getByRole('status')).toHaveText('Username is already in use');
}); });
test('Create one time access token', async ({ page }) => { test('Create one time access token', async ({ page }) => {
@@ -57,8 +57,13 @@ test('Create one time access token', async ({ page }) => {
.getByRole('row', { name: `${users.craig.firstname} ${users.craig.lastname}` }) .getByRole('row', { name: `${users.craig.firstname} ${users.craig.lastname}` })
.getByRole('button') .getByRole('button')
.click(); .click();
await page.getByRole('menuitem', { name: 'One-time link' }).click(); await page.getByRole('menuitem', { name: 'One-time link' }).click();
await page.getByLabel('One Time Link').getByRole('combobox').click();
await page.getByRole('option', { name: '12 hours' }).click();
await page.getByRole('button', { name: 'Generate Link' }).click();
await expect(page.getByRole('textbox', { name: 'One Time Link' })).toHaveValue( await expect(page.getByRole('textbox', { name: 'One Time Link' })).toHaveValue(
/http:\/\/localhost\/login\/.*/ /http:\/\/localhost\/login\/.*/
); );
@@ -95,7 +100,7 @@ test('Update user', async ({ page }) => {
await page.getByLabel('Last name').fill('Apple'); await page.getByLabel('Last name').fill('Apple');
await page.getByLabel('Email').fill('crack.apple@test.com'); await page.getByLabel('Email').fill('crack.apple@test.com');
await page.getByLabel('Username').fill('crack'); await page.getByLabel('Username').fill('crack');
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).first().click();
await expect(page.getByRole('status')).toHaveText('User updated successfully'); await expect(page.getByRole('status')).toHaveText('User updated successfully');
}); });
@@ -112,9 +117,9 @@ test('Update user fails with already taken email', async ({ page }) => {
await page.getByRole('menuitem', { name: 'Edit' }).click(); await page.getByRole('menuitem', { name: 'Edit' }).click();
await page.getByLabel('Email').fill(users.tim.email); await page.getByLabel('Email').fill(users.tim.email);
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).first().click();
await expect(page.getByRole('status')).toHaveText('Email is already taken'); await expect(page.getByRole('status')).toHaveText('Email is already in use');
}); });
test('Update user fails with already taken username', async ({ page }) => { test('Update user fails with already taken username', async ({ page }) => {
@@ -129,7 +134,43 @@ test('Update user fails with already taken username', async ({ page }) => {
await page.getByRole('menuitem', { name: 'Edit' }).click(); await page.getByRole('menuitem', { name: 'Edit' }).click();
await page.getByLabel('Username').fill(users.tim.username); await page.getByLabel('Username').fill(users.tim.username);
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).first().click();
await expect(page.getByRole('status')).toHaveText('Username is already taken'); await expect(page.getByRole('status')).toHaveText('Username is already in use');
});
test('Update user custom claims', async ({ page }) => {
await page.goto(`/settings/admin/users/${users.craig.id}`);
// Add two custom claims
await page.getByRole('button', { name: 'Add custom claim' }).click();
await page.getByPlaceholder('Key').fill('customClaim1');
await page.getByPlaceholder('Value').fill('customClaim1_value');
await page.getByRole('button', { name: 'Add another' }).click();
await page.getByPlaceholder('Key').nth(1).fill('customClaim2');
await page.getByPlaceholder('Value').nth(1).fill('customClaim2_value');
await page.getByRole('button', { name: 'Save' }).nth(1).click();
await expect(page.getByRole('status')).toHaveText('Custom claims updated successfully');
await page.reload();
// Check if custom claims are saved
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim1');
await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim1_value');
await expect(page.getByPlaceholder('Key').nth(1)).toHaveValue('customClaim2');
await expect(page.getByPlaceholder('Value').nth(1)).toHaveValue('customClaim2_value');
// Remove one custom claim
await page.getByLabel('Remove custom claim').first().click();
await page.getByRole('button', { name: 'Save' }).nth(1).click();
await page.reload();
// Check if custom claim is removed
await expect(page.getByPlaceholder('Key').first()).toHaveValue('customClaim2');
await expect(page.getByPlaceholder('Value').first()).toHaveValue('customClaim2_value');
}); });