147072dbb9
Phase 12 (UI/UX). The navigation bar now renders once from layout.html instead of being copied into each content template, so it is present on every authenticated page — including the domain page and its delete confirmation, which had no links at all — and the current page is highlighted via .Active rather than quietly dropping out of the list. New /account page changes the administrator's username and/or password: the current password is required and the attempt is throttled on the same limiter as the login form, so this route cannot be used to brute-force past that limit. A password change invalidates every other session while keeping the one performing it; a rename carries that session over. Backup and domain import move from a card in the middle of the domain list to their own /backup page, one card each; the handlers themselves are unchanged, only the page the import form renders its errors on. The domain page gains a "Sending server settings" card (server, port, encryption) so a client can be configured without reading the docs; 587 is listed only when SUBMISSION_ENABLE is true for this deployment, which is a deploy-time flag the panel cannot verify at runtime. Client-side (static/panel.js, no libraries): Copy buttons on the values that get carried elsewhere (DKIM record, new application credentials, server name), and the Addresses field is hidden while the address mode is wildcard, where the server ignores it. Verified in a container on the dev server: setup, login, every page's nav and active item, domain and application creation, all account-form paths including cross-session invalidation, import errors, full backup download. gofmt/vet/test/docker build green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
85 lines
2.5 KiB
Go
85 lines
2.5 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// ErrNoAdmin is returned by GetAdmin when primary setup has not happened yet.
|
|
var ErrNoAdmin = errors.New("no administrator account")
|
|
|
|
// Admin is the single panel administrator (spec 7.6.1).
|
|
type Admin struct {
|
|
Username string
|
|
PasswordHash string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// AdminExists reports whether the administrator account has been created. This
|
|
// doubles as the "primary setup complete" flag: once true, the /setup route is
|
|
// permanently gone (spec 7.6.1).
|
|
func (s *Store) AdminExists() (bool, error) {
|
|
var n int
|
|
if err := s.db.QueryRow("SELECT COUNT(*) FROM admin").Scan(&n); err != nil {
|
|
return false, fmt.Errorf("count admin: %w", err)
|
|
}
|
|
return n > 0, nil
|
|
}
|
|
|
|
// CreateAdmin inserts the administrator row. It fails if one already exists,
|
|
// which — combined with the id=1 constraint — makes admin creation one-shot
|
|
// even under a race between two setup submissions.
|
|
func (s *Store) CreateAdmin(username, passwordHash string) error {
|
|
_, err := s.db.Exec(
|
|
"INSERT INTO admin (id, username, password_hash, created_at) VALUES (1, ?, ?, ?)",
|
|
username, passwordHash, time.Now().UTC().Format(time.RFC3339),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("create admin: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateAdmin replaces the administrator's username and password hash. It
|
|
// touches only the admin row (id = 1): panel credentials are unrelated to the
|
|
// SASL logins applications authenticate with, which live in their own table.
|
|
// ErrNoAdmin is returned if setup has not happened yet, so a change can never
|
|
// silently create an account.
|
|
func (s *Store) UpdateAdmin(username, passwordHash string) error {
|
|
res, err := s.db.Exec(
|
|
"UPDATE admin SET username = ?, password_hash = ? WHERE id = 1",
|
|
username, passwordHash,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("update admin: %w", err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("update admin: %w", err)
|
|
}
|
|
if n == 0 {
|
|
return ErrNoAdmin
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetAdmin returns the administrator account, or ErrNoAdmin if setup is pending.
|
|
func (s *Store) GetAdmin() (Admin, error) {
|
|
var (
|
|
a Admin
|
|
createdAt string
|
|
)
|
|
err := s.db.QueryRow("SELECT username, password_hash, created_at FROM admin WHERE id = 1").
|
|
Scan(&a.Username, &a.PasswordHash, &createdAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Admin{}, ErrNoAdmin
|
|
}
|
|
if err != nil {
|
|
return Admin{}, fmt.Errorf("get admin: %w", err)
|
|
}
|
|
a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
|
return a, nil
|
|
}
|