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