feat: add support for additional emails in user management and update related configurations

This commit is contained in:
Wikid82
2025-11-25 18:30:16 +00:00
parent 07be2155be
commit 8c36a8dee4
10 changed files with 159 additions and 39 deletions
+24 -18
View File
@@ -45,12 +45,13 @@ func (h *AuthUserHandler) Get(c *gin.Context) {
// CreateRequest represents the request body for creating an auth user
type CreateAuthUserRequest struct {
Username string `json:"username" binding:"required"`
Email string `json:"email" binding:"required,email"`
Name string `json:"name"`
Password string `json:"password" binding:"required,min=8"`
Roles string `json:"roles"`
MFAEnabled bool `json:"mfa_enabled"`
Username string `json:"username" binding:"required"`
Email string `json:"email" binding:"required,email"`
Name string `json:"name"`
Password string `json:"password" binding:"required,min=8"`
Roles string `json:"roles"`
MFAEnabled bool `json:"mfa_enabled"`
AdditionalEmails string `json:"additional_emails"`
}
// Create creates a new auth user
@@ -62,12 +63,13 @@ func (h *AuthUserHandler) Create(c *gin.Context) {
}
user := models.AuthUser{
Username: req.Username,
Email: req.Email,
Name: req.Name,
Roles: req.Roles,
MFAEnabled: req.MFAEnabled,
Enabled: true,
Username: req.Username,
Email: req.Email,
Name: req.Name,
Roles: req.Roles,
MFAEnabled: req.MFAEnabled,
AdditionalEmails: req.AdditionalEmails,
Enabled: true,
}
if err := user.SetPassword(req.Password); err != nil {
@@ -85,12 +87,13 @@ func (h *AuthUserHandler) Create(c *gin.Context) {
// UpdateRequest represents the request body for updating an auth user
type UpdateAuthUserRequest struct {
Email *string `json:"email,omitempty"`
Name *string `json:"name,omitempty"`
Password *string `json:"password,omitempty"`
Roles *string `json:"roles,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
MFAEnabled *bool `json:"mfa_enabled,omitempty"`
Email *string `json:"email,omitempty"`
Name *string `json:"name,omitempty"`
Password *string `json:"password,omitempty"`
Roles *string `json:"roles,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
MFAEnabled *bool `json:"mfa_enabled,omitempty"`
AdditionalEmails *string `json:"additional_emails,omitempty"`
}
// Update updates an existing auth user
@@ -133,6 +136,9 @@ func (h *AuthUserHandler) Update(c *gin.Context) {
if req.MFAEnabled != nil {
user.MFAEnabled = *req.MFAEnabled
}
if req.AdditionalEmails != nil {
user.AdditionalEmails = *req.AdditionalEmails
}
if err := h.db.Save(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -147,6 +147,28 @@ func TestAuthUserHandler_Create(t *testing.T) {
assert.True(t, result.Enabled)
})
t.Run("with additional emails", func(t *testing.T) {
body := map[string]interface{}{
"username": "multiemail",
"email": "primary@example.com",
"password": "password123",
"additional_emails": "alt1@example.com,alt2@example.com",
}
jsonBody, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/security/users", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
var result models.AuthUser
json.Unmarshal(w.Body.Bytes(), &result)
assert.Equal(t, "multiemail", result.Username)
assert.Equal(t, "alt1@example.com,alt2@example.com", result.AdditionalEmails)
})
t.Run("invalid email", func(t *testing.T) {
body := map[string]interface{}{
"username": "baduser",
@@ -192,6 +214,24 @@ func TestAuthUserHandler_Update(t *testing.T) {
assert.False(t, result.Enabled)
})
t.Run("update additional emails", func(t *testing.T) {
body := map[string]interface{}{
"additional_emails": "newalt@example.com",
}
jsonBody, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("PUT", "/api/v1/security/users/"+user.UUID, bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var result models.AuthUser
json.Unmarshal(w.Body.Bytes(), &result)
assert.Equal(t, "newalt@example.com", result.AdditionalEmails)
})
t.Run("not found", func(t *testing.T) {
body := map[string]interface{}{"name": "Test"}
jsonBody, _ := json.Marshal(body)
+32 -12
View File
@@ -23,7 +23,7 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin
Logging: &LoggingConfig{
Logs: map[string]*LogConfig{
"access": {
Level: "DEBUG",
Level: "INFO",
Writer: &WriterConfig{
Output: "file",
Filename: logFile,
@@ -440,21 +440,41 @@ func convertAuthUsersToConfig(users []models.AuthUser) []map[string]interface{}
continue
}
userConfig := map[string]interface{}{
"username": user.Username,
"email": user.Email,
"password": user.PasswordHash, // Already bcrypt hashed
// Helper to create user config
createUserConfig := func(username, email string) map[string]interface{} {
cfg := map[string]interface{}{
"username": username,
"email": email,
"password": user.PasswordHash, // Already bcrypt hashed
}
if user.Name != "" {
cfg["name"] = user.Name
}
if user.Roles != "" {
cfg["roles"] = strings.Split(user.Roles, ",")
}
return cfg
}
if user.Name != "" {
userConfig["name"] = user.Name
}
// Add primary user
result = append(result, createUserConfig(user.Username, user.Email))
if user.Roles != "" {
userConfig["roles"] = strings.Split(user.Roles, ",")
// Add additional emails as alias users
if user.AdditionalEmails != "" {
emails := strings.Split(user.AdditionalEmails, ",")
for i, email := range emails {
email = strings.TrimSpace(email)
if email == "" {
continue
}
// Create a derived username for the alias
// We use a predictable suffix so it doesn't change
aliasUsername := fmt.Sprintf("%s_alt%d", user.Username, i+1)
result = append(result, createUserConfig(aliasUsername, email))
}
}
result = append(result, userConfig)
}
return result
}
+1 -1
View File
@@ -124,7 +124,7 @@ func TestGenerateConfig_Logging(t *testing.T) {
require.NotNil(t, config.Logging)
require.NotNil(t, config.Logging.Logs)
require.NotNil(t, config.Logging.Logs["access"])
require.Equal(t, "DEBUG", config.Logging.Logs["access"].Level)
require.Equal(t, "INFO", config.Logging.Logs["access"].Level)
require.Contains(t, config.Logging.Logs["access"].Writer.Filename, "access.log")
require.Equal(t, 10, config.Logging.Logs["access"].Writer.RollSize)
require.Equal(t, 5, config.Logging.Logs["access"].Writer.RollKeep)
+2 -1
View File
@@ -101,7 +101,8 @@ type Handler map[string]interface{}
// ReverseProxyHandler creates a reverse_proxy handler.
func ReverseProxyHandler(dial string, enableWS bool) Handler {
h := Handler{
"handler": "reverse_proxy",
"handler": "reverse_proxy",
"flush_interval": -1, // Disable buffering for better streaming performance (Plex, etc.)
"upstreams": []map[string]interface{}{
{"dial": dial},
},
+3
View File
@@ -24,6 +24,9 @@ type AuthUser struct {
PasswordHash string `gorm:"not null" json:"-"` // Never expose in JSON
Enabled bool `gorm:"default:true" json:"enabled"`
// Additional emails for linking identities (comma-separated)
AdditionalEmails string `json:"additional_emails"`
// Authorization
Roles string `json:"roles"` // Comma-separated roles (e.g., "admin,user")