Update Go module path and all import references to reflect the migration from Gitea (git.t-juice.club) to Forgejo (code.t-juice.club). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
354 lines
8.9 KiB
Go
354 lines
8.9 KiB
Go
package banking
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"code.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
|
|
changePin changePinModel
|
|
}
|
|
|
|
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.changePin.View()
|
|
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.changePin = newChangePinModel()
|
|
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) {
|
|
prevStage := m.changePin.stage
|
|
var cmd tea.Cmd
|
|
m.changePin, cmd = m.changePin.Update(msg)
|
|
|
|
// Log successful PIN change.
|
|
if m.changePin.stage == 3 && prevStage != 3 {
|
|
cmd = tea.Batch(cmd, logAction(m.sess, "CHANGE PIN", "PIN CHANGED SUCCESSFULLY"))
|
|
}
|
|
|
|
if m.changePin.done {
|
|
return m, tea.Batch(cmd, m.goToMenu())
|
|
}
|
|
return m, cmd
|
|
}
|
|
|
|
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)
|
|
}
|
|
if sess.OnCommand != nil {
|
|
sess.OnCommand("banking")
|
|
}
|
|
return nil
|
|
}
|
|
}
|