urit/internal/menu/sysop.go
2026-05-02 21:11:50 -04:00

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)
}