From a0a1012cfbaca21c29514a46c1d3eb9763a68b05 Mon Sep 17 00:00:00 2001 From: ECWireless Date: Fri, 29 May 2026 15:52:44 -0600 Subject: [PATCH 1/2] feat: add wallet authentication and permissions --- .env.example | 7 +- README.md | 11 +- package.json | 7 +- pnpm-lock.yaml | 464 +++++++++++++++++++++++++ public/raidguild-full-logo.svg | 13 + src/app/api/auth/logout/route.ts | 19 + src/app/api/auth/nonce/route.ts | 21 ++ src/app/api/auth/session/route.ts | 18 + src/app/api/auth/verify/route.ts | 115 ++++++ src/app/globals.css | 30 ++ src/app/layout.tsx | 4 +- src/app/page.tsx | 89 ++++- src/components/auth/wallet-connect.tsx | 286 +++++++++++++++ src/components/providers.tsx | 20 ++ src/components/ui/toast.tsx | 79 +++++ src/db/index.ts | 15 + src/lib/auth/permissions.ts | 160 +++++++++ src/lib/auth/session.ts | 47 +++ src/lib/auth/types.ts | 16 + src/lib/wagmi.ts | 18 + 20 files changed, 1428 insertions(+), 11 deletions(-) create mode 100644 public/raidguild-full-logo.svg create mode 100644 src/app/api/auth/logout/route.ts create mode 100644 src/app/api/auth/nonce/route.ts create mode 100644 src/app/api/auth/session/route.ts create mode 100644 src/app/api/auth/verify/route.ts create mode 100644 src/components/auth/wallet-connect.tsx create mode 100644 src/components/providers.tsx create mode 100644 src/components/ui/toast.tsx create mode 100644 src/lib/auth/permissions.ts create mode 100644 src/lib/auth/session.ts create mode 100644 src/lib/auth/types.ts create mode 100644 src/lib/wagmi.ts diff --git a/.env.example b/.env.example index b513bfe..1546433 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,5 @@ -# Public wallet/app configuration +# Public app configuration NEXT_PUBLIC_APP_URL=http://localhost:3000 -NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= # Server-only configuration DATABASE_URL= @@ -12,6 +11,10 @@ ENCRYPTION_KEY= GNOSIS_RPC_URL= MAIN_SAFE_ADDRESS= DAO_CONTRACT_ADDRESS= +DAO_SHARE_TOKEN_ADDRESS= +DAO_SHARE_THRESHOLD=100 +HATS_CONTRACT_ADDRESS= +# Decimal or hex Hat ID values are both accepted. ANGRY_DWARF_HAT_ID= # External APIs diff --git a/README.md b/README.md index d41354f..a6eea23 100644 --- a/README.md +++ b/README.md @@ -53,15 +53,24 @@ The repo is public. Do not commit real treasury addresses, DAO addresses, RPC UR Use `.env.local` for local secrets. `.env.example` should contain only placeholder keys. Drizzle CLI commands also load `.env`, with `.env.local` overriding it when present. -The app targets Neon in production, while Drizzle migrations use the standard `pg` driver so local Postgres databases work with `pnpm db:migrate`. +The app targets Neon in production. At runtime it uses Neon HTTP for Neon URLs and the standard `pg` driver for localhost database URLs, so local Postgres works in both the app and migrations. `pnpm db:reset:local` refuses non-localhost database URLs and protected database names, but it is still destructive for the selected local database. +RaidGuild member access is checked with `DAO_SHARE_TOKEN_ADDRESS`, the DAOhaus/Baal ERC-20 shares token. `DAO_SHARE_THRESHOLD` is written as a human share amount such as `100`. +`ANGRY_DWARF_HAT_ID` can be provided as a decimal or hex string. + `ENCRYPTION_KEY` must be a base64-encoded 32-byte key. Multiple-key rotation requires stable `key-id:base64-key` entries. To generate a local development key: ```bash openssl rand -base64 32 ``` +`SESSION_SECRET` must be at least 32 characters. To generate a local development value: + +```bash +openssl rand -base64 32 +``` + ## Database Deployment The `Database` GitHub Actions workflow checks generated migrations on pull requests and applies committed migrations on pushes to `main`. diff --git a/package.json b/package.json index 0cdbfe5..62da915 100644 --- a/package.json +++ b/package.json @@ -19,18 +19,23 @@ "dependencies": { "@base-ui/react": "^1.5.0", "@neondatabase/serverless": "^1.1.0", + "@tanstack/react-query": "^5.100.14", "@vercel/analytics": "^2.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.45.2", + "iron-session": "^8.0.4", "lucide-react": "^1.17.0", "next": "16.2.6", "pg": "^8.18.0", "react": "19.2.4", "react-dom": "19.2.4", "server-only": "^0.0.1", + "siwe": "^3.0.0", "tailwind-merge": "^3.6.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "viem": "^2.51.3", + "wagmi": "^3.6.16" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc8ef1b..e16b77b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@neondatabase/serverless': specifier: ^1.1.0 version: 1.1.0 + '@tanstack/react-query': + specifier: ^5.100.14 + version: 5.100.14(react@19.2.4) '@vercel/analytics': specifier: ^2.0.1 version: 2.0.1(next@16.2.6(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) @@ -26,6 +29,9 @@ importers: drizzle-orm: specifier: ^0.45.2 version: 0.45.2(@neondatabase/serverless@1.1.0)(@types/pg@8.18.0)(pg@8.18.0) + iron-session: + specifier: ^8.0.4 + version: 8.0.4 lucide-react: specifier: ^1.17.0 version: 1.17.0(react@19.2.4) @@ -44,12 +50,21 @@ importers: server-only: specifier: ^0.0.1 version: 0.0.1 + siwe: + specifier: ^3.0.0 + version: 3.0.0(ethers@6.16.0) tailwind-merge: specifier: ^3.6.0 version: 3.6.0 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + viem: + specifier: ^2.51.3 + version: 2.51.3(typescript@5.9.3)(zod@4.4.3) + wagmi: + specifier: ^3.6.16 + version: 3.6.16(@tanstack/query-core@5.100.14)(@tanstack/react-query@5.100.14(react@19.2.4))(@types/react@19.2.15)(react@19.2.4)(typescript@5.9.3)(viem@2.51.3(typescript@5.9.3)(zod@4.4.3)) devDependencies: '@tailwindcss/postcss': specifier: ^4 @@ -87,6 +102,12 @@ importers: packages: + '@adraffy/ens-normalize@1.10.1': + resolution: {integrity: sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==} + + '@adraffy/ens-normalize@1.11.1': + resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -943,6 +964,25 @@ packages: cpu: [x64] os: [win32] + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.2.0': + resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} + + '@noble/curves@1.9.1': + resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.3.2': + resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} + engines: {node: '>= 16'} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -962,6 +1002,30 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@scure/base@1.2.6': + resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} + + '@scure/bip32@1.7.0': + resolution: {integrity: sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==} + + '@scure/bip39@1.6.0': + resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} + + '@spruceid/siwe-parser@3.0.0': + resolution: {integrity: sha512-Y92k63ilw/8jH9Ry4G2e7lQd0jZAvb0d/Q7ssSD0D9mp/Zt2aCXIc3g0ny9yhplpAx1QXHsMz/JJptHK/zDGdw==} + + '@stablelib/binary@1.0.1': + resolution: {integrity: sha512-ClJWvmL6UBM/wjkvv/7m5VP3GMr9t0osr4yVgLZsLCOz4hGN9gIAFEqnJ0TsSMAN+n840nf2cHZnA5/KFqHC7Q==} + + '@stablelib/int@1.0.1': + resolution: {integrity: sha512-byr69X/sDtDiIjIV6m4roLVWnNNlRGzsvxw+agj8CIEazqWGOQp2dTYgQhtyVXV9wpO6WyXRQUzLV/JRNumT2w==} + + '@stablelib/random@1.0.2': + resolution: {integrity: sha512-rIsE83Xpb7clHPVRlBj8qNe5L8ISQOzjghYQm/dZ7VaM2KHYwMW5adjQjrzTZCchFnNCNhkwtnOBa9HTMJCI8w==} + + '@stablelib/wipe@1.0.1': + resolution: {integrity: sha512-WfqfX/eXGiAd3RJe4VU2snh/ZPwtSjLG4ynQ/vYzvghTh7dHFcI1wl+nrkWG6lGhukOxOsUHfv8dUXr58D0ayg==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -1053,6 +1117,14 @@ packages: '@tailwindcss/postcss@4.3.0': resolution: {integrity: sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==} + '@tanstack/query-core@5.100.14': + resolution: {integrity: sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==} + + '@tanstack/react-query@5.100.14': + resolution: {integrity: sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==} + peerDependencies: + react: ^18 || ^19 + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} @@ -1068,6 +1140,9 @@ packages: '@types/node@20.19.41': resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==} + '@types/node@22.7.5': + resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==} + '@types/pg@8.18.0': resolution: {integrity: sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==} @@ -1277,6 +1352,66 @@ packages: vue-router: optional: true + '@wagmi/connectors@8.0.15': + resolution: {integrity: sha512-LNr73YNs2KY6y6GXUWRXsDG0xSB5YpkO/9BSp3/2IAcM5XDPWcS4V1HfstXnP/ms9LARKLe2BAOCG44LajTWjw==} + peerDependencies: + '@base-org/account': ^2.5.1 + '@coinbase/wallet-sdk': ^4.3.6 + '@metamask/connect-evm': ^1.3.0 + '@safe-global/safe-apps-provider': ~0.18.6 + '@safe-global/safe-apps-sdk': ^9.1.0 + '@wagmi/core': 3.5.0 + '@walletconnect/ethereum-provider': ^2.21.1 + accounts: ~0.10 + porto: ~0.2.35 + typescript: '>=5.9.3' + viem: 2.x + peerDependenciesMeta: + '@base-org/account': + optional: true + '@coinbase/wallet-sdk': + optional: true + '@metamask/connect-evm': + optional: true + '@safe-global/safe-apps-provider': + optional: true + '@safe-global/safe-apps-sdk': + optional: true + '@walletconnect/ethereum-provider': + optional: true + accounts: + optional: true + porto: + optional: true + typescript: + optional: true + + '@wagmi/core@3.5.0': + resolution: {integrity: sha512-OcbdnZA6XPyDOHtY6GR8MFzRa89/EkMFCxdrWV4cgKjfsLi02NzEbMG6LQEO7VJ9ltwmLwpRQgcWnn6pBJPo7Q==} + peerDependencies: + '@tanstack/query-core': '>=5.0.0' + accounts: ~0.12 + typescript: '>=5.9.3' + viem: 2.x + peerDependenciesMeta: + '@tanstack/query-core': + optional: true + accounts: + optional: true + typescript: + optional: true + + abitype@1.2.3: + resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3.22.0 || ^4.0.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1287,6 +1422,9 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + aes-js@4.0.0-beta.5: + resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==} + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} @@ -1294,6 +1432,9 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + apg-js@4.4.0: + resolution: {integrity: sha512-fefmXFknJmtgtNEXfPwZKYkMFX4Fyeyz+fNF6JWp87biGOPslJbCBVU158zvKRZfHBKnJDy8CMM40oLFGkXT8Q==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1429,6 +1570,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1776,6 +1921,13 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + ethers@6.16.0: + resolution: {integrity: sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==} + engines: {node: '>=14.0.0'} + + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1942,6 +2094,12 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + iron-session@8.0.4: + resolution: {integrity: sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA==} + + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -2051,6 +2209,11 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isows@1.0.7: + resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} + peerDependencies: + ws: '*' + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -2221,6 +2384,14 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + mipd@0.0.7: + resolution: {integrity: sha512-aAPZPNDQ3uMTdKbuO2YmAw2TxLHO0moa4YKAyETM/DTj5FloZo+a+8tU+iv4GmW+sOxKLSRwcSFuczk+Cpt6fg==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2306,6 +2477,14 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} + ox@0.14.25: + resolution: {integrity: sha512-8DoibKtxE8yw63Y2jjMhlbjaURev6WCx4QR4MWLusl2/qIaeTzMJMBIYIDl1KOF45+8H1Ur6eLTdPlUoO8PlRw==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -2525,6 +2704,11 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siwe@3.0.0: + resolution: {integrity: sha512-P2/ry7dHYJA6JJ5+veS//Gn2XDwNb3JMvuD6xiXX8L/PJ1SNVD4a3a8xqEbmANx+7kNQcD8YAh1B9bNKKvRy/g==} + peerDependencies: + ethers: ^5.6.8 || ^6.0.8 + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2626,6 +2810,9 @@ packages: tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + tslib@2.7.0: + resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2673,6 +2860,12 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + + undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -2688,11 +2881,35 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-sync-external-store@1.4.0: + resolution: {integrity: sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + viem@2.51.3: + resolution: {integrity: sha512-DA4EbrsvatzzLo6MwcWWiv6kI6dIr3I9HH9B6qsJaClN/s0AjIDUz5RIxl+VmGrovIUCcIvG8744yuGH7d37zw==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + + wagmi@3.6.16: + resolution: {integrity: sha512-tM6/81vwmiNrJneApCL+qJdQoAyn2Fyd6bL88dC8A0Zp13MVeK7/cp1wzia5AoY0yqJRQHjEGk2Zzqrkdht/kA==} + peerDependencies: + '@tanstack/react-query': '>=5.0.0' + react: '>=18' + typescript: '>=5.9.3' + viem: 2.x + peerDependenciesMeta: + typescript: + optional: true + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -2718,6 +2935,30 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + ws@8.17.1: + resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -2738,8 +2979,30 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zustand@5.0.0: + resolution: {integrity: sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: + '@adraffy/ens-normalize@1.10.1': {} + + '@adraffy/ens-normalize@1.11.1': {} + '@alloc/quick-lru@5.2.0': {} '@babel/code-frame@7.29.7': @@ -3351,6 +3614,20 @@ snapshots: '@next/swc-win32-x64-msvc@16.2.6': optional: true + '@noble/ciphers@1.3.0': {} + + '@noble/curves@1.2.0': + dependencies: + '@noble/hashes': 1.3.2 + + '@noble/curves@1.9.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.3.2': {} + + '@noble/hashes@1.8.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3367,6 +3644,37 @@ snapshots: '@rtsao/scc@1.1.0': {} + '@scure/base@1.2.6': {} + + '@scure/bip32@1.7.0': + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + + '@scure/bip39@1.6.0': + dependencies: + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + + '@spruceid/siwe-parser@3.0.0': + dependencies: + '@noble/hashes': 1.8.0 + apg-js: 4.4.0 + + '@stablelib/binary@1.0.1': + dependencies: + '@stablelib/int': 1.0.1 + + '@stablelib/int@1.0.1': {} + + '@stablelib/random@1.0.2': + dependencies: + '@stablelib/binary': 1.0.1 + '@stablelib/wipe': 1.0.1 + + '@stablelib/wipe@1.0.1': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -3440,6 +3748,13 @@ snapshots: postcss: 8.5.15 tailwindcss: 4.3.0 + '@tanstack/query-core@5.100.14': {} + + '@tanstack/react-query@5.100.14(react@19.2.4)': + dependencies: + '@tanstack/query-core': 5.100.14 + react: 19.2.4 + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 @@ -3455,6 +3770,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@22.7.5': + dependencies: + undici-types: 6.19.8 + '@types/pg@8.18.0': dependencies: '@types/node': 20.19.41 @@ -3635,12 +3954,41 @@ snapshots: next: 16.2.6(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 + '@wagmi/connectors@8.0.15(@wagmi/core@3.5.0(@tanstack/query-core@5.100.14)(@types/react@19.2.15)(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.51.3(typescript@5.9.3)(zod@4.4.3)))(typescript@5.9.3)(viem@2.51.3(typescript@5.9.3)(zod@4.4.3))': + dependencies: + '@wagmi/core': 3.5.0(@tanstack/query-core@5.100.14)(@types/react@19.2.15)(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.51.3(typescript@5.9.3)(zod@4.4.3)) + viem: 2.51.3(typescript@5.9.3)(zod@4.4.3) + optionalDependencies: + typescript: 5.9.3 + + '@wagmi/core@3.5.0(@tanstack/query-core@5.100.14)(@types/react@19.2.15)(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.51.3(typescript@5.9.3)(zod@4.4.3))': + dependencies: + eventemitter3: 5.0.1 + mipd: 0.0.7(typescript@5.9.3) + viem: 2.51.3(typescript@5.9.3)(zod@4.4.3) + zustand: 5.0.0(@types/react@19.2.15)(react@19.2.4)(use-sync-external-store@1.4.0(react@19.2.4)) + optionalDependencies: + '@tanstack/query-core': 5.100.14 + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/react' + - immer + - react + - use-sync-external-store + + abitype@1.2.3(typescript@5.9.3)(zod@4.4.3): + optionalDependencies: + typescript: 5.9.3 + zod: 4.4.3 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 acorn@8.16.0: {} + aes-js@4.0.0-beta.5: {} + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -3652,6 +4000,8 @@ snapshots: dependencies: color-convert: 2.0.1 + apg-js@4.4.0: {} + argparse@2.0.1: {} aria-query@5.3.2: {} @@ -3808,6 +4158,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie@0.7.2: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -4287,6 +4639,21 @@ snapshots: esutils@2.0.3: {} + ethers@6.16.0: + dependencies: + '@adraffy/ens-normalize': 1.10.1 + '@noble/curves': 1.2.0 + '@noble/hashes': 1.3.2 + '@types/node': 22.7.5 + aes-js: 4.0.0-beta.5 + tslib: 2.7.0 + ws: 8.17.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + eventemitter3@5.0.1: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.1: @@ -4447,6 +4814,14 @@ snapshots: hasown: 2.0.4 side-channel: 1.1.0 + iron-session@8.0.4: + dependencies: + cookie: 0.7.2 + iron-webcrypto: 1.2.1 + uncrypto: 0.1.3 + + iron-webcrypto@1.2.1: {} + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9 @@ -4563,6 +4938,10 @@ snapshots: isexe@2.0.0: {} + isows@1.0.7(ws@8.20.1): + dependencies: + ws: 8.20.1 + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -4706,6 +5085,10 @@ snapshots: minimist@1.2.8: {} + mipd@0.0.7(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + ms@2.1.3: {} nanoid@3.3.12: {} @@ -4804,6 +5187,21 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + ox@0.14.25(typescript@5.9.3)(zod@4.4.3): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@4.4.3) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - zod + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -5064,6 +5462,12 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siwe@3.0.0(ethers@6.16.0): + dependencies: + '@spruceid/siwe-parser': 3.0.0 + '@stablelib/random': 1.0.2 + ethers: 6.16.0 + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -5175,6 +5579,8 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 + tslib@2.7.0: {} + tslib@2.8.1: {} tsx@4.22.3: @@ -5242,6 +5648,10 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + uncrypto@0.1.3: {} + + undici-types@6.19.8: {} + undici-types@6.21.0: {} unrs-resolver@1.12.2: @@ -5281,10 +5691,54 @@ snapshots: dependencies: punycode: 2.3.1 + use-sync-external-store@1.4.0(react@19.2.4): + dependencies: + react: 19.2.4 + use-sync-external-store@1.6.0(react@19.2.4): dependencies: react: 19.2.4 + viem@2.51.3(typescript@5.9.3)(zod@4.4.3): + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.9.3)(zod@4.4.3) + isows: 1.0.7(ws@8.20.1) + ox: 0.14.25(typescript@5.9.3)(zod@4.4.3) + ws: 8.20.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + + wagmi@3.6.16(@tanstack/query-core@5.100.14)(@tanstack/react-query@5.100.14(react@19.2.4))(@types/react@19.2.15)(react@19.2.4)(typescript@5.9.3)(viem@2.51.3(typescript@5.9.3)(zod@4.4.3)): + dependencies: + '@tanstack/react-query': 5.100.14(react@19.2.4) + '@wagmi/connectors': 8.0.15(@wagmi/core@3.5.0(@tanstack/query-core@5.100.14)(@types/react@19.2.15)(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.51.3(typescript@5.9.3)(zod@4.4.3)))(typescript@5.9.3)(viem@2.51.3(typescript@5.9.3)(zod@4.4.3)) + '@wagmi/core': 3.5.0(@tanstack/query-core@5.100.14)(@types/react@19.2.15)(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.51.3(typescript@5.9.3)(zod@4.4.3)) + react: 19.2.4 + use-sync-external-store: 1.4.0(react@19.2.4) + viem: 2.51.3(typescript@5.9.3)(zod@4.4.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@base-org/account' + - '@coinbase/wallet-sdk' + - '@metamask/connect-evm' + - '@safe-global/safe-apps-provider' + - '@safe-global/safe-apps-sdk' + - '@tanstack/query-core' + - '@types/react' + - '@walletconnect/ethereum-provider' + - accounts + - immer + - porto + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -5332,6 +5786,10 @@ snapshots: word-wrap@1.2.5: {} + ws@8.17.1: {} + + ws@8.20.1: {} + xtend@4.0.2: {} yallist@3.1.1: {} @@ -5343,3 +5801,9 @@ snapshots: zod: 4.4.3 zod@4.4.3: {} + + zustand@5.0.0(@types/react@19.2.15)(react@19.2.4)(use-sync-external-store@1.4.0(react@19.2.4)): + optionalDependencies: + '@types/react': 19.2.15 + react: 19.2.4 + use-sync-external-store: 1.4.0(react@19.2.4) diff --git a/public/raidguild-full-logo.svg b/public/raidguild-full-logo.svg new file mode 100644 index 0000000..f5a7fd2 --- /dev/null +++ b/public/raidguild-full-logo.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..8fd9e00 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; + +import { getAuthSession } from "@/lib/auth/session"; + +export async function POST() { + try { + const session = await getAuthSession(); + session.destroy(); + + return NextResponse.json({ ok: true }); + } catch (error) { + console.error("Wallet logout failed", error); + + return NextResponse.json( + { error: "Session configuration is missing" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/auth/nonce/route.ts b/src/app/api/auth/nonce/route.ts new file mode 100644 index 0000000..8e404e6 --- /dev/null +++ b/src/app/api/auth/nonce/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { generateNonce } from "siwe"; + +import { getAuthSession } from "@/lib/auth/session"; + +export async function GET() { + try { + const session = await getAuthSession(); + session.nonce = generateNonce(); + await session.save(); + + return NextResponse.json({ nonce: session.nonce }); + } catch (error) { + console.error("Wallet nonce creation failed", error); + + return NextResponse.json( + { error: "Session configuration is missing" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts new file mode 100644 index 0000000..536d98a --- /dev/null +++ b/src/app/api/auth/session/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; + +import { getAuthSession, serializeSession } from "@/lib/auth/session"; + +export async function GET() { + try { + const session = await getAuthSession(); + + return NextResponse.json(serializeSession(session)); + } catch (error) { + console.error("Wallet session lookup failed", error); + + return NextResponse.json( + { error: "Session configuration is missing" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/auth/verify/route.ts b/src/app/api/auth/verify/route.ts new file mode 100644 index 0000000..124aded --- /dev/null +++ b/src/app/api/auth/verify/route.ts @@ -0,0 +1,115 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { getAddress } from "viem"; +import { SiweMessage } from "siwe"; + +import { + getAuthSession, + serializeSession, +} from "@/lib/auth/session"; +import { getWalletPermissions } from "@/lib/auth/permissions"; + +type VerifyRequestBody = { + message?: string; + signature?: string; +}; + +function getPublicErrorMessage(error: unknown) { + const message = + error instanceof Error ? error.message : "Wallet verification failed"; + const lowerMessage = message.toLowerCase(); + + if (lowerMessage.includes("session_secret")) { + return "Session configuration is missing"; + } + + if (lowerMessage.includes("gnosis_rpc_url")) { + return "GNOSIS_RPC_URL is required for permission checks"; + } + + if (lowerMessage.includes("dao_share_token_address")) { + return "DAO_SHARE_TOKEN_ADDRESS is required for member access checks"; + } + + if (lowerMessage.includes("hats_contract_address")) { + return "HATS_CONTRACT_ADDRESS must be a valid EVM address"; + } + + if ( + lowerMessage.includes("execution reverted") || + lowerMessage.includes("contract function") + ) { + return "Permission contract read failed"; + } + + if (lowerMessage.includes("database_url")) { + return "DATABASE_URL is required for permission checks"; + } + + return "Wallet verification failed"; +} + +function getExpectedDomain(request: NextRequest) { + const configuredUrl = process.env.NEXT_PUBLIC_APP_URL; + + if (configuredUrl) { + return new URL(configuredUrl).host; + } + + return request.headers.get("host") ?? request.nextUrl.host; +} + +export async function POST(request: NextRequest) { + try { + const session = await getAuthSession(); + const { message, signature } = (await request.json()) as VerifyRequestBody; + + if (!session.nonce || !message || !signature) { + return NextResponse.json( + { error: "Missing SIWE nonce, message, or signature" }, + { status: 400 }, + ); + } + + const siweMessage = new SiweMessage(message); + const verification = await siweMessage.verify({ + domain: getExpectedDomain(request), + nonce: session.nonce, + signature, + }); + + if (!verification.success) { + session.destroy(); + return NextResponse.json( + { error: "Wallet signature could not be verified" }, + { status: 401 }, + ); + } + + const address = getAddress(verification.data.address); + const permissions = await getWalletPermissions(address); + + if (!permissions.canAccess) { + session.destroy(); + return NextResponse.json( + { error: "Wallet does not have RaidGuild accounting access" }, + { status: 403 }, + ); + } + + session.address = address; + session.authenticatedAt = new Date().toISOString(); + session.chainId = verification.data.chainId; + session.permissions = permissions; + delete session.nonce; + await session.save(); + + return NextResponse.json(serializeSession(session)); + } catch (error) { + console.error("Wallet verification failed", error); + + return NextResponse.json( + { error: getPublicErrorMessage(error) }, + { status: 500 }, + ); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 2a3e7e6..85593d6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -153,6 +153,36 @@ } @layer utilities { + @keyframes toast-in { + from { + opacity: 0; + transform: translateY(0.5rem) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + @keyframes toast-out { + from { + opacity: 1; + transform: translateY(0) scale(1); + } + to { + opacity: 0; + transform: translateY(0.5rem) scale(0.98); + } + } + + .toast-enter { + animation: toast-in 180ms ease-out both; + } + + .animate-toast-out { + animation: toast-out 180ms ease-in both; + } + .container-custom { @apply mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 955d95e..3feac45 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,8 @@ import type { Metadata } from "next"; import { Analytics } from "@vercel/analytics/next"; import "./globals.css"; +import { Providers } from "@/components/providers"; + export const metadata: Metadata = { title: "RaidGuild Accounting", description: "Wallet-gated accounting dashboard for RaidGuild treasury reporting.", @@ -15,7 +17,7 @@ export default function RootLayout({ return ( - {children} + {children} diff --git a/src/app/page.tsx b/src/app/page.tsx index 776c2a3..e42f0c6 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,10 +4,12 @@ import { FileSpreadsheet, Landmark, LockKeyhole, - ShieldCheck, } from "lucide-react"; +import Image from "next/image"; +import { WalletConnect } from "@/components/auth/wallet-connect"; import { Button } from "@/components/ui/button"; +import { getAuthSession, serializeSession } from "@/lib/auth/session"; const assets = [ { symbol: "USDC", balance: "$0.00", tone: "bg-moloch-500" }, @@ -23,7 +25,75 @@ const milestones = [ "Q1 XLSX export", ]; -export default function Home() { +async function getSessionState() { + try { + const session = await getAuthSession(); + return serializeSession(session); + } catch { + return { + address: null, + authenticated: false, + chainId: null, + permissions: null, + }; + } +} + +function PublicHome() { + return ( +
+
+
+
+ RaidGuild +

+ Accounting Dashboard +

+

+ Member access for treasury reporting. +

+

+ Connect a RaidGuild member wallet to view treasury balances and + export-ready accounting records. +

+
+ +
+
+
+
+
+

+ Wallet Required +

+

+ Sign in with Ethereum +

+
+
+

+ Access is checked against DAO shares, Angry Dwarf Hats, and + database-managed Cleric permissions. +

+
+ +
+
+
+
+
+ ); +} + +function MemberHome() { return (
@@ -39,10 +109,7 @@ export default function Home() { - +
@@ -165,3 +232,13 @@ export default function Home() {
); } + +export default async function Home() { + const session = await getSessionState(); + + if (!session.authenticated) { + return ; + } + + return ; +} diff --git a/src/components/auth/wallet-connect.tsx b/src/components/auth/wallet-connect.tsx new file mode 100644 index 0000000..8f9d77a --- /dev/null +++ b/src/components/auth/wallet-connect.tsx @@ -0,0 +1,286 @@ +"use client"; + +import { LogOut, ShieldCheck, Wallet } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { SiweMessage } from "siwe"; +import { useEffect, useMemo, useState } from "react"; +import { + useAccount, + useConnect, + useDisconnect, + useSignMessage, +} from "wagmi"; + +import { Button } from "@/components/ui/button"; +import { useToast } from "@/components/ui/toast"; +import type { AuthPermissions } from "@/lib/auth/types"; + +type SessionResponse = { + address: string | null; + authenticated: boolean; + chainId: number | null; + permissions: AuthPermissions | null; +}; + +const emptySession: SessionResponse = { + address: null, + authenticated: false, + chainId: null, + permissions: null, +}; + +function formatAddress(address: string) { + return `${address.slice(0, 6)}...${address.slice(-4)}`; +} + +function getFriendlyError(error: unknown) { + const message = + error instanceof Error ? error.message : "Wallet sign-in failed"; + const lowerMessage = message.toLowerCase(); + + if ( + lowerMessage.includes("user rejected") || + lowerMessage.includes("user denied") || + lowerMessage.includes("rejected the request") + ) { + return "Request cancelled."; + } + + if (lowerMessage.includes("no wallet connector")) { + return "No injected wallet found."; + } + + if (lowerMessage.includes("chain id")) { + return "Could not detect the connected chain."; + } + + if (lowerMessage.includes("session configuration")) { + return "Session setup is missing."; + } + + if (lowerMessage.includes("gnosis_rpc_url")) { + return "Gnosis RPC URL is missing."; + } + + if (lowerMessage.includes("dao_contract_address")) { + return "DAO contract address is missing."; + } + + if (lowerMessage.includes("dao_share_token_address")) { + return "DAO shares token address is missing."; + } + + if (lowerMessage.includes("hats_contract_address")) { + return "Hats contract address is invalid."; + } + + if (lowerMessage.includes("does not have raidguild accounting access")) { + return "This wallet does not have access."; + } + + if (lowerMessage.includes("execution reverted")) { + return "Permission contract read failed."; + } + + if (lowerMessage.includes("signature")) { + return "Signature verification failed."; + } + + return "Wallet sign-in failed."; +} + +export function WalletConnect() { + const account = useAccount(); + const router = useRouter(); + const { connectAsync, connectors, isPending: isConnecting } = useConnect(); + const { disconnectAsync } = useDisconnect(); + const { signMessageAsync, isPending: isSigning } = useSignMessage(); + const { showToast } = useToast(); + const [session, setSession] = useState(emptySession); + const [isLoadingSession, setIsLoadingSession] = useState(true); + const [isVerifying, setIsVerifying] = useState(false); + + const primaryConnector = useMemo( + () => + connectors.find((connector) => connector.type === "injected") ?? + connectors[0], + [connectors], + ); + + useEffect(() => { + let isMounted = true; + + fetch("/api/auth/session") + .then(async (response) => { + const payload = (await response.json()) as + | SessionResponse + | { error: string }; + + if (!response.ok || "error" in payload) { + throw new Error( + "error" in payload ? payload.error : "Session lookup failed", + ); + } + + return payload; + }) + .then((nextSession) => { + if (isMounted) { + setSession(nextSession); + } + }) + .catch(() => { + if (isMounted) { + showToast("Session setup is missing."); + } + }) + .finally(() => { + if (isMounted) { + setIsLoadingSession(false); + } + }); + + return () => { + isMounted = false; + }; + }, [showToast]); + + async function signIn() { + setIsVerifying(true); + + try { + if (!primaryConnector) { + throw new Error("No wallet connector is available"); + } + + if (!account.address && !("ethereum" in window)) { + throw new Error("No injected wallet found"); + } + + const connectedAccount = account.address + ? { address: account.address, chainId: account.chainId } + : await connectAsync({ connector: primaryConnector }); + const address = + "address" in connectedAccount + ? connectedAccount.address + : connectedAccount.accounts[0]; + const chainId = connectedAccount.chainId; + + if (!chainId) { + throw new Error("Wallet chain ID is unavailable"); + } + + const appUrl = process.env.NEXT_PUBLIC_APP_URL; + const domain = appUrl ? new URL(appUrl).host : window.location.host; + const uri = appUrl ?? window.location.origin; + const nonceResponse = await fetch("/api/auth/nonce"); + const noncePayload = (await nonceResponse.json().catch(() => ({ + error: "Could not start wallet sign-in", + }))) as + | { nonce: string } + | { error: string }; + + if (!nonceResponse.ok || "error" in noncePayload) { + throw new Error( + "error" in noncePayload + ? noncePayload.error + : "Could not start wallet sign-in", + ); + } + + const { nonce } = noncePayload; + const message = new SiweMessage({ + address, + chainId, + domain, + nonce, + statement: "Sign in to RaidGuild Accounting.", + uri, + version: "1", + }).prepareMessage(); + const signature = await signMessageAsync({ message }); + const verifyResponse = await fetch("/api/auth/verify", { + body: JSON.stringify({ message, signature }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + const nextSession = (await verifyResponse.json().catch(() => ({ + error: "Wallet verification failed", + }))) as SessionResponse | { error: string }; + + if (!verifyResponse.ok || "error" in nextSession) { + throw new Error( + "error" in nextSession + ? nextSession.error + : "Wallet verification failed", + ); + } + + setSession(nextSession); + router.refresh(); + } catch (nextError) { + showToast( + "ethereum" in window + ? getFriendlyError(nextError) + : "No injected wallet found.", + ); + } finally { + setIsVerifying(false); + } + } + + async function signOut() { + await fetch("/api/auth/logout", { method: "POST" }); + await disconnectAsync(); + setSession(emptySession); + router.refresh(); + } + + if (session.authenticated && session.address) { + return ( +
+
+ {formatAddress(session.address)} + {session.permissions?.roles.length ? ( + + {session.permissions.roles.join(", ")} + + ) : null} +
+ +
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/src/components/providers.tsx b/src/components/providers.tsx new file mode 100644 index 0000000..9481d24 --- /dev/null +++ b/src/components/providers.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState, type ReactNode } from "react"; +import { WagmiProvider } from "wagmi"; + +import { ToastProvider } from "@/components/ui/toast"; +import { wagmiConfig } from "@/lib/wagmi"; + +export function Providers({ children }: { children: ReactNode }) { + const [queryClient] = useState(() => new QueryClient()); + + return ( + + + {children} + + + ); +} diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx new file mode 100644 index 0000000..0b37c70 --- /dev/null +++ b/src/components/ui/toast.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useMemo, + useState, + type ReactNode, +} from "react"; + +type Toast = { + id: number; + message: string; + state: "entering" | "leaving"; +}; + +type ToastContextValue = { + showToast: (message: string) => void; +}; + +const ToastContext = createContext(null); + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + + const showToast = useCallback((message: string) => { + const id = Date.now(); + setToasts((currentToasts) => [ + ...currentToasts, + { id, message, state: "entering" }, + ]); + window.setTimeout(() => { + setToasts((currentToasts) => + currentToasts.map((toast) => + toast.id === id ? { ...toast, state: "leaving" } : toast, + ), + ); + }, 3000); + window.setTimeout(() => { + setToasts((currentToasts) => + currentToasts.filter((toast) => toast.id !== id), + ); + }, 3250); + }, []); + + const value = useMemo(() => ({ showToast }), [showToast]); + + return ( + + {children} +
+ {toasts.map((toast) => ( +
+ {toast.message} +
+ ))} +
+
+ ); +} + +export function useToast() { + const context = useContext(ToastContext); + + if (!context) { + throw new Error("useToast must be used within ToastProvider"); + } + + return context; +} diff --git a/src/db/index.ts b/src/db/index.ts index 612ba0d..e90baf6 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -2,11 +2,22 @@ import "server-only"; import { neon } from "@neondatabase/serverless"; import { drizzle } from "drizzle-orm/neon-http"; +import { drizzle as drizzleNode } from "drizzle-orm/node-postgres"; import * as schema from "@/db/schema"; let cachedDb: ReturnType | undefined; +function isLocalDatabaseUrl(databaseUrl: string) { + const hostname = new URL(databaseUrl).hostname; + + return ( + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "::1" + ); +} + function createDb() { const databaseUrl = process.env.DATABASE_URL; @@ -14,6 +25,10 @@ function createDb() { throw new Error("DATABASE_URL is required to initialize the database"); } + if (isLocalDatabaseUrl(databaseUrl)) { + return drizzleNode(databaseUrl, { schema }); + } + return drizzle(neon(databaseUrl), { schema }); } diff --git a/src/lib/auth/permissions.ts b/src/lib/auth/permissions.ts new file mode 100644 index 0000000..06ead2b --- /dev/null +++ b/src/lib/auth/permissions.ts @@ -0,0 +1,160 @@ +import "server-only"; + +import { and, isNull, sql } from "drizzle-orm"; +import { + createPublicClient, + getAddress, + http, + isAddress, + parseUnits, +} from "viem"; +import { gnosis } from "viem/chains"; + +import { getDb } from "@/db"; +import { clericRoles } from "@/db/schema"; +import type { AuthPermissions, AuthRole } from "@/lib/auth/types"; + +const ERC20_MEMBERSHIP_ABI = [ + { + inputs: [{ internalType: "address", name: "account", type: "address" }], + name: "balanceOf", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "decimals", + outputs: [{ internalType: "uint8", name: "", type: "uint8" }], + stateMutability: "view", + type: "function", + }, +] as const; + +const HATS_ABI = [ + { + inputs: [ + { internalType: "address", name: "_wearer", type: "address" }, + { internalType: "uint256", name: "_hatId", type: "uint256" }, + ], + name: "isWearerOfHat", + outputs: [{ internalType: "bool", name: "wearing", type: "bool" }], + stateMutability: "view", + type: "function", + }, +] as const; + +function getGnosisClient() { + const rpcUrl = process.env.GNOSIS_RPC_URL; + + if (!rpcUrl) { + throw new Error("GNOSIS_RPC_URL is required for permission checks"); + } + + return createPublicClient({ + chain: gnosis, + transport: http(rpcUrl), + }); +} + +async function hasDaoShares(address: `0x${string}`) { + const shareTokenAddress = process.env.DAO_SHARE_TOKEN_ADDRESS; + + if (!shareTokenAddress || !isAddress(shareTokenAddress)) { + throw new Error( + "DAO_SHARE_TOKEN_ADDRESS is required for member access checks", + ); + } + + const client = getGnosisClient(); + const tokenAddress = getAddress(shareTokenAddress); + const [balance, decimals] = await Promise.all([ + client.readContract({ + abi: ERC20_MEMBERSHIP_ABI, + address: tokenAddress, + args: [address], + functionName: "balanceOf", + }), + client.readContract({ + abi: ERC20_MEMBERSHIP_ABI, + address: tokenAddress, + functionName: "decimals", + }), + ]); + const threshold = parseUnits(process.env.DAO_SHARE_THRESHOLD ?? "100", decimals); + + return balance >= threshold; +} + +async function hasAngryDwarfHat(address: `0x${string}`) { + const hatsAddress = process.env.HATS_CONTRACT_ADDRESS; + const hatId = process.env.ANGRY_DWARF_HAT_ID; + + if (!hatsAddress || !hatId) { + return false; + } + + if (!isAddress(hatsAddress)) { + throw new Error("HATS_CONTRACT_ADDRESS must be a valid EVM address"); + } + + return getGnosisClient().readContract({ + abi: HATS_ABI, + address: getAddress(hatsAddress), + args: [address, BigInt(hatId)], + functionName: "isWearerOfHat", + }); +} + +async function hasClericRole(address: `0x${string}`) { + const normalizedAddress = address.toLowerCase(); + const db = getDb(); + const role = await db + .select({ id: clericRoles.id }) + .from(clericRoles) + .where( + and( + sql`lower(${clericRoles.walletAddress}) = ${normalizedAddress}`, + isNull(clericRoles.revokedAt), + ), + ) + .limit(1); + + return role.length > 0; +} + +export async function getWalletPermissions( + walletAddress: string, +): Promise { + if (!isAddress(walletAddress)) { + throw new Error("Invalid wallet address"); + } + + const address = getAddress(walletAddress); + const [isMember, isAdmin, isCleric] = await Promise.all([ + hasDaoShares(address), + hasAngryDwarfHat(address), + hasClericRole(address), + ]); + + const roles: AuthRole[] = []; + + if (isMember) { + roles.push("member"); + } + + if (isAdmin) { + roles.push("admin"); + } + + if (isCleric) { + roles.push("cleric"); + } + + return { + canAccess: isMember || isAdmin || isCleric, + canAdmin: isAdmin, + canWriteRaidAccounting: isAdmin || isCleric, + roles, + }; +} diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts new file mode 100644 index 0000000..4fca393 --- /dev/null +++ b/src/lib/auth/session.ts @@ -0,0 +1,47 @@ +import "server-only"; + +import { cookies } from "next/headers"; +import { getIronSession, type SessionOptions } from "iron-session"; + +import type { AuthSessionData } from "@/lib/auth/types"; + +const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; + +function getSessionPassword() { + const password = process.env.SESSION_SECRET; + + if (!password || password.length < 32) { + throw new Error("SESSION_SECRET must be at least 32 characters"); + } + + return password; +} + +export function getSessionOptions(): SessionOptions { + return { + cookieName: "raidguild-accounting-session", + password: getSessionPassword(), + ttl: SESSION_TTL_SECONDS, + cookieOptions: { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + }, + }; +} + +export async function getAuthSession() { + return getIronSession( + await cookies(), + getSessionOptions(), + ); +} + +export function serializeSession(session: AuthSessionData) { + return { + address: session.address ?? null, + authenticated: Boolean(session.address && session.permissions?.canAccess), + chainId: session.chainId ?? null, + permissions: session.permissions ?? null, + }; +} diff --git a/src/lib/auth/types.ts b/src/lib/auth/types.ts new file mode 100644 index 0000000..43f1321 --- /dev/null +++ b/src/lib/auth/types.ts @@ -0,0 +1,16 @@ +export type AuthRole = "admin" | "cleric" | "member"; + +export type AuthPermissions = { + canAccess: boolean; + canAdmin: boolean; + canWriteRaidAccounting: boolean; + roles: AuthRole[]; +}; + +export type AuthSessionData = { + address?: `0x${string}`; + authenticatedAt?: string; + chainId?: number; + nonce?: string; + permissions?: AuthPermissions; +}; diff --git a/src/lib/wagmi.ts b/src/lib/wagmi.ts new file mode 100644 index 0000000..2565132 --- /dev/null +++ b/src/lib/wagmi.ts @@ -0,0 +1,18 @@ +"use client"; + +import { http, createConfig } from "wagmi"; +import { arbitrum, base, gnosis, mainnet, optimism } from "wagmi/chains"; +import { injected } from "wagmi/connectors"; + +export const wagmiConfig = createConfig({ + chains: [gnosis, mainnet, arbitrum, optimism, base], + connectors: [injected()], + ssr: true, + transports: { + [arbitrum.id]: http(), + [base.id]: http(), + [gnosis.id]: http(), + [mainnet.id]: http(), + [optimism.id]: http(), + }, +}); From e25080972b6adaa551f5f37018278c2bd888366f Mon Sep 17 00:00:00 2001 From: ECWireless Date: Fri, 29 May 2026 16:20:14 -0600 Subject: [PATCH 2/2] fix: address wallet auth review feedback --- README.md | 3 +- src/app/api/auth/logout/route.ts | 2 +- src/app/api/auth/nonce/route.ts | 2 +- src/app/api/auth/session/route.ts | 2 +- src/app/api/auth/verify/route.ts | 17 +++++++- src/app/page.tsx | 14 ++++--- src/components/auth/wallet-connect.tsx | 56 ++++++++++++++++++++++---- src/components/ui/toast.tsx | 4 +- src/db/index.ts | 2 +- src/lib/auth/permissions.ts | 8 +++- 10 files changed, 87 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index a6eea23..a0f4967 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,8 @@ The app targets Neon in production. At runtime it uses Neon HTTP for Neon URLs a `pnpm db:reset:local` refuses non-localhost database URLs and protected database names, but it is still destructive for the selected local database. RaidGuild member access is checked with `DAO_SHARE_TOKEN_ADDRESS`, the DAOhaus/Baal ERC-20 shares token. `DAO_SHARE_THRESHOLD` is written as a human share amount such as `100`. -`ANGRY_DWARF_HAT_ID` can be provided as a decimal or hex string. +`HATS_CONTRACT_ADDRESS` is the Hats Protocol contract address used for hats-based permissions, formatted as a `0x`-prefixed EVM address. Use the deployed Hats contract for the target network, or a local test contract address for local chain testing. +`ANGRY_DWARF_HAT_ID` can be provided as a decimal or hex string and requires `HATS_CONTRACT_ADDRESS` to function. `ENCRYPTION_KEY` must be a base64-encoded 32-byte key. Multiple-key rotation requires stable `key-id:base64-key` entries. To generate a local development key: diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index 8fd9e00..8545a6f 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -12,7 +12,7 @@ export async function POST() { console.error("Wallet logout failed", error); return NextResponse.json( - { error: "Session configuration is missing" }, + { error: "Logout failed" }, { status: 500 }, ); } diff --git a/src/app/api/auth/nonce/route.ts b/src/app/api/auth/nonce/route.ts index 8e404e6..717cbac 100644 --- a/src/app/api/auth/nonce/route.ts +++ b/src/app/api/auth/nonce/route.ts @@ -14,7 +14,7 @@ export async function GET() { console.error("Wallet nonce creation failed", error); return NextResponse.json( - { error: "Session configuration is missing" }, + { error: "Nonce generation failed" }, { status: 500 }, ); } diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts index 536d98a..46f8269 100644 --- a/src/app/api/auth/session/route.ts +++ b/src/app/api/auth/session/route.ts @@ -11,7 +11,7 @@ export async function GET() { console.error("Wallet session lookup failed", error); return NextResponse.json( - { error: "Session configuration is missing" }, + { error: "Session lookup failed" }, { status: 500 }, ); } diff --git a/src/app/api/auth/verify/route.ts b/src/app/api/auth/verify/route.ts index 124aded..1063fe1 100644 --- a/src/app/api/auth/verify/route.ts +++ b/src/app/api/auth/verify/route.ts @@ -30,6 +30,10 @@ function getPublicErrorMessage(error: unknown) { return "DAO_SHARE_TOKEN_ADDRESS is required for member access checks"; } + if (lowerMessage.includes("dao_share_threshold")) { + return "DAO_SHARE_THRESHOLD is required for member access checks"; + } + if (lowerMessage.includes("hats_contract_address")) { return "HATS_CONTRACT_ADDRESS must be a valid EVM address"; } @@ -70,7 +74,18 @@ export async function POST(request: NextRequest) { ); } - const siweMessage = new SiweMessage(message); + let siweMessage: SiweMessage; + + try { + siweMessage = new SiweMessage(message); + } catch { + session.destroy(); + return NextResponse.json( + { error: "Invalid SIWE message" }, + { status: 400 }, + ); + } + const verification = await siweMessage.verify({ domain: getExpectedDomain(request), nonce: session.nonce, diff --git a/src/app/page.tsx b/src/app/page.tsx index e42f0c6..52b2887 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -39,7 +39,9 @@ async function getSessionState() { } } -function PublicHome() { +type SessionState = Awaited>; + +function PublicHome({ session }: { session: SessionState }) { return (
@@ -84,7 +86,7 @@ function PublicHome() { database-managed Cleric permissions.

- +
@@ -93,7 +95,7 @@ function PublicHome() { ); } -function MemberHome() { +function MemberHome({ session }: { session: SessionState }) { return (
@@ -109,7 +111,7 @@ function MemberHome() { - +
@@ -237,8 +239,8 @@ export default async function Home() { const session = await getSessionState(); if (!session.authenticated) { - return ; + return ; } - return ; + return ; } diff --git a/src/components/auth/wallet-connect.tsx b/src/components/auth/wallet-connect.tsx index 8f9d77a..67dd446 100644 --- a/src/components/auth/wallet-connect.tsx +++ b/src/components/auth/wallet-connect.tsx @@ -22,6 +22,10 @@ type SessionResponse = { permissions: AuthPermissions | null; }; +type WalletConnectProps = { + initialSession?: SessionResponse; +}; + const emptySession: SessionResponse = { address: null, authenticated: false, @@ -58,6 +62,18 @@ function getFriendlyError(error: unknown) { return "Session setup is missing."; } + if (lowerMessage.includes("session lookup")) { + return "Session lookup failed."; + } + + if (lowerMessage.includes("nonce generation")) { + return "Could not start wallet sign-in."; + } + + if (lowerMessage.includes("logout")) { + return "Sign out failed."; + } + if (lowerMessage.includes("gnosis_rpc_url")) { return "Gnosis RPC URL is missing."; } @@ -70,6 +86,10 @@ function getFriendlyError(error: unknown) { return "DAO shares token address is missing."; } + if (lowerMessage.includes("dao_share_threshold")) { + return "DAO share threshold is missing."; + } + if (lowerMessage.includes("hats_contract_address")) { return "Hats contract address is invalid."; } @@ -89,15 +109,17 @@ function getFriendlyError(error: unknown) { return "Wallet sign-in failed."; } -export function WalletConnect() { +export function WalletConnect({ initialSession }: WalletConnectProps) { const account = useAccount(); const router = useRouter(); const { connectAsync, connectors, isPending: isConnecting } = useConnect(); const { disconnectAsync } = useDisconnect(); const { signMessageAsync, isPending: isSigning } = useSignMessage(); const { showToast } = useToast(); - const [session, setSession] = useState(emptySession); - const [isLoadingSession, setIsLoadingSession] = useState(true); + const [session, setSession] = useState( + initialSession ?? emptySession, + ); + const [isLoadingSession, setIsLoadingSession] = useState(!initialSession); const [isVerifying, setIsVerifying] = useState(false); const primaryConnector = useMemo( @@ -108,6 +130,10 @@ export function WalletConnect() { ); useEffect(() => { + if (initialSession) { + return; + } + let isMounted = true; fetch("/api/auth/session") @@ -143,7 +169,7 @@ export function WalletConnect() { return () => { isMounted = false; }; - }, [showToast]); + }, [initialSession, showToast]); async function signIn() { setIsVerifying(true); @@ -230,10 +256,24 @@ export function WalletConnect() { } async function signOut() { - await fetch("/api/auth/logout", { method: "POST" }); - await disconnectAsync(); - setSession(emptySession); - router.refresh(); + try { + const response = await fetch("/api/auth/logout", { method: "POST" }); + + if (!response.ok) { + throw new Error("Logout failed"); + } + } catch (error) { + showToast(getFriendlyError(error)); + } + + try { + await disconnectAsync(); + } catch (error) { + console.warn("Wallet disconnect failed", error); + } finally { + setSession(emptySession); + router.refresh(); + } } if (session.authenticated && session.address) { diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx index 0b37c70..b37a9eb 100644 --- a/src/components/ui/toast.tsx +++ b/src/components/ui/toast.tsx @@ -10,7 +10,7 @@ import { } from "react"; type Toast = { - id: number; + id: string; message: string; state: "entering" | "leaving"; }; @@ -25,7 +25,7 @@ export function ToastProvider({ children }: { children: ReactNode }) { const [toasts, setToasts] = useState([]); const showToast = useCallback((message: string) => { - const id = Date.now(); + const id = crypto.randomUUID(); setToasts((currentToasts) => [ ...currentToasts, { id, message, state: "entering" }, diff --git a/src/db/index.ts b/src/db/index.ts index e90baf6..1adaae1 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -9,7 +9,7 @@ import * as schema from "@/db/schema"; let cachedDb: ReturnType | undefined; function isLocalDatabaseUrl(databaseUrl: string) { - const hostname = new URL(databaseUrl).hostname; + const hostname = new URL(databaseUrl).hostname.replace(/^\[(.*)\]$/, "$1"); return ( hostname === "localhost" || diff --git a/src/lib/auth/permissions.ts b/src/lib/auth/permissions.ts index 06ead2b..ff9db13 100644 --- a/src/lib/auth/permissions.ts +++ b/src/lib/auth/permissions.ts @@ -81,7 +81,13 @@ async function hasDaoShares(address: `0x${string}`) { functionName: "decimals", }), ]); - const threshold = parseUnits(process.env.DAO_SHARE_THRESHOLD ?? "100", decimals); + const thresholdValue = process.env.DAO_SHARE_THRESHOLD; + + if (!thresholdValue) { + throw new Error("DAO_SHARE_THRESHOLD is required for member access checks"); + } + + const threshold = parseUnits(thresholdValue, decimals); return balance >= threshold; }