mirror of
https://github.com/nikdoof/pocket-id.git
synced 2025-12-13 23:02:17 +00:00
feat: allow sign in with email (#100)
This commit is contained in:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -73,12 +73,7 @@ var defaultDbConfig = model.AppConfig{
|
|||||||
IsInternal: true,
|
IsInternal: true,
|
||||||
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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
17
backend/resources/email-templates/one-time-access_html.tmpl
Normal file
17
backend/resources/email-templates/one-time-access_html.tmpl
Normal 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 -}}
|
||||||
@@ -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 -}}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
UPDATE app_config_variables SET key = 'emailEnabled' WHERE key = 'emailLoginNotificationEnabled';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
UPDATE app_config_variables SET key = 'emailLoginNotificationEnabled' WHERE key = 'emailEnabled';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
UPDATE app_config_variables SET key = 'emailEnabled' WHERE key = 'emailLoginNotificationEnabled';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
UPDATE app_config_variables SET key = 'emailLoginNotificationEnabled' WHERE key = 'emailEnabled';
|
||||||
@@ -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}
|
||||||
{@render children()}
|
<div class="flex h-full flex-col">
|
||||||
|
<div class="flex flex-grow flex-col items-center justify-center">
|
||||||
|
{@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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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'
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,51 +1,69 @@
|
|||||||
<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 {
|
||||||
})
|
goto(data.redirect);
|
||||||
.catch(axiosErrorToast);
|
} 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>
|
||||||
<p class="text-muted-foreground mt-2">
|
{#if error}
|
||||||
{#if data.token === 'setup'}
|
<p class="text-muted-foreground mt-2">
|
||||||
You're about to sign in to the initial admin account. Anyone with this link can access the
|
{error}. Please try again.
|
||||||
account until a passkey is added. Please set up a passkey as soon as possible to prevent
|
</p>
|
||||||
unauthorized access.
|
{:else if !skipPage}
|
||||||
{:else}
|
<p class="text-muted-foreground mt-2">
|
||||||
You've been granted one-time access to your {$appConfigStore.appName} account. Please note that
|
{#if data.token === 'setup'}
|
||||||
if you continue, this link will become invalid. To avoid this, make sure to add a passkey. Otherwise,
|
You're about to sign in to the initial admin account. Anyone with this link can access the
|
||||||
you'll need to request a new link.
|
account until a passkey is added. Please set up a passkey as soon as possible to prevent
|
||||||
{/if}
|
unauthorized access.
|
||||||
</p>
|
{:else}
|
||||||
<Button class="mt-5" {isLoading} on:click={authenticate}>Continue</Button>
|
You've been granted one-time access to your {$appConfigStore.appName} account. Please note that
|
||||||
|
if you continue, this link will become invalid. To avoid this, make sure to add a passkey. Otherwise,
|
||||||
|
you'll need to request a new link.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<Button class="mt-5" {isLoading} on:click={authenticate}>Continue</Button>
|
||||||
|
{/if}
|
||||||
</SignInWrapper>
|
</SignInWrapper>
|
||||||
|
|||||||
@@ -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">
|
||||||
<CrossAnimated class="h-5 w-5" />
|
{#if error}
|
||||||
|
<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 }}>
|
||||||
7
frontend/src/routes/login/email/+page.server.ts
Normal file
7
frontend/src/routes/login/email/+page.server.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
|
return {
|
||||||
|
redirect: url.searchParams.get('redirect') || undefined
|
||||||
|
};
|
||||||
|
};
|
||||||
62
frontend/src/routes/login/email/+page.svelte
Normal file
62
frontend/src/routes/login/email/+page.svelte
Normal 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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 isLoading={isSendingTestEmail} variant="secondary" onclick={onTestEmail}
|
||||||
<Button variant="secondary" onclick={onDisable}>Disable</Button>
|
>Send Test Email</Button
|
||||||
<Button isLoading={isSendingTestEmail} variant="secondary" onclick={onTestEmail}
|
>
|
||||||
>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>
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -63,7 +61,7 @@
|
|||||||
label="Emails Verified"
|
label="Emails Verified"
|
||||||
description="Whether the user's email should be marked as verified for the OIDC clients."
|
description="Whether the user's email should be marked as verified for the OIDC clients."
|
||||||
bind:checked={$inputs.emailsVerified.value}
|
bind:checked={$inputs.emailsVerified.value}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5 flex justify-end">
|
<div class="mt-5 flex justify-end">
|
||||||
<Button {isLoading} type="submit">Save</Button>
|
<Button {isLoading} type="submit">Save</Button>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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.');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user