Compare commits
3 Commits
462c44ce89
...
d78d461236
| Author | SHA1 | Date | |
|---|---|---|---|
| d78d461236 | |||
| 49425635ce | |||
| 8ff029fcb7 |
@@ -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 {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
pname = "oubliette";
|
pname = "oubliette";
|
||||||
inherit version;
|
inherit version;
|
||||||
src = ./.;
|
src = ./.;
|
||||||
vendorHash = "sha256-EbJ90e4Jco7CvYYJLrewFLD5XF+Wv6TsT8RRLcj+ijU=";
|
vendorHash = "sha256-oH92jRD+2niIf7xAX1HeZvhux8lVqj43Qxdef5GjX4Q=";
|
||||||
subPackages = [ "cmd/oubliette" ];
|
subPackages = [ "cmd/oubliette" ];
|
||||||
meta = {
|
meta = {
|
||||||
description = "SSH honeypot";
|
description = "SSH honeypot";
|
||||||
|
|||||||
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