Compare commits

...

2 Commits

Author SHA1 Message Date
Elias Schneider
1f0ec08290 release: 0.5.0 2024-09-09 10:30:12 +02:00
Elias Schneider
9121239dd7 feat: add audit log with email notification (#26) 2024-09-09 10:29:41 +02:00
53 changed files with 952 additions and 164 deletions

View File

@@ -1 +1,2 @@
PUBLIC_APP_URL=http://localhost PUBLIC_APP_URL=http://localhost
TRUST_PROXY=false

View File

@@ -1 +1 @@
0.4.1 0.5.0

View File

@@ -1,3 +1,10 @@
## [](https://github.com/stonith404/pocket-id/compare/v0.4.1...v) (2024-09-09)
### Features
* add audit log with email notification ([#26](https://github.com/stonith404/pocket-id/issues/26)) ([9121239](https://github.com/stonith404/pocket-id/commit/9121239dd7c14a2107a984f9f94f54227489a63a))
## [](https://github.com/stonith404/pocket-id/compare/v0.4.0...v) (2024-09-06) ## [](https://github.com/stonith404/pocket-id/compare/v0.4.0...v) (2024-09-06)

View File

@@ -61,7 +61,7 @@ You're all set!
We use [Caddy](https://caddyserver.com) as a reverse proxy. You can use any other reverse proxy if you want but you have to configure it yourself. We use [Caddy](https://caddyserver.com) as a reverse proxy. You can use any other reverse proxy if you want but you have to configure it yourself.
#### Setup #### Setup
Run `caddy run --config Caddyfile` in the root folder. Run `caddy run --config reverse-proxy/Caddyfile` in the root folder.
### Testing ### Testing

View File

@@ -22,7 +22,7 @@ RUN CGO_ENABLED=1 GOOS=linux go build -o /app/backend/pocket-id-backend .
# Stage 3: Production Image # Stage 3: Production Image
FROM node:20-alpine FROM node:20-alpine
RUN apk add --no-cache caddy RUN apk add --no-cache caddy
COPY ./Caddyfile /etc/caddy/Caddyfile COPY ./reverse-proxy /etc/caddy/
WORKDIR /app WORKDIR /app
COPY --from=frontend-builder /app/frontend/build ./frontend/build COPY --from=frontend-builder /app/frontend/build ./frontend/build
@@ -31,6 +31,7 @@ COPY --from=frontend-builder /app/frontend/package.json ./frontend/package.json
COPY --from=backend-builder /app/backend/pocket-id-backend ./backend/pocket-id-backend COPY --from=backend-builder /app/backend/pocket-id-backend ./backend/pocket-id-backend
COPY --from=backend-builder /app/backend/migrations ./backend/migrations COPY --from=backend-builder /app/backend/migrations ./backend/migrations
COPY --from=backend-builder /app/backend/email-templates ./backend/email-templates
COPY --from=backend-builder /app/backend/images ./backend/images COPY --from=backend-builder /app/backend/images ./backend/images
COPY ./scripts ./scripts COPY ./scripts ./scripts

View File

@@ -147,6 +147,7 @@ docker compose up -d
| 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. |
| `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. |

View File

@@ -0,0 +1,119 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: Arial, sans-serif;
background-color: #f0f0f0;
color: #333;
margin: 0;
padding: 0;
}
.container {
background-color: #fff;
color: #333;
padding: 32px;
border-radius: 10px;
max-width: 600px;
margin: 40px auto;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.header .logo {
display: flex;
align-items: center;
gap: 8px;
}
.header .logo img {
width: 32px;
height: 32px;
object-fit: cover;
}
.header h1 {
font-size: 1.5rem;
font-weight: bold;
}
.warning {
background-color: #ffd966;
color: #7f6000;
padding: 4px 12px;
border-radius: 50px;
font-size: 0.875rem;
}
.content {
background-color: #fafafa;
color: #333;
padding: 24px;
border-radius: 10px;
}
.content h2 {
font-size: 1.25rem;
font-weight: bold;
margin-bottom: 16px;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.grid div {
display: flex;
flex-direction: column;
}
.grid p {
margin: 0;
}
.label {
color: #888;
font-size: 0.875rem;
margin-bottom: 4px;
}
.message {
font-size: 1rem;
line-height: 1.5;
}
</style>
<title>Pocket ID</title>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">
<img src="{{appUrl}}/api/application-configuration/logo" alt="Pocket ID" />
<h1>{{appName}}</h1>
</div>
<div class="warning">Warning</div>
</div>
<div class="content">
<h2>New Sign-In Detected</h2>
<div class="grid">
<div>
<p class="label">IP Address</p>
<p>{{ipAddress}}</p>
</div>
<div>
<p class="label">Device</p>
<p>{{device}}</p>
</div>
<div>
<p class="label">Sign-In Time</p>
<p>{{dateTimeString}}</p>
</div>
</div>
<p class="message">
This sign-in was detected from a new device or location. If you recognize this activity, you can safely ignore
this message. If not, please review your account and security settings.
</p>
</div>
</div>
</body>
</html>

View File

@@ -14,6 +14,7 @@ require (
github.com/golang-migrate/migrate/v4 v4.17.1 github.com/golang-migrate/migrate/v4 v4.17.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/mileusna/useragent v1.3.4
golang.org/x/crypto v0.26.0 golang.org/x/crypto v0.26.0
golang.org/x/time v0.6.0 golang.org/x/time v0.6.0
gorm.io/driver/sqlite v1.5.6 gorm.io/driver/sqlite v1.5.6

View File

@@ -81,6 +81,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mileusna/useragent v1.3.4 h1:MiuRRuvGjEie1+yZHO88UBYg8YBC/ddF6T7F56i3PCk=
github.com/mileusna/useragent v1.3.4/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=

View File

@@ -28,13 +28,14 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
r.Use(gin.Logger()) r.Use(gin.Logger())
// Initialize services // Initialize services
webauthnService := service.NewWebAuthnService(db, appConfigService) emailService := service.NewEmailService(appConfigService)
auditLogService := service.NewAuditLogService(db, appConfigService, emailService)
jwtService := service.NewJwtService(appConfigService) jwtService := service.NewJwtService(appConfigService)
webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
userService := service.NewUserService(db, jwtService) userService := service.NewUserService(db, jwtService)
oidcService := service.NewOidcService(db, jwtService) oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService)
testService := service.NewTestService(db, appConfigService) testService := service.NewTestService(db, appConfigService)
// Add global middleware
r.Use(middleware.NewCorsMiddleware().Add()) r.Use(middleware.NewCorsMiddleware().Add())
r.Use(middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60)) r.Use(middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60))
r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false)) r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false))
@@ -45,10 +46,11 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
// Set up API routes // Set up API routes
apiGroup := r.Group("/api") apiGroup := r.Group("/api")
controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService, jwtService) controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService)
controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService) controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService) controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService)
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService) controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService)
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
// Add test controller in non-production environments // Add test controller in non-production environments
if common.EnvConfig.AppEnv != "production" { if common.EnvConfig.AppEnv != "production" {

View File

@@ -20,9 +20,9 @@ func NewAppConfigController(
acc := &AppConfigController{ acc := &AppConfigController{
appConfigService: appConfigService, appConfigService: appConfigService,
} }
group.GET("/application-configuration", acc.listApplicationConfigurationHandler) group.GET("/application-configuration", acc.listAppConfigHandler)
group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllApplicationConfigurationHandler) group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler)
group.PUT("/application-configuration", acc.updateApplicationConfigurationHandler) group.PUT("/application-configuration", acc.updateAppConfigHandler)
group.GET("/application-configuration/logo", acc.getLogoHandler) group.GET("/application-configuration/logo", acc.getLogoHandler)
group.GET("/application-configuration/background-image", acc.getBackgroundImageHandler) group.GET("/application-configuration/background-image", acc.getBackgroundImageHandler)
@@ -36,8 +36,8 @@ type AppConfigController struct {
appConfigService *service.AppConfigService appConfigService *service.AppConfigService
} }
func (acc *AppConfigController) listApplicationConfigurationHandler(c *gin.Context) { func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListApplicationConfiguration(false) configuration, err := acc.appConfigService.ListAppConfig(false)
if err != nil { if err != nil {
utils.ControllerError(c, err) utils.ControllerError(c, err)
return return
@@ -52,8 +52,8 @@ func (acc *AppConfigController) listApplicationConfigurationHandler(c *gin.Conte
c.JSON(200, configVariablesDto) c.JSON(200, configVariablesDto)
} }
func (acc *AppConfigController) listAllApplicationConfigurationHandler(c *gin.Context) { func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListApplicationConfiguration(true) configuration, err := acc.appConfigService.ListAppConfig(true)
if err != nil { if err != nil {
utils.ControllerError(c, err) utils.ControllerError(c, err)
return return
@@ -68,14 +68,14 @@ func (acc *AppConfigController) listAllApplicationConfigurationHandler(c *gin.Co
c.JSON(200, configVariablesDto) c.JSON(200, configVariablesDto)
} }
func (acc *AppConfigController) updateApplicationConfigurationHandler(c *gin.Context) { func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
var input dto.AppConfigUpdateDto var input dto.AppConfigUpdateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err) utils.ControllerError(c, err)
return return
} }
savedConfigVariables, err := acc.appConfigService.UpdateApplicationConfiguration(input) savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(input)
if err != nil { if err != nil {
utils.ControllerError(c, err) utils.ControllerError(c, err)
return return

View File

@@ -0,0 +1,56 @@
package controller
import (
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
)
func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.AuditLogService, jwtAuthMiddleware *middleware.JwtAuthMiddleware) {
alc := AuditLogController{
auditLogService: auditLogService,
}
group.GET("/audit-logs", jwtAuthMiddleware.Add(false), alc.listAuditLogsForUserHandler)
}
type AuditLogController struct {
auditLogService *service.AuditLogService
}
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
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)
if err != nil {
utils.ControllerError(c, err)
return
}
// Map the audit logs to DTOs
var logsDtos []dto.AuditLogDto
err = dto.MapStructList(logs, &logsDtos)
if err != nil {
utils.ControllerError(c, err)
return
}
// Add device information to the logs
for i, logsDto := range logsDtos {
logsDto.Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent)
logsDtos[i] = logsDto
}
c.JSON(http.StatusOK, gin.H{
"data": logsDtos,
"pagination": pagination,
})
}

View File

@@ -46,7 +46,7 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
return return
} }
code, callbackURL, err := oc.oidcService.Authorize(input, c.GetString("userID")) code, callbackURL, err := oc.oidcService.Authorize(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
if err != nil { if err != nil {
if errors.Is(err, common.ErrOidcMissingAuthorization) { if errors.Is(err, common.ErrOidcMissingAuthorization) {
utils.CustomControllerError(c, http.StatusForbidden, err.Error()) utils.CustomControllerError(c, http.StatusForbidden, err.Error())
@@ -73,7 +73,7 @@ func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
return return
} }
code, callbackURL, err := oc.oidcService.AuthorizeNewClient(input, c.GetString("userID")) code, callbackURL, err := oc.oidcService.AuthorizeNewClient(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
if err != nil { if err != nil {
if errors.Is(err, common.ErrOidcInvalidCallbackURL) { if errors.Is(err, common.ErrOidcInvalidCallbackURL) {
utils.CustomControllerError(c, http.StatusBadRequest, err.Error()) utils.CustomControllerError(c, http.StatusBadRequest, err.Error())

View File

@@ -15,8 +15,8 @@ import (
"golang.org/x/time/rate" "golang.org/x/time/rate"
) )
func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, webauthnService *service.WebAuthnService, jwtService *service.JwtService) { func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, webauthnService *service.WebAuthnService) {
wc := &WebauthnController{webAuthnService: webauthnService, jwtService: jwtService} wc := &WebauthnController{webAuthnService: webauthnService}
group.GET("/webauthn/register/start", jwtAuthMiddleware.Add(false), wc.beginRegistrationHandler) group.GET("/webauthn/register/start", jwtAuthMiddleware.Add(false), wc.beginRegistrationHandler)
group.POST("/webauthn/register/finish", jwtAuthMiddleware.Add(false), wc.verifyRegistrationHandler) group.POST("/webauthn/register/finish", jwtAuthMiddleware.Add(false), wc.verifyRegistrationHandler)
@@ -32,7 +32,6 @@ func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware
type WebauthnController struct { type WebauthnController struct {
webAuthnService *service.WebAuthnService webAuthnService *service.WebAuthnService
jwtService *service.JwtService
} }
func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) { func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
@@ -95,7 +94,8 @@ func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
} }
userID := c.GetString("userID") userID := c.GetString("userID")
user, err := wc.webAuthnService.VerifyLogin(sessionID, userID, credentialAssertionData)
user, token, err := wc.webAuthnService.VerifyLogin(sessionID, userID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
if err != nil { if err != nil {
if errors.Is(err, common.ErrInvalidCredentials) { if errors.Is(err, common.ErrInvalidCredentials) {
utils.CustomControllerError(c, http.StatusUnauthorized, err.Error()) utils.CustomControllerError(c, http.StatusUnauthorized, err.Error())
@@ -105,12 +105,6 @@ func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
return return
} }
token, err := wc.jwtService.GenerateAccessToken(user)
if err != nil {
utils.ControllerError(c, err)
return
}
var userDto dto.UserDto var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil { if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err) utils.ControllerError(c, err)

View File

@@ -14,4 +14,10 @@ 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"`
EmailEnabled string `json:"emailEnabled" binding:"required"`
SmtHost string `json:"smtpHost"`
SmtpPort string `json:"smtpPort"`
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
SmtpUser string `json:"smtpUser"`
SmtpPassword string `json:"smtpPassword"`
} }

View File

@@ -0,0 +1,17 @@
package dto
import (
"github.com/stonith404/pocket-id/backend/internal/model"
"time"
)
type AuditLogDto struct {
ID string `json:"id"`
CreatedAt time.Time `json:"createdAt"`
Event model.AuditLogEvent `json:"event"`
IpAddress string `json:"ipAddress"`
Device string `json:"device"`
UserID string `json:"userID"`
Data model.AuditLogData `json:"data"`
}

View File

@@ -21,7 +21,6 @@ func RegisterJobs(db *gorm.DB) {
registerJob(scheduler, "ClearWebauthnSessions", "0 3 * * *", jobs.clearWebauthnSessions) registerJob(scheduler, "ClearWebauthnSessions", "0 3 * * *", jobs.clearWebauthnSessions)
registerJob(scheduler, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens) registerJob(scheduler, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens)
registerJob(scheduler, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes) registerJob(scheduler, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes)
scheduler.Start() scheduler.Start()
} }
@@ -29,17 +28,24 @@ type Jobs struct {
db *gorm.DB db *gorm.DB
} }
// 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 < ?", utils.FormatDateForDb(time.Now())).Error
} }
// 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 < ?", utils.FormatDateForDb(time.Now())).Error
} }
// 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 < ?", utils.FormatDateForDb(time.Now())).Error
}
// ClearAuditLogs deletes audit logs older than 90 days
func (j *Jobs) clearAuditLogs() error {
return j.db.Delete(&model.AuditLog{}, "created_at < ?", utils.FormatDateForDb(time.Now().AddDate(0, 0, -90))).Error
} }
func registerJob(scheduler gocron.Scheduler, name string, interval string, job func() error) { func registerJob(scheduler gocron.Scheduler, name string, interval string, job func() error) {

View File

@@ -13,4 +13,11 @@ type AppConfig struct {
BackgroundImageType AppConfigVariable BackgroundImageType AppConfigVariable
LogoImageType AppConfigVariable LogoImageType AppConfigVariable
SessionDuration AppConfigVariable SessionDuration AppConfigVariable
EmailEnabled AppConfigVariable
SmtpHost AppConfigVariable
SmtpPort AppConfigVariable
SmtpFrom AppConfigVariable
SmtpUser AppConfigVariable
SmtpPassword AppConfigVariable
} }

View File

@@ -0,0 +1,50 @@
package model
import (
"database/sql/driver"
"encoding/json"
"errors"
)
type AuditLog struct {
Base
Event AuditLogEvent
IpAddress string
UserAgent string
UserID string
Data AuditLogData
}
type AuditLogData map[string]string
type AuditLogEvent string
const (
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
)
// Scan and Value methods for GORM to handle the custom type
func (e *AuditLogEvent) Scan(value interface{}) error {
*e = AuditLogEvent(value.(string))
return nil
}
func (e AuditLogEvent) Value() (driver.Value, error) {
return string(e), nil
}
func (d *AuditLogData) Scan(value interface{}) error {
if v, ok := value.([]byte); ok {
return json.Unmarshal(v, d)
} else {
return errors.New("type assertion to []byte failed")
}
}
func (d AuditLogData) Value() (driver.Value, error) {
return json.Marshal(d)
}

View File

@@ -53,9 +53,34 @@ var defaultDbConfig = model.AppConfig{
IsInternal: true, IsInternal: true,
Value: "svg", Value: "svg",
}, },
EmailEnabled: model.AppConfigVariable{
Key: "emailEnabled",
Type: "bool",
Value: "false",
},
SmtpHost: model.AppConfigVariable{
Key: "smtpHost",
Type: "string",
},
SmtpPort: model.AppConfigVariable{
Key: "smtpPort",
Type: "number",
},
SmtpFrom: model.AppConfigVariable{
Key: "smtpFrom",
Type: "string",
},
SmtpUser: model.AppConfigVariable{
Key: "smtpUser",
Type: "string",
},
SmtpPassword: model.AppConfigVariable{
Key: "smtpPassword",
Type: "string",
},
} }
func (s *AppConfigService) UpdateApplicationConfiguration(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) { func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
var savedConfigVariables []model.AppConfigVariable var savedConfigVariables []model.AppConfigVariable
tx := s.db.Begin() tx := s.db.Begin()
@@ -67,19 +92,19 @@ func (s *AppConfigService) UpdateApplicationConfiguration(input dto.AppConfigUpd
key := field.Tag.Get("json") key := field.Tag.Get("json")
value := rv.FieldByName(field.Name).String() value := rv.FieldByName(field.Name).String()
var applicationConfigurationVariable model.AppConfigVariable var appConfigVariable model.AppConfigVariable
if err := tx.First(&applicationConfigurationVariable, "key = ? AND is_internal = false", key).Error; err != nil { if err := tx.First(&appConfigVariable, "key = ? AND is_internal = false", key).Error; err != nil {
tx.Rollback() tx.Rollback()
return nil, err return nil, err
} }
applicationConfigurationVariable.Value = value appConfigVariable.Value = value
if err := tx.Save(&applicationConfigurationVariable).Error; err != nil { if err := tx.Save(&appConfigVariable).Error; err != nil {
tx.Rollback() tx.Rollback()
return nil, err return nil, err
} }
savedConfigVariables = append(savedConfigVariables, applicationConfigurationVariable) savedConfigVariables = append(savedConfigVariables, appConfigVariable)
} }
tx.Commit() tx.Commit()
@@ -101,7 +126,7 @@ func (s *AppConfigService) UpdateImageType(imageName string, fileType string) er
return s.loadDbConfigFromDb() return s.loadDbConfigFromDb()
} }
func (s *AppConfigService) ListApplicationConfiguration(showAll bool) ([]model.AppConfigVariable, error) { func (s *AppConfigService) ListAppConfig(showAll bool) ([]model.AppConfigVariable, error) {
var configuration []model.AppConfigVariable var configuration []model.AppConfigVariable
var err error var err error

View File

@@ -0,0 +1,85 @@
package service
import (
userAgentParser "github.com/mileusna/useragent"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/utils"
"gorm.io/gorm"
"log"
)
type AuditLogService struct {
db *gorm.DB
appConfigService *AppConfigService
emailService *EmailService
}
func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailService *EmailService) *AuditLogService {
return &AuditLogService{db: db, appConfigService: appConfigService, emailService: emailService}
}
// Create creates a new audit log entry in the database
func (s *AuditLogService) Create(event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData) model.AuditLog {
auditLog := model.AuditLog{
Event: event,
IpAddress: ipAddress,
UserAgent: userAgent,
UserID: userID,
Data: data,
}
// Save the audit log in the database
if err := s.db.Create(&auditLog).Error; err != nil {
log.Printf("Failed to create audit log: %v\n", err)
return model.AuditLog{}
}
return auditLog
}
// CreateNewSignInWithEmail creates a new audit log entry in the database and sends an email if the device hasn't been used before
func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID string, data model.AuditLogData) model.AuditLog {
createdAuditLog := s.Create(model.AuditLogEventSignIn, ipAddress, userAgent, userID, data)
// Count the number of times the user has logged in from the same device
var count int64
err := s.db.Model(&model.AuditLog{}).Where("user_id = ? AND ip_address = ? AND user_agent = ?", userID, ipAddress, userAgent).Count(&count).Error
if err != nil {
log.Printf("Failed to count audit logs: %v\n", err)
return createdAuditLog
}
// If the user hasn't logged in from the same device before, send an email
if count <= 1 {
go func() {
var user model.User
s.db.Where("id = ?", userID).First(&user)
title := "New device login with " + s.appConfigService.DbConfig.AppName.Value
err := s.emailService.Send(user.Email, title, "login-with-new-device", map[string]interface{}{
"ipAddress": ipAddress,
"device": s.DeviceStringFromUserAgent(userAgent),
"dateTimeString": createdAuditLog.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC"),
})
if err != nil {
log.Printf("Failed to send email: %v\n", err)
}
}()
}
return createdAuditLog
}
// 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) {
var logs []model.AuditLog
query := s.db.Model(&model.AuditLog{}).Where("user_id = ?", userID).Order("created_at desc")
pagination, err := utils.Paginate(page, pageSize, query, &logs)
return logs, pagination, err
}
func (s *AuditLogService) DeviceStringFromUserAgent(userAgent string) string {
ua := userAgentParser.Parse(userAgent)
return ua.Name + " on " + ua.OS + " " + ua.OSVersion
}

View File

@@ -0,0 +1,67 @@
package service
import (
"errors"
"fmt"
"github.com/stonith404/pocket-id/backend/internal/common"
"net/smtp"
"os"
"strings"
)
type EmailService struct {
appConfigService *AppConfigService
}
func NewEmailService(appConfigService *AppConfigService) *EmailService {
return &EmailService{
appConfigService: appConfigService}
}
// Send sends an email notification
func (s *EmailService) Send(toEmail, title, templateName string, templateParameters map[string]interface{}) error {
// Check if SMTP settings are set
if s.appConfigService.DbConfig.EmailEnabled.Value != "true" {
return errors.New("email not enabled")
}
// Construct the email message
subject := fmt.Sprintf("Subject: %s\n", title)
subject += "From: " + s.appConfigService.DbConfig.SmtpFrom.Value + "\n"
subject += "To: " + toEmail + "\n"
subject += "Content-Type: text/html; charset=UTF-8\n"
body, err := os.ReadFile(fmt.Sprintf("./email-templates/%s.html", templateName))
bodyString := string(body)
if err != nil {
return fmt.Errorf("failed to read email template: %w", err)
}
// Replace template parameters
templateParameters["appName"] = s.appConfigService.DbConfig.AppName.Value
templateParameters["appUrl"] = common.EnvConfig.AppURL
for key, value := range templateParameters {
bodyString = strings.ReplaceAll(bodyString, fmt.Sprintf("{{%s}}", key), fmt.Sprintf("%v", value))
}
emailBody := []byte(subject + bodyString)
// Set up the authentication information.
auth := smtp.PlainAuth("", s.appConfigService.DbConfig.SmtpUser.Value, s.appConfigService.DbConfig.SmtpPassword.Value, s.appConfigService.DbConfig.SmtpHost.Value)
// Send the email
err = smtp.SendMail(
s.appConfigService.DbConfig.SmtpHost.Value+":"+s.appConfigService.DbConfig.SmtpPort.Value,
auth,
s.appConfigService.DbConfig.SmtpFrom.Value,
[]string{toEmail},
emailBody,
)
if err != nil {
return fmt.Errorf("failed to send email: %w", err)
}
return nil
}

View File

@@ -17,18 +17,22 @@ import (
) )
type OidcService struct { type OidcService struct {
db *gorm.DB db *gorm.DB
jwtService *JwtService jwtService *JwtService
appConfigService *AppConfigService
auditLogService *AuditLogService
} }
func NewOidcService(db *gorm.DB, jwtService *JwtService) *OidcService { func NewOidcService(db *gorm.DB, jwtService *JwtService, appConfigService *AppConfigService, auditLogService *AuditLogService) *OidcService {
return &OidcService{ return &OidcService{
db: db, db: db,
jwtService: jwtService, jwtService: jwtService,
appConfigService: appConfigService,
auditLogService: auditLogService,
} }
} }
func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID string) (string, string, error) { func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID, ipAddress, userAgent string) (string, string, error) {
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", input.ClientID, userID) s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", input.ClientID, userID)
@@ -42,10 +46,16 @@ func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID
} }
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce) code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce)
return code, callbackURL, err if err != nil {
return "", "", err
}
s.auditLogService.Create(model.AuditLogEventClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": userAuthorizedOIDCClient.Client.Name})
return code, callbackURL, nil
} }
func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto, userID string) (string, string, error) { func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto, userID, ipAddress, userAgent string) (string, string, error) {
var client model.OidcClient var client model.OidcClient
if err := s.db.First(&client, "id = ?", input.ClientID).Error; err != nil { if err := s.db.First(&client, "id = ?", input.ClientID).Error; err != nil {
return "", "", err return "", "", err
@@ -71,7 +81,13 @@ func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto
} }
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce) code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce)
return code, callbackURL, err if err != nil {
return "", "", err
}
s.auditLogService.Create(model.AuditLogEventNewClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name})
return code, callbackURL, nil
} }
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret string) (string, string, error) { func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret string) (string, string, error) {

View File

@@ -12,11 +12,13 @@ import (
) )
type WebAuthnService struct { type WebAuthnService struct {
db *gorm.DB db *gorm.DB
webAuthn *webauthn.WebAuthn webAuthn *webauthn.WebAuthn
jwtService *JwtService
auditLogService *AuditLogService
} }
func NewWebAuthnService(db *gorm.DB, appConfigService *AppConfigService) *WebAuthnService { func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, appConfigService *AppConfigService) *WebAuthnService {
webauthnConfig := &webauthn.Config{ webauthnConfig := &webauthn.Config{
RPDisplayName: appConfigService.DbConfig.AppName.Value, RPDisplayName: appConfigService.DbConfig.AppName.Value,
RPID: utils.GetHostFromURL(common.EnvConfig.AppURL), RPID: utils.GetHostFromURL(common.EnvConfig.AppURL),
@@ -36,7 +38,7 @@ func NewWebAuthnService(db *gorm.DB, appConfigService *AppConfigService) *WebAut
} }
wa, _ := webauthn.New(webauthnConfig) wa, _ := webauthn.New(webauthnConfig)
return &WebAuthnService{db: db, webAuthn: wa} return &WebAuthnService{db: db, webAuthn: wa, jwtService: jwtService, auditLogService: auditLogService}
} }
func (s *WebAuthnService) BeginRegistration(userID string) (*model.PublicKeyCredentialCreationOptions, error) { func (s *WebAuthnService) BeginRegistration(userID string) (*model.PublicKeyCredentialCreationOptions, error) {
@@ -129,10 +131,10 @@ func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions
}, nil }, nil
} }
func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssertionData *protocol.ParsedCredentialAssertionData) (model.User, error) { func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssertionData *protocol.ParsedCredentialAssertionData, ipAddress, userAgent string) (model.User, string, error) {
var storedSession model.WebauthnSession var storedSession model.WebauthnSession
if err := s.db.First(&storedSession, "id = ?", sessionID).Error; err != nil { if err := s.db.First(&storedSession, "id = ?", sessionID).Error; err != nil {
return model.User{}, err return model.User{}, "", err
} }
session := webauthn.SessionData{ session := webauthn.SessionData{
@@ -149,14 +151,21 @@ func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssert
}, session, credentialAssertionData) }, session, credentialAssertionData)
if err != nil { if err != nil {
return model.User{}, err return model.User{}, "", err
} }
if err := s.db.Find(&user, "id = ?", userID).Error; err != nil { if err := s.db.Find(&user, "id = ?", userID).Error; err != nil {
return model.User{}, err return model.User{}, "", err
} }
return *user, nil token, err := s.jwtService.GenerateAccessToken(*user)
if err != nil {
return model.User{}, "", err
}
s.auditLogService.CreateNewSignInWithEmail(ipAddress, userAgent, user.ID, model.AuditLogData{})
return *user, token, nil
} }
func (s *WebAuthnService) ListCredentials(userID string) ([]model.WebauthnCredential, error) { func (s *WebAuthnService) ListCredentials(userID string) ([]model.WebauthnCredential, error) {

View File

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

View File

@@ -0,0 +1,10 @@
CREATE TABLE audit_logs
(
id TEXT NOT NULL PRIMARY KEY,
created_at DATETIME,
event TEXT NOT NULL,
ip_address TEXT NOT NULL,
user_agent TEXT NOT NULL,
data BLOB NOT NULL,
user_id TEXT REFERENCES users
);

View File

@@ -9,12 +9,16 @@
input = $bindable(), input = $bindable(),
label, label,
description, description,
disabled = false,
type = 'text',
children, children,
...restProps ...restProps
}: HTMLAttributes<HTMLDivElement> & { }: HTMLAttributes<HTMLDivElement> & {
input?: FormInput<string | boolean | number>; input?: FormInput<string | boolean | number>;
label: string; label: string;
description?: string; description?: string;
disabled?: boolean;
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox';
children?: Snippet; children?: Snippet;
} = $props(); } = $props();
@@ -30,7 +34,7 @@
{#if children} {#if children}
{@render children()} {@render children()}
{:else if input} {:else if input}
<Input {id} bind:value={input.value} /> <Input {id} {type} bind:value={input.value} {disabled} />
{/if} {/if}
{#if input?.error} {#if input?.error}
<p class="mt-1 text-sm text-red-500">{input.error}</p> <p class="mt-1 text-sm text-red-500">{input.error}</p>

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import applicationConfigurationStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store'; import userStore from '$lib/stores/user-store';
import Logo from '../logo.svelte'; import Logo from '../logo.svelte';
import HeaderAvatar from './header-avatar.svelte'; import HeaderAvatar from './header-avatar.svelte';
@@ -17,7 +17,7 @@
{#if !isAuthPage} {#if !isAuthPage}
<Logo class="mr-3 h-10 w-10" /> <Logo class="mr-3 h-10 w-10" />
<h1 class="text-lg font-medium" data-testid="application-name"> <h1 class="text-lg font-medium" data-testid="application-name">
{$applicationConfigurationStore.appName} {$appConfigStore.appName}
</h1> </h1>
{/if} {/if}
</div> </div>

View File

@@ -12,7 +12,7 @@
<tr <tr
class={cn( class={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", "border-b transition-colors data-[state=selected]:bg-muted",
className className
)} )}
{...$$restProps} {...$$restProps}

View File

@@ -1,29 +1,29 @@
import type { import type {
AllApplicationConfiguration, AllAppConfig,
ApplicationConfigurationRawResponse AppConfigRawResponse
} from '$lib/types/application-configuration'; } from '$lib/types/application-configuration';
import APIService from './api-service'; import APIService from './api-service';
export default class ApplicationConfigurationService extends APIService { export default class AppConfigService extends APIService {
async list(showAll = false) { async list(showAll = false) {
let url = '/application-configuration'; let url = '/application-configuration';
if (showAll) { if (showAll) {
url += '/all'; url += '/all';
} }
const { data } = await this.api.get<ApplicationConfigurationRawResponse>(url); const { data } = await this.api.get<AppConfigRawResponse>(url);
const applicationConfiguration: Partial<AllApplicationConfiguration> = {}; const appConfig: Partial<AllAppConfig> = {};
data.forEach(({ key, value }) => { data.forEach(({ key, value }) => {
(applicationConfiguration as any)[key] = value; (appConfig as any)[key] = value;
}); });
return applicationConfiguration as AllApplicationConfiguration; return appConfig as AllAppConfig;
} }
async update(applicationConfiguration: AllApplicationConfiguration) { async update(appConfig: AllAppConfig) {
const res = await this.api.put('/application-configuration', applicationConfiguration); const res = await this.api.put('/application-configuration', appConfig);
return res.data as AllApplicationConfiguration; return res.data as AllAppConfig;
} }
async updateFavicon(favicon: File) { async updateFavicon(favicon: File) {

View File

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

View File

@@ -1,22 +1,22 @@
import ApplicationConfigurationService from '$lib/services/application-configuration-service'; import AppConfigService from '$lib/services/app-config-service';
import type { ApplicationConfiguration } from '$lib/types/application-configuration'; import type { AppConfig } from '$lib/types/application-configuration';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
const applicationConfigurationStore = writable<ApplicationConfiguration>(); const appConfigStore = writable<AppConfig>();
const applicationConfigurationService = new ApplicationConfigurationService(); const appConfigService = new AppConfigService();
const reload = async () => { const reload = async () => {
const applicationConfiguration = await applicationConfigurationService.list(); const appConfig = await appConfigService.list();
applicationConfigurationStore.set(applicationConfiguration); appConfigStore.set(appConfig);
}; };
const set = (applicationConfiguration: ApplicationConfiguration) => { const set = (appConfig: AppConfig) => {
applicationConfigurationStore.set(applicationConfiguration); appConfigStore.set(appConfig);
} };
export default { export default {
subscribe: applicationConfigurationStore.subscribe, subscribe: appConfigStore.subscribe,
reload, reload,
set set
}; };

View File

@@ -1,13 +1,18 @@
export type AllAppConfig = {
export type AllApplicationConfiguration = {
appName: string; appName: string;
sessionDuration: string; sessionDuration: string;
emailEnabled: string;
smtpHost: string;
smtpPort: string;
smtpFrom: string;
smtpUser: string;
smtpPassword: string;
}; };
export type ApplicationConfiguration = AllApplicationConfiguration; export type AppConfig = AllAppConfig;
export type ApplicationConfigurationRawResponse = { export type AppConfigRawResponse = {
key: string; key: string;
type: string; type: string;
value: string; value: string;
}[]; }[];

View File

@@ -0,0 +1,8 @@
export type AuditLog = {
id: string;
event: string;
ipAddress: string;
device: string;
createdAt: string;
data: any;
};

View File

@@ -1,19 +1,17 @@
import ApplicationConfigurationService from '$lib/services/application-configuration-service'; import AppConfigService from '$lib/services/app-config-service';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ cookies }) => { export const load: LayoutServerLoad = async ({ cookies }) => {
const userService = new UserService(cookies.get('access_token')); const userService = new UserService(cookies.get('access_token'));
const applicationConfigurationService = new ApplicationConfigurationService( const appConfigService = new AppConfigService(cookies.get('access_token'));
cookies.get('access_token')
);
const user = await userService const user = await userService
.getCurrent() .getCurrent()
.then((user) => user) .then((user) => user)
.catch(() => null); .catch(() => null);
const applicationConfiguration = await applicationConfigurationService const appConfig = await appConfigService
.list() .list()
.then((config) => config) .then((config) => config)
.catch((e) => { .catch((e) => {
@@ -24,6 +22,6 @@ export const load: LayoutServerLoad = async ({ cookies }) => {
}); });
return { return {
user, user,
applicationConfiguration appConfig
}; };
}; };

View File

@@ -4,7 +4,7 @@
import Error from '$lib/components/error.svelte'; import Error from '$lib/components/error.svelte';
import Header from '$lib/components/header/header.svelte'; import Header from '$lib/components/header/header.svelte';
import { Toaster } from '$lib/components/ui/sonner'; import { Toaster } from '$lib/components/ui/sonner';
import applicationConfigurationStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store'; import userStore from '$lib/stores/user-store';
import { ModeWatcher } from 'mode-watcher'; import { ModeWatcher } from 'mode-watcher';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
@@ -19,17 +19,17 @@
children: Snippet; children: Snippet;
} = $props(); } = $props();
const { user, applicationConfiguration } = data; const { user, appConfig } = data;
if (browser && user) { if (browser && user) {
userStore.setUser(user); userStore.setUser(user);
} }
if (applicationConfiguration) { if (appConfig) {
applicationConfigurationStore.set(applicationConfiguration); appConfigStore.set(appConfig);
} }
</script> </script>
{#if !applicationConfiguration} {#if !appConfig}
<Error <Error
message="A critical error occured. Please contact your administrator." message="A critical error occured. Please contact your administrator."
showButton={false} showButton={false}

View File

@@ -4,7 +4,7 @@
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import OidcService from '$lib/services/oidc-service'; import OidcService from '$lib/services/oidc-service';
import WebAuthnService from '$lib/services/webauthn-service'; import WebAuthnService from '$lib/services/webauthn-service';
import applicationConfigurationStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store'; import userStore from '$lib/stores/user-store';
import { getWebauthnErrorMessage } from '$lib/utils/error-util'; import { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { startAuthentication } from '@simplewebauthn/browser'; import { startAuthentication } from '@simplewebauthn/browser';
@@ -91,7 +91,7 @@
{#if !authorizationRequired && !errorMessage} {#if !authorizationRequired && !errorMessage}
<p class="text-muted-foreground mb-10 mt-2"> <p class="text-muted-foreground mb-10 mt-2">
Do you want to sign in to <b>{client.name}</b> with your Do you want to sign in to <b>{client.name}</b> with your
<b>{$applicationConfigurationStore.appName}</b> account? <b>{$appConfigStore.appName}</b> account?
</p> </p>
{:else if authorizationRequired} {:else if authorizationRequired}
<div transition:slide={{ duration: 300 }}> <div transition:slide={{ duration: 300 }}>

View File

@@ -4,7 +4,7 @@
import Logo from '$lib/components/logo.svelte'; import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import WebAuthnService from '$lib/services/webauthn-service'; import WebAuthnService from '$lib/services/webauthn-service';
import applicationConfigurationStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store'; import userStore from '$lib/stores/user-store';
import { getWebauthnErrorMessage } from '$lib/utils/error-util'; import { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { startAuthentication } from '@simplewebauthn/browser'; import { startAuthentication } from '@simplewebauthn/browser';
@@ -40,7 +40,7 @@
</div> </div>
</div> </div>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl"> <h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">
Sign in to {$applicationConfigurationStore.appName} Sign in to {$appConfigStore.appName}
</h1> </h1>
<p class="text-muted-foreground mt-2"> <p class="text-muted-foreground mt-2">
Authenticate yourself with your passkey to access the admin panel Authenticate yourself with your passkey to access the admin panel

View File

@@ -4,7 +4,7 @@
import Logo from '$lib/components/logo.svelte'; import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import applicationConfigurationStore from '$lib/stores/application-configuration-store.js'; import appConfigStore from '$lib/stores/application-configuration-store.js';
import userStore from '$lib/stores/user-store.js'; import userStore from '$lib/stores/user-store.js';
import type { User } from '$lib/types/user.type.js'; import type { User } from '$lib/types/user.type.js';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
@@ -18,9 +18,9 @@
isLoading = true; isLoading = true;
userService userService
.exchangeOneTimeAccessToken(data.token) .exchangeOneTimeAccessToken(data.token)
.then((user :User) => { .then((user: User) => {
userStore.setUser(user); userStore.setUser(user);
goto('/settings') goto('/settings');
}) })
.catch(axiosErrorToast); .catch(axiosErrorToast);
isLoading = false; isLoading = false;
@@ -29,15 +29,15 @@
<SignInWrapper> <SignInWrapper>
<div class="flex justify-center"> <div class="flex justify-center">
<div class="rounded-2xl bg-muted p-3"> <div class="bg-muted rounded-2xl p-3">
<Logo class="h-10 w-10" /> <Logo class="h-10 w-10" />
</div> </div>
</div> </div>
<h1 class="mt-5 font-playfair text-4xl font-bold">One Time Access</h1> <h1 class="font-playfair mt-5 text-4xl font-bold">One Time Access</h1>
<p class="mt-2 text-muted-foreground"> <p class="text-muted-foreground mt-2">
You've been granted one-time access to your {$applicationConfigurationStore.appName} account. Please note that if you continue, You've been granted one-time access to your {$appConfigStore.appName} account. Please note that if
this link will become invalid. To avoid this, make sure to add a passkey. Otherwise, you'll need you continue, this link will become invalid. To avoid this, make sure to add a passkey. Otherwise,
to request a new link. you'll need to request a new link.
</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

@@ -9,7 +9,10 @@
children: Snippet; children: Snippet;
} = $props(); } = $props();
let links = $state([{ href: '/settings/account', label: 'My Account' }]); let links = $state([
{ href: '/settings/account', label: 'My Account' },
{ href: '/settings/audit-log', label: 'Audit Log' }
]);
if ($userStore?.isAdmin) { if ($userStore?.isAdmin) {
links = [ links = [
@@ -22,10 +25,8 @@
</script> </script>
<section> <section>
<div class="bg-muted/40 h-screen w-full"> <div class="bg-muted/40 min-h-screen w-full">
<main <main class="mx-auto flex max-w-[1640px] flex-col gap-x-4 gap-y-10 p-4 md:p-10 lg:flex-row">
class="mx-auto flex min-h-screen 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>

View File

@@ -1,10 +1,8 @@
import ApplicationConfigurationService from '$lib/services/application-configuration-service'; import AppConfigService from '$lib/services/app-config-service';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => { export const load: PageServerLoad = async ({ cookies }) => {
const applicationConfigurationService = new ApplicationConfigurationService( const appConfigService = new AppConfigService(cookies.get('access_token'));
cookies.get('access_token') const appConfig = await appConfigService.list(true);
); return { appConfig };
const applicationConfiguration = await applicationConfigurationService.list(true);
return { applicationConfiguration };
}; };

View File

@@ -1,24 +1,30 @@
<script lang="ts"> <script lang="ts">
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import ApplicationConfigurationService from '$lib/services/application-configuration-service'; import AppConfigService from '$lib/services/app-config-service';
import applicationConfigurationStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllApplicationConfiguration } from '$lib/types/application-configuration'; import type { AllAppConfig } from '$lib/types/application-configuration';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import ApplicationConfigurationForm from './application-configuration-form.svelte'; import AppConfigEmailForm from './forms/app-config-email-form.svelte';
import AppConfigGeneralForm from './forms/app-config-general-form.svelte';
import UpdateApplicationImages from './update-application-images.svelte'; import UpdateApplicationImages from './update-application-images.svelte';
let { data } = $props(); let { data } = $props();
let applicationConfiguration = $state(data.applicationConfiguration); let appConfig = $state(data.appConfig);
const applicationConfigurationService = new ApplicationConfigurationService(); const appConfigService = new AppConfigService();
async function updateConfiguration(configuration: AllApplicationConfiguration) { async function updateAppConfig(updatedAppConfig: Partial<AllAppConfig>) {
await applicationConfigurationService await appConfigService
.update(configuration) .update({
.then(() => toast.success('Application configuration updated successfully')) ...appConfig,
.catch(axiosErrorToast); ...updatedAppConfig
await applicationConfigurationStore.reload(); })
.catch((e) => {
axiosErrorToast(e);
throw e;
});
await appConfigStore.reload();
} }
async function updateImages( async function updateImages(
@@ -26,12 +32,10 @@
backgroundImage: File | null, backgroundImage: File | null,
favicon: File | null favicon: File | null
) { ) {
const faviconPromise = favicon const faviconPromise = favicon ? appConfigService.updateFavicon(favicon) : Promise.resolve();
? applicationConfigurationService.updateFavicon(favicon) const logoPromise = logo ? appConfigService.updateLogo(logo) : Promise.resolve();
: Promise.resolve();
const logoPromise = logo ? applicationConfigurationService.updateLogo(logo) : Promise.resolve();
const backgroundImagePromise = backgroundImage const backgroundImagePromise = backgroundImage
? applicationConfigurationService.updateBackgroundImage(backgroundImage) ? appConfigService.updateBackgroundImage(backgroundImage)
: Promise.resolve(); : Promise.resolve();
await Promise.all([logoPromise, backgroundImagePromise, faviconPromise]) await Promise.all([logoPromise, backgroundImagePromise, faviconPromise])
@@ -49,7 +53,20 @@
<Card.Title>General</Card.Title> <Card.Title>General</Card.Title>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<ApplicationConfigurationForm {applicationConfiguration} callback={updateConfiguration} /> <AppConfigGeneralForm {appConfig} callback={updateAppConfig} />
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>Email</Card.Title>
<Card.Description>
Enable email notifications to alert users when a login is detected from a new device or
location.
</Card.Description>
</Card.Header>
<Card.Content>
<AppConfigEmailForm {appConfig} callback={updateAppConfig} />
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>

View File

@@ -0,0 +1,80 @@
<script lang="ts">
import FormInput from '$lib/components/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import type { AllAppConfig } from '$lib/types/application-configuration';
import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner';
import { z } from 'zod';
let {
callback,
appConfig
}: {
appConfig: AllAppConfig;
callback: (appConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props();
let isLoading = $state(false);
let emailEnabled = $state(appConfig.emailEnabled == 'true');
const updatedAppConfig = {
emailEnabled: emailEnabled.toString(),
smtpHost: appConfig.smtpHost,
smtpPort: appConfig.smtpPort,
smtpUser: appConfig.smtpUser,
smtpPassword: appConfig.smtpPassword,
smtpFrom: appConfig.smtpFrom
};
const formSchema = z.object({
smtpHost: z.string().min(1),
smtpPort: z.string().min(1),
smtpUser: z.string().min(1),
smtpPassword: z.string().min(1),
smtpFrom: z.string().email()
});
const { inputs, ...form } = createForm< typeof formSchema>(formSchema, updatedAppConfig);
async function onSubmit() {
const data = form.validate();
if (!data) return false;
isLoading = true;
await callback({
...data,
emailEnabled: 'true'
}).finally(() => (isLoading = false));
toast.success('Email configuration updated successfully');
return true;
}
async function onDisable() {
await callback({ emailEnabled: 'false' });
emailEnabled = false;
toast.success('Email disabled successfully');
}
async function onEnable() {
if (await onSubmit()) {
emailEnabled = true;
}
}
</script>
<form onsubmit={onSubmit}>
<div class="mt-5 grid grid-cols-2 gap-5">
<FormInput label="SMTP Host" bind:input={$inputs.smtpHost} />
<FormInput label="SMTP Port" bind:input={$inputs.smtpPort} />
<FormInput label="SMTP User" bind:input={$inputs.smtpUser} />
<FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} />
<FormInput label="SMTP From" bind:input={$inputs.smtpFrom} />
</div>
<div class="mt-5 flex justify-end gap-3">
{#if emailEnabled}
<Button variant="secondary" onclick={onDisable}>Disable</Button>
<Button {isLoading} onclick={onSubmit} type="submit">Save</Button>
{:else}
<Button {isLoading} onclick={onEnable} type="submit">Enable</Button>
{/if}
</div>
</form>

View File

@@ -1,23 +1,24 @@
<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 type { AllApplicationConfiguration } 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 { z } from 'zod'; import { z } from 'zod';
let { let {
callback, callback,
applicationConfiguration appConfig
}: { }: {
applicationConfiguration: AllApplicationConfiguration; appConfig: AllAppConfig;
callback: (user: AllApplicationConfiguration) => Promise<void>; callback: (appConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props(); } = $props();
let isLoading = $state(false); let isLoading = $state(false);
const updatedApplicationConfiguration: AllApplicationConfiguration = { const updatedAppConfig = {
appName: applicationConfiguration.appName, appName: appConfig.appName,
sessionDuration: applicationConfiguration.sessionDuration sessionDuration: appConfig.sessionDuration
}; };
const formSchema = z.object({ const formSchema = z.object({
@@ -32,15 +33,14 @@
} }
) )
}); });
type FormSchema = typeof formSchema;
const { inputs, ...form } = createForm<FormSchema>(formSchema, updatedApplicationConfiguration); const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
async function onSubmit() { async function onSubmit() {
const data = form.validate(); const data = form.validate();
if (!data) return; if (!data) return;
isLoading = true; isLoading = true;
await callback(data); await callback(data).finally(() => (isLoading = false));
isLoading = false; toast.success('Application configuration updated successfully');
} }
</script> </script>

View File

@@ -1,17 +1,17 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import OIDCService from '$lib/services/oidc-service'; import OIDCService from '$lib/services/oidc-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import clientSecretStore from '$lib/stores/client-secret-store';
import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type'; import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideMinus } from 'lucide-svelte'; import { LucideMinus } from 'lucide-svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import OIDCClientForm from './oidc-client-form.svelte'; import OIDCClientForm from './oidc-client-form.svelte';
import OIDCClientList from './oidc-client-list.svelte'; import OIDCClientList from './oidc-client-list.svelte';
import { axiosErrorToast } from '$lib/utils/error-util';
import clientSecretStore from '$lib/stores/client-secret-store';
import { goto } from '$app/navigation';
import applicationConfigurationStore from '$lib/stores/application-configuration-store';
let { data } = $props(); let { data } = $props();
let clients = $state(data); let clients = $state(data);
@@ -22,7 +22,7 @@
async function createOIDCClient(client: OidcClientCreateWithLogo) { async function createOIDCClient(client: OidcClientCreateWithLogo) {
try { try {
const createdClient = await oidcService.createClient(client); const createdClient = await oidcService.createClient(client);
if(client.logo){ if (client.logo) {
await oidcService.updateClientLogo(createdClient, client.logo); await oidcService.updateClientLogo(createdClient, client.logo);
} }
const clientSecret = await oidcService.createClientSecret(createdClient.id); const clientSecret = await oidcService.createClientSecret(createdClient.id);
@@ -31,7 +31,7 @@
toast.success('OIDC client created successfully'); toast.success('OIDC client created successfully');
return true; return true;
} catch (e) { } catch (e) {
axiosErrorToast(e) axiosErrorToast(e);
return false; return false;
} }
} }
@@ -46,7 +46,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<Card.Title>Create OIDC Client</Card.Title> <Card.Title>Create OIDC Client</Card.Title>
<Card.Description>Add a new OIDC client to {$applicationConfigurationStore.appName}.</Card.Description> <Card.Description>Add a new OIDC client to {$appConfigStore.appName}.</Card.Description>
</div> </div>
{#if !expandAddClient} {#if !expandAddClient}
<Button on:click={() => (expandAddClient = true)}>Add OIDC Client</Button> <Button on:click={() => (expandAddClient = true)}>Add OIDC Client</Button>

View File

@@ -2,7 +2,7 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import applicationConfigurationStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import type { Paginated } from '$lib/types/pagination.type'; import type { Paginated } from '$lib/types/pagination.type';
import type { User, UserCreate } from '$lib/types/user.type'; import type { User, UserCreate } from '$lib/types/user.type';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
@@ -42,9 +42,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<Card.Title>Create User</Card.Title> <Card.Title>Create User</Card.Title>
<Card.Description <Card.Description>Add a new user to {$appConfigStore.appName}.</Card.Description>
>Add a new user to {$applicationConfigurationStore.appName}.</Card.Description
>
</div> </div>
{#if !expandAddUser} {#if !expandAddUser}
<Button on:click={() => (expandAddUser = true)}>Add User</Button> <Button on:click={() => (expandAddUser = true)}>Add User</Button>

View File

@@ -0,0 +1,13 @@
import AuditLogService from '$lib/services/audit-log-service';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => {
const auditLogService = new AuditLogService(cookies.get('access_token'));
const auditLogs = await auditLogService.list({
limit: 15,
page: 1,
});
return {
auditLogs
};
};

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import AuditLogList from './audit-log-list.svelte';
let { data } = $props();
</script>
<svelte:head>
<title>Audit Log</title>
</svelte:head>
<Card.Root>
<Card.Header>
<Card.Title>Audit Log</Card.Title>
<Card.Description class="mt-1">See your account activities from the last 3 months.</Card.Description>
</Card.Header>
<Card.Content>
<AuditLogList auditLogs={data.auditLogs} />
</Card.Content>
</Card.Root>

View File

@@ -0,0 +1,95 @@
<script lang="ts">
import { Badge } from '$lib/components/ui/badge';
import * as Pagination from '$lib/components/ui/pagination';
import * as Table from '$lib/components/ui/table';
import AuditLogService from '$lib/services/audit-log-service';
import type { AuditLog } from '$lib/types/audit-log.type';
import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
let { auditLogs: initialAuditLog }: { auditLogs: Paginated<AuditLog> } = $props();
let auditLogs = $state<Paginated<AuditLog>>(initialAuditLog);
const auditLogService = new AuditLogService();
let pagination = $state<PaginationRequest>({
page: 1,
limit: 15
});
function toFriendlyEventString(event: string) {
const words = event.split('_');
const capitalizedWords = words.map((word) => {
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
});
return capitalizedWords.join(' ');
}
</script>
<Table.Root>
<Table.Header class="whitespace-nowrap">
<Table.Row>
<Table.Head>Time</Table.Head>
<Table.Head>Event</Table.Head>
<Table.Head>IP Address</Table.Head>
<Table.Head>Device</Table.Head>
<Table.Head>Client</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body class="whitespace-nowrap">
{#if auditLogs.data.length === 0}
<Table.Row>
<Table.Cell colspan={6} class="text-center">No logs found</Table.Cell>
</Table.Row>
{:else}
{#each auditLogs.data as auditLog}
<Table.Row>
<Table.Cell>{new Date(auditLog.createdAt).toLocaleString()}</Table.Cell>
<Table.Cell>
<Badge variant="outline">{toFriendlyEventString(auditLog.event)}</Badge>
</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

@@ -21,6 +21,33 @@ test('Update general configuration', async ({ page }) => {
await expect(page.getByLabel('Session Duration')).toHaveValue('30'); await expect(page.getByLabel('Session Duration')).toHaveValue('30');
}); });
test('Update email configuration', async ({ page }) => {
await page.goto('/settings/admin/application-configuration');
await page.getByLabel('SMTP Host').fill('smtp.gmail.com');
await page.getByLabel('SMTP Port').fill('587');
await page.getByLabel('SMTP User').fill('test@gmail.com');
await page.getByLabel('SMTP Password').fill('password');
await page.getByLabel('SMTP From').fill('test@gmail.com');
await page.getByRole('button', { name: 'Enable' }).click();
await page.getByRole('status').click();
await expect(page.getByRole('status')).toHaveText('Email configuration updated successfully');
await expect(page.getByRole('button', { name: 'Disable' })).toBeVisible();
await page.reload();
await expect(page.getByLabel('SMTP Host')).toHaveValue('smtp.gmail.com');
await expect(page.getByLabel('SMTP Port')).toHaveValue('587');
await expect(page.getByLabel('SMTP User')).toHaveValue('test@gmail.com');
await expect(page.getByLabel('SMTP Password')).toHaveValue('password');
await expect(page.getByLabel('SMTP From')).toHaveValue('test@gmail.com');
await page.getByRole('button', { name: 'Disable' }).click();
await expect(page.getByRole('status')).toHaveText('Email disabled successfully');
});
test('Update application images', async ({ page }) => { test('Update application images', async ({ page }) => {
await page.goto('/settings/admin/application-configuration'); await page.goto('/settings/admin/application-configuration');

View File

@@ -1,5 +1,5 @@
:80 { :80 {
reverse_proxy /api/* http://localhost:8080 reverse_proxy /api/* http://localhost:8080
reverse_proxy /.well-known/* http://localhost:8080 reverse_proxy /.well-known/* http://localhost:8080
reverse_proxy /* http://localhost:3000 reverse_proxy /* http://localhost:3000

View File

@@ -0,0 +1,16 @@
:80 {
reverse_proxy /api/* http://localhost:8080 {
trusted_proxies 0.0.0.0/0
}
reverse_proxy /.well-known/* http://localhost:8080 {
trusted_proxies 0.0.0.0/0
}
reverse_proxy /* http://localhost:3000 {
trusted_proxies 0.0.0.0/0
}
log {
output file /var/log/caddy/access.log
level WARN
}
}

View File

@@ -1,4 +1,3 @@
echo "Starting frontend..." echo "Starting frontend..."
node frontend/build & node frontend/build &
@@ -6,6 +5,12 @@ echo "Starting backend..."
cd backend && ./pocket-id-backend & cd backend && ./pocket-id-backend &
echo "Starting Caddy..." echo "Starting Caddy..."
caddy start --config /etc/caddy/Caddyfile &
# Check if TRUST_PROXY is set to true and use the appropriate Caddyfile
if [ "$TRUST_PROXY" = "true" ]; then
caddy start --config /etc/caddy/Caddyfile.trust-proxy &
else
caddy start --config /etc/caddy/Caddyfile &
fi
wait wait