fix: include invite URL in user invitation response and update related tests

This commit is contained in:
GitHub Actions
2026-02-16 03:38:35 +00:00
parent da3117b37c
commit 5a46ef4219
7 changed files with 151 additions and 19 deletions

View File

@@ -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,
})

View File

@@ -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) {

View File

@@ -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' })

View File

@@ -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' } })

View File

@@ -45,6 +45,7 @@ export interface InviteUserResponse {
email: string
role: string
invite_token: string
invite_url: string
email_sent: boolean
expires_at: string
}

View File

@@ -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"
/>

View File

@@ -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',
})