This repository has been archived on 2026-03-09. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
oubliette/internal/shell/banking/model.go
Torjus Håkestad d226c32b9b fix: banking shell screen rendering artifacts and transfer panic
Fix rendering issues where content from previous screens bled through
when switching between views of different heights/widths:

- Pad every line to full terminal width (ANSI-aware) so shorter lines
  overwrite leftover content from previous renders
- Track terminal height via WindowSizeMsg and pad between content and
  footer to fill the screen
- Send tea.ClearScreen on all screen transitions for height changes
- Fix panic in transfer completion when routing number is < 4 chars

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 00:50:34 +01:00

431 lines
11 KiB
Go

package banking
import (
"context"
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"git.t-juice.club/torjus/oubliette/internal/shell"
)
type screen int
const (
screenLogin screen = iota
screenMenu
screenAccountSummary
screenAccountDetail
screenTransfer
screenHistory
screenMessages
screenChangePin
screenAdmin
)
type model struct {
sess *shell.SessionContext
bankName string
terminalID string
region string
state *bankState
screen screen
quitting bool
height int
login loginModel
menu menuModel
summary accountSummaryModel
detail accountDetailModel
transfer transferModel
history historyModel
messages messagesModel
admin adminModel
pinInput string
pinStage int // 0=old, 1=new, 2=confirm, 3=done
pinMessage string
}
func newModel(sess *shell.SessionContext, bankName, terminalID, region string) *model {
state := newBankState()
unread := 0
for _, msg := range state.Messages {
if msg.Unread {
unread++
}
}
return &model{
sess: sess,
bankName: bankName,
terminalID: terminalID,
region: region,
state: state,
screen: screenLogin,
login: newLoginModel(bankName),
}
}
func (m *model) Init() tea.Cmd {
return nil
}
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.quitting {
return m, tea.Quit
}
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.height = msg.Height
return m, nil
case tea.KeyMsg:
if msg.Type == tea.KeyCtrlC {
m.quitting = true
return m, tea.Quit
}
}
switch m.screen {
case screenLogin:
return m.updateLogin(msg)
case screenMenu:
return m.updateMenu(msg)
case screenAccountSummary:
return m.updateAccountSummary(msg)
case screenAccountDetail:
return m.updateAccountDetail(msg)
case screenTransfer:
return m.updateTransfer(msg)
case screenHistory:
return m.updateHistory(msg)
case screenMessages:
return m.updateMessages(msg)
case screenChangePin:
return m.updateChangePin(msg)
case screenAdmin:
return m.updateAdmin(msg)
}
return m, nil
}
func (m *model) View() string {
var content string
switch m.screen {
case screenLogin:
content = m.login.View()
case screenMenu:
content = m.menu.View()
case screenAccountSummary:
content = m.summary.View()
case screenAccountDetail:
content = m.detail.View()
case screenTransfer:
content = m.transfer.View()
case screenHistory:
content = m.history.View()
case screenMessages:
content = m.messages.View()
case screenChangePin:
content = m.viewChangePin()
case screenAdmin:
content = m.admin.View()
}
return screenFrame(m.bankName, m.terminalID, m.region, content, m.height)
}
// --- Screen update handlers ---
func (m *model) updateLogin(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.login, cmd = m.login.Update(msg)
if m.login.stage == 2 {
// Login always succeeds — this is a honeypot.
logCmd := logAction(m.sess, fmt.Sprintf("LOGIN acct=%s", m.login.accountNum), "ACCESS GRANTED")
clearCmd := m.goToMenu()
return m, tea.Batch(cmd, logCmd, clearCmd)
}
return m, cmd
}
func (m *model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.menu, cmd = m.menu.Update(msg)
if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.Type == tea.KeyEnter {
choice := strings.TrimSpace(m.menu.choice)
switch choice {
case "1":
m.screen = screenAccountSummary
m.summary = newAccountSummaryModel(m.state.Accounts)
return m, tea.Batch(tea.ClearScreen, logAction(m.sess, "MENU 1", "ACCOUNT SUMMARY"))
case "2":
m.screen = screenAccountDetail
m.detail = newAccountDetailModel(m.state.Accounts, m.state.Transactions)
return m, tea.Batch(tea.ClearScreen, logAction(m.sess, "MENU 2", "ACCOUNT DETAIL"))
case "3":
m.screen = screenTransfer
m.transfer = newTransferModel()
return m, tea.Batch(tea.ClearScreen, logAction(m.sess, "MENU 3", "WIRE TRANSFER"))
case "4":
m.screen = screenHistory
m.history = newHistoryModel(m.state.Accounts, m.state.Transactions)
return m, tea.Batch(tea.ClearScreen, logAction(m.sess, "MENU 4", "TRANSACTION HISTORY"))
case "5":
m.screen = screenMessages
m.messages = newMessagesModel(m.state.Messages)
return m, tea.Batch(tea.ClearScreen, logAction(m.sess, "MENU 5", "SECURE MESSAGES"))
case "6":
m.screen = screenChangePin
m.pinInput = ""
m.pinStage = 0
m.pinMessage = ""
return m, tea.Batch(tea.ClearScreen, logAction(m.sess, "MENU 6", "CHANGE PIN"))
case "7":
m.quitting = true
return m, tea.Batch(logAction(m.sess, "LOGOUT", "SESSION ENDED"), tea.Quit)
case "99", "admin", "ADMIN":
m.screen = screenAdmin
m.admin = newAdminModel()
return m, tea.Batch(tea.ClearScreen, logAction(m.sess, "ADMIN ACCESS ATTEMPT", "ADMIN SCREEN SHOWN"))
}
// Invalid choice, reset.
m.menu.choice = ""
}
return m, cmd
}
func (m *model) updateAccountSummary(msg tea.Msg) (tea.Model, tea.Cmd) {
if _, ok := msg.(tea.KeyMsg); ok {
return m, m.goToMenu()
}
return m, nil
}
func (m *model) updateAccountDetail(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.detail, cmd = m.detail.Update(msg)
if m.detail.choice == "back" {
return m, tea.Batch(cmd, m.goToMenu())
}
return m, cmd
}
func (m *model) updateTransfer(msg tea.Msg) (tea.Model, tea.Cmd) {
prevStep := m.transfer.step
var cmd tea.Cmd
m.transfer, cmd = m.transfer.Update(msg)
// Transfer cancelled.
if m.transfer.confirm == "cancelled" {
clearCmd := m.goToMenu()
return m, tea.Batch(clearCmd, logAction(m.sess, "WIRE TRANSFER CANCELLED", "USER CANCELLED"))
}
// Clear screen when transfer steps change content height significantly
// (e.g. confirm→authcode, fields→confirm, authcode→complete).
if m.transfer.step != prevStep {
cmd = tea.Batch(cmd, tea.ClearScreen)
}
// Transfer completed — log it.
if m.transfer.step == transferStepComplete && prevStep != transferStepComplete {
t := m.transfer.transfer
m.state.Transfers = append(m.state.Transfers, t)
logMsg := fmt.Sprintf("WIRE TRANSFER: routing=%s dest=%s beneficiary=%s bank=%s amount=%s memo=%s auth=%s",
t.RoutingNumber, t.DestAccount, t.Beneficiary, t.BankName, t.Amount, t.Memo, t.AuthCode)
return m, tea.Batch(cmd, logAction(m.sess, logMsg, "TRANSFER QUEUED"))
}
// Completed screen → any key goes back.
if m.transfer.step == transferStepComplete {
if _, ok := msg.(tea.KeyMsg); ok && prevStep == transferStepComplete {
return m, tea.Batch(cmd, m.goToMenu())
}
}
return m, cmd
}
func (m *model) updateHistory(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.history, cmd = m.history.Update(msg)
if m.history.choice == "back" {
return m, tea.Batch(cmd, m.goToMenu())
}
return m, cmd
}
func (m *model) updateMessages(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.messages, cmd = m.messages.Update(msg)
if m.messages.choice == "back" {
return m, tea.Batch(cmd, m.goToMenu())
}
// Log when viewing a message.
if m.messages.viewing >= 0 {
idx := m.messages.viewing
return m, tea.Batch(cmd, logAction(m.sess,
fmt.Sprintf("VIEW MESSAGE #%d", idx+1),
m.state.Messages[idx].Subj))
}
return m, cmd
}
func (m *model) updateChangePin(msg tea.Msg) (tea.Model, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
if m.pinStage == 3 {
return m, m.goToMenu()
}
switch keyMsg.Type {
case tea.KeyEnter:
switch m.pinStage {
case 0:
if m.pinInput != "" {
m.pinStage = 1
m.pinInput = ""
}
case 1:
if len(m.pinInput) >= 4 {
m.pinMessage = m.pinInput
m.pinStage = 2
m.pinInput = ""
}
case 2:
if m.pinInput == m.pinMessage {
m.pinStage = 3
return m, logAction(m.sess, "CHANGE PIN", "PIN CHANGED SUCCESSFULLY")
}
m.pinInput = ""
m.pinMessage = ""
m.pinStage = 1
}
case tea.KeyEscape:
return m, m.goToMenu()
case tea.KeyBackspace:
if len(m.pinInput) > 0 {
m.pinInput = m.pinInput[:len(m.pinInput)-1]
}
default:
ch := keyMsg.String()
if len(ch) == 1 && ch[0] >= 32 && ch[0] < 127 && len(m.pinInput) < 12 {
m.pinInput += ch
}
}
return m, nil
}
func (m *model) viewChangePin() string {
var b strings.Builder
b.WriteString("\n")
b.WriteString(centerText("CHANGE PIN"))
b.WriteString("\n\n")
b.WriteString(thinDivider())
b.WriteString("\n\n")
if m.pinStage == 3 {
b.WriteString(titleStyle.Render(" PIN CHANGED SUCCESSFULLY"))
b.WriteString("\n\n")
b.WriteString(baseStyle.Render(" YOUR NEW PIN IS NOW ACTIVE."))
b.WriteString("\n")
b.WriteString(baseStyle.Render(" PLEASE USE YOUR NEW PIN FOR ALL FUTURE TRANSACTIONS."))
b.WriteString("\n\n")
b.WriteString(dimStyle.Render(" PRESS ANY KEY TO RETURN TO MAIN MENU"))
} else {
prompts := []string{" CURRENT PIN: ", " NEW PIN: ", " CONFIRM PIN: "}
for i := 0; i < m.pinStage; i++ {
b.WriteString(baseStyle.Render(prompts[i]))
b.WriteString(baseStyle.Render(strings.Repeat("*", 4)))
b.WriteString("\n")
}
if m.pinStage < 3 {
b.WriteString(titleStyle.Render(prompts[m.pinStage]))
masked := strings.Repeat("*", len(m.pinInput))
b.WriteString(inputStyle.Render(masked))
b.WriteString(inputStyle.Render("_"))
b.WriteString("\n")
}
b.WriteString("\n")
if m.pinStage == 1 {
b.WriteString(dimStyle.Render(" PIN MUST BE AT LEAST 4 CHARACTERS"))
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(dimStyle.Render(" PRESS ESC TO RETURN TO MAIN MENU"))
}
b.WriteString("\n")
return b.String()
}
func (m *model) updateAdmin(msg tea.Msg) (tea.Model, tea.Cmd) {
prevLocked := m.admin.locked
prevAttempts := m.admin.attempts
// Check for ESC before delegating.
if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.Type == tea.KeyEscape && !m.admin.locked {
return m, m.goToMenu()
}
var cmd tea.Cmd
m.admin, cmd = m.admin.Update(msg)
// Log failed attempts.
if m.admin.attempts > prevAttempts {
cmd = tea.Batch(cmd, logAction(m.sess,
fmt.Sprintf("ADMIN PIN ATTEMPT #%d", m.admin.attempts),
"INVALID CREDENTIALS"))
}
// Log lockout.
if m.admin.locked && !prevLocked {
cmd = tea.Batch(cmd, tea.ClearScreen, logAction(m.sess,
"ADMIN LOCKOUT",
"TERMINAL LOCKED - INCIDENT LOGGED"))
}
// If locked and any key pressed, go back.
if m.admin.locked {
if _, ok := msg.(tea.KeyMsg); ok && prevLocked {
return m, tea.Batch(cmd, m.goToMenu())
}
}
return m, cmd
}
func (m *model) goToMenu() tea.Cmd {
unread := 0
for _, msg := range m.state.Messages {
if msg.Unread {
unread++
}
}
m.screen = screenMenu
m.menu = newMenuModel(m.bankName, unread)
return tea.ClearScreen
}
// logAction returns a tea.Cmd that logs an action to the session store.
func logAction(sess *shell.SessionContext, input, output string) tea.Cmd {
return func() tea.Msg {
if sess.Store != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = sess.Store.AppendSessionLog(ctx, sess.SessionID, input, output)
}
return nil
}
}