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:
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user