Compare commits

..

19 Commits

Author SHA1 Message Date
Elias Schneider
47e164b4b5 release: 0.11.0 2024-10-25 21:53:25 +02:00
Elias Schneider
18c5103c20 fix: powered by link text color in light mode 2024-10-25 21:35:27 +02:00
Elias Schneider
5565f60d6d feat: add email_verified claim 2024-10-25 21:33:54 +02:00
Elias Schneider
bd4f87b2d2 release: 0.10.0 2024-10-23 11:54:47 +02:00
Elias Schneider
6560fd9279 chore: fix wrong file name of package.json in release script 2024-10-23 11:54:35 +02:00
Elias Schneider
29d632c151 fix: cache version information for 3 hours 2024-10-23 11:48:46 +02:00
Elias Schneider
2092007752 chore: dump frontend dependencies 2024-10-23 11:37:22 +02:00
Elias Schneider
0aff6181c9 chore: improve check of required tools in one time access token script 2024-10-23 10:50:49 +02:00
Elias Schneider
824c5cb4f3 fix: no DTO was returned from exchange one time access token endpoint 2024-10-23 10:30:25 +02:00
Elias Schneider
3a300a2b51 refactor: move development scripts into seperate folder 2024-10-23 10:26:18 +02:00
Elias Schneider
a1985ce1b2 feat: add script for creating one time access token 2024-10-23 10:03:17 +02:00
Elias Schneider
b39bc4f79a refactor: save dates as unix timestamps in database 2024-10-23 10:02:11 +02:00
Elias Schneider
0a07344139 fix: improve text for initial admin account setup 2024-10-22 20:41:35 +02:00
Elias Schneider
f3f0e1d56d fix: increase callback url count 2024-10-18 20:52:56 +02:00
Elias Schneider
70ad0b4f39 feat: add version information to footer and update link if new update is available 2024-10-18 20:48:59 +02:00
Elias Schneider
2587058ded release: 0.9.0 2024-10-18 08:23:55 +02:00
Elias Schneider
ff06bf0b34 feat: add environment variable to change the caddy port in Docker 2024-10-18 08:23:06 +02:00
Elias Schneider
11ed661f86 feat: use improve table for users and audit logs 2024-10-16 08:49:19 +02:00
Elias Schneider
29748cc6c7 fix: allow copy to clipboard for client secret 2024-10-13 15:55:17 +02:00
45 changed files with 984 additions and 657 deletions

18
.dockerignore Normal file
View File

@@ -0,0 +1,18 @@
node_modules
# Output
.output
.vercel
/frontend/.svelte-kit
/frontend/build
/backend/bin
# Env
.env
.env.*
# Application specific
data
/scripts/development

View File

@@ -1 +1 @@
0.8.1 0.11.0

View File

@@ -1,3 +1,44 @@
## [](https://github.com/stonith404/pocket-id/compare/v0.10.0...v) (2024-10-25)
### Features
* add `email_verified` claim ([5565f60](https://github.com/stonith404/pocket-id/commit/5565f60d6d62ca24bedea337e21effc13e5853a5))
### Bug Fixes
* powered by link text color in light mode ([18c5103](https://github.com/stonith404/pocket-id/commit/18c5103c20ce79abdc0f724cdedd642c09269e78))
## [](https://github.com/stonith404/pocket-id/compare/v0.9.0...v) (2024-10-23)
### Features
* add script for creating one time access token ([a1985ce](https://github.com/stonith404/pocket-id/commit/a1985ce1b200550e91c5cb42a8d19899dcec831e))
* add version information to footer and update link if new update is available ([70ad0b4](https://github.com/stonith404/pocket-id/commit/70ad0b4f39699fd81ffdfd5c8d6839f49348be78))
### Bug Fixes
* cache version information for 3 hours ([29d632c](https://github.com/stonith404/pocket-id/commit/29d632c1514d6edacdfebe6deae4c95fc5a0f621))
* improve text for initial admin account setup ([0a07344](https://github.com/stonith404/pocket-id/commit/0a0734413943b1fff27d8f4ccf07587e207e2189))
* increase callback url count ([f3f0e1d](https://github.com/stonith404/pocket-id/commit/f3f0e1d56d7656bdabbd745a4eaf967f63193b6c))
* no DTO was returned from exchange one time access token endpoint ([824c5cb](https://github.com/stonith404/pocket-id/commit/824c5cb4f3d6be7f940c1758112fbe9322df5768))
## [](https://github.com/stonith404/pocket-id/compare/v0.8.1...v) (2024-10-18)
### Features
* add environment variable to change the caddy port in Docker ([ff06bf0](https://github.com/stonith404/pocket-id/commit/ff06bf0b34496ce472ba6d3ebd4ea249f21c0ec3))
* use improve table for users and audit logs ([11ed661](https://github.com/stonith404/pocket-id/commit/11ed661f86a512f78f66d604a10c1d47d39f2c39))
### Bug Fixes
* allow copy to clipboard for client secret ([29748cc](https://github.com/stonith404/pocket-id/commit/29748cc6c7b7e5a6b54bfe837e0b1a98fa1ad594))
## [](https://github.com/stonith404/pocket-id/compare/v0.8.0...v) (2024-10-11) ## [](https://github.com/stonith404/pocket-id/compare/v0.8.0...v) (2024-10-11)

View File

@@ -36,6 +36,7 @@ COPY --from=backend-builder /app/backend/email-templates ./backend/email-templat
COPY --from=backend-builder /app/backend/images ./backend/images COPY --from=backend-builder /app/backend/images ./backend/images
COPY ./scripts ./scripts COPY ./scripts ./scripts
RUN chmod +x ./scripts/*.sh
EXPOSE 3000 EXPOSE 3000
ENV APP_ENV=production ENV APP_ENV=production

View File

@@ -68,7 +68,7 @@ Required tools:
cd .. cd ..
pm2 start pocket-id-backend --name pocket-id-backend pm2 start pocket-id-backend --name pocket-id-backend
# Optional: Download the GeoLite2 city database. # Optional: Download the GeoLite2 city database.
# If not downloaded the ip location in the audit log will be empty. # If not downloaded the ip location in the audit log will be empty.
MAXMIND_LICENSE_KEY=<your-key> sh scripts/download-ip-database.sh MAXMIND_LICENSE_KEY=<your-key> sh scripts/download-ip-database.sh
@@ -151,15 +151,16 @@ docker compose up -d
### Environment variables ### Environment variables
| Variable | Default Value | Recommended to change | Description | | Variable | Default Value | Recommended to change | Description |
| ---------------------- | ----------------------- | --------------------- | --------------------------------------------- | | ---------------------- | ----------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PUBLIC_APP_URL` | `http://localhost` | yes | The URL where you will access the app. | | `PUBLIC_APP_URL` | `http://localhost` | yes | The URL where you will access the app. |
| `TRUST_PROXY` | `false` | yes | Whether the app is behind a reverse proxy. | | `TRUST_PROXY` | `false` | yes | Whether the app is behind a reverse proxy. |
| `DB_PATH` | `data/pocket-id.db` | no | The path to the SQLite database. | | `DB_PATH` | `data/pocket-id.db` | no | The path to the SQLite database. |
| `UPLOAD_PATH` | `data/uploads` | no | The path where the uploaded files are stored. | | `UPLOAD_PATH` | `data/uploads` | no | The path where the uploaded files are stored. |
| `INTERNAL_BACKEND_URL` | `http://localhost:8080` | no | The URL where the backend is accessible. | | `INTERNAL_BACKEND_URL` | `http://localhost:8080` | no | The URL where the backend is accessible. |
| `PORT` | `3000` | no | The port on which the frontend should listen. | | `CADDY_PORT` | `80` | no | The port on which Caddy should listen. Caddy is only active inside the Docker container. If you want to change the exposed port of the container then you sould change this variable. |
| `BACKEND_PORT` | `8080` | no | The port on which the backend 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. |
## Contribute ## Contribute

View File

@@ -18,7 +18,7 @@ func NewOidcController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt
group.POST("/oidc/authorize", jwtAuthMiddleware.Add(false), oc.authorizeHandler) group.POST("/oidc/authorize", jwtAuthMiddleware.Add(false), oc.authorizeHandler)
group.POST("/oidc/authorize/new-client", jwtAuthMiddleware.Add(false), oc.authorizeNewClientHandler) group.POST("/oidc/authorize/new-client", jwtAuthMiddleware.Add(false), oc.authorizeNewClientHandler)
group.POST("/oidc/token", oc.createIDTokenHandler) group.POST("/oidc/token", oc.createTokensHandler)
group.GET("/oidc/userinfo", oc.userInfoHandler) group.GET("/oidc/userinfo", oc.userInfoHandler)
group.GET("/oidc/clients", jwtAuthMiddleware.Add(true), oc.listClientsHandler) group.GET("/oidc/clients", jwtAuthMiddleware.Add(true), oc.listClientsHandler)
@@ -91,7 +91,7 @@ func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
c.JSON(http.StatusOK, response) c.JSON(http.StatusOK, response)
} }
func (oc *OidcController) createIDTokenHandler(c *gin.Context) { func (oc *OidcController) createTokensHandler(c *gin.Context) {
var input dto.OidcIdTokenDto var input dto.OidcIdTokenDto
if err := c.ShouldBind(&input); err != nil { if err := c.ShouldBind(&input); err != nil {

View File

@@ -161,8 +161,14 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
return return
} }
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err)
return
}
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true) c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
c.JSON(http.StatusOK, user) c.JSON(http.StatusOK, userDto)
} }
func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) { func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {

View File

@@ -37,7 +37,7 @@ func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
"userinfo_endpoint": appUrl + "/api/oidc/userinfo", "userinfo_endpoint": appUrl + "/api/oidc/userinfo",
"jwks_uri": appUrl + "/.well-known/jwks.json", "jwks_uri": appUrl + "/.well-known/jwks.json",
"scopes_supported": []string{"openid", "profile", "email"}, "scopes_supported": []string{"openid", "profile", "email"},
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "preferred_username"}, "claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username"},
"response_types_supported": []string{"code", "id_token"}, "response_types_supported": []string{"code", "id_token"},
"subject_types_supported": []string{"public"}, "subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256"}, "id_token_signing_alg_values_supported": []string{"RS256"},

View File

@@ -14,6 +14,7 @@ type AppConfigVariableDto struct {
type AppConfigUpdateDto struct { type AppConfigUpdateDto struct {
AppName string `json:"appName" binding:"required,min=1,max=30"` AppName string `json:"appName" binding:"required,min=1,max=30"`
SessionDuration string `json:"sessionDuration" binding:"required"` SessionDuration string `json:"sessionDuration" binding:"required"`
EmailsVerified string `json:"emailsVerified" binding:"required"`
EmailEnabled string `json:"emailEnabled" binding:"required"` EmailEnabled string `json:"emailEnabled" binding:"required"`
SmtHost string `json:"smtpHost"` SmtHost string `json:"smtpHost"`
SmtpPort string `json:"smtpPort"` SmtpPort string `json:"smtpPort"`

View File

@@ -2,7 +2,9 @@ package dto
import ( import (
"errors" "errors"
"github.com/stonith404/pocket-id/backend/internal/model/types"
"reflect" "reflect"
"time"
) )
// MapStructList maps a list of source structs to a list of destination structs // MapStructList maps a list of source structs to a list of destination structs
@@ -95,7 +97,18 @@ func mapStructInternal(sourceVal reflect.Value, destVal reflect.Value) error {
if err := mapStructInternal(sourceField, destField); err != nil { if err := mapStructInternal(sourceField, destField); err != nil {
return err return err
} }
} else {
// Type switch for specific type conversions
switch sourceField.Interface().(type) {
case datatype.DateTime:
// Convert datatype.DateTime to time.Time
if sourceField.Type() == reflect.TypeOf(datatype.DateTime{}) && destField.Type() == reflect.TypeOf(time.Time{}) {
dateValue := sourceField.Interface().(datatype.DateTime)
destField.Set(reflect.ValueOf(dateValue.ToTime()))
}
}
} }
} }
} }

View File

@@ -4,7 +4,6 @@ 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"
"github.com/stonith404/pocket-id/backend/internal/utils"
"gorm.io/gorm" "gorm.io/gorm"
"log" "log"
"time" "time"
@@ -30,22 +29,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 < ?", utils.FormatDateForDb(time.Now())).Error return j.db.Delete(&model.WebauthnSession{}, "expires_at < ?", time.Now().Unix()).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 < ?", utils.FormatDateForDb(time.Now())).Error return j.db.Debug().Delete(&model.OneTimeAccessToken{}, "expires_at < ?", time.Now().Unix()).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 < ?", utils.FormatDateForDb(time.Now())).Error return j.db.Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", time.Now().Unix()).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 < ?", utils.FormatDateForDb(time.Now().AddDate(0, 0, -90))).Error return j.db.Delete(&model.AuditLog{}, "created_at < ?", time.Now().AddDate(0, 0, -90).Unix()).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

@@ -14,6 +14,7 @@ type AppConfig struct {
LogoLightImageType AppConfigVariable LogoLightImageType AppConfigVariable
LogoDarkImageType AppConfigVariable LogoDarkImageType AppConfigVariable
SessionDuration AppConfigVariable SessionDuration AppConfigVariable
EmailsVerified AppConfigVariable
EmailEnabled AppConfigVariable EmailEnabled AppConfigVariable
SmtpHost AppConfigVariable SmtpHost AppConfigVariable

View File

@@ -2,6 +2,7 @@ package model
import ( import (
"github.com/google/uuid" "github.com/google/uuid"
model "github.com/stonith404/pocket-id/backend/internal/model/types"
"gorm.io/gorm" "gorm.io/gorm"
"time" "time"
) )
@@ -9,12 +10,13 @@ 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 time.Time CreatedAt model.DateTime
} }
func (b *Base) BeforeCreate(_ *gorm.DB) (err error) { func (b *Base) BeforeCreate(_ *gorm.DB) (err error) {
if b.ID == "" { if b.ID == "" {
b.ID = uuid.New().String() b.ID = uuid.New().String()
} }
b.CreatedAt = model.DateTime(time.Now())
return return
} }

View File

@@ -4,8 +4,8 @@ import (
"database/sql/driver" "database/sql/driver"
"encoding/json" "encoding/json"
"errors" "errors"
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
"gorm.io/gorm" "gorm.io/gorm"
"time"
) )
type UserAuthorizedOidcClient struct { type UserAuthorizedOidcClient struct {
@@ -23,7 +23,7 @@ type OidcAuthorizationCode struct {
Code string Code string
Scope string Scope string
Nonce string Nonce string
ExpiresAt time.Time ExpiresAt datatype.DateTime
UserID string UserID string
User User User User

View File

@@ -0,0 +1,47 @@
package datatype
import (
"database/sql/driver"
"time"
)
// DateTime custom type for time.Time to store date as unix timestamp in the database
type DateTime time.Time
func (date *DateTime) Scan(value interface{}) (err error) {
*date = DateTime(value.(time.Time))
return
}
func (date DateTime) Value() (driver.Value, error) {
return time.Time(date).Unix(), nil
}
func (date DateTime) UTC() time.Time {
return time.Time(date).UTC()
}
func (date DateTime) ToTime() time.Time {
return time.Time(date)
}
// GormDataType gorm common data type
func (date DateTime) GormDataType() string {
return "date"
}
func (date DateTime) GobEncode() ([]byte, error) {
return time.Time(date).GobEncode()
}
func (date *DateTime) GobDecode(b []byte) error {
return (*time.Time)(date).GobDecode(b)
}
func (date DateTime) MarshalJSON() ([]byte, error) {
return time.Time(date).MarshalJSON()
}
func (date *DateTime) UnmarshalJSON(b []byte) error {
return (*time.Time)(date).UnmarshalJSON(b)
}

View File

@@ -3,7 +3,7 @@ package model
import ( import (
"github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn" "github.com/go-webauthn/webauthn/webauthn"
"time" "github.com/stonith404/pocket-id/backend/internal/model/types"
) )
type User struct { type User struct {
@@ -61,7 +61,7 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
type OneTimeAccessToken struct { type OneTimeAccessToken struct {
Base Base
Token string Token string
ExpiresAt time.Time ExpiresAt datatype.DateTime
UserID string UserID string
User User User User

View File

@@ -41,6 +41,11 @@ var defaultDbConfig = model.AppConfig{
Type: "number", Type: "number",
Value: "60", Value: "60",
}, },
EmailsVerified: model.AppConfigVariable{
Key: "emailsVerified",
Type: "bool",
Value: "false",
},
BackgroundImageType: model.AppConfigVariable{ BackgroundImageType: model.AppConfigVariable{
Key: "backgroundImageType", Key: "backgroundImageType",
Type: "string", Type: "string",

View File

@@ -6,6 +6,7 @@ import (
"github.com/stonith404/pocket-id/backend/internal/common" "github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto" "github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/model" "github.com/stonith404/pocket-id/backend/internal/model"
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
"github.com/stonith404/pocket-id/backend/internal/utils" "github.com/stonith404/pocket-id/backend/internal/utils"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"gorm.io/gorm" "gorm.io/gorm"
@@ -115,7 +116,7 @@ func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret strin
return "", "", common.ErrOidcInvalidAuthorizationCode return "", "", common.ErrOidcInvalidAuthorizationCode
} }
if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.Before(time.Now()) { if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
return "", "", common.ErrOidcInvalidAuthorizationCode return "", "", common.ErrOidcInvalidAuthorizationCode
} }
@@ -314,6 +315,7 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
if strings.Contains(scope, "email") { if strings.Contains(scope, "email") {
claims["email"] = user.Email claims["email"] = user.Email
claims["email_verified"] = s.appConfigService.DbConfig.EmailsVerified.Value == "true"
} }
if strings.Contains(scope, "groups") { if strings.Contains(scope, "groups") {
@@ -350,7 +352,7 @@ func (s *OidcService) createAuthorizationCode(clientID string, userID string, sc
} }
oidcAuthorizationCode := model.OidcAuthorizationCode{ oidcAuthorizationCode := model.OidcAuthorizationCode{
ExpiresAt: time.Now().Add(15 * time.Minute), ExpiresAt: datatype.DateTime(time.Now().Add(15 * time.Minute)),
Code: randomString, Code: randomString,
ClientID: clientID, ClientID: clientID,
UserID: userID, UserID: userID,

View File

@@ -6,6 +6,7 @@ import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"github.com/fxamacker/cbor/v2" "github.com/fxamacker/cbor/v2"
"github.com/stonith404/pocket-id/backend/internal/model/types"
"log" "log"
"os" "os"
"time" "time"
@@ -111,7 +112,7 @@ func (s *TestService) SeedDatabase() error {
Code: "auth-code", Code: "auth-code",
Scope: "openid profile", Scope: "openid profile",
Nonce: "nonce", Nonce: "nonce",
ExpiresAt: time.Now().Add(1 * time.Hour), ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[0].ID, UserID: users[0].ID,
ClientID: oidcClients[0].ID, ClientID: oidcClients[0].ID,
} }
@@ -121,7 +122,7 @@ func (s *TestService) SeedDatabase() error {
accessToken := model.OneTimeAccessToken{ accessToken := model.OneTimeAccessToken{
Token: "one-time-token", Token: "one-time-token",
ExpiresAt: time.Now().Add(1 * time.Hour), ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[0].ID, UserID: users[0].ID,
} }
if err := tx.Create(&accessToken).Error; err != nil { if err := tx.Create(&accessToken).Error; err != nil {

View File

@@ -5,6 +5,7 @@ import (
"github.com/stonith404/pocket-id/backend/internal/common" "github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto" "github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/model" "github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/model/types"
"github.com/stonith404/pocket-id/backend/internal/utils" "github.com/stonith404/pocket-id/backend/internal/utils"
"gorm.io/gorm" "gorm.io/gorm"
"time" "time"
@@ -95,7 +96,7 @@ func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Tim
oneTimeAccessToken := model.OneTimeAccessToken{ oneTimeAccessToken := model.OneTimeAccessToken{
UserID: userID, UserID: userID,
ExpiresAt: expiresAt, ExpiresAt: datatype.DateTime(expiresAt),
Token: randomString, Token: randomString,
} }
@@ -108,7 +109,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, utils.FormatDateForDb(time.Now())).Preload("User").First(&oneTimeAccessToken).Error; err != nil { if err := s.db.Where("token = ? AND expires_at > ?", token, time.Now().Unix()).Preload("User").First(&oneTimeAccessToken).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, "", common.ErrTokenInvalidOrExpired return model.User{}, "", common.ErrTokenInvalidOrExpired
} }

View File

@@ -1,8 +0,0 @@
package utils
import "time"
func FormatDateForDb(time time.Time) string {
const layout = "2006-01-02 15:04:05.000-07:00"
return time.Format(layout)
}

View File

@@ -0,0 +1,28 @@
-- Convert the Unix timestamps back to DATETIME format
UPDATE user_groups
SET created_at = datetime(created_at, 'unixepoch');
UPDATE users
SET created_at = datetime(created_at, 'unixepoch');
UPDATE audit_logs
SET created_at = datetime(created_at, 'unixepoch');
UPDATE oidc_authorization_codes
SET created_at = datetime(created_at, 'unixepoch'),
expires_at = datetime(expires_at, 'unixepoch');
UPDATE oidc_clients
SET created_at = datetime(created_at, 'unixepoch');
UPDATE one_time_access_tokens
SET created_at = datetime(created_at, 'unixepoch'),
expires_at = datetime(expires_at, 'unixepoch');
UPDATE webauthn_credentials
SET created_at = datetime(created_at, 'unixepoch');
UPDATE webauthn_sessions
SET created_at = datetime(created_at, 'unixepoch'),
expires_at = datetime(expires_at, 'unixepoch');

View File

@@ -0,0 +1,27 @@
-- Convert the DATETIME fields to Unix timestamps (in seconds)
UPDATE user_groups
SET created_at = strftime('%s', created_at);
UPDATE users
SET created_at = strftime('%s', created_at);
UPDATE audit_logs
SET created_at = strftime('%s', created_at);
UPDATE oidc_authorization_codes
SET created_at = strftime('%s', created_at),
expires_at = strftime('%s', expires_at);
UPDATE oidc_clients
SET created_at = strftime('%s', created_at);
UPDATE one_time_access_tokens
SET created_at = strftime('%s', created_at),
expires_at = strftime('%s', expires_at);
UPDATE webauthn_credentials
SET created_at = strftime('%s', created_at);
UPDATE webauthn_sessions
SET created_at = strftime('%s', created_at),
expires_at = strftime('%s', expires_at);

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "pocket-id-frontend", "name": "pocket-id-frontend",
"version": "0.0.1", "version": "0.11.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev --port 3000", "dev": "vite dev --port 3000",
@@ -12,31 +12,31 @@
"format": "prettier --write ." "format": "prettier --write ."
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.47.2", "@playwright/test": "^1.48.1",
"@sveltejs/adapter-auto": "^3.2.5", "@sveltejs/adapter-auto": "^3.3.0",
"@sveltejs/adapter-node": "^5.2.5", "@sveltejs/adapter-node": "^5.2.8",
"@sveltejs/kit": "^2.6.1", "@sveltejs/kit": "^2.7.2",
"@sveltejs/vite-plugin-svelte": "^3.1.2", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"@types/eslint": "^9.6.1", "@types/eslint": "^9.6.1",
"@types/jsonwebtoken": "^9.0.7", "@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.7.4", "@types/node": "^22.7.9",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"cbor-js": "^0.1.0", "cbor-js": "^0.1.0",
"eslint": "^9.11.1", "eslint": "^9.13.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.44.1", "eslint-plugin-svelte": "^2.46.0",
"globals": "^15.10.0", "globals": "^15.11.0",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"prettier-plugin-svelte": "^3.2.7", "prettier-plugin-svelte": "^3.2.7",
"prettier-plugin-tailwindcss": "^0.6.8", "prettier-plugin-tailwindcss": "^0.6.8",
"svelte": "^5.0.0-next.262", "svelte": "^5.0.5",
"svelte-check": "^4.0.4", "svelte-check": "^4.0.5",
"tailwindcss": "^3.4.13", "tailwindcss": "^3.4.14",
"tslib": "^2.7.0", "tslib": "^2.8.0",
"typescript": "^5.6.2", "typescript": "^5.6.3",
"typescript-eslint": "^8.8.0", "typescript-eslint": "^8.11.0",
"vite": "^5.4.8" "vite": "^5.4.10"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
@@ -47,11 +47,11 @@
"crypto": "^1.0.1", "crypto": "^1.0.1",
"formsnap": "^1.0.1", "formsnap": "^1.0.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lucide-svelte": "^0.447.0", "lucide-svelte": "^0.453.0",
"mode-watcher": "^0.4.1", "mode-watcher": "^0.4.1",
"svelte-sonner": "^0.3.28", "svelte-sonner": "^0.3.28",
"sveltekit-superforms": "^2.19.0", "sveltekit-superforms": "^2.20.0",
"tailwind-merge": "^2.5.3", "tailwind-merge": "^2.5.4",
"tailwind-variants": "^0.2.1", "tailwind-variants": "^0.2.1",
"zod": "^3.23.8" "zod": "^3.23.8"
} }

View File

@@ -11,12 +11,14 @@
let { let {
items, items,
selectedIds = $bindable(), selectedIds = $bindable(),
withoutSearch = false,
fetchItems, fetchItems,
columns, columns,
rows rows
}: { }: {
items: Paginated<T>; items: Paginated<T>;
selectedIds?: string[]; selectedIds?: string[];
withoutSearch?: boolean;
fetchItems: (search: string, page: number, limit: number) => Promise<Paginated<T>>; fetchItems: (search: string, page: number, limit: number) => Promise<Paginated<T>>;
columns: (string | { label: string; hidden?: boolean })[]; columns: (string | { label: string; hidden?: boolean })[];
rows: Snippet<[{ item: T }]>; rows: Snippet<[{ item: T }]>;
@@ -65,12 +67,14 @@
</script> </script>
<div class="w-full"> <div class="w-full">
{#if !withoutSearch}
<Input <Input
class="mb-4 max-w-sm" class="mb-4 max-w-sm"
placeholder={'Search...'} placeholder={'Search...'}
type="text" type="text"
oninput={(e) => onSearch((e.target as HTMLInputElement).value)} oninput={(e) => onSearch((e.target as HTMLInputElement).value)}
/> />
{/if}
<Table.Root> <Table.Root>
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>

View File

@@ -1,4 +1,6 @@
import { version as currentVersion } from '$app/environment';
import type { AllAppConfig, AppConfigRawResponse } from '$lib/types/application-configuration'; import type { AllAppConfig, AppConfigRawResponse } from '$lib/types/application-configuration';
import axios from 'axios';
import APIService from './api-service'; import APIService from './api-service';
export default class AppConfigService extends APIService { export default class AppConfigService extends APIService {
@@ -12,14 +14,19 @@ export default class AppConfigService extends APIService {
const appConfig: Partial<AllAppConfig> = {}; const appConfig: Partial<AllAppConfig> = {};
data.forEach(({ key, value }) => { data.forEach(({ key, value }) => {
(appConfig as any)[key] = value; (appConfig as any)[key] = this.parseValue(value);
}); });
return appConfig as AllAppConfig; return appConfig as AllAppConfig;
} }
async update(appConfig: AllAppConfig) { async update(appConfig: AllAppConfig) {
const res = await this.api.put('/application-configuration', appConfig); // Convert all values to string
const appConfigConvertedToString = {};
for (const key in appConfig) {
(appConfigConvertedToString as any)[key] = (appConfig as any)[key].toString();
}
const res = await this.api.put('/application-configuration', appConfigConvertedToString);
return res.data as AllAppConfig; return res.data as AllAppConfig;
} }
@@ -45,4 +52,31 @@ export default class AppConfigService extends APIService {
await this.api.put(`/application-configuration/background-image`, formData); await this.api.put(`/application-configuration/background-image`, formData);
} }
async getVersionInformation() {
const response = (
await axios.get('https://api.github.com/repos/stonith404/pocket-id/releases/latest')
).data;
const newestVersion = response.tag_name.replace('v', '');
const isUpToDate = newestVersion === currentVersion;
return {
isUpToDate,
newestVersion,
currentVersion
};
}
private parseValue(value: string) {
if (value === 'true') {
return true;
} else if (value === 'false') {
return false;
} else if (!isNaN(Number(value))) {
return Number(value);
} else {
return value;
}
}
} }

View File

@@ -1,18 +1,26 @@
export type AllAppConfig = { export type AppConfig = {
appName: string; appName: string;
sessionDuration: string; };
emailEnabled: string;
export type AllAppConfig = AppConfig & {
sessionDuration: number;
emailsVerified: boolean;
emailEnabled: boolean;
smtpHost: string; smtpHost: string;
smtpPort: string; smtpPort: number;
smtpFrom: string; smtpFrom: string;
smtpUser: string; smtpUser: string;
smtpPassword: string; smtpPassword: string;
}; };
export type AppConfig = AllAppConfig;
export type AppConfigRawResponse = { export type AppConfigRawResponse = {
key: string; key: string;
type: string; type: string;
value: string; value: string;
}[]; }[];
export type AppVersionInformation = {
isUpToDate: boolean;
newestVersion: string;
currentVersion: string;
};

View File

@@ -1,5 +1,5 @@
export function debounced<T extends (...args: any[]) => void>(func: T, delay: number) { export function debounced<T extends (...args: any[]) => void>(func: T, delay: number) {
let debounceTimeout: number | undefined; let debounceTimeout: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => { return (...args: Parameters<T>) => {
if (debounceTimeout !== undefined) { if (debounceTimeout !== undefined) {
@@ -10,4 +10,4 @@ export function debounced<T extends (...args: any[]) => void>(func: T, delay: nu
func(...args); func(...args);
}, delay); }, delay);
}; };
} }

View File

@@ -33,11 +33,19 @@
<Logo class="h-10 w-10" /> <Logo class="h-10 w-10" />
</div> </div>
</div> </div>
<h1 class="font-playfair mt-5 text-4xl font-bold">One Time Access</h1> <h1 class="font-playfair mt-5 text-4xl font-bold">
{data.token === 'setup' ? `${$appConfigStore.appName} Setup` : 'One Time Access'}
</h1>
<p class="text-muted-foreground mt-2"> <p class="text-muted-foreground mt-2">
You've been granted one-time access to your {$appConfigStore.appName} account. Please note that if {#if data.token === 'setup'}
you continue, this link will become invalid. To avoid this, make sure to add a passkey. Otherwise, You're about to sign in to the initial admin account. Anyone with this link can access the
you'll need to request a new link. account until a passkey is added. Please set up a passkey as soon as possible to prevent
unauthorized access.
{:else}
You've been granted one-time access to your {$appConfigStore.appName} account. Please note that
if you continue, this link will become invalid. To avoid this, make sure to add a passkey. Otherwise,
you'll need to request a new link.
{/if}
</p> </p>
<Button class="mt-5" {isLoading} on:click={authenticate}>Continue</Button> <Button class="mt-5" {isLoading} on:click={authenticate}>Continue</Button>
</SignInWrapper> </SignInWrapper>

View File

@@ -0,0 +1,24 @@
import AppConfigService from '$lib/services/app-config-service';
import type { AppVersionInformation } from '$lib/types/application-configuration';
import type { LayoutServerLoad } from './$types';
let versionInformation: AppVersionInformation;
let versionInformationLastUpdated: number;
export const load: LayoutServerLoad = async () => {
const appConfigService = new AppConfigService();
// Cache the version information for 3 hours
const cacheExpired =
versionInformationLastUpdated &&
Date.now() - versionInformationLastUpdated > 1000 * 60 * 60 * 3;
if (!versionInformation || cacheExpired) {
versionInformation = await appConfigService.getVersionInformation();
versionInformationLastUpdated = Date.now();
}
return {
versionInformation
};
};

View File

@@ -1,14 +1,20 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import userStore from '$lib/stores/user-store'; import userStore from '$lib/stores/user-store';
import { LucideExternalLink } from 'lucide-svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { LayoutData } from './$types';
let { let {
children children,
data
}: { }: {
children: Snippet; children: Snippet;
data: LayoutData;
} = $props(); } = $props();
const { versionInformation } = data;
let links = $state([ let links = $state([
{ href: '/settings/account', label: 'My Account' }, { href: '/settings/account', label: 'My Account' },
{ href: '/settings/audit-log', label: 'Audit Log' } { href: '/settings/audit-log', label: 'Audit Log' }
@@ -26,8 +32,10 @@
</script> </script>
<section> <section>
<div class="bg-muted/40 min-h-screen w-full"> <div class="bg-muted/40 flex min-h-[calc(100vh-64px)] w-full flex-col justify-between">
<main class="mx-auto flex max-w-[1640px] flex-col gap-x-4 gap-y-10 p-4 md:p-10 lg:flex-row"> <main
class="mx-auto flex w-full max-w-[1640px] flex-col gap-x-4 gap-y-10 p-4 md:p-10 lg:flex-row"
>
<div> <div>
<div class="mx-auto grid w-full gap-2"> <div class="mx-auto grid w-full gap-2">
<h1 class="mb-5 text-3xl font-semibold">Settings</h1> <h1 class="mb-5 text-3xl font-semibold">Settings</h1>
@@ -41,6 +49,15 @@
{label} {label}
</a> </a>
{/each} {/each}
{#if $userStore?.isAdmin && !versionInformation.isUpToDate}
<a
href="https://github.com/stonith404/pocket-id/releases/latest"
target="_blank"
class="flex items-center gap-2"
>
Update Pocket ID <LucideExternalLink class="my-auto inline-block h-3 w-3" />
</a>
{/if}
</nav> </nav>
</div> </div>
</div> </div>
@@ -48,5 +65,15 @@
{@render children()} {@render children()}
</div> </div>
</main> </main>
<div class="flex flex-col items-center">
<p class="text-muted-foreground py-3 text-xs">
Powered by <a
class="text-foreground"
href="https://github.com/stonith404/pocket-id"
target="_blank">Pocket ID</a
>
({versionInformation.currentVersion})
</p>
</div>
</div> </div>
</section> </section>

View File

@@ -15,10 +15,10 @@
} = $props(); } = $props();
let isLoading = $state(false); let isLoading = $state(false);
let emailEnabled = $state(appConfig.emailEnabled == 'true'); let emailEnabled = $state(appConfig.emailEnabled);
const updatedAppConfig = { const updatedAppConfig = {
emailEnabled: emailEnabled.toString(), emailEnabled: appConfig.emailEnabled,
smtpHost: appConfig.smtpHost, smtpHost: appConfig.smtpHost,
smtpPort: appConfig.smtpPort, smtpPort: appConfig.smtpPort,
smtpUser: appConfig.smtpUser, smtpUser: appConfig.smtpUser,
@@ -28,13 +28,13 @@
const formSchema = z.object({ const formSchema = z.object({
smtpHost: z.string().min(1), smtpHost: z.string().min(1),
smtpPort: z.string().min(1), smtpPort: z.number().min(1),
smtpUser: z.string().min(1), smtpUser: z.string().min(1),
smtpPassword: z.string().min(1), smtpPassword: z.string().min(1),
smtpFrom: z.string().email() smtpFrom: z.string().email()
}); });
const { inputs, ...form } = createForm< typeof formSchema>(formSchema, updatedAppConfig); const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
async function onSubmit() { async function onSubmit() {
const data = form.validate(); const data = form.validate();
@@ -42,15 +42,15 @@
isLoading = true; isLoading = true;
await callback({ await callback({
...data, ...data,
emailEnabled: 'true' emailEnabled: true
}).finally(() => (isLoading = false)); }).finally(() => (isLoading = false));
toast.success('Email configuration updated successfully'); toast.success('Email configuration updated successfully');
return true; return true;
} }
async function onDisable() { async function onDisable() {
await callback({ emailEnabled: 'false' });
emailEnabled = false; emailEnabled = false;
await callback({ emailEnabled });
toast.success('Email disabled successfully'); toast.success('Email disabled successfully');
} }
@@ -64,7 +64,7 @@
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
<div class="mt-5 grid grid-cols-2 gap-5"> <div class="mt-5 grid grid-cols-2 gap-5">
<FormInput label="SMTP Host" bind:input={$inputs.smtpHost} /> <FormInput label="SMTP Host" bind:input={$inputs.smtpHost} />
<FormInput label="SMTP Port" bind:input={$inputs.smtpPort} /> <FormInput label="SMTP Port" type="number" bind:input={$inputs.smtpPort} />
<FormInput label="SMTP User" bind:input={$inputs.smtpUser} /> <FormInput label="SMTP User" bind:input={$inputs.smtpUser} />
<FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} /> <FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} />
<FormInput label="SMTP From" bind:input={$inputs.smtpFrom} /> <FormInput label="SMTP From" bind:input={$inputs.smtpFrom} />

View File

@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import FormInput from '$lib/components/form-input.svelte'; import FormInput from '$lib/components/form-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label';
import type { AllAppConfig } from '$lib/types/application-configuration'; import type { AllAppConfig } from '$lib/types/application-configuration';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
@@ -18,20 +20,14 @@
const updatedAppConfig = { const updatedAppConfig = {
appName: appConfig.appName, appName: appConfig.appName,
sessionDuration: appConfig.sessionDuration sessionDuration: appConfig.sessionDuration,
emailsVerified: appConfig.emailsVerified
}; };
const formSchema = z.object({ const formSchema = z.object({
appName: z.string().min(2).max(30), appName: z.string().min(2).max(30),
sessionDuration: z.string().refine( sessionDuration: z.number().min(1).max(43200),
(val) => { emailsVerified: z.boolean()
const num = Number(val);
return Number.isInteger(num) && num >= 1 && num <= 43200;
},
{
message: 'Session duration must be between 1 and 43200 minutes'
}
)
}); });
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig); const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
@@ -49,9 +45,21 @@
<FormInput label="Application Name" bind:input={$inputs.appName} /> <FormInput label="Application Name" bind:input={$inputs.appName} />
<FormInput <FormInput
label="Session Duration" label="Session Duration"
type="number"
description="The duration of a session in minutes before the user has to sign in again." description="The duration of a session in minutes before the user has to sign in again."
bind:input={$inputs.sessionDuration} bind:input={$inputs.sessionDuration}
/> />
<div class="items-top mt-5 flex space-x-2">
<Checkbox id="admin-privileges" bind:checked={$inputs.emailsVerified.value} />
<div class="grid gap-1.5 leading-none">
<Label for="admin-privileges" class="mb-0 text-sm font-medium leading-none">
Emails Verified
</Label>
<p class="text-muted-foreground text-[0.8rem]">
Whether the user's email should be marked as verified for the OIDC clients.
</p>
</div>
</div>
</div> </div>
<div class="mt-5 flex justify-end"> <div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button> <Button {isLoading} type="submit">Save</Button>

View File

@@ -26,7 +26,7 @@
'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`, 'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`,
'Token URL': `https://${$page.url.hostname}/api/oidc/token`, 'Token URL': `https://${$page.url.hostname}/api/oidc/token`,
'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`, 'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`,
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`, 'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`
}; };
async function updateClient(updatedClient: OidcClientCreateWithLogo) { async function updateClient(updatedClient: OidcClientCreateWithLogo) {
@@ -95,10 +95,16 @@
</div> </div>
<div class="mb-2 mt-1 flex items-center"> <div class="mb-2 mt-1 flex items-center">
<Label class="w-44">Client secret</Label> <Label class="w-44">Client secret</Label>
<span class="text-muted-foreground text-sm" data-testid="client-secret" {#if $clientSecretStore}
>{$clientSecretStore ?? '••••••••••••••••••••••••••••••••'}</span <CopyToClipboard value={$clientSecretStore}>
> <span class="text-muted-foreground text-sm" data-testid="client-secret">
{#if !$clientSecretStore} {$clientSecretStore}
</span>
</CopyToClipboard>
{:else}
<span class="text-muted-foreground text-sm" data-testid="client-secret"
>••••••••••••••••••••••••••••••••</span
>
<Button <Button
class="ml-2" class="ml-2"
onclick={createClientSecret} onclick={createClientSecret}

View File

@@ -16,7 +16,7 @@
children?: Snippet; children?: Snippet;
} = $props(); } = $props();
const limit = 5; const limit = 20;
</script> </script>
<div {...restProps}> <div {...restProps}>
@@ -25,15 +25,15 @@
{#each callbackURLs as _, i} {#each callbackURLs as _, i}
<div class="flex gap-x-2"> <div class="flex gap-x-2">
<Input data-testid={`callback-url-${i + 1}`} bind:value={callbackURLs[i]} /> <Input data-testid={`callback-url-${i + 1}`} bind:value={callbackURLs[i]} />
{#if callbackURLs.length > 1} {#if callbackURLs.length > 1}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
on:click={() => callbackURLs = callbackURLs.filter((_, index) => index !== i)} on:click={() => (callbackURLs = callbackURLs.filter((_, index) => index !== i))}
> >
<LucideMinus class="h-4 w-4" /> <LucideMinus class="h-4 w-4" />
</Button> </Button>
{/if} {/if}
</div> </div>
{/each} {/each}
</div> </div>
@@ -46,7 +46,7 @@
class="mt-2" class="mt-2"
variant="secondary" variant="secondary"
size="sm" size="sm"
on:click={() => callbackURLs = [...callbackURLs, '']} on:click={() => (callbackURLs = [...callbackURLs, ''])}
> >
<LucidePlus class="mr-1 h-4 w-4" /> <LucidePlus class="mr-1 h-4 w-4" />
Add another Add another

View File

@@ -1,16 +1,14 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import AdvancedTable from '$lib/components/advanced-table.svelte';
import { openConfirmDialog } from '$lib/components/confirm-dialog/'; import { openConfirmDialog } from '$lib/components/confirm-dialog/';
import { Badge } from '$lib/components/ui/badge/index'; import { Badge } from '$lib/components/ui/badge/index';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Input } from '$lib/components/ui/input';
import * as Pagination from '$lib/components/ui/pagination';
import * as Table from '$lib/components/ui/table'; import * as Table from '$lib/components/ui/table';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import type { Paginated, PaginationRequest } from '$lib/types/pagination.type'; import type { Paginated } from '$lib/types/pagination.type';
import type { User } from '$lib/types/user.type'; import type { User } from '$lib/types/user.type';
import { debounced } from '$lib/utils/debounce-util';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideLink, LucidePencil, LucideTrash } from 'lucide-svelte'; import { LucideLink, LucidePencil, LucideTrash } from 'lucide-svelte';
import Ellipsis from 'lucide-svelte/icons/ellipsis'; import Ellipsis from 'lucide-svelte/icons/ellipsis';
@@ -19,23 +17,17 @@
let { users: initialUsers }: { users: Paginated<User> } = $props(); let { users: initialUsers }: { users: Paginated<User> } = $props();
let users = $state<Paginated<User>>(initialUsers); let users = $state<Paginated<User>>(initialUsers);
let oneTimeLink = $state<string | null>(null);
$effect(() => { $effect(() => {
users = initialUsers; users = initialUsers;
}); });
let oneTimeLink = $state<string | null>(null);
const userService = new UserService(); const userService = new UserService();
let pagination = $state<PaginationRequest>({ function fetchItems(search: string, page: number, limit: number) {
page: 1, return userService.list(search, { page, limit });
limit: 10 }
});
let search = $state('');
const debouncedSearch = debounced(async (searchValue: string) => {
users = await userService.list(searchValue, pagination);
}, 400);
async function deleteUser(user: User) { async function deleteUser(user: User) {
openConfirmDialog({ openConfirmDialog({
@@ -47,7 +39,7 @@
action: async () => { action: async () => {
try { try {
await userService.remove(user.id); await userService.remove(user.id);
users = await userService.list(search, pagination); users = await userService.list();
} catch (e) { } catch (e) {
axiosErrorToast(e); axiosErrorToast(e);
} }
@@ -67,105 +59,51 @@
} }
</script> </script>
<Input <AdvancedTable
type="search" items={users}
placeholder="Search users" {fetchItems}
bind:value={search} columns={[
on:input={(e) => debouncedSearch((e.target as HTMLInputElement).value)} 'First name',
/> 'Last name',
<Table.Root> 'Email',
<Table.Header> 'Username',
<Table.Row> 'Role',
<Table.Head class="hidden md:table-cell">First name</Table.Head> { label: 'Actions', hidden: true }
<Table.Head class="hidden md:table-cell">Last name</Table.Head> ]}
<Table.Head>Email</Table.Head> withoutSearch
<Table.Head>Username</Table.Head> >
<Table.Head class="hidden lg:table-cell">Role</Table.Head> {#snippet rows({ item })}
<Table.Head> <Table.Cell>{item.firstName}</Table.Cell>
<span class="sr-only">Actions</span> <Table.Cell>{item.lastName}</Table.Cell>
</Table.Head> <Table.Cell>{item.email}</Table.Cell>
</Table.Row> <Table.Cell>{item.username}</Table.Cell>
</Table.Header> <Table.Cell class="hidden lg:table-cell">
<Table.Body> <Badge variant="outline">{item.isAdmin ? 'Admin' : 'User'}</Badge>
{#if users.data.length === 0} </Table.Cell>
<Table.Row> <Table.Cell>
<Table.Cell colspan={6} class="text-center">No users found</Table.Cell> <DropdownMenu.Root>
</Table.Row> <DropdownMenu.Trigger asChild let:builder>
{:else} <Button aria-haspopup="true" size="icon" variant="ghost" builders={[builder]}>
{#each users.data as user} <Ellipsis class="h-4 w-4" />
<Table.Row> <span class="sr-only">Toggle menu</span>
<Table.Cell class="hidden md:table-cell">{user.firstName}</Table.Cell> </Button>
<Table.Cell class="hidden md:table-cell">{user.lastName}</Table.Cell> </DropdownMenu.Trigger>
<Table.Cell>{user.email}</Table.Cell> <DropdownMenu.Content align="end">
<Table.Cell>{user.username}</Table.Cell> <DropdownMenu.Item on:click={() => createOneTimeAccessToken(item.id)}
<Table.Cell class="hidden lg:table-cell"> ><LucideLink class="mr-2 h-4 w-4" />One-time link</DropdownMenu.Item
<Badge variant="outline">{user.isAdmin ? 'Admin' : 'User'}</Badge> >
</Table.Cell> <DropdownMenu.Item href="/settings/admin/users/{item.id}"
<Table.Cell> ><LucidePencil class="mr-2 h-4 w-4" /> Edit</DropdownMenu.Item
<DropdownMenu.Root> >
<DropdownMenu.Trigger asChild let:builder> <DropdownMenu.Item
<Button aria-haspopup="true" size="icon" variant="ghost" builders={[builder]}> class="text-red-500 focus:!text-red-700"
<Ellipsis class="h-4 w-4" /> on:click={() => deleteUser(item)}
<span class="sr-only">Toggle menu</span> ><LucideTrash class="mr-2 h-4 w-4" />Delete</DropdownMenu.Item
</Button> >
</DropdownMenu.Trigger> </DropdownMenu.Content>
<DropdownMenu.Content align="end"> </DropdownMenu.Root>
<DropdownMenu.Item on:click={() => createOneTimeAccessToken(user.id)} </Table.Cell>
><LucideLink class="mr-2 h-4 w-4" />One-time link</DropdownMenu.Item {/snippet}
> </AdvancedTable>
<DropdownMenu.Item href="/settings/admin/users/{user.id}"
><LucidePencil class="mr-2 h-4 w-4" /> Edit</DropdownMenu.Item
>
<DropdownMenu.Item
class="text-red-500 focus:!text-red-700"
on:click={() => deleteUser(user)}
><LucideTrash class="mr-2 h-4 w-4" />Delete</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
{#if users?.data?.length ?? 0 > 0}
<Pagination.Root
class="mt-5"
count={users.pagination.totalItems}
perPage={pagination.limit}
onPageChange={async (p) =>
(users = await userService.list(search, {
page: p,
limit: pagination.limit
}))}
bind:page={users.pagination.currentPage}
let:pages
let:currentPage
>
<Pagination.Content class="flex justify-end">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type === 'ellipsis'}
<Pagination.Item>
<Pagination.Ellipsis />
</Pagination.Item>
{:else}
<Pagination.Item>
<Pagination.Link {page} isActive={users.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
</Pagination.Root>
{/if}
<OneTimeLinkModal {oneTimeLink} /> <OneTimeLinkModal {oneTimeLink} />

View File

@@ -1,20 +1,22 @@
<script lang="ts"> <script lang="ts">
import AdvancedTable from '$lib/components/advanced-table.svelte';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
import * as Pagination from '$lib/components/ui/pagination';
import * as Table from '$lib/components/ui/table'; import * as Table from '$lib/components/ui/table';
import AuditLogService from '$lib/services/audit-log-service'; import AuditLogService from '$lib/services/audit-log-service';
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 } from '$lib/types/pagination.type';
let { auditLogs: initialAuditLog }: { auditLogs: Paginated<AuditLog> } = $props(); let { auditLogs: initialAuditLog }: { auditLogs: Paginated<AuditLog> } = $props();
let auditLogs = $state<Paginated<AuditLog>>(initialAuditLog); let auditLogs = $state<Paginated<AuditLog>>(initialAuditLog);
const auditLogService = new AuditLogService(); const auditLogService = new AuditLogService();
let pagination = $state<PaginationRequest>({ async function fetchItems(search: string, page: number, limit: number) {
page: 1, return await auditLogService.list({
limit: 15 page,
}); limit
});
}
function toFriendlyEventString(event: string) { function toFriendlyEventString(event: string) {
const words = event.split('_'); const words = event.split('_');
@@ -25,73 +27,22 @@
} }
</script> </script>
<Table.Root> <AdvancedTable
<Table.Header class="whitespace-nowrap"> items={auditLogs}
<Table.Row> {fetchItems}
<Table.Head>Time</Table.Head> columns={['Time', 'Event', 'Approximate Location', 'IP Address', 'Device', 'Client']}
<Table.Head>Event</Table.Head> withoutSearch
<Table.Head>Approximate Location</Table.Head> >
<Table.Head>IP Address</Table.Head> {#snippet rows({ item })}
<Table.Head>Device</Table.Head> <Table.Cell>{new Date(item.createdAt).toLocaleString()}</Table.Cell>
<Table.Head>Client</Table.Head> <Table.Cell>
</Table.Row> <Badge variant="outline">{toFriendlyEventString(item.event)}</Badge>
</Table.Header> </Table.Cell>
<Table.Body class="whitespace-nowrap"> <Table.Cell
{#if auditLogs.data.length === 0} >{item.city && item.country ? `${item.city}, ${item.country}` : 'Unknown'}</Table.Cell
<Table.Row> >
<Table.Cell colspan={6} class="text-center">No logs found</Table.Cell> <Table.Cell>{item.ipAddress}</Table.Cell>
</Table.Row> <Table.Cell>{item.device}</Table.Cell>
{:else} <Table.Cell>{item.data.clientName}</Table.Cell>
{#each auditLogs.data as auditLog} {/snippet}
<Table.Row> </AdvancedTable>
<Table.Cell>{new Date(auditLog.createdAt).toLocaleString()}</Table.Cell>
<Table.Cell>
<Badge variant="outline">{toFriendlyEventString(auditLog.event)}</Badge>
</Table.Cell>
<Table.Cell>{auditLog.city && auditLog.country ? `${auditLog.city}, ${auditLog.country}` : 'Unknown'}</Table.Cell>
<Table.Cell>{auditLog.ipAddress}</Table.Cell>
<Table.Cell>{auditLog.device}</Table.Cell>
<Table.Cell>{auditLog.data.clientName}</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
{#if auditLogs?.data?.length ?? 0 > 0}
<Pagination.Root
class="mt-5"
count={auditLogs.pagination.totalItems}
perPage={pagination.limit}
onPageChange={async (p) =>
(auditLogs = await auditLogService.list({
page: p,
limit: pagination.limit
}))}
bind:page={auditLogs.pagination.currentPage}
let:pages
let:currentPage
>
<Pagination.Content class="flex justify-end">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type === 'ellipsis'}
<Pagination.Item>
<Pagination.Ellipsis />
</Pagination.Item>
{:else}
<Pagination.Item>
<Pagination.Link {page} isActive={auditLogs.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
</Pagination.Root>
{/if}

View File

@@ -1,5 +1,6 @@
import adapter from '@sveltejs/adapter-node'; import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import packageJson from "./package.json" assert { type: "json" };
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
@@ -12,6 +13,9 @@ const config = {
// If your environment is not supported, or you settled on a specific environment, switch out the adapter. // If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters. // See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter(), adapter: adapter(),
version: {
name: packageJson.version,
}
} }
}; };

View File

@@ -4,7 +4,7 @@ import { cleanupBackend } from './utils/cleanup.util';
test.beforeEach(cleanupBackend); test.beforeEach(cleanupBackend);
test('Create user group', async ({ page }) => { test('Create user group', async ({ page, baseURL }) => {
await page.goto('/settings/admin/user-groups'); await page.goto('/settings/admin/user-groups');
const group = userGroups.humanResources; const group = userGroups.humanResources;
@@ -14,7 +14,9 @@ test('Create user group', async ({ page }) => {
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('status')).toHaveText('User group created successfully'); await expect(page.getByRole('status')).toHaveText('User group created successfully');
expect(page.url()).toMatch(/\/settings\/admin\/user-groups\/[a-f0-9-]+/);
const expectedRoute = new RegExp(`${baseURL}/settings/admin/user-groups/[a-f0-9-]+`);
expect(page.url()).toMatch(expectedRoute);
await expect(page.getByLabel('Friendly Name')).toHaveValue(group.friendlyName); await expect(page.getByLabel('Friendly Name')).toHaveValue(group.friendlyName);
await expect(page.getByLabel('Name', { exact: true })).toHaveValue(group.name); await expect(page.getByLabel('Name', { exact: true })).toHaveValue(group.name);

View File

@@ -1,4 +1,4 @@
:80 { :{$CADDY_PORT:80} {
reverse_proxy /api/* http://localhost:{$BACKEND_PORT:8080} reverse_proxy /api/* http://localhost:{$BACKEND_PORT:8080}
reverse_proxy /.well-known/* http://localhost:{$BACKEND_PORT:8080} reverse_proxy /.well-known/* http://localhost:{$BACKEND_PORT:8080}
reverse_proxy /* http://localhost:{$PORT:3000} reverse_proxy /* http://localhost:{$PORT:3000}

View File

@@ -1,4 +1,4 @@
:80 { :{$CADDY_PORT:80} {
reverse_proxy /api/* http://localhost:{$BACKEND_PORT:8080} { reverse_proxy /api/* http://localhost:{$BACKEND_PORT:8080} {
trusted_proxies 0.0.0.0/0 trusted_proxies 0.0.0.0/0
} }

View File

@@ -0,0 +1,75 @@
# Default database path
DB_PATH="./backend/data/pocket-id.db"
# Parse command-line arguments for the -d flag (database path)
while getopts ":d:" opt; do
case $opt in
d)
DB_PATH="$OPTARG"
;;
\?)
echo "Invalid option -$OPTARG" >&2
exit 1
;;
esac
done
shift $((OPTIND - 1))
# Ensure username or email is provided as a parameter
if [ -z "$1" ]; then
echo "Usage: $0 [-d <database_path>] <username or email>"
echo " -d Specify the database path (optional, defaults to ./backend/data/pocket-id.db)"
exit 1
fi
USER_IDENTIFIER="$1"
# Check and try to install the required commands
check_and_install() {
local cmd=$1
local pkg=$2
if ! command -v "$cmd" &>/dev/null; then
if command -v apk &>/dev/null; then
echo "$cmd not found. Installing..."
apk add "$pkg" --no-cache
else
echo "$cmd is not installed, please install it manually."
exit 1
fi
fi
}
check_and_install sqlite3 sqlite
check_and_install uuidgen uuidgen
# Generate a 16-character alphanumeric secret token
SECRET_TOKEN=$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 16)
# Get the current Unix timestamp for creation and expiration (1 hour from now)
CREATED_AT=$(date +%s)
EXPIRES_AT=$((CREATED_AT + 3600))
# Retrieve user_id from the users table based on username or email
USER_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM users WHERE username='$USER_IDENTIFIER' OR email='$USER_IDENTIFIER';")
# Check if user exists
if [ -z "$USER_ID" ]; then
echo "User not found for username/email: $USER_IDENTIFIER"
exit 1
fi
# Insert the one-time token into the one_time_access_tokens table
sqlite3 "$DB_PATH" <<EOF
INSERT INTO one_time_access_tokens (id, created_at, token, expires_at, user_id)
VALUES ('$(uuidgen)', '$CREATED_AT', '$SECRET_TOKEN', '$EXPIRES_AT', '$USER_ID');
EOF
if [ $? -eq 0 ]; then
echo "A one-time access token valid for 1 hour has been created for \"$USER_IDENTIFIER\"."
echo "Use the following URL to sign in once: ${PUBLIC_APP_URL:=https://<your-pocket-id-domain>}/login/$SECRET_TOKEN"
else
echo "Error creating access token."
exit 1
fi

View File

@@ -6,7 +6,7 @@ increment_version() {
local version=$1 local version=$1
local part=$2 local part=$2
IFS='.' read -r -a parts <<< "$version" IFS='.' read -r -a parts <<<"$version"
if [ "$part" == "minor" ]; then if [ "$part" == "minor" ]; then
parts[1]=$((parts[1] + 1)) parts[1]=$((parts[1] + 1))
parts[2]=0 parts[2]=0
@@ -30,12 +30,15 @@ else
fi fi
# Update the .version file with the new version # Update the .version file with the new version
echo $NEW_VERSION > .version echo $NEW_VERSION >.version
git add .version git add .version
# Update version in frontend/package.json
jq --arg new_version "$NEW_VERSION" '.version = $new_version' frontend/package.json >frontend/package_tmp.json && mv frontend/package_tmp.json frontend/package.json
git add frontend/package.json
# Check if conventional-changelog is installed, if not install it # Check if conventional-changelog is installed, if not install it
if ! command -v conventional-changelog &> /dev/null if ! command -v conventional-changelog &>/dev/null; then
then
echo "conventional-changelog not found, installing..." echo "conventional-changelog not found, installing..."
npm install -g conventional-changelog-cli npm install -g conventional-changelog-cli
fi fi
@@ -55,4 +58,4 @@ git tag "v$NEW_VERSION"
git push git push
git push --tags git push --tags
echo "Release process complete. New version: $NEW_VERSION" echo "Release process complete. New version: $NEW_VERSION"