Compare commits

...

16 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
Elias Schneider
4010ee27d6 release: 0.4.1 2024-09-06 09:24:42 +02:00
Elias Schneider
4e7574a297 feat: add name claim to userinfo endpoint and id token 2024-09-06 09:19:13 +02:00
Elias Schneider
8038a111dd fix: show error message if error occurs while authorizing new client 2024-09-06 08:58:23 +02:00
Elias Schneider
c6f83a581a fix: limit width of content on large screens 2024-09-06 08:57:48 +02:00
Elias Schneider
8ad632e6c1 release: 0.4.0 2024-09-03 22:42:22 +02:00
Elias Schneider
903b0b3918 feat: add support for more username formats 2024-09-03 22:35:18 +02:00
Elias Schneider
fd21ce5aac feat: add setup details to oidc client details 2024-09-03 22:24:29 +02:00
Elias Schneider
e7861df95a fix: non pointer passed to create user 2024-08-28 08:43:44 +02:00
Elias Schneider
8e27320649 refactor: rename user service 2024-08-28 08:22:27 +02:00
Elias Schneider
2b9413c757 fix: typo in hasLogo property of oidc dto 2024-08-28 08:21:46 +02:00
Elias Schneider
fd5a881cfb Merge branch 'main' of https://github.com/stonith404/pocket-id 2024-08-27 23:27:03 +02:00
Elias Schneider
28ed064668 fix: oidc client logo not displayed on authorize page 2024-08-27 23:26:56 +02:00
Elias Schneider
5446b46b65 Merge pull request #17 from stonith404/imgbot
[ImgBot] Optimize images
2024-08-26 13:59:58 +02:00
ImgBotApp
0ce6045657 [ImgBot] Optimize images
*Total -- 4,659.87kb -> 4,503.61kb (3.35%)

/frontend/tests/assets/nextcloud-logo.png -- 163.47kb -> 87.99kb (46.17%)
/frontend/tests/assets/pingvin-share-logo.png -- 85.61kb -> 58.04kb (32.2%)
/backend/images/logo.svg -- 0.68kb -> 0.53kb (22.56%)
/frontend/tests/assets/clouds.jpg -- 577.56kb -> 528.14kb (8.56%)
/backend/images/background.jpg -- 3,832.55kb -> 3,828.92kb (0.09%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
2024-08-26 11:10:18 +00:00
67 changed files with 1076 additions and 236 deletions

View File

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

View File

@@ -1 +1 @@
0.3.1
0.5.0

View File

@@ -1,3 +1,38 @@
## [](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)
### Features
* add name claim to userinfo endpoint and id token ([4e7574a](https://github.com/stonith404/pocket-id/commit/4e7574a297307395603267c7a3285d538d4111d8))
### Bug Fixes
* limit width of content on large screens ([c6f83a5](https://github.com/stonith404/pocket-id/commit/c6f83a581ad385391d77fec7eeb385060742f097))
* show error message if error occurs while authorizing new client ([8038a11](https://github.com/stonith404/pocket-id/commit/8038a111dd7fa8f5d421b29c3bc0c11d865dc71b))
## [](https://github.com/stonith404/pocket-id/compare/v0.3.1...v) (2024-09-03)
### Features
* add setup details to oidc client details ([fd21ce5](https://github.com/stonith404/pocket-id/commit/fd21ce5aac1daeba04e4e7399a0720338ea710c2))
* add support for more username formats ([903b0b3](https://github.com/stonith404/pocket-id/commit/903b0b39181c208e9411ee61849d2671e7c56dc5))
### Bug Fixes
* non pointer passed to create user ([e7861df](https://github.com/stonith404/pocket-id/commit/e7861df95a6beecab359d1c56f4383373f74bb73))
* oidc client logo not displayed on authorize page ([28ed064](https://github.com/stonith404/pocket-id/commit/28ed064668afeec8f80adda59ba94f1fc2fbce17))
* typo in hasLogo property of oidc dto ([2b9413c](https://github.com/stonith404/pocket-id/commit/2b9413c7575e1322f8547490a9b02a1836bad549))
## [](https://github.com/stonith404/pocket-id/compare/v0.3.0...v) (2024-08-24)

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.
#### Setup
Run `caddy run --config Caddyfile` in the root folder.
Run `caddy run --config reverse-proxy/Caddyfile` in the root folder.
### 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
FROM node:20-alpine
RUN apk add --no-cache caddy
COPY ./Caddyfile /etc/caddy/Caddyfile
COPY ./reverse-proxy /etc/caddy/
WORKDIR /app
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/migrations ./backend/migrations
COPY --from=backend-builder /app/backend/email-templates ./backend/email-templates
COPY --from=backend-builder /app/backend/images ./backend/images
COPY ./scripts ./scripts

View File

@@ -147,6 +147,7 @@ docker compose up -d
| Variable | Default Value | Recommended to change | Description |
| ---------------------- | ----------------------- | --------------------- | --------------------------------------------- |
| `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. |
| `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. |

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/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/mileusna/useragent v1.3.4
golang.org/x/crypto v0.26.0
golang.org/x/time v0.6.0
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-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/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/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

After

Width:  |  Height:  |  Size: 3.7 MiB

View File

@@ -1,17 +1 @@
<svg id="a"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1015 1015">
<path d="M506.6,0c209.52,0,379.98,170.45,379.98,379.96,0,82.33-25.9,160.68-74.91,226.54-48.04,64.59-113.78,111.51-190.13,135.71l-21.1,6.7-50.29-248.04,13.91-6.73c45.41-21.95,74.76-68.71,74.76-119.11,0-72.91-59.31-132.23-132.21-132.23s-132.23,59.32-132.23,132.23c0,50.4,29.36,97.16,74.77,119.11l13.65,6.61-81.01,499.24h-226.36V0h351.18Z" />
<style>
@media (prefers-color-scheme: dark) {
#a path {
fill: #ffffff;
}
}
@media (prefers-color-scheme: light) {
#a path {
fill: #000000;
}
}
</style>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" id="a" viewBox="0 0 1015 1015"><path d="M506.6,0c209.52,0,379.98,170.45,379.98,379.96,0,82.33-25.9,160.68-74.91,226.54-48.04,64.59-113.78,111.51-190.13,135.71l-21.1,6.7-50.29-248.04,13.91-6.73c45.41-21.95,74.76-68.71,74.76-119.11,0-72.91-59.31-132.23-132.21-132.23s-132.23,59.32-132.23,132.23c0,50.4,29.36,97.16,74.77,119.11l13.65,6.61-81.01,499.24h-226.36V0h351.18Z"/><style>@media (prefers-color-scheme:dark){#a path{fill:#fff}}@media (prefers-color-scheme:light){#a path{fill:#000}}</style></svg>

Before

Width:  |  Height:  |  Size: 696 B

After

Width:  |  Height:  |  Size: 539 B

View File

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

View File

@@ -20,9 +20,9 @@ func NewAppConfigController(
acc := &AppConfigController{
appConfigService: appConfigService,
}
group.GET("/application-configuration", acc.listApplicationConfigurationHandler)
group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllApplicationConfigurationHandler)
group.PUT("/application-configuration", acc.updateApplicationConfigurationHandler)
group.GET("/application-configuration", acc.listAppConfigHandler)
group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler)
group.PUT("/application-configuration", acc.updateAppConfigHandler)
group.GET("/application-configuration/logo", acc.getLogoHandler)
group.GET("/application-configuration/background-image", acc.getBackgroundImageHandler)
@@ -36,8 +36,8 @@ type AppConfigController struct {
appConfigService *service.AppConfigService
}
func (acc *AppConfigController) listApplicationConfigurationHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListApplicationConfiguration(false)
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListAppConfig(false)
if err != nil {
utils.ControllerError(c, err)
return
@@ -52,8 +52,8 @@ func (acc *AppConfigController) listApplicationConfigurationHandler(c *gin.Conte
c.JSON(200, configVariablesDto)
}
func (acc *AppConfigController) listAllApplicationConfigurationHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListApplicationConfiguration(true)
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListAppConfig(true)
if err != nil {
utils.ControllerError(c, err)
return
@@ -68,14 +68,14 @@ func (acc *AppConfigController) listAllApplicationConfigurationHandler(c *gin.Co
c.JSON(200, configVariablesDto)
}
func (acc *AppConfigController) updateApplicationConfigurationHandler(c *gin.Context) {
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
var input dto.AppConfigUpdateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err)
return
}
savedConfigVariables, err := acc.appConfigService.UpdateApplicationConfiguration(input)
savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(input)
if err != nil {
utils.ControllerError(c, err)
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
}
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 errors.Is(err, common.ErrOidcMissingAuthorization) {
utils.CustomControllerError(c, http.StatusForbidden, err.Error())
@@ -73,7 +73,7 @@ func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
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 errors.Is(err, common.ErrOidcInvalidCallbackURL) {
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())

View File

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

View File

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

View File

@@ -14,4 +14,10 @@ type AppConfigVariableDto struct {
type AppConfigUpdateDto struct {
AppName string `json:"appName" binding:"required,min=1,max=30"`
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

@@ -1,13 +1,13 @@
package dto
type PublicOidcClientDto struct {
ID string `json:"id"`
Name string `json:"name"`
ID string `json:"id"`
Name string `json:"name"`
HasLogo bool `json:"hasLogo"`
}
type OidcClientDto struct {
PublicOidcClientDto
HasLogo bool `json:"hasLogo"`
CallbackURLs []string `json:"callbackURLs"`
CreatedBy UserDto `json:"createdBy"`
}

View File

@@ -23,8 +23,3 @@ type OneTimeAccessTokenCreateDto struct {
UserID string `json:"userId" binding:"required"`
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
}
type LoginUserDto struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}

View File

@@ -20,7 +20,10 @@ var validateUrlList validator.Func = func(fl validator.FieldLevel) bool {
}
var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
regex := "^[a-z0-9_]*$"
// [a-zA-Z0-9] : The username must start with an alphanumeric character
// [a-zA-Z0-9_.@-]* : The rest of the username can contain alphanumeric characters, dots, underscores, hyphens, and "@" symbols
// [a-zA-Z0-9]$ : The username must end with an alphanumeric character
regex := "^[a-zA-Z0-9][a-zA-Z0-9_.@-]*[a-zA-Z0-9]$"
matched, _ := regexp.MatchString(regex, fl.Field().String())
return matched
}

View File

@@ -21,7 +21,6 @@ func RegisterJobs(db *gorm.DB) {
registerJob(scheduler, "ClearWebauthnSessions", "0 3 * * *", jobs.clearWebauthnSessions)
registerJob(scheduler, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens)
registerJob(scheduler, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes)
scheduler.Start()
}
@@ -29,17 +28,24 @@ type Jobs struct {
db *gorm.DB
}
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
func (j *Jobs) clearWebauthnSessions() 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 {
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 {
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) {

View File

@@ -13,4 +13,11 @@ type AppConfig struct {
BackgroundImageType AppConfigVariable
LogoImageType 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,
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
tx := s.db.Begin()
@@ -67,19 +92,19 @@ func (s *AppConfigService) UpdateApplicationConfiguration(input dto.AppConfigUpd
key := field.Tag.Get("json")
value := rv.FieldByName(field.Name).String()
var applicationConfigurationVariable model.AppConfigVariable
if err := tx.First(&applicationConfigurationVariable, "key = ? AND is_internal = false", key).Error; err != nil {
var appConfigVariable model.AppConfigVariable
if err := tx.First(&appConfigVariable, "key = ? AND is_internal = false", key).Error; err != nil {
tx.Rollback()
return nil, err
}
applicationConfigurationVariable.Value = value
if err := tx.Save(&applicationConfigurationVariable).Error; err != nil {
appConfigVariable.Value = value
if err := tx.Save(&appConfigVariable).Error; err != nil {
tx.Rollback()
return nil, err
}
savedConfigVariables = append(savedConfigVariables, applicationConfigurationVariable)
savedConfigVariables = append(savedConfigVariables, appConfigVariable)
}
tx.Commit()
@@ -101,7 +126,7 @@ func (s *AppConfigService) UpdateImageType(imageName string, fileType string) er
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 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 {
db *gorm.DB
jwtService *JwtService
db *gorm.DB
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{
db: db,
jwtService: jwtService,
db: db,
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
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)
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
if err := s.db.First(&client, "id = ?", input.ClientID).Error; err != nil {
return "", "", err
@@ -71,7 +81,13 @@ func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto
}
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) {
@@ -303,6 +319,7 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
profileClaims := map[string]interface{}{
"given_name": user.FirstName,
"family_name": user.LastName,
"name": user.FirstName + " " + user.LastName,
"preferred_username": user.Username,
}

View File

@@ -48,20 +48,20 @@ func (s *UserService) DeleteUser(userID string) error {
}
func (s *UserService) CreateUser(input dto.UserCreateDto) (model.User, error) {
user := &model.User{
user := model.User{
FirstName: input.FirstName,
LastName: input.LastName,
Email: input.Email,
Username: input.Username,
IsAdmin: input.IsAdmin,
}
if err := s.db.Create(user).Error; err != nil {
if err := s.db.Create(&user).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.User{}, s.checkDuplicatedFields(*user)
return model.User{}, s.checkDuplicatedFields(user)
}
return model.User{}, err
}
return *user, nil
return user, nil
}
func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, updateOwnUser bool) (model.User, error) {

View File

@@ -12,11 +12,13 @@ import (
)
type WebAuthnService struct {
db *gorm.DB
webAuthn *webauthn.WebAuthn
db *gorm.DB
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{
RPDisplayName: appConfigService.DbConfig.AppName.Value,
RPID: utils.GetHostFromURL(common.EnvConfig.AppURL),
@@ -36,7 +38,7 @@ func NewWebAuthnService(db *gorm.DB, appConfigService *AppConfigService) *WebAut
}
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) {
@@ -129,10 +131,10 @@ func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions
}, 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
if err := s.db.First(&storedSession, "id = ?", sessionID).Error; err != nil {
return model.User{}, err
return model.User{}, "", err
}
session := webauthn.SessionData{
@@ -149,14 +151,21 @@ func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssert
}, session, credentialAssertionData)
if err != nil {
return model.User{}, err
return model.User{}, "", err
}
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) {

View File

@@ -46,7 +46,7 @@ func handleValidationError(validationErrors validator.ValidationErrors) string {
case "email":
errorMessage = fmt.Sprintf("%s must be a valid email address", fieldName)
case "username":
errorMessage = fmt.Sprintf("%s must contain only lowercase letters, numbers, and underscores", fieldName)
errorMessage = fmt.Sprintf("%s must only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character", fieldName)
case "url":
errorMessage = fmt.Sprintf("%s must be a valid URL", fieldName)
case "min":

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

View File

@@ -23,7 +23,7 @@
<Avatar.Fallback>{initials}</Avatar.Fallback>
</Avatar.Root></DropdownMenu.Trigger
>
<DropdownMenu.Content class="w-40" align="start">
<DropdownMenu.Content class="min-w-40" align="start">
<DropdownMenu.Label class="font-normal">
<div class="flex flex-col space-y-1">
<p class="text-sm font-medium leading-none">

View File

@@ -1,22 +1,23 @@
<script lang="ts">
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 Logo from '../logo.svelte';
import HeaderAvatar from './header-avatar.svelte';
let isAuthPage = $derived(
!$page.error && ($page.url.pathname.startsWith('/authorize') || $page.url.pathname.startsWith('/login'))
!$page.error &&
($page.url.pathname.startsWith('/authorize') || $page.url.pathname.startsWith('/login'))
);
</script>
<div class=" w-full {isAuthPage ? 'absolute top-0 z-10 mt-4' : 'border-b'}">
<div class="mx-auto flex w-full max-w-[1520px] items-center justify-between px-4 md:px-10">
<div class="mx-auto flex w-full max-w-[1640px] items-center justify-between px-4 md:px-10">
<div class="flex h-16 items-center">
{#if !isAuthPage}
<Logo class="mr-3 h-10 w-10" />
<h1 class="text-lg font-medium" data-testid="application-name">
{$applicationConfigurationStore.appName}
{$appConfigStore.appName}
</h1>
{/if}
</div>

View File

@@ -12,7 +12,7 @@
<tr
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
)}
{...$$restProps}

View File

@@ -1,29 +1,29 @@
import type {
AllApplicationConfiguration,
ApplicationConfigurationRawResponse
AllAppConfig,
AppConfigRawResponse
} from '$lib/types/application-configuration';
import APIService from './api-service';
export default class ApplicationConfigurationService extends APIService {
export default class AppConfigService extends APIService {
async list(showAll = false) {
let url = '/application-configuration';
if (showAll) {
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 }) => {
(applicationConfiguration as any)[key] = value;
(appConfig as any)[key] = value;
});
return applicationConfiguration as AllApplicationConfiguration;
return appConfig as AllAppConfig;
}
async update(applicationConfiguration: AllApplicationConfiguration) {
const res = await this.api.put('/application-configuration', applicationConfiguration);
return res.data as AllApplicationConfiguration;
async update(appConfig: AllAppConfig) {
const res = await this.api.put('/application-configuration', appConfig);
return res.data as AllAppConfig;
}
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 type { ApplicationConfiguration } from '$lib/types/application-configuration';
import AppConfigService from '$lib/services/app-config-service';
import type { AppConfig } from '$lib/types/application-configuration';
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 applicationConfiguration = await applicationConfigurationService.list();
applicationConfigurationStore.set(applicationConfiguration);
const appConfig = await appConfigService.list();
appConfigStore.set(appConfig);
};
const set = (applicationConfiguration: ApplicationConfiguration) => {
applicationConfigurationStore.set(applicationConfiguration);
}
const set = (appConfig: AppConfig) => {
appConfigStore.set(appConfig);
};
export default {
subscribe: applicationConfigurationStore.subscribe,
subscribe: appConfigStore.subscribe,
reload,
set
};

View File

@@ -1,13 +1,18 @@
export type AllApplicationConfiguration = {
export type AllAppConfig = {
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;
type: string;
value: string;
}[];
type: 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 type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ cookies }) => {
const userService = new UserService(cookies.get('access_token'));
const applicationConfigurationService = new ApplicationConfigurationService(
cookies.get('access_token')
);
const appConfigService = new AppConfigService(cookies.get('access_token'));
const user = await userService
.getCurrent()
.then((user) => user)
.catch(() => null);
const applicationConfiguration = await applicationConfigurationService
const appConfig = await appConfigService
.list()
.then((config) => config)
.catch((e) => {
@@ -24,6 +22,6 @@ export const load: LayoutServerLoad = async ({ cookies }) => {
});
return {
user,
applicationConfiguration
appConfig
};
};

View File

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

View File

@@ -4,7 +4,7 @@
import * as Card from '$lib/components/ui/card';
import OidcService from '$lib/services/oidc-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 { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { startAuthentication } from '@simplewebauthn/browser';
@@ -83,16 +83,17 @@
<SignInWrapper>
<ClientProviderImages {client} {success} error={!!errorMessage} />
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Sign in to {client.name}</h1>
{#if !authorizationRequired}
{#if errorMessage}
<p class="text-muted-foreground mb-10 mt-2">
{#if errorMessage}
{errorMessage}. Please try again.
{:else}
Do you want to sign in to <b>{client.name}</b> with your
<b>{$applicationConfigurationStore.appName}</b> account?
{/if}
{errorMessage}. Please try again.
</p>
{:else}
{/if}
{#if !authorizationRequired && !errorMessage}
<p class="text-muted-foreground mb-10 mt-2">
Do you want to sign in to <b>{client.name}</b> with your
<b>{$appConfigStore.appName}</b> account?
</p>
{:else if authorizationRequired}
<div transition:slide={{ duration: 300 }}>
<Card.Root class="mb-10 mt-6">
<Card.Header class="pb-5">

View File

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

View File

@@ -4,7 +4,7 @@
import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button';
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 type { User } from '$lib/types/user.type.js';
import { axiosErrorToast } from '$lib/utils/error-util';
@@ -18,9 +18,9 @@
isLoading = true;
userService
.exchangeOneTimeAccessToken(data.token)
.then((user :User) => {
.then((user: User) => {
userStore.setUser(user);
goto('/settings')
goto('/settings');
})
.catch(axiosErrorToast);
isLoading = false;
@@ -29,15 +29,15 @@
<SignInWrapper>
<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" />
</div>
</div>
<h1 class="mt-5 font-playfair text-4xl font-bold">One Time Access</h1>
<p class="mt-2 text-muted-foreground">
You've been granted one-time access to your {$applicationConfigurationStore.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.
<h1 class="font-playfair mt-5 text-4xl font-bold">One Time Access</h1>
<p class="text-muted-foreground mt-2">
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.
</p>
<Button class="mt-5" {isLoading} on:click={authenticate}>Continue</Button>
</SignInWrapper>

View File

@@ -9,7 +9,10 @@
children: Snippet;
} = $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) {
links = [
@@ -22,24 +25,26 @@
</script>
<section>
<div class="h-screen w-full">
<main class="flex min-h-screen flex-1 flex-col gap-4 bg-muted/40 p-4 md:gap-8 md:p-10">
<div class="mx-auto grid w-full max-w-[1440px] gap-2">
<h1 class="text-3xl font-semibold">Settings</h1>
</div>
<div
class="mx-auto grid w-full max-w-[1440px] items-start gap-6 md:grid-cols-[180px_1fr] lg:grid-cols-[250px_1fr]"
>
<nav class="grid gap-4 text-sm text-muted-foreground">
{#each links as { href, label }}
<a {href} class={$page.url.pathname.startsWith(href) ? 'font-bold text-primary' : ''}>
{label}
</a>
{/each}
</nav>
<div class="flex flex-col gap-5">
{@render children()}
<div class="bg-muted/40 min-h-screen w-full">
<main class="mx-auto flex max-w-[1640px] flex-col gap-x-4 gap-y-10 p-4 md:p-10 lg:flex-row">
<div>
<div class="mx-auto grid w-full gap-2">
<h1 class="mb-5 text-3xl font-semibold">Settings</h1>
</div>
<div
class="mx-auto grid items-start gap-6 md:grid-cols-[180px_1fr] lg:grid-cols-[250px_1fr]"
>
<nav class="text-muted-foreground grid gap-4 text-sm">
{#each links as { href, label }}
<a {href} class={$page.url.pathname.startsWith(href) ? 'text-primary font-bold' : ''}>
{label}
</a>
{/each}
</nav>
</div>
</div>
<div class="flex w-full flex-col gap-5">
{@render children()}
</div>
</main>
</div>

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';
export const load: PageServerLoad = async ({ cookies }) => {
const applicationConfigurationService = new ApplicationConfigurationService(
cookies.get('access_token')
);
const applicationConfiguration = await applicationConfigurationService.list(true);
return { applicationConfiguration };
const appConfigService = new AppConfigService(cookies.get('access_token'));
const appConfig = await appConfigService.list(true);
return { appConfig };
};

View File

@@ -1,24 +1,30 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import ApplicationConfigurationService from '$lib/services/application-configuration-service';
import applicationConfigurationStore from '$lib/stores/application-configuration-store';
import type { AllApplicationConfiguration } from '$lib/types/application-configuration';
import AppConfigService from '$lib/services/app-config-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllAppConfig } from '$lib/types/application-configuration';
import { axiosErrorToast } from '$lib/utils/error-util';
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';
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) {
await applicationConfigurationService
.update(configuration)
.then(() => toast.success('Application configuration updated successfully'))
.catch(axiosErrorToast);
await applicationConfigurationStore.reload();
async function updateAppConfig(updatedAppConfig: Partial<AllAppConfig>) {
await appConfigService
.update({
...appConfig,
...updatedAppConfig
})
.catch((e) => {
axiosErrorToast(e);
throw e;
});
await appConfigStore.reload();
}
async function updateImages(
@@ -26,12 +32,10 @@
backgroundImage: File | null,
favicon: File | null
) {
const faviconPromise = favicon
? applicationConfigurationService.updateFavicon(favicon)
: Promise.resolve();
const logoPromise = logo ? applicationConfigurationService.updateLogo(logo) : Promise.resolve();
const faviconPromise = favicon ? appConfigService.updateFavicon(favicon) : Promise.resolve();
const logoPromise = logo ? appConfigService.updateLogo(logo) : Promise.resolve();
const backgroundImagePromise = backgroundImage
? applicationConfigurationService.updateBackgroundImage(backgroundImage)
? appConfigService.updateBackgroundImage(backgroundImage)
: Promise.resolve();
await Promise.all([logoPromise, backgroundImagePromise, faviconPromise])
@@ -49,7 +53,20 @@
<Card.Title>General</Card.Title>
</Card.Header>
<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.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">
import FormInput from '$lib/components/form-input.svelte';
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 { toast } from 'svelte-sonner';
import { z } from 'zod';
let {
callback,
applicationConfiguration
appConfig
}: {
applicationConfiguration: AllApplicationConfiguration;
callback: (user: AllApplicationConfiguration) => Promise<void>;
appConfig: AllAppConfig;
callback: (appConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props();
let isLoading = $state(false);
const updatedApplicationConfiguration: AllApplicationConfiguration = {
appName: applicationConfiguration.appName,
sessionDuration: applicationConfiguration.sessionDuration
const updatedAppConfig = {
appName: appConfig.appName,
sessionDuration: appConfig.sessionDuration
};
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() {
const data = form.validate();
if (!data) return;
isLoading = true;
await callback(data);
isLoading = false;
await callback(data).finally(() => (isLoading = false));
toast.success('Application configuration updated successfully');
}
</script>

View File

@@ -1,17 +1,17 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
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 { axiosErrorToast } from '$lib/utils/error-util';
import { LucideMinus } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import OIDCClientForm from './oidc-client-form.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 clients = $state(data);
@@ -22,7 +22,7 @@
async function createOIDCClient(client: OidcClientCreateWithLogo) {
try {
const createdClient = await oidcService.createClient(client);
if(client.logo){
if (client.logo) {
await oidcService.updateClientLogo(createdClient, client.logo);
}
const clientSecret = await oidcService.createClientSecret(createdClient.id);
@@ -31,7 +31,7 @@
toast.success('OIDC client created successfully');
return true;
} catch (e) {
axiosErrorToast(e)
axiosErrorToast(e);
return false;
}
}
@@ -46,7 +46,7 @@
<div class="flex items-center justify-between">
<div>
<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>
{#if !expandAddClient}
<Button on:click={() => (expandAddClient = true)}>Add OIDC Client</Button>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { beforeNavigate } from '$app/navigation';
import { page } from '$app/stores';
import { openConfirmDialog } from '$lib/components/confirm-dialog';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
@@ -10,13 +11,24 @@
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideChevronLeft, LucideRefreshCcw } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import OidcForm from '../oidc-client-form.svelte';
let { data } = $props();
let client = $state(data);
let showAllDetails = $state(false);
const oidcService = new OidcService();
const setupDetails = {
'Authorization URL': `https://${$page.url.hostname}/authorize`,
'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`,
'Token URL': `https://${$page.url.hostname}/api/oidc/token`,
'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`,
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`,
PKCE: 'Disabled'
};
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
let success = true;
const dataPromise = oidcService.updateClient(client.id, updatedClient);
@@ -74,23 +86,43 @@
<Card.Title>{client.name}</Card.Title>
</Card.Header>
<Card.Content>
<div class="flex">
<Label class="mb-0 w-44">Client ID</Label>
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
</div>
<div class="mt-3 flex items-center">
<Label class="mb-0 w-44">Client secret</Label>
<span class="text-muted-foreground text-sm" data-testid="client-secret"
>{$clientSecretStore ?? '••••••••••••••••••••••••••••••••'}</span
>
{#if !$clientSecretStore}
<Button
class="ml-2"
onclick={createClientSecret}
size="sm"
variant="ghost"
aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button
<div class="flex flex-col">
<div class="mb-2 flex">
<Label class="mb-0 w-44">Client ID</Label>
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
</div>
<div class="mb-2 mt-1 flex items-center">
<Label class="w-44">Client secret</Label>
<span class="text-muted-foreground text-sm" data-testid="client-secret"
>{$clientSecretStore ?? '••••••••••••••••••••••••••••••••'}</span
>
{#if !$clientSecretStore}
<Button
class="ml-2"
onclick={createClientSecret}
size="sm"
variant="ghost"
aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button
>
{/if}
</div>
{#if showAllDetails}
<div transition:slide>
{#each Object.entries(setupDetails) as [key, value]}
<div class="mb-5 flex">
<Label class="mb-0 w-44">{key}</Label>
<span class="text-muted-foreground text-sm">{value}</span>
</div>
{/each}
</div>
{/if}
{#if !showAllDetails}
<div class="mt-4 flex justify-center">
<Button on:click={() => (showAllDetails = true)} size="sm" variant="ghost"
>Show more details</Button
>
</div>
{/if}
</div>
</Card.Content>

View File

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

View File

@@ -32,7 +32,10 @@
.string()
.min(2)
.max(30)
.regex(/^[a-z0-9_]+$/, 'Only lowercase letters, numbers, and underscores are allowed'),
.regex(
/^[a-z0-9_@.-]+$/,
"Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols"
),
email: z.string().email(),
isAdmin: z.boolean()
});

View File

@@ -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');
});
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 }) => {
await page.goto('/settings/admin/application-configuration');

Binary file not shown.

Before

Width:  |  Height:  |  Size: 578 KiB

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,5 +1,5 @@
:80 {
reverse_proxy /api/* http://localhost:8080
reverse_proxy /api/* http://localhost:8080
reverse_proxy /.well-known/* http://localhost:8080
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..."
node frontend/build &
@@ -6,6 +5,12 @@ echo "Starting backend..."
cd backend && ./pocket-id-backend &
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