fix bug
This commit is contained in:
1 parent
b55ceb1fdd
commit
a951e4aad3
6 files changed
+279
-44
No files matched your search
@@ -22,6 +22,11 @@ var schemaSQL string
|
|||||||
|
|
||||||
const schemaMigrationLockKey int64 = 7337741001
|
const schemaMigrationLockKey int64 = 7337741001
|
||||||
|
|
||||||
|
// maxOpenConns 是连接池上限,见 Open 里的说明。取值远低于 PostgreSQL 默认的
|
||||||
|
// max_connections=100,同时远高于应用自身的嵌套取连接深度(启动期的 schema
|
||||||
|
// advisory lock 会在持有一条连接的同时让 seedBuiltins 另取连接),不会自锁。
|
||||||
|
const maxOpenConns = 32
|
||||||
|
|
||||||
var schemaDeadlockRetryDelays = [...]time.Duration{
|
var schemaDeadlockRetryDelays = [...]time.Duration{
|
||||||
100 * time.Millisecond,
|
100 * time.Millisecond,
|
||||||
250 * time.Millisecond,
|
250 * time.Millisecond,
|
||||||
@@ -133,6 +138,16 @@ func Open(dsn string) (*DB, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// database/sql 默认不限制连接数:池里没有空闲连接时会无条件新建,一路顶到
|
||||||
|
// PostgreSQL 的 max_connections(默认 100)才被拒,于是高峰期的查询拿到的是
|
||||||
|
// `FATAL: sorry, too many clients already` 这种**错误**。封顶之后超额查询改为
|
||||||
|
// 排队等待空闲连接——同样的负载下变成变慢而不是报错,调用方不必再去区分
|
||||||
|
// "读不到"和"没有"。maxOpenConns 要留出余量给 psql / reset-password.sh 以及
|
||||||
|
// 可能并存的其他实例;若 max_connections 调低过,这里也要跟着往下调。
|
||||||
|
sqlDB.SetMaxOpenConns(maxOpenConns)
|
||||||
|
sqlDB.SetMaxIdleConns(maxOpenConns)
|
||||||
|
sqlDB.SetConnMaxLifetime(30 * time.Minute)
|
||||||
|
sqlDB.SetConnMaxIdleTime(5 * time.Minute)
|
||||||
if err := sqlDB.Ping(); err != nil {
|
if err := sqlDB.Ping(); err != nil {
|
||||||
sqlDB.Close()
|
sqlDB.Close()
|
||||||
return nil, fmt.Errorf("ping postgres (%s): %w", config.Redact(dsn), err)
|
return nil, fmt.Errorf("ping postgres (%s): %w", config.Redact(dsn), err)
|
||||||
|
|||||||
@@ -25,6 +25,24 @@ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, key
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InsertSettingIfAbsent 只在 key 尚未存在时写入,已存在则原样保留并返回 inserted=false。
|
||||||
|
// 给 auth.password_hash 这类"只允许首次设置"的键用:判定落在数据库的主键约束上,
|
||||||
|
// 调用方先 GetSetting 再写的那种检查就只是快速失败路径——读出错或并发撞车时,
|
||||||
|
// 已有的值也不会被 ON CONFLICT DO UPDATE 顺手覆盖掉。
|
||||||
|
func (d *DB) InsertSettingIfAbsent(key, value string) (inserted bool, err error) {
|
||||||
|
res, err := d.Exec(`
|
||||||
|
INSERT INTO settings(key, value) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (key) DO NOTHING`, key, value)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return n > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetBool returns the boolean setting, or def when unset/unparseable.
|
// GetBool returns the boolean setting, or def when unset/unparseable.
|
||||||
func (d *DB) GetBool(key string, def bool) bool {
|
func (d *DB) GetBool(key string, def bool) bool {
|
||||||
v, ok, err := d.GetSetting(key)
|
v, ok, err := d.GetSetting(key)
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InsertSettingIfAbsent 是 auth.password_hash 的兜底:调用方的 GetSetting 检查可能
|
||||||
|
// 因为数据库报错而失效,也可能被并发请求插队(bcrypt 要跑几十毫秒),所以"仅首次
|
||||||
|
// 可设"的保证必须落在主键约束上,而不是上层的 if 判断。
|
||||||
|
func TestInsertSettingIfAbsentDoesNotOverwrite(t *testing.T) {
|
||||||
|
d, err := Open(testDSN(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("postgres unavailable (%v)", err)
|
||||||
|
}
|
||||||
|
defer d.Close()
|
||||||
|
|
||||||
|
// 用测试专属 key,绝不碰开发库里真实的 auth.password_hash。
|
||||||
|
key := fmt.Sprintf("test.insert_if_absent.%d", time.Now().UnixNano())
|
||||||
|
defer func() { _, _ = d.Exec(`DELETE FROM settings WHERE key=$1`, key) }()
|
||||||
|
|
||||||
|
inserted, err := d.InsertSettingIfAbsent(key, "first")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !inserted {
|
||||||
|
t.Fatal("首次写入应返回 inserted=true")
|
||||||
|
}
|
||||||
|
|
||||||
|
inserted, err = d.InsertSettingIfAbsent(key, "second")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if inserted {
|
||||||
|
t.Fatal("键已存在时应返回 inserted=false")
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok, err := d.GetSetting(key)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("GetSetting: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
if got != "first" {
|
||||||
|
t.Fatalf("值被覆盖成 %q,应保持 %q", got, "first")
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
-5
@@ -10,6 +10,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
@@ -20,8 +21,30 @@ const (
|
|||||||
authPassKey = "auth.password_hash"
|
authPassKey = "auth.password_hash"
|
||||||
jwtTTL = 7 * 24 * time.Hour
|
jwtTTL = 7 * 24 * time.Hour
|
||||||
keyChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
keyChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
|
||||||
|
// 下限与 setup 页的前端校验一致——校验只放在前端等于没放,直接打 API 就能
|
||||||
|
// 绕过。上限是 bcrypt 的硬限制:超过 72 字节 GenerateFromPassword 会返回
|
||||||
|
// ErrPasswordTooLong,提前挡掉好过让用户收到一句含义不明的「密码加密失败」。
|
||||||
|
minPasswordRunes = 8
|
||||||
|
maxPasswordBytes = 72
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// errDataSourceUnavailable 是密码相关读操作失败时统一的回复。这些 handler 绝不能
|
||||||
|
// 把"读不到"当成"没有设置":authInit 曾因此在数据库报错时放行,让未认证请求覆盖
|
||||||
|
// 掉已有的管理员密码。
|
||||||
|
const errDataSourceUnavailable = "数据源暂时不可用,请稍后重试"
|
||||||
|
|
||||||
|
// validatePassword 返回空串表示通过,否则返回可直接展示给用户的中文原因。
|
||||||
|
func validatePassword(pw string) string {
|
||||||
|
if utf8.RuneCountInString(pw) < minPasswordRunes {
|
||||||
|
return fmt.Sprintf("密码长度至少 %d 位", minPasswordRunes)
|
||||||
|
}
|
||||||
|
if len(pw) > maxPasswordBytes {
|
||||||
|
return fmt.Sprintf("密码长度不能超过 %d 字节", maxPasswordBytes)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// loadOrCreateJWTKey reads the 32-byte signing key from keyDir/jwt.key. keyDir is
|
// loadOrCreateJWTKey reads the 32-byte signing key from keyDir/jwt.key. keyDir is
|
||||||
// the project base dir (next to the executable), NOT the browsable workspace root
|
// the project base dir (next to the executable), NOT the browsable workspace root
|
||||||
// (dataDir) — the signing key must never be listable/downloadable via the file
|
// (dataDir) — the signing key must never be listable/downloadable via the file
|
||||||
@@ -115,12 +138,19 @@ func (s *Server) requireAuth(h http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/auth/status — reports whether the admin password has been initialised.
|
// GET /api/auth/status — reports whether the admin password has been initialised.
|
||||||
|
// 读失败必须回 503 而不是 initialized:false:前端在 initialized:false 时会把用户
|
||||||
|
// 送到 /setup 去设置密码(login/page.tsx),把数据库故障包装成 200 等于把用户往
|
||||||
|
// 覆盖已有密码的路上推。
|
||||||
func (s *Server) authStatus(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) authStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
pg := s.pg(w)
|
pg := s.pg(w)
|
||||||
if pg == nil {
|
if pg == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hash, _, _ := pg.GetSetting(authPassKey)
|
hash, _, err := pg.GetSetting(authPassKey)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, 503, errDataSourceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, 200, map[string]any{"initialized": hash != ""})
|
writeJSON(w, 200, map[string]any{"initialized": hash != ""})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +160,11 @@ func (s *Server) authInit(w http.ResponseWriter, r *http.Request) {
|
|||||||
if pg == nil {
|
if pg == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
existing, _, _ := pg.GetSetting(authPassKey)
|
existing, _, err := pg.GetSetting(authPassKey)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, 503, errDataSourceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
if existing != "" {
|
if existing != "" {
|
||||||
writeErr(w, 403, "密码已设置")
|
writeErr(w, 403, "密码已设置")
|
||||||
return
|
return
|
||||||
@@ -142,15 +176,27 @@ func (s *Server) authInit(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, 400, "密码不能为空")
|
writeErr(w, 400, "密码不能为空")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if msg := validatePassword(req.Password); msg != "" {
|
||||||
|
writeErr(w, 400, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, 500, "密码加密失败")
|
writeErr(w, 500, "密码加密失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := pg.SetSetting(authPassKey, string(hash)); err != nil {
|
// 用 INSERT ... ON CONFLICT DO NOTHING 而不是 upsert:上面那次 GetSetting 只是
|
||||||
|
// 快速失败路径,真正"仅首次可设"的保证落在主键约束上。bcrypt 要跑几十毫秒,
|
||||||
|
// 这期间别的请求完全可能先把密码设好,而读检查本身也可能因故障而失效。
|
||||||
|
inserted, err := pg.InsertSettingIfAbsent(authPassKey, string(hash))
|
||||||
|
if err != nil {
|
||||||
writeErr(w, 500, "保存失败: "+err.Error())
|
writeErr(w, 500, "保存失败: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !inserted {
|
||||||
|
writeErr(w, 403, "密码已设置")
|
||||||
|
return
|
||||||
|
}
|
||||||
tok, err := signJWT(s.jwtKey)
|
tok, err := signJWT(s.jwtKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, 500, "token 生成失败")
|
writeErr(w, 500, "token 生成失败")
|
||||||
@@ -183,7 +229,15 @@ func (s *Server) authChangePassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, 400, "新密码不能为空")
|
writeErr(w, 400, "新密码不能为空")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hash, ok, _ := pg.GetSetting(authPassKey)
|
if msg := validatePassword(req.NewPassword); msg != "" {
|
||||||
|
writeErr(w, 400, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash, ok, err := pg.GetSetting(authPassKey)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, 503, errDataSourceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
if !ok || hash == "" {
|
if !ok || hash == "" {
|
||||||
writeErr(w, 403, "密码未初始化,请先设置密码")
|
writeErr(w, 403, "密码未初始化,请先设置密码")
|
||||||
return
|
return
|
||||||
@@ -222,7 +276,11 @@ func (s *Server) authLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, 401, "用户名或密码错误")
|
writeErr(w, 401, "用户名或密码错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hash, ok, _ := pg.GetSetting(authPassKey)
|
hash, ok, err := pg.GetSetting(authPassKey)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, 503, errDataSourceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
if !ok || hash == "" {
|
if !ok || hash == "" {
|
||||||
writeErr(w, 403, "密码未初始化,请先设置密码")
|
writeErr(w, 403, "密码未初始化,请先设置密码")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetSetting 对"键不存在"和"读取出错"的返回值只差一个 error:两种情况 value 都是
|
||||||
|
// 空串。密码相关的 handler 一旦把 error 当成"还没设置密码",就会在数据库抖动期间
|
||||||
|
// 敞开初始化入口——authInit 会放行一个未认证请求去覆盖已有的管理员密码,
|
||||||
|
// authStatus 则会把前端直接送到 /setup 去照着做这件事。
|
||||||
|
//
|
||||||
|
// 这两个测试把 handler 的连接池关掉来制造读取失败,断言两处都 fail closed。
|
||||||
|
func TestAuthStatusFailsClosedWhenDataSourceUnavailable(t *testing.T) {
|
||||||
|
m, err := NewManager(t.TempDir(), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("postgres unavailable (%v)", err)
|
||||||
|
}
|
||||||
|
defer m.Close()
|
||||||
|
// 关掉池子,让后续 GetSetting 返回 error 而不是 sql.ErrNoRows。
|
||||||
|
if err := m.pg.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &Server{m: m}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.authStatus(w, httptest.NewRequest("GET", "/api/auth/status", nil))
|
||||||
|
|
||||||
|
if w.Code != 503 {
|
||||||
|
t.Fatalf("status=%d want 503 (读失败被当成未初始化会把用户送去 /setup 覆盖密码); body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Initialized *bool `json:"initialized"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &payload); err == nil && payload.Initialized != nil {
|
||||||
|
t.Fatalf("读失败时不应回答 initialized,得到 %v", *payload.Initialized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthInitFailsClosedWhenDataSourceUnavailable(t *testing.T) {
|
||||||
|
m, err := NewManager(t.TempDir(), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("postgres unavailable (%v)", err)
|
||||||
|
}
|
||||||
|
defer m.Close()
|
||||||
|
if err := m.pg.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &Server{m: m}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
body := strings.NewReader(`{"password":"correct horse battery"}`)
|
||||||
|
s.authInit(w, httptest.NewRequest("POST", "/api/auth/init", body))
|
||||||
|
|
||||||
|
if w.Code != 503 {
|
||||||
|
t.Fatalf("status=%d want 503 (读失败时放行会让未认证请求覆盖已有密码); body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(w.Body.String(), "token") {
|
||||||
|
t.Fatalf("读失败时不应签发 token: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatePassword(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, pw string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"空", "", true},
|
||||||
|
{"七位", "1234567", true},
|
||||||
|
{"八位", "12345678", false},
|
||||||
|
{"八个汉字按字符数而非字节数计", "密码密码密码密码", false},
|
||||||
|
{"三个汉字够 9 字节但只有 3 个字符", "密码强", true},
|
||||||
|
{"72 字节", strings.Repeat("a", 72), false},
|
||||||
|
{"73 字节超出 bcrypt 上限", strings.Repeat("a", 73), true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := validatePassword(tc.pw); (got != "") != tc.wantErr {
|
||||||
|
t.Fatalf("validatePassword(%q)=%q, wantErr=%v", tc.pw, got, tc.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,13 +17,20 @@ export default function SetupPage() {
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [checking, setChecking] = useState(true);
|
const [checking, setChecking] = useState(true);
|
||||||
|
// 查不到初始化状态时不能默认当成"未初始化"——那样会把初始化表单摆给一个
|
||||||
|
// 其实已经设过密码的实例,用户照着填就会覆盖掉原密码。此时关闭入口。
|
||||||
|
const [unavailable, setUnavailable] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.authStatus()
|
api
|
||||||
|
.authStatus()
|
||||||
.then(({ initialized }) => {
|
.then(({ initialized }) => {
|
||||||
if (initialized) router.replace("/login");
|
if (initialized) router.replace("/login");
|
||||||
})
|
})
|
||||||
.catch(() => setError("无法连接到后端服务"))
|
.catch((err) => {
|
||||||
|
setError(err instanceof Error ? err.message : "无法连接到后端服务");
|
||||||
|
setUnavailable(true);
|
||||||
|
})
|
||||||
.finally(() => setChecking(false));
|
.finally(() => setChecking(false));
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
@@ -61,13 +68,7 @@ export default function SetupPage() {
|
|||||||
<div className="absolute size-60 rounded-full border border-primary-foreground/15" />
|
<div className="absolute size-60 rounded-full border border-primary-foreground/15" />
|
||||||
<div className="absolute size-40 rounded-full border border-primary-foreground/20" />
|
<div className="absolute size-40 rounded-full border border-primary-foreground/20" />
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img src="/logo.png" alt="ARTEX" width={160} height={160} className="relative brightness-0 invert" />
|
||||||
src="/logo.png"
|
|
||||||
alt="ARTEX"
|
|
||||||
width={160}
|
|
||||||
height={160}
|
|
||||||
className="relative brightness-0 invert"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -75,38 +76,51 @@ export default function SetupPage() {
|
|||||||
<div className="flex w-full items-center justify-center bg-background p-8 lg:w-2/3">
|
<div className="flex w-full items-center justify-center bg-background p-8 lg:w-2/3">
|
||||||
<div className="w-full max-w-md space-y-10 py-24 lg:py-32">
|
<div className="w-full max-w-md space-y-10 py-24 lg:py-32">
|
||||||
<div className="space-y-4 text-center">
|
<div className="space-y-4 text-center">
|
||||||
<h2 className="text-2xl font-medium tracking-tight">初始化密码</h2>
|
<h2 className="text-2xl font-medium tracking-tight">{unavailable ? "无法确认初始化状态" : "初始化密码"}</h2>
|
||||||
<p className="mx-auto max-w-xl text-muted-foreground">首次使用 ARTEX,请为账户设置一个登录密码(至少 8 位)</p>
|
<p className="mx-auto max-w-xl text-muted-foreground">
|
||||||
|
{unavailable
|
||||||
|
? "后端或数据库暂时不可用。为避免覆盖实例上已有的密码,初始化入口已临时关闭——请恢复服务后重试。"
|
||||||
|
: "首次使用 ARTEX,请为账户设置一个登录密码(至少 8 位)"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
{unavailable ? (
|
||||||
<div className="space-y-1.5">
|
<div className="flex flex-col gap-4">
|
||||||
<Label htmlFor="password">新密码</Label>
|
{error && <p className="text-center text-sm text-destructive">{error}</p>}
|
||||||
<Input
|
<Button type="button" className="w-full" onClick={() => window.location.reload()}>
|
||||||
id="password"
|
重试
|
||||||
type="password"
|
</Button>
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder="至少 8 位"
|
|
||||||
autoFocus
|
|
||||||
autoComplete="new-password"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
) : (
|
||||||
<Label htmlFor="confirm">确认密码</Label>
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
<Input
|
<div className="space-y-1.5">
|
||||||
id="confirm"
|
<Label htmlFor="password">新密码</Label>
|
||||||
type="password"
|
<Input
|
||||||
value={confirm}
|
id="password"
|
||||||
onChange={(e) => setConfirm(e.target.value)}
|
type="password"
|
||||||
placeholder="再次输入密码"
|
value={password}
|
||||||
autoComplete="new-password"
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
/>
|
placeholder="至少 8 位"
|
||||||
</div>
|
autoFocus
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
autoComplete="new-password"
|
||||||
<Button type="submit" className="w-full" disabled={loading || !password || !confirm}>
|
/>
|
||||||
{loading ? "保存中..." : "设置密码并登录"}
|
</div>
|
||||||
</Button>
|
<div className="space-y-1.5">
|
||||||
</form>
|
<Label htmlFor="confirm">确认密码</Label>
|
||||||
|
<Input
|
||||||
|
id="confirm"
|
||||||
|
type="password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
placeholder="再次输入密码"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
<Button type="submit" className="w-full" disabled={loading || !password || !confirm}>
|
||||||
|
{loading ? "保存中..." : "设置密码并登录"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user