mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-16 02:03:01 +03:00
feat: add ability to send login code via email (#457)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
This commit is contained in:
@@ -43,9 +43,10 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
|
|||||||
|
|
||||||
group.POST("/users/me/one-time-access-token", authMiddleware.WithAdminNotRequired().Add(), uc.createOwnOneTimeAccessTokenHandler)
|
group.POST("/users/me/one-time-access-token", authMiddleware.WithAdminNotRequired().Add(), uc.createOwnOneTimeAccessTokenHandler)
|
||||||
group.POST("/users/:id/one-time-access-token", authMiddleware.Add(), uc.createAdminOneTimeAccessTokenHandler)
|
group.POST("/users/:id/one-time-access-token", authMiddleware.Add(), uc.createAdminOneTimeAccessTokenHandler)
|
||||||
|
group.POST("/users/:id/one-time-access-email", authMiddleware.Add(), uc.RequestOneTimeAccessEmailAsAdminHandler)
|
||||||
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)
|
group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.RequestOneTimeAccessEmailAsUnauthenticatedUserHandler)
|
||||||
|
|
||||||
group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler)
|
group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler)
|
||||||
group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler)
|
group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler)
|
||||||
@@ -356,18 +357,63 @@ func (uc *UserController) createOwnOneTimeAccessTokenHandler(c *gin.Context) {
|
|||||||
uc.createOneTimeAccessTokenHandler(c, true)
|
uc.createOneTimeAccessTokenHandler(c, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createAdminOneTimeAccessTokenHandler godoc
|
||||||
|
// @Summary Create one-time access token for user (admin)
|
||||||
|
// @Description Generate a one-time access token for a specific user (admin only)
|
||||||
|
// @Tags Users
|
||||||
|
// @Param id path string true "User ID"
|
||||||
|
// @Param body body dto.OneTimeAccessTokenCreateDto true "Token options"
|
||||||
|
// @Success 201 {object} object "{ \"token\": \"string\" }"
|
||||||
|
// @Router /api/users/{id}/one-time-access-token [post]
|
||||||
func (uc *UserController) createAdminOneTimeAccessTokenHandler(c *gin.Context) {
|
func (uc *UserController) createAdminOneTimeAccessTokenHandler(c *gin.Context) {
|
||||||
uc.createOneTimeAccessTokenHandler(c, false)
|
uc.createOneTimeAccessTokenHandler(c, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (uc *UserController) requestOneTimeAccessEmailHandler(c *gin.Context) {
|
// RequestOneTimeAccessEmailAsUnauthenticatedUserHandler godoc
|
||||||
var input dto.OneTimeAccessEmailDto
|
// @Summary Request one-time access email
|
||||||
|
// @Description Request a one-time access email for unauthenticated users
|
||||||
|
// @Tags Users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body dto.OneTimeAccessEmailAsUnauthenticatedUserDto true "Email request information"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /api/one-time-access-email [post]
|
||||||
|
func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler(c *gin.Context) {
|
||||||
|
var input dto.OneTimeAccessEmailAsUnauthenticatedUserDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := uc.userService.RequestOneTimeAccessEmail(c.Request.Context(), input.Email, input.RedirectPath)
|
err := uc.userService.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), input.Email, input.RedirectPath)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestOneTimeAccessEmailAsAdminHandler godoc
|
||||||
|
// @Summary Request one-time access email (admin)
|
||||||
|
// @Description Request a one-time access email for a specific user (admin only)
|
||||||
|
// @Tags Users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "User ID"
|
||||||
|
// @Param body body dto.OneTimeAccessEmailAsAdminDto true "Email request options"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /api/users/{id}/one-time-access-email [post]
|
||||||
|
func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context) {
|
||||||
|
var input dto.OneTimeAccessEmailAsAdminDto
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := c.Param("id")
|
||||||
|
|
||||||
|
err := uc.userService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, input.ExpiresAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -12,37 +12,38 @@ type AppConfigVariableDto struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AppConfigUpdateDto struct {
|
type AppConfigUpdateDto struct {
|
||||||
AppName string `json:"appName" binding:"required,min=1,max=30"`
|
AppName string `json:"appName" binding:"required,min=1,max=30"`
|
||||||
SessionDuration string `json:"sessionDuration" binding:"required"`
|
SessionDuration string `json:"sessionDuration" binding:"required"`
|
||||||
EmailsVerified string `json:"emailsVerified" binding:"required"`
|
EmailsVerified string `json:"emailsVerified" binding:"required"`
|
||||||
DisableAnimations string `json:"disableAnimations" binding:"required"`
|
DisableAnimations string `json:"disableAnimations" binding:"required"`
|
||||||
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
|
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
|
||||||
SmtpHost string `json:"smtpHost"`
|
SmtpHost 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"`
|
||||||
SmtpUser string `json:"smtpUser"`
|
SmtpUser string `json:"smtpUser"`
|
||||||
SmtpPassword string `json:"smtpPassword"`
|
SmtpPassword string `json:"smtpPassword"`
|
||||||
SmtpTls string `json:"smtpTls" binding:"required,oneof=none starttls tls"`
|
SmtpTls string `json:"smtpTls" binding:"required,oneof=none starttls tls"`
|
||||||
SmtpSkipCertVerify string `json:"smtpSkipCertVerify"`
|
SmtpSkipCertVerify string `json:"smtpSkipCertVerify"`
|
||||||
LdapEnabled string `json:"ldapEnabled" binding:"required"`
|
LdapEnabled string `json:"ldapEnabled" binding:"required"`
|
||||||
LdapUrl string `json:"ldapUrl"`
|
LdapUrl string `json:"ldapUrl"`
|
||||||
LdapBindDn string `json:"ldapBindDn"`
|
LdapBindDn string `json:"ldapBindDn"`
|
||||||
LdapBindPassword string `json:"ldapBindPassword"`
|
LdapBindPassword string `json:"ldapBindPassword"`
|
||||||
LdapBase string `json:"ldapBase"`
|
LdapBase string `json:"ldapBase"`
|
||||||
LdapUserSearchFilter string `json:"ldapUserSearchFilter"`
|
LdapUserSearchFilter string `json:"ldapUserSearchFilter"`
|
||||||
LdapUserGroupSearchFilter string `json:"ldapUserGroupSearchFilter"`
|
LdapUserGroupSearchFilter string `json:"ldapUserGroupSearchFilter"`
|
||||||
LdapSkipCertVerify string `json:"ldapSkipCertVerify"`
|
LdapSkipCertVerify string `json:"ldapSkipCertVerify"`
|
||||||
LdapAttributeUserUniqueIdentifier string `json:"ldapAttributeUserUniqueIdentifier"`
|
LdapAttributeUserUniqueIdentifier string `json:"ldapAttributeUserUniqueIdentifier"`
|
||||||
LdapAttributeUserUsername string `json:"ldapAttributeUserUsername"`
|
LdapAttributeUserUsername string `json:"ldapAttributeUserUsername"`
|
||||||
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"`
|
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"`
|
||||||
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"`
|
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"`
|
||||||
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"`
|
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"`
|
||||||
LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"`
|
LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"`
|
||||||
LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"`
|
LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"`
|
||||||
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`
|
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`
|
||||||
LdapAttributeGroupName string `json:"ldapAttributeGroupName"`
|
LdapAttributeGroupName string `json:"ldapAttributeGroupName"`
|
||||||
LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"`
|
LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"`
|
||||||
LdapSoftDeleteUsers string `json:"ldapSoftDeleteUsers"`
|
LdapSoftDeleteUsers string `json:"ldapSoftDeleteUsers"`
|
||||||
EmailOneTimeAccessEnabled string `json:"emailOneTimeAccessEnabled" binding:"required"`
|
EmailOneTimeAccessAsAdminEnabled string `json:"emailOneTimeAccessAsAdminEnabled" binding:"required"`
|
||||||
EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"`
|
EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required"`
|
||||||
|
EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,11 +32,15 @@ type OneTimeAccessTokenCreateDto struct {
|
|||||||
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
|
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OneTimeAccessEmailDto struct {
|
type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
|
||||||
Email string `json:"email" binding:"required,email"`
|
Email string `json:"email" binding:"required,email"`
|
||||||
RedirectPath string `json:"redirectPath"`
|
RedirectPath string `json:"redirectPath"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OneTimeAccessEmailAsAdminDto struct {
|
||||||
|
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
type UserUpdateUserGroupDto struct {
|
type UserUpdateUserGroupDto struct {
|
||||||
UserGroupIds []string `json:"userGroupIds" binding:"required"`
|
UserGroupIds []string `json:"userGroupIds" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,15 +41,16 @@ type AppConfig struct {
|
|||||||
LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal
|
LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal
|
||||||
LogoDarkImageType AppConfigVariable `key:"logoDarkImageType,internal"` // Internal
|
LogoDarkImageType AppConfigVariable `key:"logoDarkImageType,internal"` // Internal
|
||||||
// Email
|
// Email
|
||||||
SmtpHost AppConfigVariable `key:"smtpHost"`
|
SmtpHost AppConfigVariable `key:"smtpHost"`
|
||||||
SmtpPort AppConfigVariable `key:"smtpPort"`
|
SmtpPort AppConfigVariable `key:"smtpPort"`
|
||||||
SmtpFrom AppConfigVariable `key:"smtpFrom"`
|
SmtpFrom AppConfigVariable `key:"smtpFrom"`
|
||||||
SmtpUser AppConfigVariable `key:"smtpUser"`
|
SmtpUser AppConfigVariable `key:"smtpUser"`
|
||||||
SmtpPassword AppConfigVariable `key:"smtpPassword"`
|
SmtpPassword AppConfigVariable `key:"smtpPassword"`
|
||||||
SmtpTls AppConfigVariable `key:"smtpTls"`
|
SmtpTls AppConfigVariable `key:"smtpTls"`
|
||||||
SmtpSkipCertVerify AppConfigVariable `key:"smtpSkipCertVerify"`
|
SmtpSkipCertVerify AppConfigVariable `key:"smtpSkipCertVerify"`
|
||||||
EmailLoginNotificationEnabled AppConfigVariable `key:"emailLoginNotificationEnabled"`
|
EmailLoginNotificationEnabled AppConfigVariable `key:"emailLoginNotificationEnabled"`
|
||||||
EmailOneTimeAccessEnabled AppConfigVariable `key:"emailOneTimeAccessEnabled,public"` // Public
|
EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigVariable `key:"emailOneTimeAccessAsUnauthenticatedEnabled,public"` // Public
|
||||||
|
EmailOneTimeAccessAsAdminEnabled AppConfigVariable `key:"emailOneTimeAccessAsAdminEnabled,public"` // Public
|
||||||
// LDAP
|
// LDAP
|
||||||
LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public
|
LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public
|
||||||
LdapUrl AppConfigVariable `key:"ldapUrl"`
|
LdapUrl AppConfigVariable `key:"ldapUrl"`
|
||||||
@@ -77,7 +78,7 @@ func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable {
|
|||||||
cfgValue := reflect.ValueOf(c).Elem()
|
cfgValue := reflect.ValueOf(c).Elem()
|
||||||
cfgType := cfgValue.Type()
|
cfgType := cfgValue.Type()
|
||||||
|
|
||||||
res := make([]AppConfigVariable, cfgType.NumField())
|
var res []AppConfigVariable
|
||||||
|
|
||||||
for i := range cfgType.NumField() {
|
for i := range cfgType.NumField() {
|
||||||
field := cfgType.Field(i)
|
field := cfgType.Field(i)
|
||||||
@@ -94,10 +95,12 @@ func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable {
|
|||||||
|
|
||||||
fieldValue := cfgValue.Field(i)
|
fieldValue := cfgValue.Field(i)
|
||||||
|
|
||||||
res[i] = AppConfigVariable{
|
appConfigVariable := AppConfigVariable{
|
||||||
Key: key,
|
Key: key,
|
||||||
Value: fieldValue.FieldByName("Value").String(),
|
Value: fieldValue.FieldByName("Value").String(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
res = append(res, appConfigVariable)
|
||||||
}
|
}
|
||||||
|
|
||||||
return res
|
return res
|
||||||
|
|||||||
@@ -73,7 +73,8 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
|
|||||||
SmtpTls: model.AppConfigVariable{Value: "none"},
|
SmtpTls: model.AppConfigVariable{Value: "none"},
|
||||||
SmtpSkipCertVerify: model.AppConfigVariable{Value: "false"},
|
SmtpSkipCertVerify: model.AppConfigVariable{Value: "false"},
|
||||||
EmailLoginNotificationEnabled: model.AppConfigVariable{Value: "false"},
|
EmailLoginNotificationEnabled: model.AppConfigVariable{Value: "false"},
|
||||||
EmailOneTimeAccessEnabled: model.AppConfigVariable{Value: "false"},
|
EmailOneTimeAccessAsUnauthenticatedEnabled: model.AppConfigVariable{Value: "false"},
|
||||||
|
EmailOneTimeAccessAsAdminEnabled: model.AppConfigVariable{Value: "false"},
|
||||||
// LDAP
|
// LDAP
|
||||||
LdapEnabled: model.AppConfigVariable{Value: "false"},
|
LdapEnabled: model.AppConfigVariable{Value: "false"},
|
||||||
LdapUrl: model.AppConfigVariable{},
|
LdapUrl: model.AppConfigVariable{},
|
||||||
@@ -151,11 +152,6 @@ func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppCon
|
|||||||
return nil, &common.UiConfigDisabledError{}
|
return nil, &common.UiConfigDisabledError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If EmailLoginNotificationEnabled is set to false (explicitly), disable the EmailOneTimeAccessEnabled
|
|
||||||
if input.EmailLoginNotificationEnabled == "false" {
|
|
||||||
input.EmailOneTimeAccessEnabled = "false"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start the transaction
|
// Start the transaction
|
||||||
tx, err := s.updateAppConfigStartTransaction(ctx)
|
tx, err := s.updateAppConfigStartTransaction(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -447,44 +447,6 @@ func TestUpdateAppConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("auto disables EmailOneTimeAccessEnabled when EmailLoginNotificationEnabled is false", func(t *testing.T) {
|
|
||||||
db := newAppConfigTestDatabaseForTest(t)
|
|
||||||
|
|
||||||
// Create a service with default config
|
|
||||||
service := &AppConfigService{
|
|
||||||
db: db,
|
|
||||||
}
|
|
||||||
err := service.LoadDbConfig(t.Context())
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// First enable both settings
|
|
||||||
err = service.UpdateAppConfigValues(t.Context(),
|
|
||||||
"emailLoginNotificationEnabled", "true",
|
|
||||||
"emailOneTimeAccessEnabled", "true",
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Verify both are enabled
|
|
||||||
config := service.GetDbConfig()
|
|
||||||
require.True(t, config.EmailLoginNotificationEnabled.IsTrue())
|
|
||||||
require.True(t, config.EmailOneTimeAccessEnabled.IsTrue())
|
|
||||||
|
|
||||||
// Now disable EmailLoginNotificationEnabled
|
|
||||||
input := dto.AppConfigUpdateDto{
|
|
||||||
EmailLoginNotificationEnabled: "false",
|
|
||||||
// Don't set EmailOneTimeAccessEnabled, it should be auto-disabled
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update config
|
|
||||||
_, err = service.UpdateAppConfig(t.Context(), input)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Verify EmailOneTimeAccessEnabled was automatically disabled
|
|
||||||
config = service.GetDbConfig()
|
|
||||||
require.False(t, config.EmailLoginNotificationEnabled.IsTrue())
|
|
||||||
require.False(t, config.EmailOneTimeAccessEnabled.IsTrue())
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("cannot update when UiConfigDisabled is true", func(t *testing.T) {
|
t.Run("cannot update when UiConfigDisabled is true", func(t *testing.T) {
|
||||||
// Save the original state and restore it after the test
|
// Save the original state and restore it after the test
|
||||||
originalUiConfigDisabled := common.EnvConfig.UiConfigDisabled
|
originalUiConfigDisabled := common.EnvConfig.UiConfigDisabled
|
||||||
|
|||||||
@@ -104,10 +104,10 @@ func SendEmail[V any](ctx context.Context, srv *EmailService, toEmail email.Addr
|
|||||||
// so we use the domain of the from address instead (the same as Thunderbird does)
|
// so we use the domain of the from address instead (the same as Thunderbird does)
|
||||||
// if the address does not have an @ (which would be unusual), we use hostname
|
// if the address does not have an @ (which would be unusual), we use hostname
|
||||||
|
|
||||||
from_address := dbConfig.SmtpFrom.Value
|
fromAddress := dbConfig.SmtpFrom.Value
|
||||||
domain := ""
|
domain := ""
|
||||||
if strings.Contains(from_address, "@") {
|
if strings.Contains(fromAddress, "@") {
|
||||||
domain = strings.Split(from_address, "@")[1]
|
domain = strings.Split(fromAddress, "@")[1]
|
||||||
} else {
|
} else {
|
||||||
hostname, err := os.Hostname()
|
hostname, err := os.Hostname()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ type OneTimeAccessTemplateData = struct {
|
|||||||
Code string
|
Code string
|
||||||
LoginLink string
|
LoginLink string
|
||||||
LoginLinkWithCode string
|
LoginLinkWithCode string
|
||||||
|
ExpirationString string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiKeyExpiringSoonTemplateData struct {
|
type ApiKeyExpiringSoonTemplateData struct {
|
||||||
|
|||||||
@@ -348,23 +348,24 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
|
|||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) RequestOneTimeAccessEmail(ctx context.Context, emailAddress, redirectPath string) error {
|
func (s *UserService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, userID string, expiration time.Time) error {
|
||||||
tx := s.db.Begin()
|
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsAdminEnabled.IsTrue()
|
||||||
defer func() {
|
|
||||||
tx.Rollback()
|
|
||||||
}()
|
|
||||||
|
|
||||||
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessEnabled.IsTrue()
|
|
||||||
if isDisabled {
|
if isDisabled {
|
||||||
return &common.OneTimeAccessDisabledError{}
|
return &common.OneTimeAccessDisabledError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
var user model.User
|
return s.requestOneTimeAccessEmailInternal(ctx, userID, "", expiration)
|
||||||
err := tx.
|
|
||||||
WithContext(ctx).
|
}
|
||||||
Where("email = ?", emailAddress).
|
|
||||||
First(&user).
|
func (s *UserService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, userID, redirectPath string) error {
|
||||||
Error
|
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue()
|
||||||
|
if isDisabled {
|
||||||
|
return &common.OneTimeAccessDisabledError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var userId string
|
||||||
|
err := s.db.Model(&model.User{}).Select("id").Where("email = ?", userID).First(&userId).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Do not return error if user not found to prevent email enumeration
|
// Do not return error if user not found to prevent email enumeration
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@@ -374,7 +375,22 @@ func (s *UserService) RequestOneTimeAccessEmail(ctx context.Context, emailAddres
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
oneTimeAccessToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, time.Now().Add(15*time.Minute), tx)
|
expiration := time.Now().Add(15 * time.Minute)
|
||||||
|
return s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, expiration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, expiration time.Time) error {
|
||||||
|
tx := s.db.Begin()
|
||||||
|
defer func() {
|
||||||
|
tx.Rollback()
|
||||||
|
}()
|
||||||
|
|
||||||
|
user, err := s.GetUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
oneTimeAccessToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, expiration, tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -405,6 +421,7 @@ func (s *UserService) RequestOneTimeAccessEmail(ctx context.Context, emailAddres
|
|||||||
Code: oneTimeAccessToken,
|
Code: oneTimeAccessToken,
|
||||||
LoginLink: link,
|
LoginLink: link,
|
||||||
LoginLinkWithCode: linkWithCode,
|
LoginLinkWithCode: linkWithCode,
|
||||||
|
ExpirationString: utils.DurationToString(time.Until(expiration).Round(time.Second)),
|
||||||
})
|
})
|
||||||
if errInternal != nil {
|
if errInternal != nil {
|
||||||
log.Printf("Failed to send email to '%s': %v\n", user.Email, errInternal)
|
log.Printf("Failed to send email to '%s': %v\n", user.Email, errInternal)
|
||||||
|
|||||||
52
backend/internal/utils/date_time_util.go
Normal file
52
backend/internal/utils/date_time_util.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DurationToString converts a time.Duration to a human-readable string. Respects minutes, hours and days.
|
||||||
|
func DurationToString(duration time.Duration) string {
|
||||||
|
// For a duration less than a day
|
||||||
|
if duration < 24*time.Hour {
|
||||||
|
hours := int(duration.Hours())
|
||||||
|
mins := int(duration.Minutes()) % 60
|
||||||
|
|
||||||
|
switch hours {
|
||||||
|
case 0:
|
||||||
|
return fmt.Sprintf("%d minutes", mins)
|
||||||
|
case 1:
|
||||||
|
if mins == 0 {
|
||||||
|
return "1 hour"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("1 hour and %d minutes", mins)
|
||||||
|
default:
|
||||||
|
if mins == 0 {
|
||||||
|
return fmt.Sprintf("%d hours", hours)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d hours and %d minutes", hours, mins)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For durations of a day or more
|
||||||
|
days := int(duration.Hours() / 24)
|
||||||
|
hours := int(duration.Hours()) % 24
|
||||||
|
|
||||||
|
switch hours {
|
||||||
|
case 0:
|
||||||
|
if days == 1 {
|
||||||
|
return "1 day"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d days", days)
|
||||||
|
case 1:
|
||||||
|
if days == 1 {
|
||||||
|
return "1 day and 1 hour"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d days and 1 hour", days)
|
||||||
|
default:
|
||||||
|
if days == 1 {
|
||||||
|
return fmt.Sprintf("1 day and %d hours", hours)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d days and %d hours", days, hours)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
<div class="content">
|
<div class="content">
|
||||||
<h2>Login Code</h2>
|
<h2>Login Code</h2>
|
||||||
<p class="message">
|
<p class="message">
|
||||||
Click the button below to sign in to {{ .AppName }} with a login code.</br>Or visit <a href="{{ .Data.LoginLink }}">{{ .Data.LoginLink }}</a> and enter the code <strong>{{ .Data.Code }}</strong>.</br></br>This code expires in 15 minutes.
|
Click the button below to sign in to {{ .AppName }} with a login code.</br>Or visit <a href="{{ .Data.LoginLink }}">{{ .Data.LoginLink }}</a> and enter the code <strong>{{ .Data.Code }}</strong>.</br></br>This code expires in {{.Data.ExpirationString}}.
|
||||||
</p>
|
</p>
|
||||||
<div class="button-container">
|
<div class="button-container">
|
||||||
<a class="button" href="{{ .Data.LoginLinkWithCode }}" class="button">Sign In</a>
|
<a class="button" href="{{ .Data.LoginLinkWithCode }}" class="button">Sign In</a>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Login Code
|
Login Code
|
||||||
====================
|
====================
|
||||||
|
|
||||||
Click the link below to sign in to {{ .AppName }} with a login code. This code expires in 15 minutes.
|
Click the link below to sign in to {{ .AppName }} with a login code. This code expires in {{.Data.ExpirationString}}.
|
||||||
|
|
||||||
{{ .Data.LoginLinkWithCode }}
|
{{ .Data.LoginLinkWithCode }}
|
||||||
|
|
||||||
|
|||||||
@@ -156,7 +156,7 @@
|
|||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"images_updated_successfully": "Images updated successfully",
|
"images_updated_successfully": "Images updated successfully",
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"enable_email_notifications_to_alert_users_when_a_login_is_detected_from_a_new_device_or_location": "Enable email notifications to alert users when a login is detected from a new device or location.",
|
"configure_stmp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.",
|
||||||
"ldap": "LDAP",
|
"ldap": "LDAP",
|
||||||
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configure LDAP settings to sync users and groups from an LDAP server.",
|
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configure LDAP settings to sync users and groups from an LDAP server.",
|
||||||
"images": "Images",
|
"images": "Images",
|
||||||
@@ -180,7 +180,10 @@
|
|||||||
"enabled_emails": "Enabled Emails",
|
"enabled_emails": "Enabled Emails",
|
||||||
"email_login_notification": "Email Login Notification",
|
"email_login_notification": "Email Login Notification",
|
||||||
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Send an email to the user when they log in from a new device.",
|
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Send an email to the user when they log in from a new device.",
|
||||||
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to sign in with a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
|
"emai_login_code_requested_by_user": "Email Login Code Requested by User",
|
||||||
|
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
|
||||||
|
"email_login_code_from_admin": "Email Login Code from Admin",
|
||||||
|
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
|
||||||
"send_test_email": "Send test email",
|
"send_test_email": "Send test email",
|
||||||
"application_configuration_updated_successfully": "Application configuration updated successfully",
|
"application_configuration_updated_successfully": "Application configuration updated successfully",
|
||||||
"application_name": "Application Name",
|
"application_name": "Application Name",
|
||||||
@@ -334,5 +337,8 @@
|
|||||||
"are_you_sure_you_want_to_disable_this_user": "Are you sure you want to disable this user? They will not be able to log in or access any services.",
|
"are_you_sure_you_want_to_disable_this_user": "Are you sure you want to disable this user? They will not be able to log in or access any services.",
|
||||||
"ldap_soft_delete_users": "Keep disabled users from LDAP.",
|
"ldap_soft_delete_users": "Keep disabled users from LDAP.",
|
||||||
"ldap_soft_delete_users_description": "When enabled, users removed from LDAP will be disabled rather than deleted from the system.",
|
"ldap_soft_delete_users_description": "When enabled, users removed from LDAP will be disabled rather than deleted from the system.",
|
||||||
|
"login_code_email_success": "The login code has been sent to the user.",
|
||||||
|
"send_email": "Send Email",
|
||||||
|
"show_code": "Show Code",
|
||||||
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security."
|
"callback_url_description": "URL(s) provided by your client. Wildcards (*) are supported, but best avoided for better security."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,10 @@
|
|||||||
import { Separator } from '$lib/components/ui/separator';
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
import { m } from '$lib/paraglide/messages';
|
import { m } from '$lib/paraglide/messages';
|
||||||
import UserService from '$lib/services/user-service';
|
import UserService from '$lib/services/user-service';
|
||||||
|
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
import { mode } from 'mode-watcher';
|
import { mode } from 'mode-watcher';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
userId = $bindable()
|
userId = $bindable()
|
||||||
@@ -32,7 +34,7 @@
|
|||||||
[m.one_month()]: 60 * 60 * 24 * 30
|
[m.one_month()]: 60 * 60 * 24 * 30
|
||||||
};
|
};
|
||||||
|
|
||||||
async function createOneTimeAccessToken() {
|
async function createLoginCode() {
|
||||||
try {
|
try {
|
||||||
const expiration = new Date(Date.now() + availableExpirations[selectedExpiration] * 1000);
|
const expiration = new Date(Date.now() + availableExpirations[selectedExpiration] * 1000);
|
||||||
code = await userService.createOneTimeAccessToken(expiration, userId!);
|
code = await userService.createOneTimeAccessToken(expiration, userId!);
|
||||||
@@ -42,6 +44,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendLoginCodeEmail() {
|
||||||
|
try {
|
||||||
|
const expiration = new Date(Date.now() + availableExpirations[selectedExpiration] * 1000);
|
||||||
|
await userService.requestOneTimeAccessEmailAsAdmin(userId!, expiration);
|
||||||
|
toast.success(m.login_code_email_success());
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (e) {
|
||||||
|
axiosErrorToast(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onOpenChange(open: boolean) {
|
function onOpenChange(open: boolean) {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
oneTimeLink = null;
|
oneTimeLink = null;
|
||||||
@@ -81,13 +94,20 @@
|
|||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Root>
|
</Select.Root>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Dialog.Footer class="mt-2">
|
||||||
onclick={() => createOneTimeAccessToken()}
|
{#if $appConfigStore.emailOneTimeAccessAsAdminEnabled}
|
||||||
disabled={!selectedExpiration}
|
<Button
|
||||||
class="mt-2 w-full"
|
onclick={() => sendLoginCodeEmail()}
|
||||||
>
|
variant="secondary"
|
||||||
{m.generate_code()}
|
disabled={!selectedExpiration}
|
||||||
</Button>
|
>
|
||||||
|
{m.send_email()}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
<Button onclick={() => createLoginCode()} disabled={!selectedExpiration}
|
||||||
|
>{m.show_code()}</Button
|
||||||
|
>
|
||||||
|
</Dialog.Footer>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-col items-center gap-2">
|
<div class="flex flex-col items-center gap-2">
|
||||||
<CopyToClipboard value={code!}>
|
<CopyToClipboard value={code!}>
|
||||||
|
|||||||
@@ -87,10 +87,14 @@ export default class UserService extends APIService {
|
|||||||
return res.data as User;
|
return res.data as User;
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestOneTimeAccessEmail(email: string, redirectPath?: string) {
|
async requestOneTimeAccessEmailAsUnauthenticatedUser(email: string, redirectPath?: string) {
|
||||||
await this.api.post('/one-time-access-email', { email, redirectPath });
|
await this.api.post('/one-time-access-email', { email, redirectPath });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async requestOneTimeAccessEmailAsAdmin(userId: string, expiresAt: Date) {
|
||||||
|
await this.api.post(`/users/${userId}/one-time-access-email`, { expiresAt });
|
||||||
|
}
|
||||||
|
|
||||||
async updateUserGroups(id: string, userGroupIds: string[]) {
|
async updateUserGroups(id: string, userGroupIds: string[]) {
|
||||||
const res = await this.api.put(`/users/${id}/user-groups`, { userGroupIds });
|
const res = await this.api.put(`/users/${id}/user-groups`, { userGroupIds });
|
||||||
return res.data as User;
|
return res.data as User;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
export type AppConfig = {
|
export type AppConfig = {
|
||||||
appName: string;
|
appName: string;
|
||||||
allowOwnAccountEdit: boolean;
|
allowOwnAccountEdit: boolean;
|
||||||
emailOneTimeAccessEnabled: boolean;
|
emailOneTimeAccessAsUnauthenticatedEnabled: boolean;
|
||||||
|
emailOneTimeAccessAsAdminEnabled: boolean;
|
||||||
ldapEnabled: boolean;
|
ldapEnabled: boolean;
|
||||||
disableAnimations: boolean;
|
disableAnimations: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($appConfigStore.emailOneTimeAccessEnabled) {
|
if ($appConfigStore.emailOneTimeAccessAsUnauthenticatedEnabled) {
|
||||||
methods.push({
|
methods.push({
|
||||||
icon: LucideMail,
|
icon: LucideMail,
|
||||||
title: m.email_login(),
|
title: m.email_login(),
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
id="application-configuration-email"
|
id="application-configuration-email"
|
||||||
icon={Mail}
|
icon={Mail}
|
||||||
title={m.email()}
|
title={m.email()}
|
||||||
description={m.enable_email_notifications_to_alert_users_when_a_login_is_detected_from_a_new_device_or_location()}
|
description={m.configure_stmp_to_send_emails()}
|
||||||
>
|
>
|
||||||
<AppConfigEmailForm {appConfig} callback={updateAppConfig} />
|
<AppConfigEmailForm {appConfig} callback={updateAppConfig} />
|
||||||
</CollapsibleCard>
|
</CollapsibleCard>
|
||||||
|
|||||||
@@ -39,7 +39,8 @@
|
|||||||
smtpFrom: z.string().email(),
|
smtpFrom: z.string().email(),
|
||||||
smtpTls: z.enum(['none', 'starttls', 'tls']),
|
smtpTls: z.enum(['none', 'starttls', 'tls']),
|
||||||
smtpSkipCertVerify: z.boolean(),
|
smtpSkipCertVerify: z.boolean(),
|
||||||
emailOneTimeAccessEnabled: z.boolean(),
|
emailOneTimeAccessAsUnauthenticatedEnabled: z.boolean(),
|
||||||
|
emailOneTimeAccessAsAdminEnabled: z.boolean(),
|
||||||
emailLoginNotificationEnabled: z.boolean()
|
emailLoginNotificationEnabled: z.boolean()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,9 +89,7 @@
|
|||||||
await appConfigService
|
await appConfigService
|
||||||
.sendTestEmail()
|
.sendTestEmail()
|
||||||
.then(() => toast.success(m.test_email_sent_successfully()))
|
.then(() => toast.success(m.test_email_sent_successfully()))
|
||||||
.catch(() =>
|
.catch(() => toast.error(m.failed_to_send_test_email()))
|
||||||
toast.error(m.failed_to_send_test_email())
|
|
||||||
)
|
|
||||||
.finally(() => (isSendingTestEmail = false));
|
.finally(() => (isSendingTestEmail = false));
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -136,10 +135,16 @@
|
|||||||
bind:checked={$inputs.emailLoginNotificationEnabled.value}
|
bind:checked={$inputs.emailLoginNotificationEnabled.value}
|
||||||
/>
|
/>
|
||||||
<CheckboxWithLabel
|
<CheckboxWithLabel
|
||||||
id="email-login"
|
id="email-login-user"
|
||||||
label={m.email_login()}
|
label={m.emai_login_code_requested_by_user()}
|
||||||
description={m.allow_users_to_sign_in_with_a_login_code_sent_to_their_email()}
|
description={m.allow_users_to_sign_in_with_a_login_code_sent_to_their_email()}
|
||||||
bind:checked={$inputs.emailOneTimeAccessEnabled.value}
|
bind:checked={$inputs.emailOneTimeAccessAsUnauthenticatedEnabled.value}
|
||||||
|
/>
|
||||||
|
<CheckboxWithLabel
|
||||||
|
id="email-login-admin"
|
||||||
|
label={m.email_login_code_from_admin()}
|
||||||
|
description={m.allows_an_admin_to_send_a_login_code_to_the_user()}
|
||||||
|
bind:checked={$inputs.emailOneTimeAccessAsAdminEnabled.value}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -161,4 +161,4 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</AdvancedTable>
|
</AdvancedTable>
|
||||||
|
|
||||||
<OneTimeLinkModal userId={userIdToCreateOneTimeLink} />
|
<OneTimeLinkModal bind:userId={userIdToCreateOneTimeLink} />
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ test('Update email configuration', async ({ page }) => {
|
|||||||
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.getByLabel('Email Login Notification').click();
|
await page.getByLabel('Email Login Notification').click();
|
||||||
await page.getByLabel('Email Login', { exact: true }).click();
|
await page.getByLabel('Email Login Code Requested by User').click();
|
||||||
|
await page.getByLabel('Email Login Code from Admin').click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Save' }).nth(1).click();
|
await page.getByRole('button', { name: 'Save' }).nth(1).click();
|
||||||
|
|
||||||
@@ -46,7 +47,8 @@ test('Update email configuration', async ({ page }) => {
|
|||||||
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 expect(page.getByLabel('Email Login Notification')).toBeChecked();
|
||||||
await expect(page.getByLabel('Email Login', { exact: true })).toBeChecked();
|
await expect(page.getByLabel('Email Login Code Requested by User')).toBeChecked();
|
||||||
|
await expect(page.getByLabel('Email Login Code from Admin')).toBeChecked();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Update LDAP configuration', async ({ page }) => {
|
test('Update LDAP configuration', async ({ page }) => {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ test('Create one time access token', async ({ page, context }) => {
|
|||||||
|
|
||||||
await page.getByLabel('Login Code').getByRole('combobox').click();
|
await page.getByLabel('Login Code').getByRole('combobox').click();
|
||||||
await page.getByRole('option', { name: '12 hours' }).click();
|
await page.getByRole('option', { name: '12 hours' }).click();
|
||||||
await page.getByRole('button', { name: 'Generate Code' }).click();
|
await page.getByRole('button', { name: 'Show Code' }).click();
|
||||||
|
|
||||||
const link = await page.getByTestId('login-code-link').textContent();
|
const link = await page.getByTestId('login-code-link').textContent();
|
||||||
await context.clearCookies();
|
await context.clearCookies();
|
||||||
|
|||||||
Reference in New Issue
Block a user