fix initial login setup and admin page load

This commit is contained in:
2026-05-22 06:21:04 +00:00
parent e8f9dea282
commit 4fa705d246
7 changed files with 215 additions and 5 deletions
+23
View File
@@ -63,6 +63,14 @@ func (d *DB) ResolveEmail(ctx context.Context, email string) (*models.User, erro
return d.GetUserByID(ctx, userID)
}
// HasAdminUser returns true if at least one enabled admin user exists.
func (d *DB) HasAdminUser(ctx context.Context) (bool, error) {
var n int
err := d.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM users WHERE admin=1 AND enabled=1").Scan(&n)
return n > 0, err
}
// CreateUser inserts a new user. Returns the new ID.
func (d *DB) CreateUser(ctx context.Context, domainID int64, username, email, passwordHash, displayName string, quotaBytes int64, domainAdmin bool) (int64, error) {
res, err := d.db.ExecContext(ctx, `
@@ -78,6 +86,21 @@ func (d *DB) CreateUser(ctx context.Context, domainID int64, username, email, pa
return res.LastInsertId()
}
// CreateAdminUser inserts the first superadmin (admin=1). Used only during initial setup.
func (d *DB) CreateAdminUser(ctx context.Context, domainID int64, email, passwordHash string) (int64, error) {
username := email
res, err := d.db.ExecContext(ctx, `
INSERT INTO users
(domain_id, username, email, password_hash, display_name, quota_bytes,
enabled, admin, domain_admin, created_at)
VALUES (?, ?, ?, ?, ?, 0, 1, 1, 1, ?)`,
domainID, username, email, passwordHash, email, time.Now().UTC())
if err != nil {
return 0, fmt.Errorf("create admin user: %w", err)
}
return res.LastInsertId()
}
// UpdateUsedBytes sets the cached used_bytes for a user (approximate, updated on store).
func (d *DB) UpdateUsedBytes(ctx context.Context, userID int64, delta int64) error {
_, err := d.db.ExecContext(ctx,
+10
View File
@@ -21,12 +21,22 @@ const adminPreAuthCookieName = "mailgo_admin_preauth"
const adminPreAuthMaxAge = 300 // 5 minutes
// loginGet renders the admin login form.
// Redirects to /admin/setup if no admin user has been created yet.
func (s *Server) loginGet(w http.ResponseWriter, r *http.Request) {
// Already logged in → dashboard.
if s.currentAdmin(r) != nil {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
// First-run: no admin user exists yet.
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
if has, err := s.deps.DB.HasAdminUser(ctx); err == nil && !has {
http.Redirect(w, r, "/admin/setup", http.StatusSeeOther)
return
}
flash, errMsg := flashFrom(r)
s.render(w, "login", struct {
basePage
+116
View File
@@ -710,3 +710,119 @@ func (s *Server) generateDKIM(ctx context.Context, domainID int64, algo, selecto
func createDefaultMailboxes(ctx context.Context, database *db.DB, userID int64) error {
return database.CreateDefaultMailboxes(ctx, userID)
}
// ---- First-run setup ----
type setupPage struct {
basePage
}
// setupGet renders the initial setup form (only accessible when no admin user exists).
func (s *Server) setupGet(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
has, err := s.deps.DB.HasAdminUser(ctx)
if err != nil {
http.Error(w, "database error", http.StatusInternalServerError)
return
}
if has {
// Setup already done — redirect to login.
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
flash, errMsg := flashFrom(r)
s.render(w, "setup", setupPage{basePage: newBaseNoUser(flash, errMsg)})
}
// setupPost creates the first admin user + its primary domain.
// Only callable when no admin user exists; subsequent calls redirect to login.
func (s *Server) setupPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
has, err := s.deps.DB.HasAdminUser(ctx)
if err != nil {
http.Error(w, "database error", http.StatusInternalServerError)
return
}
if has {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
password := r.FormValue("password")
confirm := r.FormValue("confirm_password")
domain := strings.ToLower(strings.TrimSpace(r.FormValue("domain")))
// Validate.
if email == "" || len(email) > 254 || !strings.Contains(email, "@") {
redirect(w, r, "/admin/setup", "", "Invalid email address.")
return
}
if domain == "" || len(domain) > 253 || strings.Contains(domain, " ") {
redirect(w, r, "/admin/setup", "", "Invalid domain name.")
return
}
if len(password) < 8 || len(password) > 1024 {
redirect(w, r, "/admin/setup", "", "Password must be at least 8 characters.")
return
}
if password != confirm {
redirect(w, r, "/admin/setup", "", "Passwords do not match.")
return
}
hash, err := appCrypto.HashPassword(password)
if err != nil {
log.Printf("[admin] setup hash: %v", err)
redirect(w, r, "/admin/setup", "", "Internal error.")
return
}
// Create domain first (user references it).
selector := s.deps.Cfg.DKIMSelector
if selector == "" {
selector = "mail"
}
algo := s.deps.Cfg.DKIMAlgo
if algo == "" {
algo = "rsa2048"
}
domainID, err := s.deps.DB.CreateDomain(ctx, domain, selector, algo)
if err != nil {
log.Printf("[admin] setup create domain: %v", err)
redirect(w, r, "/admin/setup", "", "Failed to create domain.")
return
}
// Create admin user.
userID, err := s.deps.DB.CreateAdminUser(ctx, domainID, email, hash)
if err != nil {
log.Printf("[admin] setup create admin: %v", err)
redirect(w, r, "/admin/setup", "", "Failed to create admin user.")
return
}
// Default mailboxes + calendar + address book.
if err := createDefaultMailboxes(ctx, s.deps.DB, userID); err != nil {
log.Printf("[admin] setup mailboxes: %v", err)
}
if _, err := s.deps.DB.EnsureDefaultCalendar(ctx, userID); err != nil {
log.Printf("[admin] setup calendar: %v", err)
}
if _, err := s.deps.DB.EnsureDefaultAddressBook(ctx, userID); err != nil {
log.Printf("[admin] setup addressbook: %v", err)
}
log.Printf("[admin] setup complete: admin=%s domain=%s", email, domain)
redirect(w, r, "/admin/login", "Setup complete. Sign in with your new credentials.", "")
}
+11
View File
@@ -54,7 +54,18 @@ func (s *Server) Handler() http.Handler {
func (s *Server) setupRoutes() {
m := s.mux
// Root redirect — catches http://host:8081/ and http://host:8081 (no /admin/ prefix).
m.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" || r.URL.Path == "" {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
http.NotFound(w, r)
})
// Public
m.HandleFunc("GET /admin/setup", s.setupGet)
m.HandleFunc("POST /admin/setup", s.setupPost)
m.HandleFunc("GET /admin/login", s.loginGet)
m.HandleFunc("POST /admin/login", s.loginPost)
m.HandleFunc("GET /admin/login/mfa", s.mfaGet)