From 2e7e498cb3a3d3c33f5c37dea521ec5e0cfafb2d Mon Sep 17 00:00:00 2001 From: dou Date: Thu, 6 Aug 2026 15:50:16 +0800 Subject: [PATCH] feat: add admin user management and in-app system guide --- CLAUDE.md | 7 + backend/auth.go | 483 +++++++++++++- backend/auth_handler.go | 281 ++++++++ backend/auth_middleware.go | 35 +- backend/auth_test.go | 186 ++++++ backend/config.go | 32 +- backend/config.yaml | 4 + backend/go.mod | 1 + backend/go.sum | 3 + backend/main.go | 151 +---- backend/user_tables.go | 26 +- backend/user_tables_samples_test.go | 74 +++ docker-compose.yaml | 3 + web/package-lock.json | 10 + web/package.json | 1 + web/public/guide.html | 620 ++++++++++++++++++ .../samples/hk-company-financials-sample.xlsx | Bin 0 -> 7479 bytes web/public/samples/hk-stock-daily-sample.csv | 16 + web/src/App.css | 9 + web/src/App.tsx | 102 ++- web/src/api/auth.ts | 43 +- web/src/api/client.ts | 57 ++ web/src/api/conversations.ts | 13 +- web/src/api/userTables.ts | 15 +- web/src/api/users.ts | 45 ++ web/src/components/AnalysisPanel.tsx | 3 +- web/src/components/DataTable.tsx | 3 +- web/src/components/FeedbackButton.tsx | 3 +- web/src/components/GuidePanel.css | 88 +++ web/src/components/GuidePanel.tsx | 58 ++ web/src/components/KnowledgePanel.tsx | 9 +- web/src/components/LoginPage.css | 194 ++++++ web/src/components/LoginPage.tsx | 222 ++----- web/src/components/MetricExplanations.tsx | 3 +- web/src/components/TableSelector.tsx | 3 +- web/src/components/UserManagement.css | 203 ++++++ web/src/components/UserManagement.tsx | 368 +++++++++++ web/src/hooks/useExploreSSE.ts | 3 +- web/src/i18n/en.json | 59 +- web/src/i18n/zh.json | 59 +- 40 files changed, 3046 insertions(+), 449 deletions(-) create mode 100644 backend/auth_handler.go create mode 100644 backend/auth_test.go create mode 100644 backend/user_tables_samples_test.go create mode 100644 web/public/guide.html create mode 100644 web/public/samples/hk-company-financials-sample.xlsx create mode 100644 web/public/samples/hk-stock-daily-sample.csv create mode 100644 web/src/api/client.ts create mode 100644 web/src/api/users.ts create mode 100644 web/src/components/GuidePanel.css create mode 100644 web/src/components/GuidePanel.tsx create mode 100644 web/src/components/LoginPage.css create mode 100644 web/src/components/UserManagement.css create mode 100644 web/src/components/UserManagement.tsx diff --git a/CLAUDE.md b/CLAUDE.md index a735132..ea20512 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,13 @@ These are NOT in official docs — discovered through testing: - `POC_WORKSPACE_ID` — Workspace UUID - `DASHSCOPE_API_KEY` — Alibaba Cloud LLM API key (used by Catalog) +Optional authentication overrides: +- `ADMIN_USERNAME` — fixed administrator username (default: `admin`) +- `ADMIN_PASSWORD` — fixed administrator password (default: `User@123`; must contain a letter, number, and special character and be at least 8 characters) +- `MO_ACCOUNT_NAME` — optional local workspace account override when Catalog cannot resolve workspace metadata + +The administrator account is synchronized on every app startup. Public registration is disabled; ordinary users are created from the administrator-only User Management dialog. + ## Docker Services | Service | Internal Port | External Port | diff --git a/backend/auth.go b/backend/auth.go index 68a91d4..4e18d81 100644 --- a/backend/auth.go +++ b/backend/auth.go @@ -4,27 +4,94 @@ import ( "crypto/rand" "database/sql" "encoding/hex" + "errors" "fmt" "log" "net/http" "regexp" + "strings" "time" + "unicode/utf8" "golang.org/x/crypto/bcrypt" ) -var validUsername = regexp.MustCompile(`^[a-zA-Z0-9_]{3,32}$`) +var ( + validUsername = regexp.MustCompile(`^[a-zA-Z0-9_]{3,32}$`) + hasLetter = regexp.MustCompile(`[a-zA-Z]`) + hasNumber = regexp.MustCompile(`[0-9]`) + hasSpecial = regexp.MustCompile(`[^a-zA-Z0-9]`) + + ErrInvalidCredentials = errors.New("invalid username or password") + ErrInvalidSession = errors.New("invalid session") + ErrAccountDisabled = errors.New("account disabled") + ErrAccountExpired = errors.New("account expired") + ErrUsernameTaken = errors.New("username already taken") + ErrUserNotFound = errors.New("user not found") + ErrAdminFixed = errors.New("administrator account is fixed") +) + +var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60) + +type ValidationError struct { + Message string +} + +func (e *ValidationError) Error() string { + return e.Message +} + +func validationError(message string) error { + return &ValidationError{Message: message} +} + +type AuthUser struct { + ID string `json:"id"` + Username string `json:"username"` + IsAdmin bool `json:"is_admin"` + IsActive bool `json:"is_active"` + ExpiresAt *string `json:"expires_at"` + Remark string `json:"remark"` + CreatedAt string `json:"created_at"` +} + +type CreateUserInput struct { + Username string + Password string + IsActive bool + ExpiresAt *string + Remark string +} + +type UpdateUserInput struct { + Password *string + IsActive *bool + ExpiresAt *string + ExpiresAtSet bool + Remark *string +} type AuthService struct { db *sql.DB } -func NewAuthService(db *sql.DB) (*AuthService, error) { +func NewAuthService(db *sql.DB, cfg AuthConfig) (*AuthService, error) { + if err := validateUsername(cfg.AdminUsername); err != nil { + return nil, fmt.Errorf("invalid administrator username: %w", err) + } + if err := validatePassword(cfg.AdminPassword); err != nil { + return nil, fmt.Errorf("invalid administrator password: %w", err) + } + ddl := []string{ `CREATE TABLE IF NOT EXISTS poc_users ( id VARCHAR(64) PRIMARY KEY, username VARCHAR(32) UNIQUE NOT NULL, password VARCHAR(128) NOT NULL, + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + expires_at DATETIME NULL, + remark VARCHAR(255) NULL, created_at DATETIME DEFAULT NOW() )`, `CREATE TABLE IF NOT EXISTS poc_sessions ( @@ -38,65 +105,355 @@ func NewAuthService(db *sql.DB) (*AuthService, error) { return nil, fmt.Errorf("auth ddl: %w", err) } } + svc := &AuthService{db: db} + for _, column := range []struct { + name string + typeSQL string + }{ + {"is_admin", "BOOLEAN NOT NULL DEFAULT FALSE"}, + {"is_active", "BOOLEAN NOT NULL DEFAULT TRUE"}, + {"expires_at", "DATETIME NULL"}, + {"remark", "VARCHAR(255) NULL"}, + } { + if err := svc.ensureUserColumn(column.name, column.typeSQL); err != nil { + return nil, err + } + } + if err := svc.syncAdministrator(cfg.AdminUsername, cfg.AdminPassword); err != nil { + return nil, err + } + go svc.cleanupLoop() return svc, nil } -func (a *AuthService) Register(username, password string) error { - if !validUsername.MatchString(username) { - return fmt.Errorf("username must be 3-32 alphanumeric or underscore characters") +func (a *AuthService) ensureUserColumn(column, typeSQL string) error { + var count int + if err := a.db.QueryRow( + `SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'poc_users' AND column_name = ?`, + column, + ).Scan(&count); err != nil { + return fmt.Errorf("check poc_users.%s: %w", column, err) + } + if count > 0 { + return nil } - if len(password) < 6 { - return fmt.Errorf("password must be at least 6 characters") + stmt := fmt.Sprintf("ALTER TABLE poc_users ADD COLUMN %s %s", column, typeSQL) + log.Printf("[migration] %s", stmt) + if _, err := a.db.Exec(stmt); err != nil { + return fmt.Errorf("add poc_users.%s: %w", column, err) } + return nil +} + +func (a *AuthService) syncAdministrator(username, password string) error { hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { - return fmt.Errorf("hash password: %w", err) + return fmt.Errorf("hash administrator password: %w", err) } - id := newUUID() - _, err = a.db.Exec("INSERT INTO poc_users (id, username, password) VALUES (?, ?, ?)", id, username, string(hash)) + + tx, err := a.db.Begin() if err != nil { - return fmt.Errorf("username already taken") + return fmt.Errorf("begin administrator sync: %w", err) + } + defer tx.Rollback() + + if _, err := tx.Exec(`UPDATE poc_users SET is_admin = FALSE WHERE username <> ? AND is_admin = TRUE`, username); err != nil { + return fmt.Errorf("demote old administrators: %w", err) + } + + var id string + err = tx.QueryRow(`SELECT id FROM poc_users WHERE username = ?`, username).Scan(&id) + switch { + case err == sql.ErrNoRows: + id = newUUID() + if _, err := tx.Exec( + `INSERT INTO poc_users (id, username, password, is_admin, is_active, expires_at, remark) VALUES (?, ?, ?, TRUE, TRUE, NULL, NULL)`, + id, username, string(hash), + ); err != nil { + return fmt.Errorf("create administrator: %w", err) + } + case err != nil: + return fmt.Errorf("find administrator: %w", err) + default: + if _, err := tx.Exec( + `UPDATE poc_users SET password = ?, is_admin = TRUE, is_active = TRUE, expires_at = NULL WHERE id = ?`, + string(hash), id, + ); err != nil { + return fmt.Errorf("update administrator: %w", err) + } + } + + // The configured password is authoritative after every restart. + if _, err := tx.Exec(`DELETE FROM poc_sessions WHERE user_id = ?`, id); err != nil { + return fmt.Errorf("revoke administrator sessions: %w", err) } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit administrator sync: %w", err) + } + log.Printf("administrator account synchronized: %s", username) return nil } -func (a *AuthService) Login(username, password string) (string, error) { - var id, hash string - err := a.db.QueryRow("SELECT id, password FROM poc_users WHERE username = ?", username).Scan(&id, &hash) +func validateUsername(username string) error { + if !validUsername.MatchString(strings.TrimSpace(username)) { + return validationError("username must be 3-32 letters, numbers, or underscore characters") + } + return nil +} + +func validatePassword(password string) error { + switch { + case len(password) < 8: + return validationError("password must be at least 8 characters") + case !hasLetter.MatchString(password): + return validationError("password must contain a letter") + case !hasNumber.MatchString(password): + return validationError("password must contain a number") + case !hasSpecial.MatchString(password): + return validationError("password must contain a special character") + default: + return nil + } +} + +func normalizeRemark(remark string) (string, error) { + remark = strings.TrimSpace(remark) + if utf8.RuneCountInString(remark) > 255 { + return "", validationError("remark must not exceed 255 characters") + } + return remark, nil +} + +func normalizeExpiresAt(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", nil + } + layouts := []string{time.RFC3339, "2006-01-02T15:04:05", "2006-01-02"} + for _, layout := range layouts { + var parsed time.Time + var err error + if layout == time.RFC3339 { + parsed, err = time.Parse(layout, value) + if err == nil { + parsed = parsed.In(shanghaiLocation) + } + } else { + parsed, err = time.ParseInLocation(layout, value, shanghaiLocation) + } + if err != nil { + continue + } + if layout == "2006-01-02" { + parsed = parsed.Add(23*time.Hour + 59*time.Minute + 59*time.Second) + } + return parsed.Format("2006-01-02 15:04:05"), nil + } + return "", validationError("invalid expiry date") +} + +func (a *AuthService) Login(username, password string) (string, *AuthUser, error) { + username = strings.TrimSpace(username) + var hash string + user, err := scanAuthUserWithPassword(a.db.QueryRow(userSelectWithPasswordSQL+` WHERE username = ?`, username), &hash) if err != nil { - return "", fmt.Errorf("invalid username or password") + if err == sql.ErrNoRows { + return "", nil, ErrInvalidCredentials + } + return "", nil, fmt.Errorf("query user: %w", err) } if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil { - return "", fmt.Errorf("invalid username or password") + return "", nil, ErrInvalidCredentials } + if err := accountAvailability(user); err != nil { + return "", nil, err + } + var tokenBytes [32]byte if _, err := rand.Read(tokenBytes[:]); err != nil { - return "", fmt.Errorf("generate token: %w", err) + return "", nil, fmt.Errorf("generate token: %w", err) } token := hex.EncodeToString(tokenBytes[:]) expiresAt := time.Now().Add(7 * 24 * time.Hour) - _, err = a.db.Exec("INSERT INTO poc_sessions (token, user_id, expires_at) VALUES (?, ?, ?)", token, id, expiresAt) + if _, err := a.db.Exec( + `INSERT INTO poc_sessions (token, user_id, expires_at) VALUES (?, ?, ?)`, + token, user.ID, expiresAt, + ); err != nil { + return "", nil, fmt.Errorf("create session: %w", err) + } + return token, user, nil +} + +func (a *AuthService) ValidateSession(token string) (*AuthUser, error) { + user, err := scanAuthUser(a.db.QueryRow( + `SELECT u.id, u.username, u.is_admin, u.is_active, u.expires_at, u.remark, u.created_at + FROM poc_sessions s JOIN poc_users u ON u.id = s.user_id + WHERE s.token = ? AND s.expires_at > NOW()`, + token, + )) + if err != nil { + if err == sql.ErrNoRows { + return nil, ErrInvalidSession + } + return nil, fmt.Errorf("validate session: %w", err) + } + if err := accountAvailability(user); err != nil { + return nil, err + } + return user, nil +} + +func (a *AuthService) ListUsers() ([]AuthUser, error) { + rows, err := a.db.Query(userSelectSQL + ` ORDER BY is_admin DESC, created_at ASC`) if err != nil { - return "", fmt.Errorf("create session: %w", err) + return nil, fmt.Errorf("list users: %w", err) + } + defer rows.Close() + + users := make([]AuthUser, 0) + for rows.Next() { + user, err := scanAuthUser(rows) + if err != nil { + return nil, fmt.Errorf("scan user: %w", err) + } + users = append(users, *user) } - return token, nil + return users, rows.Err() } -func (a *AuthService) ValidateSession(token string) (string, bool) { - var userID string - err := a.db.QueryRow("SELECT user_id FROM poc_sessions WHERE token = ? AND expires_at > NOW()", token).Scan(&userID) +func (a *AuthService) GetUser(id string) (*AuthUser, error) { + user, err := scanAuthUser(a.db.QueryRow(userSelectSQL+` WHERE id = ?`, id)) + if err == sql.ErrNoRows { + return nil, ErrUserNotFound + } + if err != nil { + return nil, fmt.Errorf("get user: %w", err) + } + return user, nil +} + +func (a *AuthService) CreateUser(input CreateUserInput) (*AuthUser, error) { + input.Username = strings.TrimSpace(input.Username) + if err := validateUsername(input.Username); err != nil { + return nil, err + } + if err := validatePassword(input.Password); err != nil { + return nil, err + } + remark, err := normalizeRemark(input.Remark) + if err != nil { + return nil, err + } + var expires any + if input.ExpiresAt != nil { + normalized, err := normalizeExpiresAt(*input.ExpiresAt) + if err != nil { + return nil, err + } + if normalized != "" { + expires = normalized + } + } + hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) if err != nil { - return "", false + return nil, fmt.Errorf("hash password: %w", err) + } + var existing int + if err := a.db.QueryRow(`SELECT COUNT(*) FROM poc_users WHERE username = ?`, input.Username).Scan(&existing); err != nil { + return nil, fmt.Errorf("check username: %w", err) + } + if existing > 0 { + return nil, ErrUsernameTaken } - return userID, true + + id := newUUID() + if _, err := a.db.Exec( + `INSERT INTO poc_users (id, username, password, is_admin, is_active, expires_at, remark) VALUES (?, ?, ?, FALSE, ?, ?, ?)`, + id, input.Username, string(hash), input.IsActive, expires, nullableString(remark), + ); err != nil { + return nil, fmt.Errorf("create user: %w", err) + } + return a.GetUser(id) } -func (a *AuthService) GetUsername(userID string) (string, error) { - var username string - err := a.db.QueryRow("SELECT username FROM poc_users WHERE id = ?", userID).Scan(&username) - return username, err +func (a *AuthService) UpdateUser(id string, input UpdateUserInput) (*AuthUser, error) { + current, err := a.GetUser(id) + if err != nil { + return nil, err + } + if current.IsAdmin { + return nil, ErrAdminFixed + } + + setParts := make([]string, 0, 4) + args := make([]any, 0, 5) + revokeSessions := false + if input.Password != nil { + if err := validatePassword(*input.Password); err != nil { + return nil, err + } + hash, err := bcrypt.GenerateFromPassword([]byte(*input.Password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("hash password: %w", err) + } + setParts = append(setParts, "password = ?") + args = append(args, string(hash)) + revokeSessions = true + } + if input.IsActive != nil { + setParts = append(setParts, "is_active = ?") + args = append(args, *input.IsActive) + if !*input.IsActive { + revokeSessions = true + } + } + if input.ExpiresAtSet { + setParts = append(setParts, "expires_at = ?") + if input.ExpiresAt == nil || strings.TrimSpace(*input.ExpiresAt) == "" { + args = append(args, nil) + } else { + normalized, err := normalizeExpiresAt(*input.ExpiresAt) + if err != nil { + return nil, err + } + args = append(args, normalized) + if isExpiredValue(normalized) { + revokeSessions = true + } + } + } + if input.Remark != nil { + remark, err := normalizeRemark(*input.Remark) + if err != nil { + return nil, err + } + setParts = append(setParts, "remark = ?") + args = append(args, nullableString(remark)) + } + if len(setParts) == 0 { + return nil, validationError("no supported fields to update") + } + + tx, err := a.db.Begin() + if err != nil { + return nil, fmt.Errorf("begin user update: %w", err) + } + defer tx.Rollback() + args = append(args, id) + if _, err := tx.Exec(`UPDATE poc_users SET `+strings.Join(setParts, ", ")+` WHERE id = ?`, args...); err != nil { + return nil, fmt.Errorf("update user: %w", err) + } + if revokeSessions { + if _, err := tx.Exec(`DELETE FROM poc_sessions WHERE user_id = ?`, id); err != nil { + return nil, fmt.Errorf("revoke user sessions: %w", err) + } + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit user update: %w", err) + } + return a.GetUser(id) } func (a *AuthService) Logout(token string) { @@ -118,6 +475,74 @@ func (a *AuthService) cleanupLoop() { } } +const userSelectSQL = `SELECT id, username, is_admin, is_active, expires_at, remark, created_at FROM poc_users` +const userSelectWithPasswordSQL = `SELECT id, username, is_admin, is_active, expires_at, remark, created_at, password FROM poc_users` + +type rowScanner interface { + Scan(dest ...any) error +} + +func scanAuthUser(scanner rowScanner) (*AuthUser, error) { + var user AuthUser + var expiresAt, remark sql.NullString + if err := scanner.Scan( + &user.ID, &user.Username, &user.IsAdmin, &user.IsActive, + &expiresAt, &remark, &user.CreatedAt, + ); err != nil { + return nil, err + } + user.ExpiresAt = jsonDateTime(expiresAt) + user.Remark = remark.String + user.CreatedAt = strings.Replace(user.CreatedAt, " ", "T", 1) + return &user, nil +} + +func scanAuthUserWithPassword(scanner rowScanner, password *string) (*AuthUser, error) { + var user AuthUser + var expiresAt, remark sql.NullString + if err := scanner.Scan( + &user.ID, &user.Username, &user.IsAdmin, &user.IsActive, + &expiresAt, &remark, &user.CreatedAt, password, + ); err != nil { + return nil, err + } + user.ExpiresAt = jsonDateTime(expiresAt) + user.Remark = remark.String + user.CreatedAt = strings.Replace(user.CreatedAt, " ", "T", 1) + return &user, nil +} + +func jsonDateTime(value sql.NullString) *string { + if !value.Valid || value.String == "" { + return nil + } + formatted := strings.Replace(value.String, " ", "T", 1) + return &formatted +} + +func accountAvailability(user *AuthUser) error { + if !user.IsActive { + return ErrAccountDisabled + } + if user.ExpiresAt != nil && isExpiredValue(*user.ExpiresAt) { + return ErrAccountExpired + } + return nil +} + +func isExpiredValue(value string) bool { + value = strings.Replace(value, "T", " ", 1) + parsed, err := time.ParseInLocation("2006-01-02 15:04:05", value, shanghaiLocation) + return err == nil && parsed.Before(time.Now().In(shanghaiLocation)) +} + +func nullableString(value string) any { + if value == "" { + return nil + } + return value +} + const cookieName = "poc_token" const cookieMaxAge = 7 * 24 * 60 * 60 diff --git a/backend/auth_handler.go b/backend/auth_handler.go new file mode 100644 index 0000000..09694d0 --- /dev/null +++ b/backend/auth_handler.go @@ -0,0 +1,281 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "strings" +) + +type AuthHandler struct { + auth *AuthService +} + +func NewAuthHandler(auth *AuthService) *AuthHandler { + return &AuthHandler{auth: auth} +} + +func (h *AuthHandler) RegisterDisabled(w http.ResponseWriter, r *http.Request) { + setCORSHeaders(w) + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + writeJSON(w, http.StatusForbidden, map[string]string{"error": "registration is disabled; contact an administrator"}) +} + +func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { + setCORSHeaders(w) + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := decodeJSON(w, r, &req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + if strings.TrimSpace(req.Username) == "" || req.Password == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username and password are required"}) + return + } + token, user, err := h.auth.Login(req.Username, req.Password) + if err != nil { + writeAuthError(w, err, true) + return + } + setTokenCookie(w, token) + writeJSON(w, http.StatusOK, user) +} + +func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { + setCORSHeaders(w) + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + if cookie, err := r.Cookie(cookieName); err == nil { + h.auth.Logout(cookie.Value) + } + clearTokenCookie(w) + w.WriteHeader(http.StatusNoContent) +} + +func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) { + setCORSHeaders(w) + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + cookie, err := r.Cookie(cookieName) + if err != nil || cookie.Value == "" { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return + } + user, err := h.auth.ValidateSession(cookie.Value) + if err != nil { + clearTokenCookie(w) + writeAuthError(w, err, false) + return + } + writeJSON(w, http.StatusOK, user) +} + +func (h *AuthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + setCORSHeaders(w) + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + current, ok := UserFromContext(r.Context()) + if !ok || !current.IsAdmin { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "administrator permission required"}) + return + } + + path := strings.TrimPrefix(r.URL.Path, "/api/users") + if path == "" || path == "/" { + switch r.Method { + case http.MethodGet: + h.listUsers(w) + case http.MethodPost: + h.createUser(w, r) + default: + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + } + return + } + + id := strings.Trim(path, "/") + if id == "" || strings.Contains(id, "/") { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "user not found"}) + return + } + if r.Method != http.MethodPatch { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + h.updateUser(w, r, id) +} + +func (h *AuthHandler) listUsers(w http.ResponseWriter) { + users, err := h.auth.ListUsers() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load users"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"users": users}) +} + +func (h *AuthHandler) createUser(w http.ResponseWriter, r *http.Request) { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + IsActive *bool `json:"is_active"` + ExpiresAt *string `json:"expires_at"` + Remark string `json:"remark"` + } + if err := decodeJSON(w, r, &req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + isActive := true + if req.IsActive != nil { + isActive = *req.IsActive + } + user, err := h.auth.CreateUser(CreateUserInput{ + Username: req.Username, Password: req.Password, IsActive: isActive, + ExpiresAt: req.ExpiresAt, Remark: req.Remark, + }) + if err != nil { + writeAuthError(w, err, false) + return + } + writeJSON(w, http.StatusCreated, map[string]any{"user": user}) +} + +func (h *AuthHandler) updateUser(w http.ResponseWriter, r *http.Request, id string) { + var raw map[string]json.RawMessage + if err := decodeJSON(w, r, &raw); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + for field := range raw { + switch field { + case "password", "is_active", "expires_at", "remark": + default: + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unsupported field: " + field}) + return + } + } + var input UpdateUserInput + if value, ok := raw["password"]; ok { + var password string + if err := json.Unmarshal(value, &password); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "password must be a string"}) + return + } + input.Password = &password + } + if value, ok := raw["is_active"]; ok { + var active bool + if err := json.Unmarshal(value, &active); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "is_active must be a boolean"}) + return + } + input.IsActive = &active + } + if value, ok := raw["expires_at"]; ok { + input.ExpiresAtSet = true + if !bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + var expiresAt string + if err := json.Unmarshal(value, &expiresAt); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "expires_at must be a date string or null"}) + return + } + input.ExpiresAt = &expiresAt + } + } + if value, ok := raw["remark"]; ok { + var remark string + if err := json.Unmarshal(value, &remark); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "remark must be a string"}) + return + } + input.Remark = &remark + } + + user, err := h.auth.UpdateUser(id, input) + if err != nil { + writeAuthError(w, err, false) + return + } + writeJSON(w, http.StatusOK, map[string]any{"user": user}) +} + +func decodeJSON(w http.ResponseWriter, r *http.Request, target any) error { + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + return decoder.Decode(target) +} + +func writeAuthError(w http.ResponseWriter, err error, login bool) { + status := http.StatusInternalServerError + message := "internal server error" + var validationErr *ValidationError + switch { + case errors.As(err, &validationErr): + status = http.StatusBadRequest + message = validationErr.Message + case errors.Is(err, ErrInvalidCredentials): + status = http.StatusUnauthorized + message = "invalid username or password" + case errors.Is(err, ErrInvalidSession): + status = http.StatusUnauthorized + message = "login expired; please log in again" + case errors.Is(err, ErrAccountDisabled): + if login { + status = http.StatusForbidden + } else { + status = http.StatusUnauthorized + } + message = "account has been disabled; contact an administrator" + case errors.Is(err, ErrAccountExpired): + if login { + status = http.StatusForbidden + } else { + status = http.StatusUnauthorized + } + message = "account has expired; contact an administrator" + case errors.Is(err, ErrUsernameTaken): + status = http.StatusConflict + message = "username already exists" + case errors.Is(err, ErrUserNotFound): + status = http.StatusNotFound + message = "user not found" + case errors.Is(err, ErrAdminFixed): + message = "administrator account is fixed and cannot be modified" + } + writeJSON(w, status, map[string]string{"error": message}) +} diff --git a/backend/auth_middleware.go b/backend/auth_middleware.go index 6a00387..246a8dc 100644 --- a/backend/auth_middleware.go +++ b/backend/auth_middleware.go @@ -2,18 +2,22 @@ package main import ( "context" - "encoding/json" "net/http" "strings" ) type contextKey string -const userIDKey contextKey = "user_id" +const authUserKey contextKey = "auth_user" + +func UserFromContext(ctx context.Context) (*AuthUser, bool) { + user, ok := ctx.Value(authUserKey).(*AuthUser) + return user, ok +} func UserIDFromContext(ctx context.Context) string { - if v, ok := ctx.Value(userIDKey).(string); ok { - return v + if user, ok := UserFromContext(ctx); ok { + return user.ID } return "" } @@ -26,33 +30,24 @@ func authMiddleware(auth *AuthService, next http.Handler) http.Handler { next.ServeHTTP(w, r) return } - if !strings.HasPrefix(path, "/api/") { - next.ServeHTTP(w, r) - return - } - if r.Method == http.MethodOptions { + if !strings.HasPrefix(path, "/api/") || r.Method == http.MethodOptions { next.ServeHTTP(w, r) return } cookie, err := r.Cookie(cookieName) if err != nil || cookie.Value == "" { - writeUnauthorized(w) + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "please log in"}) return } - userID, ok := auth.ValidateSession(cookie.Value) - if !ok { - writeUnauthorized(w) + user, err := auth.ValidateSession(cookie.Value) + if err != nil { + clearTokenCookie(w) + writeAuthError(w, err, false) return } - ctx := context.WithValue(r.Context(), userIDKey, userID) + ctx := context.WithValue(r.Context(), authUserKey, user) next.ServeHTTP(w, r.WithContext(ctx)) }) } - -func writeUnauthorized(w http.ResponseWriter) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusUnauthorized) - _ = json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) -} diff --git a/backend/auth_test.go b/backend/auth_test.go new file mode 100644 index 0000000..e61f203 --- /dev/null +++ b/backend/auth_test.go @@ -0,0 +1,186 @@ +package main + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "golang.org/x/crypto/bcrypt" +) + +func TestValidateCredentials(t *testing.T) { + for _, username := range []string{"abc", "user_01", "ADMIN"} { + if err := validateUsername(username); err != nil { + t.Fatalf("expected valid username %q: %v", username, err) + } + } + for _, username := range []string{"ab", "has-dash", "has space", ""} { + if err := validateUsername(username); err == nil { + t.Fatalf("expected invalid username %q", username) + } + } + + if err := validatePassword("Good@123"); err != nil { + t.Fatalf("expected valid password: %v", err) + } + for _, password := range []string{"Short1!", "onlyletters!", "12345678!", "Letters123"} { + if err := validatePassword(password); err == nil { + t.Fatalf("expected invalid password %q", password) + } + } +} + +func TestEnsureUserColumnIsIdempotent(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + svc := &AuthService{db: db} + + checkQuery := regexp.QuoteMeta(`SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'poc_users' AND column_name = ?`) + mock.ExpectQuery(checkQuery).WithArgs("is_admin").WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + if err := svc.ensureUserColumn("is_admin", "BOOLEAN NOT NULL DEFAULT FALSE"); err != nil { + t.Fatal(err) + } + + mock.ExpectQuery(checkQuery).WithArgs("remark").WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + mock.ExpectExec(regexp.QuoteMeta("ALTER TABLE poc_users ADD COLUMN remark VARCHAR(255) NULL")).WillReturnResult(sqlmock.NewResult(0, 0)) + if err := svc.ensureUserColumn("remark", "VARCHAR(255) NULL"); err != nil { + t.Fatal(err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestSyncAdministratorCreatesThenUpdates(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + svc := &AuthService{db: db} + + mock.ExpectBegin() + mock.ExpectExec(regexp.QuoteMeta(`UPDATE poc_users SET is_admin = FALSE WHERE username <> ? AND is_admin = TRUE`)).WithArgs("admin").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT id FROM poc_users WHERE username = ?`)).WithArgs("admin").WillReturnError(sqlmock.ErrCancelled) + if err := svc.syncAdministrator("admin", "User@123"); err == nil { + t.Fatal("expected query failure to be returned") + } + + // Fresh service expectations for the create path. + db2, mock2, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db2.Close() + svc2 := &AuthService{db: db2} + mock2.ExpectBegin() + mock2.ExpectExec(regexp.QuoteMeta(`UPDATE poc_users SET is_admin = FALSE WHERE username <> ? AND is_admin = TRUE`)).WithArgs("admin").WillReturnResult(sqlmock.NewResult(0, 1)) + mock2.ExpectQuery(regexp.QuoteMeta(`SELECT id FROM poc_users WHERE username = ?`)).WithArgs("admin").WillReturnRows(sqlmock.NewRows([]string{"id"})) + mock2.ExpectExec(regexp.QuoteMeta(`INSERT INTO poc_users (id, username, password, is_admin, is_active, expires_at, remark) VALUES (?, ?, ?, TRUE, TRUE, NULL, NULL)`)).WithArgs(sqlmock.AnyArg(), "admin", sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1)) + mock2.ExpectExec(regexp.QuoteMeta(`DELETE FROM poc_sessions WHERE user_id = ?`)).WithArgs(sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 0)) + mock2.ExpectCommit() + if err := svc2.syncAdministrator("admin", "User@123"); err != nil { + t.Fatal(err) + } + + mock2.ExpectBegin() + mock2.ExpectExec(regexp.QuoteMeta(`UPDATE poc_users SET is_admin = FALSE WHERE username <> ? AND is_admin = TRUE`)).WithArgs("admin").WillReturnResult(sqlmock.NewResult(0, 0)) + mock2.ExpectQuery(regexp.QuoteMeta(`SELECT id FROM poc_users WHERE username = ?`)).WithArgs("admin").WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("admin-id")) + mock2.ExpectExec(regexp.QuoteMeta(`UPDATE poc_users SET password = ?, is_admin = TRUE, is_active = TRUE, expires_at = NULL WHERE id = ?`)).WithArgs(sqlmock.AnyArg(), "admin-id").WillReturnResult(sqlmock.NewResult(0, 1)) + mock2.ExpectExec(regexp.QuoteMeta(`DELETE FROM poc_sessions WHERE user_id = ?`)).WithArgs("admin-id").WillReturnResult(sqlmock.NewResult(0, 1)) + mock2.ExpectCommit() + if err := svc2.syncAdministrator("admin", "User@123"); err != nil { + t.Fatal(err) + } + if err := mock2.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestLoginRejectsUnavailableAccounts(t *testing.T) { + hash, err := bcrypt.GenerateFromPassword([]byte("User@123"), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + columns := []string{"id", "username", "is_admin", "is_active", "expires_at", "remark", "created_at", "password"} + + for _, tc := range []struct { + name string + active bool + expiresAt any + wantErr error + }{ + {"disabled", false, nil, ErrAccountDisabled}, + {"expired", true, "2020-01-01 23:59:59", ErrAccountExpired}, + } { + t.Run(tc.name, func(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + svc := &AuthService{db: db} + mock.ExpectQuery(regexp.QuoteMeta(userSelectWithPasswordSQL + ` WHERE username = ?`)).WithArgs("user_01").WillReturnRows( + sqlmock.NewRows(columns).AddRow("id-1", "user_01", false, tc.active, tc.expiresAt, nil, "2026-01-01 10:00:00", string(hash)), + ) + _, _, gotErr := svc.Login("user_01", "User@123") + if !errors.Is(gotErr, tc.wantErr) { + t.Fatalf("got %v, want %v", gotErr, tc.wantErr) + } + }) + } +} + +func TestUpdatePasswordRevokesSessions(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + svc := &AuthService{db: db} + columns := []string{"id", "username", "is_admin", "is_active", "expires_at", "remark", "created_at"} + userRow := func() *sqlmock.Rows { + return sqlmock.NewRows(columns).AddRow("user-id", "user_01", false, true, nil, "team", "2026-01-01 10:00:00") + } + mock.ExpectQuery(regexp.QuoteMeta(userSelectSQL + ` WHERE id = ?`)).WithArgs("user-id").WillReturnRows(userRow()) + mock.ExpectBegin() + mock.ExpectExec(regexp.QuoteMeta(`UPDATE poc_users SET password = ? WHERE id = ?`)).WithArgs(sqlmock.AnyArg(), "user-id").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM poc_sessions WHERE user_id = ?`)).WithArgs("user-id").WillReturnResult(sqlmock.NewResult(0, 2)) + mock.ExpectCommit() + mock.ExpectQuery(regexp.QuoteMeta(userSelectSQL + ` WHERE id = ?`)).WithArgs("user-id").WillReturnRows(userRow()) + password := "New@1234" + if _, err := svc.UpdateUser("user-id", UpdateUserInput{Password: &password}); err != nil { + t.Fatal(err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestOrdinaryUserCannotAccessUserManagement(t *testing.T) { + handler := NewAuthHandler(&AuthService{}) + request := httptest.NewRequest(http.MethodGet, "/api/users", nil) + request = request.WithContext(context.WithValue(request.Context(), authUserKey, &AuthUser{ID: "user-id", IsAdmin: false})) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusForbidden { + t.Fatalf("got status %d, want %d", recorder.Code, http.StatusForbidden) + } +} + +func TestRegistrationIsDisabled(t *testing.T) { + handler := NewAuthHandler(&AuthService{}) + request := httptest.NewRequest(http.MethodPost, "/api/auth/register", nil) + recorder := httptest.NewRecorder() + handler.RegisterDisabled(recorder, request) + if recorder.Code != http.StatusForbidden { + t.Fatalf("got status %d, want %d", recorder.Code, http.StatusForbidden) + } +} diff --git a/backend/config.go b/backend/config.go index 3fa6445..782324c 100644 --- a/backend/config.go +++ b/backend/config.go @@ -12,6 +12,12 @@ type Config struct { Catalog CatalogConfig `yaml:"catalog"` Explore ExploreConfig `yaml:"explore"` Jobs JobsConfig `yaml:"jobs"` + Auth AuthConfig `yaml:"auth"` +} + +type AuthConfig struct { + AdminUsername string `yaml:"admin_username"` + AdminPassword string `yaml:"admin_password"` } type JobsConfig struct { @@ -19,10 +25,10 @@ type JobsConfig struct { } type CCASSSyncConfig struct { - Enabled bool `yaml:"enabled"` // 是否启用定时爬取 - Schedule string `yaml:"schedule"` // 每天执行时间,如 "20:00" - Top int `yaml:"top"` // 爬取前 N 只股票,0 = 全量 - Script string `yaml:"script"` // 脚本路径,默认 scripts/cron_ccass.sh + Enabled bool `yaml:"enabled"` // 是否启用定时爬取 + Schedule string `yaml:"schedule"` // 每天执行时间,如 "20:00" + Top int `yaml:"top"` // 爬取前 N 只股票,0 = 全量 + Script string `yaml:"script"` // 脚本路径,默认 scripts/cron_ccass.sh } type ServerConfig struct { @@ -37,12 +43,12 @@ type CatalogConfig struct { } type ExploreConfig struct { - DBName string `yaml:"db_name"` - Tables []string `yaml:"tables"` - PlanningMode string `yaml:"planning_mode"` - Verbose string `yaml:"verbose"` - LLMModel string `yaml:"llm_model"` - KnowledgeBaseID int64 `yaml:"knowledge_base_id"` + DBName string `yaml:"db_name"` + Tables []string `yaml:"tables"` + PlanningMode string `yaml:"planning_mode"` + Verbose string `yaml:"verbose"` + LLMModel string `yaml:"llm_model"` + KnowledgeBaseID int64 `yaml:"knowledge_base_id"` } func LoadConfig(path string) (*Config, error) { @@ -64,6 +70,12 @@ func LoadConfig(path string) (*Config, error) { if cfg.Catalog.URL == "" { cfg.Catalog.URL = "http://localhost:8084" } + if cfg.Auth.AdminUsername == "" { + cfg.Auth.AdminUsername = "admin" + } + if cfg.Auth.AdminPassword == "" { + cfg.Auth.AdminPassword = "User@123" + } return &cfg, nil } diff --git a/backend/config.yaml b/backend/config.yaml index 4bb6997..68e9eda 100644 --- a/backend/config.yaml +++ b/backend/config.yaml @@ -7,6 +7,10 @@ catalog: api_key: "${MOI_SYSTEM_API_KEY}" workspace_id: "${POC_WORKSPACE_ID}" +auth: + admin_username: "${ADMIN_USERNAME}" + admin_password: "${ADMIN_PASSWORD}" + jobs: ccass: enabled: true # 设为 true 启用 CCASS 定时爬取 diff --git a/backend/go.mod b/backend/go.mod index 242ed55..c69adaa 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -12,6 +12,7 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect + github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect github.com/richardlehane/mscfb v1.0.6 // indirect github.com/richardlehane/msoleps v1.0.6 // indirect github.com/tiendc/go-deepcopy v1.7.2 // indirect diff --git a/backend/go.sum b/backend/go.sum index 72b7b73..6263c10 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,9 +1,12 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8= diff --git a/backend/main.go b/backend/main.go index 7e6ac92..752244c 100644 --- a/backend/main.go +++ b/backend/main.go @@ -48,7 +48,7 @@ func main() { log.Fatalf("init conversations db: %v", err) } - authSvc, err := NewAuthService(feedbackDB.RawDB()) + authSvc, err := NewAuthService(feedbackDB.RawDB(), cfg.Auth) if err != nil { log.Fatalf("init auth service: %v", err) } @@ -64,108 +64,13 @@ func main() { mux := http.NewServeMux() - mux.HandleFunc("/api/auth/register", func(w http.ResponseWriter, r *http.Request) { - setCORSHeaders(w) - if r.Method == http.MethodOptions { - w.WriteHeader(http.StatusNoContent) - return - } - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - var req struct { - Username string `json:"username"` - Password string `json:"password"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) - return - } - if err := authSvc.Register(req.Username, req.Password); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - token, err := authSvc.Login(req.Username, req.Password) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "registered but login failed"}) - return - } - setTokenCookie(w, token) - writeJSON(w, http.StatusCreated, map[string]string{"username": req.Username}) - }) - - mux.HandleFunc("/api/auth/login", func(w http.ResponseWriter, r *http.Request) { - setCORSHeaders(w) - if r.Method == http.MethodOptions { - w.WriteHeader(http.StatusNoContent) - return - } - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - var req struct { - Username string `json:"username"` - Password string `json:"password"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) - return - } - token, err := authSvc.Login(req.Username, req.Password) - if err != nil { - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()}) - return - } - setTokenCookie(w, token) - writeJSON(w, http.StatusOK, map[string]string{"username": req.Username}) - }) - - mux.HandleFunc("/api/auth/logout", func(w http.ResponseWriter, r *http.Request) { - setCORSHeaders(w) - if r.Method == http.MethodOptions { - w.WriteHeader(http.StatusNoContent) - return - } - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - if cookie, err := r.Cookie(cookieName); err == nil { - authSvc.Logout(cookie.Value) - } - clearTokenCookie(w) - w.WriteHeader(http.StatusNoContent) - }) - - mux.HandleFunc("/api/auth/me", func(w http.ResponseWriter, r *http.Request) { - setCORSHeaders(w) - if r.Method == http.MethodOptions { - w.WriteHeader(http.StatusNoContent) - return - } - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - cookie, err := r.Cookie(cookieName) - if err != nil || cookie.Value == "" { - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) - return - } - userID, ok := authSvc.ValidateSession(cookie.Value) - if !ok { - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) - return - } - username, err := authSvc.GetUsername(userID) - if err != nil { - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) - return - } - writeJSON(w, http.StatusOK, map[string]string{"username": username}) - }) + authHandler := NewAuthHandler(authSvc) + mux.HandleFunc("/api/auth/register", authHandler.RegisterDisabled) + mux.HandleFunc("/api/auth/login", authHandler.Login) + mux.HandleFunc("/api/auth/logout", authHandler.Logout) + mux.HandleFunc("/api/auth/me", authHandler.Me) + mux.Handle("/api/users", authHandler) + mux.Handle("/api/users/", authHandler) // Dynamic /api/tables: system tables + user-uploaded tables systemTableList := []map[string]string{ @@ -274,27 +179,29 @@ func buildMOConfig(cfg *Config) *mysql.Config { moPort = "6001" } - // Get account_name from Catalog workspace API - url := fmt.Sprintf("%s/api/v1/workspaces/%s", cfg.Catalog.URL, cfg.Catalog.WorkspaceID) - req, _ := http.NewRequest("GET", url, nil) - req.Header.Set("X-API-Key", cfg.Catalog.APIKey) - resp, err := http.DefaultClient.Do(req) - if err != nil { - log.Fatalf("get workspace account: %v", err) - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) + acct := strings.TrimSpace(os.Getenv("MO_ACCOUNT_NAME")) + if acct == "" { + // Resolve account_name from Catalog unless local deployment provides it directly. + url := fmt.Sprintf("%s/api/v1/workspaces/%s", cfg.Catalog.URL, cfg.Catalog.WorkspaceID) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("X-API-Key", cfg.Catalog.APIKey) + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Fatalf("get workspace account: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) - var wsResp struct { - Data struct { - AccountName string `json:"account_name"` - } `json:"data"` - } - if err := json.Unmarshal(body, &wsResp); err != nil || wsResp.Data.AccountName == "" { - log.Fatalf("parse workspace account: %v, body: %s", err, body) + var wsResp struct { + Data struct { + AccountName string `json:"account_name"` + } `json:"data"` + } + if err := json.Unmarshal(body, &wsResp); err != nil || wsResp.Data.AccountName == "" { + log.Fatalf("parse workspace account: %v, body: %s", err, body) + } + acct = wsResp.Data.AccountName } - - acct := wsResp.Data.AccountName user := acct + ":moi_core_system" pass := cfg.Catalog.APIKey dbName := cfg.Explore.DBName diff --git a/backend/user_tables.go b/backend/user_tables.go index 8ee2192..94a2130 100644 --- a/backend/user_tables.go +++ b/backend/user_tables.go @@ -68,18 +68,20 @@ type DataPreviewResult struct { var validTableName = regexp.MustCompile(`^[a-z0-9_]{1,64}$`) var systemTables = map[string]bool{ - "ms_t_stk_hsi": true, - "ms_v_stk_hsi_daily": true, - "ms_t_stk_sis": true, - "ms_v_stock_capital": true, - "ds_t_int_hsicl_dtl": true, - "sehknews": true, - "profit_loss": true, - "ccass_holdings": true, - "poc_user_tables": true, - "feedback_tasks": true, - "conversations": true, - "messages": true, + "ms_t_stk_hsi": true, + "ms_v_stk_hsi_daily": true, + "ms_t_stk_sis": true, + "ms_v_stock_capital": true, + "ds_t_int_hsicl_dtl": true, + "sehknews": true, + "profit_loss": true, + "ccass_holdings": true, + "poc_user_tables": true, + "feedback_tasks": true, + "conversations": true, + "messages": true, + "poc_users": true, + "poc_sessions": true, } type tempFileEntry struct { diff --git a/backend/user_tables_samples_test.go b/backend/user_tables_samples_test.go new file mode 100644 index 0000000..ac23c0e --- /dev/null +++ b/backend/user_tables_samples_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "path/filepath" + "reflect" + "testing" +) + +func TestGuideSampleFilesAreImportable(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + file string + sheet string + rows int + columns []string + columnType []string + }{ + { + name: "daily stock CSV", + file: "hk-stock-daily-sample.csv", + sheet: "hk-stock-daily-sample.csv", + rows: 15, + columns: []string{ + "trade_date", "stock_code", "stock_name", "close_price_hkd", "daily_change_pct", "volume", + }, + columnType: []string{ + "DATE", "VARCHAR(255)", "VARCHAR(255)", "DECIMAL(18,6)", "DECIMAL(18,6)", "BIGINT", + }, + }, + { + name: "quarterly financial XLSX", + file: "hk-company-financials-sample.xlsx", + sheet: "Financials", + rows: 12, + columns: []string{ + "report_date", "stock_code", "company_name", "industry_name", "revenue_hkd_mn", "gross_profit_hkd_mn", "net_profit_hkd_mn", "eps_hkd", + }, + columnType: []string{ + "DATE", "VARCHAR(255)", "VARCHAR(255)", "VARCHAR(255)", "DECIMAL(18,6)", "DECIMAL(18,6)", "DECIMAL(18,6)", "DECIMAL(18,6)", + }, + }, + } + + service := &UserTableService{} + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + preview, err := service.PreviewFile(filepath.Join("..", "web", "public", "samples", tc.file)) + if err != nil { + t.Fatalf("preview sample: %v", err) + } + if preview.SheetName != tc.sheet { + t.Fatalf("sheet name = %q, want %q", preview.SheetName, tc.sheet) + } + if preview.TotalRows != tc.rows { + t.Fatalf("row count = %d, want %d", preview.TotalRows, tc.rows) + } + + columns := make([]string, len(preview.Columns)) + columnTypes := make([]string, len(preview.Columns)) + for i, column := range preview.Columns { + columns[i] = column.Name + columnTypes[i] = column.InferredType + } + if !reflect.DeepEqual(columns, tc.columns) { + t.Fatalf("columns = %#v, want %#v", columns, tc.columns) + } + if !reflect.DeepEqual(columnTypes, tc.columnType) { + t.Fatalf("column types = %#v, want %#v", columnTypes, tc.columnType) + } + }) + } +} diff --git a/docker-compose.yaml b/docker-compose.yaml index e55b229..9f87580 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -75,9 +75,12 @@ services: environment: MOI_SYSTEM_API_KEY: ${MOI_SYSTEM_API_KEY:-} POC_WORKSPACE_ID: ${POC_WORKSPACE_ID:-} + MO_ACCOUNT_NAME: ${MO_ACCOUNT_NAME:-} CATALOG_URL: http://catalog:8081 STATIC_DIR: /app/web/dist SERVER_PORT: "3000" + ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-User@123} volumes: - ./logs:/app/logs command: sh -c './hk-poc-backend -config config.yaml 2>&1 | tee -a /app/logs/app.log' diff --git a/web/package-lock.json b/web/package-lock.json index 4651d86..f9cd336 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,6 +11,7 @@ "echarts": "^6.0.0", "echarts-for-react": "^3.0.6", "katex": "^0.16.40", + "lucide-react": "^0.468.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-markdown": "^10.1.0", @@ -2882,6 +2883,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmmirror.com/markdown-table/-/markdown-table-3.0.4.tgz", diff --git a/web/package.json b/web/package.json index 3049a86..e7a1bb2 100644 --- a/web/package.json +++ b/web/package.json @@ -15,6 +15,7 @@ "echarts": "^6.0.0", "echarts-for-react": "^3.0.6", "katex": "^0.16.40", + "lucide-react": "^0.468.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-markdown": "^10.1.0", diff --git a/web/public/guide.html b/web/public/guide.html new file mode 100644 index 0000000..cd27b79 --- /dev/null +++ b/web/public/guide.html @@ -0,0 +1,620 @@ + + + + + + 使用说明 · 香港市场数据探索 + + + + + +
+
+
+ + +
+
+ +
+ + +
+

1功能概览

+

系统面向香港证券市场数据分析场景,支持用自然语言查询恒生指数、个股行情、市值与行业分类、新闻公告、财务报表和 CCASS 持仓。系统自动生成 SQL、执行查询,并以表格、图表和文字说明呈现结果。

+

管理员和普通用户共用登录入口。普通用户可以使用全部分析功能;用户管理仅管理员可见、可操作。

+
+ +
+

2核心功能

+
+
💬 自然语言查询
直接描述分析目标,支持流式回答、追问和上下文会话。
+
🗂️ 数据源选择
按问题选择系统数据表或个人上传的数据表,缩小分析范围。
+
📊 表格与图表
查看查询结果、分页数据,并切换折线、柱状、饼图等展示方式。
+
🧮 计算链路
查看关键字段口径、SQL 与 Python 脚本,并下载一键复现包。
+
📁 自定义数据表
上传 CSV 或 Excel,预览字段、确认类型和备注后创建个人数据表。
+
🧠 知识库
维护业务逻辑、术语表、案例库和展示规则,辅助 SQL 生成。
+
🔎 反馈与分析中心
对不准确结果提交反馈,集中查看问题、建议和修正 SQL。
+
👤 用户与会话
保存并切换历史会话;管理员可创建、启停和维护普通用户。
+
+
+ +
+

3快速上手

+
    +
  1. 登录系统
    使用管理员提供的用户名和密码登录。账户被禁用或超过有效期后将无法继续使用。
  2. +
  3. 新建对话
    点击左侧 + 新对话 返回欢迎页。发送第一条问题后系统会自动保存会话,可在左侧列表切换或删除。
  4. +
  5. 选择数据表
    在输入框上方选择与问题相关的数据表;不确定时可以不选,由系统结合已有数据判断。
  6. +
  7. 输入分析问题
    尽量写明时间范围、股票代码、比较对象和期望展示方式,例如“展示 2025 年 1 月恒生指数走势,用折线图表示”。
  8. +
  9. 查看并调整结果
    查看文字结论、SQL、数据表和图表。可修改图表类型、维度、指标、排序和 Top N,再继续追问。
  10. +
  11. 复现或反馈
    展开计算链路查看口径和脚本;结果不符合预期时点击“结果不准确?”,随后在“分析中心”查看处理结果。
  12. +
+
💡 提示:问题越具体,生成结果越稳定。涉及个股时建议使用五位股票代码,并明确自然日或报告期。
+
+ +
+

4导入自定义数据与样例文件

+
    +
  1. 下载样例文件
    选择下方 CSV 日行情样例或 XLSX 季度财务样例。两份文件均为模拟数据,仅用于功能演示。
  2. +
  3. 上传并预览
    进入 数据表管理,点击 上传 Excel,拖入文件或点击选择。支持 .csv.xls.xlsx,最大 300 MB;Excel 仅读取第一个工作表。
  4. +
  5. 确认表与字段
    表名只能包含小写字母、数字和下划线。建议在默认名称后追加用户名或用途以保持唯一,并检查系统推断的 VARCHARBIGINTDECIMALDATE 等类型。
  6. +
  7. 建表并查询
    补充表注释和字段注释后确认建表。回到对话区选择新表,再输入下载卡片中的示例问题。
  8. +
+ +
+ +
+ 香港股票日行情样例(CSV) + 3 家模拟公司、5 个交易日,共 15 行;包含日期、代码、名称、收盘价、涨跌幅和成交量。 + 建议表名:hk_stock_daily_sample_用户名 +
示例问题:比较三家公司 5 个交易日的平均涨跌幅,并用柱状图展示。
+
+ ⬇ 下载 CSV +
+ +
+ +
+ 香港公司季度财务样例(XLSX) + 3 家模拟公司、4 个季度,共 12 行;包含报告日期、行业、营收、毛利、净利润和每股收益。 + 建议表名:hk_company_financials_sample_用户名 +
示例问题:计算各公司全年净利率并按从高到低排序。
+
+ ⬇ 下载 XLSX +
+ +
⚠️ 数据声明:样例中的公司、代码和数值均为虚构,不代表真实证券行情、公司披露或投资建议。
+
+ +
+

5管理员操作

+
    +
  1. 打开用户管理
    管理员登录后点击顶部 用户管理;普通用户不会看到该入口,也无法调用相关接口。
  2. +
  3. 创建普通用户
    填写用户名、初始密码、有效期和备注。密码至少 8 位,且同时包含字母、数字和特殊字符。
  4. +
  5. 维护账户
    可启用或禁用账户、调整有效期并更新备注。有效期留空表示长期有效,选定日期当天结束前可用。
  6. +
  7. 重置密码
    为普通用户设置符合策略的新密码。禁用、过期或重置密码后,该用户的已有会话会立即失效。
  8. +
+
管理员账户由系统配置固定维护,不能在用户管理界面修改或删除。
+
+ +
+

6常见问题

+

Q1:查询结果不准确或没有数据怎么办?

+

检查时间范围、股票代码和所选数据表是否正确;尝试把复杂问题拆成两步。仍有问题时提交反馈,并在分析中心查看建议和修正 SQL。

+

Q2:为什么上传后的列类型不合适?

+

系统根据文件内容自动推断类型。请在确认建表前检查每一列,标识符应使用 VARCHAR,金额和比例可使用 DECIMAL,日期使用 DATEDATETIME

+

Q3:谁能看到我上传的数据表?

+

数据表列表按当前账户隔离。为避免物理表名冲突,请使用带用户名或用途后缀的唯一表名,不要直接复用他人的表名。

+

Q4:普通用户和管理员有什么区别?

+

普通用户可以使用对话、数据表、图表、知识库、分析中心和复现功能,唯独不能创建或维护其他用户。

+
+ +
+
本产品由矩阵起源(MatrixOrigin)提供技术支持
+ 返回系统 → +
+
+ +
+ + +
+

1Overview

+

The system is designed for Hong Kong securities market analysis. Ask natural-language questions about the Hang Seng Index, stock trading, market capitalization, industry classifications, announcements, financial statements, and CCASS holdings. The system generates SQL, runs the query, and presents the result as text, tables, and charts.

+

Administrators and regular users share one sign-in page. Regular users can use every analysis feature; user management is available only to administrators.

+
+ +
+

2Core features

+
+
💬 Natural-language queries
Describe an analysis directly, receive streaming answers, and ask follow-up questions.
+
🗂️ Data source selection
Select system tables or your uploaded tables to focus the analysis.
+
📊 Tables and charts
Inspect paginated results and switch between line, bar, pie, and other charts.
+
🧮 Calculation lineage
Review metric definitions, SQL, and Python scripts, or download a reproduction package.
+
📁 Custom data tables
Upload CSV or Excel, preview columns, confirm types and descriptions, then create a table.
+
🧠 Knowledge base
Maintain business logic, glossaries, examples, and presentation rules for SQL generation.
+
🔎 Feedback and analysis
Report inaccurate results and review detected issues, suggestions, and corrected SQL.
+
👤 Users and conversations
Resume saved conversations; administrators can create and maintain regular users.
+
+
+ +
+

3Quick start

+
    +
  1. Sign in
    Use the username and password supplied by your administrator. Disabled or expired accounts cannot continue using the system.
  2. +
  3. Start a conversation
    Select + New Chat in the sidebar. A conversation is saved after you send its first question and can later be selected or deleted.
  4. +
  5. Select data tables
    Choose relevant tables above the prompt. If you are unsure, leave the selection empty and let the system consider the available data.
  6. +
  7. Ask a specific question
    Include the date range, stock code, comparison group, and desired presentation, such as “Show the Hang Seng Index trend for January 2025 as a line chart.”
  8. +
  9. Review and refine
    Inspect the narrative, SQL, data table, and chart. Adjust the chart type, dimensions, metrics, sorting, or Top N, then ask a follow-up.
  10. +
  11. Reproduce or report
    Expand the calculation lineage to inspect methodology and scripts. For an unexpected result, select “Inaccurate result?” and review the task in Analysis Center.
  12. +
+
💡 Tip: Specific questions produce more stable results. For stocks, use five-digit codes and state whether dates refer to trading days or reporting periods.
+
+ +
+

4Import custom data and samples

+
    +
  1. Download a sample file
    Choose the daily stock CSV or quarterly financial XLSX below. Both contain synthetic data for feature demonstrations only.
  2. +
  3. Upload and preview
    Open Table Management, select Upload Excel, then drop or choose a file. The system accepts .csv, .xls, and .xlsx up to 300 MB; only the first Excel worksheet is read.
  4. +
  5. Confirm the table and columns
    Table names may contain only lowercase letters, numbers, and underscores. Add your username or purpose to the default name to keep it unique, and verify inferred types such as VARCHAR, BIGINT, DECIMAL, and DATE.
  6. +
  7. Create and query
    Add table and column descriptions, then create the table. Return to the conversation, select the new table, and try the prompt shown on its download card.
  8. +
+ +
+ +
+ Hong Kong daily stock sample (CSV) + 15 rows covering three fictional companies and five trading days, with dates, codes, names, closing prices, daily changes, and volume. + Suggested table name: hk_stock_daily_sample_username +
Try: Compare the average daily change of the three companies and display it as a bar chart.
+
+ ⬇ Download CSV +
+ +
+ +
+ Hong Kong quarterly financial sample (XLSX) + 12 rows covering three fictional companies and four quarters, with report dates, industries, revenue, gross profit, net profit, and EPS. + Suggested table name: hk_company_financials_sample_username +
Try: Calculate each company’s full-year net margin and rank the companies from highest to lowest.
+
+ ⬇ Download XLSX +
+ +
⚠️ Data notice: All companies, codes, and values in the samples are fictional. They do not represent real market data, company disclosures, or investment advice.
+
+ +
+

5Administrator tasks

+
    +
  1. Open User Management
    After signing in as an administrator, select User Management in the header. Regular users neither see the entry nor have API access.
  2. +
  3. Create a regular user
    Enter a username, initial password, expiry date, and optional note. Passwords require at least eight characters with a letter, number, and special character.
  4. +
  5. Maintain an account
    Enable or disable the account, change its expiry, and update its note. A blank expiry means no expiry; a selected date remains valid through the end of that day.
  6. +
  7. Reset a password
    Set a new policy-compliant password. Disabling, expiry, or password reset immediately invalidates the user’s existing sessions.
  8. +
+
The administrator account is fixed by system configuration and cannot be edited or deleted in User Management.
+
+ +
+

6Frequently asked questions

+

Q1: What should I do when a query is inaccurate or returns no data?

+

Check the date range, stock code, and selected tables, then try splitting a complex request into smaller questions. If the issue persists, submit feedback and review the suggestion and corrected SQL in Analysis Center.

+

Q2: Why is an uploaded column assigned the wrong type?

+

The system infers types from file contents. Before creating the table, use VARCHAR for identifiers, DECIMAL for amounts or rates, and DATE or DATETIME for dates.

+

Q3: Who can see my uploaded tables?

+

The table list is scoped to the current account. To avoid physical table-name conflicts, use a unique name with your username or purpose and do not reuse another user’s table name.

+

Q4: What is the difference between a regular user and an administrator?

+

Regular users can use conversations, custom tables, charts, the knowledge base, Analysis Center, and reproduction features. Only user creation and maintenance are unavailable.

+
+ + +
+
+ + + + diff --git a/web/public/samples/hk-company-financials-sample.xlsx b/web/public/samples/hk-company-financials-sample.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..03c26f886d582a84a1369d10d07f7638b7253e08 GIT binary patch literal 7479 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 X?6p+UZi9-1gnfI3-r`2VPoMoiDpUe$ literal 0 HcmV?d00001 diff --git a/web/public/samples/hk-stock-daily-sample.csv b/web/public/samples/hk-stock-daily-sample.csv new file mode 100644 index 0000000..74fa46b --- /dev/null +++ b/web/public/samples/hk-stock-daily-sample.csv @@ -0,0 +1,16 @@ +trade_date,stock_code,stock_name,close_price_hkd,daily_change_pct,volume +2025-01-02,DEMO001,Harbour Technology,42.80,1.42,1285000 +2025-01-03,DEMO001,Harbour Technology,43.15,0.82,1362000 +2025-01-06,DEMO001,Harbour Technology,42.70,-1.04,1198000 +2025-01-07,DEMO001,Harbour Technology,44.05,3.16,1624000 +2025-01-08,DEMO001,Harbour Technology,44.60,1.25,1489000 +2025-01-02,DEMO002,Pearl Financial,28.35,-0.53,896000 +2025-01-03,DEMO002,Pearl Financial,28.90,1.94,1025000 +2025-01-06,DEMO002,Pearl Financial,29.10,0.69,984000 +2025-01-07,DEMO002,Pearl Financial,28.75,-1.20,1107000 +2025-01-08,DEMO002,Pearl Financial,29.40,2.26,1215000 +2025-01-02,DEMO003,Victoria Energy,17.90,0.28,1765000 +2025-01-03,DEMO003,Victoria Energy,17.65,-1.40,1689000 +2025-01-06,DEMO003,Victoria Energy,18.20,3.12,2043000 +2025-01-07,DEMO003,Victoria Energy,18.55,1.92,1986000 +2025-01-08,DEMO003,Victoria Energy,18.30,-1.35,1814000 diff --git a/web/src/App.css b/web/src/App.css index c2aa2cb..54b98b5 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -81,6 +81,15 @@ body { white-space: nowrap; } +.header-username { + max-width: 140px; + overflow: hidden; + color: #d0d5dd; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + /* App body (sidebar + main) */ .app-body { display: flex; diff --git a/web/src/App.tsx b/web/src/App.tsx index 8848c39..b159431 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -8,7 +8,10 @@ import { KnowledgePanel } from './components/KnowledgePanel' import { AnalysisPanel } from './components/AnalysisPanel' import { UserTablePanel } from './components/UserTablePanel' import { LoginPage } from './components/LoginPage' -import { getMe, logout } from './api/auth' +import { UserManagement } from './components/UserManagement' +import { GuidePanel } from './components/GuidePanel' +import { getMe, logout, type AuthUser } from './api/auth' +import { setUnauthorizedHandler } from './api/client' import { listConversations, createConversation, @@ -17,11 +20,6 @@ import { import './App.css' const LANG_STORAGE_KEY = 'hk-poc.lang' -const USER_MANUAL_URLS: Record = { - zh: '/docs/hk-market-data-explorer-user-manual-zh.pdf', - en: '/docs/hk-market-data-explorer-user-manual-en.pdf', -} - function initialLang(): Language { try { const saved = localStorage.getItem(LANG_STORAGE_KEY) @@ -42,31 +40,50 @@ function App() { try { localStorage.setItem(LANG_STORAGE_KEY, lang) } catch { /* ignore */ } }, [lang]) - const [user, setUser] = useState(null) + const [user, setUser] = useState(null) const [authChecked, setAuthChecked] = useState(false) - - useEffect(() => { - getMe().then(u => { - if (u) setUser(u.username) - setAuthChecked(true) - }) - }, []) - - const handleLogout = useCallback(async () => { - await logout() - setUser(null) - setConversations([]) - setActiveId(null) - }, []) - + const [authMessage, setAuthMessage] = useState('') const [conversations, setConversations] = useState([]) const [activeId, setActiveId] = useState(null) const [sidebarCollapsed, setSidebarCollapsed] = useState(false) const [knowledgeOpen, setKnowledgeOpen] = useState(false) const [analysisOpen, setAnalysisOpen] = useState(false) const [tableManageOpen, setTableManageOpen] = useState(false) + const [userManagementOpen, setUserManagementOpen] = useState(false) + const [guideOpen, setGuideOpen] = useState(false) const [tableRefreshKey, setTableRefreshKey] = useState(0) + const clearUserState = useCallback((message = '') => { + setUser(null) + setAuthMessage(message) + setConversations([]) + setActiveId(null) + setKnowledgeOpen(false) + setAnalysisOpen(false) + setTableManageOpen(false) + setUserManagementOpen(false) + setGuideOpen(false) + }, [setActiveId, setAnalysisOpen, setAuthMessage, setConversations, setGuideOpen, setKnowledgeOpen, setTableManageOpen, setUser, setUserManagementOpen]) + + useEffect(() => { + setUnauthorizedHandler((message) => clearUserState(message)) + return () => setUnauthorizedHandler(null) + }, [clearUserState]) + + useEffect(() => { + getMe() + .then((currentUser) => { + if (currentUser) setUser(currentUser) + }) + .catch((err) => setAuthMessage(err instanceof Error ? err.message : '')) + .finally(() => setAuthChecked(true)) + }, []) + + const handleLogout = useCallback(async () => { + await logout() + clearUserState() + }, [clearUserState]) + useEffect(() => { if (user) { listConversations() @@ -128,7 +145,13 @@ function App() { if (!user) { return ( - setUser(username)} /> + { + setAuthMessage('') + setUser(loggedInUser) + }} + /> ) } @@ -151,17 +174,22 @@ function App() {
+ {user.is_admin ? ( + + ) : null} - setGuideOpen(true)} + aria-label={t('openGuide')} + title={t('openGuide')} > - {t('userManual')} - - {user} + {t('guide')} + + {user.username}
@@ -209,6 +237,10 @@ function App() { setKnowledgeOpen(false)} /> setAnalysisOpen(false)} /> setTableManageOpen(false)} onTablesChanged={() => setTableRefreshKey(k => k + 1)} /> + setGuideOpen(false)} /> + {userManagementOpen && user.is_admin ? ( + setUserManagementOpen(false)} /> + ) : null} ) } diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index 0ce70f3..7e6aa51 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -1,45 +1,36 @@ +import { ApiError, apiFetch, parseJSON, responseError } from './client' + const BASE = '/api/auth' export interface AuthUser { + id: string username: string -} - -async function parseJSON(resp: Response): Promise { - const data = await resp.json() - if (!resp.ok) { - throw new Error(data.error || `HTTP ${resp.status}`) - } - return data as T + is_admin: boolean + is_active: boolean + expires_at: string | null + remark: string + created_at: string } export async function login(username: string, password: string): Promise { - const resp = await fetch(`${BASE}/login`, { + const resp = await apiFetch(`${BASE}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), - }) - return parseJSON(resp) -} - -export async function register(username: string, password: string): Promise { - const resp = await fetch(`${BASE}/register`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password }), - }) + }, { skipUnauthorized: true }) return parseJSON(resp) } export async function logout(): Promise { - await fetch(`${BASE}/logout`, { method: 'POST' }) + await apiFetch(`${BASE}/logout`, { method: 'POST' }, { skipUnauthorized: true }) } export async function getMe(): Promise { - try { - const resp = await fetch(`${BASE}/me`) - if (resp.status === 401) return null - return parseJSON(resp) - } catch { - return null + const resp = await apiFetch(`${BASE}/me`, undefined, { skipUnauthorized: true }) + if (resp.status === 401) { + const message = await responseError(resp) + if (message === 'unauthorized' || message === 'invalid username or password') return null + throw new ApiError(resp.status, message) } + return parseJSON(resp) } diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..0ac0374 --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,57 @@ +export class ApiError extends Error { + status: number + + constructor(status: number, message: string) { + super(message) + this.status = status + } +} + +let unauthorizedHandler: ((message: string) => void) | null = null + +export function setUnauthorizedHandler(handler: ((message: string) => void) | null) { + unauthorizedHandler = handler +} + +export async function responseError(resp: Response): Promise { + const raw = await resp.text().catch(() => '') + if (!raw) return `HTTP ${resp.status}` + try { + const data = JSON.parse(raw) as { error?: unknown } + return typeof data.error === 'string' ? data.error : raw + } catch { + return raw + } +} + +export async function apiFetch( + input: RequestInfo | URL, + init?: RequestInit, + options: { skipUnauthorized?: boolean } = {} +): Promise { + const resp = await fetch(input, init) + if (resp.status === 401 && !options.skipUnauthorized) { + const message = await responseError(resp.clone()) + unauthorizedHandler?.(message) + } + return resp +} + +export async function parseJSON(resp: Response): Promise { + if (!resp.ok) { + throw new ApiError(resp.status, await responseError(resp)) + } + return resp.json() as Promise +} + +export function handleXHRUnauthorized(status: number, responseText: string) { + if (status !== 401) return + let message = responseText || 'Login expired; please log in again' + try { + const data = JSON.parse(responseText) as { error?: unknown } + if (typeof data.error === 'string') message = data.error + } catch { + // Keep the raw response. + } + unauthorizedHandler?.(message) +} diff --git a/web/src/api/conversations.ts b/web/src/api/conversations.ts index 5eacc35..5fac7bf 100644 --- a/web/src/api/conversations.ts +++ b/web/src/api/conversations.ts @@ -1,4 +1,5 @@ import type { ChartSpec, ConversationMeta, StoredMessage } from '../types' +import { apiFetch } from './client' const BASE = '/api/conversations' @@ -11,19 +12,19 @@ async function parseJSON(resp: Response): Promise { } export async function listConversations(): Promise { - const resp = await fetch(BASE) + const resp = await apiFetch(BASE) const data = await parseJSON<{ conversations: ConversationMeta[] }>(resp) return data.conversations ?? [] } export async function createConversation(): Promise { - const resp = await fetch(BASE, { method: 'POST' }) + const resp = await apiFetch(BASE, { method: 'POST' }) const data = await parseJSON<{ id: string }>(resp) return data.id } export async function updateConversationTitle(id: string, title: string): Promise { - const resp = await fetch(`${BASE}/${id}`, { + const resp = await apiFetch(`${BASE}/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title }), @@ -34,14 +35,14 @@ export async function updateConversationTitle(id: string, title: string): Promis } export async function deleteConversation(id: string): Promise { - const resp = await fetch(`${BASE}/${id}`, { method: 'DELETE' }) + const resp = await apiFetch(`${BASE}/${id}`, { method: 'DELETE' }) if (!resp.ok) { throw new Error(`delete failed: ${resp.status}`) } } export async function listMessages(id: string): Promise { - const resp = await fetch(`${BASE}/${id}/messages`) + const resp = await apiFetch(`${BASE}/${id}/messages`) const data = await parseJSON<{ messages: StoredMessage[] }>(resp) return data.messages ?? [] } @@ -51,7 +52,7 @@ export async function updateMessageChartSpec( messageId: string, spec: ChartSpec ): Promise { - const resp = await fetch( + const resp = await apiFetch( `${BASE}/${conversationId}/messages/${messageId}/chart-spec`, { method: 'PATCH', diff --git a/web/src/api/userTables.ts b/web/src/api/userTables.ts index b8c5848..be7dd36 100644 --- a/web/src/api/userTables.ts +++ b/web/src/api/userTables.ts @@ -1,3 +1,5 @@ +import { apiFetch, handleXHRUnauthorized } from './client' + export interface ColumnInfo { name: string inferred_type?: string @@ -55,6 +57,7 @@ export async function uploadPreview( if (xhr.status >= 200 && xhr.status < 300) { resolve(JSON.parse(xhr.responseText)) } else { + handleXHRUnauthorized(xhr.status, xhr.responseText) reject(new Error(`HTTP ${xhr.status}: ${xhr.responseText}`)) } } @@ -82,7 +85,7 @@ export async function createTable( onProgress?: (p: ImportProgress) => void, signal?: AbortSignal ): Promise { - const resp = await fetch(`${BASE}/create`, { + const resp = await apiFetch(`${BASE}/create`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(req), @@ -117,12 +120,12 @@ export async function createTable( } export async function listUserTables(): Promise { - const resp = await fetch(BASE) + const resp = await apiFetch(BASE) return parseJSON(resp) } export async function deleteUserTable(name: string): Promise { - const resp = await fetch(`${BASE}/${name}`, { method: 'DELETE' }) + const resp = await apiFetch(`${BASE}/${name}`, { method: 'DELETE' }) if (!resp.ok) { throw new Error(`delete failed: ${resp.status}`) } @@ -132,7 +135,7 @@ export async function updateMetadata( name: string, req: { table_comment: string; columns: { name: string; comment: string }[] } ): Promise { - const resp = await fetch(`${BASE}/${name}/metadata`, { + const resp = await apiFetch(`${BASE}/${name}/metadata`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(req), @@ -143,11 +146,11 @@ export async function updateMetadata( } export async function previewTableData(name: string): Promise { - const resp = await fetch(`${BASE}/${name}/preview`) + const resp = await apiFetch(`${BASE}/${name}/preview`) return parseJSON(resp) } export async function getTableColumns(name: string): Promise { - const resp = await fetch(`${BASE}/${name}/columns`) + const resp = await apiFetch(`${BASE}/${name}/columns`) return parseJSON(resp) } diff --git a/web/src/api/users.ts b/web/src/api/users.ts new file mode 100644 index 0000000..d849487 --- /dev/null +++ b/web/src/api/users.ts @@ -0,0 +1,45 @@ +import type { AuthUser } from './auth' +import { apiFetch, parseJSON } from './client' + +const BASE = '/api/users' + +export interface CreateUserPayload { + username: string + password: string + expires_at: string | null + is_active: boolean + remark: string +} + +export interface UpdateUserPayload { + password?: string + is_active?: boolean + expires_at?: string | null + remark?: string +} + +export async function listUsers(): Promise { + const resp = await apiFetch(BASE) + const data = await parseJSON<{ users: AuthUser[] }>(resp) + return data.users ?? [] +} + +export async function createUser(payload: CreateUserPayload): Promise { + const resp = await apiFetch(BASE, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + const data = await parseJSON<{ user: AuthUser }>(resp) + return data.user +} + +export async function updateUser(id: string, payload: UpdateUserPayload): Promise { + const resp = await apiFetch(`${BASE}/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + const data = await parseJSON<{ user: AuthUser }>(resp) + return data.user +} diff --git a/web/src/components/AnalysisPanel.tsx b/web/src/components/AnalysisPanel.tsx index e576b1e..7e8db33 100644 --- a/web/src/components/AnalysisPanel.tsx +++ b/web/src/components/AnalysisPanel.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback, useRef } from 'react' import { useT } from '../i18n' +import { apiFetch } from '../api/client' import './AnalysisPanel.css' interface FeedbackTask { @@ -59,7 +60,7 @@ export function AnalysisPanel({ open, onClose }: AnalysisPanelProps) { const fetchTasks = useCallback(async () => { try { - const resp = await fetch('/api/feedback') + const resp = await apiFetch('/api/feedback') const data = await resp.json() const items: FeedbackTask[] = data.tasks || [] setTasks(items) diff --git a/web/src/components/DataTable.tsx b/web/src/components/DataTable.tsx index 2d19d61..d2c8431 100644 --- a/web/src/components/DataTable.tsx +++ b/web/src/components/DataTable.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react' import type { SQLResult } from '../types' import { useT } from '../i18n' import { tpl } from '../i18n' +import { apiFetch } from '../api/client' interface DataTableProps { result: SQLResult @@ -61,7 +62,7 @@ export function DataTable({ result }: DataTableProps) { setLoading(true) try { - const resp = await fetch('/api/query/paginate', { + const resp = await apiFetch('/api/query/paginate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sql, page: pageNum, page_size: size }), diff --git a/web/src/components/FeedbackButton.tsx b/web/src/components/FeedbackButton.tsx index ac3412a..3d43ba8 100644 --- a/web/src/components/FeedbackButton.tsx +++ b/web/src/components/FeedbackButton.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' import { useT } from '../i18n' +import { apiFetch } from '../api/client' interface FeedbackButtonProps { question: string @@ -19,7 +20,7 @@ export function FeedbackButton({ question, sql, sqlResult, sessionId }: Feedback if (submitting) return setSubmitting(true) try { - await fetch('/api/feedback', { + await apiFetch('/api/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/web/src/components/GuidePanel.css b/web/src/components/GuidePanel.css new file mode 100644 index 0000000..8e93f54 --- /dev/null +++ b/web/src/components/GuidePanel.css @@ -0,0 +1,88 @@ +.guide-panel-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.3); + z-index: 100; + animation: kp-fadeIn 0.2s ease-out; +} + +.guide-panel { + position: fixed; + top: 0; + right: 0; + width: 760px; + max-width: 90vw; + height: 100vh; + background: #fff; + z-index: 101; + display: flex; + flex-direction: column; + box-shadow: -4px 0 24px rgba(0, 0, 0, 0.12); + animation: kp-slideIn 0.25s ease-out; +} + +.guide-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid #e4e7ec; + flex-shrink: 0; +} + +.guide-panel-header h2 { + margin: 0; + color: #1a1a2e; + font-size: 17px; + font-weight: 600; +} + +.guide-panel-header p { + margin: 2px 0 0; + color: #94a3b8; + font-size: 12px; +} + +.guide-panel-close { + padding: 4px 8px; + border: 0; + border-radius: 6px; + background: none; + color: #94a3b8; + font-size: 20px; + line-height: 1; + cursor: pointer; + transition: all 0.15s; +} + +.guide-panel-close:hover { + background: #f1f5f9; + color: #475569; +} + +.guide-panel-close:focus-visible { + outline: 3px solid rgba(22, 93, 255, 0.24); + outline-offset: 2px; +} + +.guide-panel-frame { + width: 100%; + min-height: 0; + flex: 1; + border: 0; + background: #f7f8fa; +} + +@media (max-width: 640px) { + .guide-panel { + width: 100vw; + max-width: 100vw; + } +} + +@media (prefers-reduced-motion: reduce) { + .guide-panel-overlay, + .guide-panel { + animation-duration: 0.01ms; + } +} diff --git a/web/src/components/GuidePanel.tsx b/web/src/components/GuidePanel.tsx new file mode 100644 index 0000000..5690b6d --- /dev/null +++ b/web/src/components/GuidePanel.tsx @@ -0,0 +1,58 @@ +import { useEffect } from 'react' +import { useT } from '../i18n' +import './GuidePanel.css' + +interface GuidePanelProps { + open: boolean + onClose: () => void +} + +export function GuidePanel({ open, onClose }: GuidePanelProps) { + const { lang, t } = useT() + + useEffect(() => { + if (!open) return + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose() + } + + window.addEventListener('keydown', handleEscape) + return () => window.removeEventListener('keydown', handleEscape) + }, [open, onClose]) + + if (!open) return null + + return ( + <> +
+
+
+
+

{t('guide')}

+

{t('guideDesc')}

+
+ +
+ +