1033 lines
31 KiB
Go
1033 lines
31 KiB
Go
// sysop.go implements the sysop management menu.
|
|
//
|
|
// This replaces Edit_Accounts() and related functions from ACCOUNTS.C
|
|
// in the original TAG-BBS. The original had a single "edit which
|
|
// account?" prompt that loaded a user by slot number and displayed
|
|
// all fields with letter-key editing.
|
|
//
|
|
// The modernized version uses a sub-menu with dedicated commands:
|
|
// L List all users (paginated)
|
|
// N List new/unvalidated users
|
|
// V View/edit a user's full account details
|
|
// Q Return to main menu
|
|
//
|
|
// The view/edit flow mirrors the original's:
|
|
// Display_Account → ReadChar → modify field in memory → redisplay
|
|
// 1=Save ESC=Cancel 2=Validate 3=Toggle active
|
|
// A=Name B=Password C=Comments F-I=Security J-N=Stats Q-R=Time
|
|
package menu
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/urit/urit/internal/auth"
|
|
"github.com/urit/urit/internal/models"
|
|
"github.com/urit/urit/internal/session"
|
|
)
|
|
|
|
const sysopPageSize = 15
|
|
|
|
// cmdSysopMenu is the sysop management sub-menu.
|
|
// Replaces: Edit_Accounts() from ACCOUNTS.C
|
|
//
|
|
// The original's flow was:
|
|
// FOREVER { "Edit which account?" → slot number → Display_Account → edit keys }
|
|
//
|
|
// Ours adds structured navigation on top: list, filter, then view/edit.
|
|
func cmdSysopMenu(ctx *Context) error {
|
|
ctx.Sess.NewLine()
|
|
ctx.Sess.Color(session.AnsiFgRed, session.AnsiBold)
|
|
ctx.Sess.WriteString("Sysop Menu\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.WriteString(strings.Repeat("─", 40) + "\r\n")
|
|
|
|
ctx.Sess.Color(session.AnsiFgBrightWhite)
|
|
ctx.Sess.WriteString(" [L] ")
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.WriteString("List all users\r\n")
|
|
ctx.Sess.Color(session.AnsiFgBrightWhite)
|
|
ctx.Sess.WriteString(" [N] ")
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.WriteString("New/unvalidated users\r\n")
|
|
ctx.Sess.Color(session.AnsiFgBrightWhite)
|
|
ctx.Sess.WriteString(" [V] ")
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.WriteString("View/edit user\r\n")
|
|
ctx.Sess.Color(session.AnsiFgBrightWhite)
|
|
ctx.Sess.WriteString(" [B] ")
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.WriteString("Board management\r\n")
|
|
ctx.Sess.Color(session.AnsiFgBrightWhite)
|
|
ctx.Sess.WriteString(" [U] ")
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.WriteString("Bulletin management\r\n")
|
|
ctx.Sess.Color(session.AnsiFgBrightWhite)
|
|
ctx.Sess.WriteString(" [F] ")
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.WriteString("File library management\r\n")
|
|
ctx.Sess.Color(session.AnsiFgBrightWhite)
|
|
ctx.Sess.WriteString(" [C] ")
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.WriteString("Call log\r\n")
|
|
ctx.Sess.Color(session.AnsiFgBrightWhite)
|
|
ctx.Sess.WriteString(" [X] ")
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.WriteString("Force disconnect node\r\n")
|
|
ctx.Sess.Color(session.AnsiFgBrightWhite)
|
|
ctx.Sess.WriteString(" [Q] ")
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.WriteString("Return to main menu\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
for {
|
|
ctx.Sess.NewLine()
|
|
ctx.Sess.Color(session.AnsiFgRed)
|
|
ctx.Sess.WriteString("Sysop> ")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
ch, err := ctx.Sess.ReadKey(idleTimeout)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
switch toUpper(ch) {
|
|
case 'L':
|
|
ctx.Sess.Color(session.AnsiFgBrightWhite)
|
|
ctx.Sess.WriteString("List Users\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
sysopListUsers(ctx)
|
|
|
|
case 'N':
|
|
ctx.Sess.Color(session.AnsiFgBrightWhite)
|
|
ctx.Sess.WriteString("New Users\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
sysopListNew(ctx)
|
|
|
|
case 'V':
|
|
ctx.Sess.Color(session.AnsiFgBrightWhite)
|
|
ctx.Sess.WriteString("View/Edit User\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
sysopViewUserPrompt(ctx)
|
|
|
|
case 'B':
|
|
ctx.Sess.Color(session.AnsiFgBrightWhite)
|
|
ctx.Sess.WriteString("Boards\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
sysopBoardMenu(ctx)
|
|
|
|
case 'U':
|
|
ctx.Sess.Color(session.AnsiFgBrightWhite)
|
|
ctx.Sess.WriteString("Bulletins\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
sysopBulletinMenu(ctx)
|
|
|
|
case 'F':
|
|
ctx.Sess.Color(session.AnsiFgBrightWhite)
|
|
ctx.Sess.WriteString("Libraries\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
sysopLibraryMenu(ctx)
|
|
|
|
case 'C':
|
|
ctx.Sess.Color(session.AnsiFgBrightWhite)
|
|
ctx.Sess.WriteString("Call Log\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
sysopCallLog(ctx)
|
|
|
|
case 'X':
|
|
ctx.Sess.Color(session.AnsiFgBrightWhite)
|
|
ctx.Sess.WriteString("Force Disconnect\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
sysopForceDisconnect(ctx)
|
|
|
|
case 'Q', '\x1b':
|
|
ctx.Sess.WriteString("Return\r\n")
|
|
return nil
|
|
|
|
case '?':
|
|
ctx.Sess.WriteString("Help\r\n")
|
|
ctx.Sess.Color(session.AnsiFgBrightBlack)
|
|
ctx.Sess.WriteString(" L=List N=New V=View B=Boards U=Bulletins F=Libraries C=Log X=Kick Q=Quit\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
}
|
|
}
|
|
}
|
|
|
|
// sysopListUsers displays a paginated list of all user accounts.
|
|
// Shows all users including inactive ones, ordered by ID.
|
|
//
|
|
// Replaces the implicit listing that happened when the sysop scrolled
|
|
// through slot numbers in the original. The original had no actual
|
|
// list view — you had to know the slot number or use List_New_Accounts.
|
|
func sysopListUsers(ctx *Context) {
|
|
total, _ := ctx.Store.CountUsers()
|
|
|
|
ctx.Sess.NewLine()
|
|
ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold)
|
|
ctx.Sess.WriteString("User Accounts\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.WriteString(strings.Repeat("─", 60) + "\r\n")
|
|
|
|
// Column header
|
|
ctx.Sess.Color(session.AnsiFgBrightBlack)
|
|
ctx.Sess.Printf(" %-5s %-20s %-8s %4s %4s %4s %s\r\n",
|
|
"ID", "Name", "Status", "Brd", "Lib", "Bul", "Last On")
|
|
ctx.Sess.WriteString(strings.Repeat("─", 60) + "\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
offset := 0
|
|
for {
|
|
users, err := ctx.Store.ListAllUsers(offset, sysopPageSize)
|
|
if err != nil {
|
|
ctx.Sess.WriteString(" Error loading users.\r\n")
|
|
return
|
|
}
|
|
if len(users) == 0 {
|
|
if offset == 0 {
|
|
ctx.Sess.WriteString(" (no users)\r\n")
|
|
}
|
|
return
|
|
}
|
|
|
|
for _, u := range users {
|
|
// Inactive accounts are dimmed, matching the original's
|
|
// "INACTIVE Account [n]" display in Display_Account.
|
|
if !u.Active {
|
|
ctx.Sess.Color(session.AnsiFgBrightBlack)
|
|
} else {
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
}
|
|
|
|
lastOn := "never"
|
|
if u.LastOn != nil {
|
|
lastOn = u.LastOn.Format("Jan 02 06")
|
|
}
|
|
|
|
// Status with color coding
|
|
statusColor := statusToColor(u.SecStatus)
|
|
ctx.Sess.Printf(" %-5d %-20s ", u.ID, truncate(u.Name, 20))
|
|
ctx.Sess.Color(statusColor)
|
|
ctx.Sess.Printf("%-8s", u.StatusLabel())
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
if !u.Active {
|
|
ctx.Sess.Color(session.AnsiFgBrightBlack)
|
|
}
|
|
ctx.Sess.Printf(" %4d %4d %4d %s\r\n",
|
|
u.SecBoard, u.SecLibrary, u.SecBulletin, lastOn)
|
|
}
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
// Check if there are more pages
|
|
offset += len(users)
|
|
if offset >= total || len(users) < sysopPageSize {
|
|
ctx.Sess.NewLine()
|
|
ctx.Sess.Color(session.AnsiFgBrightBlack)
|
|
ctx.Sess.Printf(" %d user(s) total\r\n", total)
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
return
|
|
}
|
|
|
|
// Pagination prompt
|
|
ctx.Sess.NewLine()
|
|
ctx.Sess.Color(session.AnsiFgBrightBlack)
|
|
ctx.Sess.Printf(" -- Page %d (%d/%d) [M]ore [Q]uit -- ",
|
|
offset/sysopPageSize+1, offset, total)
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
ch, err := ctx.Sess.ReadKey(idleTimeout)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if toUpper(ch) == 'Q' || ch == '\x1b' {
|
|
ctx.Sess.WriteString("Quit\r\n")
|
|
return
|
|
}
|
|
ctx.Sess.WriteString("More\r\n")
|
|
}
|
|
}
|
|
|
|
// sysopListNew shows only unvalidated (SecStatus == 1) accounts.
|
|
// This is a direct replacement for List_New_Accounts() from ACCOUNTS.C,
|
|
// which iterated every slot and printed the number if Sec_Status == 1.
|
|
//
|
|
// Ours is more useful — it shows names and creation dates.
|
|
func sysopListNew(ctx *Context) {
|
|
// Fetch all users and filter for new status. With a reasonable
|
|
// number of accounts this is fine; a dedicated query can be added
|
|
// if performance becomes an issue.
|
|
users, err := ctx.Store.ListAllUsers(0, 10000)
|
|
if err != nil {
|
|
ctx.Sess.WriteString(" Error loading users.\r\n")
|
|
return
|
|
}
|
|
|
|
ctx.Sess.NewLine()
|
|
ctx.Sess.Color(session.AnsiFgYellow, session.AnsiBold)
|
|
ctx.Sess.WriteString("Unvalidated Accounts (Status: New)\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.WriteString(strings.Repeat("─", 50) + "\r\n")
|
|
|
|
count := 0
|
|
for _, u := range users {
|
|
if u.SecStatus != 1 || !u.Active {
|
|
continue
|
|
}
|
|
created := u.CreatedAt.Format("Jan 02, 2006 3:04 PM")
|
|
comment := u.Comments
|
|
if len(comment) > 40 {
|
|
comment = comment[:37] + "..."
|
|
}
|
|
|
|
ctx.Sess.Color(session.AnsiFgBrightWhite)
|
|
ctx.Sess.Printf(" [%d] %s\r\n", u.ID, u.Name)
|
|
ctx.Sess.Color(session.AnsiFgBrightBlack)
|
|
ctx.Sess.Printf(" Created: %s\r\n", created)
|
|
if comment != "" {
|
|
ctx.Sess.Printf(" Comment: %s\r\n", comment)
|
|
}
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
count++
|
|
}
|
|
|
|
if count == 0 {
|
|
ctx.Sess.WriteString(" (no unvalidated accounts)\r\n")
|
|
} else {
|
|
ctx.Sess.NewLine()
|
|
ctx.Sess.Color(session.AnsiFgBrightBlack)
|
|
ctx.Sess.Printf(" %d unvalidated account(s)\r\n", count)
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
}
|
|
}
|
|
|
|
// sysopViewUserPrompt asks for a user ID and displays the full account.
|
|
func sysopViewUserPrompt(ctx *Context) {
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.WriteString(" User ID: ")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
input, err := ctx.Sess.ReadLine("", 10, inputTimeout)
|
|
if err != nil {
|
|
return
|
|
}
|
|
input = strings.TrimSpace(input)
|
|
if input == "" {
|
|
return
|
|
}
|
|
|
|
id, err := strconv.ParseInt(input, 10, 64)
|
|
if err != nil || id < 1 {
|
|
ctx.Sess.WriteString(" Invalid user ID.\r\n")
|
|
return
|
|
}
|
|
|
|
sysopEditUser(ctx, id)
|
|
}
|
|
|
|
// sysopEditUser is the edit loop for a single user account.
|
|
//
|
|
// This is the modernized equivalent of the inner FOREVER loop in
|
|
// Edit_Accounts() from ACCOUNTS.C. The original pattern was:
|
|
// 1. Display_Account(slot, &hoozer) — show all fields
|
|
// 2. ReadChar() — wait for keypress
|
|
// 3. switch on key to edit field in memory
|
|
// 4. loop back to 1 (redisplay with changes)
|
|
// 5. '1' saves, ESC cancels
|
|
//
|
|
// We follow the same pattern: load a copy, edit in memory, display
|
|
// after each change, and only write to the database on explicit save.
|
|
// This means the sysop can experiment with changes and bail out with
|
|
// ESC if they change their mind — same UX as the original.
|
|
func sysopEditUser(ctx *Context, id int64) {
|
|
user, err := ctx.Store.GetUser(id)
|
|
if err != nil || user == nil {
|
|
ctx.Sess.WriteString(" User not found.\r\n")
|
|
return
|
|
}
|
|
|
|
// Track whether any fields have been modified so we can warn on ESC.
|
|
dirty := false
|
|
|
|
for {
|
|
// Step 1: Display the full account (redraw each iteration).
|
|
sysopDisplayAccount(ctx, user, dirty)
|
|
|
|
// Step 2: Wait for a keypress.
|
|
ch, err := ctx.Sess.ReadKey(idleTimeout)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Step 3: Dispatch.
|
|
switch toUpper(ch) {
|
|
|
|
// --- Save / Cancel ---
|
|
|
|
case '1': // Save
|
|
ctx.Sess.WriteString("Save\r\n")
|
|
if err := ctx.Store.UpdateUser(user); err != nil {
|
|
ctx.Sess.Color(session.AnsiFgRed)
|
|
ctx.Sess.Printf(" Error saving: %v\r\n", err)
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.ReadKey(idleTimeout)
|
|
continue
|
|
}
|
|
ctx.Sess.Color(session.AnsiFgGreen)
|
|
ctx.Sess.Printf(" Account #%d saved.\r\n", user.ID)
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.ReadKey(idleTimeout)
|
|
return
|
|
|
|
case '\x1b': // ESC — Cancel
|
|
ctx.Sess.WriteString("Cancel\r\n")
|
|
if dirty {
|
|
ctx.Sess.Color(session.AnsiFgYellow)
|
|
ctx.Sess.WriteString(" Unsaved changes! Discard? [Y/N] ")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
confirm, err := ctx.Sess.ReadKey(inputTimeout)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if toUpper(confirm) != 'Y' {
|
|
ctx.Sess.WriteString("No\r\n")
|
|
continue
|
|
}
|
|
ctx.Sess.WriteString("Yes\r\n")
|
|
}
|
|
return
|
|
|
|
// --- Quick actions ---
|
|
|
|
case '2': // Validate — apply valid security levels from config
|
|
ctx.Sess.WriteString("Validate\r\n")
|
|
// Mirrors the original's case '2' which set all security
|
|
// fields to System.User_Defaults.Valid.* values.
|
|
vs := ctx.Cfg.Users.ValidSecurity
|
|
user.SecStatus = vs.Status
|
|
user.SecBoard = vs.Board
|
|
user.SecLibrary = vs.Library
|
|
user.SecBulletin = vs.Bulletin
|
|
user.TimeLimit = int64(ctx.Cfg.Users.ValidTimeLimit)
|
|
user.TimeUsed = 0
|
|
user.TimeTotal = 0
|
|
dirty = true
|
|
|
|
case '3': // Toggle active flag
|
|
// The original had '3' for re-activate and DEL for delete
|
|
// (which zeroed the slot number). Our toggle is simpler and
|
|
// reversible.
|
|
user.Active = !user.Active
|
|
if user.Active {
|
|
ctx.Sess.WriteString("Activate\r\n")
|
|
} else {
|
|
ctx.Sess.WriteString("Deactivate\r\n")
|
|
}
|
|
dirty = true
|
|
|
|
case 'D': // Permanent delete
|
|
ctx.Sess.WriteString("Delete\r\n")
|
|
if sysopDeleteUser(ctx, user) {
|
|
return // User was deleted, exit editor
|
|
}
|
|
|
|
// --- Edit identity ---
|
|
|
|
case 'A': // Name
|
|
ctx.Sess.WriteString("Name\r\n")
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.WriteString(" New name: ")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
name, err := ctx.Sess.ReadLine(user.Name, 30, inputTimeout)
|
|
if err != nil {
|
|
return
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
if name != "" && name != user.Name {
|
|
// Check for duplicate names
|
|
existing, _ := ctx.Store.GetUserByName(name)
|
|
if existing != nil && existing.ID != user.ID {
|
|
ctx.Sess.Color(session.AnsiFgRed)
|
|
ctx.Sess.WriteString(" Name already taken.\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.ReadKey(idleTimeout)
|
|
} else {
|
|
user.Name = name
|
|
dirty = true
|
|
}
|
|
}
|
|
|
|
case 'B': // Password reset
|
|
ctx.Sess.WriteString("Password\r\n")
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.WriteString(" New password: ")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
pass, err := ctx.Sess.ReadLineNoEcho(72, inputTimeout)
|
|
if err != nil {
|
|
return
|
|
}
|
|
pass = strings.TrimSpace(pass)
|
|
if pass == "" {
|
|
ctx.Sess.WriteString(" (cancelled)\r\n")
|
|
continue
|
|
}
|
|
if len(pass) < 4 {
|
|
ctx.Sess.Color(session.AnsiFgRed)
|
|
ctx.Sess.WriteString(" Password must be at least 4 characters.\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.ReadKey(idleTimeout)
|
|
continue
|
|
}
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.WriteString("\r\n Confirm: ")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
pass2, err := ctx.Sess.ReadLineNoEcho(72, inputTimeout)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if strings.TrimSpace(pass2) != pass {
|
|
ctx.Sess.Color(session.AnsiFgRed)
|
|
ctx.Sess.WriteString(" Passwords don't match.\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.ReadKey(idleTimeout)
|
|
continue
|
|
}
|
|
hash, err := auth.HashPassword(pass)
|
|
if err != nil {
|
|
ctx.Sess.Color(session.AnsiFgRed)
|
|
ctx.Sess.Printf(" Hash error: %v\r\n", err)
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.ReadKey(idleTimeout)
|
|
continue
|
|
}
|
|
user.PasswordHash = hash
|
|
dirty = true
|
|
ctx.Sess.WriteString("\r\n")
|
|
|
|
case 'C': // Comments
|
|
ctx.Sess.WriteString("Comments\r\n")
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.WriteString(" Comment: ")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
comment, err := ctx.Sess.ReadLine(user.Comments, 80, inputTimeout)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if strings.TrimSpace(comment) != user.Comments {
|
|
user.Comments = strings.TrimSpace(comment)
|
|
dirty = true
|
|
}
|
|
|
|
// --- Security levels (mirrors original F-I) ---
|
|
|
|
case 'F': // SecStatus
|
|
ctx.Sess.WriteString("Status\r\n")
|
|
if v, ok := sysopReadInt(ctx, "SecStatus", user.SecStatus, 0, 255); ok {
|
|
user.SecStatus = v
|
|
dirty = true
|
|
}
|
|
case 'G': // SecBoard
|
|
ctx.Sess.WriteString("Board\r\n")
|
|
if v, ok := sysopReadInt(ctx, "SecBoard", user.SecBoard, 0, 255); ok {
|
|
user.SecBoard = v
|
|
dirty = true
|
|
}
|
|
case 'H': // SecLibrary
|
|
ctx.Sess.WriteString("Library\r\n")
|
|
if v, ok := sysopReadInt(ctx, "SecLibrary", user.SecLibrary, 0, 255); ok {
|
|
user.SecLibrary = v
|
|
dirty = true
|
|
}
|
|
case 'I': // SecBulletin
|
|
ctx.Sess.WriteString("Bulletin\r\n")
|
|
if v, ok := sysopReadInt(ctx, "SecBulletin", user.SecBulletin, 0, 255); ok {
|
|
user.SecBulletin = v
|
|
dirty = true
|
|
}
|
|
|
|
// --- Activity stats (mirrors original J-N) ---
|
|
|
|
case 'J': // MessagesPosted
|
|
ctx.Sess.WriteString("Messages\r\n")
|
|
if v, ok := sysopReadInt(ctx, "MsgsPosted", user.MessagesPosted, 0, 999999); ok {
|
|
user.MessagesPosted = v
|
|
dirty = true
|
|
}
|
|
case 'K': // MailSent
|
|
ctx.Sess.WriteString("Mail Sent\r\n")
|
|
if v, ok := sysopReadInt(ctx, "MailSent", user.MailSent, 0, 999999); ok {
|
|
user.MailSent = v
|
|
dirty = true
|
|
}
|
|
case 'L': // MailReceived
|
|
ctx.Sess.WriteString("Mail Rcvd\r\n")
|
|
if v, ok := sysopReadInt(ctx, "MailRecv", user.MailReceived, 0, 999999); ok {
|
|
user.MailReceived = v
|
|
dirty = true
|
|
}
|
|
case 'M': // Uploads
|
|
ctx.Sess.WriteString("Uploads\r\n")
|
|
if v, ok := sysopReadInt(ctx, "Uploads", user.Uploads, 0, 999999); ok {
|
|
user.Uploads = v
|
|
dirty = true
|
|
}
|
|
case 'N': // Downloads
|
|
ctx.Sess.WriteString("Downloads\r\n")
|
|
if v, ok := sysopReadInt(ctx, "Downloads", user.Downloads, 0, 999999); ok {
|
|
user.Downloads = v
|
|
dirty = true
|
|
}
|
|
|
|
// --- Time fields (mirrors original Q-R) ---
|
|
|
|
case 'Q': // TimeLimit
|
|
ctx.Sess.WriteString("Time Limit\r\n")
|
|
if v, ok := sysopReadInt64(ctx, "TimeLimit (secs)", user.TimeLimit, 0, 86400*7); ok {
|
|
user.TimeLimit = v
|
|
dirty = true
|
|
}
|
|
case 'R': // TimeUsed
|
|
ctx.Sess.WriteString("Time Used\r\n")
|
|
if v, ok := sysopReadInt64(ctx, "TimeUsed (secs)", user.TimeUsed, 0, 86400*365); ok {
|
|
user.TimeUsed = v
|
|
dirty = true
|
|
}
|
|
|
|
case '?': // Help
|
|
ctx.Sess.WriteString("Help\r\n")
|
|
sysopEditHelp(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// sysopDisplayAccount renders the full account detail screen.
|
|
// This is the modernized equivalent of Display_Account() from ACCOUNTS.C.
|
|
//
|
|
// The original displayed every field with a letter key for editing:
|
|
// A> Name B> Pass C-E> Comments F-I> Security levels
|
|
// J> Messages_Posted K-L> Mail counts M-N> Transfer counts
|
|
// Q> Time_Limit R> Time_Used
|
|
//
|
|
// Ours keeps the same letter-key layout so the sysop can press a key
|
|
// to edit the corresponding field. A dirty indicator (*) shows when
|
|
// unsaved changes exist.
|
|
func sysopDisplayAccount(ctx *Context, user *models.User, dirty bool) {
|
|
ctx.Sess.ClearScreen()
|
|
|
|
// Header — matches original's "Account [n]" / "INACTIVE Account [n]"
|
|
ctx.Sess.Color(session.AnsiFgRed, session.AnsiBold)
|
|
if !user.Active {
|
|
ctx.Sess.Printf(" INACTIVE Account #%d", user.ID)
|
|
} else {
|
|
ctx.Sess.Printf(" Account #%d", user.ID)
|
|
}
|
|
if dirty {
|
|
ctx.Sess.Color(session.AnsiFgYellow)
|
|
ctx.Sess.WriteString(" *")
|
|
}
|
|
ctx.Sess.WriteString("\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.WriteString(strings.Repeat("─", 50) + "\r\n")
|
|
ctx.Sess.NewLine()
|
|
|
|
// Identity
|
|
sysopField(ctx, "A", "Name", user.Name)
|
|
sysopField(ctx, "B", "Password", "(hashed)")
|
|
ctx.Sess.NewLine()
|
|
|
|
// Comments — the original had 3 fixed comment lines; we have one field
|
|
sysopField(ctx, "C", "Comments", "")
|
|
if user.Comments != "" {
|
|
ctx.Sess.Color(session.AnsiFgWhite)
|
|
// Display multi-line comments indented
|
|
for _, line := range strings.Split(user.Comments, "\n") {
|
|
ctx.Sess.Printf(" %s\r\n", line)
|
|
}
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
} else {
|
|
ctx.Sess.Color(session.AnsiFgBrightBlack)
|
|
ctx.Sess.WriteString(" (none)\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
}
|
|
ctx.Sess.NewLine()
|
|
|
|
// Security — mirrors the original's F through I fields
|
|
statusColor := statusToColor(user.SecStatus)
|
|
ctx.Sess.Color(session.AnsiFgBrightWhite)
|
|
ctx.Sess.WriteString(" F> ")
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.WriteString("Status: ")
|
|
ctx.Sess.Color(statusColor)
|
|
ctx.Sess.Printf("%d (%s)\r\n", user.SecStatus, user.StatusLabel())
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
sysopFieldInt(ctx, "G", "Board", user.SecBoard)
|
|
sysopFieldInt(ctx, "H", "Library", user.SecLibrary)
|
|
sysopFieldInt(ctx, "I", "Bulletin", user.SecBulletin)
|
|
ctx.Sess.NewLine()
|
|
|
|
// Activity stats — mirrors J through N
|
|
sysopFieldInt(ctx, "J", "Messages Posted", user.MessagesPosted)
|
|
sysopFieldInt(ctx, "K", "Mail Sent", user.MailSent)
|
|
sysopFieldInt(ctx, "L", "Mail Received", user.MailReceived)
|
|
sysopFieldInt(ctx, "M", "Uploads", user.Uploads)
|
|
sysopFieldInt(ctx, "N", "Downloads", user.Downloads)
|
|
ctx.Sess.NewLine()
|
|
|
|
// Time tracking — mirrors Q and R
|
|
sysopField(ctx, "Q", "Time Limit",
|
|
fmt.Sprintf("%d secs (%s)", user.TimeLimit, fmtSeconds(user.TimeLimit)))
|
|
sysopField(ctx, "R", "Time Used",
|
|
fmt.Sprintf("%d secs (%s)", user.TimeUsed, fmtSeconds(user.TimeUsed)))
|
|
sysopField(ctx, " ", "Time Total",
|
|
fmt.Sprintf("%d secs (%s)", user.TimeTotal, fmtSeconds(user.TimeTotal)))
|
|
ctx.Sess.NewLine()
|
|
|
|
// Metadata (not in original — new fields)
|
|
if user.LastOn != nil {
|
|
sysopField(ctx, " ", "Last On", user.LastOn.Format("Jan 02, 2006 3:04 PM"))
|
|
} else {
|
|
sysopField(ctx, " ", "Last On", "never")
|
|
}
|
|
sysopField(ctx, " ", "Created", user.CreatedAt.Format("Jan 02, 2006 3:04 PM"))
|
|
ctx.Sess.NewLine()
|
|
|
|
// Footer — action bar
|
|
ctx.Sess.Color(session.AnsiFgBrightBlack)
|
|
ctx.Sess.WriteString(" 1=Save ESC=Cancel 2=Validate 3=Active D=Delete ?=Help\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
}
|
|
|
|
// sysopEditHelp displays the key reference for the account editor.
|
|
func sysopEditHelp(ctx *Context) {
|
|
ctx.Sess.NewLine()
|
|
ctx.Sess.Color(session.AnsiFgBrightBlack)
|
|
ctx.Sess.WriteString(" Key Reference:\r\n")
|
|
ctx.Sess.WriteString(" 1 Save changes to database\r\n")
|
|
ctx.Sess.WriteString(" ESC Cancel (discard changes)\r\n")
|
|
ctx.Sess.WriteString(" 2 Validate — set valid security levels\r\n")
|
|
ctx.Sess.WriteString(" 3 Toggle active/inactive\r\n")
|
|
ctx.Sess.WriteString(" D Permanently delete user\r\n")
|
|
ctx.Sess.WriteString(" A Edit name\r\n")
|
|
ctx.Sess.WriteString(" B Reset password\r\n")
|
|
ctx.Sess.WriteString(" C Edit comments\r\n")
|
|
ctx.Sess.WriteString(" F-I Edit security levels\r\n")
|
|
ctx.Sess.WriteString(" J-N Edit activity stats\r\n")
|
|
ctx.Sess.WriteString(" Q-R Edit time tracking\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.NewLine()
|
|
ctx.Sess.WriteString(" Press any key to continue.\r\n")
|
|
ctx.Sess.ReadKey(idleTimeout)
|
|
}
|
|
|
|
// sysopDeleteUser permanently removes a user account from the database.
|
|
// This is the nuclear option — unlike '3' (toggle active), this actually
|
|
// deletes the user record and all their mail. The original TAG-BBS's DEL
|
|
// key zeroed the slot number, which effectively deleted the account since
|
|
// the slot could be reused.
|
|
//
|
|
// Safety measures:
|
|
// - Cannot delete the sysop account (ID 1)
|
|
// - Requires typing "DELETE" to confirm (not just Y/N)
|
|
// - Force-disconnects the user if they're online
|
|
//
|
|
// Returns true if the user was deleted (caller should exit the editor).
|
|
func sysopDeleteUser(ctx *Context, user *models.User) bool {
|
|
// Prevent deleting the sysop
|
|
if user.ID == 1 {
|
|
ctx.Sess.Color(session.AnsiFgRed)
|
|
ctx.Sess.WriteString(" Cannot delete the sysop account.\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.ReadKey(idleTimeout)
|
|
return false
|
|
}
|
|
|
|
ctx.Sess.Color(session.AnsiFgRed, session.AnsiBold)
|
|
ctx.Sess.Printf("\r\n PERMANENTLY delete %s (#%d)?\r\n", user.Name, user.ID)
|
|
ctx.Sess.Color(session.AnsiFgRed)
|
|
ctx.Sess.WriteString(" This removes the account and all their mail.\r\n")
|
|
ctx.Sess.WriteString(" Type DELETE to confirm: ")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
input, err := ctx.Sess.ReadLine("", 10, inputTimeout)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if strings.TrimSpace(input) != "DELETE" {
|
|
ctx.Sess.WriteString(" Cancelled.\r\n")
|
|
ctx.Sess.ReadKey(idleTimeout)
|
|
return false
|
|
}
|
|
|
|
// Force-disconnect if the user is online
|
|
if ctx.Nodes != nil {
|
|
for _, n := range ctx.Nodes.ActiveNodes() {
|
|
if n.UserID == user.ID && n.Node != ctx.Sess.Node {
|
|
ctx.Nodes.DisconnectNode(n.Node)
|
|
ctx.Sess.Printf(" Disconnected node %d.\r\n", n.Node)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Delete from database
|
|
if err := ctx.Store.HardDeleteUser(user.ID); err != nil {
|
|
ctx.Sess.Color(session.AnsiFgRed)
|
|
ctx.Sess.Printf(" Error: %v\r\n", err)
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.ReadKey(idleTimeout)
|
|
return false
|
|
}
|
|
|
|
ctx.Sess.Color(session.AnsiFgGreen)
|
|
ctx.Sess.Printf(" Account #%d (%s) deleted.\r\n", user.ID, user.Name)
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.ReadKey(idleTimeout)
|
|
return true
|
|
}
|
|
|
|
// sysopForceDisconnect prompts for a node number and kicks that session.
|
|
// Shows the active node list first so the sysop can see who's online.
|
|
func sysopForceDisconnect(ctx *Context) {
|
|
if ctx.Nodes == nil {
|
|
ctx.Sess.WriteString(" Node manager unavailable.\r\n")
|
|
return
|
|
}
|
|
|
|
nodes := ctx.Nodes.ActiveNodes()
|
|
if len(nodes) <= 1 {
|
|
ctx.Sess.WriteString(" No other nodes are connected.\r\n")
|
|
return
|
|
}
|
|
|
|
// Show active nodes
|
|
ctx.Sess.NewLine()
|
|
ctx.Sess.Color(session.AnsiFgBrightBlack)
|
|
ctx.Sess.Printf(" %-6s %-20s %s\r\n", "Node", "User", "Address")
|
|
ctx.Sess.WriteString(strings.Repeat("─", 50) + "\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
for _, n := range nodes {
|
|
if n.Node == ctx.Sess.Node {
|
|
continue // Don't show yourself
|
|
}
|
|
name := n.UserName
|
|
if name == "" {
|
|
name = "(connecting)"
|
|
}
|
|
ctx.Sess.Printf(" %-6d %-20s %s\r\n", n.Node, name, n.RemoteAddr)
|
|
}
|
|
|
|
ctx.Sess.NewLine()
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.WriteString(" Disconnect node: ")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
input, err := ctx.Sess.ReadLine("", 5, inputTimeout)
|
|
if err != nil {
|
|
return
|
|
}
|
|
input = strings.TrimSpace(input)
|
|
if input == "" {
|
|
return
|
|
}
|
|
|
|
node, err := strconv.Atoi(input)
|
|
if err != nil || node < 1 {
|
|
ctx.Sess.WriteString(" Invalid node number.\r\n")
|
|
return
|
|
}
|
|
|
|
if node == ctx.Sess.Node {
|
|
ctx.Sess.WriteString(" Cannot disconnect yourself.\r\n")
|
|
return
|
|
}
|
|
|
|
if err := ctx.Nodes.DisconnectNode(node); err != nil {
|
|
ctx.Sess.Color(session.AnsiFgRed)
|
|
ctx.Sess.Printf(" %v\r\n", err)
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
return
|
|
}
|
|
|
|
ctx.Sess.Color(session.AnsiFgGreen)
|
|
ctx.Sess.Printf(" Node %d disconnected.\r\n", node)
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
}
|
|
|
|
// sysopCallLog displays the recent call log with full detail.
|
|
// The sysop version shows more info than the public stats: IP addresses,
|
|
// disconnect reasons, and node numbers.
|
|
func sysopCallLog(ctx *Context) {
|
|
entries, err := ctx.Store.ListCallLog(30)
|
|
if err != nil {
|
|
ctx.Sess.WriteString(" Error loading call log.\r\n")
|
|
return
|
|
}
|
|
|
|
ctx.Sess.NewLine()
|
|
ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold)
|
|
ctx.Sess.WriteString("Call Log (last 30 events)\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.WriteString(strings.Repeat("─", 70) + "\r\n")
|
|
|
|
if len(entries) == 0 {
|
|
ctx.Sess.WriteString(" (no events logged)\r\n")
|
|
return
|
|
}
|
|
|
|
ctx.Sess.Color(session.AnsiFgBrightBlack)
|
|
ctx.Sess.Printf(" %-15s %-7s %-4s %-14s %s\r\n",
|
|
"Time", "Event", "Node", "User", "Detail")
|
|
ctx.Sess.WriteString(strings.Repeat("─", 70) + "\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
for _, e := range entries {
|
|
ts := e.CreatedAt.Format("Jan02 3:04PM")
|
|
|
|
var eventColor string
|
|
switch e.Event {
|
|
case "login":
|
|
eventColor = session.AnsiFgGreen
|
|
case "logoff":
|
|
eventColor = session.AnsiFgCyan
|
|
default:
|
|
eventColor = session.AnsiFgYellow
|
|
}
|
|
|
|
ctx.Sess.Color(session.AnsiFgBrightBlack)
|
|
ctx.Sess.Printf(" %-15s", ts)
|
|
ctx.Sess.Color(eventColor)
|
|
ctx.Sess.Printf(" %-7s", e.Event)
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.Printf(" %-4d %-14s %s\r\n",
|
|
e.Node, truncate(e.UserName, 14), e.Detail)
|
|
}
|
|
|
|
ctx.Sess.NewLine()
|
|
ctx.Sess.Color(session.AnsiFgBrightBlack)
|
|
ctx.Sess.Printf(" %d event(s)\r\n", len(entries))
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
}
|
|
|
|
// sysopReadInt prompts for an integer value with the current value shown.
|
|
// Returns the new value and true if changed, or the old value and false
|
|
// if cancelled/empty. This is our equivalent of the original's
|
|
// NumberInput() function.
|
|
func sysopReadInt(ctx *Context, label string, current, min, max int) (int, bool) {
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.Printf(" %s [%d]: ", label, current)
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
input, err := ctx.Sess.ReadLine("", 10, inputTimeout)
|
|
if err != nil || strings.TrimSpace(input) == "" {
|
|
return current, false
|
|
}
|
|
|
|
v, err := strconv.Atoi(strings.TrimSpace(input))
|
|
if err != nil {
|
|
ctx.Sess.Color(session.AnsiFgRed)
|
|
ctx.Sess.WriteString(" Not a number.\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.ReadKey(idleTimeout)
|
|
return current, false
|
|
}
|
|
if v < min || v > max {
|
|
ctx.Sess.Color(session.AnsiFgRed)
|
|
ctx.Sess.Printf(" Must be %d-%d.\r\n", min, max)
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.ReadKey(idleTimeout)
|
|
return current, false
|
|
}
|
|
if v == current {
|
|
return current, false
|
|
}
|
|
return v, true
|
|
}
|
|
|
|
// sysopReadInt64 is like sysopReadInt but for int64 fields (time values).
|
|
func sysopReadInt64(ctx *Context, label string, current, min, max int64) (int64, bool) {
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.Printf(" %s [%d]: ", label, current)
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
|
|
input, err := ctx.Sess.ReadLine("", 10, inputTimeout)
|
|
if err != nil || strings.TrimSpace(input) == "" {
|
|
return current, false
|
|
}
|
|
|
|
v, err := strconv.ParseInt(strings.TrimSpace(input), 10, 64)
|
|
if err != nil {
|
|
ctx.Sess.Color(session.AnsiFgRed)
|
|
ctx.Sess.WriteString(" Not a number.\r\n")
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.ReadKey(idleTimeout)
|
|
return current, false
|
|
}
|
|
if v < min || v > max {
|
|
ctx.Sess.Color(session.AnsiFgRed)
|
|
ctx.Sess.Printf(" Must be %d-%d.\r\n", min, max)
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
ctx.Sess.ReadKey(idleTimeout)
|
|
return current, false
|
|
}
|
|
if v == current {
|
|
return current, false
|
|
}
|
|
return v, true
|
|
}
|
|
|
|
// --- Display helpers ---
|
|
|
|
// sysopField displays a labeled field in the account view.
|
|
// The letter key prefix matches the original's Display_Account format.
|
|
func sysopField(ctx *Context, key, label, value string) {
|
|
ctx.Sess.Color(session.AnsiFgBrightWhite)
|
|
if key == " " {
|
|
ctx.Sess.WriteString(" ")
|
|
} else {
|
|
ctx.Sess.Printf(" %s> ", key)
|
|
}
|
|
ctx.Sess.Color(session.AnsiFgCyan)
|
|
ctx.Sess.Printf("%-16s ", label+":")
|
|
ctx.Sess.Color(session.AnsiFgWhite)
|
|
ctx.Sess.Printf("%s\r\n", value)
|
|
ctx.Sess.Color(session.AnsiReset)
|
|
}
|
|
|
|
// sysopFieldInt displays a labeled integer field.
|
|
func sysopFieldInt(ctx *Context, key, label string, value int) {
|
|
sysopField(ctx, key, label, strconv.Itoa(value))
|
|
}
|
|
|
|
// statusToColor returns an ANSI color code for a security status level.
|
|
func statusToColor(secStatus int) string {
|
|
switch {
|
|
case secStatus == 0:
|
|
return session.AnsiFgBrightBlack // Guest
|
|
case secStatus == 1:
|
|
return session.AnsiFgYellow // New
|
|
case secStatus >= 2 && secStatus < 100:
|
|
return session.AnsiFgGreen // Valid
|
|
case secStatus >= 100 && secStatus < 150:
|
|
return session.AnsiFgCyan // BoardOp
|
|
case secStatus >= 150 && secStatus < 255:
|
|
return session.AnsiFgBlue // LibOp
|
|
case secStatus == 255:
|
|
return session.AnsiFgRed // Sysop
|
|
default:
|
|
return session.AnsiFgWhite
|
|
}
|
|
}
|
|
|
|
// fmtSeconds formats a seconds count as a human-readable duration.
|
|
func fmtSeconds(secs int64) string {
|
|
if secs <= 0 {
|
|
return "0s"
|
|
}
|
|
h := secs / 3600
|
|
m := (secs % 3600) / 60
|
|
if h > 0 {
|
|
return fmt.Sprintf("%dh %dm", h, m)
|
|
}
|
|
return fmt.Sprintf("%dm", m)
|
|
}
|