mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-13 00:33:06 +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/: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
|
||||
|
||||
@@ -43,6 +43,7 @@ type AppConfigUpdateDto struct {
|
||||
LdapAttributeGroupName string `json:"ldapAttributeGroupName"`
|
||||
LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"`
|
||||
LdapSoftDeleteUsers string `json:"ldapSoftDeleteUsers"`
|
||||
EmailOneTimeAccessEnabled string `json:"emailOneTimeAccessEnabled" binding:"required"`
|
||||
EmailOneTimeAccessAsAdminEnabled string `json:"emailOneTimeAccessAsAdminEnabled" 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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -49,7 +49,8 @@ type AppConfig struct {
|
||||
SmtpTls AppConfigVariable `key:"smtpTls"`
|
||||
SmtpSkipCertVerify AppConfigVariable `key:"smtpSkipCertVerify"`
|
||||
EmailLoginNotificationEnabled AppConfigVariable `key:"emailLoginNotificationEnabled"`
|
||||
EmailOneTimeAccessEnabled AppConfigVariable `key:"emailOneTimeAccessEnabled,public"` // Public
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -61,6 +61,7 @@ type OneTimeAccessTemplateData = struct {
|
||||
Code string
|
||||
LoginLink string
|
||||
LoginLinkWithCode string
|
||||
ExpirationString string
|
||||
}
|
||||
|
||||
type ApiKeyExpiringSoonTemplateData struct {
|
||||
|
||||
@@ -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)
|
||||
|
||||
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">
|
||||
<h2>Login Code</h2>
|
||||
<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>
|
||||
<div class="button-container">
|
||||
<a class="button" href="{{ .Data.LoginLinkWithCode }}" class="button">Sign In</a>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<Dialog.Footer class="mt-2">
|
||||
{#if $appConfigStore.emailOneTimeAccessAsAdminEnabled}
|
||||
<Button
|
||||
onclick={() => createOneTimeAccessToken()}
|
||||
onclick={() => sendLoginCodeEmail()}
|
||||
variant="secondary"
|
||||
disabled={!selectedExpiration}
|
||||
class="mt-2 w-full"
|
||||
>
|
||||
{m.generate_code()}
|
||||
{m.send_email()}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button onclick={() => createLoginCode()} disabled={!selectedExpiration}
|
||||
>{m.show_code()}</Button
|
||||
>
|
||||
</Dialog.Footer>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<CopyToClipboard value={code!}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export type AppConfig = {
|
||||
appName: string;
|
||||
allowOwnAccountEdit: boolean;
|
||||
emailOneTimeAccessEnabled: boolean;
|
||||
emailOneTimeAccessAsUnauthenticatedEnabled: boolean;
|
||||
emailOneTimeAccessAsAdminEnabled: boolean;
|
||||
ldapEnabled: boolean;
|
||||
disableAnimations: boolean;
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
}
|
||||
];
|
||||
|
||||
if ($appConfigStore.emailOneTimeAccessEnabled) {
|
||||
if ($appConfigStore.emailOneTimeAccessAsUnauthenticatedEnabled) {
|
||||
methods.push({
|
||||
icon: LucideMail,
|
||||
title: m.email_login(),
|
||||
|
||||
@@ -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()}
|
||||
>
|
||||
<AppConfigEmailForm {appConfig} callback={updateAppConfig} />
|
||||
</CollapsibleCard>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
</script>
|
||||
@@ -136,10 +135,16 @@
|
||||
bind:checked={$inputs.emailLoginNotificationEnabled.value}
|
||||
/>
|
||||
<CheckboxWithLabel
|
||||
id="email-login"
|
||||
label={m.email_login()}
|
||||
id="email-login-user"
|
||||
label={m.emai_login_code_requested_by_user()}
|
||||
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>
|
||||
</fieldset>
|
||||
|
||||
@@ -161,4 +161,4 @@
|
||||
{/snippet}
|
||||
</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 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 }) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user