19 KiB
Implementation Plan: Application URL Setting for User Invitations
Issue: When inviting users, verification email links use the internal address (e.g., localhost:8080) instead of the public-facing URL. External users cannot access these links.
Target Files:
- Backend: settings_handler.go, user_handler.go, mail_service.go
- Frontend: SystemSettings.tsx, UsersPage.tsx
- Translations: translation.json
1. Recommended Setting Name: "Application URL"
Research & Justification
| Application | Setting Name | Notes |
|---|---|---|
| Nginx Proxy Manager | N/A | No email system |
| GitLab | external_url |
Most common in self-hosted apps |
| Grafana | root_url |
Part of [server] config |
| Nextcloud | overwrite.cli.url |
Complex naming |
| WordPress | Site Address (URL) |
User-facing name |
| Portainer | Public URL |
Clean, simple |
| Home Assistant | external_url |
Paired with internal_url |
| Authentik | AUTHENTIK_HOST |
Environment variable |
Recommendation: "Application URL"
Why this name:
- User-Friendly: "Application URL" is clearer than "Base URL" or "External URL" for non-technical users
- Self-Explanatory: Immediately conveys "the URL used to access this application"
- Consistent with Charon's naming: Follows the pattern of human-readable settings (e.g., "Caddy Admin API Endpoint", "SSL Provider")
- Internal Key:
app.public_url- clear, namespaced, and follows existingcaddy.admin_apipattern
Alternative consideration: "Public URL" is slightly more descriptive but may confuse users who don't understand internal vs external networking.
2. Database Schema Changes
No schema changes required. The existing settings table structure supports this:
// backend/internal/models/setting.go (existing)
type Setting struct {
ID uint `json:"id" gorm:"primaryKey"`
Key string `json:"key" gorm:"uniqueIndex"` // "app.public_url"
Value string `json:"value" gorm:"type:text"` // "https://charon.example.com"
Type string `json:"type" gorm:"index"` // "string"
Category string `json:"category" gorm:"index"` // "general"
UpdatedAt time.Time `json:"updated_at"`
}
The new setting will be stored as:
- Key:
app.public_url - Value: User-provided URL (e.g.,
https://charon.example.com) - Type:
string - Category:
general
3. Backend Changes
3.1 Settings Handler Updates
File: backend/internal/api/handlers/settings_handler.go
Add a new helper function to retrieve the public URL:
// GetPublicURL retrieves the configured public URL or falls back to request host.
// This should be used for all user-facing URLs (emails, invite links).
func GetPublicURL(db *gorm.DB, c *gin.Context) string {
var setting models.Setting
if err := db.Where("key = ?", "app.public_url").First(&setting).Error; err == nil {
if setting.Value != "" {
return strings.TrimSuffix(setting.Value, "/")
}
}
// Fallback to request-derived URL
return getBaseURL(c)
}
Add a new endpoint to validate URL format:
// ValidatePublicURL validates a URL is properly formatted and accessible.
func (h *SettingsHandler) ValidatePublicURL(c *gin.Context) {
var req struct {
URL string `json:"url" binding:"required,url"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate URL structure
parsed, err := url.Parse(req.URL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
c.JSON(http.StatusBadRequest, gin.H{
"valid": false,
"error": "URL must start with http:// or https://",
})
return
}
c.JSON(http.StatusOK, gin.H{
"valid": true,
"normalized": strings.TrimSuffix(req.URL, "/"),
})
}
Route Registration in routes.go:
settings.POST("/validate-url", settingsHandler.ValidatePublicURL)
3.2 User Handler Updates
File: backend/internal/api/handlers/user_handler.go
Current Issue (lines 392-400):
// getBaseURL extracts the base URL from the request.
func getBaseURL(c *gin.Context) string {
scheme := "https"
if c.Request.TLS == nil {
if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" {
scheme = proto
} else {
scheme = "http"
}
}
return scheme + "://" + c.Request.Host
}
Solution: Modify InviteUser to use the configured public URL:
// InviteUser creates a new user with an invite token and sends an email (admin only).
func (h *UserHandler) InviteUser(c *gin.Context) {
// ... existing validation code ...
// Try to send invite email
emailSent := false
if h.MailService.IsConfigured() {
baseURL := GetPublicURL(h.DB, c) // Changed from getBaseURL(c)
appName := getAppName(h.DB)
if err := h.MailService.SendInvite(user.Email, inviteToken, appName, baseURL); err == nil {
emailSent = true
}
}
// ... rest of handler ...
}
Add a new endpoint for previewing the invite URL:
// PreviewInviteURLRequest represents the request for previewing an invite URL.
type PreviewInviteURLRequest struct {
Email string `json:"email" binding:"required,email"`
}
// PreviewInviteURL returns what the invite URL would look like with current settings.
func (h *UserHandler) PreviewInviteURL(c *gin.Context) {
role, _ := c.Get("role")
if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
var req PreviewInviteURLRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
baseURL := GetPublicURL(h.DB, c)
// Generate a sample token for preview (not stored)
sampleToken := "SAMPLE_TOKEN_PREVIEW"
inviteURL := fmt.Sprintf("%s/accept-invite?token=%s", strings.TrimSuffix(baseURL, "/"), sampleToken)
// Check if public URL is configured
var setting models.Setting
isConfigured := h.DB.Where("key = ?", "app.public_url").First(&setting).Error == nil && setting.Value != ""
c.JSON(http.StatusOK, gin.H{
"preview_url": inviteURL,
"base_url": baseURL,
"is_configured": isConfigured,
"email": req.Email,
"warning": !isConfigured,
"warning_message": "Application URL not configured. The invite link may not be accessible from external networks.",
})
}
Route Registration:
r.POST("/users/preview-invite-url", h.PreviewInviteURL)
3.3 Mail Service Updates
File: backend/internal/services/mail_service.go
No changes required - the SendInvite function already accepts baseURL as a parameter:
func (s *MailService) SendInvite(email, inviteToken, appName, baseURL string) error {
inviteURL := fmt.Sprintf("%s/accept-invite?token=%s", strings.TrimSuffix(baseURL, "/"), inviteToken)
// ...
}
4. Frontend Changes
4.1 System Settings UI
File: frontend/src/pages/SystemSettings.tsx
Add a new section for Application URL after the General Configuration card:
// Add state
const [publicURL, setPublicURL] = useState('')
const [publicURLValid, setPublicURLValid] = useState<boolean | null>(null)
// Update useEffect to load setting
useEffect(() => {
if (settings) {
// ... existing settings ...
if (settings['app.public_url']) setPublicURL(settings['app.public_url'])
}
}, [settings])
// Add validation function
const validatePublicURL = async (url: string) => {
if (!url) {
setPublicURLValid(null)
return
}
try {
const response = await client.post('/settings/validate-url', { url })
setPublicURLValid(response.data.valid)
} catch {
setPublicURLValid(false)
}
}
// Add to saveSettingsMutation
const saveSettingsMutation = useMutation({
mutationFn: async () => {
// ... existing saves ...
await updateSetting('app.public_url', publicURL, 'general', 'string')
},
// ...
})
New UI Section (add after General Configuration card):
{/* Application URL */}
<Card>
<CardHeader>
<CardTitle>{t('systemSettings.applicationUrl.title')}</CardTitle>
<CardDescription>{t('systemSettings.applicationUrl.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert variant="info">
<Info className="h-4 w-4" />
<AlertDescription>
{t('systemSettings.applicationUrl.infoMessage')}
</AlertDescription>
</Alert>
<div className="space-y-2">
<Label htmlFor="public-url">{t('systemSettings.applicationUrl.label')}</Label>
<div className="flex gap-2">
<Input
id="public-url"
type="url"
value={publicURL}
onChange={(e) => {
setPublicURL(e.target.value)
validatePublicURL(e.target.value)
}}
placeholder="https://charon.example.com"
className={cn(
publicURLValid === false && 'border-red-500',
publicURLValid === true && 'border-green-500'
)}
/>
{publicURLValid !== null && (
publicURLValid ? (
<CheckCircle2 className="h-5 w-5 text-green-500 self-center" />
) : (
<XCircle className="h-5 w-5 text-red-500 self-center" />
)
)}
</div>
<p className="text-sm text-content-muted">
{t('systemSettings.applicationUrl.helper')}
</p>
{publicURLValid === false && (
<p className="text-sm text-red-500">
{t('systemSettings.applicationUrl.invalidUrl')}
</p>
)}
</div>
{!publicURL && (
<Alert variant="warning">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
{t('systemSettings.applicationUrl.notConfiguredWarning')}
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
4.2 User Invitation Flow Updates
File: frontend/src/pages/UsersPage.tsx
Add URL preview to the InviteModal component:
// Add to InviteModal component
const [urlPreview, setUrlPreview] = useState<{
preview_url: string
base_url: string
is_configured: boolean
warning: boolean
warning_message: string
} | null>(null)
// Add preview fetch when email changes
useEffect(() => {
if (email && email.includes('@')) {
const fetchPreview = async () => {
try {
const response = await client.post('/users/preview-invite-url', { email })
setUrlPreview(response.data)
} catch {
setUrlPreview(null)
}
}
const debounce = setTimeout(fetchPreview, 500)
return () => clearTimeout(debounce)
} else {
setUrlPreview(null)
}
}, [email])
Add preview UI to modal (before the submit buttons):
{/* URL Preview */}
{urlPreview && (
<div className="space-y-2 p-4 bg-surface-subtle rounded-lg border border-border">
<div className="flex items-center gap-2">
<ExternalLink className="h-4 w-4 text-content-muted" />
<Label className="text-sm font-medium">
{t('users.inviteUrlPreview')}
</Label>
</div>
<div className="text-sm font-mono text-content-secondary break-all bg-surface-elevated p-2 rounded">
{urlPreview.preview_url.replace('SAMPLE_TOKEN_PREVIEW', '...')}
</div>
{urlPreview.warning && (
<Alert variant="warning" className="mt-2">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="text-xs">
{t('users.inviteUrlWarning')}
<Link to="/settings/system" className="ml-1 underline">
{t('users.configureApplicationUrl')}
</Link>
</AlertDescription>
</Alert>
)}
</div>
)}
4.3 API Client Updates
File: frontend/src/api/settings.ts
Add new function:
/**
* Validates a URL for use as the application URL.
* @param url - The URL to validate
* @returns Promise resolving to validation result
*/
export const validatePublicURL = async (url: string): Promise<{
valid: boolean
normalized?: string
error?: string
}> => {
const response = await client.post('/settings/validate-url', { url })
return response.data
}
File: frontend/src/api/users.ts
Add new function:
/** Response from invite URL preview. */
export interface PreviewInviteURLResponse {
preview_url: string
base_url: string
is_configured: boolean
email: string
warning: boolean
warning_message: string
}
/**
* Previews what the invite URL will look like for a given email.
* @param email - The email to preview
* @returns Promise resolving to PreviewInviteURLResponse
*/
export const previewInviteURL = async (email: string): Promise<PreviewInviteURLResponse> => {
const response = await client.post<PreviewInviteURLResponse>('/users/preview-invite-url', { email })
return response.data
}
5. Translation Updates
File: frontend/src/locales/en/translation.json
Add under systemSettings:
"applicationUrl": {
"title": "Application URL",
"description": "Configure the public URL used for user-facing links and emails.",
"label": "Application URL",
"helper": "The public URL where users access Charon (e.g., https://charon.example.com). Used in invitation emails and password reset links.",
"infoMessage": "This URL is used when sending invitation emails. If not configured, Charon will use the URL from the current browser request, which may not work for external users.",
"invalidUrl": "Please enter a valid URL starting with http:// or https://",
"notConfiguredWarning": "Application URL is not configured. Invitation emails will use the current browser URL, which may not be accessible from external networks."
}
Add under users:
"inviteUrlPreview": "Invite Link Preview",
"inviteUrlWarning": "Application URL is not configured. This link may not work for external users.",
"configureApplicationUrl": "Configure Application URL"
Repeat for other locale files: de/translation.json, es/translation.json, fr/translation.json, zh/translation.json
6. Implementation Phases
Phase 1: Backend Foundation
Files to modify:
-
backend/internal/api/handlers/settings_handler.go
- Add
GetPublicURL()helper function - Add
ValidatePublicURL()endpoint
- Add
-
backend/internal/api/handlers/user_handler.go
- Modify
InviteUser()to useGetPublicURL() - Add
PreviewInviteURL()endpoint
- Modify
-
backend/internal/api/routes/routes.go
- Register new routes
Deliverable: Backend can store, validate, and use the public URL setting.
Phase 2: Frontend Settings UI
Files to modify:
-
frontend/src/pages/SystemSettings.tsx
- Add Application URL input field
- Add validation feedback
- Add warning when not configured
-
- Add
validatePublicURL()function
- Add
-
frontend/src/locales/en/translation.json
- Add
systemSettings.applicationUrl.*translations
- Add
Deliverable: Admin can configure the Application URL in settings.
Phase 3: User Invitation Preview
Files to modify:
-
frontend/src/pages/UsersPage.tsx
- Add URL preview to
InviteModal - Add warning banner if URL not configured
- Add URL preview to
-
- Add
previewInviteURL()function
- Add
-
frontend/src/locales/en/translation.json
- Add
users.inviteUrlPreview,users.inviteUrlWarning,users.configureApplicationUrl
- Add
Deliverable: Admin sees URL preview and warnings when inviting users.
Phase 4: i18n & Polish
Files to modify:
-
All locale files:
frontend/src/locales/de/translation.jsonfrontend/src/locales/es/translation.jsonfrontend/src/locales/fr/translation.jsonfrontend/src/locales/zh/translation.json
-
Add unit tests for new endpoints
Deliverable: Complete feature with all translations.
7. Security Considerations
7.1 URL Validation
- SSRF Prevention: The URL is only used for generating email links, not for server-side requests
- Input Validation: Backend validates URL format (must start with
http://orhttps://) - XSS Prevention: URL is HTML-escaped in email templates (already handled by Go's
html/template)
7.2 Authorization
- Settings Access: Only admins can modify
app.public_url(existing middleware) - Preview Access: Only admins can preview invite URLs
7.3 Data Exposure
- The preview endpoint only returns sanitized data, no sensitive tokens
- Sample tokens in preview are clearly marked and not stored
8. Testing Checklist
- Setting saves and persists across restarts
- URL validation rejects invalid formats
- Invite emails use the configured URL
- Invite emails fall back to request URL if not configured
- Preview shows warning when URL not configured
- Preview updates when email changes
- Accept invite page works with the configured URL
- All translations display correctly
- Setting is admin-only
9. Files Changed Summary
| File | Changes |
|---|---|
backend/internal/api/handlers/settings_handler.go |
Add GetPublicURL(), ValidatePublicURL() |
backend/internal/api/handlers/user_handler.go |
Modify InviteUser(), add PreviewInviteURL() |
backend/internal/api/routes/routes.go |
Register 2 new routes |
frontend/src/pages/SystemSettings.tsx |
Add Application URL card |
frontend/src/pages/UsersPage.tsx |
Add URL preview to InviteModal |
frontend/src/api/settings.ts |
Add validatePublicURL() |
frontend/src/api/users.ts |
Add previewInviteURL() |
frontend/src/locales/*/translation.json |
Add new translation keys |
No changes required to:
backend/internal/models/setting.go- Uses existing schemabackend/internal/services/mail_service.go- Already accepts URL parameter.gitignore,codecov.yml,.dockerignore,Dockerfile- No changes needed