feat: add support for additional emails in user management and update related configurations
This commit is contained in:
@@ -68,13 +68,11 @@ ARG VCS_REF=unknown
|
||||
ARG BUILD_DATE=unknown
|
||||
|
||||
# Build the Go binary with version information injected via ldflags
|
||||
# -gcflags "all=-N -l" disables optimizations and inlining for better debugging
|
||||
# xx-go handles CGO and cross-compilation flags automatically
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
CGO_ENABLED=1 xx-go build \
|
||||
-gcflags "all=-N -l" \
|
||||
-ldflags "-X github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version.Version=${VERSION} \
|
||||
-ldflags "-s -w -X github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version.Version=${VERSION} \
|
||||
-X github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version.GitCommit=${VCS_REF} \
|
||||
-X github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version.BuildTime=${BUILD_DATE}" \
|
||||
-o cpmp ./cmd/api
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface AuthUser {
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
additional_emails?: string;
|
||||
}
|
||||
|
||||
export interface AuthUserStats {
|
||||
@@ -62,6 +63,7 @@ export interface CreateAuthUserRequest {
|
||||
password?: string;
|
||||
roles: string;
|
||||
mfa_enabled: boolean;
|
||||
additional_emails?: string;
|
||||
}
|
||||
|
||||
export interface UpdateAuthUserRequest {
|
||||
@@ -71,6 +73,7 @@ export interface UpdateAuthUserRequest {
|
||||
roles?: string;
|
||||
mfa_enabled?: boolean;
|
||||
enabled?: boolean;
|
||||
additional_emails?: string;
|
||||
}
|
||||
|
||||
export const getAuthUsers = async (): Promise<AuthUser[]> => {
|
||||
|
||||
@@ -226,24 +226,58 @@ export default function Providers() {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">Client ID</label>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-400">Client ID</label>
|
||||
<div className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
className="w-4 h-4 rounded-full bg-gray-700 text-gray-400 hover:bg-gray-600 flex items-center justify-center text-xs"
|
||||
title="OAuth Client ID help"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
<div className="invisible group-hover:visible absolute bottom-6 left-1/2 transform -translate-x-1/2 bg-gray-800 text-white text-xs rounded-lg px-3 py-2 whitespace-nowrap z-10 shadow-lg">
|
||||
The public identifier for your OAuth application.<br/>
|
||||
Found in your provider's developer console (e.g., Google Cloud Console, GitHub Developer Settings).
|
||||
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.client_id}
|
||||
onChange={e => setFormData({ ...formData, client_id: e.target.value })}
|
||||
placeholder="e.g., 123456789.apps.googleusercontent.com"
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">
|
||||
{editingProvider ? 'Client Secret (leave blank to keep)' : 'Client Secret'}
|
||||
</label>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-400">
|
||||
{editingProvider ? 'Client Secret (leave blank to keep)' : 'Client Secret'}
|
||||
</label>
|
||||
<div className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
className="w-4 h-4 rounded-full bg-gray-700 text-gray-400 hover:bg-gray-600 flex items-center justify-center text-xs"
|
||||
title="OAuth Client Secret help"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
<div className="invisible group-hover:visible absolute bottom-6 left-1/2 transform -translate-x-1/2 bg-gray-800 text-white text-xs rounded-lg px-3 py-2 whitespace-nowrap z-10 shadow-lg">
|
||||
The private key for your OAuth application.<br/>
|
||||
Keep this secret and secure! Generate/regenerate in your provider's developer console.
|
||||
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
required={!editingProvider}
|
||||
value={formData.client_secret}
|
||||
onChange={e => setFormData({ ...formData, client_secret: e.target.value })}
|
||||
placeholder="Enter your client secret"
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function Users() {
|
||||
password: '',
|
||||
roles: '',
|
||||
mfa_enabled: false,
|
||||
additional_emails: '',
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
@@ -27,6 +28,7 @@ export default function Users() {
|
||||
name: formData.name,
|
||||
roles: formData.roles,
|
||||
mfa_enabled: formData.mfa_enabled,
|
||||
additional_emails: formData.additional_emails,
|
||||
};
|
||||
if (formData.password) {
|
||||
updateData.password = formData.password;
|
||||
@@ -63,6 +65,7 @@ export default function Users() {
|
||||
password: '',
|
||||
roles: '',
|
||||
mfa_enabled: false,
|
||||
additional_emails: '',
|
||||
});
|
||||
setEditingUser(null);
|
||||
};
|
||||
@@ -76,6 +79,7 @@ export default function Users() {
|
||||
password: '', // Don't populate password
|
||||
roles: user.roles,
|
||||
mfa_enabled: user.mfa_enabled,
|
||||
additional_emails: user.additional_emails || '',
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
@@ -222,6 +226,17 @@ export default function Users() {
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">Additional Emails (comma separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.additional_emails || ''}
|
||||
onChange={e => setFormData({ ...formData, additional_emails: e.target.value })}
|
||||
placeholder="email2@example.com, email3@example.com"
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Used for linking multiple OAuth identities to this user.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
Reference in New Issue
Block a user