feat: customer config management — CRUD, API retrieval, per-customer auth (v0.2.0)

New "Configurations" section lets operators pre-configure customer settings
in the Hub, then docker-setup.sh can download a ready-made controller.yaml
using just a customer ID and retrieval password.

- Store: customer_configs table with CRUD + per-customer API key lookup
- API: GET /api/v1/config/{id} with X-Retrieval-Password auth
- Auth: per-customer API keys alongside existing global key (backward compatible)
- Web UI: /configs list, create, edit, delete, YAML preview, copy-to-clipboard
- YAML gen: deep-merge controller.yaml.example template with customer overrides
- Template fetcher: background goroutine refreshing template from Gitea repo
- Navigation: Dashboard / Configurations tabs on all pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 13:36:32 +01:00
parent 36a7d1c162
commit 4c8bf63ce3
18 changed files with 1631 additions and 67 deletions
+135
View File
@@ -98,6 +98,18 @@ func (s *Store) migrate() error {
backup_json TEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS customer_configs (
customer_id TEXT PRIMARY KEY,
customer_name TEXT NOT NULL DEFAULT '',
domain TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
retrieval_password TEXT NOT NULL,
api_key TEXT NOT NULL,
config_json TEXT NOT NULL DEFAULT '{}',
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
`)
if err != nil {
return err
@@ -499,6 +511,129 @@ func (s *Store) Close() error {
return s.db.Close()
}
// CustomerConfig holds a pre-provisioned customer configuration.
type CustomerConfig struct {
CustomerID string
CustomerName string
Domain string
Email string
RetrievalPassword string
APIKey string
ConfigJSON string // JSON object with customer-specific override fields
CreatedAt time.Time
UpdatedAt time.Time
}
// SaveCustomerConfig creates or updates a customer configuration.
func (s *Store) SaveCustomerConfig(cfg *CustomerConfig) error {
_, err := s.db.Exec(`
INSERT INTO customer_configs (customer_id, customer_name, domain, email,
retrieval_password, api_key, config_json, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(customer_id) DO UPDATE SET
customer_name = excluded.customer_name,
domain = excluded.domain,
email = excluded.email,
retrieval_password = excluded.retrieval_password,
api_key = excluded.api_key,
config_json = excluded.config_json,
updated_at = datetime('now')`,
cfg.CustomerID, cfg.CustomerName, cfg.Domain, cfg.Email,
cfg.RetrievalPassword, cfg.APIKey, cfg.ConfigJSON,
)
return err
}
// GetCustomerConfig returns a customer configuration by ID, or nil if not found.
func (s *Store) GetCustomerConfig(customerID string) (*CustomerConfig, error) {
var cfg CustomerConfig
var createdAt, updatedAt string
err := s.db.QueryRow(`
SELECT customer_id, customer_name, domain, email,
retrieval_password, api_key, config_json, created_at, updated_at
FROM customer_configs WHERE customer_id = ?`,
customerID,
).Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email,
&cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON,
&createdAt, &updatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
cfg.CreatedAt = parseSQLiteTime(createdAt)
cfg.UpdatedAt = parseSQLiteTime(updatedAt)
return &cfg, nil
}
// ListCustomerConfigs returns all customer configurations ordered by ID.
func (s *Store) ListCustomerConfigs() ([]CustomerConfig, error) {
rows, err := s.db.Query(`
SELECT customer_id, customer_name, domain, email,
retrieval_password, api_key, config_json, created_at, updated_at
FROM customer_configs ORDER BY customer_id`)
if err != nil {
return nil, err
}
defer rows.Close()
var configs []CustomerConfig
for rows.Next() {
var cfg CustomerConfig
var createdAt, updatedAt string
if err := rows.Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email,
&cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON,
&createdAt, &updatedAt); err != nil {
return nil, err
}
cfg.CreatedAt = parseSQLiteTime(createdAt)
cfg.UpdatedAt = parseSQLiteTime(updatedAt)
configs = append(configs, cfg)
}
return configs, rows.Err()
}
// DeleteCustomerConfig deletes a customer configuration.
func (s *Store) DeleteCustomerConfig(customerID string) error {
_, err := s.db.Exec("DELETE FROM customer_configs WHERE customer_id = ?", customerID)
return err
}
// GetCustomerConfigByAPIKey looks up a customer config by its unique API key.
// Returns nil if no matching key is found.
func (s *Store) GetCustomerConfigByAPIKey(apiKey string) (*CustomerConfig, error) {
var cfg CustomerConfig
var createdAt, updatedAt string
err := s.db.QueryRow(`
SELECT customer_id, customer_name, domain, email,
retrieval_password, api_key, config_json, created_at, updated_at
FROM customer_configs WHERE api_key = ?`,
apiKey,
).Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email,
&cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON,
&createdAt, &updatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
cfg.CreatedAt = parseSQLiteTime(createdAt)
cfg.UpdatedAt = parseSQLiteTime(updatedAt)
return &cfg, nil
}
// UpdateRetrievalPassword updates the retrieval password for a customer config.
func (s *Store) UpdateRetrievalPassword(customerID, newPassword string) error {
_, err := s.db.Exec(`
UPDATE customer_configs SET retrieval_password = ?, updated_at = datetime('now')
WHERE customer_id = ?`,
newPassword, customerID,
)
return err
}
// parseSQLiteTime tries multiple formats that modernc.org/sqlite may return.
func parseSQLiteTime(s string) time.Time {
formats := []string{