Compare commits

..

29 Commits

Author SHA1 Message Date
Elias Schneider
5c57beb4d7 release: 0.24.1 2025-01-13 15:14:10 +01:00
Elias Schneider
2a984eeaf1 docs: add account recovery to README 2025-01-13 15:13:56 +01:00
Elias Schneider
be6e25a167 fix: remove restrictive validation for group names 2025-01-13 12:38:02 +01:00
Elias Schneider
888557171d fix: optional arguments not working with create-one-time-access-token.sh 2025-01-13 12:32:22 +01:00
Elias Schneider
4d337a20c5 fix: audit log table overflow if row data is long 2025-01-12 01:21:47 +01:00
Elias Schneider
69afd9ad9f release: 0.24.0 2025-01-11 23:46:39 +01:00
Elias Schneider
fd69830c26 feat: add sorting for tables 2025-01-11 20:32:22 +01:00
Elias Schneider
61d18a9d1b fix: pkce state not correctly reflected in oidc client info 2025-01-10 09:32:51 +01:00
Elias Schneider
a649c4b4a5 fix: send test email to the user that has requested it 2025-01-10 09:25:26 +01:00
Elias Schneider
82e475a923 release: 0.23.0 2025-01-03 16:34:23 +01:00
Elias Schneider
2d31fc2cc9 feat: use same table component for OIDC client list as all other lists 2025-01-03 16:19:15 +01:00
Elias Schneider
adcf3ddc66 feat: add PKCE for non public clients 2025-01-03 16:15:10 +01:00
Elias Schneider
785200de61 chore: include static assets in binary 2025-01-03 15:12:07 +01:00
Elias Schneider
ee885fbff5 release: 0.22.0 2025-01-01 23:13:53 +01:00
Elias Schneider
333a1a18d5 fix: make user validation consistent between pages 2025-01-01 23:13:16 +01:00
Elias Schneider
1ff20caa3c fix: allow first and last name of user to be between 1 and 50 characters 2025-01-01 22:48:51 +01:00
Elias Schneider
f6f2736bba fix: hash in callback url is incorrectly appended 2025-01-01 22:46:59 +01:00
Elias Schneider
993330d932 Merge remote-tracking branch 'origin/main' 2025-01-01 22:46:29 +01:00
Jan-Philipp Fischer
204313aacf docs: add "groups" scope to the oauth2-proxy sample configuration (#85) 2024-12-31 11:31:39 +01:00
Elias Schneider
0729ce9e1a fix: passkey can't be added if PUBLIC_APP_URL includes a port 2024-12-31 10:42:54 +01:00
Elias Schneider
2d0bd8dcbf feat: add warning if passkeys missing 2024-12-23 09:59:12 +01:00
Elias Schneider
ff75322e7d docs: improve text in README 2024-12-20 08:20:40 +01:00
Elias Schneider
daced661c4 release: 0.21.0 2024-12-17 19:58:55 +01:00
Elias Schneider
0716c38fb8 feat: improve error state design for login page 2024-12-17 19:36:47 +01:00
Elias Schneider
789d9394a5 fix: OIDC client logo gets removed if other properties get updated 2024-12-17 19:00:33 +01:00
Elias Schneider
aeda512cb7 release: 0.20.1 2024-12-13 09:12:37 +01:00
Elias Schneider
5480ab0f18 tests: add e2e test for one time access tokens 2024-12-13 09:03:52 +01:00
Elias Schneider
bad901ea2b fix: wrong date time datatype used for read operations with Postgres 2024-12-13 08:43:46 +01:00
Elias Schneider
34e35193f9 fix: create-one-time-access-token.sh script not compatible with postgres 2024-12-12 23:03:07 +01:00
111 changed files with 864 additions and 400 deletions

View File

@@ -1 +1 @@
0.20.0 0.24.1

View File

@@ -1,3 +1,68 @@
## [](https://github.com/stonith404/pocket-id/compare/v0.24.0...v) (2025-01-13)
### Bug Fixes
* audit log table overflow if row data is long ([4d337a2](https://github.com/stonith404/pocket-id/commit/4d337a20c5cb92ef80bb7402f9b99b08e3ad0b6b))
* optional arguments not working with `create-one-time-access-token.sh` ([8885571](https://github.com/stonith404/pocket-id/commit/888557171d61589211b10f70dce405126216ad61))
* remove restrictive validation for group names ([be6e25a](https://github.com/stonith404/pocket-id/commit/be6e25a167de8bf07075b46f09d9fc1fa6c74426))
## [](https://github.com/stonith404/pocket-id/compare/v0.23.0...v) (2025-01-11)
### Features
* add sorting for tables ([fd69830](https://github.com/stonith404/pocket-id/commit/fd69830c2681985e4fd3c5336a2b75c9fb7bc5d4))
### Bug Fixes
* pkce state not correctly reflected in oidc client info ([61d18a9](https://github.com/stonith404/pocket-id/commit/61d18a9d1b167ff59a59523ff00d00ca8f23258d))
* send test email to the user that has requested it ([a649c4b](https://github.com/stonith404/pocket-id/commit/a649c4b4a543286123f4d1f3c411fe1a7e2c6d71))
## [](https://github.com/stonith404/pocket-id/compare/v0.22.0...v) (2025-01-03)
### Features
* add PKCE for non public clients ([adcf3dd](https://github.com/stonith404/pocket-id/commit/adcf3ddc6682794e136a454ef9e69ddd130626a8))
* use same table component for OIDC client list as all other lists ([2d31fc2](https://github.com/stonith404/pocket-id/commit/2d31fc2cc9201bb93d296faae622f52c6dcdfebc))
## [](https://github.com/stonith404/pocket-id/compare/v0.21.0...v) (2025-01-01)
### Features
* add warning if passkeys missing ([2d0bd8d](https://github.com/stonith404/pocket-id/commit/2d0bd8dcbfb73650b7829cb66f40decb284bd73b))
### Bug Fixes
* allow first and last name of user to be between 1 and 50 characters ([1ff20ca](https://github.com/stonith404/pocket-id/commit/1ff20caa3ccd651f9fb30f958ffb807dfbbcbd8a))
* hash in callback url is incorrectly appended ([f6f2736](https://github.com/stonith404/pocket-id/commit/f6f2736bba65eee017f2d8cdaa70621574092869))
* make user validation consistent between pages ([333a1a1](https://github.com/stonith404/pocket-id/commit/333a1a18d59f675111f4ed106fa5614ef563c6f4))
* passkey can't be added if `PUBLIC_APP_URL` includes a port ([0729ce9](https://github.com/stonith404/pocket-id/commit/0729ce9e1a8dab9912900a01dcd0fbd892718a1a))
## [](https://github.com/stonith404/pocket-id/compare/v0.20.1...v) (2024-12-17)
### Features
* improve error state design for login page ([0716c38](https://github.com/stonith404/pocket-id/commit/0716c38fb8ce7fa719c7fe0df750bdb213786c21))
### Bug Fixes
* OIDC client logo gets removed if other properties get updated ([789d939](https://github.com/stonith404/pocket-id/commit/789d9394a533831e7e2fb8dc3f6b338787336ad8))
## [](https://github.com/stonith404/pocket-id/compare/v0.20.0...v) (2024-12-13)
### Bug Fixes
* `create-one-time-access-token.sh` script not compatible with postgres ([34e3519](https://github.com/stonith404/pocket-id/commit/34e35193f9f3813f6248e60f15080d753e8da7ae))
* wrong date time datatype used for read operations with Postgres ([bad901e](https://github.com/stonith404/pocket-id/commit/bad901ea2b661aadd286e5e4bed317e73bd8a70d))
## [](https://github.com/stonith404/pocket-id/compare/v0.19.0...v) (2024-12-12) ## [](https://github.com/stonith404/pocket-id/compare/v0.19.0...v) (2024-12-12)

View File

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

View File

@@ -10,6 +10,24 @@ The goal of Pocket ID is to be a simple and easy-to-use. There are other self-ho
Additionally, what makes Pocket ID special is that it only supports [passkey](https://www.passkeys.io/) authentication, which means you dont need a password. Some people might not like this idea at first, but I believe passkeys are the future, and once you try them, youll love them. For example, you can now use a physical Yubikey to sign in to all your self-hosted services easily and securely. Additionally, what makes Pocket ID special is that it only supports [passkey](https://www.passkeys.io/) authentication, which means you dont need a password. Some people might not like this idea at first, but I believe passkeys are the future, and once you try them, youll love them. For example, you can now use a physical Yubikey to sign in to all your self-hosted services easily and securely.
## Table of Contents
- [ Pocket ID](#-pocket-id)
- [Table of Contents](#table-of-contents)
- [Setup](#setup)
- [Before you start](#before-you-start)
- [Installation with Docker (recommended)](#installation-with-docker-recommended)
- [Unraid](#unraid)
- [Stand-alone Installation](#stand-alone-installation)
- [Nginx Reverse Proxy](#nginx-reverse-proxy)
- [Proxy Services with Pocket ID](#proxy-services-with-pocket-id)
- [Update](#update)
- [Docker](#docker)
- [Stand-alone](#stand-alone)
- [Environment variables](#environment-variables)
- [Account recovery](#account-recovery)
- [Contribute](#contribute)
## Setup ## Setup
> [!WARNING] > [!WARNING]
@@ -17,7 +35,7 @@ Additionally, what makes Pocket ID special is that it only supports [passkey](ht
### Before you start ### Before you start
Pocket ID requires a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts), meaning it must be served over HTTPS. This is necessary because Pocket ID uses the [WebAuthn API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API) which requires a secure context. Pocket ID requires a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts), meaning it must be served over HTTPS. This is necessary because Pocket ID uses the [WebAuthn API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API).
### Installation with Docker (recommended) ### Installation with Docker (recommended)
@@ -78,14 +96,14 @@ Required tools:
# Optional: Start Caddy (You can use any other reverse proxy) # Optional: Start Caddy (You can use any other reverse proxy)
cd .. cd ..
pm2 start --name pocket-id-caddy caddy -- run --config Caddyfile pm2 start --name pocket-id-caddy caddy -- run --config reverse-proxy/Caddyfile
``` ```
You can now sign in with the admin account on `http://localhost/login/setup`. You can now sign in with the admin account on `http://localhost/login/setup`.
### Nginx Reverse Proxy ### Nginx Reverse Proxy
To use Nginx in front of Pocket ID, add the following configuration to increase the header buffer size because, as SvelteKit generates larger headers. To use Nginx as a reverse proxy for Pocket ID, update the configuration to increase the header buffer size. This adjustment is necessary because SvelteKit generates larger headers, which may exceed the default buffer limits.
```nginx ```nginx
proxy_busy_buffers_size 512k; proxy_busy_buffers_size 512k;
@@ -95,7 +113,7 @@ proxy_buffer_size 256k;
## Proxy Services with Pocket ID ## Proxy Services with Pocket ID
As the goal of Pocket ID is to stay simple, we don't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/oauth2-proxy) to add authentication to your services that don't support OIDC. As the goal of Pocket ID is to stay simple, it doesn't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/oauth2-proxy) to add authentication to your services that don't support OIDC.
See the [guide](docs/proxy-services.md) for more information. See the [guide](docs/proxy-services.md) for more information.
@@ -136,7 +154,7 @@ docker compose up -d
# Optional: Start Caddy (You can use any other reverse proxy) # Optional: Start Caddy (You can use any other reverse proxy)
cd .. cd ..
pm2 start caddy --name pocket-id-caddy -- run --config Caddyfile pm2 start caddy --name pocket-id-caddy -- run --config reverse-proxy/Caddyfile
``` ```
## Environment variables ## Environment variables
@@ -157,6 +175,16 @@ docker compose up -d
| `PORT` | `3000` | no | The port on which the frontend should listen. | | `PORT` | `3000` | no | The port on which the frontend should listen. |
| `BACKEND_PORT` | `8080` | no | The port on which the backend should listen. | | `BACKEND_PORT` | `8080` | no | The port on which the backend should listen. |
## Account recovery
There are two ways to create a one-time access link for a user:
1. **UI**: An admin can create a one-time access link for the user in the admin panel under the "Users" tab by clicking on the three dots next to the user's name and selecting "One-time link".
2. **Terminal**: You can create a one-time access link for a user by running the `scripts/create-one-time-access-token.sh` script. To execute this script with Docker you have to run the following command:
```bash
docker compose exec pocket-id sh "sh scripts/create-one-time-access-token.sh <username or email>"
```
## Contribute ## Contribute
You're very welcome to contribute to Pocket ID! Please follow the [contribution guide](/CONTRIBUTING.md) to get started. You're very welcome to contribute to Pocket ID! Please follow the [contribution guide](/CONTRIBUTING.md) to get started.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,8 +3,8 @@ package controller
import ( import (
"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/utils"
"net/http" "net/http"
"strconv"
"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"
@@ -23,12 +23,16 @@ type AuditLogController struct {
} }
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) { func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err)
return
}
userID := c.GetString("userID") userID := c.GetString("userID")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
// 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, sortedPaginationRequest)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return

View File

@@ -5,8 +5,8 @@ import (
"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"
"strings" "strings"
) )
@@ -153,11 +153,14 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
} }
func (oc *OidcController) listClientsHandler(c *gin.Context) { func (oc *OidcController) listClientsHandler(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
searchTerm := c.Query("search") searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err)
return
}
clients, pagination, err := oc.oidcService.ListClients(searchTerm, page, pageSize) clients, pagination, err := oc.oidcService.ListClients(searchTerm, sortedPaginationRequest)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return

View File

@@ -6,9 +6,9 @@ import (
"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"
"time" "time"
) )
@@ -37,11 +37,14 @@ type UserController struct {
} }
func (uc *UserController) listUsersHandler(c *gin.Context) { func (uc *UserController) listUsersHandler(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
searchTerm := c.Query("search") searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err)
return
}
users, pagination, err := uc.UserService.ListUsers(searchTerm, page, pageSize) users, pagination, err := uc.UserService.ListUsers(searchTerm, sortedPaginationRequest)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return

View File

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

View File

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

View File

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

View File

@@ -23,8 +23,8 @@ type UserGroupDtoWithUserCount struct {
} }
type UserGroupCreateDto struct { type UserGroupCreateDto struct {
FriendlyName string `json:"friendlyName" binding:"required,min=3,max=30"` FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50"`
Name string `json:"name" binding:"required,min=3,max=30,userGroupName"` Name string `json:"name" binding:"required,min=2,max=255"`
} }
type UserGroupUpdateUsersDto struct { type UserGroupUpdateUsersDto struct {

View File

@@ -28,13 +28,6 @@ var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
return matched return matched
} }
var validateUserGroupName validator.Func = func(fl validator.FieldLevel) bool {
// The string can only contain lowercase letters, numbers, and underscores
regex := "^[a-z0-9_]*$"
matched, _ := regexp.MatchString(regex, fl.Field().String())
return matched
}
var validateClaimKey validator.Func = func(fl validator.FieldLevel) bool { var validateClaimKey validator.Func = func(fl validator.FieldLevel) bool {
// The string can only contain letters and numbers // The string can only contain letters and numbers
regex := "^[A-Za-z0-9]*$" regex := "^[A-Za-z0-9]*$"
@@ -53,13 +46,6 @@ 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("userGroupName", validateUserGroupName); err != nil {
log.Fatalf("Failed to register custom validation: %v", err)
}
}
if v, ok := binding.Validator.Engine().(*validator.Validate); ok { if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
if err := v.RegisterValidation("claimKey", validateClaimKey); err != nil { if err := v.RegisterValidation("claimKey", validateClaimKey); err != nil {
log.Fatalf("Failed to register custom validation: %v", err) log.Fatalf("Failed to register custom validation: %v", err)

View File

@@ -4,6 +4,7 @@ import (
"github.com/go-co-op/gocron/v2" "github.com/go-co-op/gocron/v2"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/stonith404/pocket-id/backend/internal/model" "github.com/stonith404/pocket-id/backend/internal/model"
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
"gorm.io/gorm" "gorm.io/gorm"
"log" "log"
"time" "time"
@@ -29,22 +30,22 @@ type Jobs struct {
// ClearWebauthnSessions deletes WebAuthn sessions that have expired // ClearWebauthnSessions deletes WebAuthn sessions that have expired
func (j *Jobs) clearWebauthnSessions() error { func (j *Jobs) clearWebauthnSessions() error {
return j.db.Delete(&model.WebauthnSession{}, "expires_at < ?", time.Now().Unix()).Error return j.db.Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
} }
// ClearOneTimeAccessTokens deletes one-time access tokens that have expired // ClearOneTimeAccessTokens deletes one-time access tokens that have expired
func (j *Jobs) clearOneTimeAccessTokens() error { func (j *Jobs) clearOneTimeAccessTokens() error {
return j.db.Debug().Delete(&model.OneTimeAccessToken{}, "expires_at < ?", time.Now().Unix()).Error return j.db.Debug().Delete(&model.OneTimeAccessToken{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
} }
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired // ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
func (j *Jobs) clearOidcAuthorizationCodes() error { func (j *Jobs) clearOidcAuthorizationCodes() error {
return j.db.Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", time.Now().Unix()).Error return j.db.Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
} }
// ClearAuditLogs deletes audit logs older than 90 days // ClearAuditLogs deletes audit logs older than 90 days
func (j *Jobs) clearAuditLogs() error { func (j *Jobs) clearAuditLogs() error {
return j.db.Delete(&model.AuditLog{}, "created_at < ?", time.Now().AddDate(0, 0, -90).Unix()).Error return j.db.Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90))).Error
} }
func registerJob(scheduler gocron.Scheduler, name string, interval string, job func() error) { func registerJob(scheduler gocron.Scheduler, name string, interval string, job func() error) {

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import (
"time" "time"
) )
// DateTime custom type for time.Time to store date as unix timestamp in the database // DateTime custom type for time.Time to store date as unix timestamp for sqlite and as date for postgres
type DateTime time.Time type DateTime time.Time
func (date *DateTime) Scan(value interface{}) (err error) { func (date *DateTime) Scan(value interface{}) (err error) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,8 +7,10 @@ import (
"fmt" "fmt"
"github.com/fxamacker/cbor/v2" "github.com/fxamacker/cbor/v2"
"github.com/stonith404/pocket-id/backend/internal/model/types" "github.com/stonith404/pocket-id/backend/internal/model/types"
"github.com/stonith404/pocket-id/backend/resources"
"log" "log"
"os" "os"
"path/filepath"
"time" "time"
"github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
@@ -57,6 +59,29 @@ func (s *TestService) SeedDatabase() error {
} }
} }
oneTimeAccessTokens := []model.OneTimeAccessToken{{
Base: model.Base{
ID: "bf877753-4ea4-4c9c-bbbd-e198bb201cb8",
},
Token: "HPe6k6uiDRRVuAQV",
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[0].ID,
},
{
Base: model.Base{
ID: "d3afae24-fe2d-4a98-abec-cf0b8525096a",
},
Token: "YCGDtftvsvYWiXd0",
ExpiresAt: datatype.DateTime(time.Now().Add(-1 * time.Second)), // expired
UserID: users[0].ID,
},
}
for _, token := range oneTimeAccessTokens {
if err := tx.Create(&token).Error; err != nil {
return err
}
}
userGroups := []model.UserGroup{ userGroups := []model.UserGroup{
{ {
Base: model.Base{ Base: model.Base{
@@ -222,11 +247,21 @@ func (s *TestService) ResetApplicationImages() error {
return err return err
} }
if err := utils.CopyDirectory("./images", common.EnvConfig.UploadPath+"/application-images"); err != nil { files, err := resources.FS.ReadDir("images")
log.Printf("Error copying directory: %v", err) if err != nil {
return err return err
} }
for _, file := range files {
srcFilePath := filepath.Join("images", file.Name())
destFilePath := filepath.Join(common.EnvConfig.UploadPath, "application-images", file.Name())
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
if err != nil {
return err
}
}
return nil return nil
} }

View File

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

View File

@@ -21,7 +21,7 @@ func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditL
return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService} return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService}
} }
func (s *UserService) ListUsers(searchTerm string, page int, pageSize int) ([]model.User, utils.PaginationResponse, error) { func (s *UserService) ListUsers(searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.User, utils.PaginationResponse, error) {
var users []model.User var users []model.User
query := s.db.Model(&model.User{}) query := s.db.Model(&model.User{})
@@ -30,7 +30,7 @@ func (s *UserService) ListUsers(searchTerm string, page int, pageSize int) ([]mo
query = query.Where("email LIKE ? OR first_name LIKE ? OR username LIKE ?", searchPattern, searchPattern, searchPattern) query = query.Where("email LIKE ? OR first_name LIKE ? OR username LIKE ?", searchPattern, searchPattern, searchPattern)
} }
pagination, err := utils.Paginate(page, pageSize, query, &users) pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &users)
return users, pagination, err return users, pagination, err
} }
@@ -112,7 +112,7 @@ func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Tim
func (s *UserService) ExchangeOneTimeAccessToken(token string) (model.User, string, error) { func (s *UserService) ExchangeOneTimeAccessToken(token string) (model.User, string, error) {
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, datatype.DateTime(time.Now())).Preload("User").First(&oneTimeAccessToken).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, "", &common.TokenInvalidOrExpiredError{} return model.User{}, "", &common.TokenInvalidOrExpiredError{}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

Before

Width:  |  Height:  |  Size: 3.7 MiB

After

Width:  |  Height:  |  Size: 3.7 MiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 539 B

After

Width:  |  Height:  |  Size: 539 B

View File

Before

Width:  |  Height:  |  Size: 434 B

After

Width:  |  Height:  |  Size: 434 B

View File

Before

Width:  |  Height:  |  Size: 434 B

After

Width:  |  Height:  |  Size: 434 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,26 +5,44 @@
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 Empty from '$lib/icons/empty.svelte';
import type { Paginated } from '$lib/types/pagination.type'; import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import { debounced } from '$lib/utils/debounce-util'; import { debounced } from '$lib/utils/debounce-util';
import { cn } from '$lib/utils/style';
import { ChevronDown } from 'lucide-svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import Button from './ui/button/button.svelte';
let { let {
items, items,
requestOptions = $bindable(),
selectedIds = $bindable(), selectedIds = $bindable(),
withoutSearch = false, withoutSearch = false,
fetchItems, defaultSort,
onRefresh,
columns, columns,
rows rows
}: { }: {
items: Paginated<T>; items: Paginated<T>;
requestOptions?: SearchPaginationSortRequest;
selectedIds?: string[]; selectedIds?: string[];
withoutSearch?: boolean; withoutSearch?: boolean;
fetchItems: (search: string, page: number, limit: number) => Promise<Paginated<T>>; defaultSort?: { column: string; direction: 'asc' | 'desc' };
columns: (string | { label: string; hidden?: boolean })[]; onRefresh: (requestOptions: SearchPaginationSortRequest) => Promise<Paginated<T>>;
columns: { label: string; hidden?: boolean; sortColumn?: string }[];
rows: Snippet<[{ item: T }]>; rows: Snippet<[{ item: T }]>;
} = $props(); } = $props();
if (!requestOptions) {
requestOptions = {
search: '',
sort: defaultSort,
pagination: {
page: items.pagination.currentPage,
limit: items.pagination.itemsPerPage
}
};
}
let availablePageSizes: number[] = [10, 20, 50, 100]; let availablePageSizes: number[] = [10, 20, 50, 100];
let allChecked = $derived.by(() => { let allChecked = $derived.by(() => {
@@ -38,7 +56,8 @@
}); });
const onSearch = debounced(async (searchValue: string) => { const onSearch = debounced(async (searchValue: string) => {
items = await fetchItems(searchValue, 1, items.pagination.itemsPerPage); requestOptions.search = searchValue;
onRefresh(requestOptions);
}, 300); }, 300);
async function onAllCheck(checked: boolean) { async function onAllCheck(checked: boolean) {
@@ -59,11 +78,20 @@
} }
async function onPageChange(page: number) { async function onPageChange(page: number) {
items = await fetchItems('', page, items.pagination.itemsPerPage); requestOptions!.pagination = { limit: items.pagination.itemsPerPage, page };
onRefresh(requestOptions!);
} }
async function onPageSizeChange(size: number) { async function onPageSizeChange(size: number) {
items = await fetchItems('', 1, size); requestOptions!.pagination = { limit: size, page: 1 };
onRefresh(requestOptions!);
}
async function onSort(column?: string, direction: 'asc' | 'desc' = 'asc') {
if (!column) return;
requestOptions!.sort = { column, direction };
onRefresh(requestOptions!);
} }
</script> </script>
@@ -73,7 +101,7 @@
<p class="text-muted-foreground mt-3 text-sm">No items found</p> <p class="text-muted-foreground mt-3 text-sm">No items found</p>
</div> </div>
{:else} {:else}
<div class="w-full"> <div class="w-full overflow-x-auto">
{#if !withoutSearch} {#if !withoutSearch}
<Input <Input
class="mb-4 max-w-sm" class="mb-4 max-w-sm"
@@ -83,20 +111,40 @@
/> />
{/if} {/if}
<Table.Root> <Table.Root class="min-w-full table-auto">
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
{#if selectedIds} {#if selectedIds}
<Table.Head> <Table.Head class="w-12">
<Checkbox checked={allChecked} onCheckedChange={(c) => onAllCheck(c as boolean)} /> <Checkbox checked={allChecked} onCheckedChange={(c) => onAllCheck(c as boolean)} />
</Table.Head> </Table.Head>
{/if} {/if}
{#each columns as column} {#each columns as column}
{#if typeof column === 'string'} <Table.Head class={cn(column.hidden && 'sr-only', column.sortColumn && 'px-0')}>
<Table.Head>{column}</Table.Head> {#if column.sortColumn}
{:else} <Button
<Table.Head class={column.hidden ? 'sr-only' : ''}>{column.label}</Table.Head> variant="ghost"
{/if} class="flex items-center"
on:click={() =>
onSort(
column.sortColumn,
requestOptions.sort?.direction === 'desc' ? 'asc' : 'desc'
)}
>
{column.label}
{#if requestOptions.sort?.column === column.sortColumn}
<ChevronDown
class={cn(
'ml-2 h-4 w-4',
requestOptions.sort?.direction === 'asc' ? 'rotate-180' : ''
)}
/>
{/if}
</Button>
{:else}
{column.label}
{/if}
</Table.Head>
{/each} {/each}
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
@@ -104,7 +152,7 @@
{#each items.data as item} {#each items.data as item}
<Table.Row class={selectedIds?.includes(item.id) ? 'bg-muted/20' : ''}> <Table.Row class={selectedIds?.includes(item.id) ? 'bg-muted/20' : ''}>
{#if selectedIds} {#if selectedIds}
<Table.Cell> <Table.Cell class="w-12">
<Checkbox <Checkbox
checked={selectedIds.includes(item.id)} checked={selectedIds.includes(item.id)}
onCheckedChange={(c) => onCheck(c as boolean, item.id)} onCheckedChange={(c) => onCheck(c as boolean, item.id)}
@@ -117,9 +165,7 @@
</Table.Body> </Table.Body>
</Table.Root> </Table.Root>
<div <div class="mt-5 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
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"> <div class="flex items-center space-x-2">
<p class="text-sm font-medium">Items per page</p> <p class="text-sm font-medium">Items per page</p>
<Select.Root <Select.Root

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -62,7 +62,7 @@
</nav> </nav>
</div> </div>
</div> </div>
<div class="flex w-full flex-col gap-5"> <div class="flex w-full flex-col gap-5 overflow-x-hidden">
{@render children()} {@render children()}
</div> </div>
</main> </main>

View File

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

View File

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

View File

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

View File

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

View File

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

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