mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
fix(security): move account recovery to local CLI, remove unauthenticated reset endpoints
Unauthenticated POST /api/reset-password and /api/reset-account were a remotely exploitable auth-bypass on public-facing deployments. The confirm phrase was embedded in the frontend and echoed back by the API, so it was friction, not authentication: anyone who knew the account email could reset the password, log in, and obtain a valid JWT. Recovery now runs as local CLI commands that operate directly on the database without starting the HTTP server: nofx reset-password --email you@example.com nofx reset-account These require shell/file access to the host, which a remote attacker does not have, so recovery stays safe even when NOFX is exposed to the public internet. - cli.go: new reset-password / reset-account subcommands (hidden password input on a TTY, --password/stdin for scripting, min 8 chars) - main.go: dispatch subcommands before the server starts (backward compatible with the legacy `nofx <dbpath>` arg) - api: remove public /reset-password and /reset-account routes, their handlers, and the public confirm-phrase constants - web: replace the self-service reset form with CLI instructions; drop the AuthContext resetPassword call and the LoginPage reset-account call (en/zh/id) - telegram: refresh the bot allowlist comment
This commit is contained in:
@@ -191,123 +191,14 @@ func (s *Server) handleChangePassword(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "Password updated"})
|
c.JSON(http.StatusOK, gin.H{"message": "Password updated"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// resetPasswordConfirmPhrase is the friction step for /api/reset-password.
|
// NOTE: Password and account recovery used to live here as the public,
|
||||||
// Same security rationale as resetAccountConfirmPhrase — not a cryptographic
|
// unauthenticated handlers handleResetPassword / handleResetAccount. They were
|
||||||
// check, just a guard against accidental and drive-by triggers.
|
// removed because an unauthenticated recovery endpoint is a remotely
|
||||||
const resetPasswordConfirmPhrase = "I_UNDERSTAND_THIS_RESETS_MY_PASSWORD"
|
// exploitable auth-bypass on any public-facing deployment: the confirm phrase
|
||||||
|
// was embedded in the frontend (and echoed back by the API), so it was friction
|
||||||
// handleResetPassword resets the password for the given email.
|
// rather than authentication. Recovery now lives in the local CLI
|
||||||
//
|
// (`nofx reset-password` / `nofx reset-account`, see cli.go), which requires
|
||||||
// SECURITY NOTE: This endpoint is intentionally callable without a JWT — it
|
// shell access to the host — something a remote attacker does not have.
|
||||||
// IS the recovery path for "forgot password" in the single-user self-hosted
|
|
||||||
// threat model this project targets. A logged-in user changes password via
|
|
||||||
// PUT /api/user/password; this endpoint exists for users who can no longer
|
|
||||||
// log in. Mitigations:
|
|
||||||
//
|
|
||||||
// 1. Requires the confirm phrase (blocks accidental and drive-by triggers).
|
|
||||||
// 2. New password must be ≥ 8 chars.
|
|
||||||
// 3. Authenticated session change is preferred (PUT /api/user/password).
|
|
||||||
//
|
|
||||||
// Operators exposing the API to the public internet should put a reverse-proxy
|
|
||||||
// auth layer in front of /api/reset-password OR set up out-of-band recovery
|
|
||||||
// (email link, OTP) instead of relying on this endpoint.
|
|
||||||
func (s *Server) handleResetPassword(c *gin.Context) {
|
|
||||||
var req struct {
|
|
||||||
Email string `json:"email" binding:"required,email"`
|
|
||||||
NewPassword string `json:"new_password" binding:"required,min=8"`
|
|
||||||
Confirm string `json:"confirm"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
SafeBadRequest(c, "email, new_password (min 8 chars), and confirm are required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Confirm != resetPasswordConfirmPhrase {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
|
||||||
"error": "Confirmation phrase required",
|
|
||||||
"hint": `Body must include {"confirm":"` + resetPasswordConfirmPhrase + `"}`,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.store.User().GetByEmail(req.Email)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Email does not exist"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
newPasswordHash, err := auth.HashPassword(req.NewPassword)
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Password processing failed", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := s.store.User().UpdatePassword(user.ID, newPasswordHash); err != nil {
|
|
||||||
SafeInternalError(c, "Password update failed", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Infof("✓ User %s password reset via reset endpoint", user.Email)
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Password reset successful, please login with new password"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// resetAccountConfirmPhrase must appear in the request body for /api/reset-account.
|
|
||||||
// This is the single intentional friction step that prevents accidental wipes
|
|
||||||
// from drive-by scripts and crawlers. It is NOT a cryptographic check — anyone
|
|
||||||
// who reads this source can send the phrase. The real safety comes from:
|
|
||||||
//
|
|
||||||
// 1. Wallet keys are NO LONGER auto-adopted by the next registrant
|
|
||||||
// (adoptOrphanRecords was removed). The historical takeover path was:
|
|
||||||
// reset → register → inherit prior wallet → drain. That path is closed.
|
|
||||||
// 2. The destructive action is loud (logged at Warn level).
|
|
||||||
//
|
|
||||||
// Operators who expose the API to the public internet and want stronger
|
|
||||||
// gating can wrap this route with a reverse-proxy auth header check.
|
|
||||||
const resetAccountConfirmPhrase = "I_UNDERSTAND_THIS_DELETES_EVERYTHING"
|
|
||||||
|
|
||||||
// handleResetAccount wipes all users + traders + strategies + AI models +
|
|
||||||
// exchanges, returning the system to uninitialized state.
|
|
||||||
//
|
|
||||||
// SECURITY NOTE: For the single-user, self-hosted threat model this project
|
|
||||||
// targets, this endpoint is intentionally callable without a JWT — the
|
|
||||||
// frontend "forgot account" button must still work after the user forgets
|
|
||||||
// their password. The confirm phrase blocks accidental and drive-by triggers;
|
|
||||||
// the removal of orphan adoption blocks the post-reset takeover. A determined
|
|
||||||
// attacker on a public-facing deployment can still grief by wiping local
|
|
||||||
// state, but they cannot steal funds (everything is deleted, not transferred).
|
|
||||||
func (s *Server) handleResetAccount(c *gin.Context) {
|
|
||||||
var req struct {
|
|
||||||
Confirm string `json:"confirm"`
|
|
||||||
}
|
|
||||||
_ = c.ShouldBindJSON(&req)
|
|
||||||
if req.Confirm != resetAccountConfirmPhrase {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
|
||||||
"error": "Confirmation phrase required",
|
|
||||||
"hint": `Body must include {"confirm":"` + resetAccountConfirmPhrase + `"}`,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := s.store.Transaction(func(tx *gorm.DB) error {
|
|
||||||
// Wipe ALL records — including wallet keys and exchange credentials.
|
|
||||||
// Preserving them across user identities is what enabled the takeover.
|
|
||||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Trader{})
|
|
||||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Strategy{})
|
|
||||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.AIModel{})
|
|
||||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Exchange{})
|
|
||||||
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.User{}).Error; err != nil {
|
|
||||||
return fmt.Errorf("failed to delete users: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
SafeInternalError(c, "Failed to reset account", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Warnf("⚠ Account reset performed — all users, traders, strategies, ai_models, exchanges wiped")
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"message": "System wiped. All wallet keys and exchange credentials were deleted. Register a fresh account and re-import everything.",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// initUserDefaultConfigs Initialize default configs for new user
|
// initUserDefaultConfigs Initialize default configs for new user
|
||||||
func (s *Server) initUserDefaultConfigs(userID string, lang string) error {
|
func (s *Server) initUserDefaultConfigs(userID string, lang string) error {
|
||||||
|
|||||||
@@ -165,14 +165,13 @@ func (s *Server) setupRoutes() {
|
|||||||
// Authentication related routes (no authentication required)
|
// Authentication related routes (no authentication required)
|
||||||
s.route(api, "POST", "/register", "Register new user", s.handleRegister)
|
s.route(api, "POST", "/register", "Register new user", s.handleRegister)
|
||||||
s.route(api, "POST", "/login", "User login, returns JWT token", s.handleLogin)
|
s.route(api, "POST", "/login", "User login, returns JWT token", s.handleLogin)
|
||||||
// SECURITY: /reset-password and /reset-account are PUBLIC by necessity —
|
// SECURITY: password/account recovery is NOT exposed over HTTP. An
|
||||||
// they ARE the recovery paths when the user can no longer log in. Both
|
// unauthenticated recovery endpoint is a remote auth-bypass on any
|
||||||
// require a literal confirmation phrase in the request body, which
|
// public-facing deployment (the confirm phrase is in the frontend and
|
||||||
// blocks accidental triggers and drive-by scripts. The historical
|
// returned by the API, so it is friction, not authentication). Recovery
|
||||||
// takeover path (post-reset wallet-key adoption) was closed by
|
// is now a local CLI run on the host — `nofx reset-password` /
|
||||||
// removing adoptOrphanRecords. See handler_user.go for details.
|
// `nofx reset-account` — which requires shell access the attacker lacks.
|
||||||
s.route(api, "POST", "/reset-password", "Reset password by email (requires confirm phrase)", s.handleResetPassword)
|
// See cli.go.
|
||||||
s.route(api, "POST", "/reset-account", "[DESTRUCTIVE] Wipe everything (requires confirm phrase)", s.handleResetAccount)
|
|
||||||
|
|
||||||
// Routes requiring authentication
|
// Routes requiring authentication
|
||||||
protected := api.Group("/", s.authMiddleware())
|
protected := api.Group("/", s.authMiddleware())
|
||||||
|
|||||||
228
cli.go
Normal file
228
cli.go
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"nofx/auth"
|
||||||
|
"nofx/config"
|
||||||
|
"nofx/crypto"
|
||||||
|
"nofx/logger"
|
||||||
|
"nofx/store"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"golang.org/x/term"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// minResetPasswordLen mirrors the minimum enforced on the authenticated
|
||||||
|
// password-change path (PUT /api/user/password).
|
||||||
|
const minResetPasswordLen = 8
|
||||||
|
|
||||||
|
// runCLISubcommand dispatches local admin subcommands.
|
||||||
|
//
|
||||||
|
// SECURITY: account recovery (reset-password, reset-account) is intentionally
|
||||||
|
// NOT exposed over HTTP. Performing it requires running this binary on the host,
|
||||||
|
// which in turn requires shell/file access to the server. A remote attacker on a
|
||||||
|
// public-facing deployment has only the network — they can reach the API but not
|
||||||
|
// a local process — so recovery cannot be triggered remotely. This is what makes
|
||||||
|
// the recovery path safe even when NOFX is deployed on the public internet.
|
||||||
|
//
|
||||||
|
// Returns true if a subcommand was recognized and handled (caller should exit).
|
||||||
|
// Unknown first args fall through to false to preserve the historical behavior
|
||||||
|
// where `nofx <dbpath>` overrides the SQLite path.
|
||||||
|
func runCLISubcommand(args []string) bool {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "reset-password":
|
||||||
|
runResetPassword(args[1:])
|
||||||
|
return true
|
||||||
|
case "reset-account":
|
||||||
|
runResetAccount(args[1:])
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// openStoreForCLI loads config + encryption and opens the same database the
|
||||||
|
// server uses, so subcommands operate on the live data.
|
||||||
|
func openStoreForCLI(dbPathOverride string) (*store.Store, error) {
|
||||||
|
_ = godotenv.Load()
|
||||||
|
logger.Init(nil)
|
||||||
|
config.MustInit()
|
||||||
|
cfg := config.Get()
|
||||||
|
if strings.TrimSpace(dbPathOverride) != "" {
|
||||||
|
cfg.DBPath = dbPathOverride
|
||||||
|
}
|
||||||
|
|
||||||
|
cryptoService, err := crypto.NewCryptoService()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("initialize encryption service: %w", err)
|
||||||
|
}
|
||||||
|
crypto.SetGlobalCryptoService(cryptoService)
|
||||||
|
|
||||||
|
if cfg.DBType == "sqlite" {
|
||||||
|
if dir := filepath.Dir(cfg.DBPath); dir != "." {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("create data directory: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dbType := store.DBTypeSQLite
|
||||||
|
if cfg.DBType == "postgres" {
|
||||||
|
dbType = store.DBTypePostgres
|
||||||
|
}
|
||||||
|
return store.NewWithConfig(store.DBConfig{
|
||||||
|
Type: dbType,
|
||||||
|
Path: cfg.DBPath,
|
||||||
|
Host: cfg.DBHost,
|
||||||
|
Port: cfg.DBPort,
|
||||||
|
User: cfg.DBUser,
|
||||||
|
Password: cfg.DBPassword,
|
||||||
|
DBName: cfg.DBName,
|
||||||
|
SSLMode: cfg.DBSSLMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// runResetPassword resets the password for a single account from the command
|
||||||
|
// line. Usage: `nofx reset-password --email you@example.com`.
|
||||||
|
func runResetPassword(args []string) {
|
||||||
|
fs := flag.NewFlagSet("reset-password", flag.ExitOnError)
|
||||||
|
email := fs.String("email", "", "email of the account to reset (required)")
|
||||||
|
password := fs.String("password", "", "new password (min 8 chars); omit to enter it interactively")
|
||||||
|
dbPath := fs.String("db", "", "override SQLite DB path (defaults to config / DB_PATH)")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
if strings.TrimSpace(*email) == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "error: --email is required")
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: nofx reset-password --email you@example.com")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := openStoreForCLI(*dbPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
user, err := st.User().GetByEmail(strings.TrimSpace(*email))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: no account found for %q\n", strings.TrimSpace(*email))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
newPassword, err := resolveNewPassword(*password)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := auth.HashPassword(newPassword)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: failed to hash password: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := st.User().UpdatePassword(user.ID, hash); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: failed to update password: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("✓ Password reset for %s. Log in with the new password.\n", user.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runResetAccount wipes the database back to an uninitialized state. This is the
|
||||||
|
// destructive "forgot everything" recovery, moved off the public API.
|
||||||
|
func runResetAccount(args []string) {
|
||||||
|
fs := flag.NewFlagSet("reset-account", flag.ExitOnError)
|
||||||
|
dbPath := fs.String("db", "", "override SQLite DB path (defaults to config / DB_PATH)")
|
||||||
|
yes := fs.Bool("yes", false, "skip the interactive confirmation prompt")
|
||||||
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
|
if !*yes {
|
||||||
|
fmt.Print("This permanently deletes ALL users, traders, strategies, AI models and\n" +
|
||||||
|
"exchanges — including wallet keys and exchange credentials.\n" +
|
||||||
|
"Type 'wipe' to confirm: ")
|
||||||
|
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||||
|
if strings.TrimSpace(line) != "wipe" {
|
||||||
|
fmt.Fprintln(os.Stderr, "aborted")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := openStoreForCLI(*dbPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
err = st.Transaction(func(tx *gorm.DB) error {
|
||||||
|
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Trader{})
|
||||||
|
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Strategy{})
|
||||||
|
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.AIModel{})
|
||||||
|
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Exchange{})
|
||||||
|
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.User{}).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to delete users: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: failed to reset account: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("✓ System wiped. Register a fresh account and re-import everything.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveNewPassword returns the new password from the --password flag, or
|
||||||
|
// prompts for it (hidden) on a TTY, or reads a single line from piped stdin.
|
||||||
|
func resolveNewPassword(flagValue string) (string, error) {
|
||||||
|
if flagValue != "" {
|
||||||
|
if len(flagValue) < minResetPasswordLen {
|
||||||
|
return "", fmt.Errorf("password must be at least %d characters", minResetPasswordLen)
|
||||||
|
}
|
||||||
|
return flagValue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if term.IsTerminal(int(os.Stdin.Fd())) {
|
||||||
|
fmt.Printf("New password (min %d chars): ", minResetPasswordLen)
|
||||||
|
first, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
|
fmt.Println()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read password: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Print("Confirm new password: ")
|
||||||
|
second, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
|
fmt.Println()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read password: %w", err)
|
||||||
|
}
|
||||||
|
if string(first) != string(second) {
|
||||||
|
return "", errors.New("passwords do not match")
|
||||||
|
}
|
||||||
|
if len(first) < minResetPasswordLen {
|
||||||
|
return "", fmt.Errorf("password must be at least %d characters", minResetPasswordLen)
|
||||||
|
}
|
||||||
|
return string(first), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||||
|
password := strings.TrimRight(line, "\r\n")
|
||||||
|
if password == "" {
|
||||||
|
return "", fmt.Errorf("no password provided on stdin: %w", err)
|
||||||
|
}
|
||||||
|
if len(password) < minResetPasswordLen {
|
||||||
|
return "", fmt.Errorf("password must be at least %d characters", minResetPasswordLen)
|
||||||
|
}
|
||||||
|
return password, nil
|
||||||
|
}
|
||||||
1
go.mod
1
go.mod
@@ -96,6 +96,7 @@ require (
|
|||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
|
golang.org/x/term v0.43.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
howett.net/plist v1.0.1 // indirect
|
howett.net/plist v1.0.1 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -251,6 +251,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||||
|
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
|
|||||||
8
main.go
8
main.go
@@ -24,6 +24,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// Local admin subcommands (account recovery) run directly against the
|
||||||
|
// database and never start the HTTP server. Recovery therefore requires
|
||||||
|
// shell/file access to the host instead of a network request, which keeps
|
||||||
|
// it safe even when NOFX is exposed to the public internet. See cli.go.
|
||||||
|
if runCLISubcommand(os.Args[1:]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Load .env environment variables
|
// Load .env environment variables
|
||||||
_ = godotenv.Load()
|
_ = godotenv.Load()
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ type allowedRoute struct {
|
|||||||
// - PUT /api/user/password (password takeover)
|
// - PUT /api/user/password (password takeover)
|
||||||
// - PUT /api/models (LLM API key + endpoint swap → exfil)
|
// - PUT /api/models (LLM API key + endpoint swap → exfil)
|
||||||
// - POST/PUT/DELETE /api/exchanges* (exchange credential swap → drain)
|
// - POST/PUT/DELETE /api/exchanges* (exchange credential swap → drain)
|
||||||
// - POST /api/reset-password, /api/reset-account (destructive)
|
// - account recovery (password reset / account wipe) is intentionally
|
||||||
|
// CLI-only (`nofx reset-password` / `nofx reset-account`) and has no
|
||||||
|
// HTTP endpoint, so the bot cannot reach it
|
||||||
// - POST /api/wallet/generate, /api/wallet/validate
|
// - POST /api/wallet/generate, /api/wallet/validate
|
||||||
// - POST /api/telegram/* (rebind bot)
|
// - POST /api/telegram/* (rebind bot)
|
||||||
var botAPIAllowlist = []allowedRoute{
|
var botAPIAllowlist = []allowedRoute{
|
||||||
@@ -197,4 +199,3 @@ func (t *apiCallTool) execute(req *apiRequest) string {
|
|||||||
}
|
}
|
||||||
return string(body)
|
return string(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useLanguage } from '../../contexts/LanguageContext'
|
|||||||
import { t } from '../../i18n/translations'
|
import { t } from '../../i18n/translations'
|
||||||
import { DeepVoidBackground } from '../common/DeepVoidBackground'
|
import { DeepVoidBackground } from '../common/DeepVoidBackground'
|
||||||
import { LanguageSwitcher } from '../common/LanguageSwitcher'
|
import { LanguageSwitcher } from '../common/LanguageSwitcher'
|
||||||
import { invalidateSystemConfig } from '../../lib/config'
|
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { language } = useLanguage()
|
const { language } = useLanguage()
|
||||||
@@ -38,31 +37,14 @@ export function LoginPage() {
|
|||||||
}
|
}
|
||||||
}, [language])
|
}, [language])
|
||||||
|
|
||||||
const handleResetAccount = async () => {
|
// Account wipe was removed from the public API (it was an unauthenticated
|
||||||
if (!window.confirm(t('forgotAccountConfirm', language))) return
|
// destructive endpoint). It now runs as a local CLI command on the server,
|
||||||
try {
|
// so we surface the instruction instead of calling an endpoint.
|
||||||
const res = await fetch('/api/reset-account', {
|
const handleResetAccount = () => {
|
||||||
method: 'POST',
|
toast(t('resetAccountCliIntro', language), {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
description: 'nofx reset-account',
|
||||||
body: JSON.stringify({
|
duration: 10000,
|
||||||
confirm: 'I_UNDERSTAND_THIS_DELETES_EVERYTHING',
|
})
|
||||||
}),
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
localStorage.removeItem('auth_token')
|
|
||||||
localStorage.removeItem('auth_user')
|
|
||||||
localStorage.removeItem('user_id')
|
|
||||||
sessionStorage.removeItem('from401')
|
|
||||||
invalidateSystemConfig()
|
|
||||||
toast.success(t('forgotAccountSuccess', language))
|
|
||||||
setTimeout(() => navigate('/setup'), 1500)
|
|
||||||
} else {
|
|
||||||
const data = await res.json()
|
|
||||||
toast.error(data.error || 'Reset failed')
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error('Network error')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
|
|||||||
@@ -1,57 +1,27 @@
|
|||||||
import React, { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../../contexts/AuthContext'
|
|
||||||
import { useLanguage } from '../../contexts/LanguageContext'
|
import { useLanguage } from '../../contexts/LanguageContext'
|
||||||
import { t } from '../../i18n/translations'
|
import { t } from '../../i18n/translations'
|
||||||
import { Header } from '../common/Header'
|
import { Header } from '../common/Header'
|
||||||
import { ArrowLeft, KeyRound, Eye, EyeOff } from 'lucide-react'
|
import { ArrowLeft, KeyRound, Copy, Check } from 'lucide-react'
|
||||||
import PasswordChecklist from 'react-password-checklist'
|
|
||||||
import { Input } from '../ui/input'
|
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
const RESET_PASSWORD_COMMAND = 'nofx reset-password --email you@example.com'
|
||||||
|
|
||||||
export function ResetPasswordPage() {
|
export function ResetPasswordPage() {
|
||||||
const { language } = useLanguage()
|
const { language } = useLanguage()
|
||||||
const { resetPassword } = useAuth()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [email, setEmail] = useState('')
|
const [copied, setCopied] = useState(false)
|
||||||
const [newPassword, setNewPassword] = useState('')
|
|
||||||
const [confirmPassword, setConfirmPassword] = useState('')
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
const [success, setSuccess] = useState(false)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
|
||||||
const [passwordValid, setPasswordValid] = useState(false)
|
|
||||||
|
|
||||||
const handleResetPassword = async (e: React.FormEvent) => {
|
const handleCopy = async () => {
|
||||||
e.preventDefault()
|
try {
|
||||||
setError('')
|
await navigator.clipboard.writeText(RESET_PASSWORD_COMMAND)
|
||||||
setSuccess(false)
|
setCopied(true)
|
||||||
|
toast.success(t('copy', language))
|
||||||
// 验证两次密码是否一致
|
setTimeout(() => setCopied(false), 2000)
|
||||||
if (newPassword !== confirmPassword) {
|
} catch {
|
||||||
setError(t('passwordMismatch', language))
|
toast.error(t('copy', language))
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
|
|
||||||
const result = await resetPassword(email, newPassword)
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
setSuccess(true)
|
|
||||||
toast.success(t('resetPasswordSuccess', language) || '重置成功')
|
|
||||||
// 3秒后跳转到登录页面
|
|
||||||
setTimeout(() => {
|
|
||||||
navigate('/login')
|
|
||||||
}, 3000)
|
|
||||||
} else {
|
|
||||||
const msg = result.message || t('resetPasswordFailed', language)
|
|
||||||
setError(msg)
|
|
||||||
toast.error(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -84,173 +54,51 @@ export function ResetPasswordPage() {
|
|||||||
<h1 className="text-2xl font-bold" style={{ color: '#EAECEF' }}>
|
<h1 className="text-2xl font-bold" style={{ color: '#EAECEF' }}>
|
||||||
{t('resetPasswordTitle', language)}
|
{t('resetPasswordTitle', language)}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm mt-2" style={{ color: '#848E9C' }}>
|
|
||||||
使用邮箱和新密码重置账户密码
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reset Password Form */}
|
{/* CLI recovery instructions */}
|
||||||
<div
|
<div
|
||||||
className="rounded-lg p-6"
|
className="rounded-lg p-6"
|
||||||
style={{ background: '#1E2329', border: '1px solid #2B3139' }}
|
style={{ background: '#1E2329', border: '1px solid #2B3139' }}
|
||||||
>
|
>
|
||||||
{success ? (
|
<p
|
||||||
<div className="text-center py-8">
|
className="text-sm leading-relaxed mb-4"
|
||||||
<div className="text-5xl mb-4">✅</div>
|
style={{ color: '#EAECEF' }}
|
||||||
<p
|
>
|
||||||
className="text-lg font-semibold mb-2"
|
{t('resetPasswordCliIntro', language)}
|
||||||
style={{ color: '#EAECEF' }}
|
</p>
|
||||||
>
|
|
||||||
{t('resetPasswordSuccess', language)}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm" style={{ color: '#848E9C' }}>
|
|
||||||
3秒后将自动跳转到登录页面...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={handleResetPassword} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
className="block text-sm font-semibold mb-2"
|
|
||||||
style={{ color: '#EAECEF' }}
|
|
||||||
>
|
|
||||||
{t('email', language)}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
placeholder={t('emailPlaceholder', language)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div
|
||||||
<label
|
className="flex items-center justify-between gap-3 rounded px-3 py-3 font-mono text-xs"
|
||||||
className="block text-sm font-semibold mb-2"
|
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||||
style={{ color: '#EAECEF' }}
|
>
|
||||||
>
|
<code
|
||||||
{t('newPassword', language)}
|
className="break-all"
|
||||||
</label>
|
style={{ color: '#F0B90B' }}
|
||||||
<div className="relative">
|
>
|
||||||
<Input
|
{RESET_PASSWORD_COMMAND}
|
||||||
type={showPassword ? 'text' : 'password'}
|
</code>
|
||||||
value={newPassword}
|
<button
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
type="button"
|
||||||
className="pr-10"
|
onClick={handleCopy}
|
||||||
placeholder={t('newPasswordPlaceholder', language)}
|
className="shrink-0 btn-icon"
|
||||||
required
|
style={{ color: '#848E9C' }}
|
||||||
/>
|
aria-label={t('copy', language)}
|
||||||
<button
|
>
|
||||||
type="button"
|
{copied ? (
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
<Check className="w-4 h-4" style={{ color: '#0ECB81' }} />
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
) : (
|
||||||
className="absolute inset-y-0 right-2 w-8 h-10 flex items-center justify-center btn-icon"
|
<Copy className="w-4 h-4" />
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
{showPassword ? (
|
|
||||||
<EyeOff className="w-5 h-5" />
|
|
||||||
) : (
|
|
||||||
<Eye className="w-5 h-5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
className="block text-sm font-semibold mb-2"
|
|
||||||
style={{ color: '#EAECEF' }}
|
|
||||||
>
|
|
||||||
{t('confirmPassword', language)}
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
type={showConfirmPassword ? 'text' : 'password'}
|
|
||||||
value={confirmPassword}
|
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
||||||
className="pr-10"
|
|
||||||
placeholder={t('confirmPasswordPlaceholder', language)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
onClick={() =>
|
|
||||||
setShowConfirmPassword(!showConfirmPassword)
|
|
||||||
}
|
|
||||||
className="absolute inset-y-0 right-2 w-8 h-10 flex items-center justify-center btn-icon"
|
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
{showConfirmPassword ? (
|
|
||||||
<EyeOff className="w-5 h-5" />
|
|
||||||
) : (
|
|
||||||
<Eye className="w-5 h-5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 密码强度检查(必须通过才允许提交) */}
|
|
||||||
<div
|
|
||||||
className="mt-1 text-xs"
|
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="mb-1"
|
|
||||||
style={{ color: 'var(--brand-light-gray)' }}
|
|
||||||
>
|
|
||||||
{t('passwordRequirements', language)}
|
|
||||||
</div>
|
|
||||||
<PasswordChecklist
|
|
||||||
rules={[
|
|
||||||
'minLength',
|
|
||||||
'capital',
|
|
||||||
'lowercase',
|
|
||||||
'number',
|
|
||||||
'specialChar',
|
|
||||||
'match',
|
|
||||||
]}
|
|
||||||
minLength={8}
|
|
||||||
value={newPassword}
|
|
||||||
valueAgain={confirmPassword}
|
|
||||||
messages={{
|
|
||||||
minLength: t('passwordRuleMinLength', language),
|
|
||||||
capital: t('passwordRuleUppercase', language),
|
|
||||||
lowercase: t('passwordRuleLowercase', language),
|
|
||||||
number: t('passwordRuleNumber', language),
|
|
||||||
specialChar: t('passwordRuleSpecial', language),
|
|
||||||
match: t('passwordRuleMatch', language),
|
|
||||||
}}
|
|
||||||
className="space-y-1"
|
|
||||||
onChange={(isValid) => setPasswordValid(isValid)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
className="text-sm px-3 py-2 rounded"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(246, 70, 93, 0.1)',
|
|
||||||
color: '#F6465D',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<p
|
||||||
type="submit"
|
className="text-xs leading-relaxed mt-4"
|
||||||
disabled={loading || !passwordValid}
|
style={{ color: '#848E9C' }}
|
||||||
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
|
>
|
||||||
style={{ background: '#F0B90B', color: '#000' }}
|
{t('resetPasswordCliSecurityNote', language)}
|
||||||
>
|
</p>
|
||||||
{loading
|
|
||||||
? t('loading', language)
|
|
||||||
: t('resetPasswordButton', language)}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,10 +33,6 @@ interface AuthContextType {
|
|||||||
betaCode?: string,
|
betaCode?: string,
|
||||||
mode?: UserMode
|
mode?: UserMode
|
||||||
) => Promise<{ success: boolean; message?: string }>
|
) => Promise<{ success: boolean; message?: string }>
|
||||||
resetPassword: (
|
|
||||||
email: string,
|
|
||||||
newPassword: string
|
|
||||||
) => Promise<{ success: boolean; message?: string }>
|
|
||||||
logout: () => void
|
logout: () => void
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
}
|
}
|
||||||
@@ -259,36 +255,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetPassword = async (email: string, newPassword: string) => {
|
// NOTE: in-browser password reset was removed. Recovery now runs as a local
|
||||||
try {
|
// CLI command on the server (`nofx reset-password`), so it cannot be triggered
|
||||||
const response = await fetch('/api/reset-password', {
|
// remotely. The reset-password page shows the operator how to run it.
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
email,
|
|
||||||
new_password: newPassword,
|
|
||||||
// Server-side guard against accidental/drive-by triggers.
|
|
||||||
// Phrase must match handler_user.go resetPasswordConfirmPhrase.
|
|
||||||
confirm: 'I_UNDERSTAND_THIS_RESETS_MY_PASSWORD',
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return { success: true, message: data.message }
|
|
||||||
} else {
|
|
||||||
return { success: false, message: data.error }
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: 'Password reset failed, please try again',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
const savedToken = localStorage.getItem('auth_token')
|
const savedToken = localStorage.getItem('auth_token')
|
||||||
@@ -316,7 +285,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
login,
|
login,
|
||||||
loginAdmin,
|
loginAdmin,
|
||||||
register,
|
register,
|
||||||
resetPassword,
|
|
||||||
logout,
|
logout,
|
||||||
isLoading,
|
isLoading,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -506,6 +506,12 @@ export const translations = {
|
|||||||
'Password reset successful! Please login with your new password',
|
'Password reset successful! Please login with your new password',
|
||||||
resetPasswordFailed: 'Password reset failed',
|
resetPasswordFailed: 'Password reset failed',
|
||||||
backToLogin: 'Back to Login',
|
backToLogin: 'Back to Login',
|
||||||
|
resetPasswordCliIntro:
|
||||||
|
'For security, password recovery is no longer available from the browser. Run this command on the server where NOFX is installed:',
|
||||||
|
resetPasswordCliSecurityNote:
|
||||||
|
'This requires shell access to the server, which keeps your account safe even when NOFX is exposed to the internet.',
|
||||||
|
resetAccountCliIntro:
|
||||||
|
'To wipe everything and start over, run this command on the server where NOFX is installed:',
|
||||||
copy: 'Copy',
|
copy: 'Copy',
|
||||||
loginSuccess: 'Login successful',
|
loginSuccess: 'Login successful',
|
||||||
registrationSuccess: 'Registration successful',
|
registrationSuccess: 'Registration successful',
|
||||||
@@ -1835,6 +1841,12 @@ export const translations = {
|
|||||||
resetPasswordSuccess: '密码重置成功!请使用新密码登录',
|
resetPasswordSuccess: '密码重置成功!请使用新密码登录',
|
||||||
resetPasswordFailed: '密码重置失败',
|
resetPasswordFailed: '密码重置失败',
|
||||||
backToLogin: '返回登录',
|
backToLogin: '返回登录',
|
||||||
|
resetPasswordCliIntro:
|
||||||
|
'出于安全考虑,密码找回不再通过浏览器进行。请在部署 NOFX 的服务器上运行以下命令:',
|
||||||
|
resetPasswordCliSecurityNote:
|
||||||
|
'该操作需要服务器的 shell 访问权限,因此即使 NOFX 暴露在公网上,你的账户依然安全。',
|
||||||
|
resetAccountCliIntro:
|
||||||
|
'如需清空所有数据并重新开始,请在部署 NOFX 的服务器上运行以下命令:',
|
||||||
copy: '复制',
|
copy: '复制',
|
||||||
loginSuccess: '登录成功',
|
loginSuccess: '登录成功',
|
||||||
registrationSuccess: '注册成功',
|
registrationSuccess: '注册成功',
|
||||||
@@ -3100,6 +3112,12 @@ export const translations = {
|
|||||||
resetPasswordSuccess: 'Kata sandi berhasil direset! Silakan masuk dengan kata sandi baru',
|
resetPasswordSuccess: 'Kata sandi berhasil direset! Silakan masuk dengan kata sandi baru',
|
||||||
resetPasswordFailed: 'Gagal mereset kata sandi',
|
resetPasswordFailed: 'Gagal mereset kata sandi',
|
||||||
backToLogin: 'Kembali ke Login',
|
backToLogin: 'Kembali ke Login',
|
||||||
|
resetPasswordCliIntro:
|
||||||
|
'Demi keamanan, pemulihan kata sandi tidak lagi tersedia dari browser. Jalankan perintah ini di server tempat NOFX dipasang:',
|
||||||
|
resetPasswordCliSecurityNote:
|
||||||
|
'Ini memerlukan akses shell ke server, sehingga akun Anda tetap aman bahkan saat NOFX terekspos ke internet.',
|
||||||
|
resetAccountCliIntro:
|
||||||
|
'Untuk menghapus semua data dan memulai dari awal, jalankan perintah ini di server tempat NOFX dipasang:',
|
||||||
copy: 'Salin',
|
copy: 'Salin',
|
||||||
loginSuccess: 'Berhasil masuk',
|
loginSuccess: 'Berhasil masuk',
|
||||||
registrationSuccess: 'Berhasil mendaftar',
|
registrationSuccess: 'Berhasil mendaftar',
|
||||||
|
|||||||
Reference in New Issue
Block a user