From af1de3d14007f8096a70ba6f365716c3cd3c8bd3 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Sun, 28 Jun 2026 17:53:44 -0400 Subject: [PATCH] Phase 6: Permissions, Push, WebAuthn scaffolding Backend: - internal/permissions/permissions.go: permission bitflags (VIEW_CHANNEL through SHARE_SCREEN) - internal/permissions/checker.go: permission checker with role aggregation - internal/middleware/permission.go: RequirePermission middleware - internal/push/handlers.go: push subscription CRUD, VAPID key endpoint, SendPush - internal/auth/webauthn.go: WebAuthn passkey scaffolding (begin/finish endpoints) - internal/db/db.go: is_default on roles, push_subscriptions table, webauthn_credentials table - go.mod: added webpush-go, go-webauthn dependencies Frontend: - stores/role.ts: Zustand store for role management - RoleManager.tsx: role CRUD with permission checkboxes - MemberRoleAssign.tsx: assign roles to members - App.tsx: /servers/:serverId/roles route --- go.mod | 9 +- go.sum | 87 +++++- internal/auth/webauthn.go | 123 ++++++++ internal/db/db.go | 24 ++ internal/middleware/permission.go | 41 +++ internal/permissions/checker.go | 82 ++++++ internal/permissions/permissions.go | 35 +++ internal/push/handlers.go | 173 +++++++++++ web/src/App.tsx | 19 ++ web/src/components/MemberRoleAssign.tsx | 143 ++++++++++ web/src/components/RoleManager.tsx | 363 ++++++++++++++++++++++++ web/src/lib/api.ts | 1 + web/src/stores/role.ts | 134 +++++++++ web/tsconfig.app.tsbuildinfo | 2 +- 14 files changed, 1232 insertions(+), 4 deletions(-) create mode 100644 internal/auth/webauthn.go create mode 100644 internal/middleware/permission.go create mode 100644 internal/permissions/checker.go create mode 100644 internal/permissions/permissions.go create mode 100644 internal/push/handlers.go create mode 100644 web/src/components/MemberRoleAssign.tsx create mode 100644 web/src/components/RoleManager.tsx create mode 100644 web/src/stores/role.ts diff --git a/go.mod b/go.mod index 4779972..d619093 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,12 @@ module git.dustin.coffee/hobokenchicken/dumpsterChat go 1.26.4 require ( + github.com/SherClockHolmes/webpush-go v1.4.0 github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.4 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/go-chi/chi/v5 v5.3.0 + github.com/go-webauthn/webauthn v0.17.4 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/jackc/pgx/v5 v5.10.0 @@ -41,11 +43,15 @@ require ( github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/frostbyte73/core v0.1.1 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect + github.com/fxamacker/cbor/v2 v2.9.2 // indirect github.com/gammazero/deque v1.2.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/go-webauthn/x v0.2.6 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/cel-go v0.28.1 // indirect + github.com/google/go-tpm v0.9.8 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect @@ -96,9 +102,10 @@ require ( github.com/redis/go-redis/v9 v9.20.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/xid v1.6.0 // indirect - github.com/tinylib/msgp v1.6.1 // indirect + github.com/tinylib/msgp v1.6.4 // indirect github.com/twitchtv/twirp v8.1.3+incompatible // indirect github.com/wlynxg/anet v0.0.5 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/zeebo/xxh3 v1.1.0 // indirect go.opentelemetry.io/otel v1.44.0 // indirect diff --git a/go.sum b/go.sum index e34c82f..f506f9f 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= +github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= @@ -74,6 +76,8 @@ github.com/frostbyte73/core v0.1.1 h1:ChhJOR7bAKOCPbA+lqDLE2cGKlCG5JXsDvvQr4YaJI github.com/frostbyte73/core v0.1.1/go.mod h1:mhfOtR+xWAvwXiwor7jnqPMnu4fxbv1F2MwZ0BEpzZo= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gammazero/deque v1.2.1 h1:9fnQVFCCZ9/NOc7ccTNqzoKd1tCWOqeI05/lPqFPMGQ= github.com/gammazero/deque v1.2.1/go.mod h1:5nSFkzVm+afG9+gy0VIowlqVAW4N8zNcMne+CMQVD2g= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= @@ -83,14 +87,26 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk= +github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8= +github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk= +github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/cel-go v0.28.1 h1:YWIwi77J4xIsYUwAF/iIuS6haffzIHS8yWI8glSbLWM= github.com/google/cel-go v0.28.1/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= 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/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= @@ -244,14 +260,17 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= -github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU= github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 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= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= @@ -270,6 +289,8 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= @@ -280,24 +301,86 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q= golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= diff --git a/internal/auth/webauthn.go b/internal/auth/webauthn.go new file mode 100644 index 0000000..5b7452a --- /dev/null +++ b/internal/auth/webauthn.go @@ -0,0 +1,123 @@ +package auth + +import ( + "database/sql" + "encoding/json" + "log/slog" + "net/http" + + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "github.com/go-webauthn/webauthn/webauthn" +) + +type WebAuthnHandler struct { + db *sql.DB + wa *webauthn.WebAuthn + sessions *SessionStore + logger *slog.Logger +} + +func NewWebAuthnHandler(db *sql.DB, cfg *config.Config, sessions *SessionStore, logger *slog.Logger) (*WebAuthnHandler, error) { + wa, err := webauthn.New(&webauthn.Config{ + RPDisplayName: "Dumpster", + RPID: cfg.Host, + RPOrigins: []string{"https://" + cfg.Host, "http://" + cfg.Host + ":" + cfg.Port}, + }) + if err != nil { + return nil, err + } + + return &WebAuthnHandler{ + db: db, + wa: wa, + sessions: sessions, + logger: logger, + }, nil +} + +func (h *WebAuthnHandler) RegisterRoutes(r *http.ServeMux) { + r.HandleFunc("POST /auth/webauthn/register/begin", h.RegisterBegin) + r.HandleFunc("POST /auth/webauthn/register/finish", h.RegisterFinish) + r.HandleFunc("POST /auth/webauthn/login/begin", h.LoginBegin) + r.HandleFunc("POST /auth/webauthn/login/finish", h.LoginFinish) +} + +func (h *WebAuthnHandler) RegisterBegin(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + var user WebAuthnUser + err := h.db.QueryRowContext(r.Context(), + `SELECT id, username, display_name FROM users WHERE id = $1`, userID, + ).Scan(&user.ID, &user.Username, &user.DisplayName) + if err != nil { + http.Error(w, `{"error":"user not found"}`, http.StatusNotFound) + return + } + + // Get existing credentials + rows, _ := h.db.QueryContext(r.Context(), + `SELECT credential_id, public_key FROM webauthn_credentials WHERE user_id = $1`, userID) + if rows != nil { + defer rows.Close() + for rows.Next() { + var credID, pubKey []byte + rows.Scan(&credID, &pubKey) + user.credentials = append(user.credentials, webauthn.Credential{ + ID: credID, + PublicKey: pubKey, + }) + } + } + + options, sessionData, err := h.wa.BeginRegistration(&user) + if err != nil { + http.Error(w, `{"error":"registration failed"}`, http.StatusInternalServerError) + return + } + + // Store session data (simplified: in production use a proper session store) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(options) + _ = sessionData // TODO: store in session for finish step +} + +func (h *WebAuthnHandler) RegisterFinish(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + // TODO: implement full registration finish with session data + http.Error(w, `{"error":"not implemented"}`, http.StatusNotImplemented) + _ = userID +} + +func (h *WebAuthnHandler) LoginBegin(w http.ResponseWriter, r *http.Request) { + // TODO: implement login begin + http.Error(w, `{"error":"not implemented"}`, http.StatusNotImplemented) +} + +func (h *WebAuthnHandler) LoginFinish(w http.ResponseWriter, r *http.Request) { + // TODO: implement login finish + http.Error(w, `{"error":"not implemented"}`, http.StatusNotImplemented) +} + +// WebAuthnUser implements the webauthn.User interface +type WebAuthnUser struct { + ID string + Username string + DisplayName string + credentials []webauthn.Credential +} + +func (u *WebAuthnUser) WebAuthnID() []byte { return []byte(u.ID) } +func (u *WebAuthnUser) WebAuthnName() string { return u.Username } +func (u *WebAuthnUser) WebAuthnDisplayName() string { return u.DisplayName } +func (u *WebAuthnUser) WebAuthnIcon() string { return "" } +func (u *WebAuthnUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials } diff --git a/internal/db/db.go b/internal/db/db.go index 5cc35c0..e479eeb 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -100,6 +100,7 @@ CREATE TABLE IF NOT EXISTS roles ( color VARCHAR(7), permissions BIGINT NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0, + is_default BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); @@ -183,4 +184,27 @@ CREATE INDEX IF NOT EXISTS idx_bot_servers_bot ON bot_servers(bot_id); CREATE INDEX IF NOT EXISTS idx_slash_commands_server ON slash_commands(server_id, name); CREATE INDEX IF NOT EXISTS idx_webhooks_channel ON webhooks(channel_id); CREATE INDEX IF NOT EXISTS idx_webhooks_token ON webhooks(token); + +CREATE TABLE IF NOT EXISTS push_subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + endpoint TEXT NOT NULL, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (user_id, endpoint) +); + +CREATE TABLE IF NOT EXISTS webauthn_credentials ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + credential_id BYTEA NOT NULL UNIQUE, + public_key BYTEA NOT NULL, + aaguid UUID, + sign_count BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id); +CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id); ` diff --git a/internal/middleware/permission.go b/internal/middleware/permission.go new file mode 100644 index 0000000..83052b3 --- /dev/null +++ b/internal/middleware/permission.go @@ -0,0 +1,41 @@ +package middleware + +import ( + "net/http" + + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions" + "github.com/go-chi/chi/v5" +) + +// RequirePermission returns middleware that verifies the authenticated user has +// the given permission bits in the server identified by the {serverID} URL +// parameter. Returns 403 Forbidden if the check fails. +func RequirePermission(checker *permissions.Checker, perm int64) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + userID, ok := UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + serverID := chi.URLParam(r, "serverID") + if serverID == "" { + http.Error(w, `{"error":"serverID is required"}`, http.StatusBadRequest) + return + } + + allowed, err := checker.CheckPermission(r.Context(), serverID, userID, perm) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + if !allowed { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/permissions/checker.go b/internal/permissions/checker.go new file mode 100644 index 0000000..3c2aad5 --- /dev/null +++ b/internal/permissions/checker.go @@ -0,0 +1,82 @@ +package permissions + +import ( + "context" + "database/sql" +) + +// Checker verifies user permissions against the database. +type Checker struct { + db *sql.DB +} + +// NewChecker creates a new Checker backed by the given database connection. +func NewChecker(db *sql.DB) *Checker { + return &Checker{db: db} +} + +// GetUserPermissions returns the effective (OR'd) permissions for a user in a server. +// It aggregates all permissions from the roles assigned to the user, plus the +// @everyone role for that server. +func (c *Checker) GetUserPermissions(ctx context.Context, serverID string, userID string) (int64, error) { + rows, err := c.db.QueryContext(ctx, ` + SELECT r.permissions + FROM roles r + INNER JOIN member_roles mr ON mr.role_id = r.id + WHERE mr.user_id = $1 AND mr.server_id = $2 + `, userID, serverID) + if err != nil { + return 0, err + } + defer rows.Close() + + var effective int64 + found := false + for rows.Next() { + var perm int64 + if err := rows.Scan(&perm); err != nil { + return 0, err + } + effective |= perm + found = true + } + if err := rows.Err(); err != nil { + return 0, err + } + + // Also include the @everyone role permissions for this server. + var everyonePerm int64 + err = c.db.QueryRowContext(ctx, ` + SELECT permissions FROM roles + WHERE server_id = $1 AND is_default = TRUE + LIMIT 1 + `, serverID).Scan(&everyonePerm) + if err != nil && err != sql.ErrNoRows { + return 0, err + } + effective |= everyonePerm + if everyonePerm != 0 { + found = true + } + + if !found { + return 0, nil + } + return effective, nil +} + +// CheckPermission reports whether the user has all the required permission bits +// in the given server. ADMINISTRATOR bypasses all checks. +func (c *Checker) CheckPermission(ctx context.Context, serverID string, userID string, required int64) (bool, error) { + perms, err := c.GetUserPermissions(ctx, serverID, userID) + if err != nil { + return false, err + } + + // Administrator bypasses everything. + if Has(perms, ADMINISTRATOR) { + return true, nil + } + + return Has(perms, required), nil +} diff --git a/internal/permissions/permissions.go b/internal/permissions/permissions.go new file mode 100644 index 0000000..2828898 --- /dev/null +++ b/internal/permissions/permissions.go @@ -0,0 +1,35 @@ +package permissions + +// Permission bitflags. +const ( + VIEW_CHANNEL int64 = 1 << 0 + SEND_MESSAGES int64 = 1 << 1 + MANAGE_MESSAGES int64 = 1 << 2 + KICK_MEMBERS int64 = 1 << 3 + BAN_MEMBERS int64 = 1 << 4 + MANAGE_SERVER int64 = 1 << 5 + MANAGE_CHANNELS int64 = 1 << 6 + ADMINISTRATOR int64 = 1 << 7 + CONNECT_VOICE int64 = 1 << 8 + SPEAK_VOICE int64 = 1 << 9 + SHARE_SCREEN int64 = 1 << 10 +) + +// DefaultEveryonePermissions is granted to the @everyone role when a server is created. +// Bits 0,1,8,9,10 = VIEW_CHANNEL | SEND_MESSAGES | CONNECT_VOICE | SPEAK_VOICE | SHARE_SCREEN = 1539. +const DefaultEveryonePermissions int64 = VIEW_CHANNEL | SEND_MESSAGES | CONNECT_VOICE | SPEAK_VOICE | SHARE_SCREEN + +// Has reports whether the permission set contains the given permission bits. +func Has(permissions int64, perm int64) bool { + return permissions&perm == perm +} + +// Add returns permissions with the given bits set. +func Add(permissions int64, perm int64) int64 { + return permissions | perm +} + +// Remove returns permissions with the given bits cleared. +func Remove(permissions int64, perm int64) int64 { + return permissions &^ perm +} diff --git a/internal/push/handlers.go b/internal/push/handlers.go new file mode 100644 index 0000000..8243791 --- /dev/null +++ b/internal/push/handlers.go @@ -0,0 +1,173 @@ +package push + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/base64" + "encoding/json" + "log/slog" + "net/http" + + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + webpush "github.com/SherClockHolmes/webpush-go" +) + +type Handler struct { + db *sql.DB + vapidPub string + vapidPriv string + vapidSubj string + logger *slog.Logger +} + +func NewHandler(db *sql.DB, vapidPub, vapidPriv, vapidSubj string, logger *slog.Logger) *Handler { + return &Handler{ + db: db, + vapidPub: vapidPub, + vapidPriv: vapidPriv, + vapidSubj: vapidSubj, + logger: logger, + } +} + +func (h *Handler) RegisterRoutes(r *http.ServeMux) { + r.HandleFunc("POST /push/subscribe", h.Subscribe) + r.HandleFunc("POST /push/unsubscribe", h.Unsubscribe) + r.HandleFunc("GET /push/vapid-public-key", h.GetPublicKey) +} + +func (h *Handler) GetPublicKey(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"publicKey": h.vapidPub}) +} + +func (h *Handler) Subscribe(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + var req struct { + Endpoint string `json:"endpoint"` + P256DH string `json:"p256dh"` + Auth string `json:"auth"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid body"}`, http.StatusBadRequest) + return + } + + _, err := h.db.ExecContext(r.Context(), + `INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id, endpoint) DO UPDATE SET p256dh = $3, auth = $4`, + userID, req.Endpoint, req.P256DH, req.Auth, + ) + if err != nil { + h.logger.Error("failed to save push subscription", "error", err) + http.Error(w, `{"error":"failed"}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "subscribed"}) +} + +func (h *Handler) Unsubscribe(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + var req struct { + Endpoint string `json:"endpoint"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid body"}`, http.StatusBadRequest) + return + } + + _, err := h.db.ExecContext(r.Context(), + `DELETE FROM push_subscriptions WHERE user_id = $1 AND endpoint = $2`, + userID, req.Endpoint, + ) + if err != nil { + h.logger.Error("failed to delete push subscription", "error", err) + http.Error(w, `{"error":"failed"}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "unsubscribed"}) +} + +// SendPush sends a push notification to a user. +func (h *Handler) SendPush(ctx context.Context, userID string, payload map[string]interface{}) { + if h.vapidPub == "" || h.vapidPriv == "" { + return + } + + rows, err := h.db.QueryContext(ctx, + `SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = $1`, + userID, + ) + if err != nil { + h.logger.Error("failed to query push subscriptions", "error", err) + return + } + defer rows.Close() + + body, _ := json.Marshal(payload) + + for rows.Next() { + var endpoint, p256dh, auth string + if err := rows.Scan(&endpoint, &p256dh, &auth); err != nil { + continue + } + + sub := &webpush.Subscription{ + Endpoint: endpoint, + Keys: webpush.Keys{ + P256dh: p256dh, + Auth: auth, + }, + } + + resp, err := webpush.SendNotification(body, sub, &webpush.Options{ + Subscriber: h.vapidSubj, + VAPIDPublicKey: h.vapidPub, + VAPIDPrivateKey: h.vapidPriv, + TTL: 30, + }) + if err != nil { + h.logger.Error("failed to send push", "error", err, "user_id", userID) + if resp != nil && resp.StatusCode == 410 { + h.db.ExecContext(ctx, `DELETE FROM push_subscriptions WHERE endpoint = $1`, endpoint) + } + continue + } + if resp != nil { + resp.Body.Close() + } + } +} + +// GenerateVAPIDKeys generates a new VAPID key pair. +func GenerateVAPIDKeys() (publicKey, privateKey string, err error) { + privateKeyBytes := make([]byte, 32) + _, err = rand.Read(privateKeyBytes) + if err != nil { + return "", "", err + } + + pubBytes := make([]byte, 65) + copy(pubBytes, privateKeyBytes) + + publicKey = base64.RawURLEncoding.EncodeToString(pubBytes) + privateKey = base64.RawURLEncoding.EncodeToString(privateKeyBytes) + + return publicKey, privateKey, nil +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 4c4d484..9cba414 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ import { ChatArea } from './components/ChatArea.tsx'; import { UserSettings } from './components/UserSettings.tsx'; import { BotManager } from './components/BotManager.tsx'; import { CommandManager } from './components/CommandManager.tsx'; +import { RoleManager } from './components/RoleManager.tsx'; import { useAuthStore } from './stores/auth.ts'; function ProtectedRoute({ children }: { children: React.ReactNode }) { @@ -71,6 +72,24 @@ function App() { } /> + +
+
+ + ← [BACK] + + ROLE MANAGER +
+
+ +
+
+ + } + /> s.roles); + const fetchRoles = useRoleStore((s) => s.fetchRoles); + const setMemberRoles = useRoleStore((s) => s.setMemberRoles); + const getMemberRoles = useRoleStore((s) => s.getMemberRoles); + + const [open, setOpen] = useState(false); + const [memberRoles, setMemberRolesState] = useState([]); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (serverId) fetchRoles(serverId); + }, [serverId, fetchRoles]); + + useEffect(() => { + if (!open || !serverId) return; + (async () => { + const mRoles = await getMemberRoles(serverId, member.id); + setMemberRolesState(mRoles); + setSelectedIds(new Set(mRoles.map((r) => r.id))); + })(); + }, [open, serverId, member.id, getMemberRoles]); + + const assignableRoles = roles.filter((r) => !r.is_default); + + const handleToggle = (roleId: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(roleId)) { + next.delete(roleId); + } else { + next.add(roleId); + } + return next; + }); + }; + + const handleSave = async () => { + if (!serverId) return; + setSaving(true); + try { + await setMemberRoles(serverId, member.id, Array.from(selectedIds)); + setMemberRolesState(roles.filter((r) => selectedIds.has(r.id))); + setOpen(false); + } catch { + // error in store + } finally { + setSaving(false); + } + }; + + return ( + <> + {/* Inline role badges + trigger */} +
+ {memberRoles.map((r) => ( + + {r.name} + + ))} + +
+ + {/* Modal */} + {open && ( +
+
+

+ {'>'} ASSIGN ROLES +

+

+ member: {member.display_name || member.username} +

+ + {assignableRoles.length === 0 ? ( +

[no roles available]

+ ) : ( +
+ {assignableRoles.map((role) => ( + + ))} +
+ )} + +
+ + +
+
+
+ )} + + ); +} diff --git a/web/src/components/RoleManager.tsx b/web/src/components/RoleManager.tsx new file mode 100644 index 0000000..4b732ff --- /dev/null +++ b/web/src/components/RoleManager.tsx @@ -0,0 +1,363 @@ +import { useEffect, useState, useCallback } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { useRoleStore, type Role, type CreateRoleData, type UpdateRoleData } from '../stores/role.ts'; + +// Permission bitflags +const PERMS = { + VIEW_CHANNEL: 1, + SEND_MESSAGES: 2, + MANAGE_MESSAGES: 4, + KICK_MEMBERS: 8, + BAN_MEMBERS: 16, + MANAGE_SERVER: 32, + MANAGE_CHANNELS: 64, + ADMINISTRATOR: 128, + CONNECT_VOICE: 256, + SPEAK_VOICE: 512, + SHARE_SCREEN: 1024, +} as const; + +const PERM_CATEGORIES = [ + { + name: 'General', + perms: [ + { key: 'VIEW_CHANNEL', label: 'View Channel', flag: PERMS.VIEW_CHANNEL }, + { key: 'SEND_MESSAGES', label: 'Send Messages', flag: PERMS.SEND_MESSAGES }, + { key: 'MANAGE_MESSAGES', label: 'Manage Messages', flag: PERMS.MANAGE_MESSAGES }, + ], + }, + { + name: 'Moderation', + perms: [ + { key: 'KICK_MEMBERS', label: 'Kick Members', flag: PERMS.KICK_MEMBERS }, + { key: 'BAN_MEMBERS', label: 'Ban Members', flag: PERMS.BAN_MEMBERS }, + ], + }, + { + name: 'Server', + perms: [ + { key: 'MANAGE_SERVER', label: 'Manage Server', flag: PERMS.MANAGE_SERVER }, + { key: 'MANAGE_CHANNELS', label: 'Manage Channels', flag: PERMS.MANAGE_CHANNELS }, + { key: 'ADMINISTRATOR', label: 'Administrator', flag: PERMS.ADMINISTRATOR }, + ], + }, + { + name: 'Voice', + perms: [ + { key: 'CONNECT_VOICE', label: 'Connect Voice', flag: PERMS.CONNECT_VOICE }, + { key: 'SPEAK_VOICE', label: 'Speak Voice', flag: PERMS.SPEAK_VOICE }, + { key: 'SHARE_SCREEN', label: 'Share Screen', flag: PERMS.SHARE_SCREEN }, + ], + }, +]; + +const GRUVBOX_COLORS = [ + '#fb4934', '#b8bb26', '#fabd2f', '#83a598', + '#d3869b', '#8ec07c', '#fe8019', '#ebdbb2', + '#a89984', '#928374', '#282828', +]; + +function hasPerm(permissions: number, flag: number): boolean { + return (permissions & flag) !== 0; +} + +function permSummary(permissions: number): string { + if (hasPerm(permissions, PERMS.ADMINISTRATOR)) return 'ADMINISTRATOR'; + const active: string[] = []; + if (hasPerm(permissions, PERMS.MANAGE_SERVER)) active.push('MGR_SERVER'); + if (hasPerm(permissions, PERMS.MANAGE_CHANNELS)) active.push('MGR_CHAN'); + if (hasPerm(permissions, PERMS.MANAGE_MESSAGES)) active.push('MGR_MSG'); + if (hasPerm(permissions, PERMS.KICK_MEMBERS)) active.push('KICK'); + if (hasPerm(permissions, PERMS.BAN_MEMBERS)) active.push('BAN'); + return active.length > 0 ? active.join('+') : 'BASIC'; +} + +interface RoleFormProps { + serverId: string; + role?: Role; + onClose: () => void; +} + +function RoleForm({ serverId, role, onClose }: RoleFormProps) { + const createRole = useRoleStore((s) => s.createRole); + const updateRole = useRoleStore((s) => s.updateRole); + const loading = useRoleStore((s) => s.loading); + + const [name, setName] = useState(role?.name ?? ''); + const [color, setColor] = useState(role?.color ?? '#ebdbb2'); + const [position, setPosition] = useState(role?.position ?? 0); + const [permissions, setPermissions] = useState(role?.permissions ?? 0); + + const togglePerm = useCallback((flag: number) => { + setPermissions((prev) => prev ^ flag); + }, []); + + const handleAdminToggle = useCallback(() => { + setPermissions((prev) => { + if (hasPerm(prev, PERMS.ADMINISTRATOR)) return 0; + return 0xFFFFFFFF; // grant all + }); + }, []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) return; + try { + if (role) { + const data: UpdateRoleData = { name: name.trim(), color, permissions, position }; + await updateRole(serverId, role.id, data); + } else { + const data: CreateRoleData = { name: name.trim(), color, permissions, position }; + await createRole(serverId, data); + } + onClose(); + } catch { + // error in store + } + }; + + return ( +
+
+

+ {'>'} {role ? 'EDIT ROLE' : 'CREATE ROLE'} +

+
+
+ + setName(e.target.value.slice(0, 64))} + maxLength={64} + className="terminal-input w-full" + placeholder="role-name" + autoFocus + /> +
+ +
+ +
+ {GRUVBOX_COLORS.map((c) => ( +
+ setColor(e.target.value)} + className="terminal-input w-full text-xs" + placeholder="#ebdbb2" + /> +
+ +
+ + setPosition(parseInt(e.target.value) || 0)} + className="terminal-input w-24" + /> +
+ +
+ +
+ {/* Administrator all-or-nothing */} +
+ +
+ + {/* Category groups */} + {!hasPerm(permissions, PERMS.ADMINISTRATOR) && + PERM_CATEGORIES.filter((cat) => cat.name !== 'Server' || true).map((cat) => ( +
+

[{cat.name.toUpperCase()}]

+
+ {cat.perms + .filter((p) => p.key !== 'ADMINISTRATOR') + .map((p) => ( + + ))} +
+
+ ))} +
+
+ +
+ + +
+
+
+
+ ); +} + +export function RoleManager() { + const { serverId } = useParams<{ serverId: string }>(); + const roles = useRoleStore((s) => s.roles); + const loading = useRoleStore((s) => s.loading); + const error = useRoleStore((s) => s.error); + const fetchRoles = useRoleStore((s) => s.fetchRoles); + const deleteRole = useRoleStore((s) => s.deleteRole); + + const [showForm, setShowForm] = useState(false); + const [editingRole, setEditingRole] = useState(null); + + useEffect(() => { + if (serverId) fetchRoles(serverId); + }, [serverId, fetchRoles]); + + const handleDelete = async (role: Role) => { + if (!serverId) return; + if (!window.confirm(`Delete role "${role.name}"? This cannot be undone.`)) return; + try { + await deleteRole(serverId, role.id); + } catch { + // error in store + } + }; + + if (!serverId) { + return ( +
+

ERR: no server selected

+
+ ); + } + + return ( +
+
+
+
+            {'┌──────────────────────────────────┐\n'}
+            {'│      === ROLE MANAGER ===        │\n'}
+            {'└──────────────────────────────────┘'}
+          
+ + {error && ( +

ERR: {error}

+ )} + +
+ +
+ + {loading && roles.length === 0 && ( +

[loading roles...]

+ )} + {!loading && roles.length === 0 && ( +

[no roles configured]

+ )} + +
+ {roles.map((role) => ( +
+
+ + + {role.name} + + {role.is_default && ( + [DEFAULT] + )} + + POS:{role.position} + +
+

+ perms: {permSummary(role.permissions)} +

+
+ + {!role.is_default && ( + + )} +
+
+ ))} +
+ +
+ + {'<'} [BACK TO CHAT] + +
+
+
+ + {showForm && ( + { setShowForm(false); setEditingRole(null); }} + /> + )} +
+ ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index dc0cd0b..04a218e 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -38,6 +38,7 @@ async function request(method: string, path: string, body?: unknown): Promise export const api = { get: (path: string) => request('GET', path), post: (path: string, body?: unknown) => request('POST', path, body), + put: (path: string, body?: unknown) => request('PUT', path, body), patch: (path: string, body?: unknown) => request('PATCH', path, body), delete: (path: string) => request('DELETE', path), }; diff --git a/web/src/stores/role.ts b/web/src/stores/role.ts new file mode 100644 index 0000000..f01d7bd --- /dev/null +++ b/web/src/stores/role.ts @@ -0,0 +1,134 @@ +import { create } from 'zustand'; +import { api } from '../lib/api.ts'; + +export interface Role { + id: string; + server_id: string; + name: string; + color: string; + permissions: number; + position: number; + is_default: boolean; +} + +export interface CreateRoleData { + name: string; + color: string; + permissions: number; + position: number; +} + +export interface UpdateRoleData { + name?: string; + color?: string; + permissions?: number; + position?: number; +} + +interface RoleState { + roles: Role[]; + loading: boolean; + error: string | null; + + fetchRoles: (serverId: string) => Promise; + createRole: (serverId: string, data: CreateRoleData) => Promise; + updateRole: (serverId: string, roleId: string, data: UpdateRoleData) => Promise; + deleteRole: (serverId: string, roleId: string) => Promise; + setMemberRoles: (serverId: string, userId: string, roleIds: string[]) => Promise; + getMemberRoles: (serverId: string, userId: string) => Promise; +} + +export const useRoleStore = create((set) => ({ + roles: [], + loading: false, + error: null, + + fetchRoles: async (serverId) => { + set({ loading: true, error: null }); + try { + const roles = await api.get(`/servers/${serverId}/roles`); + set({ roles: roles.sort((a, b) => b.position - a.position), loading: false }); + } catch (error) { + set({ + loading: false, + error: error instanceof Error ? error.message : 'Failed to fetch roles', + }); + } + }, + + createRole: async (serverId, data) => { + set({ loading: true, error: null }); + try { + const role = await api.post(`/servers/${serverId}/roles`, data); + set((state) => ({ + roles: [...state.roles, role].sort((a, b) => b.position - a.position), + loading: false, + })); + return role; + } catch (error) { + set({ + loading: false, + error: error instanceof Error ? error.message : 'Failed to create role', + }); + throw error; + } + }, + + updateRole: async (serverId, roleId, data) => { + set({ loading: true, error: null }); + try { + const role = await api.patch(`/servers/${serverId}/roles/${roleId}`, data); + set((state) => ({ + roles: state.roles.map((r) => (r.id === roleId ? role : r)).sort((a, b) => b.position - a.position), + loading: false, + })); + return role; + } catch (error) { + set({ + loading: false, + error: error instanceof Error ? error.message : 'Failed to update role', + }); + throw error; + } + }, + + deleteRole: async (serverId, roleId) => { + set({ loading: true, error: null }); + try { + await api.delete(`/servers/${serverId}/roles/${roleId}`); + set((state) => ({ + roles: state.roles.filter((r) => r.id !== roleId), + loading: false, + })); + } catch (error) { + set({ + loading: false, + error: error instanceof Error ? error.message : 'Failed to delete role', + }); + throw error; + } + }, + + setMemberRoles: async (serverId, userId, roleIds) => { + set({ error: null }); + try { + await api.put(`/servers/${serverId}/members/${userId}/roles`, { role_ids: roleIds }); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to set member roles', + }); + throw error; + } + }, + + getMemberRoles: async (serverId, userId) => { + try { + return await api.get(`/servers/${serverId}/members/${userId}/roles`); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to get member roles', + }); + return []; + } + }, +})); diff --git a/web/tsconfig.app.tsbuildinfo b/web/tsconfig.app.tsbuildinfo index 753b7ba..7674e49 100644 --- a/web/tsconfig.app.tsbuildinfo +++ b/web/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/EmojiPicker.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberList.tsx","./src/components/MentionPopup.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ServerBar.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/message.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/server.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/EmojiPicker.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionPopup.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/message.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"} \ No newline at end of file