fix: include invite URL in user invitation response and update related tests
This commit is contained in:
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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' } })
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface InviteUserResponse {
|
||||
email: string
|
||||
role: string
|
||||
invite_token: string
|
||||
invite_url: string
|
||||
email_sent: boolean
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) {
|
||||
const [selectedHosts, setSelectedHosts] = useState<number[]>([])
|
||||
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) {
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={`${window.location.origin}/accept-invite?token=${inviteResult.token}`}
|
||||
value={inviteResult.inviteUrl || `${window.location.origin}/accept-invite?token=${inviteResult.token}`}
|
||||
readOnly
|
||||
className="flex-1 text-sm"
|
||||
/>
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user