15 KiB
Security Headers "Apply Preset" Workflow Analysis
Date: December 18, 2025 Issue: User confusion after applying security header preset - no feedback, unclear activation status
Executive Summary
The user applied a security header preset (e.g., "Basic Security") and experienced confusion because:
- No toast appeared (actually it does, but message is ambiguous)
- No loading indicator (button state doesn't show progress)
- Profile appeared in "Custom Profiles" (unclear naming)
- Uncertainty about activation (doesn't know if headers are live)
- Suggested renaming section if headers are already active
KEY FINDING: Headers are NOT ACTIVE after applying preset. The preset creates a new custom profile that must be manually assigned to each proxy host to take effect.
Root Cause: UX does not communicate the multi-step workflow clearly.
🔍 Complete Workflow Trace
Step 1: User Action
Location: SecurityHeaders.tsx Trigger: User clicks "Apply" button on a preset card (Basic/Strict/Paranoid)
<Button onClick={() => handleApplyPreset(profile.preset_type)}>
<Play className="h-4 w-4 mr-1" /> Apply
</Button>
Step 2: Frontend Handler
Function: handleApplyPreset(presetType: string)
const handleApplyPreset = (presetType: string) => {
const name = `${presetType.charAt(0).toUpperCase() + presetType.slice(1)} Security Profile`;
applyPresetMutation.mutate({ preset_type: presetType, name });
};
What happens:
- Constructs name: "Basic Security Profile", "Strict Security Profile", etc.
- Calls mutation from React Query hook
Step 3: React Query Hook
Location: useSecurityHeaders.ts
export function useApplySecurityHeaderPreset() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: ApplyPresetRequest) => securityHeadersApi.applyPreset(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['securityHeaderProfiles'] });
toast.success('Preset applied successfully');
},
onError: (error: Error) => {
toast.error(`Failed to apply preset: ${error.message}`);
},
});
}
What happens:
- ✅ DOES show toast:
'Preset applied successfully' - ✅ DOES invalidate queries (triggers refetch of profile list)
- ❌ DOES NOT show loading indicator during mutation
Step 4: Backend Handler
Location: security_headers_handler.go
func (h *SecurityHeadersHandler) ApplyPreset(c *gin.Context) {
var req struct {
PresetType string `json:"preset_type" binding:"required"`
Name string `json:"name" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
profile, err := h.service.ApplyPreset(req.PresetType, req.Name)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"profile": profile})
}
What happens:
- Receives preset type and name
- Delegates to service layer
- Returns created profile
- ❌ DOES NOT trigger
ApplyConfig()(no Caddy reload)
Step 5: Service Layer
Location: security_headers_service.go
func (s *SecurityHeadersService) ApplyPreset(presetType, name string) (*models.SecurityHeaderProfile, error) {
presets := s.GetPresets()
var selectedPreset *models.SecurityHeaderProfile
for i := range presets {
if presets[i].PresetType == presetType {
selectedPreset = &presets[i]
break
}
}
if selectedPreset == nil {
return nil, fmt.Errorf("preset type %s not found", presetType)
}
// Create a copy with custom name and UUID
newProfile := *selectedPreset
newProfile.ID = 0 // Clear ID so GORM creates a new record
newProfile.UUID = uuid.New().String()
newProfile.Name = name
newProfile.IsPreset = false // User-created profiles are not presets
newProfile.PresetType = "" // Clear preset type for custom profiles
if err := s.db.Create(&newProfile).Error; err != nil {
return nil, fmt.Errorf("failed to create profile from preset: %w", err)
}
return &newProfile, nil
}
What happens:
- Finds the requested preset (basic/strict/paranoid)
- Creates a COPY of the preset as a new custom profile
- Saves to database
- Returns the new profile
- ❌ Profile is NOT assigned to any hosts
- ❌ Headers are NOT active yet
Step 6: Profile Appears in UI
Location: "Custom Profiles" section
What user sees:
- New card appears in "Custom Profiles" grid
- Shows profile name, security score, timestamp
- User can Edit/Clone/Delete
- ⚠️ No indication that profile needs to be assigned to hosts
🔑 Critical Understanding: Per-Host Assignment
How Security Headers Work
Security headers in Charon are PER-HOST, not global:
// ProxyHost model
type ProxyHost struct {
// ...
SecurityHeaderProfileID *uint `json:"security_header_profile_id"`
SecurityHeaderProfile *SecurityHeaderProfile `json:"security_header_profile" gorm:"foreignKey:SecurityHeaderProfileID"`
SecurityHeadersEnabled bool `json:"security_headers_enabled" gorm:"default:true"`
SecurityHeadersCustom string `json:"security_headers_custom" gorm:"type:text"`
// ...
}
Key facts:
- Each proxy host can reference ONE profile via
SecurityHeaderProfileID - If no profile is assigned, host uses inline settings or defaults
- Creating a profile DOES NOT automatically assign it to any hosts
- Headers are applied when Caddy config is generated from ProxyHost data
When Headers Become Active
Location: config.go
func buildSecurityHeadersHandler(host *models.ProxyHost) (Handler, error) {
if host == nil {
return nil, nil
}
// Use profile if configured
var cfg *models.SecurityHeaderProfile
if host.SecurityHeaderProfile != nil {
cfg = host.SecurityHeaderProfile // ✅ Profile assigned to host
} else if !host.SecurityHeadersEnabled {
// No profile and headers disabled - skip
return nil, nil
} else {
// Use default secure headers
cfg = getDefaultSecurityHeaderProfile() // ⚠️ Fallback defaults
}
// ... builds headers from cfg ...
}
Activation requires:
- User creates/edits a proxy host
- User selects the security header profile in the host form
- User saves the host
ProxyHostHandler.UpdateProxyHost()callscaddyManager.ApplyConfig()- Caddy reloads with new headers applied
❌ Current Behavior vs ✅ Expected Behavior
| Aspect | Current | Expected | Severity |
|---|---|---|---|
| Toast notification | ✅ Shows "Preset applied successfully" | ✅ Same (but could be clearer) | Low |
| Loading indicator | ❌ None during mutation | ✅ Should show loading state on button | Medium |
| Profile location | ✅ Appears in "Custom Profiles" | ⚠️ Should clarify activation needed | High |
| User confusion | ❌ "Is it active?" "What's next?" | ✅ Clear next steps | Critical |
| Caddy reload | ❌ Not triggered | ✅ Correct - only reload when assigned to host | Low |
| Section naming | "Custom Profiles" | ⚠️ Misleading - implies active | Medium |
🐛 Root Cause of User Confusion
Problem 1: Ambiguous Toast Message
Current: "Preset applied successfully"
User thinks: "Applied to what? Is it protecting my sites now?"
Problem 2: No Loading Indicator
Button shows no feedback during the async operation. User doesn't know:
- When request starts
- When request completes
- If anything happened at all
Problem 3: "Custom Profiles" Name is Misleading
This section name implies:
- These are "your active profiles"
- Headers are protecting something
Reality: These are AVAILABLE profiles, not ACTIVE profiles
Problem 4: No Next Steps Guidance
After applying preset, UI doesn't tell user:
- ✅ Profile created
- ⚠️ Next: Assign this profile to proxy hosts
- 📍 Where: Edit any Proxy Host → Security Headers dropdown
🎯 Recommended Fixes
Fix 1: Improve Toast Messages ⭐ HIGH PRIORITY
Change in: useSecurityHeaders.ts
// CURRENT
toast.success('Preset applied successfully');
// RECOMMENDED
toast.success('Profile created! Assign it to proxy hosts to activate headers.');
Better yet, use a rich toast with action:
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['securityHeaderProfiles'] });
toast.success(
<div>
<strong>Profile created!</strong>
<p className="text-sm">Assign it to proxy hosts to activate security headers.</p>
</div>,
{ duration: 5000 }
);
},
Fix 2: Add Loading State to Apply Button ⭐ HIGH PRIORITY
Change in: SecurityHeaders.tsx
<Button
variant="outline"
size="sm"
onClick={() => handleApplyPreset(profile.preset_type)}
disabled={applyPresetMutation.isPending} // ✅ Already exists!
>
{applyPresetMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-1 animate-spin" /> Applying...
</>
) : (
<>
<Play className="h-4 w-4 mr-1" /> Apply
</>
)}
</Button>
Issue: Loading state needs to track WHICH preset is being applied (currently all buttons disable)
Fix 3: Rename "Custom Profiles" Section ⭐ MEDIUM PRIORITY
Options:
| Name | Pros | Cons | Verdict |
|---|---|---|---|
| "Available Profiles" | ✅ Accurate | ❌ Generic | ⭐⭐⭐ Good |
| "Your Profiles" | ✅ User-centric | ❌ Still ambiguous | ⭐⭐ Okay |
| "Saved Profiles" | ✅ Clear state | ❌ Wordy | ⭐⭐⭐ Good |
| "Custom Profiles (Not Assigned)" | ✅ Very clear | ❌ Too long | ⭐⭐ Okay |
Recommended: "Your Saved Profiles"
- Clear that these are stored but not necessarily active
- Differentiates from system presets
- User-friendly tone
Fix 4: Add Empty State Guidance ⭐ MEDIUM PRIORITY
After applying first preset, show a helpful alert:
{customProfiles.length === 1 && (
<Alert variant="info" className="mb-4">
<Info className="w-4 h-4" />
<div>
<p className="font-semibold">Next Step: Assign to Proxy Hosts</p>
<p className="text-sm mt-1">
Go to <Link to="/proxy-hosts">Proxy Hosts</Link>, edit a host,
and select this profile under "Security Headers" to activate protection.
</p>
</div>
</Alert>
)}
Fix 5: Track Apply State Per-Preset ⭐ HIGH PRIORITY
Problem: applyPresetMutation.isPending is global - disables all buttons
Solution: Track which preset is being applied
const [applyingPreset, setApplyingPreset] = useState<string | null>(null);
const handleApplyPreset = (presetType: string) => {
setApplyingPreset(presetType);
const name = `${presetType.charAt(0).toUpperCase() + presetType.slice(1)} Security Profile`;
applyPresetMutation.mutate(
{ preset_type: presetType, name },
{
onSettled: () => setApplyingPreset(null),
}
);
};
// In button:
disabled={applyingPreset !== null}
// Show loading only for the specific button:
{applyingPreset === profile.preset_type ? <Loader2 .../> : <Play .../>}
📋 Implementation Checklist
Phase 1: Immediate Fixes (High Priority)
- Fix toast message to clarify next steps
- Add per-preset loading state tracking
- Show loading spinner on Apply button for active preset
- Disable all Apply buttons while any is loading
Phase 2: UX Improvements (Medium Priority)
- Rename "Custom Profiles" to "Your Saved Profiles"
- Add info alert after first profile creation
- Link alert to Proxy Hosts page with guidance
Phase 3: Advanced (Low Priority)
- Add tooltip to Apply button explaining what happens
- Show usage count on profile cards ("Used by X hosts")
- Add "Assign to Hosts" quick action after creation
🧪 Testing Checklist
Before marking as complete:
1. Apply Preset Flow
- Click "Apply" on Basic preset
- Verify button shows loading spinner
- Verify other Apply buttons are disabled
- Verify toast appears with clear message
- Verify new profile appears in "Your Saved Profiles"
- Verify profile shows correct security score
2. Assignment Verification
- Navigate to Proxy Hosts
- Edit a host
- Verify new profile appears in Security Headers dropdown
- Select profile and save
- Verify Caddy reloads
- Verify headers appear in HTTP response (curl -I)
3. Edge Cases
- Apply same preset twice (should create second copy)
- Apply preset while offline (should show error toast)
- Apply preset with very long name
- Rapid-click Apply button (should debounce)
🔗 Related Files
Backend
- security_headers_handler.go - API endpoint
- security_headers_service.go - Business logic
- proxy_host.go - Host-profile relationship
- config.go - Header application logic
Frontend
- SecurityHeaders.tsx - Main UI
- useSecurityHeaders.ts - React Query hooks
- securityHeaders.ts - API client
📊 Summary
What Actually Happens
- User clicks "Apply" on preset
- Frontend creates a new custom profile by copying preset settings
- Profile is saved to database
- Profile appears in "Custom Profiles" list
- Headers are NOT ACTIVE until profile is assigned to a proxy host
- User must edit each proxy host and select the profile
- Only then does Caddy reload with new headers
Why User is Confused
- ✅ Toast says "applied" but headers aren't active
- ❌ No loading indicator during save
- ❌ Section name "Custom Profiles" doesn't indicate activation needed
- ❌ No guidance on next steps
- ❌ User expects preset to "just work" globally
Solution
Improve feedback and guidance to make the workflow explicit:
- Clear toast: "Profile created! Assign to hosts to activate."
- Loading state: Show spinner on Apply button
- Better naming: "Your Saved Profiles" instead of "Custom Profiles"
- Next steps: Show alert linking to Proxy Hosts page
Status: Analysis Complete ✅ Next Action: Implement Phase 1 fixes