feat: add Banking TUI shell using bubbletea
Add an 80s-style green-on-black bank terminal shell ("banking") using
charmbracelet/bubbletea for full-screen TUI rendering over SSH.
Screens: login, main menu, account summary, account detail with
transactions, wire transfer wizard (6-step form capturing routing
number, destination, beneficiary, amount, memo, auth code), transaction
history with pagination, secure messages with breadcrumb content (fake
internal IPs, vault codes), change PIN, and hidden admin access (99)
that locks after 3 failed attempts with COBOL-style error output.
All key actions (login, navigation, wire transfers, admin attempts) are
logged to the session store. Wire transfer data is the honeypot gold.
Configurable via [shell.banking] in TOML: bank_name, terminal_id, region.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -34,7 +34,7 @@ Key settings:
|
|||||||
- `auth.accept_after` — accept login after N failures per IP (default `10`)
|
- `auth.accept_after` — accept login after N failures per IP (default `10`)
|
||||||
- `auth.credential_ttl` — how long to remember accepted credentials (default `24h`)
|
- `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)
|
- `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.db_path` — SQLite database path (default `oubliette.db`)
|
||||||
- `storage.retention_days` — auto-prune records older than N days (default `90`)
|
- `storage.retention_days` — auto-prune records older than N days (default `90`)
|
||||||
- `storage.retention_interval` — how often to run retention (default `1h`)
|
- `storage.retention_interval` — how often to run retention (default `1h`)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
"git.t-juice.club/torjus/oubliette/internal/web"
|
"git.t-juice.club/torjus/oubliette/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Version = "0.4.0"
|
const Version = "0.5.0"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := run(); err != nil {
|
if err := run(); err != nil {
|
||||||
|
|||||||
17
go.mod
17
go.mod
@@ -4,18 +4,35 @@ go 1.25.5
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.6.0
|
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
|
github.com/google/uuid v1.6.0
|
||||||
golang.org/x/crypto v0.48.0
|
golang.org/x/crypto v0.48.0
|
||||||
modernc.org/sqlite v1.45.0
|
modernc.org/sqlite v1.45.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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/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-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/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
golang.org/x/sys v0.41.0 // 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/libc v1.67.6 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
|||||||
48
go.sum
48
go.sum
@@ -1,34 +1,70 @@
|
|||||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
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 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
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.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
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.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 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
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 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
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/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
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 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"git.t-juice.club/torjus/oubliette/internal/detection"
|
"git.t-juice.club/torjus/oubliette/internal/detection"
|
||||||
"git.t-juice.club/torjus/oubliette/internal/notify"
|
"git.t-juice.club/torjus/oubliette/internal/notify"
|
||||||
"git.t-juice.club/torjus/oubliette/internal/shell"
|
"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/bash"
|
||||||
"git.t-juice.club/torjus/oubliette/internal/shell/fridge"
|
"git.t-juice.club/torjus/oubliette/internal/shell/fridge"
|
||||||
"git.t-juice.club/torjus/oubliette/internal/storage"
|
"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 {
|
if err := registry.Register(fridge.NewFridgeShell(), 1); err != nil {
|
||||||
return nil, fmt.Errorf("registering fridge shell: %w", err)
|
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{
|
s := &Server{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
|
|||||||
74
internal/shell/banking/banking.go
Normal file
74
internal/shell/banking/banking.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
533
internal/shell/banking/banking_test.go
Normal file
533
internal/shell/banking/banking_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
258
internal/shell/banking/data.go
Normal file
258
internal/shell/banking/data.go
Normal file
@@ -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 <sysadmin@internal.securebank.local>
|
||||||
|
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 <compliance@internal.securebank.local>
|
||||||
|
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 <itsec@internal.securebank.local>
|
||||||
|
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 <wireops@internal.securebank.local>
|
||||||
|
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
|
||||||
|
}
|
||||||
422
internal/shell/banking/model.go
Normal file
422
internal/shell/banking/model.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
213
internal/shell/banking/screen_accounts.go
Normal file
213
internal/shell/banking/screen_accounts.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
111
internal/shell/banking/screen_admin.go
Normal file
111
internal/shell/banking/screen_admin.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
152
internal/shell/banking/screen_history.go
Normal file
152
internal/shell/banking/screen_history.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
package banking
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
|
type historyModel struct {
|
||||||
|
accounts []Account
|
||||||
|
transactions map[string][]Transaction
|
||||||
|
selected int
|
||||||
|
page int
|
||||||
|
pageSize int
|
||||||
|
choosing bool
|
||||||
|
choice string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHistoryModel(accounts []Account, transactions map[string][]Transaction) historyModel {
|
||||||
|
return historyModel{
|
||||||
|
accounts: accounts,
|
||||||
|
transactions: transactions,
|
||||||
|
choosing: true,
|
||||||
|
pageSize: 12,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m historyModel) Update(msg tea.Msg) (historyModel, tea.Cmd) {
|
||||||
|
keyMsg, ok := msg.(tea.KeyMsg)
|
||||||
|
if !ok {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.choosing {
|
||||||
|
switch keyMsg.Type {
|
||||||
|
case tea.KeyEnter:
|
||||||
|
for i := range m.accounts {
|
||||||
|
if m.choice == fmt.Sprintf("%d", i+1) {
|
||||||
|
m.selected = i
|
||||||
|
m.choosing = false
|
||||||
|
m.page = 0
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.choice == "0" {
|
||||||
|
m.choice = "back"
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case tea.KeyBackspace:
|
||||||
|
if len(m.choice) > 0 {
|
||||||
|
m.choice = m.choice[:len(m.choice)-1]
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
ch := keyMsg.String()
|
||||||
|
if len(ch) == 1 && ch[0] >= '0' && ch[0] <= '9' {
|
||||||
|
m.choice += ch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch keyMsg.String() {
|
||||||
|
case "n", "N":
|
||||||
|
acctNum := m.accounts[m.selected].Number
|
||||||
|
txns := m.transactions[acctNum]
|
||||||
|
maxPage := (len(txns) - 1) / m.pageSize
|
||||||
|
if m.page < maxPage {
|
||||||
|
m.page++
|
||||||
|
}
|
||||||
|
case "p", "P":
|
||||||
|
if m.page > 0 {
|
||||||
|
m.page--
|
||||||
|
}
|
||||||
|
case "b", "B":
|
||||||
|
m.choosing = true
|
||||||
|
m.choice = ""
|
||||||
|
default:
|
||||||
|
m.choice = "back"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m historyModel) View() string {
|
||||||
|
if m.choosing {
|
||||||
|
return m.viewChooseAccount()
|
||||||
|
}
|
||||||
|
return m.viewHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m historyModel) viewChooseAccount() string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(centerText("TRANSACTION HISTORY"))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString(thinDivider())
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString(titleStyle.Render(" SELECT ACCOUNT:"))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
for i, acct := range m.accounts {
|
||||||
|
b.WriteString(baseStyle.Render(fmt.Sprintf(" [%d] %s - %s",
|
||||||
|
i+1, acct.Number, acct.Type)))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(baseStyle.Render(" [0] RETURN TO MAIN MENU"))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString(thinDivider())
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString(titleStyle.Render(" ENTER SELECTION: "))
|
||||||
|
b.WriteString(inputStyle.Render(m.choice))
|
||||||
|
b.WriteString(inputStyle.Render("_"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m historyModel) viewHistory() string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
acct := m.accounts[m.selected]
|
||||||
|
txns := m.transactions[acct.Number]
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(centerText(fmt.Sprintf("TRANSACTION HISTORY - %s (%s)", acct.Number, acct.Type)))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
b.WriteString(titleStyle.Render(fmt.Sprintf(" %-12s %-40s %14s", "DATE", "DESCRIPTION", "AMOUNT")))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(dimStyle.Render(" " + strings.Repeat("-", 68)))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
start := m.page * m.pageSize
|
||||||
|
end := min(start+m.pageSize, len(txns))
|
||||||
|
|
||||||
|
for _, txn := range txns[start:end] {
|
||||||
|
b.WriteString(baseStyle.Render(fmt.Sprintf(" %-12s %-40s %14s",
|
||||||
|
txn.Date, txn.Description, formatCurrency(txn.Amount))))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
totalPages := (len(txns) + m.pageSize - 1) / m.pageSize
|
||||||
|
b.WriteString(dimStyle.Render(fmt.Sprintf(" PAGE %d OF %d", m.page+1, totalPages)))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString(dimStyle.Render(" [N]EXT PAGE [P]REV PAGE [B]ACK ANY OTHER KEY = MAIN MENU"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
103
internal/shell/banking/screen_login.go
Normal file
103
internal/shell/banking/screen_login.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
80
internal/shell/banking/screen_menu.go
Normal file
80
internal/shell/banking/screen_menu.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
122
internal/shell/banking/screen_messages.go
Normal file
122
internal/shell/banking/screen_messages.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
194
internal/shell/banking/screen_transfer.go
Normal file
194
internal/shell/banking/screen_transfer.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
128
internal/shell/banking/style.go
Normal file
128
internal/shell/banking/style.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
@@ -24,6 +24,11 @@ password = "admin"
|
|||||||
# password = "fridge"
|
# password = "fridge"
|
||||||
# shell = "fridge"
|
# shell = "fridge"
|
||||||
|
|
||||||
|
# [[auth.static_credentials]]
|
||||||
|
# username = "teller"
|
||||||
|
# password = "banking"
|
||||||
|
# shell = "banking"
|
||||||
|
|
||||||
[storage]
|
[storage]
|
||||||
db_path = "oubliette.db"
|
db_path = "oubliette.db"
|
||||||
retention_days = 90
|
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"
|
# 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
|
# 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]
|
# [detection]
|
||||||
# enabled = true
|
# enabled = true
|
||||||
# threshold = 0.6 # 0.0–1.0, sessions above this trigger notifications
|
# threshold = 0.6 # 0.0–1.0, sessions above this trigger notifications
|
||||||
|
|||||||
Reference in New Issue
Block a user