feat: 认证接口 JSON 化——/api/auth/login|register|logout

- auth.go:Login/Register 改 JSON 绑定(loginRequest/registerRequest),
  错误码复用 i18n 键:登录失败 login_error(401)、限流锁定 login_locked(429)、
  注册校验 400、用户名冲突 user_username_exists(409)、注册禁用
  registration_disabled(403);成功返回 {ok,redirect}(角色定向 /admin 或 /)
- main.go:旧 POST /login /register /logout 移除,迁入 /api/auth 分组
- i18n:新增 registration_disabled(中英)
- security_test.go:env 路由改 /api/auth/*,e.login 改 JSON 登录,
  新增 postJSON/respCode/respRedirect/respOK 测试辅助
- p2_validation_test.go:TestLoginRateLimited/TestLoginTimingDoesNotRevealUser/
  TestRegisterRejectsInvalidEmail 迁移 JSON 断言(429/401/409/400)
- base.html:logout-form 全局委托 fetch + login/register 模板 id/JS
- main_test.go:冒烟断言补 /api/auth 三端点
- go build/vet/test ./... 全绿
This commit is contained in:
2026-08-27 19:31:29 +08:00
parent 2c46846309
commit 7062c755a3
11 files changed
+222 -106

No files matched your search

+2 -2
View File
@@ -11,8 +11,8 @@
## 零行为搬移(medium
- [ ] 5. 附件三件套(admin/my+ `/api/profile/avatar` 换注册路径
- [ ] 6. 搬移端点模板更新:`article_create.html` 3 处 fetch URL、`profile.html` 1 处
- [x] 5. 附件三件套(admin/my+ `/api/profile/avatar` 换注册路径(路由已在基建 commit 搬移)
- [x] 6. 搬移端点模板更新:`article_create.html` 3 处 fetch URL、`profile.html` 1 处(删除改用 DELETE
## 功能改造(medium
+53 -30
View File
@@ -33,17 +33,27 @@ func LoginPage() gin.HandlerFunc {
}
}
// loginRequest 是 POST /api/auth/login 的 JSON 请求体。
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// Login 处理登录表单提交。它对每个 IP+用户名实施速率限制
// SECURITY_TODO #10),对于不存在的用户名会执行一次虚拟 bcrypt 比较,
// 使耗时不会暴露用户名是否存在(SECURITY_TODO #25)。
func Login(db *gorm.DB, limiter *LoginRateLimiter) gin.HandlerFunc {
return func(c *gin.Context) {
username := c.PostForm("username")
password := c.PostForm("password")
var req loginRequest
if !bindJSON(c, &req) {
return
}
username := req.Username
password := req.Password
key := GetClientIP(c) + "\x00" + username
if !limiter.Allow(key) {
c.Redirect(http.StatusFound, "/login?error=locked")
APIError(c, http.StatusTooManyRequests, "login_locked")
return
}
@@ -53,19 +63,19 @@ func Login(db *gorm.DB, limiter *LoginRateLimiter) gin.HandlerFunc {
// 使耗时不会暴露用户名是否存在。
limiter.Fail(key)
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(password))
c.Redirect(http.StatusFound, "/login?error=1")
APIError(c, http.StatusUnauthorized, "login_error")
return
}
if !user.CheckPassword(password) {
limiter.Fail(key)
c.Redirect(http.StatusFound, "/login?error=1")
APIError(c, http.StatusUnauthorized, "login_error")
return
}
// 拒绝非正常状态账户登录(已禁用 / 已锁定 / 未激活)。
if user.Status != models.StatusNormal {
c.Redirect(http.StatusFound, "/login?error=1")
APIError(c, http.StatusUnauthorized, "login_error")
return
}
@@ -88,26 +98,26 @@ func Login(db *gorm.DB, limiter *LoginRateLimiter) gin.HandlerFunc {
session.Set("user_id", user.ID)
session.Set("username", user.Username)
if err := session.Save(); err != nil {
c.String(http.StatusInternalServerError, "Failed to save session")
APIError(c, http.StatusInternalServerError, "api_error")
return
}
// 根据用户角色重定向:管理员到 /admin,其他用户到首页
// 根据用户角色跳转:管理员到 /admin,其他用户到首页
redirect := "/"
if user.Role == models.RoleAdmin {
c.Redirect(http.StatusFound, "/admin")
} else {
c.Redirect(http.StatusFound, "/")
redirect = "/admin"
}
APIOK(c, redirect, nil)
}
}
// Logout 清除会话并重定向回首页
// Logout 清除会话并返回跳转首页指令,由前端 fetch 发起跳转
func Logout() gin.HandlerFunc {
return func(c *gin.Context) {
session := sessions.Default(c)
session.Clear()
session.Save()
c.Redirect(http.StatusFound, "/")
APIOK(c, "/", nil)
}
}
@@ -133,53 +143,66 @@ func RegisterPage(db *gorm.DB) gin.HandlerFunc {
}
}
// registerRequest 是 POST /api/auth/register 的 JSON 请求体。
type registerRequest struct {
Username string `json:"username"`
Password string `json:"password"`
ConfirmPassword string `json:"confirm_password"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
}
// Register 处理注册表单提交。
func Register(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
// 检查是否允许注册
var s models.SiteSetting
if err := db.First(&s, 1).Error; err != nil || !s.AllowRegistration {
c.Redirect(http.StatusFound, "/login")
APIError(c, http.StatusForbidden, "registration_disabled")
return
}
username := strings.TrimSpace(c.PostForm("username"))
password := c.PostForm("password")
confirmPassword := c.PostForm("confirm_password")
email := strings.TrimSpace(c.PostForm("email"))
displayName := strings.TrimSpace(c.PostForm("display_name"))
var req registerRequest
if !bindJSON(c, &req) {
return
}
username := strings.TrimSpace(req.Username)
password := req.Password
confirmPassword := req.ConfirmPassword
email := strings.TrimSpace(req.Email)
displayName := strings.TrimSpace(req.DisplayName)
// 校验输入
if username == "" || password == "" {
c.Redirect(http.StatusFound, "/register?error=register_required")
APIError(c, http.StatusBadRequest, "register_required")
return
}
if len(username) < 3 || len(username) > 32 {
c.Redirect(http.StatusFound, "/register?error=register_username_length")
APIError(c, http.StatusBadRequest, "register_username_length")
return
}
if len(password) < 6 {
c.Redirect(http.StatusFound, "/register?error=register_password_length")
APIError(c, http.StatusBadRequest, "register_password_length")
return
}
if password != confirmPassword {
c.Redirect(http.StatusFound, "/register?error=register_password_mismatch")
APIError(c, http.StatusBadRequest, "register_password_mismatch")
return
}
// SECURITY (#24):拒绝格式非法的邮箱地址(可选字段)。
if !validateEmail(email) {
c.Redirect(http.StatusFound, "/register?error=register_email_invalid")
APIError(c, http.StatusBadRequest, "register_email_invalid")
return
}
// 检查用户名是否已存在
var existingUser models.User
if err := db.Where("username = ?", username).First(&existingUser).Error; err == nil {
c.Redirect(http.StatusFound, "/register?error=user_username_exists")
APIError(c, http.StatusConflict, "user_username_exists")
return
}
@@ -197,12 +220,12 @@ func Register(db *gorm.DB) gin.HandlerFunc {
}
if err := user.SetPassword(password); err != nil {
c.Redirect(http.StatusFound, "/register?error=register_error")
APIError(c, http.StatusInternalServerError, "register_error")
return
}
if err := db.Create(&user).Error; err != nil {
c.Redirect(http.StatusFound, "/register?error=register_error")
APIError(c, http.StatusInternalServerError, "register_error")
return
}
@@ -220,11 +243,11 @@ func Register(db *gorm.DB) gin.HandlerFunc {
session.Set("user_id", user.ID)
session.Set("username", user.Username)
if err := session.Save(); err != nil {
c.Redirect(http.StatusFound, "/login")
APIError(c, http.StatusInternalServerError, "api_error")
return
}
// 重定向到首页
c.Redirect(http.StatusFound, "/")
// 首页
APIOK(c, "/", nil)
}
}
+40 -39
View File
@@ -8,6 +8,8 @@ import (
"strings"
"testing"
"github.com/gin-gonic/gin"
"go_blog/models"
)
@@ -257,17 +259,18 @@ func TestRegisterRejectsInvalidEmail(t *testing.T) {
}
anonCookie := e.sessionCookie(w)
fields := url.Values{}
fields.Set("username", "carol")
fields.Set("password", "secret1")
fields.Set("confirm_password", "secret1")
fields.Set("email", "abc")
w2 := postForm(e, http.MethodPost, "/register", anonCookie, m[1], fields)
if w2.Code != http.StatusFound {
t.Fatalf("register invalid email: status = %d, want 302", w2.Code)
fields := gin.H{
"username": "carol",
"password": "secret1",
"confirm_password": "secret1",
"email": "abc",
}
if loc := w2.Header().Get("Location"); loc != "/register?error=register_email_invalid" {
t.Fatalf("register invalid email: location = %q", loc)
w2 := postJSON(e, http.MethodPost, "/api/auth/register", anonCookie, m[1], fields)
if w2.Code != http.StatusBadRequest {
t.Fatalf("register invalid email: status = %d, want 400", w2.Code)
}
if code := respCode(w2); code != "register_email_invalid" {
t.Fatalf("register invalid email: code = %q, want register_email_invalid", code)
}
var count int64
e.db.Model(&models.User{}).Where("username = ?", "carol").Count(&count)
@@ -275,10 +278,10 @@ func TestRegisterRejectsInvalidEmail(t *testing.T) {
t.Fatal("carol created with invalid email")
}
fields.Set("email", "carol@example.com")
w2 = postForm(e, http.MethodPost, "/register", anonCookie, m[1], fields)
if w2.Code != http.StatusFound {
t.Fatalf("register valid email: status = %d, want 302", w2.Code)
fields["email"] = "carol@example.com"
w2 = postJSON(e, http.MethodPost, "/api/auth/register", anonCookie, m[1], fields)
if w2.Code != http.StatusOK || !respOK(w2) {
t.Fatalf("register valid email: status = %d, body %s", w2.Code, w2.Body.String())
}
e.db.Model(&models.User{}).Where("username = ?", "carol").Count(&count)
if count != 1 {
@@ -302,7 +305,7 @@ func limiterEntryKey(e *securityTestEnv, username string) string {
func TestLoginRateLimited(t *testing.T) {
e := newSecurityTestEnv(t)
loginAttempt := func(form url.Values) (*httptest.ResponseRecorder, string) {
loginAttempt := func(body gin.H) (*httptest.ResponseRecorder, string) {
// 每次尝试使用全新的匿名会话(和 CSRF 令牌)。
req := httptest.NewRequest(http.MethodGet, "/login", nil)
w := httptest.NewRecorder()
@@ -312,37 +315,36 @@ func TestLoginRateLimited(t *testing.T) {
t.Fatal("login page did not render a CSRF token")
}
anonCookie := e.sessionCookie(w)
form.Set("_csrf", m[1])
w2 := postForm(e, http.MethodPost, "/login", anonCookie, m[1], form)
w2 := postJSON(e, http.MethodPost, "/api/auth/login", anonCookie, m[1], body)
return w2, anonCookie
}
bad := url.Values{"username": {"alice"}, "password": {"wrong-password"}}
bad := gin.H{"username": "alice", "password": "wrong-password"}
for i := 0; i < maxLoginFailures; i++ {
w, _ := loginAttempt(bad)
if w.Code != http.StatusFound {
t.Fatalf("attempt %d: status = %d, want 302", i+1, w.Code)
if w.Code != http.StatusUnauthorized {
t.Fatalf("attempt %d: status = %d, want 401", i+1, w.Code)
}
if loc := w.Header().Get("Location"); loc != "/login?error=1" {
t.Fatalf("attempt %d: location = %q, want /login?error=1", i+1, loc)
if code := respCode(w); code != "login_error" {
t.Fatalf("attempt %d: code = %q, want login_error", i+1, code)
}
}
// 下一次尝试(即使密码正确)也会被锁定。
good := url.Values{"username": {"alice"}, "password": {"pw-alice"}}
good := gin.H{"username": "alice", "password": "pw-alice"}
w, _ := loginAttempt(good)
if w.Code != http.StatusFound {
t.Fatalf("locked attempt: status = %d, want 302", w.Code)
if w.Code != http.StatusTooManyRequests {
t.Fatalf("locked attempt: status = %d, want 429", w.Code)
}
if loc := w.Header().Get("Location"); loc != "/login?error=locked" {
t.Fatalf("locked attempt: location = %q, want /login?error=locked", loc)
if code := respCode(w); code != "login_locked" {
t.Fatalf("locked attempt: code = %q, want login_locked", code)
}
// 不同的键(用户名)不受影响。
w, _ = loginAttempt(url.Values{"username": {"bob"}, "password": {"pw-bob"}})
if w.Code != http.StatusFound || w.Header().Get("Location") != "/" {
t.Fatalf("different user login during lock: status = %d, location = %q",
w.Code, w.Header().Get("Location"))
w, _ = loginAttempt(gin.H{"username": "bob", "password": "pw-bob"})
if w.Code != http.StatusOK || respRedirect(w) != "/" {
t.Fatalf("different user login during lock: status = %d, redirect = %q",
w.Code, respRedirect(w))
}
// 重置后,被锁定的键再次可用。
@@ -352,9 +354,9 @@ func TestLoginRateLimited(t *testing.T) {
}
e.limiter.Reset(aliceKey)
w, _ = loginAttempt(good)
if w.Code != http.StatusFound || w.Header().Get("Location") != "/" {
t.Fatalf("login after reset: status = %d, location = %q",
w.Code, w.Header().Get("Location"))
if w.Code != http.StatusOK || respRedirect(w) != "/" {
t.Fatalf("login after reset: status = %d, redirect = %q",
w.Code, respRedirect(w))
}
}
@@ -373,11 +375,10 @@ func TestLoginTimingDoesNotRevealUser(t *testing.T) {
}
anonCookie := e.sessionCookie(w)
fields := url.Values{"username": {"does-not-exist-31415"}, "password": {"anything"}}
fields.Set("_csrf", m[1])
w2 := postForm(e, http.MethodPost, "/login", anonCookie, m[1], fields)
if w2.Code != http.StatusFound || w2.Header().Get("Location") != "/login?error=1" {
t.Fatalf("unknown user: status = %d, location = %q", w2.Code, w2.Header().Get("Location"))
w2 := postJSON(e, http.MethodPost, "/api/auth/login", anonCookie, m[1],
gin.H{"username": "does-not-exist-31415", "password": "anything"})
if w2.Code != http.StatusUnauthorized || respCode(w2) != "login_error" {
t.Fatalf("unknown user: status = %d, code = %q", w2.Code, respCode(w2))
}
// 未知用户的键必须被计入失败次数(若限流器共享),
+63 -18
View File
@@ -1,6 +1,8 @@
package handlers
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
@@ -80,12 +82,16 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv {
r.Use(middleware.SetUserContext(db))
r.GET("/login", LoginPage())
r.POST("/login", Login(db, limiter))
r.POST("/logout", Logout())
r.POST("/article/:slug/comments", PostComment(db))
r.GET("/register", RegisterPage(db))
r.POST("/register", Register(db))
r.GET("/rss", RSSFeed(db))
r.POST("/article/:slug/comments", PostComment(db))
api := r.Group("/api")
{
api.POST("/auth/login", Login(db, limiter))
api.POST("/auth/logout", Logout())
api.POST("/auth/register", Register(db))
}
protected := r.Group("/my", middleware.AuthRequired(db))
{
@@ -154,20 +160,13 @@ func (e *securityTestEnv) login(t *testing.T, username string) string {
}
cookie := e.sessionCookie(w)
// 携带令牌 POST 凭据。
form := url.Values{}
form.Set("username", username)
form.Set("password", "pw-"+username)
form.Set("_csrf", m[1])
req = httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if cookie != "" {
req.Header.Set("Cookie", cookie)
}
w = httptest.NewRecorder()
e.router.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("POST /login (%s): status = %d, body %s", username, w.Code, w.Body.String())
// 携带令牌 POST 凭据JSON API
w = postJSON(e, http.MethodPost, "/api/auth/login", cookie, m[1], gin.H{
"username": username,
"password": "pw-" + username,
})
if w.Code != http.StatusOK {
t.Fatalf("POST /api/auth/login (%s): status = %d, body %s", username, w.Code, w.Body.String())
}
authCookie := e.sessionCookie(w)
if authCookie == "" {
@@ -202,6 +201,52 @@ func (e *securityTestEnv) do(method, path, cookie string, body io.Reader, conten
return w
}
// postJSON 是 JSON 请求小助手,以 X-CSRF-Token 请求头发送令牌(AJAX 模式)。
func postJSON(e *securityTestEnv, method, path, cookie, csrfToken string, body interface{}) *httptest.ResponseRecorder {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(body); err != nil {
panic(err)
}
req := httptest.NewRequest(method, path, &buf)
req.Header.Set("Content-Type", "application/json")
if csrfToken != "" {
req.Header.Set("X-CSRF-Token", csrfToken)
}
if cookie != "" {
req.Header.Set("Cookie", cookie)
}
w := httptest.NewRecorder()
e.router.ServeHTTP(w, req)
return w
}
// respCode 从 JSON 错误响应中提取 code 字段。
func respCode(w *httptest.ResponseRecorder) string {
var r struct {
Code string `json:"code"`
}
_ = json.Unmarshal(w.Body.Bytes(), &r)
return r.Code
}
// respRedirect 从 JSON 成功响应中提取 redirect 字段。
func respRedirect(w *httptest.ResponseRecorder) string {
var r struct {
Redirect string `json:"redirect"`
}
_ = json.Unmarshal(w.Body.Bytes(), &r)
return r.Redirect
}
// respOK 报告 JSON 响应是否成功(ok=true)。
func respOK(w *httptest.ResponseRecorder) bool {
var r struct {
OK bool `json:"ok"`
}
_ = json.Unmarshal(w.Body.Bytes(), &r)
return r.OK
}
func (e *securityTestEnv) upload(t *testing.T, cookie, csrfToken, articleID string) *httptest.ResponseRecorder {
t.Helper()
var buf strings.Builder
+4 -2
View File
@@ -75,7 +75,8 @@ var translations = map[Lang]map[string]string{
"register_password_length": "Password must be at least 6 characters.",
"register_password_mismatch": "Passwords do not match.",
"register_email_invalid": "Please enter a valid email address.",
"register_error": "Registration failed. Please try again.",
"register_error": "Registration failed. Please try again.",
"registration_disabled": "Registration is currently disabled.",
// 设置
"settings_allow_registration": "Allow user registration",
@@ -511,7 +512,8 @@ var translations = map[Lang]map[string]string{
"register_password_length": "密码至少需要6个字符。",
"register_password_mismatch": "两次输入的密码不一致。",
"register_email_invalid": "请输入有效的邮箱地址。",
"register_error": "注册失败,请重试。",
"register_error": "注册失败,请重试。",
"registration_disabled": "当前已关闭注册。",
// 平台设置
"settings_allow_registration": "允许用户注册",
+3 -3
View File
@@ -162,10 +162,7 @@ func registerRoutes(router *gin.Engine, cfg *config.Config, db *gorm.DB, loginLi
router.GET("/rss", handlers.RSSFeed(db))
router.GET("/feed", handlers.RSSFeed(db))
router.GET("/login", handlers.LoginPage())
router.POST("/login", handlers.Login(db, loginLimiter))
router.GET("/register", handlers.RegisterPage(db))
router.POST("/register", handlers.Register(db))
router.POST("/logout", handlers.Logout())
router.GET("/article/:slug", handlers.ArticleDetail(db))
router.POST("/article/:slug/comments", handlers.PostComment(db))
@@ -173,6 +170,9 @@ func registerRoutes(router *gin.Engine, cfg *config.Config, db *gorm.DB, loginLi
api := router.Group("/api")
{
api.GET("/articles", handlers.HomeArticlesAPI(db))
api.POST("/auth/login", handlers.Login(db, loginLimiter))
api.POST("/auth/register", handlers.Register(db))
api.POST("/auth/logout", handlers.Logout())
}
// 受保护的后台路由(仅管理员角色)。
+6 -2
View File
@@ -141,9 +141,13 @@ func TestRegisterRoutesSmoke(t *testing.T) {
want := map[string]string{
// 既有 JSON API。
"GET /api/articles": "",
"GET /api/articles": "",
// 认证 API。
"POST /api/auth/login": "",
"POST /api/auth/register": "",
"POST /api/auth/logout": "",
// 搬移的附件/头像端点。
"POST /api/admin/articles/attachments": "",
"POST /api/admin/articles/attachments": "",
"DELETE /api/admin/articles/attachments/:id": "",
"GET /api/admin/articles/:id/attachments": "",
"POST /api/profile/avatar": "",
+1 -1
View File
@@ -6,7 +6,7 @@
<h2 class="text-3xl font-bold text-gray-900">{{index .Tr "dash_title"}}</h2>
<p class="text-gray-500 mt-1">{{index .Tr "dash_welcome"}} <span class="font-medium text-gray-700">{{.Username}}</span>!</p>
</div>
<form action="/logout" method="post" class="m-0">
<form action="/logout" method="post" class="m-0 logout-form">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<button
type="submit"
+12 -1
View File
@@ -82,7 +82,7 @@
</a>
{{end}}
<div class="border-t border-gray-100 my-1"></div>
<form action="/logout" method="post" class="m-0">
<form action="/logout" method="post" class="m-0 logout-form">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<button type="submit" class="w-full text-left block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors cursor-pointer bg-transparent border-none">
{{index .Tr "logout"}}
@@ -224,6 +224,17 @@
if (el) { el.textContent = msg; el.classList.remove('hidden'); }
};
// Logout formsclass=logout-form)统一改走 JSON API 后跳转首页。
document.addEventListener('submit', function (e) {
var form = e.target;
if (form.classList && form.classList.contains('logout-form')) {
e.preventDefault();
blogAPI('POST', '/api/auth/logout').then(function (r) {
window.location.href = (r && r.redirect) || '/';
});
}
});
function toggleDropdown() {
var menu = document.getElementById('dropdownMenu');
menu.classList.toggle('hidden');
+19 -4
View File
@@ -4,13 +4,11 @@
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
<h2 class="text-2xl font-bold text-gray-900 mb-6 text-center">{{index .Tr "login_title"}}</h2>
{{if .Error}}
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm">
<div id="loginError" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm {{if not .Error}}hidden{{end}}">
{{.Error}}
</div>
{{end}}
<form action="/login" method="post" class="space-y-5">
<form id="loginForm" action="/login" method="post" class="space-y-5">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "login_username"}}</label>
@@ -51,4 +49,21 @@
</div>
</section>
{{template "footer" .}}
<script>
(function () {
var form = document.getElementById('loginForm');
if (!form) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
var btn = e.submitter || null;
if (btn) btn.disabled = true;
blogAPI('POST', '/api/auth/login', blogForm(form, btn)).then(function (r) {
if (btn) btn.disabled = false;
if (r.ok) { window.location.href = r.redirect || '/'; }
else { blogShowError('loginError', r.error || 'Failed to sign in.'); }
});
});
})();
</script>
{{end}}
+19 -4
View File
@@ -4,13 +4,11 @@
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
<h2 class="text-2xl font-bold text-gray-900 mb-6 text-center">{{index .Tr "register_title"}}</h2>
{{if .Error}}
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm">
<div id="registerError" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm {{if not .Error}}hidden{{end}}">
{{.Error}}
</div>
{{end}}
<form action="/register" method="post" class="space-y-5">
<form id="registerForm" action="/register" method="post" class="space-y-5">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "register_username"}}</label>
@@ -88,4 +86,21 @@
</div>
</section>
{{template "footer" .}}
<script>
(function () {
var form = document.getElementById('registerForm');
if (!form) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
var btn = e.submitter || null;
if (btn) btn.disabled = true;
blogAPI('POST', '/api/auth/register', blogForm(form, btn)).then(function (r) {
if (btn) btn.disabled = false;
if (r.ok) { window.location.href = r.redirect || '/'; }
else { blogShowError('registerError', r.error || 'Registration failed.'); }
});
});
})();
</script>
{{end}}