feat: add Banking TUI shell using bubbletea

Add an 80s-style green-on-black bank terminal shell ("banking") using
charmbracelet/bubbletea for full-screen TUI rendering over SSH.

Screens: login, main menu, account summary, account detail with
transactions, wire transfer wizard (6-step form capturing routing
number, destination, beneficiary, amount, memo, auth code), transaction
history with pagination, secure messages with breadcrumb content (fake
internal IPs, vault codes), change PIN, and hidden admin access (99)
that locks after 3 failed attempts with COBOL-style error output.

All key actions (login, navigation, wire transfers, admin attempts) are
logged to the session store. Wire transfer data is the honeypot gold.

Configurable via [shell.banking] in TOML: bank_name, terminal_id, region.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 23:17:12 +01:00
parent 462c44ce89
commit 8ff029fcb7
18 changed files with 2466 additions and 8 deletions

View File

@@ -0,0 +1,152 @@
package banking
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
type historyModel struct {
accounts []Account
transactions map[string][]Transaction
selected int
page int
pageSize int
choosing bool
choice string
}
func newHistoryModel(accounts []Account, transactions map[string][]Transaction) historyModel {
return historyModel{
accounts: accounts,
transactions: transactions,
choosing: true,
pageSize: 12,
}
}
func (m historyModel) Update(msg tea.Msg) (historyModel, tea.Cmd) {
keyMsg, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
if m.choosing {
switch keyMsg.Type {
case tea.KeyEnter:
for i := range m.accounts {
if m.choice == fmt.Sprintf("%d", i+1) {
m.selected = i
m.choosing = false
m.page = 0
break
}
}
if m.choice == "0" {
m.choice = "back"
}
return m, nil
case tea.KeyBackspace:
if len(m.choice) > 0 {
m.choice = m.choice[:len(m.choice)-1]
}
default:
ch := keyMsg.String()
if len(ch) == 1 && ch[0] >= '0' && ch[0] <= '9' {
m.choice += ch
}
}
} else {
switch keyMsg.String() {
case "n", "N":
acctNum := m.accounts[m.selected].Number
txns := m.transactions[acctNum]
maxPage := (len(txns) - 1) / m.pageSize
if m.page < maxPage {
m.page++
}
case "p", "P":
if m.page > 0 {
m.page--
}
case "b", "B":
m.choosing = true
m.choice = ""
default:
m.choice = "back"
}
}
return m, nil
}
func (m historyModel) View() string {
if m.choosing {
return m.viewChooseAccount()
}
return m.viewHistory()
}
func (m historyModel) viewChooseAccount() string {
var b strings.Builder
b.WriteString("\n")
b.WriteString(centerText("TRANSACTION HISTORY"))
b.WriteString("\n\n")
b.WriteString(thinDivider())
b.WriteString("\n\n")
b.WriteString(titleStyle.Render(" SELECT ACCOUNT:"))
b.WriteString("\n\n")
for i, acct := range m.accounts {
b.WriteString(baseStyle.Render(fmt.Sprintf(" [%d] %s - %s",
i+1, acct.Number, acct.Type)))
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(baseStyle.Render(" [0] RETURN TO MAIN MENU"))
b.WriteString("\n\n")
b.WriteString(thinDivider())
b.WriteString("\n\n")
b.WriteString(titleStyle.Render(" ENTER SELECTION: "))
b.WriteString(inputStyle.Render(m.choice))
b.WriteString(inputStyle.Render("_"))
b.WriteString("\n")
return b.String()
}
func (m historyModel) viewHistory() string {
var b strings.Builder
acct := m.accounts[m.selected]
txns := m.transactions[acct.Number]
b.WriteString("\n")
b.WriteString(centerText(fmt.Sprintf("TRANSACTION HISTORY - %s (%s)", acct.Number, acct.Type)))
b.WriteString("\n\n")
b.WriteString(titleStyle.Render(fmt.Sprintf(" %-12s %-40s %14s", "DATE", "DESCRIPTION", "AMOUNT")))
b.WriteString("\n")
b.WriteString(dimStyle.Render(" " + strings.Repeat("-", 68)))
b.WriteString("\n")
start := m.page * m.pageSize
end := min(start+m.pageSize, len(txns))
for _, txn := range txns[start:end] {
b.WriteString(baseStyle.Render(fmt.Sprintf(" %-12s %-40s %14s",
txn.Date, txn.Description, formatCurrency(txn.Amount))))
b.WriteString("\n")
}
b.WriteString("\n")
totalPages := (len(txns) + m.pageSize - 1) / m.pageSize
b.WriteString(dimStyle.Render(fmt.Sprintf(" PAGE %d OF %d", m.page+1, totalPages)))
b.WriteString("\n\n")
b.WriteString(dimStyle.Render(" [N]EXT PAGE [P]REV PAGE [B]ACK ANY OTHER KEY = MAIN MENU"))
b.WriteString("\n")
return b.String()
}