From 57196de98d2c7b1b569c132bc366033fc71374bc Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Sat, 17 Oct 2026 02:29:56 -0700 Subject: [PATCH 1/2] Allow email (OTP) users to be promoted to admin Drop the GitHub-only gate in Auth.PromoteAdmin and ErrNotPromotable, show the promote control for every non-admin on /admin/users, and update the README. An email login is as strong as its mailbox, which is also what a GitHub account falls back on for recovery. Closes #565 Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++--- admin/admin.go | 2 -- admin/admin_test.go | 8 ++++---- auth/auth.go | 14 ++++---------- auth/auth_test.go | 11 +++++------ themes/default/templates/admin_users.html | 2 +- 6 files changed, 17 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index a16cb99f..5148781a 100644 --- a/README.md +++ b/README.md @@ -119,9 +119,9 @@ admin_github_id=12345 # numeric id: https://api.github.com/users Other accounts can still log in as regular users but are never promoted. Leave both unset to keep the first-to-login behaviour. ### Managing Admins -The pin above only decides who becomes the *first* admin. After that, admins are managed from the **Users** page in the admin area (`/admin/users`), which lists everyone who has logged in. An existing admin can promote any GitHub user to admin or demote another admin (including the wizard's password account); the last remaining admin can't be demoted, so the site never ends up with none. Email-login users can't be made admin (see #565). +The pin above only decides who becomes the *first* admin. After that, admins are managed from the **Users** page in the admin area (`/admin/users`), which lists everyone who has logged in. An existing admin can promote any user (GitHub or email login) to admin or demote another admin (including the wizard's password account); the last remaining admin can't be demoted, so the site never ends up with none. An email-login admin is only as secure as that mailbox, so promote only addresses you trust. -To hand the site over to a different GitHub account: log in with the new account once so it appears in the list, promote it from your current admin account, then log in as the new account and demote the old one. +To hand the site over to a different account: log in with the new account once so it appears in the list, promote it from your current admin account, then log in as the new account and demote the old one. ### Email Login (one-time codes) Visitors without a GitHub account can log in with an emailed 6-digit code. Add SMTP details to `.env`: @@ -132,7 +132,7 @@ smtp_user=postmaster@example.com # omit for an unauthenticated relay smtp_password=... smtp_from=blog@example.com ``` -When `smtp_host` and `smtp_from` are both set the login page offers "sign in with email"; otherwise it shows GitHub only. Codes expire after 10 minutes, allow 5 wrong attempts, and can be re-requested once a minute. Email users are regular users — they cannot be made admin (see above). +When `smtp_host` and `smtp_from` are both set the login page offers "sign in with email"; otherwise it shows GitHub only. Codes expire after 10 minutes, allow 5 wrong attempts, and can be re-requested once a minute. Email users start as regular users; an admin can promote them (see above). SMTP settings are read once at startup, so restart goblog after changing any `smtp_*` value in `.env` for the change to take effect. Go's SMTP client only sends `smtp_user`/`smtp_password` over an encrypted connection (STARTTLS, or implicit TLS on port 465) unless the host is `localhost`, so if you need an unencrypted remote relay, use it without credentials. diff --git a/admin/admin.go b/admin/admin.go index f578c520..cbda6e4a 100644 --- a/admin/admin.go +++ b/admin/admin.go @@ -1068,8 +1068,6 @@ func (a *Admin) PromoteAdmin(c *gin.Context) { c.JSON(http.StatusOK, "") case errors.Is(err, gorm.ErrRecordNotFound): c.JSON(http.StatusNotFound, "No such user") - case errors.Is(err, auth.ErrNotPromotable): - c.JSON(http.StatusBadRequest, err.Error()) default: log.Println("ERROR PROMOTING ADMIN: ", err) c.JSON(http.StatusInternalServerError, "Error promoting user") diff --git a/admin/admin_test.go b/admin/admin_test.go index d0c4d05a..3ceb02bb 100644 --- a/admin/admin_test.go +++ b/admin/admin_test.go @@ -1090,8 +1090,8 @@ func TestAdminUsers_RendersUsers(t *testing.T) { if r := row(plain); !control("promoteAdmin", plain).MatchString(r) { t.Errorf("github non-admin must have a promote control: %s", r) } - if r := row(mailer); strings.Contains(r, "promoteAdmin(") || strings.Contains(r, "demoteAdmin(") { - t.Errorf("email user must have no controls: %s", r) + if r := row(mailer); !control("promoteAdmin", mailer).MatchString(r) { + t.Errorf("email non-admin must have a promote control: %s", r) } if !strings.Contains(body, "3 users") { t.Errorf("expected a total in the summary") @@ -1161,8 +1161,8 @@ func TestPromoteAdminAPI(t *testing.T) { if n != 1 { t.Fatalf("expected plain to be admin after promotion") } - if w := adminsJSON(router, "POST", mailer.ID); w.Code != http.StatusBadRequest { - t.Errorf("promote email user: expected 400, got %d", w.Code) + if w := adminsJSON(router, "POST", mailer.ID); w.Code != http.StatusOK { + t.Errorf("promote email user: expected 200, got %d", w.Code) } if w := adminsJSON(router, "POST", 999); w.Code != http.StatusNotFound { t.Errorf("promote unknown user: expected 404, got %d", w.Code) diff --git a/auth/auth.go b/auth/auth.go index 22e66a14..5385fe62 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -342,10 +342,6 @@ func (a *Auth) EnsureAdmin(user *BlogUser) error { }) } -// ErrNotPromotable is returned by PromoteAdmin for users who can't hold -// admin: only GitHub users can (email login is a weaker credential, see #565). -var ErrNotPromotable = errors.New("only GitHub users can be admin") - // ErrLastAdmin is returned by DemoteAdmin when the demotion would leave the // site with no admin at all, which would also re-open the install wizard // (IsWizardMode is "no admin_users row"). @@ -388,18 +384,16 @@ func (a *Auth) ListUsers(offset, limit int) ([]UserListing, int64, error) { // PromoteAdmin makes the given user an admin. Unlike EnsureAdmin this is not // gated on the admin_login / admin_github_id pin: that pin only governs who -// bootstraps the first admin, after which existing admins decide. Returns -// gorm.ErrRecordNotFound for an unknown user and ErrNotPromotable for a -// non-GitHub user. Idempotent. +// bootstraps the first admin, after which existing admins decide. Any provider +// can be promoted: an email login is as strong as its mailbox, which is also +// what a GitHub account falls back on for recovery (#565). Returns +// gorm.ErrRecordNotFound for an unknown user. Idempotent. func (a *Auth) PromoteAdmin(userID int) error { return (*a.db).Transaction(func(tx *gorm.DB) error { var user BlogUser if err := tx.First(&user, userID).Error; err != nil { return err } - if user.Provider != ProviderGitHub { - return ErrNotPromotable - } var existing AdminUser err := tx.Where("blog_user_id = ?", userID).First(&existing).Error if err == nil { diff --git a/auth/auth_test.go b/auth/auth_test.go index 1892f561..a86780d7 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -395,17 +395,16 @@ func TestPromoteAdmin_Idempotent(t *testing.T) { } } -func TestPromoteAdmin_EmailUser_Refused(t *testing.T) { +func TestPromoteAdmin_EmailUser_AddsRow(t *testing.T) { a, db := newAuth(t) u := auth.BlogUser{Provider: auth.ProviderEmail, ProviderID: "a@example.com", Login: "a@example.com"} db.Create(&u) - err := a.PromoteAdmin(u.ID) - if !errors.Is(err, auth.ErrNotPromotable) { - t.Fatalf("expected ErrNotPromotable, got %v", err) + if err := a.PromoteAdmin(u.ID); err != nil { + t.Fatalf("PromoteAdmin: %v", err) } - if n := countAdmins(t, db); n != 0 { - t.Fatalf("expected no admin rows, got %d", n) + if n := countAdmins(t, db); n != 1 { + t.Fatalf("expected 1 admin row, got %d", n) } } diff --git a/themes/default/templates/admin_users.html b/themes/default/templates/admin_users.html index 213abf6e..dc5b412b 100644 --- a/themes/default/templates/admin_users.html +++ b/themes/default/templates/admin_users.html @@ -27,7 +27,7 @@

Users

{{ if .IsAdmin }} {{ if $.can_demote }}[demote]{{ end }} - {{ else if eq .Provider "github" }} + {{ else }} [promote to admin] {{ end }} From 94e8d645800fd134967218e99c7d9f75be29f479 Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Sat, 17 Oct 2026 02:35:31 -0700 Subject: [PATCH 2/2] Update users page help text for email admins Co-Authored-By: Claude Opus 5.5 --- themes/default/templates/admin_users.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/themes/default/templates/admin_users.html b/themes/default/templates/admin_users.html index dc5b412b..ee117e04 100644 --- a/themes/default/templates/admin_users.html +++ b/themes/default/templates/admin_users.html @@ -4,7 +4,7 @@

Users

Page {{ .page }} of {{ .total_pages }} · {{ .total }} user{{ if ne .total 1 }}s{{ end }}

-

Admins can manage posts, pages, comments, settings and other users. Only GitHub users can be made admin, and the last admin can't be demoted.

+

Admins can manage posts, pages, comments, settings and other users. An email-login admin is only as secure as that mailbox. The last admin can't be demoted.

{{ if .users }}