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
This commit is contained in:
@@ -3,10 +3,12 @@ module git.dustin.coffee/hobokenchicken/dumpsterChat
|
|||||||
go 1.26.4
|
go 1.26.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||||
github.com/charmbracelet/bubbles v0.21.0
|
github.com/charmbracelet/bubbles v0.21.0
|
||||||
github.com/charmbracelet/bubbletea v1.3.4
|
github.com/charmbracelet/bubbletea v1.3.4
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||||
github.com/go-chi/chi/v5 v5.3.0
|
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/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/frostbyte73/core v0.1.1 // indirect
|
github.com/frostbyte73/core v0.1.1 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.10.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/gammazero/deque v1.2.1 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // 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/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
github.com/google/cel-go v0.28.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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // 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/redis/go-redis/v9 v9.20.0 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/rs/xid v1.6.0 // 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/twitchtv/twirp v8.1.3+incompatible // indirect
|
||||||
github.com/wlynxg/anet v0.0.5 // 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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
|
|||||||
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
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 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
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 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
|
||||||
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
|
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
|
||||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
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/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 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
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 h1:9fnQVFCCZ9/NOc7ccTNqzoKd1tCWOqeI05/lPqFPMGQ=
|
||||||
github.com/gammazero/deque v1.2.1/go.mod h1:5nSFkzVm+afG9+gy0VIowlqVAW4N8zNcMne+CMQVD2g=
|
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=
|
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/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 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
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 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
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 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
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 h1:YWIwi77J4xIsYUwAF/iIuS6haffzIHS8yWI8glSbLWM=
|
||||||
github.com/google/cel-go v0.28.1/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
|
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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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 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/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||||
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
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 h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU=
|
||||||
github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A=
|
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 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
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 h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
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 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
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/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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
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 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
|
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/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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
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 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
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 h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q=
|
||||||
golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
|
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 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
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 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
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-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.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 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
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 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
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 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
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 h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
|
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=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -100,6 +100,7 @@ CREATE TABLE IF NOT EXISTS roles (
|
|||||||
color VARCHAR(7),
|
color VARCHAR(7),
|
||||||
permissions BIGINT NOT NULL DEFAULT 0,
|
permissions BIGINT NOT NULL DEFAULT 0,
|
||||||
position INTEGER NOT NULL DEFAULT 0,
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
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_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_channel ON webhooks(channel_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_webhooks_token ON webhooks(token);
|
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);
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { ChatArea } from './components/ChatArea.tsx';
|
|||||||
import { UserSettings } from './components/UserSettings.tsx';
|
import { UserSettings } from './components/UserSettings.tsx';
|
||||||
import { BotManager } from './components/BotManager.tsx';
|
import { BotManager } from './components/BotManager.tsx';
|
||||||
import { CommandManager } from './components/CommandManager.tsx';
|
import { CommandManager } from './components/CommandManager.tsx';
|
||||||
|
import { RoleManager } from './components/RoleManager.tsx';
|
||||||
import { useAuthStore } from './stores/auth.ts';
|
import { useAuthStore } from './stores/auth.ts';
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
@@ -71,6 +72,24 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/servers/:serverId/roles"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<div className="h-full w-full flex flex-col bg-gb-bg">
|
||||||
|
<header className="flex items-center gap-4 px-4 py-2 border-b border-gb-bg-t bg-gb-bg-s">
|
||||||
|
<Link to="/" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
||||||
|
← [BACK]
|
||||||
|
</Link>
|
||||||
|
<span className="text-gb-orange font-mono text-sm">ROLE MANAGER</span>
|
||||||
|
</header>
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<RoleManager />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRoleStore, type Role } from '../stores/role.ts';
|
||||||
|
import type { User } from '../stores/auth.ts';
|
||||||
|
|
||||||
|
interface MemberRoleAssignProps {
|
||||||
|
serverId: string;
|
||||||
|
member: User;
|
||||||
|
allMembers?: User[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MemberRoleAssign({ serverId, member }: MemberRoleAssignProps) {
|
||||||
|
const roles = useRoleStore((s) => 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<Role[]>([]);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(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 */}
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
{memberRoles.map((r) => (
|
||||||
|
<span
|
||||||
|
key={r.id}
|
||||||
|
className="inline-block px-1.5 py-0.5 text-xs font-mono border border-gb-bg-t"
|
||||||
|
style={{ color: r.color || '#ebdbb2', borderColor: r.color || undefined }}
|
||||||
|
>
|
||||||
|
{r.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="terminal-button text-xs"
|
||||||
|
>
|
||||||
|
[ROLES]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
{open && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||||
|
<div className="border border-gb-bg-t bg-gb-bg p-6 max-w-md w-full mx-4 max-h-[80vh] overflow-y-auto">
|
||||||
|
<p className="text-gb-orange font-mono text-sm mb-1">
|
||||||
|
{'>'} ASSIGN ROLES
|
||||||
|
</p>
|
||||||
|
<p className="text-gb-fg-f font-mono text-xs mb-4">
|
||||||
|
member: <span className="text-gb-aqua">{member.display_name || member.username}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{assignableRoles.length === 0 ? (
|
||||||
|
<p className="text-gb-fg-f font-mono text-sm">[no roles available]</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
{assignableRoles.map((role) => (
|
||||||
|
<label
|
||||||
|
key={role.id}
|
||||||
|
className="flex items-center gap-3 cursor-pointer font-mono text-xs p-2 border border-gb-bg-t hover:bg-gb-bg-s transition-colors"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIds.has(role.id)}
|
||||||
|
onChange={() => handleToggle(role.id)}
|
||||||
|
className="accent-gb-orange"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="inline-block w-3 h-3 border border-gb-bg-t shrink-0"
|
||||||
|
style={{ backgroundColor: role.color || '#ebdbb2' }}
|
||||||
|
/>
|
||||||
|
<span style={{ color: role.color || '#ebdbb2' }}>{role.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
className="terminal-button text-xs"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? '[SAVING...]' : '[SAVE]'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="text-gb-fg-f hover:text-gb-aqua font-mono text-xs"
|
||||||
|
>
|
||||||
|
[CANCEL]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||||
|
<div className="border border-gb-bg-t bg-gb-bg p-6 max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<p className="text-gb-orange font-mono text-sm mb-4">
|
||||||
|
{'>'} {role ? 'EDIT ROLE' : 'CREATE ROLE'}
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-gb-fg-f mb-1 font-mono text-xs">NAME:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value.slice(0, 64))}
|
||||||
|
maxLength={64}
|
||||||
|
className="terminal-input w-full"
|
||||||
|
placeholder="role-name"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-gb-fg-f mb-1 font-mono text-xs">COLOR:</label>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
|
{GRUVBOX_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
className={`w-7 h-7 border-2 transition-all ${
|
||||||
|
color === c ? 'border-gb-fg scale-110' : 'border-gb-bg-t'
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
title={c}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={color}
|
||||||
|
onChange={(e) => setColor(e.target.value)}
|
||||||
|
className="terminal-input w-full text-xs"
|
||||||
|
placeholder="#ebdbb2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-gb-fg-f mb-1 font-mono text-xs">POSITION:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={position}
|
||||||
|
onChange={(e) => setPosition(parseInt(e.target.value) || 0)}
|
||||||
|
className="terminal-input w-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-gb-fg-f mb-2 font-mono text-xs">PERMISSIONS:</label>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Administrator all-or-nothing */}
|
||||||
|
<div className="border border-gb-bg-t p-3">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer font-mono text-xs">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hasPerm(permissions, PERMS.ADMINISTRATOR)}
|
||||||
|
onChange={handleAdminToggle}
|
||||||
|
className="accent-gb-red"
|
||||||
|
/>
|
||||||
|
<span className="text-gb-red">Administrator (grants all permissions)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category groups */}
|
||||||
|
{!hasPerm(permissions, PERMS.ADMINISTRATOR) &&
|
||||||
|
PERM_CATEGORIES.filter((cat) => cat.name !== 'Server' || true).map((cat) => (
|
||||||
|
<div key={cat.name} className="border border-gb-bg-t p-3">
|
||||||
|
<p className="text-gb-aqua font-mono text-xs mb-2">[{cat.name.toUpperCase()}]</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{cat.perms
|
||||||
|
.filter((p) => p.key !== 'ADMINISTRATOR')
|
||||||
|
.map((p) => (
|
||||||
|
<label
|
||||||
|
key={p.key}
|
||||||
|
className="flex items-center gap-2 cursor-pointer font-mono text-xs"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hasPerm(permissions, p.flag)}
|
||||||
|
onChange={() => togglePerm(p.flag)}
|
||||||
|
className="accent-gb-orange"
|
||||||
|
/>
|
||||||
|
<span className="text-gb-fg">{p.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<button type="submit" className="terminal-button" disabled={loading || !name.trim()}>
|
||||||
|
{loading ? '[SAVING...]' : '[SAVE]'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm"
|
||||||
|
>
|
||||||
|
[CANCEL]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Role | null>(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 (
|
||||||
|
<div className="h-full w-full bg-gb-bg p-8 flex items-center justify-center">
|
||||||
|
<p className="text-gb-red font-mono">ERR: no server selected</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full bg-gb-bg p-4 md:p-8 overflow-y-auto">
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<div className="border border-gb-bg-t p-6">
|
||||||
|
<pre className="text-gb-orange font-mono text-center mb-6">
|
||||||
|
{'┌──────────────────────────────────┐\n'}
|
||||||
|
{'│ === ROLE MANAGER === │\n'}
|
||||||
|
{'└──────────────────────────────────┘'}
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-gb-red text-sm font-mono mb-4">ERR: {error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setEditingRole(null); setShowForm(true); }}
|
||||||
|
className="terminal-button"
|
||||||
|
>
|
||||||
|
[CREATE ROLE]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && roles.length === 0 && (
|
||||||
|
<p className="text-gb-fg-f font-mono text-sm">[loading roles...]</p>
|
||||||
|
)}
|
||||||
|
{!loading && roles.length === 0 && (
|
||||||
|
<p className="text-gb-fg-f font-mono text-sm">[no roles configured]</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{roles.map((role) => (
|
||||||
|
<div key={role.id} className="border border-gb-bg-t p-4">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<span
|
||||||
|
className="inline-block w-4 h-4 border border-gb-bg-t shrink-0"
|
||||||
|
style={{ backgroundColor: role.color || '#ebdbb2' }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="font-mono text-sm font-bold truncate"
|
||||||
|
style={{ color: role.color || '#ebdbb2' }}
|
||||||
|
>
|
||||||
|
{role.name}
|
||||||
|
</span>
|
||||||
|
{role.is_default && (
|
||||||
|
<span className="text-gb-fg-f font-mono text-xs">[DEFAULT]</span>
|
||||||
|
)}
|
||||||
|
<span className="text-gb-fg-f font-mono text-xs ml-auto shrink-0">
|
||||||
|
POS:{role.position}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gb-fg-f font-mono text-xs mb-3">
|
||||||
|
perms: {permSummary(role.permissions)}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setEditingRole(role); setShowForm(true); }}
|
||||||
|
className="terminal-button text-xs"
|
||||||
|
>
|
||||||
|
[EDIT]
|
||||||
|
</button>
|
||||||
|
{!role.is_default && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDelete(role)}
|
||||||
|
className="terminal-button text-xs hover:!text-gb-red"
|
||||||
|
>
|
||||||
|
[DELETE]
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 pt-4 border-t border-gb-bg-t">
|
||||||
|
<Link to="/" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
||||||
|
{'<'} [BACK TO CHAT]
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<RoleForm
|
||||||
|
serverId={serverId}
|
||||||
|
role={editingRole ?? undefined}
|
||||||
|
onClose={() => { setShowForm(false); setEditingRole(null); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
|||||||
export const api = {
|
export const api = {
|
||||||
get: <T>(path: string) => request<T>('GET', path),
|
get: <T>(path: string) => request<T>('GET', path),
|
||||||
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
||||||
|
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
|
||||||
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
|
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
|
||||||
delete: <T>(path: string) => request<T>('DELETE', path),
|
delete: <T>(path: string) => request<T>('DELETE', path),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
createRole: (serverId: string, data: CreateRoleData) => Promise<Role>;
|
||||||
|
updateRole: (serverId: string, roleId: string, data: UpdateRoleData) => Promise<Role>;
|
||||||
|
deleteRole: (serverId: string, roleId: string) => Promise<void>;
|
||||||
|
setMemberRoles: (serverId: string, userId: string, roleIds: string[]) => Promise<void>;
|
||||||
|
getMemberRoles: (serverId: string, userId: string) => Promise<Role[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useRoleStore = create<RoleState>((set) => ({
|
||||||
|
roles: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
fetchRoles: async (serverId) => {
|
||||||
|
set({ loading: true, error: null });
|
||||||
|
try {
|
||||||
|
const roles = await api.get<Role[]>(`/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<Role>(`/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<Role>(`/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<Role[]>(`/servers/${serverId}/members/${userId}/roles`);
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to get member roles',
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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"}
|
{"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"}
|
||||||
Reference in New Issue
Block a user