diff --git a/README.md b/README.md index aeab1c4..75535fe 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Key settings: - `auth.accept_after` — accept login after N failures per IP (default `10`) - `auth.credential_ttl` — how long to remember accepted credentials (default `24h`) - `auth.static_credentials` — always-accepted username/password pairs (optional `shell` field routes to a specific shell) -- Available shells: `bash` (fake Linux shell), `fridge` (Samsung Smart Fridge OS) +- Available shells: `bash` (fake Linux shell), `fridge` (Samsung Smart Fridge OS), `banking` (80s-style bank terminal TUI) - `storage.db_path` — SQLite database path (default `oubliette.db`) - `storage.retention_days` — auto-prune records older than N days (default `90`) - `storage.retention_interval` — how often to run retention (default `1h`) diff --git a/cmd/oubliette/main.go b/cmd/oubliette/main.go index 06936e5..c4af8ad 100644 --- a/cmd/oubliette/main.go +++ b/cmd/oubliette/main.go @@ -19,7 +19,7 @@ import ( "git.t-juice.club/torjus/oubliette/internal/web" ) -const Version = "0.4.0" +const Version = "0.5.0" func main() { if err := run(); err != nil { diff --git a/go.mod b/go.mod index f41df74..a8e8eab 100644 --- a/go.mod +++ b/go.mod @@ -4,18 +4,35 @@ go 1.25.5 require ( github.com/BurntSushi/toml v1.6.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/google/uuid v1.6.0 golang.org/x/crypto v0.48.0 modernc.org/sqlite v1.45.0 ) require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 10c12aa..bea8c35 100644 --- a/go.sum +++ b/go.sum @@ -1,34 +1,70 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= diff --git a/internal/server/server.go b/internal/server/server.go index bd76650..d53f359 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -17,6 +17,7 @@ import ( "git.t-juice.club/torjus/oubliette/internal/detection" "git.t-juice.club/torjus/oubliette/internal/notify" "git.t-juice.club/torjus/oubliette/internal/shell" + "git.t-juice.club/torjus/oubliette/internal/shell/banking" "git.t-juice.club/torjus/oubliette/internal/shell/bash" "git.t-juice.club/torjus/oubliette/internal/shell/fridge" "git.t-juice.club/torjus/oubliette/internal/storage" @@ -42,6 +43,9 @@ func New(cfg config.Config, store storage.Store, logger *slog.Logger) (*Server, if err := registry.Register(fridge.NewFridgeShell(), 1); err != nil { return nil, fmt.Errorf("registering fridge shell: %w", err) } + if err := registry.Register(banking.NewBankingShell(), 1); err != nil { + return nil, fmt.Errorf("registering banking shell: %w", err) + } s := &Server{ cfg: cfg, diff --git a/internal/shell/banking/banking.go b/internal/shell/banking/banking.go new file mode 100644 index 0000000..8764985 --- /dev/null +++ b/internal/shell/banking/banking.go @@ -0,0 +1,74 @@ +package banking + +import ( + "context" + "fmt" + "io" + "math/rand/v2" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "git.t-juice.club/torjus/oubliette/internal/shell" +) + +const sessionTimeout = 10 * time.Minute + +// BankingShell is an 80s-style green-on-black bank terminal TUI. +type BankingShell struct{} + +// NewBankingShell returns a new BankingShell instance. +func NewBankingShell() *BankingShell { + return &BankingShell{} +} + +func (b *BankingShell) Name() string { return "banking" } +func (b *BankingShell) Description() string { return "80s-style banking terminal TUI" } + +func (b *BankingShell) Handle(ctx context.Context, sess *shell.SessionContext, rw io.ReadWriteCloser) error { + ctx, cancel := context.WithTimeout(ctx, sessionTimeout) + defer cancel() + + bankName := configString(sess.ShellConfig, "bank_name", "SECUREBANK") + terminalID := configString(sess.ShellConfig, "terminal_id", "") + region := configString(sess.ShellConfig, "region", "NORTHEAST") + + if terminalID == "" { + terminalID = fmt.Sprintf("SB-%04d", rand.IntN(10000)) + } + + m := newModel(sess, bankName, terminalID, region) + p := tea.NewProgram(m, + tea.WithInput(rw), + tea.WithOutput(rw), + tea.WithAltScreen(), + ) + + done := make(chan error, 1) + go func() { + _, err := p.Run() + done <- err + }() + + select { + case err := <-done: + return err + case <-ctx.Done(): + p.Quit() + <-done + return nil + } +} + +// configString reads a string from the shell config map with a default. +func configString(cfg map[string]any, key, defaultVal string) string { + if cfg == nil { + return defaultVal + } + if v, ok := cfg[key]; ok { + if s, ok := v.(string); ok && s != "" { + return s + } + } + return defaultVal +} diff --git a/internal/shell/banking/banking_test.go b/internal/shell/banking/banking_test.go new file mode 100644 index 0000000..1cd90d6 --- /dev/null +++ b/internal/shell/banking/banking_test.go @@ -0,0 +1,533 @@ +package banking + +import ( + "context" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "git.t-juice.club/torjus/oubliette/internal/shell" + "git.t-juice.club/torjus/oubliette/internal/storage" +) + +// newTestModel creates a model with a test session context. +func newTestModel(t *testing.T) (*model, *storage.MemoryStore) { + t.Helper() + store := storage.NewMemoryStore() + sessID, _ := store.CreateSession(context.Background(), "127.0.0.1", "banker", "banking") + sess := &shell.SessionContext{ + SessionID: sessID, + Username: "banker", + Store: store, + } + m := newModel(sess, "SECUREBANK", "SB-0001", "NORTHEAST") + return m, store +} + +// sendKeys sends a string of characters as individual key messages to the model. +func sendKeys(m *model, s string) { + for _, ch := range s { + var msg tea.KeyMsg + switch ch { + case '\r': + msg = tea.KeyMsg{Type: tea.KeyEnter} + case '\x1b': + msg = tea.KeyMsg{Type: tea.KeyEscape} + case '\x03': + msg = tea.KeyMsg{Type: tea.KeyCtrlC} + default: + msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{ch}} + } + m.Update(msg) + } +} + +func TestBankingShellName(t *testing.T) { + sh := NewBankingShell() + if sh.Name() != "banking" { + t.Errorf("Name() = %q, want %q", sh.Name(), "banking") + } + if sh.Description() == "" { + t.Error("Description() should not be empty") + } +} + +func TestFormatCurrency(t *testing.T) { + tests := []struct { + cents int64 + want string + }{ + {0, "$0.00"}, + {100, "$1.00"}, + {4738291, "$47,382.91"}, + {18254100, "$182,541.00"}, + {52387450, "$523,874.50"}, + {25000000, "$250,000.00"}, + {-125000, "-$1,250.00"}, + {99, "$0.99"}, + } + for _, tt := range tests { + got := formatCurrency(tt.cents) + if got != tt.want { + t.Errorf("formatCurrency(%d) = %q, want %q", tt.cents, got, tt.want) + } + } +} + +func TestNewBankState(t *testing.T) { + state := newBankState() + if len(state.Accounts) != 4 { + t.Errorf("expected 4 accounts, got %d", len(state.Accounts)) + } + for _, acct := range state.Accounts { + txns, ok := state.Transactions[acct.Number] + if !ok { + t.Errorf("no transactions for account %s", acct.Number) + continue + } + if len(txns) == 0 { + t.Errorf("account %s has no transactions", acct.Number) + } + } + if len(state.Messages) != 4 { + t.Errorf("expected 4 messages, got %d", len(state.Messages)) + } +} + +func TestLoginScreenRenders(t *testing.T) { + m, _ := newTestModel(t) + + view := m.View() + if !strings.Contains(view, "SECUREBANK") { + t.Error("login should show bank name") + } + if !strings.Contains(view, "AUTHORIZED ACCESS ONLY") { + t.Error("login should show authorization warning") + } + if !strings.Contains(view, "ACCOUNT NUMBER") { + t.Error("login should prompt for account number") + } +} + +func TestLoginFlow(t *testing.T) { + m, _ := newTestModel(t) + + // Type account number. + sendKeys(m, "12345678") + view := m.View() + if !strings.Contains(view, "12345678") { + t.Error("should show typed account number") + } + + // Press enter. + sendKeys(m, "\r") + view = m.View() + if !strings.Contains(view, "PIN") { + t.Error("should show PIN prompt after entering account number") + } + + // Type PIN and enter. + sendKeys(m, "1234\r") + + // Should be on menu now. + if m.screen != screenMenu { + t.Errorf("expected screenMenu, got %d", m.screen) + } +} + +func TestMainMenuRenders(t *testing.T) { + m, _ := newTestModel(t) + sendKeys(m, "12345678\r1234\r") + + view := m.View() + if !strings.Contains(view, "MAIN MENU") { + t.Error("should show MAIN MENU after login") + } + if !strings.Contains(view, "WIRE TRANSFER") { + t.Error("menu should contain WIRE TRANSFER option") + } + if !strings.Contains(view, "SECURE MESSAGES") { + t.Error("menu should contain SECURE MESSAGES option") + } + if !strings.Contains(view, "ACCOUNT SUMMARY") { + t.Error("menu should contain ACCOUNT SUMMARY option") + } +} + +func TestAccountSummary(t *testing.T) { + m, _ := newTestModel(t) + sendKeys(m, "12345678\r1234\r") // login + sendKeys(m, "1\r") // account summary + + if m.screen != screenAccountSummary { + t.Fatalf("expected screenAccountSummary, got %d", m.screen) + } + + view := m.View() + if !strings.Contains(view, "ACCOUNT SUMMARY") { + t.Error("should show ACCOUNT SUMMARY") + } + if !strings.Contains(view, "CHECKING") { + t.Error("should show CHECKING account") + } + if !strings.Contains(view, "SAVINGS") { + t.Error("should show SAVINGS account") + } + if !strings.Contains(view, "TOTAL") { + t.Error("should show TOTAL") + } + + // Press any key to return. + sendKeys(m, " ") + if m.screen != screenMenu { + t.Errorf("should return to menu, got screen %d", m.screen) + } +} + +func TestWireTransferFlow(t *testing.T) { + m, _ := newTestModel(t) + sendKeys(m, "12345678\r1234\r") // login + sendKeys(m, "3\r") // wire transfer + + if m.screen != screenTransfer { + t.Fatalf("expected screenTransfer, got %d", m.screen) + } + + view := m.View() + if !strings.Contains(view, "WIRE TRANSFER") { + t.Error("should show WIRE TRANSFER header") + } + + // Fill all fields. + sendKeys(m, "021000021\r") // routing + sendKeys(m, "9876543210\r") // dest account + sendKeys(m, "JOHN DOE\r") // beneficiary + sendKeys(m, "FIRST NATIONAL BANK\r") // bank name + sendKeys(m, "50000\r") // amount + sendKeys(m, "INVOICE 12345\r") // memo + + // Should be on confirm step. + view = m.View() + if !strings.Contains(view, "TRANSFER SUMMARY") { + t.Error("should show TRANSFER SUMMARY for confirmation") + } + if !strings.Contains(view, "021000021") { + t.Error("summary should show routing number") + } + + // Confirm. + sendKeys(m, "Y\r") + + // Auth code. + sendKeys(m, "AUTH99\r") + + view = m.View() + if !strings.Contains(view, "TRANSFER QUEUED") { + t.Error("should show TRANSFER QUEUED confirmation") + } + + // Check wire transfer was stored. + if len(m.state.Transfers) != 1 { + t.Fatalf("expected 1 transfer, got %d", len(m.state.Transfers)) + } + wt := m.state.Transfers[0] + if wt.RoutingNumber != "021000021" { + t.Errorf("routing = %q, want %q", wt.RoutingNumber, "021000021") + } + if wt.DestAccount != "9876543210" { + t.Errorf("dest = %q, want %q", wt.DestAccount, "9876543210") + } + if wt.Beneficiary != "JOHN DOE" { + t.Errorf("beneficiary = %q, want %q", wt.Beneficiary, "JOHN DOE") + } + + // Press key to return to menu. + sendKeys(m, " ") + if m.screen != screenMenu { + t.Errorf("should return to menu after transfer, got screen %d", m.screen) + } +} + +func TestWireTransferCancel(t *testing.T) { + m, _ := newTestModel(t) + sendKeys(m, "12345678\r1234\r") // login + sendKeys(m, "3\r") // wire transfer + sendKeys(m, "021000021\r") + sendKeys(m, "9876543210\r") + sendKeys(m, "JOHN DOE\r") + sendKeys(m, "FIRST NATIONAL BANK\r") + sendKeys(m, "50000\r") + sendKeys(m, "INVOICE 12345\r") + + // Cancel at confirm step. + sendKeys(m, "N\r") + + if m.screen != screenMenu { + t.Errorf("should return to menu after cancel, got screen %d", m.screen) + } +} + +func TestTransactionHistory(t *testing.T) { + m, _ := newTestModel(t) + sendKeys(m, "12345678\r1234\r") // login + sendKeys(m, "4\r") // transaction history + + if m.screen != screenHistory { + t.Fatalf("expected screenHistory, got %d", m.screen) + } + + view := m.View() + if !strings.Contains(view, "TRANSACTION HISTORY") { + t.Error("should show TRANSACTION HISTORY header") + } + + // Select first account. + sendKeys(m, "1\r") + view = m.View() + if !strings.Contains(view, "DATE") { + t.Error("should show transaction list with DATE column") + } + if !strings.Contains(view, "PAGE") { + t.Error("should show page indicator") + } + + // Press B to go back to account list. + sendKeys(m, "B") + view = m.View() + if !strings.Contains(view, "SELECT ACCOUNT") { + t.Error("should return to account selection") + } + + // Press 0 to return to menu. + sendKeys(m, "0\r") + if m.screen != screenMenu { + t.Errorf("should return to menu, got screen %d", m.screen) + } +} + +func TestSecureMessages(t *testing.T) { + m, _ := newTestModel(t) + sendKeys(m, "12345678\r1234\r") // login + sendKeys(m, "5\r") // secure messages + + if m.screen != screenMessages { + t.Fatalf("expected screenMessages, got %d", m.screen) + } + + view := m.View() + if !strings.Contains(view, "SECURE MESSAGES") { + t.Error("should show SECURE MESSAGES header") + } + if !strings.Contains(view, "SCHEDULED MAINTENANCE") { + t.Error("should show first message subject") + } + + // View first message. + sendKeys(m, "1\r") + view = m.View() + if !strings.Contains(view, "10.48.2.100") { + t.Error("message body should contain breadcrumb IP") + } + + // Press key to return to list. + sendKeys(m, " ") + + // Return to menu. + sendKeys(m, "0\r") + if m.screen != screenMenu { + t.Errorf("should return to menu, got screen %d", m.screen) + } +} + +func TestAdminAccessDenied(t *testing.T) { + m, _ := newTestModel(t) + sendKeys(m, "12345678\r1234\r") // login + sendKeys(m, "99\r") // admin + + if m.screen != screenAdmin { + t.Fatalf("expected screenAdmin, got %d", m.screen) + } + + view := m.View() + if !strings.Contains(view, "SYSTEM ADMINISTRATION") { + t.Error("should show SYSTEM ADMINISTRATION header") + } + + // Three failed PIN attempts. + sendKeys(m, "secret1\r") + view = m.View() + if !strings.Contains(view, "INVALID CREDENTIALS") { + t.Error("should show INVALID CREDENTIALS after first attempt") + } + + sendKeys(m, "secret2\r") + sendKeys(m, "secret3\r") + + view = m.View() + if !strings.Contains(view, "ACCESS DENIED") { + t.Error("should show ACCESS DENIED after 3 attempts") + } + if !strings.Contains(view, "ABEND S0C4") { + t.Error("should show COBOL-style error") + } + + // Press key to return to menu. + sendKeys(m, " ") + if m.screen != screenMenu { + t.Errorf("should return to menu after lockout, got screen %d", m.screen) + } +} + +func TestAdminEscapeReturns(t *testing.T) { + m, _ := newTestModel(t) + sendKeys(m, "12345678\r1234\r") // login + sendKeys(m, "99\r") // admin + sendKeys(m, "\x1b") // ESC to return + + if m.screen != screenMenu { + t.Errorf("ESC should return to menu from admin, got screen %d", m.screen) + } +} + +func TestChangePin(t *testing.T) { + m, _ := newTestModel(t) + sendKeys(m, "12345678\r1234\r") // login + sendKeys(m, "6\r") // change PIN + + if m.screen != screenChangePin { + t.Fatalf("expected screenChangePin, got %d", m.screen) + } + + view := m.View() + if !strings.Contains(view, "CHANGE PIN") { + t.Error("should show CHANGE PIN header") + } + + // Old PIN. + sendKeys(m, "1234\r") + // New PIN. + sendKeys(m, "5678\r") + // Confirm PIN. + sendKeys(m, "5678\r") + + view = m.View() + if !strings.Contains(view, "PIN CHANGED SUCCESSFULLY") { + t.Error("should show PIN CHANGED SUCCESSFULLY") + } + + // Press key to return. + sendKeys(m, " ") + if m.screen != screenMenu { + t.Errorf("should return to menu after PIN change, got screen %d", m.screen) + } +} + +func TestLogoutExits(t *testing.T) { + m, _ := newTestModel(t) + sendKeys(m, "12345678\r1234\r") // login + sendKeys(m, "7\r") // logout + + if !m.quitting { + t.Error("should be quitting after logout") + } +} + +func TestSessionLogs(t *testing.T) { + m, store := newTestModel(t) + + // Send login keys and manually run returned commands. + // Type account number. + sendKeys(m, "12345678") + // Enter to advance to PIN. + sendKeys(m, "\r") + // Type PIN. + sendKeys(m, "1234") + // Enter to login — this returns a logAction cmd. + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd != nil { + // Execute the batch of commands (login log). + execCmds(cmd) + } + + // Give async store writes a moment. + time.Sleep(50 * time.Millisecond) + + if len(store.SessionLogs) == 0 { + t.Error("expected session logs to be recorded after login") + } + + found := false + for _, log := range store.SessionLogs { + if strings.Contains(log.Input, "LOGIN") { + found = true + break + } + } + if !found { + t.Error("expected a LOGIN entry in session logs") + } + + // Navigate to account summary — also returns a logAction cmd. + sendKeys(m, "1") + _, cmd = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd != nil { + execCmds(cmd) + } + time.Sleep(50 * time.Millisecond) + + foundMenu := false + for _, log := range store.SessionLogs { + if strings.Contains(log.Input, "MENU") { + foundMenu = true + break + } + } + if !foundMenu { + t.Error("expected a MENU entry in session logs for account summary") + } +} + +// execCmds recursively executes tea.Cmd functions (including batches). +func execCmds(cmd tea.Cmd) { + if cmd == nil { + return + } + msg := cmd() + // tea.BatchMsg is a slice of Cmds returned by tea.Batch. + if batch, ok := msg.(tea.BatchMsg); ok { + for _, c := range batch { + execCmds(c) + } + } +} + +func TestConfigString(t *testing.T) { + cfg := map[string]any{ + "bank_name": "TESTBANK", + "region": "SOUTHWEST", + } + if got := configString(cfg, "bank_name", "DEFAULT"); got != "TESTBANK" { + t.Errorf("configString() = %q, want %q", got, "TESTBANK") + } + if got := configString(cfg, "missing", "DEFAULT"); got != "DEFAULT" { + t.Errorf("configString() = %q, want %q", got, "DEFAULT") + } + if got := configString(nil, "bank_name", "DEFAULT"); got != "DEFAULT" { + t.Errorf("configString(nil) = %q, want %q", got, "DEFAULT") + } +} + +func TestScreenFrame(t *testing.T) { + frame := screenFrame("TESTBANK", "TB-0001", "NORTHEAST", "content here") + if !strings.Contains(frame, "TESTBANK FEDERAL RESERVE SYSTEM") { + t.Error("frame should contain bank name in header") + } + if !strings.Contains(frame, "TB-0001") { + t.Error("frame should contain terminal ID in footer") + } + if !strings.Contains(frame, "content here") { + t.Error("frame should contain the content") + } +} diff --git a/internal/shell/banking/data.go b/internal/shell/banking/data.go new file mode 100644 index 0000000..09d8c9a --- /dev/null +++ b/internal/shell/banking/data.go @@ -0,0 +1,258 @@ +package banking + +import ( + "fmt" + "time" +) + +// Account types. +const ( + AcctChecking = "CHECKING" + AcctSavings = "SAVINGS" + AcctMoneyMarket = "MONEY MARKET" + AcctCertDeposit = "CERT OF DEPOSIT" +) + +// Account represents a fake bank account. +type Account struct { + Number string + Type string + Balance int64 // cents +} + +// Transaction represents a fake bank transaction. +type Transaction struct { + Date string + Description string + Amount int64 // cents (negative for debits) + Balance int64 // running balance in cents +} + +// SecureMessage represents a fake internal message. +type SecureMessage struct { + ID int + Date string + From string + Subj string + Body string + Unread bool +} + +// WireTransfer captures data entered during the wire transfer wizard. +type WireTransfer struct { + RoutingNumber string + DestAccount string + Beneficiary string + BankName string + Amount string + Memo string + AuthCode string +} + +// bankState holds all fake data for a session. +type bankState struct { + Accounts []Account + Transactions map[string][]Transaction // keyed by account number + Messages []SecureMessage + Transfers []WireTransfer +} + +func newBankState() *bankState { + now := time.Now() + + accounts := []Account{ + {Number: "****4821", Type: AcctChecking, Balance: 4738291}, + {Number: "****7203", Type: AcctSavings, Balance: 18254100}, + {Number: "****9915", Type: AcctMoneyMarket, Balance: 52387450}, + {Number: "****1102", Type: AcctCertDeposit, Balance: 25000000}, + } + + transactions := make(map[string][]Transaction) + transactions["****4821"] = generateCheckingTxns(now, accounts[0].Balance) + transactions["****7203"] = generateSavingsTxns(now, accounts[1].Balance) + transactions["****9915"] = generateMoneyMarketTxns(now, accounts[2].Balance) + transactions["****1102"] = generateCDTxns(now, accounts[3].Balance) + + messages := []SecureMessage{ + { + ID: 1, + Date: now.Add(-2 * 24 * time.Hour).Format("01/02/2006"), + From: "SYSTEM ADMINISTRATOR", + Subj: "SCHEDULED MAINTENANCE WINDOW", + Unread: true, + Body: fmt.Sprintf(`FROM: SYSTEM ADMINISTRATOR +DATE: %s +RE: SCHEDULED MAINTENANCE WINDOW + +ALL TERMINALS WILL BE OFFLINE FOR MAINTENANCE: + DATE: %s + TIME: 02:00 - 04:00 EST + AFFECTED: ALL REGIONS + +DURING THIS WINDOW, THE FOLLOWING SYSTEMS WILL BE UNAVAILABLE: + - WIRE TRANSFER PROCESSING (10.48.2.100:8443) + - ACCOUNT MANAGEMENT (10.48.2.101:8443) + - ACH BATCH PROCESSOR (10.48.2.105:9090) + +PLEASE ENSURE ALL PENDING TRANSACTIONS ARE SUBMITTED BEFORE 01:30 EST. + +CONTACT: HELPDESK EXT 4400 OR ops-support@internal.securebank.local`, + now.Add(-2*24*time.Hour).Format("01/02/2006 15:04"), + now.Add(5*24*time.Hour).Format("01/02/2006")), + }, + { + ID: 2, + Date: now.Add(-5 * 24 * time.Hour).Format("01/02/2006"), + From: "COMPLIANCE DEPT", + Subj: "QUARTERLY AUDIT REMINDER", + Unread: true, + Body: `FROM: COMPLIANCE DEPT +RE: QUARTERLY AUDIT REMINDER + +ALL BRANCH MANAGERS: + +THE Q4 COMPLIANCE AUDIT IS SCHEDULED FOR NEXT WEEK. +PLEASE ENSURE THE FOLLOWING ARE CURRENT: + + 1. TRANSACTION LOGS EXPORTED TO \\FILESERV01\AUDIT\Q4 + 2. VAULT ACCESS CODES ROTATED (LAST ROTATION: SEE VAULT-MGMT PORTAL) + 3. EMPLOYEE ACCESS REVIEWS COMPLETED IN IAM PORTAL (https://iam.internal:8443) + +NOTE: DEFAULT CREDENTIALS FOR THE AUDIT PORTAL HAVE BEEN RESET. +NEW CREDENTIALS DISTRIBUTED VIA SECURE COURIER. +REFERENCE: AUDIT-2024-Q4-0847 + +VAULT MASTER CODE HINT: FIRST 4 OF ROUTING + BRANCH ZIP (STANDARD FORMAT)`, + }, + { + ID: 3, + Date: now.Add(-8 * 24 * time.Hour).Format("01/02/2006"), + From: "IT SECURITY", + Subj: "PASSWORD POLICY UPDATE", + Unread: false, + Body: `FROM: IT SECURITY +RE: PASSWORD POLICY UPDATE - EFFECTIVE IMMEDIATELY + +ALL STAFF: + +PER FEDERAL BANKING REGULATION 12 CFR 748, THE FOLLOWING +PASSWORD POLICY IS NOW IN EFFECT: + + - MINIMUM 12 CHARACTERS + - MUST CONTAIN UPPERCASE, LOWERCASE, NUMBER, SPECIAL CHAR + - 90-DAY ROTATION CYCLE + - NO REUSE OF LAST 24 PASSWORDS + +LEGACY SYSTEM ACCOUNTS (MAINFRAME, AS/400) ARE EXEMPT UNTIL +MIGRATION IS COMPLETE. CURRENT LEGACY ACCESS: + MAINFRAME: telnet://10.48.1.50:23 (CICS REGION PROD1) + AS/400: tn5250://10.48.1.55 (SUBSYSTEM QINTER) + +SERVICE ACCOUNT PASSWORDS ARE MANAGED VIA CYBERARK: + https://pam.internal.securebank.local:8443 + +TICKET: SEC-2024-1847`, + }, + { + ID: 4, + Date: now.Add(-12 * 24 * time.Hour).Format("01/02/2006"), + From: "WIRE OPERATIONS", + Subj: "FEDWIRE CUTOFF TIME CHANGE", + Unread: false, + Body: `FROM: WIRE OPERATIONS +RE: FEDWIRE CUTOFF TIME CHANGE + +EFFECTIVE NEXT MONDAY, FEDWIRE CUTOFF TIMES ARE: + DOMESTIC WIRES: 16:30 EST (WAS 17:00) + INTERNATIONAL WIRES: 14:00 EST (NO CHANGE) + BOOK TRANSFERS: 17:30 EST (NO CHANGE) + +WIRES SUBMITTED AFTER CUTOFF WILL BE QUEUED FOR NEXT +BUSINESS DAY PROCESSING. + +FOR EMERGENCY SAME-DAY PROCESSING AFTER CUTOFF: + CONTACT WIRE ROOM: EXT 4450 + AUTH CODE REQUIRED (OBTAIN FROM BRANCH MANAGER) + APPROVAL CHAIN: OPS-MGR -> VP-WIRE -> SVP-TREASURY + +CORRESPONDENT BANK CONTACTS: + JPMORGAN: wire.ops@jpmc.com / 212-555-0147 + CITI: fedwire@citi.com / 212-555-0283`, + }, + } + + return &bankState{ + Accounts: accounts, + Transactions: transactions, + Messages: messages, + } +} + +func generateCheckingTxns(now time.Time, endBalance int64) []Transaction { + txns := []Transaction{ + {Description: "ACH DEPOSIT - PAYROLL", Amount: 485000}, + {Description: "CHECK #1847", Amount: -125000}, + {Description: "POS DEBIT - WHOLE FOODS #1284", Amount: -18743}, + {Description: "ATM WITHDRAWAL - MAIN ST BRANCH", Amount: -40000}, + {Description: "ACH DEBIT - MORTGAGE PMT", Amount: -215000}, + {Description: "WIRE TRANSFER IN - REF#8847201", Amount: 1250000}, + {Description: "POS DEBIT - SHELL OIL #4492", Amount: -6821}, + {Description: "ACH DEPOSIT - PAYROLL", Amount: 485000}, + {Description: "CHECK #1848", Amount: -75000}, + {Description: "ONLINE TRANSFER TO SAVINGS", Amount: -100000}, + {Description: "POS DEBIT - AMAZON.COM", Amount: -14599}, + {Description: "ACH DEBIT - ELECTRIC COMPANY", Amount: -18742}, + {Description: "ATM WITHDRAWAL - PARK AVE BRANCH", Amount: -20000}, + {Description: "WIRE TRANSFER OUT - REF#9014882", Amount: -500000}, + {Description: "POS DEBIT - COSTCO #0441", Amount: -28734}, + {Description: "ACH DEPOSIT - TAX REFUND", Amount: 342100}, + } + return populateTransactions(txns, now, endBalance) +} + +func generateSavingsTxns(now time.Time, endBalance int64) []Transaction { + txns := []Transaction{ + {Description: "INTEREST PAYMENT", Amount: 4521}, + {Description: "ONLINE TRANSFER FROM CHECKING", Amount: 100000}, + {Description: "INTEREST PAYMENT", Amount: 4633}, + {Description: "ACH DEPOSIT - DIVIDEND PMT", Amount: 125000}, + {Description: "ONLINE TRANSFER FROM CHECKING", Amount: 200000}, + {Description: "INTEREST PAYMENT", Amount: 4748}, + {Description: "WITHDRAWAL - TRANSFER TO MM", Amount: -500000}, + {Description: "INTEREST PAYMENT", Amount: 4812}, + } + return populateTransactions(txns, now, endBalance) +} + +func generateMoneyMarketTxns(now time.Time, endBalance int64) []Transaction { + txns := []Transaction{ + {Description: "INTEREST PAYMENT - TIER 3 RATE", Amount: 21847}, + {Description: "DEPOSIT - TRANSFER FROM SAVINGS", Amount: 500000}, + {Description: "INTEREST PAYMENT - TIER 3 RATE", Amount: 22105}, + {Description: "WITHDRAWAL - WIRE TRANSFER", Amount: -1000000}, + {Description: "DEPOSIT - ACH TRANSFER", Amount: 750000}, + {Description: "INTEREST PAYMENT - TIER 3 RATE", Amount: 22394}, + } + return populateTransactions(txns, now, endBalance) +} + +func generateCDTxns(now time.Time, endBalance int64) []Transaction { + txns := []Transaction{ + {Description: "CERTIFICATE OPENED - 12MO TERM", Amount: 25000000}, + {Description: "INTEREST ACCRUAL", Amount: 10417}, + {Description: "INTEREST ACCRUAL", Amount: 10417}, + {Description: "INTEREST ACCRUAL", Amount: 10417}, + } + return populateTransactions(txns, now, endBalance) +} + +func populateTransactions(txns []Transaction, now time.Time, endBalance int64) []Transaction { + // Work backwards from end balance to assign dates and running balances. + bal := endBalance + for i := len(txns) - 1; i >= 0; i-- { + txns[i].Balance = bal + txns[i].Date = now.Add(time.Duration(-(len(txns) - i)) * 3 * 24 * time.Hour).Format("01/02/2006") + bal -= txns[i].Amount + } + return txns +} diff --git a/internal/shell/banking/model.go b/internal/shell/banking/model.go new file mode 100644 index 0000000..c45bb94 --- /dev/null +++ b/internal/shell/banking/model.go @@ -0,0 +1,422 @@ +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 + } +} diff --git a/internal/shell/banking/screen_accounts.go b/internal/shell/banking/screen_accounts.go new file mode 100644 index 0000000..f78dc19 --- /dev/null +++ b/internal/shell/banking/screen_accounts.go @@ -0,0 +1,213 @@ +package banking + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +// --- Account Summary --- + +type accountSummaryModel struct { + accounts []Account +} + +func newAccountSummaryModel(accounts []Account) accountSummaryModel { + return accountSummaryModel{accounts: accounts} +} + +func (m accountSummaryModel) Update(_ tea.Msg) (accountSummaryModel, tea.Cmd) { + // Any key returns to menu. + return m, nil +} + +func (m accountSummaryModel) View() string { + var b strings.Builder + + b.WriteString("\n") + b.WriteString(centerText("ACCOUNT SUMMARY")) + b.WriteString("\n\n") + b.WriteString(thinDivider()) + b.WriteString("\n\n") + + // Header. + b.WriteString(titleStyle.Render(fmt.Sprintf(" %-12s %-18s %18s", "ACCOUNT", "TYPE", "BALANCE"))) + b.WriteString("\n") + b.WriteString(dimStyle.Render(" " + strings.Repeat("-", 50))) + b.WriteString("\n") + + total := int64(0) + for _, acct := range m.accounts { + total += acct.Balance + b.WriteString(baseStyle.Render(fmt.Sprintf(" %-12s %-18s %18s", + acct.Number, acct.Type, formatCurrency(acct.Balance)))) + b.WriteString("\n") + } + + b.WriteString(dimStyle.Render(" " + strings.Repeat("-", 50))) + b.WriteString("\n") + b.WriteString(titleStyle.Render(fmt.Sprintf(" %-12s %-18s %18s", "", "TOTAL", formatCurrency(total)))) + b.WriteString("\n\n") + b.WriteString(thinDivider()) + b.WriteString("\n\n") + b.WriteString(dimStyle.Render(" PRESS ANY KEY TO RETURN TO MAIN MENU")) + b.WriteString("\n") + + return b.String() +} + +// --- Account Detail (with transactions) --- + +type accountDetailModel struct { + accounts []Account + transactions map[string][]Transaction + selected int + page int + pageSize int + choosing bool + choice string +} + +func newAccountDetailModel(accounts []Account, transactions map[string][]Transaction) accountDetailModel { + return accountDetailModel{ + accounts: accounts, + transactions: transactions, + choosing: true, + pageSize: 10, + } +} + +func (m accountDetailModel) Update(msg tea.Msg) (accountDetailModel, 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 accountDetailModel) View() string { + if m.choosing { + return m.viewChooseAccount() + } + return m.viewDetail() +} + +func (m accountDetailModel) viewChooseAccount() string { + var b strings.Builder + + b.WriteString("\n") + b.WriteString(centerText("ACCOUNT DETAIL")) + 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 %s", + i+1, acct.Number, acct.Type, formatCurrency(acct.Balance)))) + 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 accountDetailModel) viewDetail() string { + var b strings.Builder + + acct := m.accounts[m.selected] + txns := m.transactions[acct.Number] + + b.WriteString("\n") + b.WriteString(centerText(fmt.Sprintf("ACCOUNT DETAIL - %s", acct.Number))) + b.WriteString("\n\n") + + b.WriteString(baseStyle.Render(fmt.Sprintf(" TYPE: %s", acct.Type))) + b.WriteString("\n") + b.WriteString(baseStyle.Render(fmt.Sprintf(" BALANCE: %s", formatCurrency(acct.Balance)))) + b.WriteString("\n\n") + b.WriteString(thinDivider()) + b.WriteString("\n\n") + + // Header. + b.WriteString(titleStyle.Render(fmt.Sprintf(" %-12s %-34s %12s %12s", "DATE", "DESCRIPTION", "AMOUNT", "BALANCE"))) + b.WriteString("\n") + b.WriteString(dimStyle.Render(" " + strings.Repeat("-", 72))) + b.WriteString("\n") + + // Paginate. + 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 %-34s %12s %12s", + txn.Date, txn.Description, formatCurrency(txn.Amount), formatCurrency(txn.Balance)))) + 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() +} diff --git a/internal/shell/banking/screen_admin.go b/internal/shell/banking/screen_admin.go new file mode 100644 index 0000000..a690bde --- /dev/null +++ b/internal/shell/banking/screen_admin.go @@ -0,0 +1,111 @@ +package banking + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +type adminModel struct { + pin string + attempts int + locked bool +} + +func newAdminModel() adminModel { + return adminModel{} +} + +func (m adminModel) Update(msg tea.Msg) (adminModel, tea.Cmd) { + if m.locked { + return m, nil + } + + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + + switch keyMsg.Type { + case tea.KeyEnter: + if m.pin != "" { + m.attempts++ + if m.attempts >= 3 { + m.locked = true + } + m.pin = "" + } + case tea.KeyBackspace: + if len(m.pin) > 0 { + m.pin = m.pin[:len(m.pin)-1] + } + default: + ch := keyMsg.String() + if len(ch) == 1 && ch[0] >= 32 && ch[0] < 127 && len(m.pin) < 20 { + m.pin += ch + } + } + return m, nil +} + +func (m adminModel) View() string { + var b strings.Builder + + b.WriteString("\n") + b.WriteString(centerText("SYSTEM ADMINISTRATION")) + b.WriteString("\n\n") + b.WriteString(thinDivider()) + b.WriteString("\n\n") + + if m.locked { + b.WriteString(errorStyle.Render(" *** ACCESS DENIED ***")) + b.WriteString("\n\n") + b.WriteString(errorStyle.Render(" MAXIMUM AUTHENTICATION ATTEMPTS EXCEEDED")) + b.WriteString("\n") + b.WriteString(errorStyle.Render(" TERMINAL LOCKED - INCIDENT LOGGED")) + b.WriteString("\n\n") + b.WriteString(baseStyle.Render(" SECURITY ALERT HAS BEEN DISPATCHED TO:")) + b.WriteString("\n") + b.WriteString(baseStyle.Render(" - INFORMATION SECURITY DEPT")) + b.WriteString("\n") + b.WriteString(baseStyle.Render(" - BRANCH SECURITY OFFICER")) + b.WriteString("\n") + b.WriteString(baseStyle.Render(" - FEDERAL RESERVE OVERSIGHT")) + b.WriteString("\n\n") + b.WriteString(baseStyle.Render(fmt.Sprintf(" INCIDENT REF: SEC-%d-ADMIN-BRUTE", 20240000+m.attempts))) + b.WriteString("\n\n") + b.WriteString(dimStyle.Render(" IEF4271I UNAUTHORIZED ACCESS ATTEMPT - ABEND S0C4")) + b.WriteString("\n") + b.WriteString(dimStyle.Render(" IEF4272I JOB SECADMIN STEP0001 - COND CODE 4088")) + b.WriteString("\n\n") + b.WriteString(thinDivider()) + b.WriteString("\n\n") + b.WriteString(dimStyle.Render(" PRESS ANY KEY TO RETURN TO MAIN MENU")) + b.WriteString("\n") + } else { + b.WriteString(titleStyle.Render(" RESTRICTED ACCESS - ADMINISTRATOR ONLY")) + b.WriteString("\n\n") + b.WriteString(baseStyle.Render(" THIS FUNCTION REQUIRES LEVEL 5 SECURITY CLEARANCE.")) + b.WriteString("\n") + b.WriteString(baseStyle.Render(" ALL ACCESS ATTEMPTS ARE LOGGED AND AUDITED.")) + b.WriteString("\n\n") + + if m.attempts > 0 { + b.WriteString(errorStyle.Render(fmt.Sprintf(" INVALID CREDENTIALS (%d OF 3 ATTEMPTS)", m.attempts))) + b.WriteString("\n\n") + } + + b.WriteString(titleStyle.Render(" ADMIN PIN: ")) + masked := strings.Repeat("*", len(m.pin)) + b.WriteString(inputStyle.Render(masked)) + b.WriteString(inputStyle.Render("_")) + b.WriteString("\n\n") + b.WriteString(thinDivider()) + b.WriteString("\n\n") + b.WriteString(dimStyle.Render(" PRESS ESC TO RETURN TO MAIN MENU")) + b.WriteString("\n") + } + + return b.String() +} diff --git a/internal/shell/banking/screen_history.go b/internal/shell/banking/screen_history.go new file mode 100644 index 0000000..38d878c --- /dev/null +++ b/internal/shell/banking/screen_history.go @@ -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() +} diff --git a/internal/shell/banking/screen_login.go b/internal/shell/banking/screen_login.go new file mode 100644 index 0000000..f8aca1b --- /dev/null +++ b/internal/shell/banking/screen_login.go @@ -0,0 +1,103 @@ +package banking + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +type loginModel struct { + accountNum string + pin string + stage int // 0 = account, 1 = pin, 2 = authenticating + bankName string +} + +func newLoginModel(bankName string) loginModel { + return loginModel{bankName: bankName} +} + +func (m loginModel) Update(msg tea.Msg) (loginModel, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + + switch keyMsg.Type { + case tea.KeyEnter: + if m.stage == 0 && m.accountNum != "" { + m.stage = 1 + return m, nil + } + if m.stage == 1 && m.pin != "" { + m.stage = 2 + return m, nil + } + case tea.KeyBackspace: + if m.stage == 0 && len(m.accountNum) > 0 { + m.accountNum = m.accountNum[:len(m.accountNum)-1] + } else if m.stage == 1 && len(m.pin) > 0 { + m.pin = m.pin[:len(m.pin)-1] + } + default: + ch := keyMsg.String() + if len(ch) == 1 && ch[0] >= 32 && ch[0] < 127 { + if m.stage == 0 && len(m.accountNum) < 20 { + m.accountNum += ch + } else if m.stage == 1 && len(m.pin) < 12 { + m.pin += ch + } + } + } + return m, nil +} + +func (m loginModel) View() string { + var b strings.Builder + + b.WriteString("\n") + b.WriteString(centerText(m.bankName + " ONLINE BANKING")) + b.WriteString("\n") + b.WriteString(centerText("AUTHORIZED ACCESS ONLY")) + b.WriteString("\n\n") + b.WriteString(thinDivider()) + b.WriteString("\n\n") + + b.WriteString(titleStyle.Render(" ACCOUNT NUMBER: ")) + if m.stage == 0 { + b.WriteString(inputStyle.Render(m.accountNum)) + b.WriteString(inputStyle.Render("_")) + } else { + b.WriteString(baseStyle.Render(m.accountNum)) + } + b.WriteString("\n\n") + + if m.stage >= 1 { + b.WriteString(titleStyle.Render(" PIN: ")) + masked := strings.Repeat("*", len(m.pin)) + if m.stage == 1 { + b.WriteString(inputStyle.Render(masked)) + b.WriteString(inputStyle.Render("_")) + } else { + b.WriteString(baseStyle.Render(masked)) + } + b.WriteString("\n") + } + + if m.stage == 2 { + b.WriteString("\n") + b.WriteString(baseStyle.Render(" AUTHENTICATING...")) + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(thinDivider()) + b.WriteString("\n\n") + b.WriteString(dimStyle.Render(" WARNING: UNAUTHORIZED ACCESS TO THIS SYSTEM IS A FEDERAL CRIME")) + b.WriteString("\n") + b.WriteString(dimStyle.Render(fmt.Sprintf(" UNDER 18 U.S.C. %s 1030. ALL ACTIVITY IS MONITORED AND LOGGED.", "\u00A7"))) + b.WriteString("\n") + + return b.String() +} diff --git a/internal/shell/banking/screen_menu.go b/internal/shell/banking/screen_menu.go new file mode 100644 index 0000000..75bc660 --- /dev/null +++ b/internal/shell/banking/screen_menu.go @@ -0,0 +1,80 @@ +package banking + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +type menuModel struct { + choice string + unread int + bankName string +} + +func newMenuModel(bankName string, unreadCount int) menuModel { + return menuModel{bankName: bankName, unread: unreadCount} +} + +func (m menuModel) Update(msg tea.Msg) (menuModel, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + + switch keyMsg.Type { + case tea.KeyEnter: + 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] >= 32 && ch[0] < 127 { + if len(m.choice) < 10 { + m.choice += ch + } + } + } + return m, nil +} + +func (m menuModel) View() string { + var b strings.Builder + + b.WriteString("\n") + b.WriteString(centerText("MAIN MENU")) + b.WriteString("\n\n") + b.WriteString(thinDivider()) + b.WriteString("\n\n") + + items := []struct { + num string + desc string + }{ + {"1", "ACCOUNT SUMMARY"}, + {"2", "ACCOUNT DETAIL / TRANSACTIONS"}, + {"3", "WIRE TRANSFER"}, + {"4", "TRANSACTION HISTORY"}, + {"5", fmt.Sprintf("SECURE MESSAGES (%d UNREAD)", m.unread)}, + {"6", "CHANGE PIN"}, + {"7", "LOGOUT"}, + } + + for _, item := range items { + b.WriteString(baseStyle.Render(fmt.Sprintf(" [%s] %s", item.num, item.desc))) + b.WriteString("\n") + } + + b.WriteString("\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() +} diff --git a/internal/shell/banking/screen_messages.go b/internal/shell/banking/screen_messages.go new file mode 100644 index 0000000..af7ec75 --- /dev/null +++ b/internal/shell/banking/screen_messages.go @@ -0,0 +1,122 @@ +package banking + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +type messagesModel struct { + messages []SecureMessage + viewing int // -1 = list, >= 0 = detail + choice string +} + +func newMessagesModel(messages []SecureMessage) messagesModel { + return messagesModel{messages: messages, viewing: -1} +} + +func (m messagesModel) Update(msg tea.Msg) (messagesModel, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + + if m.viewing >= 0 { + // In detail view, any key goes back to list. + m.viewing = -1 + m.choice = "" + return m, nil + } + + switch keyMsg.Type { + case tea.KeyEnter: + if m.choice == "0" { + m.choice = "back" + return m, nil + } + for i := range m.messages { + if m.choice == fmt.Sprintf("%d", i+1) { + m.viewing = i + m.messages[i].Unread = false + return m, nil + } + } + m.choice = "" + 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' && len(m.choice) < 2 { + m.choice += ch + } + } + return m, nil +} + +func (m messagesModel) View() string { + if m.viewing >= 0 { + return m.viewDetail() + } + return m.viewList() +} + +func (m messagesModel) viewList() string { + var b strings.Builder + + b.WriteString("\n") + b.WriteString(centerText("SECURE MESSAGES")) + b.WriteString("\n\n") + b.WriteString(thinDivider()) + b.WriteString("\n\n") + + b.WriteString(titleStyle.Render(fmt.Sprintf(" %-4s %-3s %-12s %-22s %s", "#", "", "DATE", "FROM", "SUBJECT"))) + b.WriteString("\n") + b.WriteString(dimStyle.Render(" " + strings.Repeat("-", 68))) + b.WriteString("\n") + + for i, msg := range m.messages { + marker := " " + if msg.Unread { + marker = " * " + } + b.WriteString(baseStyle.Render(fmt.Sprintf(" [%d]%s%-12s %-22s %s", + i+1, marker, msg.Date, msg.From, msg.Subj))) + 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(" SELECT MESSAGE: ")) + b.WriteString(inputStyle.Render(m.choice)) + b.WriteString(inputStyle.Render("_")) + b.WriteString("\n") + + return b.String() +} + +func (m messagesModel) viewDetail() string { + var b strings.Builder + + msg := m.messages[m.viewing] + + b.WriteString("\n") + b.WriteString(centerText(fmt.Sprintf("MESSAGE #%d", m.viewing+1))) + b.WriteString("\n\n") + b.WriteString(thinDivider()) + b.WriteString("\n\n") + b.WriteString(baseStyle.Render(msg.Body)) + b.WriteString("\n\n") + b.WriteString(thinDivider()) + b.WriteString("\n\n") + b.WriteString(dimStyle.Render(" PRESS ANY KEY TO RETURN TO MESSAGE LIST")) + b.WriteString("\n") + + return b.String() +} diff --git a/internal/shell/banking/screen_transfer.go b/internal/shell/banking/screen_transfer.go new file mode 100644 index 0000000..7be75c2 --- /dev/null +++ b/internal/shell/banking/screen_transfer.go @@ -0,0 +1,194 @@ +package banking + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +const ( + transferStepRouting = iota + transferStepDest + transferStepBeneficiary + transferStepBankName + transferStepAmount + transferStepMemo + transferStepConfirm + transferStepAuthCode + transferStepComplete +) + +var transferPrompts = []string{ + " ROUTING NUMBER (ABA): ", + " DESTINATION ACCOUNT: ", + " BENEFICIARY NAME: ", + " RECEIVING BANK NAME: ", + " AMOUNT (USD): ", + " MEMO / REFERENCE: ", + "", + " AUTHORIZATION CODE: ", +} + +type transferModel struct { + step int + fields [8]string // indexed by step + transfer WireTransfer + confirm string // y/n input for confirm step +} + +func newTransferModel() transferModel { + return transferModel{} +} + +func (m transferModel) Update(msg tea.Msg) (transferModel, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + + if m.step == transferStepComplete { + return m, nil + } + + if m.step == transferStepConfirm { + switch keyMsg.Type { + case tea.KeyEnter: + switch strings.ToUpper(m.confirm) { + case "Y", "YES": + m.step = transferStepAuthCode + case "N", "NO": + m.confirm = "cancelled" + } + return m, nil + case tea.KeyBackspace: + if len(m.confirm) > 0 { + m.confirm = m.confirm[:len(m.confirm)-1] + } + default: + ch := keyMsg.String() + if len(ch) == 1 && ch[0] >= 32 && ch[0] < 127 && len(m.confirm) < 3 { + m.confirm += ch + } + } + return m, nil + } + + switch keyMsg.Type { + case tea.KeyEnter: + val := strings.TrimSpace(m.fields[m.step]) + if val == "" { + return m, nil + } + if m.step == transferStepAuthCode { + m.transfer.AuthCode = val + m.step = transferStepComplete + return m, nil + } + switch m.step { + case transferStepRouting: + m.transfer.RoutingNumber = val + case transferStepDest: + m.transfer.DestAccount = val + case transferStepBeneficiary: + m.transfer.Beneficiary = val + case transferStepBankName: + m.transfer.BankName = val + case transferStepAmount: + m.transfer.Amount = val + case transferStepMemo: + m.transfer.Memo = val + } + m.step++ + return m, nil + case tea.KeyBackspace: + if len(m.fields[m.step]) > 0 { + m.fields[m.step] = m.fields[m.step][:len(m.fields[m.step])-1] + } + default: + ch := keyMsg.String() + if len(ch) == 1 && ch[0] >= 32 && ch[0] < 127 && len(m.fields[m.step]) < 40 { + m.fields[m.step] += ch + } + } + return m, nil +} + +func (m transferModel) View() string { + var b strings.Builder + + b.WriteString("\n") + b.WriteString(centerText("WIRE TRANSFER")) + b.WriteString("\n\n") + b.WriteString(thinDivider()) + b.WriteString("\n\n") + + // Show completed fields. + for i := 0; i < m.step && i < len(transferPrompts); i++ { + if i == transferStepConfirm { + continue + } + prompt := transferPrompts[i] + if prompt == "" { + continue + } + b.WriteString(baseStyle.Render(prompt)) + b.WriteString(baseStyle.Render(m.fields[i])) + b.WriteString("\n") + } + + // Current field. + switch { + case m.step == transferStepConfirm: + b.WriteString("\n") + b.WriteString(titleStyle.Render(" === TRANSFER SUMMARY ===")) + b.WriteString("\n\n") + b.WriteString(baseStyle.Render(fmt.Sprintf(" ROUTING: %s", m.transfer.RoutingNumber))) + b.WriteString("\n") + b.WriteString(baseStyle.Render(fmt.Sprintf(" ACCOUNT: %s", m.transfer.DestAccount))) + b.WriteString("\n") + b.WriteString(baseStyle.Render(fmt.Sprintf(" BENEFICIARY: %s", m.transfer.Beneficiary))) + b.WriteString("\n") + b.WriteString(baseStyle.Render(fmt.Sprintf(" BANK: %s", m.transfer.BankName))) + b.WriteString("\n") + b.WriteString(baseStyle.Render(fmt.Sprintf(" AMOUNT: $%s", m.transfer.Amount))) + b.WriteString("\n") + b.WriteString(baseStyle.Render(fmt.Sprintf(" MEMO: %s", m.transfer.Memo))) + b.WriteString("\n\n") + b.WriteString(titleStyle.Render(" CONFIRM TRANSFER? (Y/N): ")) + b.WriteString(inputStyle.Render(m.confirm)) + b.WriteString(inputStyle.Render("_")) + b.WriteString("\n") + case m.step == transferStepComplete: + b.WriteString("\n") + b.WriteString(thinDivider()) + b.WriteString("\n\n") + b.WriteString(titleStyle.Render(" TRANSFER QUEUED FOR PROCESSING")) + b.WriteString("\n\n") + b.WriteString(baseStyle.Render(fmt.Sprintf(" CONFIRMATION #: WR-%s-%s", + m.transfer.RoutingNumber[:4], "847291"))) + b.WriteString("\n") + b.WriteString(baseStyle.Render(" STATUS: PENDING FEDWIRE SETTLEMENT")) + b.WriteString("\n") + b.WriteString(baseStyle.Render(" ESTIMATED COMPLETION: NEXT BUSINESS DAY")) + b.WriteString("\n\n") + b.WriteString(dimStyle.Render(" PRESS ANY KEY TO RETURN TO MAIN MENU")) + b.WriteString("\n") + case m.step < len(transferPrompts): + prompt := transferPrompts[m.step] + b.WriteString(titleStyle.Render(prompt)) + b.WriteString(inputStyle.Render(m.fields[m.step])) + b.WriteString(inputStyle.Render("_")) + b.WriteString("\n") + } + + if m.step < transferStepConfirm { + b.WriteString("\n") + b.WriteString(thinDivider()) + b.WriteString("\n\n") + b.WriteString(dimStyle.Render(fmt.Sprintf(" STEP %d OF 6", m.step+1))) + b.WriteString("\n") + } + + return b.String() +} diff --git a/internal/shell/banking/style.go b/internal/shell/banking/style.go new file mode 100644 index 0000000..f516b6b --- /dev/null +++ b/internal/shell/banking/style.go @@ -0,0 +1,128 @@ +package banking + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +const termWidth = 80 + +// Color palette — green-on-black retro terminal. +var ( + colorGreen = lipgloss.Color("#00FF00") + colorDim = lipgloss.Color("#007700") + colorBlack = lipgloss.Color("#000000") + colorBright = lipgloss.Color("#AAFFAA") + colorRed = lipgloss.Color("#FF3333") +) + +// Reusable styles. +var ( + baseStyle = lipgloss.NewStyle(). + Foreground(colorGreen). + Background(colorBlack) + + headerStyle = lipgloss.NewStyle(). + Foreground(colorBright). + Background(colorBlack). + Bold(true). + Width(termWidth). + Align(lipgloss.Center) + + titleStyle = lipgloss.NewStyle(). + Foreground(colorGreen). + Background(colorBlack). + Bold(true) + + dimStyle = lipgloss.NewStyle(). + Foreground(colorDim). + Background(colorBlack) + + errorStyle = lipgloss.NewStyle(). + Foreground(colorRed). + Background(colorBlack). + Bold(true) + + inputStyle = lipgloss.NewStyle(). + Foreground(colorBright). + Background(colorBlack) +) + +// divider returns an 80-column === line. +func divider() string { + return dimStyle.Render(strings.Repeat("=", termWidth)) +} + +// thinDivider returns an 80-column --- line. +func thinDivider() string { + return dimStyle.Render(strings.Repeat("-", termWidth)) +} + +// centerText centers text within 80 columns. +func centerText(s string) string { + return headerStyle.Render(s) +} + +// padRight pads a string to the given width. +func padRight(s string, width int) string { + if len(s) >= width { + return s[:width] + } + return s + strings.Repeat(" ", width-len(s)) +} + +// formatCurrency formats cents as $X,XXX.XX +func formatCurrency(cents int64) string { + negative := cents < 0 + if negative { + cents = -cents + } + dollars := cents / 100 + remainder := cents % 100 + + // Add thousands separators. + ds := fmt.Sprintf("%d", dollars) + if len(ds) > 3 { + var parts []string + for len(ds) > 3 { + parts = append([]string{ds[len(ds)-3:]}, parts...) + ds = ds[:len(ds)-3] + } + parts = append([]string{ds}, parts...) + ds = strings.Join(parts, ",") + } + + if negative { + return fmt.Sprintf("-$%s.%02d", ds, remainder) + } + return fmt.Sprintf("$%s.%02d", ds, remainder) +} + +// screenFrame wraps content in the persistent header and footer. +func screenFrame(bankName, terminalID, region, content string) string { + var b strings.Builder + + // Header. + b.WriteString(divider()) + b.WriteString("\n") + b.WriteString(centerText(bankName + " FEDERAL RESERVE SYSTEM")) + b.WriteString("\n") + b.WriteString(centerText("SECURE BANKING TERMINAL")) + b.WriteString("\n") + b.WriteString(divider()) + b.WriteString("\n") + + // Content. + b.WriteString(content) + + // Footer. + b.WriteString("\n") + b.WriteString(divider()) + b.WriteString("\n") + footer := fmt.Sprintf(" TERMINAL: %s | REGION: %s | ENCRYPTED SESSION ACTIVE", terminalID, region) + b.WriteString(dimStyle.Render(padRight(footer, termWidth))) + + return b.String() +} diff --git a/oubliette.toml.example b/oubliette.toml.example index 10d3ef1..2112f8c 100644 --- a/oubliette.toml.example +++ b/oubliette.toml.example @@ -24,6 +24,11 @@ password = "admin" # password = "fridge" # shell = "fridge" +# [[auth.static_credentials]] +# username = "teller" +# password = "banking" +# shell = "banking" + [storage] db_path = "oubliette.db" retention_days = 90 @@ -38,6 +43,12 @@ hostname = "ubuntu-server" # banner = "Welcome to Ubuntu 22.04.3 LTS (GNU/Linux 5.15.0-89-generic x86_64)\r\n\r\n" # fake_user = "" # override username in prompt; empty = use authenticated user +# Per-shell configuration (optional). +# [shell.banking] +# bank_name = "SECUREBANK" +# terminal_id = "SB-0001" # random if not set +# region = "NORTHEAST" + # [detection] # enabled = true # threshold = 0.6 # 0.0–1.0, sessions above this trigger notifications