feat: allow sign in with email (#100)

This commit is contained in:
Elias Schneider
2025-01-19 15:30:31 +01:00
committed by GitHub
parent e284e352e2
commit 06b90eddd6
42 changed files with 422 additions and 145 deletions

View File

@@ -2,7 +2,6 @@ package bootstrap
import ( import (
_ "github.com/golang-migrate/migrate/v4/source/file" _ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/stonith404/pocket-id/backend/internal/job"
"github.com/stonith404/pocket-id/backend/internal/service" "github.com/stonith404/pocket-id/backend/internal/service"
) )
@@ -11,6 +10,5 @@ func Bootstrap() {
appConfigService := service.NewAppConfigService(db) appConfigService := service.NewAppConfigService(db)
initApplicationImages() initApplicationImages()
job.RegisterJobs(db)
initRouter(db, appConfigService) initRouter(db, appConfigService)
} }

View File

@@ -38,21 +38,25 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
auditLogService := service.NewAuditLogService(db, appConfigService, emailService, geoLiteService) auditLogService := service.NewAuditLogService(db, appConfigService, emailService, geoLiteService)
jwtService := service.NewJwtService(appConfigService) jwtService := service.NewJwtService(appConfigService)
webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService) webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
userService := service.NewUserService(db, jwtService, auditLogService) userService := service.NewUserService(db, jwtService, auditLogService, emailService)
customClaimService := service.NewCustomClaimService(db) customClaimService := service.NewCustomClaimService(db)
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService) oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
testService := service.NewTestService(db, appConfigService) testService := service.NewTestService(db, appConfigService)
userGroupService := service.NewUserGroupService(db) userGroupService := service.NewUserGroupService(db)
ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService) ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService)
rateLimitMiddleware := middleware.NewRateLimitMiddleware()
// Setup global middleware
r.Use(middleware.NewCorsMiddleware().Add()) r.Use(middleware.NewCorsMiddleware().Add())
r.Use(middleware.NewErrorHandlerMiddleware().Add()) r.Use(middleware.NewErrorHandlerMiddleware().Add())
r.Use(middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60)) r.Use(rateLimitMiddleware.Add(rate.Every(time.Second), 60))
r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false)) r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false))
job.RegisterLdapJobs(ldapService, appConfigService) job.RegisterLdapJobs(ldapService, appConfigService)
job.RegisterDbCleanupJobs(db)
// Initialize middleware // Initialize middleware for specific routes
jwtAuthMiddleware := middleware.NewJwtAuthMiddleware(jwtService, false) jwtAuthMiddleware := middleware.NewJwtAuthMiddleware(jwtService, false)
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware() fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()

View File

@@ -97,7 +97,7 @@ func (e *MissingPermissionError) HttpStatusCode() int { return http.StatusForbid
type TooManyRequestsError struct{} type TooManyRequestsError struct{}
func (e *TooManyRequestsError) Error() string { func (e *TooManyRequestsError) Error() string {
return "Too many requests. Please wait a while before trying again." return "Too many requests"
} }
func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests } func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests }

View File

@@ -30,6 +30,7 @@ func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt
group.POST("/users/:id/one-time-access-token", jwtAuthMiddleware.Add(true), uc.createOneTimeAccessTokenHandler) group.POST("/users/:id/one-time-access-token", jwtAuthMiddleware.Add(true), uc.createOneTimeAccessTokenHandler)
group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler) group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler)
group.POST("/one-time-access-token/setup", uc.getSetupAccessTokenHandler) group.POST("/one-time-access-token/setup", uc.getSetupAccessTokenHandler)
group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.requestOneTimeAccessEmailHandler)
} }
type UserController struct { type UserController struct {
@@ -145,7 +146,7 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
return return
} }
token, err := uc.userService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt, c.ClientIP(), c.Request.UserAgent()) token, err := uc.userService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return
@@ -154,8 +155,24 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{"token": token}) c.JSON(http.StatusCreated, gin.H{"token": token})
} }
func (uc *UserController) requestOneTimeAccessEmailHandler(c *gin.Context) {
var input dto.OneTimeAccessEmailDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
err := uc.userService.RequestOneTimeAccessEmail(input.Email, input.RedirectPath)
if err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) { func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Param("token")) user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Param("token"), c.ClientIP(), c.Request.UserAgent())
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return

View File

@@ -16,7 +16,6 @@ type AppConfigUpdateDto struct {
SessionDuration string `json:"sessionDuration" binding:"required"` SessionDuration string `json:"sessionDuration" binding:"required"`
EmailsVerified string `json:"emailsVerified" binding:"required"` EmailsVerified string `json:"emailsVerified" binding:"required"`
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"` AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
EmailEnabled string `json:"emailEnabled" binding:"required"`
SmtHost string `json:"smtpHost"` SmtHost string `json:"smtpHost"`
SmtpPort string `json:"smtpPort"` SmtpPort string `json:"smtpPort"`
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"` SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
@@ -38,4 +37,6 @@ type AppConfigUpdateDto struct {
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"` LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`
LdapAttributeGroupName string `json:"ldapAttributeGroupName"` LdapAttributeGroupName string `json:"ldapAttributeGroupName"`
LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"` LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"`
EmailOneTimeAccessEnabled string `json:"emailOneTimeAccessEnabled" binding:"required"`
EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"`
} }

View File

@@ -26,3 +26,8 @@ type OneTimeAccessTokenCreateDto struct {
UserID string `json:"userId" binding:"required"` UserID string `json:"userId" binding:"required"`
ExpiresAt time.Time `json:"expiresAt" binding:"required"` ExpiresAt time.Time `json:"expiresAt" binding:"required"`
} }
type OneTimeAccessEmailDto struct {
Email string `json:"email" binding:"required,email"`
RedirectPath string `json:"redirectPath"`
}

View File

@@ -10,7 +10,7 @@ import (
"time" "time"
) )
func RegisterJobs(db *gorm.DB) { func RegisterDbCleanupJobs(db *gorm.DB) {
scheduler, err := gocron.NewScheduler() scheduler, err := gocron.NewScheduler()
if err != nil { if err != nil {
log.Fatalf("Failed to create a new scheduler: %s", err) log.Fatalf("Failed to create a new scheduler: %s", err)

View File

@@ -16,8 +16,12 @@ func NewRateLimitMiddleware() *RateLimitMiddleware {
} }
func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc { func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
// Map to store the rate limiters per IP
var clients = make(map[string]*client)
var mu sync.Mutex
// Start the cleanup routine // Start the cleanup routine
go cleanupClients() go cleanupClients(&mu, clients)
return func(c *gin.Context) { return func(c *gin.Context) {
ip := c.ClientIP() ip := c.ClientIP()
@@ -29,7 +33,7 @@ func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
return return
} }
limiter := getLimiter(ip, limit, burst) limiter := getLimiter(ip, limit, burst, &mu, clients)
if !limiter.Allow() { if !limiter.Allow() {
c.Error(&common.TooManyRequestsError{}) c.Error(&common.TooManyRequestsError{})
c.Abort() c.Abort()
@@ -45,12 +49,8 @@ type client struct {
lastSeen time.Time lastSeen time.Time
} }
// Map to store the rate limiters per IP
var clients = make(map[string]*client)
var mu sync.Mutex
// Cleanup routine to remove stale clients that haven't been seen for a while // Cleanup routine to remove stale clients that haven't been seen for a while
func cleanupClients() { func cleanupClients(mu *sync.Mutex, clients map[string]*client) {
for { for {
time.Sleep(time.Minute) time.Sleep(time.Minute)
mu.Lock() mu.Lock()
@@ -64,7 +64,7 @@ func cleanupClients() {
} }
// getLimiter retrieves the rate limiter for a given IP address, creating one if it doesn't exist // getLimiter retrieves the rate limiter for a given IP address, creating one if it doesn't exist
func getLimiter(ip string, limit rate.Limit, burst int) *rate.Limiter { func getLimiter(ip string, limit rate.Limit, burst int, mu *sync.Mutex, clients map[string]*client) *rate.Limiter {
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()

View File

@@ -20,7 +20,6 @@ type AppConfig struct {
LogoLightImageType AppConfigVariable LogoLightImageType AppConfigVariable
LogoDarkImageType AppConfigVariable LogoDarkImageType AppConfigVariable
// Email // Email
EmailEnabled AppConfigVariable
SmtpHost AppConfigVariable SmtpHost AppConfigVariable
SmtpPort AppConfigVariable SmtpPort AppConfigVariable
SmtpFrom AppConfigVariable SmtpFrom AppConfigVariable
@@ -28,6 +27,8 @@ type AppConfig struct {
SmtpPassword AppConfigVariable SmtpPassword AppConfigVariable
SmtpTls AppConfigVariable SmtpTls AppConfigVariable
SmtpSkipCertVerify AppConfigVariable SmtpSkipCertVerify AppConfigVariable
EmailLoginNotificationEnabled AppConfigVariable
EmailOneTimeAccessEnabled AppConfigVariable
// LDAP // LDAP
LdapEnabled AppConfigVariable LdapEnabled AppConfigVariable
LdapUrl AppConfigVariable LdapUrl AppConfigVariable

View File

@@ -74,11 +74,6 @@ var defaultDbConfig = model.AppConfig{
DefaultValue: "svg", DefaultValue: "svg",
}, },
// Email // Email
EmailEnabled: model.AppConfigVariable{
Key: "emailEnabled",
Type: "bool",
DefaultValue: "false",
},
SmtpHost: model.AppConfigVariable{ SmtpHost: model.AppConfigVariable{
Key: "smtpHost", Key: "smtpHost",
Type: "string", Type: "string",
@@ -109,6 +104,17 @@ var defaultDbConfig = model.AppConfig{
Type: "bool", Type: "bool",
DefaultValue: "false", DefaultValue: "false",
}, },
EmailLoginNotificationEnabled: model.AppConfigVariable{
Key: "emailLoginNotificationEnabled",
Type: "bool",
DefaultValue: "false",
},
EmailOneTimeAccessEnabled: model.AppConfigVariable{
Key: "emailOneTimeAccessEnabled",
Type: "bool",
IsPublic: true,
DefaultValue: "false",
},
// LDAP // LDAP
LdapEnabled: model.AppConfigVariable{ LdapEnabled: model.AppConfigVariable{
Key: "ldapEnabled", Key: "ldapEnabled",
@@ -182,6 +188,13 @@ func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]mode
key := field.Tag.Get("json") key := field.Tag.Get("json")
value := rv.FieldByName(field.Name).String() value := rv.FieldByName(field.Name).String()
// If the emailEnabled is set to false, disable the emailOneTimeAccessEnabled
if key == s.DbConfig.EmailOneTimeAccessEnabled.Key {
if rv.FieldByName("EmailEnabled").String() == "false" {
value = "false"
}
}
var appConfigVariable model.AppConfigVariable var appConfigVariable model.AppConfigVariable
if err := tx.First(&appConfigVariable, "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()

View File

@@ -58,8 +58,8 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID
return createdAuditLog return createdAuditLog
} }
// If the user hasn't logged in from the same device before, send an email // If the user hasn't logged in from the same device before and email notifications are enabled, send an email
if count <= 1 { if s.appConfigService.DbConfig.EmailLoginNotificationEnabled.Value == "true" && count <= 1 {
go func() { go func() {
var user model.User var user model.User
s.db.Where("id = ?", userID).First(&user) s.db.Where("id = ?", userID).First(&user)

View File

@@ -3,7 +3,6 @@ package service
import ( import (
"bytes" "bytes"
"crypto/tls" "crypto/tls"
"errors"
"fmt" "fmt"
"github.com/stonith404/pocket-id/backend/internal/common" "github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/model" "github.com/stonith404/pocket-id/backend/internal/model"
@@ -16,8 +15,13 @@ import (
"net/smtp" "net/smtp"
"net/textproto" "net/textproto"
ttemplate "text/template" ttemplate "text/template"
"time"
) )
var netDialer = &net.Dialer{
Timeout: 3 * time.Second,
}
type EmailService struct { type EmailService struct {
appConfigService *AppConfigService appConfigService *AppConfigService
db *gorm.DB db *gorm.DB
@@ -58,11 +62,6 @@ func (srv *EmailService) SendTestEmail(recipientUserId string) error {
} }
func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error { func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error {
// Check if SMTP settings are set
if srv.appConfigService.DbConfig.EmailEnabled.Value != "true" {
return errors.New("email not enabled")
}
data := &email.TemplateData[V]{ data := &email.TemplateData[V]{
AppName: srv.appConfigService.DbConfig.AppName.Value, AppName: srv.appConfigService.DbConfig.AppName.Value,
LogoURL: common.EnvConfig.AppURL + "/api/application-configuration/logo", LogoURL: common.EnvConfig.AppURL + "/api/application-configuration/logo",
@@ -112,11 +111,13 @@ func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.T
tlsConfig, tlsConfig,
) )
} }
defer client.Quit()
if err != nil { if err != nil {
return fmt.Errorf("failed to connect to SMTP server: %w", err) return fmt.Errorf("failed to connect to SMTP server: %w", err)
} }
defer client.Close()
smtpUser := srv.appConfigService.DbConfig.SmtpUser.Value smtpUser := srv.appConfigService.DbConfig.SmtpUser.Value
smtpPassword := srv.appConfigService.DbConfig.SmtpPassword.Value smtpPassword := srv.appConfigService.DbConfig.SmtpPassword.Value
@@ -141,7 +142,11 @@ func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.T
} }
func (srv *EmailService) connectToSmtpServerUsingImplicitTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) { func (srv *EmailService) connectToSmtpServerUsingImplicitTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
conn, err := tls.Dial("tcp", serverAddr, tlsConfig) tlsDialer := &tls.Dialer{
NetDialer: netDialer,
Config: tlsConfig,
}
conn, err := tlsDialer.Dial("tcp", serverAddr)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err) return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
} }
@@ -156,7 +161,7 @@ func (srv *EmailService) connectToSmtpServerUsingImplicitTLS(serverAddr string,
} }
func (srv *EmailService) connectToSmtpServerUsingStartTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) { func (srv *EmailService) connectToSmtpServerUsingStartTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
conn, err := net.Dial("tcp", serverAddr) conn, err := netDialer.Dial("tcp", serverAddr)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err) return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
} }

View File

@@ -9,7 +9,7 @@ import (
/** /**
How to add new template: How to add new template:
- pick unique and descriptive template ${name} (for example "login-with-new-device") - pick unique and descriptive template ${name} (for example "login-with-new-device")
- in backend/email-templates/ create "${name}_html.tmpl" and "${name}_text.tmpl" - in backend/resources/email-templates/ create "${name}_html.tmpl" and "${name}_text.tmpl"
- create xxxxTemplate and xxxxTemplateData (for example NewLoginTemplate and NewLoginTemplateData) - create xxxxTemplate and xxxxTemplateData (for example NewLoginTemplate and NewLoginTemplateData)
- Path *must* be ${name} - Path *must* be ${name}
- add xxxTemplate.Path to "emailTemplatePaths" at the end - add xxxTemplate.Path to "emailTemplatePaths" at the end
@@ -27,6 +27,13 @@ var NewLoginTemplate = email.Template[NewLoginTemplateData]{
}, },
} }
var OneTimeAccessTemplate = email.Template[OneTimeAccessTemplateData]{
Path: "one-time-access",
Title: func(data *email.TemplateData[OneTimeAccessTemplateData]) string {
return "One time access"
},
}
var TestTemplate = email.Template[struct{}]{ var TestTemplate = email.Template[struct{}]{
Path: "test", Path: "test",
Title: func(data *email.TemplateData[struct{}]) string { Title: func(data *email.TemplateData[struct{}]) string {
@@ -42,5 +49,9 @@ type NewLoginTemplateData struct {
DateTime time.Time DateTime time.Time
} }
type OneTimeAccessTemplateData = struct {
Link string
}
// this is list of all template paths used for preloading templates // this is list of all template paths used for preloading templates
var emailTemplatesPaths = []string{NewLoginTemplate.Path, TestTemplate.Path} var emailTemplatesPaths = []string{NewLoginTemplate.Path, OneTimeAccessTemplate.Path, TestTemplate.Path}

View File

@@ -2,12 +2,17 @@ package service
import ( import (
"errors" "errors"
"fmt"
"github.com/stonith404/pocket-id/backend/internal/common" "github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto" "github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/model" "github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/model/types" "github.com/stonith404/pocket-id/backend/internal/model/types"
"github.com/stonith404/pocket-id/backend/internal/utils" "github.com/stonith404/pocket-id/backend/internal/utils"
"github.com/stonith404/pocket-id/backend/internal/utils/email"
"gorm.io/gorm" "gorm.io/gorm"
"log"
"net/url"
"strings"
"time" "time"
) )
@@ -15,10 +20,11 @@ type UserService struct {
db *gorm.DB db *gorm.DB
jwtService *JwtService jwtService *JwtService
auditLogService *AuditLogService auditLogService *AuditLogService
emailService *EmailService
} }
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService) *UserService { func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService) *UserService {
return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService} return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService, emailService: emailService}
} }
func (s *UserService) ListUsers(searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.User, utils.PaginationResponse, error) { func (s *UserService) ListUsers(searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.User, utils.PaginationResponse, error) {
@@ -99,7 +105,46 @@ func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, u
return user, nil return user, nil
} }
func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time, ipAddress, userAgent string) (string, error) { func (s *UserService) RequestOneTimeAccessEmail(emailAddress, redirectPath string) error {
var user model.User
if err := s.db.Where("email = ?", emailAddress).First(&user).Error; err != nil {
// Do not return error if user not found to prevent email enumeration
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
} else {
return err
}
}
oneTimeAccessToken, err := s.CreateOneTimeAccessToken(user.ID, time.Now().Add(time.Hour))
if err != nil {
return err
}
link := fmt.Sprintf("%s/login/%s", common.EnvConfig.AppURL, oneTimeAccessToken)
// Add redirect path to the link
if strings.HasPrefix(redirectPath, "/") {
encodedRedirectPath := url.QueryEscape(redirectPath)
link = fmt.Sprintf("%s?redirect=%s", link, encodedRedirectPath)
}
go func() {
err := SendEmail(s.emailService, email.Address{
Name: user.Username,
Email: user.Email,
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
Link: link,
})
if err != nil {
log.Printf("Failed to send email to '%s': %v\n", user.Email, err)
}
}()
return nil
}
func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time) (string, error) {
randomString, err := utils.GenerateRandomAlphanumericString(16) randomString, err := utils.GenerateRandomAlphanumericString(16)
if err != nil { if err != nil {
return "", err return "", err
@@ -115,12 +160,10 @@ func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Tim
return "", err return "", err
} }
s.auditLogService.Create(model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, userID, model.AuditLogData{})
return oneTimeAccessToken.Token, nil return oneTimeAccessToken.Token, nil
} }
func (s *UserService) ExchangeOneTimeAccessToken(token string) (model.User, string, error) { func (s *UserService) ExchangeOneTimeAccessToken(token string, ipAddress, userAgent string) (model.User, string, error) {
var oneTimeAccessToken model.OneTimeAccessToken var oneTimeAccessToken model.OneTimeAccessToken
if err := s.db.Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).Preload("User").First(&oneTimeAccessToken).Error; err != nil { if err := s.db.Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).Preload("User").First(&oneTimeAccessToken).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -137,6 +180,10 @@ func (s *UserService) ExchangeOneTimeAccessToken(token string) (model.User, stri
return model.User{}, "", err return model.User{}, "", err
} }
if ipAddress != "" && userAgent != "" {
s.auditLogService.Create(model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{})
}
return oneTimeAccessToken.User, accessToken, nil return oneTimeAccessToken.User, accessToken, nil
} }

View File

@@ -9,8 +9,6 @@ import (
ttemplate "text/template" ttemplate "text/template"
) )
const templateComponentsDir = "components"
type Template[V any] struct { type Template[V any] struct {
Path string Path string
Title func(data *TemplateData[V]) string Title func(data *TemplateData[V]) string

View File

@@ -76,5 +76,20 @@
font-size: 1rem; font-size: 1rem;
line-height: 1.5; line-height: 1.5;
} }
.button {
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
background-color: #000000;
color: #ffffff;
padding: 0.7rem 1.5rem;
outline: none;
border: none;
text-decoration: none;
}
.button-container {
text-align: center;
margin-top: 24px;
}
</style> </style>
{{ end }} {{ end }}

View File

@@ -1,7 +1,7 @@
{{ define "base" }} {{ define "base" }}
<div class="header"> <div class="header">
<div class="logo"> <div class="logo">
<img src="{{ .LogoURL }}" alt="Pocket ID"/> <img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
<h1>{{ .AppName }}</h1> <h1>{{ .AppName }}</h1>
</div> </div>
<div class="warning">Warning</div> <div class="warning">Warning</div>

View File

@@ -0,0 +1,17 @@
{{ define "base" }}
<div class="header">
<div class="logo">
<img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
<h1>{{ .AppName }}</h1>
</div>
</div>
<div class="content">
<h2>One-Time Access</h2>
<p class="message">
Click the button below to sign in to {{ .AppName }} with a one-time access link. This link expires in 15 minutes.
</p>
<div class="button-container">
<a class="button" href="{{ .Data.Link }}" class="button">Sign In</a>
</div>
</div>
{{ end -}}

View File

@@ -0,0 +1,8 @@
{{ define "base" -}}
One-Time Access
====================
Click the link below to sign in to {{ .AppName }} with a one-time access link. This link expires in 15 minutes.
{{ .Data.Link }}
{{ end -}}

View File

@@ -1,7 +1,7 @@
{{ define "base" -}} {{ define "base" -}}
<div class="header"> <div class="header">
<div class="logo"> <div class="logo">
<img src="{{ .LogoURL }}" alt="Pocket ID"/> <img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
<h1>{{ .AppName }}</h1> <h1>{{ .AppName }}</h1>
</div> </div>
</div> </div>

View File

@@ -0,0 +1 @@
UPDATE app_config_variables SET key = 'emailEnabled' WHERE key = 'emailLoginNotificationEnabled';

View File

@@ -0,0 +1 @@
UPDATE app_config_variables SET key = 'emailLoginNotificationEnabled' WHERE key = 'emailEnabled';

View File

@@ -0,0 +1 @@
UPDATE app_config_variables SET key = 'emailEnabled' WHERE key = 'emailLoginNotificationEnabled';

View File

@@ -0,0 +1 @@
UPDATE app_config_variables SET key = 'emailLoginNotificationEnabled' WHERE key = 'emailEnabled';

View File

@@ -2,22 +2,38 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { browserSupportsWebAuthn } from '@simplewebauthn/browser'; import { browserSupportsWebAuthn } from '@simplewebauthn/browser';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { Button } from './ui/button';
import * as Card from './ui/card'; import * as Card from './ui/card';
import WebAuthnUnsupported from './web-authn-unsupported.svelte'; import WebAuthnUnsupported from './web-authn-unsupported.svelte';
import { page } from '$app/stores';
let { let {
children children,
showEmailOneTimeAccessButton = false
}: { }: {
children: Snippet; children: Snippet;
showEmailOneTimeAccessButton?: boolean;
} = $props(); } = $props();
</script> </script>
<!-- Desktop -->
<div class="hidden h-screen items-center text-center lg:flex"> <div class="hidden h-screen items-center text-center lg:flex">
<div class="min-w-[650px] p-16"> <div class="h-full min-w-[650px] p-16 {showEmailOneTimeAccessButton ? 'pb-0' : ''}">
{#if browser && !browserSupportsWebAuthn()} {#if browser && !browserSupportsWebAuthn()}
<WebAuthnUnsupported /> <WebAuthnUnsupported />
{:else} {:else}
<div class="flex h-full flex-col">
<div class="flex flex-grow flex-col items-center justify-center">
{@render children()} {@render children()}
</div>
{#if showEmailOneTimeAccessButton}
<div class="mb-4 flex justify-center">
<Button href="/login/email?redirect={encodeURIComponent($page.url.pathname + $page.url.search)}" variant="link" class="text-muted-foreground text-xs">
Don't have access to your passkey?
</Button>
</div>
{/if}
</div>
{/if} {/if}
</div> </div>
<img <img
@@ -27,15 +43,25 @@
/> />
</div> </div>
<!-- Mobile -->
<div <div
class="flex h-screen items-center justify-center bg-[url('/api/application-configuration/background-image')] bg-cover bg-center text-center lg:hidden" class="flex h-screen items-center justify-center bg-[url('/api/application-configuration/background-image')] bg-cover bg-center text-center lg:hidden"
> >
<Card.Root class="mx-3"> <Card.Root class="mx-3">
<Card.CardContent class="px-4 py-10 sm:p-10"> <Card.CardContent
class="px-4 py-10 sm:p-10 {showEmailOneTimeAccessButton ? 'pb-3 sm:pb-3' : ''}"
>
{#if browser && !browserSupportsWebAuthn()} {#if browser && !browserSupportsWebAuthn()}
<WebAuthnUnsupported /> <WebAuthnUnsupported />
{:else} {:else}
{@render children()} {@render children()}
{#if showEmailOneTimeAccessButton}
<div class="mt-5">
<Button href="/login/email?redirect={encodeURIComponent($page.url.pathname + $page.url.search)}" variant="link" class="text-muted-foreground text-xs">
Don't have access to your passkey?
</Button>
</div>
{/if}
{/if} {/if}
</Card.CardContent> </Card.CardContent>
</Card.Root> </Card.Root>

View File

@@ -11,13 +11,7 @@ export default class AppConfigService extends APIService {
} }
const { data } = await this.api.get<AppConfigRawResponse>(url); const { data } = await this.api.get<AppConfigRawResponse>(url);
return this.parseConfigList(data);
const appConfig: Partial<AllAppConfig> = {};
data.forEach(({ key, value }) => {
(appConfig as any)[key] = this.parseValue(value);
});
return appConfig as AllAppConfig;
} }
async update(appConfig: AllAppConfig) { async update(appConfig: AllAppConfig) {
@@ -27,7 +21,7 @@ export default class AppConfigService extends APIService {
(appConfigConvertedToString as any)[key] = (appConfig as any)[key].toString(); (appConfigConvertedToString as any)[key] = (appConfig as any)[key].toString();
} }
const res = await this.api.put('/application-configuration', appConfigConvertedToString); const res = await this.api.put('/application-configuration', appConfigConvertedToString);
return res.data as AllAppConfig; return this.parseConfigList(res.data);
} }
async updateFavicon(favicon: File) { async updateFavicon(favicon: File) {
@@ -76,6 +70,15 @@ export default class AppConfigService extends APIService {
}; };
} }
private parseConfigList(data: AppConfigRawResponse) {
const appConfig: Partial<AllAppConfig> = {};
data.forEach(({ key, value }) => {
(appConfig as any)[key] = this.parseValue(value);
});
return appConfig as AllAppConfig;
}
private parseValue(value: string) { private parseValue(value: string) {
if (value === 'true') { if (value === 'true') {
return true; return true;

View File

@@ -51,4 +51,8 @@ export default class UserService extends APIService {
const res = await this.api.post(`/one-time-access-token/${token}`); const res = await this.api.post(`/one-time-access-token/${token}`);
return res.data as User; return res.data as User;
} }
async requestOneTimeAccessEmail(email: string, redirectPath?: string) {
await this.api.post('/one-time-access-email', { email, redirectPath });
}
} }

View File

@@ -1,6 +1,7 @@
export type AppConfig = { export type AppConfig = {
appName: string; appName: string;
allowOwnAccountEdit: boolean; allowOwnAccountEdit: boolean;
emailOneTimeAccessEnabled: boolean
}; };
export type AllAppConfig = AppConfig & { export type AllAppConfig = AppConfig & {
@@ -8,7 +9,6 @@ export type AllAppConfig = AppConfig & {
sessionDuration: number; sessionDuration: number;
emailsVerified: boolean; emailsVerified: boolean;
// Email // Email
emailEnabled: boolean;
smtpHost: string; smtpHost: string;
smtpPort: number; smtpPort: number;
smtpFrom: string; smtpFrom: string;
@@ -16,6 +16,7 @@ export type AllAppConfig = AppConfig & {
smtpPassword: string; smtpPassword: string;
smtpTls: boolean; smtpTls: boolean;
smtpSkipCertVerify: boolean; smtpSkipCertVerify: boolean;
emailLoginNotificationEnabled: boolean;
// LDAP // LDAP
ldapEnabled: boolean; ldapEnabled: boolean;
ldapUrl: string; ldapUrl: string;

View File

@@ -2,10 +2,19 @@ import { WebAuthnError } from '@simplewebauthn/browser';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
export function axiosErrorToast(e: unknown, message: string = 'An unknown error occurred') { export function getAxiosErrorMessage(
e: unknown,
defaultMessage: string = 'An unknown error occurred'
) {
let message = defaultMessage;
if (e instanceof AxiosError) { if (e instanceof AxiosError) {
message = e.response?.data.error || message; message = e.response?.data.error || message;
} }
return message;
}
export function axiosErrorToast(e: unknown, defaultMessage: string = 'An unknown error occurred') {
const message = getAxiosErrorMessage(e, defaultMessage);
toast.error(message); toast.error(message);
} }

View File

@@ -91,7 +91,7 @@
{#if client == null} {#if client == null}
<p>Client not found</p> <p>Client not found</p>
{:else} {:else}
<SignInWrapper> <SignInWrapper showEmailOneTimeAccessButton={$appConfigStore.emailOneTimeAccessEnabled}>
<ClientProviderImages {client} {success} error={!!errorMessage} /> <ClientProviderImages {client} {success} error={!!errorMessage} />
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Sign in to {client.name}</h1> <h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Sign in to {client.name}</h1>
{#if errorMessage} {#if errorMessage}
@@ -136,7 +136,7 @@
</Card.Root> </Card.Root>
</div> </div>
{/if} {/if}
<div class="flex justify-center gap-2"> <div class="flex w-full justify-stretch gap-2">
<Button onclick={() => history.back()} class="w-full" variant="secondary">Cancel</Button> <Button onclick={() => history.back()} class="w-full" variant="secondary">Cancel</Button>
{#if !errorMessage} {#if !errorMessage}
<Button <Button

View File

@@ -8,7 +8,7 @@
import { getWebauthnErrorMessage } from '$lib/utils/error-util'; import { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { startAuthentication } from '@simplewebauthn/browser'; import { startAuthentication } from '@simplewebauthn/browser';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import LoginLogoErrorIndicator from './components/login-logo-error-indicator.svelte'; import LoginLogoErrorSuccessIndicator from './components/login-logo-error-success-indicator.svelte';
const webauthnService = new WebAuthnService(); const webauthnService = new WebAuthnService();
let isLoading = $state(false); let isLoading = $state(false);
@@ -35,9 +35,9 @@
<title>Sign In</title> <title>Sign In</title>
</svelte:head> </svelte:head>
<SignInWrapper> <SignInWrapper showEmailOneTimeAccessButton={$appConfigStore.emailOneTimeAccessEnabled}>
<div class="flex justify-center"> <div class="flex justify-center">
<LoginLogoErrorIndicator error={!!error} /> <LoginLogoErrorSuccessIndicator error={!!error} />
</div> </div>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl"> <h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">
Sign in to {$appConfigStore.appName} Sign in to {$appConfigStore.appName}

View File

@@ -1,7 +1,8 @@
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => { export const load: PageServerLoad = async ({ params, url }) => {
return { return {
token: params.token token: params.token,
redirect: url.searchParams.get('redirect') || '/settings'
}; };
}; };

View File

@@ -1,41 +1,58 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import SignInWrapper from '$lib/components/login-wrapper.svelte'; import SignInWrapper from '$lib/components/login-wrapper.svelte';
import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import appConfigStore 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 { getAxiosErrorMessage } from '$lib/utils/error-util';
import { axiosErrorToast } from '$lib/utils/error-util'; import { onMount } from 'svelte';
import LoginLogoErrorSuccessIndicator from '../components/login-logo-error-success-indicator.svelte';
let { data } = $props(); let { data } = $props();
let isLoading = $state(false); let isLoading = $state(false);
let error: string | undefined = $state();
const skipPage = data.redirect !== '/settings';
const userService = new UserService(); const userService = new UserService();
async function authenticate() { async function authenticate() {
isLoading = true; isLoading = true;
userService try {
.exchangeOneTimeAccessToken(data.token) const user = await userService.exchangeOneTimeAccessToken(data.token);
.then((user: User) => {
userStore.setUser(user); userStore.setUser(user);
goto('/settings');
}) try {
.catch(axiosErrorToast); goto(data.redirect);
} catch (e) {
error = 'Invalid redirect URL';
}
} catch (e) {
error = getAxiosErrorMessage(e);
}
isLoading = false; isLoading = false;
} }
onMount(() => {
if (skipPage) {
authenticate();
}
});
</script> </script>
<SignInWrapper> <SignInWrapper>
<div class="flex justify-center"> <div class="flex justify-center">
<div class="bg-muted rounded-2xl p-3"> <LoginLogoErrorSuccessIndicator error={!!error} />
<Logo class="h-10 w-10" />
</div>
</div> </div>
<h1 class="font-playfair mt-5 text-4xl font-bold"> <h1 class="font-playfair mt-5 text-4xl font-bold">
{data.token === 'setup' ? `${$appConfigStore.appName} Setup` : 'One Time Access'} {data.token === 'setup' ? `${$appConfigStore.appName} Setup` : 'One Time Access'}
</h1> </h1>
{#if error}
<p class="text-muted-foreground mt-2">
{error}. Please try again.
</p>
{:else if !skipPage}
<p class="text-muted-foreground mt-2"> <p class="text-muted-foreground mt-2">
{#if data.token === 'setup'} {#if data.token === 'setup'}
You're about to sign in to the initial admin account. Anyone with this link can access the You're about to sign in to the initial admin account. Anyone with this link can access the
@@ -48,4 +65,5 @@
{/if} {/if}
</p> </p>
<Button class="mt-5" {isLoading} on:click={authenticate}>Continue</Button> <Button class="mt-5" {isLoading} on:click={authenticate}>Continue</Button>
{/if}
</SignInWrapper> </SignInWrapper>

View File

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

View File

@@ -0,0 +1,7 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => {
return {
redirect: url.searchParams.get('redirect') || undefined
};
};

View File

@@ -0,0 +1,62 @@
<script lang="ts">
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import { Button } from '$lib/components/ui/button';
import Input from '$lib/components/ui/input/input.svelte';
import UserService from '$lib/services/user-service';
import { fade } from 'svelte/transition';
import LoginLogoErrorSuccessIndicator from '../components/login-logo-error-success-indicator.svelte';
const { data } = $props();
const userService = new UserService();
let email = $state('');
let isLoading = $state(false);
let error: string | undefined = $state(undefined);
let success = $state(false);
async function requestEmail() {
isLoading = true;
await userService
.requestOneTimeAccessEmail(email, data.redirect)
.then(() => (success = true))
.catch((e) => (error = e.response?.data.error || 'An unknown error occured'));
isLoading = false;
}
</script>
<svelte:head>
<title>Email One Time Access</title>
</svelte:head>
<SignInWrapper>
<div class="flex justify-center">
<LoginLogoErrorSuccessIndicator {success} error={!!error} />
</div>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Email One Time Access</h1>
{#if error}
<p class="text-muted-foreground mt-2" in:fade>
{error}. Please try again.
</p>
<div class="mt-10 flex w-full justify-stretch gap-2">
<Button variant="secondary" class="w-full" href="/">Go back</Button>
<Button class="w-full" onclick={() => (error = undefined)}>Try again</Button>
</div>
{:else if success}
<p class="text-muted-foreground mt-2" in:fade>
An email has been sent to the provided email, if it exists in the system.
</p>
{:else}
<form onsubmit={requestEmail}>
<p class="text-muted-foreground mt-2" in:fade>
Enter your email to receive an email with a one time access link.
</p>
<Input id="Email" class="mt-7" placeholder="Your email" bind:value={email} />
<div class="mt-8 flex justify-stretch gap-2">
<Button variant="secondary" class="w-full" href="/">Go back</Button>
<Button class="w-full" type="submit" {isLoading}>Submit</Button>
</div>
</form>
{/if}
</SignInWrapper>

View File

@@ -16,7 +16,7 @@
const appConfigService = new AppConfigService(); const appConfigService = new AppConfigService();
async function updateAppConfig(updatedAppConfig: Partial<AllAppConfig>) { async function updateAppConfig(updatedAppConfig: Partial<AllAppConfig>) {
await appConfigService appConfig = await appConfigService
.update({ .update({
...appConfig, ...appConfig,
...updatedAppConfig ...updatedAppConfig

View File

@@ -19,17 +19,17 @@
const appConfigService = new AppConfigService(); const appConfigService = new AppConfigService();
let isSendingTestEmail = $state(false); let isSendingTestEmail = $state(false);
let emailEnabled = $state(appConfig.emailEnabled);
const updatedAppConfig = { const updatedAppConfig = {
emailEnabled: appConfig.emailEnabled,
smtpHost: appConfig.smtpHost, smtpHost: appConfig.smtpHost,
smtpPort: appConfig.smtpPort, smtpPort: appConfig.smtpPort,
smtpUser: appConfig.smtpUser, smtpUser: appConfig.smtpUser,
smtpPassword: appConfig.smtpPassword, smtpPassword: appConfig.smtpPassword,
smtpFrom: appConfig.smtpFrom, smtpFrom: appConfig.smtpFrom,
smtpTls: appConfig.smtpTls, smtpTls: appConfig.smtpTls,
smtpSkipCertVerify: appConfig.smtpSkipCertVerify smtpSkipCertVerify: appConfig.smtpSkipCertVerify,
emailOneTimeAccessEnabled: appConfig.emailOneTimeAccessEnabled,
emailLoginNotificationEnabled: appConfig.emailLoginNotificationEnabled
}; };
const formSchema = z.object({ const formSchema = z.object({
@@ -39,7 +39,9 @@
smtpPassword: z.string(), smtpPassword: z.string(),
smtpFrom: z.string().email(), smtpFrom: z.string().email(),
smtpTls: z.boolean(), smtpTls: z.boolean(),
smtpSkipCertVerify: z.boolean() smtpSkipCertVerify: z.boolean(),
emailOneTimeAccessEnabled: z.boolean(),
emailLoginNotificationEnabled: z.boolean()
}); });
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig); const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
@@ -47,26 +49,11 @@
async function onSubmit() { async function onSubmit() {
const data = form.validate(); const data = form.validate();
if (!data) return false; if (!data) return false;
await callback({ await callback(data);
...data,
emailEnabled: true
});
toast.success('Email configuration updated successfully'); toast.success('Email configuration updated successfully');
return true; return true;
} }
async function onDisable() {
emailEnabled = false;
await callback({ emailEnabled });
toast.success('Email disabled successfully');
}
async function onEnable() {
if (await onSubmit()) {
emailEnabled = true;
}
}
async function onTestEmail() { async function onTestEmail() {
isSendingTestEmail = true; isSendingTestEmail = true;
await appConfigService await appConfigService
@@ -80,7 +67,8 @@
</script> </script>
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
<div class="mt-5 grid grid-cols-1 items-start gap-5 md:grid-cols-2"> <h4 class="text-lg font-semibold">SMTP Configuration</h4>
<div class="mt-4 grid grid-cols-1 items-start gap-5 md:grid-cols-2">
<FormInput label="SMTP Host" bind:input={$inputs.smtpHost} /> <FormInput label="SMTP Host" bind:input={$inputs.smtpHost} />
<FormInput label="SMTP Port" type="number" bind:input={$inputs.smtpPort} /> <FormInput label="SMTP Port" type="number" bind:input={$inputs.smtpPort} />
<FormInput label="SMTP User" bind:input={$inputs.smtpUser} /> <FormInput label="SMTP User" bind:input={$inputs.smtpUser} />
@@ -99,15 +87,26 @@
bind:checked={$inputs.smtpSkipCertVerify.value} bind:checked={$inputs.smtpSkipCertVerify.value}
/> />
</div> </div>
<h4 class="mt-10 text-lg font-semibold">Enabled Emails</h4>
<div class="mt-4 flex flex-col gap-3">
<CheckboxWithLabel
id="email-login-notification"
label="Email Login Notification"
description="Send an email to the user when they log in from a new device."
bind:checked={$inputs.emailLoginNotificationEnabled.value}
/>
<CheckboxWithLabel
id="email-one-time-access"
label="Email One Time Access"
description="Allows users to sign in with a link sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry."
bind:checked={$inputs.emailOneTimeAccessEnabled.value}
/>
</div>
<div class="mt-8 flex flex-wrap justify-end gap-3"> <div class="mt-8 flex flex-wrap justify-end gap-3">
{#if emailEnabled}
<Button variant="secondary" onclick={onDisable}>Disable</Button>
<Button isLoading={isSendingTestEmail} variant="secondary" onclick={onTestEmail} <Button isLoading={isSendingTestEmail} variant="secondary" onclick={onTestEmail}
>Send Test Email</Button >Send Test Email</Button
> >
<Button type="submit">Save</Button> <Button type="submit">Save</Button>
{:else}
<Button onclick={onEnable}>Enable</Button>
{/if}
</div> </div>
</form> </form>

View File

@@ -2,8 +2,6 @@
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte'; import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
import FormInput from '$lib/components/form-input.svelte'; import FormInput from '$lib/components/form-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label';
import type { AllAppConfig } from '$lib/types/application-configuration'; import type { AllAppConfig } from '$lib/types/application-configuration';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
@@ -23,14 +21,14 @@
appName: appConfig.appName, appName: appConfig.appName,
sessionDuration: appConfig.sessionDuration, sessionDuration: appConfig.sessionDuration,
emailsVerified: appConfig.emailsVerified, emailsVerified: appConfig.emailsVerified,
allowOwnAccountEdit: appConfig.allowOwnAccountEdit allowOwnAccountEdit: appConfig.allowOwnAccountEdit,
}; };
const formSchema = z.object({ const formSchema = z.object({
appName: z.string().min(2).max(30), appName: z.string().min(2).max(30),
sessionDuration: z.number().min(1).max(43200), sessionDuration: z.number().min(1).max(43200),
emailsVerified: z.boolean(), emailsVerified: z.boolean(),
allowOwnAccountEdit: z.boolean() allowOwnAccountEdit: z.boolean(),
}); });
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig); const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);

View File

@@ -88,7 +88,6 @@
label="Public Client" label="Public Client"
description="Public clients do not have a client secret and use PKCE instead. Enable this if your client is a SPA or mobile app." description="Public clients do not have a client secret and use PKCE instead. Enable this if your client is a SPA or mobile app."
onCheckedChange={(v) => { onCheckedChange={(v) => {
console.log(v)
if (v == true) form.setValue('pkceEnabled', true); if (v == true) form.setValue('pkceEnabled', true);
}} }}
bind:checked={$inputs.isPublic.value} bind:checked={$inputs.isPublic.value}

View File

@@ -29,11 +29,12 @@ test('Update email configuration', async ({ page }) => {
await page.getByLabel('SMTP User').fill('test@gmail.com'); await page.getByLabel('SMTP User').fill('test@gmail.com');
await page.getByLabel('SMTP Password').fill('password'); await page.getByLabel('SMTP Password').fill('password');
await page.getByLabel('SMTP From').fill('test@gmail.com'); await page.getByLabel('SMTP From').fill('test@gmail.com');
await page.getByRole('button', { name: 'Enable' }).nth(0).click(); await page.getByLabel('Email Login Notification').click();
await page.getByRole('status').click(); await page.getByLabel('Email One Time Access').click();
await page.getByRole('button', { name: 'Save' }).nth(1).click();
await expect(page.getByRole('status')).toHaveText('Email configuration updated successfully'); await expect(page.getByRole('status')).toHaveText('Email configuration updated successfully');
await expect(page.getByRole('button', { name: 'Disable' })).toBeVisible();
await page.reload(); await page.reload();
@@ -42,10 +43,8 @@ test('Update email configuration', async ({ page }) => {
await expect(page.getByLabel('SMTP User')).toHaveValue('test@gmail.com'); await expect(page.getByLabel('SMTP User')).toHaveValue('test@gmail.com');
await expect(page.getByLabel('SMTP Password')).toHaveValue('password'); await expect(page.getByLabel('SMTP Password')).toHaveValue('password');
await expect(page.getByLabel('SMTP From')).toHaveValue('test@gmail.com'); await expect(page.getByLabel('SMTP From')).toHaveValue('test@gmail.com');
await expect(page.getByLabel('Email Login Notification')).toBeChecked();
await page.getByRole('button', { name: 'Disable' }).click(); await expect(page.getByLabel('Email One Time Access')).toBeChecked();
await expect(page.getByRole('status')).toHaveText('Email disabled successfully');
}); });
test('Update application images', async ({ page }) => { test('Update application images', async ({ page }) => {
@@ -55,7 +54,7 @@ test('Update application images', async ({ page }) => {
await page.getByLabel('Light Mode Logo').setInputFiles('tests/assets/pingvin-share-logo.png'); await page.getByLabel('Light Mode Logo').setInputFiles('tests/assets/pingvin-share-logo.png');
await page.getByLabel('Dark Mode Logo').setInputFiles('tests/assets/nextcloud-logo.png'); await page.getByLabel('Dark Mode Logo').setInputFiles('tests/assets/nextcloud-logo.png');
await page.getByLabel('Background Image').setInputFiles('tests/assets/clouds.jpg'); await page.getByLabel('Background Image').setInputFiles('tests/assets/clouds.jpg');
await page.getByRole('button', { name: 'Save' }).nth(1).click(); await page.getByRole('button', { name: 'Save' }).nth(2).click();
await expect(page.getByRole('status')).toHaveText('Images updated successfully'); await expect(page.getByRole('status')).toHaveText('Images updated successfully');

View File

@@ -17,5 +17,5 @@ test('Sign in with expired one time access token fails', async ({ page }) => {
await page.goto(`/login/${token.token}`); await page.goto(`/login/${token.token}`);
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('status')).toHaveText('Token is invalid or expired'); await expect(page.getByRole('paragraph')).toHaveText('Token is invalid or expired. Please try again.');
}); });