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)

View File

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

View File

@@ -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.
## 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
> [!WARNING]
@@ -17,7 +35,7 @@ Additionally, what makes Pocket ID special is that it only supports [passkey](ht
### Before you start
Pocket ID requires a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts), meaning it must be served over HTTPS. This is necessary because Pocket ID uses the [WebAuthn API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API) which requires a secure context.
Pocket ID requires a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts), meaning it must be served over HTTPS. This is necessary because Pocket ID uses the [WebAuthn API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API).
### Installation with Docker (recommended)
@@ -78,14 +96,14 @@ Required tools:
# Optional: Start Caddy (You can use any other reverse proxy)
cd ..
pm2 start --name pocket-id-caddy caddy -- run --config Caddyfile
pm2 start --name pocket-id-caddy caddy -- run --config reverse-proxy/Caddyfile
```
You can now sign in with the admin account on `http://localhost/login/setup`.
### Nginx Reverse Proxy
To use Nginx in front of Pocket ID, add the following configuration to increase the header buffer size because, as SvelteKit generates larger headers.
To use Nginx as a reverse proxy for Pocket ID, update the configuration to increase the header buffer size. This adjustment is necessary because SvelteKit generates larger headers, which may exceed the default buffer limits.
```nginx
proxy_busy_buffers_size 512k;
@@ -95,7 +113,7 @@ proxy_buffer_size 256k;
## Proxy Services with Pocket ID
As the goal of Pocket ID is to stay simple, we don't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/oauth2-proxy) to add authentication to your services that don't support OIDC.
As the goal of Pocket ID is to stay simple, it doesn't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/oauth2-proxy) to add authentication to your services that don't support OIDC.
See the [guide](docs/proxy-services.md) for more information.
@@ -136,7 +154,7 @@ docker compose up -d
# Optional: Start Caddy (You can use any other reverse proxy)
cd ..
pm2 start caddy --name pocket-id-caddy -- run --config Caddyfile
pm2 start caddy --name pocket-id-caddy -- run --config reverse-proxy/Caddyfile
```
## Environment variables
@@ -157,6 +175,16 @@ docker compose up -d
| `PORT` | `3000` | no | The port on which the frontend 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
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 (
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/utils"
"github.com/stonith404/pocket-id/backend/resources"
"log"
"os"
"path"
"strings"
)
@@ -12,7 +14,7 @@ import (
func initApplicationImages() {
dirPath := common.EnvConfig.UploadPath + "/application-images"
sourceFiles, err := os.ReadDir("./images")
sourceFiles, err := resources.FS.ReadDir("images")
if err != nil && !os.IsNotExist(err) {
log.Fatalf("Error reading directory: %v", err)
}
@@ -27,10 +29,10 @@ func initApplicationImages() {
if sourceFile.IsDir() || imageAlreadyExists(sourceFile.Name(), destinationFiles) {
continue
}
srcFilePath := "./images/" + sourceFile.Name()
destFilePath := dirPath + "/" + sourceFile.Name()
srcFilePath := path.Join("images", sourceFile.Name())
destFilePath := path.Join(dirPath, sourceFile.Name())
err := utils.CopyFile(srcFilePath, destFilePath)
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
if err != nil {
log.Fatalf("Error copying file: %v", err)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,13 +28,6 @@ var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
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 {
// The string can only contain letters and numbers
regex := "^[A-Za-z0-9]*$"
@@ -53,13 +46,6 @@ func init() {
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 err := v.RegisterValidation("claimKey", validateClaimKey); err != nil {
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/google/uuid"
"github.com/stonith404/pocket-id/backend/internal/model"
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
"gorm.io/gorm"
"log"
"time"
@@ -29,22 +30,22 @@ type Jobs struct {
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
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
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
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
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) {

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import (
"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
func (date *DateTime) Scan(value interface{}) (err error) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,7 @@ func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditL
return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService}
}
func (s *UserService) ListUsers(searchTerm string, page int, pageSize int) ([]model.User, utils.PaginationResponse, error) {
func (s *UserService) ListUsers(searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.User, utils.PaginationResponse, error) {
var users []model.User
query := s.db.Model(&model.User{})
@@ -30,7 +30,7 @@ func (s *UserService) ListUsers(searchTerm string, page int, pageSize int) ([]mo
query = query.Where("email LIKE ? OR first_name LIKE ? OR username LIKE ?", searchPattern, searchPattern, searchPattern)
}
pagination, err := utils.Paginate(page, pageSize, query, &users)
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &users)
return users, pagination, err
}
@@ -112,7 +112,7 @@ func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Tim
func (s *UserService) ExchangeOneTimeAccessToken(token string) (model.User, string, error) {
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) {
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 {
webauthnConfig := &webauthn.Config{
RPDisplayName: appConfigService.DbConfig.AppName.Value,
RPID: utils.GetHostFromURL(common.EnvConfig.AppURL),
RPID: utils.GetHostnameFromURL(common.EnvConfig.AppURL),
RPOrigins: []string{common.EnvConfig.AppURL},
Timeouts: webauthn.TimeoutsConfig{
Login: webauthn.TimeoutConfig{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

Before

Width:  |  Height:  |  Size: 3.7 MiB

After

Width:  |  Height:  |  Size: 3.7 MiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 539 B

After

Width:  |  Height:  |  Size: 539 B

View File

Before

Width:  |  Height:  |  Size: 434 B

After

Width:  |  Height:  |  Size: 434 B

View File

Before

Width:  |  Height:  |  Size: 434 B

After

Width:  |  Height:  |  Size: 434 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -62,7 +62,7 @@
</nav>
</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()}
</div>
</main>

View File

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

View File

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

View File

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

View File

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

View File

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

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