diff --git a/backend/internal/controller/user_controller.go b/backend/internal/controller/user_controller.go index c7b09a38..f778ab79 100644 --- a/backend/internal/controller/user_controller.go +++ b/backend/internal/controller/user_controller.go @@ -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/: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/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/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler) @@ -356,18 +357,63 @@ func (uc *UserController) createOwnOneTimeAccessTokenHandler(c *gin.Context) { 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) { uc.createOneTimeAccessTokenHandler(c, false) } -func (uc *UserController) requestOneTimeAccessEmailHandler(c *gin.Context) { - var input dto.OneTimeAccessEmailDto +// RequestOneTimeAccessEmailAsUnauthenticatedUserHandler godoc +// @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 { _ = c.Error(err) 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 { _ = c.Error(err) return diff --git a/backend/internal/dto/app_config_dto.go b/backend/internal/dto/app_config_dto.go index 91be6dd9..baef5847 100644 --- a/backend/internal/dto/app_config_dto.go +++ b/backend/internal/dto/app_config_dto.go @@ -12,37 +12,38 @@ type AppConfigVariableDto struct { } type AppConfigUpdateDto struct { - AppName string `json:"appName" binding:"required,min=1,max=30"` - SessionDuration string `json:"sessionDuration" binding:"required"` - EmailsVerified string `json:"emailsVerified" binding:"required"` - DisableAnimations string `json:"disableAnimations" binding:"required"` - AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"` - SmtpHost string `json:"smtpHost"` - SmtpPort string `json:"smtpPort"` - SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"` - SmtpUser string `json:"smtpUser"` - SmtpPassword string `json:"smtpPassword"` - SmtpTls string `json:"smtpTls" binding:"required,oneof=none starttls tls"` - SmtpSkipCertVerify string `json:"smtpSkipCertVerify"` - LdapEnabled string `json:"ldapEnabled" binding:"required"` - LdapUrl string `json:"ldapUrl"` - LdapBindDn string `json:"ldapBindDn"` - LdapBindPassword string `json:"ldapBindPassword"` - LdapBase string `json:"ldapBase"` - LdapUserSearchFilter string `json:"ldapUserSearchFilter"` - LdapUserGroupSearchFilter string `json:"ldapUserGroupSearchFilter"` - LdapSkipCertVerify string `json:"ldapSkipCertVerify"` - LdapAttributeUserUniqueIdentifier string `json:"ldapAttributeUserUniqueIdentifier"` - LdapAttributeUserUsername string `json:"ldapAttributeUserUsername"` - LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"` - LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"` - LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"` - LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"` - LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"` - LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"` - LdapAttributeGroupName string `json:"ldapAttributeGroupName"` - LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"` - LdapSoftDeleteUsers string `json:"ldapSoftDeleteUsers"` - EmailOneTimeAccessEnabled string `json:"emailOneTimeAccessEnabled" binding:"required"` - EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"` + AppName string `json:"appName" binding:"required,min=1,max=30"` + SessionDuration string `json:"sessionDuration" binding:"required"` + EmailsVerified string `json:"emailsVerified" binding:"required"` + DisableAnimations string `json:"disableAnimations" binding:"required"` + AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"` + SmtpHost string `json:"smtpHost"` + SmtpPort string `json:"smtpPort"` + SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"` + SmtpUser string `json:"smtpUser"` + SmtpPassword string `json:"smtpPassword"` + SmtpTls string `json:"smtpTls" binding:"required,oneof=none starttls tls"` + SmtpSkipCertVerify string `json:"smtpSkipCertVerify"` + LdapEnabled string `json:"ldapEnabled" binding:"required"` + LdapUrl string `json:"ldapUrl"` + LdapBindDn string `json:"ldapBindDn"` + LdapBindPassword string `json:"ldapBindPassword"` + LdapBase string `json:"ldapBase"` + LdapUserSearchFilter string `json:"ldapUserSearchFilter"` + LdapUserGroupSearchFilter string `json:"ldapUserGroupSearchFilter"` + LdapSkipCertVerify string `json:"ldapSkipCertVerify"` + LdapAttributeUserUniqueIdentifier string `json:"ldapAttributeUserUniqueIdentifier"` + LdapAttributeUserUsername string `json:"ldapAttributeUserUsername"` + LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"` + LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"` + LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"` + LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"` + LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"` + LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"` + LdapAttributeGroupName string `json:"ldapAttributeGroupName"` + LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"` + LdapSoftDeleteUsers string `json:"ldapSoftDeleteUsers"` + EmailOneTimeAccessAsAdminEnabled string `json:"emailOneTimeAccessAsAdminEnabled" binding:"required"` + EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required"` + EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"` } diff --git a/backend/internal/dto/user_dto.go b/backend/internal/dto/user_dto.go index da984743..75919bf6 100644 --- a/backend/internal/dto/user_dto.go +++ b/backend/internal/dto/user_dto.go @@ -32,11 +32,15 @@ type OneTimeAccessTokenCreateDto struct { ExpiresAt time.Time `json:"expiresAt" binding:"required"` } -type OneTimeAccessEmailDto struct { +type OneTimeAccessEmailAsUnauthenticatedUserDto struct { Email string `json:"email" binding:"required,email"` RedirectPath string `json:"redirectPath"` } +type OneTimeAccessEmailAsAdminDto struct { + ExpiresAt time.Time `json:"expiresAt" binding:"required"` +} + type UserUpdateUserGroupDto struct { UserGroupIds []string `json:"userGroupIds" binding:"required"` } diff --git a/backend/internal/model/app_config.go b/backend/internal/model/app_config.go index 075e9996..0e03f62f 100644 --- a/backend/internal/model/app_config.go +++ b/backend/internal/model/app_config.go @@ -41,15 +41,16 @@ type AppConfig struct { LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal LogoDarkImageType AppConfigVariable `key:"logoDarkImageType,internal"` // Internal // Email - SmtpHost AppConfigVariable `key:"smtpHost"` - SmtpPort AppConfigVariable `key:"smtpPort"` - SmtpFrom AppConfigVariable `key:"smtpFrom"` - SmtpUser AppConfigVariable `key:"smtpUser"` - SmtpPassword AppConfigVariable `key:"smtpPassword"` - SmtpTls AppConfigVariable `key:"smtpTls"` - SmtpSkipCertVerify AppConfigVariable `key:"smtpSkipCertVerify"` - EmailLoginNotificationEnabled AppConfigVariable `key:"emailLoginNotificationEnabled"` - EmailOneTimeAccessEnabled AppConfigVariable `key:"emailOneTimeAccessEnabled,public"` // Public + SmtpHost AppConfigVariable `key:"smtpHost"` + SmtpPort AppConfigVariable `key:"smtpPort"` + SmtpFrom AppConfigVariable `key:"smtpFrom"` + SmtpUser AppConfigVariable `key:"smtpUser"` + SmtpPassword AppConfigVariable `key:"smtpPassword"` + SmtpTls AppConfigVariable `key:"smtpTls"` + SmtpSkipCertVerify AppConfigVariable `key:"smtpSkipCertVerify"` + EmailLoginNotificationEnabled AppConfigVariable `key:"emailLoginNotificationEnabled"` + EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigVariable `key:"emailOneTimeAccessAsUnauthenticatedEnabled,public"` // Public + EmailOneTimeAccessAsAdminEnabled AppConfigVariable `key:"emailOneTimeAccessAsAdminEnabled,public"` // Public // LDAP LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public LdapUrl AppConfigVariable `key:"ldapUrl"` @@ -77,7 +78,7 @@ func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable { cfgValue := reflect.ValueOf(c).Elem() cfgType := cfgValue.Type() - res := make([]AppConfigVariable, cfgType.NumField()) + var res []AppConfigVariable for i := range cfgType.NumField() { field := cfgType.Field(i) @@ -94,10 +95,12 @@ func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable { fieldValue := cfgValue.Field(i) - res[i] = AppConfigVariable{ + appConfigVariable := AppConfigVariable{ Key: key, Value: fieldValue.FieldByName("Value").String(), } + + res = append(res, appConfigVariable) } return res diff --git a/backend/internal/service/app_config_service.go b/backend/internal/service/app_config_service.go index a5953a03..d6a511fe 100644 --- a/backend/internal/service/app_config_service.go +++ b/backend/internal/service/app_config_service.go @@ -73,7 +73,8 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig { SmtpTls: model.AppConfigVariable{Value: "none"}, SmtpSkipCertVerify: 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 LdapEnabled: model.AppConfigVariable{Value: "false"}, LdapUrl: model.AppConfigVariable{}, @@ -151,11 +152,6 @@ func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppCon return nil, &common.UiConfigDisabledError{} } - // If EmailLoginNotificationEnabled is set to false (explicitly), disable the EmailOneTimeAccessEnabled - if input.EmailLoginNotificationEnabled == "false" { - input.EmailOneTimeAccessEnabled = "false" - } - // Start the transaction tx, err := s.updateAppConfigStartTransaction(ctx) if err != nil { diff --git a/backend/internal/service/app_config_service_test.go b/backend/internal/service/app_config_service_test.go index ea7100c1..0304ab8b 100644 --- a/backend/internal/service/app_config_service_test.go +++ b/backend/internal/service/app_config_service_test.go @@ -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) { // Save the original state and restore it after the test originalUiConfigDisabled := common.EnvConfig.UiConfigDisabled diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go index 7b7325fd..1a2b1cd9 100644 --- a/backend/internal/service/email_service.go +++ b/backend/internal/service/email_service.go @@ -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) // if the address does not have an @ (which would be unusual), we use hostname - from_address := dbConfig.SmtpFrom.Value + fromAddress := dbConfig.SmtpFrom.Value domain := "" - if strings.Contains(from_address, "@") { - domain = strings.Split(from_address, "@")[1] + if strings.Contains(fromAddress, "@") { + domain = strings.Split(fromAddress, "@")[1] } else { hostname, err := os.Hostname() if err != nil { diff --git a/backend/internal/service/email_service_templates.go b/backend/internal/service/email_service_templates.go index c140ab63..a7ca10a0 100644 --- a/backend/internal/service/email_service_templates.go +++ b/backend/internal/service/email_service_templates.go @@ -61,6 +61,7 @@ type OneTimeAccessTemplateData = struct { Code string LoginLink string LoginLinkWithCode string + ExpirationString string } type ApiKeyExpiringSoonTemplateData struct { diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index afd65231..b6347d04 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -348,23 +348,24 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd return user, nil } -func (s *UserService) RequestOneTimeAccessEmail(ctx context.Context, emailAddress, redirectPath string) error { - tx := s.db.Begin() - defer func() { - tx.Rollback() - }() - - isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessEnabled.IsTrue() +func (s *UserService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, userID string, expiration time.Time) error { + isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsAdminEnabled.IsTrue() if isDisabled { return &common.OneTimeAccessDisabledError{} } - var user model.User - err := tx. - WithContext(ctx). - Where("email = ?", emailAddress). - First(&user). - Error + return s.requestOneTimeAccessEmailInternal(ctx, userID, "", expiration) + +} + +func (s *UserService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, userID, redirectPath string) 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 { // Do not return error if user not found to prevent email enumeration 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 { return err } @@ -405,6 +421,7 @@ func (s *UserService) RequestOneTimeAccessEmail(ctx context.Context, emailAddres Code: oneTimeAccessToken, LoginLink: link, LoginLinkWithCode: linkWithCode, + ExpirationString: utils.DurationToString(time.Until(expiration).Round(time.Second)), }) if errInternal != nil { log.Printf("Failed to send email to '%s': %v\n", user.Email, errInternal) diff --git a/backend/internal/utils/date_time_util.go b/backend/internal/utils/date_time_util.go new file mode 100644 index 00000000..5ed15766 --- /dev/null +++ b/backend/internal/utils/date_time_util.go @@ -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) + } + } +} diff --git a/backend/resources/email-templates/one-time-access_html.tmpl b/backend/resources/email-templates/one-time-access_html.tmpl index 2c150ee9..bd7ca059 100644 --- a/backend/resources/email-templates/one-time-access_html.tmpl +++ b/backend/resources/email-templates/one-time-access_html.tmpl @@ -8,7 +8,7 @@

Login Code

- Click the button below to sign in to {{ .AppName }} with a login code.
Or visit {{ .Data.LoginLink }} and enter the code {{ .Data.Code }}.

This code expires in 15 minutes. + Click the button below to sign in to {{ .AppName }} with a login code.
Or visit {{ .Data.LoginLink }} and enter the code {{ .Data.Code }}.

This code expires in {{.Data.ExpirationString}}.

Sign In diff --git a/backend/resources/email-templates/one-time-access_text.tmpl b/backend/resources/email-templates/one-time-access_text.tmpl index 073edde8..5979d69f 100644 --- a/backend/resources/email-templates/one-time-access_text.tmpl +++ b/backend/resources/email-templates/one-time-access_text.tmpl @@ -2,7 +2,7 @@ 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 }} diff --git a/frontend/messages/en-US.json b/frontend/messages/en-US.json index b419e353..fd0f7a54 100644 --- a/frontend/messages/en-US.json +++ b/frontend/messages/en-US.json @@ -156,7 +156,7 @@ "actions": "Actions", "images_updated_successfully": "Images updated successfully", "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", "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", @@ -180,7 +180,10 @@ "enabled_emails": "Enabled Emails", "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.", - "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", "application_configuration_updated_successfully": "Application configuration updated successfully", "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.", "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.", + "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." } diff --git a/frontend/src/lib/components/one-time-link-modal.svelte b/frontend/src/lib/components/one-time-link-modal.svelte index 9300df1f..c60f8dd9 100644 --- a/frontend/src/lib/components/one-time-link-modal.svelte +++ b/frontend/src/lib/components/one-time-link-modal.svelte @@ -9,8 +9,10 @@ import { Separator } from '$lib/components/ui/separator'; import { m } from '$lib/paraglide/messages'; import UserService from '$lib/services/user-service'; + import appConfigStore from '$lib/stores/application-configuration-store'; import { axiosErrorToast } from '$lib/utils/error-util'; import { mode } from 'mode-watcher'; + import { toast } from 'svelte-sonner'; let { userId = $bindable() @@ -32,7 +34,7 @@ [m.one_month()]: 60 * 60 * 24 * 30 }; - async function createOneTimeAccessToken() { + async function createLoginCode() { try { const expiration = new Date(Date.now() + availableExpirations[selectedExpiration] * 1000); 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) { if (!open) { oneTimeLink = null; @@ -81,13 +94,20 @@
- + + {#if $appConfigStore.emailOneTimeAccessAsAdminEnabled} + + {/if} + + {:else}
diff --git a/frontend/src/lib/services/user-service.ts b/frontend/src/lib/services/user-service.ts index 97efa326..25002db0 100644 --- a/frontend/src/lib/services/user-service.ts +++ b/frontend/src/lib/services/user-service.ts @@ -87,10 +87,14 @@ export default class UserService extends APIService { 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 }); } + async requestOneTimeAccessEmailAsAdmin(userId: string, expiresAt: Date) { + await this.api.post(`/users/${userId}/one-time-access-email`, { expiresAt }); + } + async updateUserGroups(id: string, userGroupIds: string[]) { const res = await this.api.put(`/users/${id}/user-groups`, { userGroupIds }); return res.data as User; diff --git a/frontend/src/lib/types/application-configuration.ts b/frontend/src/lib/types/application-configuration.ts index 2678d49e..b9a01a9b 100644 --- a/frontend/src/lib/types/application-configuration.ts +++ b/frontend/src/lib/types/application-configuration.ts @@ -1,7 +1,8 @@ export type AppConfig = { appName: string; allowOwnAccountEdit: boolean; - emailOneTimeAccessEnabled: boolean; + emailOneTimeAccessAsUnauthenticatedEnabled: boolean; + emailOneTimeAccessAsAdminEnabled: boolean; ldapEnabled: boolean; disableAnimations: boolean; }; diff --git a/frontend/src/routes/login/alternative/+page.svelte b/frontend/src/routes/login/alternative/+page.svelte index d3c5ab06..838fe847 100644 --- a/frontend/src/routes/login/alternative/+page.svelte +++ b/frontend/src/routes/login/alternative/+page.svelte @@ -17,7 +17,7 @@ } ]; - if ($appConfigStore.emailOneTimeAccessEnabled) { + if ($appConfigStore.emailOneTimeAccessAsUnauthenticatedEnabled) { methods.push({ icon: LucideMail, title: m.email_login(), diff --git a/frontend/src/routes/settings/admin/application-configuration/+page.svelte b/frontend/src/routes/settings/admin/application-configuration/+page.svelte index 35cd8e76..93ccfa62 100644 --- a/frontend/src/routes/settings/admin/application-configuration/+page.svelte +++ b/frontend/src/routes/settings/admin/application-configuration/+page.svelte @@ -73,7 +73,7 @@ id="application-configuration-email" icon={Mail} 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()} > diff --git a/frontend/src/routes/settings/admin/application-configuration/forms/app-config-email-form.svelte b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-email-form.svelte index 16b3d391..10c2ae4b 100644 --- a/frontend/src/routes/settings/admin/application-configuration/forms/app-config-email-form.svelte +++ b/frontend/src/routes/settings/admin/application-configuration/forms/app-config-email-form.svelte @@ -39,7 +39,8 @@ smtpFrom: z.string().email(), smtpTls: z.enum(['none', 'starttls', 'tls']), smtpSkipCertVerify: z.boolean(), - emailOneTimeAccessEnabled: z.boolean(), + emailOneTimeAccessAsUnauthenticatedEnabled: z.boolean(), + emailOneTimeAccessAsAdminEnabled: z.boolean(), emailLoginNotificationEnabled: z.boolean() }); @@ -88,9 +89,7 @@ await appConfigService .sendTestEmail() .then(() => toast.success(m.test_email_sent_successfully())) - .catch(() => - toast.error(m.failed_to_send_test_email()) - ) + .catch(() => toast.error(m.failed_to_send_test_email())) .finally(() => (isSendingTestEmail = false)); } @@ -136,10 +135,16 @@ bind:checked={$inputs.emailLoginNotificationEnabled.value} /> +
diff --git a/frontend/src/routes/settings/admin/users/user-list.svelte b/frontend/src/routes/settings/admin/users/user-list.svelte index 2211237c..89008f23 100644 --- a/frontend/src/routes/settings/admin/users/user-list.svelte +++ b/frontend/src/routes/settings/admin/users/user-list.svelte @@ -161,4 +161,4 @@ {/snippet} - + diff --git a/frontend/tests/application-configuration.spec.ts b/frontend/tests/application-configuration.spec.ts index 6b888c9d..ffc93b54 100644 --- a/frontend/tests/application-configuration.spec.ts +++ b/frontend/tests/application-configuration.spec.ts @@ -32,7 +32,8 @@ test('Update email configuration', async ({ page }) => { await page.getByLabel('SMTP Password').fill('password'); await page.getByLabel('SMTP From').fill('test@gmail.com'); 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(); @@ -46,7 +47,8 @@ test('Update email configuration', async ({ page }) => { await expect(page.getByLabel('SMTP Password')).toHaveValue('password'); await expect(page.getByLabel('SMTP From')).toHaveValue('test@gmail.com'); 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 }) => { diff --git a/frontend/tests/user-settings.spec.ts b/frontend/tests/user-settings.spec.ts index 357b652e..edf16f04 100644 --- a/frontend/tests/user-settings.spec.ts +++ b/frontend/tests/user-settings.spec.ts @@ -64,7 +64,7 @@ test('Create one time access token', async ({ page, context }) => { await page.getByLabel('Login Code').getByRole('combobox').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(); await context.clearCookies();