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 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 } if keyMsg, ok := msg.(tea.KeyMsg); ok { if keyMsg.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) } // --- 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") m.goToMenu() return m, tea.Batch(cmd, logCmd) } 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, logAction(m.sess, "MENU 1", "ACCOUNT SUMMARY") case "2": m.screen = screenAccountDetail m.detail = newAccountDetailModel(m.state.Accounts, m.state.Transactions) return m, logAction(m.sess, "MENU 2", "ACCOUNT DETAIL") case "3": m.screen = screenTransfer m.transfer = newTransferModel() return m, logAction(m.sess, "MENU 3", "WIRE TRANSFER") case "4": m.screen = screenHistory m.history = newHistoryModel(m.state.Accounts, m.state.Transactions) return m, logAction(m.sess, "MENU 4", "TRANSACTION HISTORY") case "5": m.screen = screenMessages m.messages = newMessagesModel(m.state.Messages) return m, logAction(m.sess, "MENU 5", "SECURE MESSAGES") case "6": m.screen = screenChangePin m.pinInput = "" m.pinStage = 0 m.pinMessage = "" return m, 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, 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 { 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" { 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" { m.goToMenu() return m, logAction(m.sess, "WIRE TRANSFER CANCELLED", "USER CANCELLED") } // 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 { 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" { 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" { m.goToMenu() return m, cmd } // 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 { m.goToMenu() return m, nil } 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: m.goToMenu() return m, nil 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 { m.goToMenu() return m, nil } 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, 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 { m.goToMenu() } } return m, cmd } func (m *model) goToMenu() { unread := 0 for _, msg := range m.state.Messages { if msg.Unread { unread++ } } m.screen = screenMenu m.menu = newMenuModel(m.bankName, unread) } // 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 } }