UX improvements.

This commit is contained in:
2026-07-12 00:40:19 +00:00
parent 994c9fbfdf
commit 43d407bf2e
9 changed files with 552 additions and 267 deletions

View File

@@ -2,7 +2,9 @@ package v1
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
type cloudflareZone struct {
@@ -10,12 +12,19 @@ type cloudflareZone struct {
Name string `json:"name"`
}
type cloudflarePermissions struct {
Zone bool `json:"zone"` // can list zones
DNS bool `json:"dns"` // can read/edit DNS records
}
type cloudflareVerifyResponse struct {
TokenConfigured bool `json:"tokenConfigured"`
TokenValid bool `json:"tokenValid"`
TokenStatus string `json:"tokenStatus"`
Zones []cloudflareZone `json:"zones"`
Error string `json:"error,omitempty"`
TokenConfigured bool `json:"tokenConfigured"`
TokenValid bool `json:"tokenValid"`
TokenStatus string `json:"tokenStatus"`
Zones []cloudflareZone `json:"zones"`
RequiredZones []string `json:"requiredZones"`
Permissions cloudflarePermissions `json:"permissions"`
Error string `json:"error,omitempty"`
}
// CloudflareVerify checks whether the stored Cloudflare API token is valid
@@ -47,6 +56,15 @@ func (api *API) CloudflareVerify(w http.ResponseWriter, r *http.Request) {
return
}
resp.Zones = zones
resp.Permissions.Zone = true
// Test DNS access on the first available zone
if len(zones) > 0 {
resp.Permissions.DNS = testCloudflareDNSAccess(token, zones[0].ID)
}
// Derive required zones from registered domains
resp.RequiredZones = api.getRequiredZones()
respondJSON(w, http.StatusOK, resp)
}
@@ -78,6 +96,36 @@ func (api *API) CloudflareSetZoneID(w http.ResponseWriter, r *http.Request) {
respondMessage(w, http.StatusOK, "Zone ID saved")
}
// getRequiredZones extracts unique root zones from all registered domains.
// e.g., "app.cloud.payne.io" → "payne.io"
func (api *API) getRequiredZones() []string {
doms, err := api.domains.List()
if err != nil || len(doms) == 0 {
return nil
}
seen := make(map[string]bool)
var zones []string
for _, dom := range doms {
zone := rootZone(dom.DomainName)
if zone != "" && !seen[zone] {
seen[zone] = true
zones = append(zones, zone)
}
}
return zones
}
// rootZone extracts the registrable zone from a FQDN.
// "app.cloud.payne.io" → "payne.io", "foo.example.com" → "example.com"
func rootZone(domain string) string {
parts := strings.Split(domain, ".")
if len(parts) < 2 {
return ""
}
return strings.Join(parts[len(parts)-2:], ".")
}
func verifyCloudflareToken(apiToken string) (string, error) {
req, err := http.NewRequest(http.MethodGet,
"https://api.cloudflare.com/client/v4/user/tokens/verify", nil)
@@ -103,6 +151,24 @@ func verifyCloudflareToken(apiToken string) (string, error) {
return result.Result.Status, nil
}
// testCloudflareDNSAccess checks if the token can list DNS records for a zone.
func testCloudflareDNSAccess(apiToken, zoneID string) bool {
req, err := http.NewRequest(http.MethodGet,
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?per_page=1", zoneID), nil)
if err != nil {
return false
}
req.Header.Set("Authorization", "Bearer "+apiToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK
}
func listCloudflareZones(apiToken string) ([]cloudflareZone, error) {
req, err := http.NewRequest(http.MethodGet,
"https://api.cloudflare.com/client/v4/zones", nil)