diff --git a/backend/internal/api/handlers/user_handler.go b/backend/internal/api/handlers/user_handler.go index 087c4d38..bb74ce1c 100644 --- a/backend/internal/api/handlers/user_handler.go +++ b/backend/internal/api/handlers/user_handler.go @@ -524,24 +524,27 @@ func (h *UserHandler) InviteUser(c *gin.Context) { }) // Send invite email asynchronously (non-blocking) - // Capture user data BEFORE launching goroutine to prevent race conditions - emailSent := true // Set true immediately since email will be sent in background - if h.MailService.IsConfigured() { - userEmail := user.Email // Capture email before goroutine + // Capture the generated invite URL from configured public URL only. + inviteURL := "" + baseURL, hasConfiguredPublicURL := utils.GetConfiguredPublicURL(h.DB) + if hasConfiguredPublicURL { + inviteURL = fmt.Sprintf("%s/accept-invite?token=%s", strings.TrimSuffix(baseURL, "/"), inviteToken) + } + + // Only mark as sent when SMTP is configured AND invite URL is usable. + emailSent := false + if h.MailService.IsConfigured() && hasConfiguredPublicURL { + emailSent = true + userEmail := user.Email userToken := inviteToken + appName := getAppName(h.DB) go func() { - baseURL, ok := utils.GetConfiguredPublicURL(h.DB) - if ok { - appName := getAppName(h.DB) - if err := h.MailService.SendInvite(userEmail, userToken, appName, baseURL); err != nil { - // Log failure but don't block response - middleware.GetRequestLogger(c).WithField("user_email", userEmail).WithError(err).Error("Failed to send invite email") - } + if err := h.MailService.SendInvite(userEmail, userToken, appName, baseURL); err != nil { + // Log failure but don't block response + middleware.GetRequestLogger(c).WithField("user_email", userEmail).WithError(err).Error("Failed to send invite email") } }() - } else { - emailSent = false } c.JSON(http.StatusCreated, gin.H{ @@ -550,6 +553,7 @@ func (h *UserHandler) InviteUser(c *gin.Context) { "email": user.Email, "role": user.Role, "invite_token": inviteToken, // Return token in case email fails + "invite_url": inviteURL, "email_sent": emailSent, "expires_at": inviteExpires, }) diff --git a/backend/internal/api/handlers/user_handler_test.go b/backend/internal/api/handlers/user_handler_test.go index 932290b3..181ff237 100644 --- a/backend/internal/api/handlers/user_handler_test.go +++ b/backend/internal/api/handlers/user_handler_test.go @@ -1331,6 +1331,7 @@ func TestUserHandler_InviteUser_Success(t *testing.T) { err := json.Unmarshal(w.Body.Bytes(), &resp) require.NoError(t, err, "Failed to unmarshal response") assert.NotEmpty(t, resp["invite_token"]) + assert.Equal(t, "", resp["invite_url"]) // email_sent is false because no SMTP is configured assert.Equal(t, false, resp["email_sent"].(bool)) @@ -1454,6 +1455,114 @@ func TestUserHandler_InviteUser_WithSMTPConfigured(t *testing.T) { err := json.Unmarshal(w.Body.Bytes(), &resp) require.NoError(t, err, "Failed to unmarshal response") assert.NotEmpty(t, resp["invite_token"]) + assert.Equal(t, "", resp["invite_url"]) + assert.Equal(t, false, resp["email_sent"].(bool)) +} + +func TestUserHandler_InviteUser_WithSMTPAndConfiguredPublicURL_IncludesInviteURL(t *testing.T) { + handler, db := setupUserHandlerWithProxyHosts(t) + + admin := &models.User{ + UUID: uuid.NewString(), + APIKey: uuid.NewString(), + Email: "admin-publicurl@example.com", + Role: "admin", + } + db.Create(admin) + + settings := []models.Setting{ + {Key: "smtp_host", Value: "smtp.example.com", Type: "string", Category: "smtp"}, + {Key: "smtp_port", Value: "587", Type: "integer", Category: "smtp"}, + {Key: "smtp_username", Value: "user@example.com", Type: "string", Category: "smtp"}, + {Key: "smtp_password", Value: "password", Type: "string", Category: "smtp"}, + {Key: "smtp_from_address", Value: "noreply@example.com", Type: "string", Category: "smtp"}, + {Key: "app.public_url", Value: "https://charon.example.com", Type: "string", Category: "app"}, + } + for _, setting := range settings { + db.Create(&setting) + } + + handler.MailService = services.NewMailService(db) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Set("userID", admin.ID) + c.Next() + }) + r.POST("/users/invite", handler.InviteUser) + + body := map[string]any{ + "email": "smtp-public-url@example.com", + } + jsonBody, _ := json.Marshal(body) + req := httptest.NewRequest("POST", "/users/invite", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) + + var resp map[string]any + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err, "Failed to unmarshal response") + token := resp["invite_token"].(string) + assert.Equal(t, "https://charon.example.com/accept-invite?token="+token, resp["invite_url"]) + assert.Equal(t, true, resp["email_sent"].(bool)) +} + +func TestUserHandler_InviteUser_WithSMTPAndMalformedPublicURL_DoesNotExposeInviteURL(t *testing.T) { + handler, db := setupUserHandlerWithProxyHosts(t) + + admin := &models.User{ + UUID: uuid.NewString(), + APIKey: uuid.NewString(), + Email: "admin-malformed-publicurl@example.com", + Role: "admin", + } + db.Create(admin) + + settings := []models.Setting{ + {Key: "smtp_host", Value: "smtp.example.com", Type: "string", Category: "smtp"}, + {Key: "smtp_port", Value: "587", Type: "integer", Category: "smtp"}, + {Key: "smtp_username", Value: "user@example.com", Type: "string", Category: "smtp"}, + {Key: "smtp_password", Value: "password", Type: "string", Category: "smtp"}, + {Key: "smtp_from_address", Value: "noreply@example.com", Type: "string", Category: "smtp"}, + {Key: "app.public_url", Value: "https://charon.example.com/path", Type: "string", Category: "app"}, + } + for _, setting := range settings { + db.Create(&setting) + } + + handler.MailService = services.NewMailService(db) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Set("userID", admin.ID) + c.Next() + }) + r.POST("/users/invite", handler.InviteUser) + + body := map[string]any{ + "email": "smtp-malformed-url@example.com", + } + jsonBody, _ := json.Marshal(body) + req := httptest.NewRequest("POST", "/users/invite", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) + + var resp map[string]any + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err, "Failed to unmarshal response") + assert.NotEmpty(t, resp["invite_token"]) + assert.Equal(t, "", resp["invite_url"]) + assert.Equal(t, false, resp["email_sent"].(bool)) } func TestUserHandler_InviteUser_WithSMTPConfigured_DefaultAppName(t *testing.T) { diff --git a/frontend/src/api/__tests__/users.test.ts b/frontend/src/api/__tests__/users.test.ts index f66d317a..ab4b3f81 100644 --- a/frontend/src/api/__tests__/users.test.ts +++ b/frontend/src/api/__tests__/users.test.ts @@ -50,7 +50,7 @@ describe('users api', () => { }) it('invites users and updates permissions', async () => { - vi.mocked(client.post).mockResolvedValueOnce({ data: { invite_token: 't' } }) + vi.mocked(client.post).mockResolvedValueOnce({ data: { invite_token: 't', invite_url: 'https://charon.example.com/accept-invite?token=t' } }) await inviteUser({ email: 'i', permission_mode: 'allow_all' }) expect(client.post).toHaveBeenCalledWith('/users/invite', { email: 'i', permission_mode: 'allow_all' }) diff --git a/frontend/src/api/users.test.ts b/frontend/src/api/users.test.ts index 06ed6ffc..6ff9baa8 100644 --- a/frontend/src/api/users.test.ts +++ b/frontend/src/api/users.test.ts @@ -50,7 +50,7 @@ describe('users api', () => { it('creates, invites, updates, and deletes users', async () => { mockedClient.post .mockResolvedValueOnce({ data: { id: 3, uuid: 'u3', email: 'c@example.com', name: 'C', role: 'user', enabled: true, permission_mode: 'allow_all', created_at: '', updated_at: '' } }) - .mockResolvedValueOnce({ data: { id: 4, uuid: 'u4', email: 'invite@example.com', role: 'user', invite_token: 'token', email_sent: true, expires_at: '' } }) + .mockResolvedValueOnce({ data: { id: 4, uuid: 'u4', email: 'invite@example.com', role: 'user', invite_token: 'token', invite_url: 'https://charon.example.com/accept-invite?token=token', email_sent: true, expires_at: '' } }) mockedClient.put.mockResolvedValueOnce({ data: { message: 'updated' } }) mockedClient.delete.mockResolvedValueOnce({ data: { message: 'deleted' } }) diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index d6949a2d..12d708e7 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -45,6 +45,7 @@ export interface InviteUserResponse { email: string role: string invite_token: string + invite_url: string email_sent: boolean expires_at: string } diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx index fcefebc1..cdcad5d3 100644 --- a/frontend/src/pages/UsersPage.tsx +++ b/frontend/src/pages/UsersPage.tsx @@ -54,6 +54,7 @@ function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) { const [selectedHosts, setSelectedHosts] = useState([]) const [inviteResult, setInviteResult] = useState<{ token: string + inviteUrl: string emailSent: boolean expiresAt: string } | null>(null) @@ -125,6 +126,7 @@ function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) { queryClient.invalidateQueries({ queryKey: ['users'] }) setInviteResult({ token: data.invite_token, + inviteUrl: data.invite_url, emailSent: data.email_sent, expiresAt: data.expires_at, }) @@ -140,10 +142,24 @@ function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) { }, }) - const copyInviteLink = () => { + const copyInviteLink = async () => { if (inviteResult?.token) { - const link = `${window.location.origin}/accept-invite?token=${inviteResult.token}` - navigator.clipboard.writeText(link) + const link = inviteResult.inviteUrl || `${window.location.origin}/accept-invite?token=${inviteResult.token}` + + try { + await navigator.clipboard.writeText(link) + } catch { + const textarea = document.createElement('textarea') + textarea.value = link + textarea.setAttribute('readonly', 'true') + textarea.style.position = 'absolute' + textarea.style.left = '-9999px' + document.body.appendChild(textarea) + textarea.select() + document.execCommand('copy') + document.body.removeChild(textarea) + } + toast.success(t('users.inviteLinkCopied')) } } @@ -219,7 +235,7 @@ function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) {
diff --git a/frontend/src/pages/__tests__/UsersPage.test.tsx b/frontend/src/pages/__tests__/UsersPage.test.tsx index a136d1b6..adc67560 100644 --- a/frontend/src/pages/__tests__/UsersPage.test.tsx +++ b/frontend/src/pages/__tests__/UsersPage.test.tsx @@ -217,6 +217,7 @@ describe('UsersPage', () => { email: 'new@example.com', role: 'user', invite_token: 'test-token-123', + invite_url: 'https://charon.example.com/accept-invite?token=test-token-123', email_sent: false, expires_at: '2024-01-03T00:00:00Z', }) @@ -326,6 +327,7 @@ describe('UsersPage', () => { email: 'manual@example.com', role: 'user', invite_token: 'token-123', + invite_url: 'https://charon.example.com/accept-invite?token=token-123', email_sent: false, expires_at: '2025-01-01T00:00:00Z', })