fix initial login setup and admin page load
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.", "")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user