an appview-less Bluesky client using Constellation and PDS Queries reddwarf.app
frontend spa bluesky reddwarf microcosm

oauth!!! (UnifiedAuthProvider)

rimar1337 9c34168f 395af63b

+1
.gitignore
··· 7 7 .env 8 8 .nitro 9 9 .tanstack 10 + public/client-metadata.json
+18 -2
README.md
··· 5 5 6 6 huge thanks to [Microcosm](https://microcosm.blue/) for making this possible 7 7 8 + ## running dev and build 9 + in the `vite.config.ts` file you should change these values 10 + ```ts 11 + const PROD_URL = "https://reddwarf.whey.party" 12 + const DEV_URL = "https://local3768forumtest.whey.party" 13 + ``` 14 + the PROD_URL is what will compile your oauth client metadata so it is very important to change that. same for DEV_URL if you are using a tunnel for dev work 15 + 16 + run dev with `npm run dev` (port 3768) and build with `npm run build` (the output is the `dist` folder) 17 + 8 18 ## useQuery 9 19 Red Dwarf has been upgraded from its original bespoke caching system to Tanstack Query (react query). this migration was done to achieve a more robust and maintainable approach to data fetching and caching and state synchronization. ive seen serious performance gains from this switch! 10 20 ··· 22 32 ### Slingshot 23 33 though Red Dwarf was made before Microcosm [Slingshot](https://slingshot.microcosm.blue) existed, it now uses Slingshot to reduce load from each respective PDS server. Slignshot 24 34 25 - ## PassAuthProvider 26 - a really bad app-password auth provider, inherited from TestFront and used in all my projects from TestFront to ForumTest (im very good at naming things). in ForumTest, its been superseded by the [OAuthProvider](https://tangled.sh/@whey.party/forumtest/blob/main/src/providers/OAuthProvider.tsx). i havent backported it here and maybe soon, although oauth makes it slightly more annoying to do development because it requires a tunnel so maybe someday if i managed to merge the password and oauth logins to provide both options 35 + ## UnifiedAuthProvider 36 + a merged auth provider with oauth and password based login. oauth makes it slightly more annoying to do development because it requires a tunnel, so so the password auth option is still here if you do prefer password login for whatever reason. 37 + 38 + ### Pass Auth 39 + a really bad app-password auth provider, inherited from TestFront and used in all my projects from TestFront to ForumTest (im very good at naming things). 40 + 41 + ### OAuth 42 + taken from ForumTest [OAuthProvider](https://tangled.sh/@whey.party/forumtest/blob/main/src/providers/OAuthProvider.tsx) 27 43 28 44 ## Custom Feeds 29 45 they work, but i havent implemented a simple way of viewing arbitraty feeds. currently it either loads discover (logged out) or your saved feeds (logged in) and its not a technical limitation i just havent implemented it yet
-1
index.html
··· 11 11 /> 12 12 <link rel="apple-touch-icon" href="/redstar.png" /> 13 13 <link rel="manifest" href="/manifest.json" /> 14 - <link rel="stylesheet" href="/src/styles/app.css" /> 15 14 <title>Red Dwarf</title> 16 15 </head> 17 16 <body>
+48
oauthdev.mts
··· 1 + import fs from 'fs'; 2 + import path from 'path'; 3 + //import { generateClientMetadata } from './src/helpers/oauthClient' 4 + export const generateClientMetadata = (appOrigin: string) => { 5 + const callbackPath = '/callback'; 6 + 7 + return { 8 + "client_id": `${appOrigin}/client-metadata.json`, 9 + "client_name": "ForumTest", 10 + "client_uri": appOrigin, 11 + "logo_uri": `${appOrigin}/logo192.png`, 12 + "tos_uri": `${appOrigin}/terms-of-service`, 13 + "policy_uri": `${appOrigin}/privacy-policy`, 14 + "redirect_uris": [`${appOrigin}${callbackPath}`] as [string, ...string[]], 15 + "scope": "atproto transition:generic", 16 + "grant_types": ["authorization_code", "refresh_token"] as ["authorization_code", "refresh_token"], 17 + "response_types": ["code"] as ["code"], 18 + "token_endpoint_auth_method": "none" as "none", 19 + "application_type": "web" as "web", 20 + "dpop_bound_access_tokens": true 21 + }; 22 + } 23 + 24 + 25 + export function generateMetadataPlugin({prod, dev}:{prod: string, dev: string}) { 26 + return { 27 + name: 'vite-plugin-generate-metadata', 28 + config(_config: any, { mode }: any) { 29 + let appOrigin; 30 + if (mode === 'production') { 31 + appOrigin = prod 32 + if (!appOrigin || !appOrigin.startsWith('https://')) { 33 + throw new Error('VITE_APP_ORIGIN environment variable must be set to a valid HTTPS URL for production build.'); 34 + } 35 + } else { 36 + appOrigin = dev; 37 + } 38 + 39 + 40 + const metadata = generateClientMetadata(appOrigin); 41 + const outputPath = path.resolve(process.cwd(), 'public', 'client-metadata.json'); 42 + 43 + fs.writeFileSync(outputPath, JSON.stringify(metadata, null, 2)); 44 + 45 + console.log(`✅ Generated client-metadata.json for ${appOrigin}`); 46 + }, 47 + }; 48 + }
+194 -11
package-lock.json
··· 7 7 "name": "red-dwarf-tanstack", 8 8 "dependencies": { 9 9 "@atproto/api": "^0.16.6", 10 + "@atproto/oauth-client-browser": "^0.3.33", 10 11 "@tailwindcss/vite": "^4.0.6", 11 12 "@tanstack/query-sync-storage-persister": "^5.85.6", 12 13 "@tanstack/react-devtools": "^0.2.2", ··· 71 72 "dev": true, 72 73 "license": "ISC" 73 74 }, 75 + "node_modules/@atproto-labs/did-resolver": { 76 + "version": "0.2.2", 77 + "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.2.2.tgz", 78 + "integrity": "sha512-ca2B7xR43tVoQ8XxBvha58DXwIH8cIyKQl6lpOKGkPUrJuFoO4iCLlDiSDi2Ueh+yE1rMDPP/qveHdajgDX3WQ==", 79 + "license": "MIT", 80 + "dependencies": { 81 + "@atproto-labs/fetch": "0.2.3", 82 + "@atproto-labs/pipe": "0.1.1", 83 + "@atproto-labs/simple-store": "0.3.0", 84 + "@atproto-labs/simple-store-memory": "0.1.4", 85 + "@atproto/did": "0.2.1", 86 + "zod": "^3.23.8" 87 + } 88 + }, 89 + "node_modules/@atproto-labs/fetch": { 90 + "version": "0.2.3", 91 + "resolved": "https://registry.npmjs.org/@atproto-labs/fetch/-/fetch-0.2.3.tgz", 92 + "integrity": "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw==", 93 + "license": "MIT", 94 + "dependencies": { 95 + "@atproto-labs/pipe": "0.1.1" 96 + } 97 + }, 98 + "node_modules/@atproto-labs/handle-resolver": { 99 + "version": "0.3.2", 100 + "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver/-/handle-resolver-0.3.2.tgz", 101 + "integrity": "sha512-KIerCzh3qb+zZoqWbIvTlvBY0XPq0r56kwViaJY/LTe/3oPO2JaqlYKS/F4dByWBhHK6YoUOJ0sWrh6PMJl40A==", 102 + "license": "MIT", 103 + "dependencies": { 104 + "@atproto-labs/simple-store": "0.3.0", 105 + "@atproto-labs/simple-store-memory": "0.1.4", 106 + "@atproto/did": "0.2.1", 107 + "zod": "^3.23.8" 108 + } 109 + }, 110 + "node_modules/@atproto-labs/identity-resolver": { 111 + "version": "0.3.2", 112 + "resolved": "https://registry.npmjs.org/@atproto-labs/identity-resolver/-/identity-resolver-0.3.2.tgz", 113 + "integrity": "sha512-MYxO9pe0WsFyi5HFdKAwqIqHfiF2kBPoVhAIuH/4PYHzGr799ED47xLhNMxR3ZUYrJm5+TQzWXypGZ0Btw1Ffw==", 114 + "license": "MIT", 115 + "dependencies": { 116 + "@atproto-labs/did-resolver": "0.2.2", 117 + "@atproto-labs/handle-resolver": "0.3.2" 118 + } 119 + }, 120 + "node_modules/@atproto-labs/pipe": { 121 + "version": "0.1.1", 122 + "resolved": "https://registry.npmjs.org/@atproto-labs/pipe/-/pipe-0.1.1.tgz", 123 + "integrity": "sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg==", 124 + "license": "MIT" 125 + }, 126 + "node_modules/@atproto-labs/simple-store": { 127 + "version": "0.3.0", 128 + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store/-/simple-store-0.3.0.tgz", 129 + "integrity": "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==", 130 + "license": "MIT" 131 + }, 132 + "node_modules/@atproto-labs/simple-store-memory": { 133 + "version": "0.1.4", 134 + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store-memory/-/simple-store-memory-0.1.4.tgz", 135 + "integrity": "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw==", 136 + "license": "MIT", 137 + "dependencies": { 138 + "@atproto-labs/simple-store": "0.3.0", 139 + "lru-cache": "^10.2.0" 140 + } 141 + }, 142 + "node_modules/@atproto-labs/simple-store-memory/node_modules/lru-cache": { 143 + "version": "10.4.3", 144 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", 145 + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", 146 + "license": "ISC" 147 + }, 74 148 "node_modules/@atproto/api": { 75 149 "version": "0.16.6", 76 150 "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.16.6.tgz", ··· 88 162 } 89 163 }, 90 164 "node_modules/@atproto/common-web": { 91 - "version": "0.4.2", 92 - "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.2.tgz", 93 - "integrity": "sha512-vrXwGNoFGogodjQvJDxAeP3QbGtawgZute2ed1XdRO0wMixLk3qewtikZm06H259QDJVu6voKC5mubml+WgQUw==", 165 + "version": "0.4.3", 166 + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.3.tgz", 167 + "integrity": "sha512-nRDINmSe4VycJzPo6fP/hEltBcULFxt9Kw7fQk6405FyAWZiTluYHlXOnU7GkQfeUK44OENG1qFTBcmCJ7e8pg==", 94 168 "license": "MIT", 95 169 "dependencies": { 96 170 "graphemer": "^1.4.0", ··· 99 173 "zod": "^3.23.8" 100 174 } 101 175 }, 176 + "node_modules/@atproto/did": { 177 + "version": "0.2.1", 178 + "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.2.1.tgz", 179 + "integrity": "sha512-1i5BTU2GnBaaeYWhxUOnuEKFVq9euT5+dQPFabHpa927BlJ54PmLGyBBaOI7/NbLmN5HWwBa18SBkMpg3jGZRA==", 180 + "license": "MIT", 181 + "dependencies": { 182 + "zod": "^3.23.8" 183 + } 184 + }, 185 + "node_modules/@atproto/jwk": { 186 + "version": "0.6.0", 187 + "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.6.0.tgz", 188 + "integrity": "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==", 189 + "license": "MIT", 190 + "dependencies": { 191 + "multiformats": "^9.9.0", 192 + "zod": "^3.23.8" 193 + } 194 + }, 195 + "node_modules/@atproto/jwk-jose": { 196 + "version": "0.1.11", 197 + "resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.1.11.tgz", 198 + "integrity": "sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q==", 199 + "license": "MIT", 200 + "dependencies": { 201 + "@atproto/jwk": "0.6.0", 202 + "jose": "^5.2.0" 203 + } 204 + }, 205 + "node_modules/@atproto/jwk-webcrypto": { 206 + "version": "0.2.0", 207 + "resolved": "https://registry.npmjs.org/@atproto/jwk-webcrypto/-/jwk-webcrypto-0.2.0.tgz", 208 + "integrity": "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg==", 209 + "license": "MIT", 210 + "dependencies": { 211 + "@atproto/jwk": "0.6.0", 212 + "@atproto/jwk-jose": "0.1.11", 213 + "zod": "^3.23.8" 214 + } 215 + }, 102 216 "node_modules/@atproto/lexicon": { 103 - "version": "0.5.0", 104 - "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.5.0.tgz", 105 - "integrity": "sha512-3aAzEAy9EAPs3CxznzMhEcqDd7m3vz1eze/ya9/ThbB7yleqJIhz5GY2q76tCCwHPhn5qDDMhlA9kKV6fG23gA==", 217 + "version": "0.5.1", 218 + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.5.1.tgz", 219 + "integrity": "sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A==", 106 220 "license": "MIT", 107 221 "dependencies": { 108 - "@atproto/common-web": "^0.4.2", 222 + "@atproto/common-web": "^0.4.3", 109 223 "@atproto/syntax": "^0.4.1", 110 224 "iso-datestring-validator": "^2.2.2", 111 225 "multiformats": "^9.9.0", 112 226 "zod": "^3.23.8" 113 227 } 114 228 }, 229 + "node_modules/@atproto/oauth-client": { 230 + "version": "0.5.7", 231 + "resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.5.7.tgz", 232 + "integrity": "sha512-pDvbvy9DCxrAJv7bAbBUzWrHZKhFy091HvEMZhr+EyZA6gSCGYmmQJG/coDj0oICSVQeafAZd+IxR0YUCWwmEg==", 233 + "license": "MIT", 234 + "dependencies": { 235 + "@atproto-labs/did-resolver": "0.2.2", 236 + "@atproto-labs/fetch": "0.2.3", 237 + "@atproto-labs/handle-resolver": "0.3.2", 238 + "@atproto-labs/identity-resolver": "0.3.2", 239 + "@atproto-labs/simple-store": "0.3.0", 240 + "@atproto-labs/simple-store-memory": "0.1.4", 241 + "@atproto/did": "0.2.1", 242 + "@atproto/jwk": "0.6.0", 243 + "@atproto/oauth-types": "0.4.2", 244 + "@atproto/xrpc": "0.7.5", 245 + "core-js": "^3", 246 + "multiformats": "^9.9.0", 247 + "zod": "^3.23.8" 248 + } 249 + }, 250 + "node_modules/@atproto/oauth-client-browser": { 251 + "version": "0.3.33", 252 + "resolved": "https://registry.npmjs.org/@atproto/oauth-client-browser/-/oauth-client-browser-0.3.33.tgz", 253 + "integrity": "sha512-IvHn/5W3e9GXFUGXQ4MV19E4HXY4zJFgu+eZRWexIXnZl4GwgTH7op8J1SosczdOK1Ngu+LnHE6npcNhUGGd6Q==", 254 + "license": "MIT", 255 + "dependencies": { 256 + "@atproto-labs/did-resolver": "0.2.2", 257 + "@atproto-labs/handle-resolver": "0.3.2", 258 + "@atproto-labs/simple-store": "0.3.0", 259 + "@atproto/did": "0.2.1", 260 + "@atproto/jwk": "0.6.0", 261 + "@atproto/jwk-webcrypto": "0.2.0", 262 + "@atproto/oauth-client": "0.5.7", 263 + "@atproto/oauth-types": "0.4.2", 264 + "core-js": "^3" 265 + } 266 + }, 267 + "node_modules/@atproto/oauth-types": { 268 + "version": "0.4.2", 269 + "resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.4.2.tgz", 270 + "integrity": "sha512-gcfNTyFsPJcYDf79M0iKHykWqzxloscioKoerdIN3MTS3htiNOSgZjm2p8ho7pdrElLzea3qktuhTQI39j1XFQ==", 271 + "license": "MIT", 272 + "dependencies": { 273 + "@atproto/did": "0.2.1", 274 + "@atproto/jwk": "0.6.0", 275 + "zod": "^3.23.8" 276 + } 277 + }, 115 278 "node_modules/@atproto/syntax": { 116 279 "version": "0.4.1", 117 280 "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.1.tgz", ··· 119 282 "license": "MIT" 120 283 }, 121 284 "node_modules/@atproto/xrpc": { 122 - "version": "0.7.4", 123 - "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.4.tgz", 124 - "integrity": "sha512-sDi68+QE1XHegTaNAndlX41Gp827pouSzSs8CyAwhrqZdsJUxE3P7TMtrA0z+zAjvxVyvzscRc0TsN/fGUGrhw==", 285 + "version": "0.7.5", 286 + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.5.tgz", 287 + "integrity": "sha512-MUYNn5d2hv8yVegRL0ccHvTHAVj5JSnW07bkbiaz96UH45lvYNRVwt44z+yYVnb0/mvBzyD3/ZQ55TRGt7fHkA==", 125 288 "license": "MIT", 126 289 "dependencies": { 127 - "@atproto/lexicon": "^0.5.0", 290 + "@atproto/lexicon": "^0.5.1", 128 291 "zod": "^3.23.8" 129 292 } 130 293 }, ··· 2869 3032 "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==", 2870 3033 "license": "MIT" 2871 3034 }, 3035 + "node_modules/core-js": { 3036 + "version": "3.46.0", 3037 + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.46.0.tgz", 3038 + "integrity": "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==", 3039 + "hasInstallScript": true, 3040 + "license": "MIT", 3041 + "funding": { 3042 + "type": "opencollective", 3043 + "url": "https://opencollective.com/core-js" 3044 + } 3045 + }, 2872 3046 "node_modules/cssstyle": { 2873 3047 "version": "4.6.0", 2874 3048 "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", ··· 3427 3601 "license": "MIT", 3428 3602 "bin": { 3429 3603 "jiti": "lib/jiti-cli.mjs" 3604 + } 3605 + }, 3606 + "node_modules/jose": { 3607 + "version": "5.10.0", 3608 + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", 3609 + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", 3610 + "license": "MIT", 3611 + "funding": { 3612 + "url": "https://github.com/sponsors/panva" 3430 3613 } 3431 3614 }, 3432 3615 "node_modules/jotai": {
+3 -2
package.json
··· 3 3 "private": true, 4 4 "type": "module", 5 5 "scripts": { 6 - "dev": "vite --port 3000", 7 - "start": "vite --port 3000", 6 + "dev": "vite --port 3768", 7 + "start": "vite --port 3768", 8 8 "build": "vite build && tsc", 9 9 "serve": "vite preview", 10 10 "test": "vitest run" 11 11 }, 12 12 "dependencies": { 13 13 "@atproto/api": "^0.16.6", 14 + "@atproto/oauth-client-browser": "^0.3.33", 14 15 "@tailwindcss/vite": "^4.0.6", 15 16 "@tanstack/query-sync-storage-persister": "^5.85.6", 16 17 "@tanstack/react-devtools": "^0.2.2",
+3 -2
src/components/InfiniteCustomFeed.tsx
··· 1 1 import * as React from "react"; 2 2 //import { useInView } from "react-intersection-observer"; 3 3 import { UniversalPostRendererATURILoader } from "~/components/UniversalPostRenderer"; 4 - import { useAuth } from "~/providers/PassAuthProvider"; 4 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 5 5 import { 6 6 useQueryArbitrary, 7 7 useQueryIdentity, ··· 19 19 pdsUrl, 20 20 feedServiceDid, 21 21 }: InfiniteCustomFeedProps) { 22 - const { agent, authed } = useAuth(); 22 + const { agent } = useAuth(); 23 + const authed = !!agent?.did; 23 24 24 25 // const identityresultmaybe = useQueryIdentity(agent?.did); 25 26 // const identity = identityresultmaybe?.data;
+201 -197
src/components/Login.tsx
··· 1 + // src/components/Login.tsx 1 2 import React, { useEffect, useState, useRef } from "react"; 2 - import { useAuth } from "~/providers/PassAuthProvider"; 3 - 4 - interface LoginProps { 5 - compact?: boolean; 6 - } 7 - 8 - export default function Login({ compact = false }: LoginProps) { 9 - const { loginStatus, login, logout, loading, authed, agent } = useAuth(); 10 - const [user, setUser] = useState(""); 11 - const [password, setPassword] = useState(""); 12 - const [serviceURL, setServiceURL] = useState("bsky.social"); 13 - const [showLoginForm, setShowLoginForm] = useState(false); 14 - const formRef = useRef<HTMLDivElement>(null); 15 - 16 - useEffect(() => { 17 - function handleClickOutside(event: MouseEvent) { 18 - if (formRef.current && !formRef.current.contains(event.target as Node)) { 19 - setShowLoginForm(false); 20 - } 21 - } 22 - 23 - if (showLoginForm) { 24 - document.addEventListener("mousedown", handleClickOutside); 25 - } 3 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 4 + import { Agent } from "@atproto/api"; 26 5 27 - return () => { 28 - document.removeEventListener("mousedown", handleClickOutside); 29 - }; 30 - }, [showLoginForm]); 6 + // --- 1. The Main Component (Orchestrator with `compact` prop) --- 7 + export default function Login({ compact = false }: { compact?: boolean }) { 8 + const { status, agent, logout } = useAuth(); 31 9 32 - if (loading) { 10 + // Loading state can be styled differently based on the prop 11 + if (status === "loading") { 33 12 return ( 34 - <div className="flex items-center justify-center p-6 text-gray-500 dark:text-gray-400"> 35 - Loading... 13 + <div 14 + className={ 15 + compact 16 + ? "flex items-center justify-center p-1" 17 + : "p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-6 mx-4 flex justify-center items-center h-[280px]" 18 + } 19 + > 20 + <span 21 + className={`border-t-transparent rounded-full animate-spin ${ 22 + compact 23 + ? "w-5 h-5 border-2 border-gray-400" 24 + : "w-8 h-8 border-4 border-gray-400" 25 + }`} 26 + /> 36 27 </div> 37 28 ); 38 29 } 39 30 40 - if (compact) { 41 - if (authed) { 31 + // --- LOGGED IN STATE --- 32 + if (status === "signedIn") { 33 + // Large view 34 + if (!compact) { 42 35 return ( 36 + <div className="p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-6 mx-4"> 37 + <div className="flex flex-col items-center justify-center text-center"> 38 + <p className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-100"> 39 + You are logged in! 40 + </p> 41 + <ProfileThing agent={agent} large /> 42 + <button 43 + onClick={logout} 44 + className="bg-gray-600 mt-4 hover:bg-gray-700 text-white rounded px-6 py-2 font-semibold text-base transition-colors" 45 + > 46 + Log out 47 + </button> 48 + </div> 49 + </div> 50 + ); 51 + } 52 + // Compact view 53 + return ( 54 + <div className="flex items-center gap-4"> 55 + <ProfileThing agent={agent} /> 43 56 <button 44 57 onClick={logout} 45 58 className="text-sm bg-gray-600 hover:bg-gray-700 text-white rounded px-3 py-1 font-medium transition-colors" 46 59 > 47 60 Log out 48 61 </button> 49 - ); 50 - } else { 51 - return ( 52 - <div className="relative" ref={formRef}> 53 - <button 54 - onClick={() => setShowLoginForm(!showLoginForm)} 55 - className="text-sm bg-gray-600 hover:bg-gray-700 text-white rounded px-3 py-1 font-medium transition-colors" 56 - > 57 - Log in 58 - </button> 59 - {showLoginForm && ( 60 - <div className="absolute top-full right-0 mt-2 w-80 bg-white dark:bg-gray-900 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 z-50"> 61 - <form 62 - onSubmit={(e) => { 63 - e.preventDefault(); 64 - login(user, password, `https://${serviceURL}`); 65 - setShowLoginForm(false); 66 - }} 67 - className="flex flex-col gap-3" 68 - > 69 - <p className="text-xs text-gray-500 dark:text-gray-400"> 70 - sorry for the temporary login, 71 - <br /> 72 - oauth will come soon enough i swear 73 - </p> 74 - <input 75 - type="text" 76 - placeholder="Username" 77 - value={user} 78 - onChange={(e) => setUser(e.target.value)} 79 - className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" 80 - autoComplete="username" 81 - /> 82 - <input 83 - type="password" 84 - placeholder="Password" 85 - value={password} 86 - onChange={(e) => setPassword(e.target.value)} 87 - className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" 88 - autoComplete="current-password" 89 - /> 90 - <input 91 - type="text" 92 - placeholder="bsky.social" 93 - value={serviceURL} 94 - onChange={(e) => setServiceURL(e.target.value)} 95 - className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" 96 - /> 97 - <button 98 - type="submit" 99 - className="bg-gray-600 hover:bg-gray-700 text-white rounded px-4 py-2 font-medium text-sm transition-colors" 100 - > 101 - Log in 102 - </button> 103 - </form> 104 - </div> 105 - )} 106 - </div> 107 - ); 108 - } 62 + </div> 63 + ); 64 + } 65 + 66 + // --- LOGGED OUT STATE --- 67 + if (!compact) { 68 + // Large view renders the form directly in the card 69 + return ( 70 + <div className="p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-6 mx-4"> 71 + <UnifiedLoginForm /> 72 + </div> 73 + ); 109 74 } 110 75 76 + // Compact view renders a button that toggles the form in a dropdown 77 + return <CompactLoginButton />; 78 + } 79 + 80 + // --- 2. The Reusable, Self-Contained Login Form Component --- 81 + export function UnifiedLoginForm() { 82 + const [mode, setMode] = useState<"oauth" | "password">("oauth"); 83 + 111 84 return ( 112 - <div className="p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-6 mx-4"> 113 - {authed ? ( 114 - <div className="flex flex-col items-center justify-center text-center"> 115 - <p className="text-lg font-semibold mb-2 text-gray-800 dark:text-gray-100"> 116 - You are logged in! 117 - </p> 118 - <ProfileThing /> 119 - <button 120 - onClick={logout} 121 - className="bg-gray-600 mt-2 hover:bg-gray-700 text-white rounded px-6 py-2 font-semibold text-base transition-colors" 122 - > 123 - Log out 124 - </button> 125 - </div> 126 - ) : ( 127 - <form 128 - onSubmit={(e) => { 129 - e.preventDefault(); 130 - login(user, password, `https://${serviceURL}`); 131 - }} 132 - className="flex flex-col gap-4" 133 - > 134 - <p className="text-sm text-gray-500 dark:text-gray-400 mb-2"> 135 - sorry for the temporary login, 136 - <br /> 137 - oauth will come soon enough i swear 138 - </p> 139 - <input 140 - type="text" 141 - placeholder="Username" 142 - value={user} 143 - onChange={(e) => setUser(e.target.value)} 144 - className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-base focus:outline-none focus:ring-2 focus:ring-blue-500" 145 - autoComplete="username" 146 - /> 147 - <input 148 - type="password" 149 - placeholder="Password" 150 - value={password} 151 - onChange={(e) => setPassword(e.target.value)} 152 - className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-base focus:outline-none focus:ring-2 focus:ring-blue-500" 153 - autoComplete="current-password" 154 - /> 155 - <input 156 - type="text" 157 - placeholder="bsky.social" 158 - value={serviceURL} 159 - onChange={(e) => setServiceURL(e.target.value)} 160 - className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-base focus:outline-none focus:ring-2 focus:ring-blue-500" 161 - /> 162 - <button 163 - type="submit" 164 - className="bg-gray-600 hover:bg-gray-700 text-white rounded px-6 py-2 font-semibold text-base transition-colors mt-2" 165 - > 166 - Log in 167 - </button> 168 - </form> 169 - )} 85 + <div> 86 + <div className="flex border-b border-gray-200 dark:border-gray-700 mb-4"> 87 + <TabButton 88 + label="OAuth" 89 + active={mode === "oauth"} 90 + onClick={() => setMode("oauth")} 91 + /> 92 + <TabButton 93 + label="Password" 94 + active={mode === "password"} 95 + onClick={() => setMode("password")} 96 + /> 97 + </div> 98 + {mode === "oauth" ? <OAuthForm /> : <PasswordForm />} 170 99 </div> 171 100 ); 172 101 } 173 102 174 - export const ProfileThing = () => { 175 - const { agent, loading, loginStatus, authed } = useAuth(); 176 - const [response, setResponse] = useState<any>(null); 103 + // --- 3. Helper components for layouts, forms, and UI --- 104 + 105 + // A new component to contain the logic for the compact dropdown 106 + const CompactLoginButton = () => { 107 + const [showForm, setShowForm] = useState(false); 108 + const formRef = useRef<HTMLDivElement>(null); 177 109 178 110 useEffect(() => { 179 - if (loginStatus && agent && !loading && authed) { 180 - fetchUser(); 111 + function handleClickOutside(event: MouseEvent) { 112 + if (formRef.current && !formRef.current.contains(event.target as Node)) { 113 + setShowForm(false); 114 + } 115 + } 116 + if (showForm) { 117 + document.addEventListener("mousedown", handleClickOutside); 181 118 } 182 - // eslint-disable-next-line 183 - }, [loginStatus, agent, loading, authed]); 119 + return () => { 120 + document.removeEventListener("mousedown", handleClickOutside); 121 + }; 122 + }, [showForm]); 123 + 124 + return ( 125 + <div className="relative" ref={formRef}> 126 + <button 127 + onClick={() => setShowForm(!showForm)} 128 + className="text-sm bg-gray-600 hover:bg-gray-700 text-white rounded px-3 py-1 font-medium transition-colors" 129 + > 130 + Log in 131 + </button> 132 + {showForm && ( 133 + <div className="absolute top-full right-0 mt-2 w-80 bg-white dark:bg-gray-900 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 z-50"> 134 + <UnifiedLoginForm /> 135 + </div> 136 + )} 137 + </div> 138 + ); 139 + }; 140 + 141 + const TabButton = ({ label, active, onClick }: { label: string; active: boolean; onClick: () => void; }) => ( 142 + <button 143 + onClick={onClick} 144 + className={`px-4 py-2 text-sm font-medium transition-colors ${ 145 + active 146 + ? "text-gray-200 border-b-2 border-gray-500" 147 + : "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200" 148 + }`} 149 + > 150 + {label} 151 + </button> 152 + ); 153 + 154 + const OAuthForm = () => { 155 + const { loginWithOAuth } = useAuth(); 156 + const [handle, setHandle] = useState(""); 157 + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (handle.trim()) loginWithOAuth(handle); }; 158 + return ( 159 + <form onSubmit={handleSubmit} className="flex flex-col gap-3"> 160 + <p className="text-xs text-gray-500 dark:text-gray-400">Sign in with AT. Your password is never shared.</p> 161 + <input type="text" placeholder="handle.bsky.social" value={handle} onChange={(e) => setHandle(e.target.value)} className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500" /> 162 + <button type="submit" className="bg-gray-600 hover:bg-gray-700 text-white rounded px-4 py-2 font-medium text-sm transition-colors">Log in</button> 163 + </form> 164 + ); 165 + }; 166 + 167 + const PasswordForm = () => { 168 + const { loginWithPassword } = useAuth(); 169 + const [user, setUser] = useState(""); 170 + const [password, setPassword] = useState(""); 171 + const [serviceURL, setServiceURL] = useState("bsky.social"); 172 + const [error, setError] = useState<string | null>(null); 184 173 185 - const fetchUser = async () => { 186 - if (!agent) { 187 - console.error("Agent is null or undefined"); 188 - return; 174 + const handleSubmit = async (e: React.FormEvent) => { 175 + e.preventDefault(); 176 + setError(null); 177 + try { 178 + await loginWithPassword(user, password, `https://${serviceURL}`); 179 + } catch (err) { 180 + setError("Login failed. Check your handle and App Password."); 189 181 } 190 - const res = await agent.app.bsky.actor.getProfile({ 191 - actor: agent.assertDid, 192 - }); 193 - setResponse(res.data); 194 182 }; 195 183 196 - if (!authed) { 197 - return 198 - return ( 199 - <div className="inline-block"> 200 - <span className="text-gray-100 text-base font-medium px-1.5"> 201 - Login 202 - </span> 203 - </div> 204 - ); 205 - } 206 - 207 - if (!response) { 208 - return ( 209 - <div className="flex flex-col items-start gap-1.5"> 210 - <span className="w-5 h-5 border-2 border-gray-200 dark:border-gray-600 border-t-transparent rounded-full animate-spin inline-block" /> 211 - <span className="text-gray-100">Loading... </span> 212 - </div> 213 - ); 214 - } 215 - 216 184 return ( 217 - <div className="flex flex-row items-start gap-1.5"> 218 - <img 219 - src={response?.avatar} 220 - alt="avatar" 221 - className="w-[30px] h-[30px] rounded-full object-cover" 222 - /> 223 - <div className="flex flex-col items-start"> 224 - <div className="text-gray-100 text-xs">{response?.displayName}</div> 225 - <div className="text-gray-100 text-xs">@{response?.handle}</div> 226 - </div> 227 - </div> 185 + <form onSubmit={handleSubmit} className="flex flex-col gap-3"> 186 + <p className="text-xs text-red-500 dark:text-red-400">Warning: Less secure. Use an App Password.</p> 187 + <input type="text" placeholder="handle.bsky.social" value={user} onChange={(e) => setUser(e.target.value)} className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500" autoComplete="username" /> 188 + <input type="password" placeholder="App Password" value={password} onChange={(e) => setPassword(e.target.value)} className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500" autoComplete="current-password" /> 189 + <input type="text" placeholder="PDS (e.g., bsky.social)" value={serviceURL} onChange={(e) => setServiceURL(e.target.value)} className="px-3 py-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-gray-500" /> 190 + {error && <p className="text-xs text-red-500">{error}</p>} 191 + <button type="submit" className="bg-gray-600 hover:bg-gray-700 text-white rounded px-4 py-2 font-medium text-sm transition-colors">Log in</button> 192 + </form> 228 193 ); 229 194 }; 195 + 196 + // --- Profile Component (now supports a `large` prop for styling) --- 197 + export const ProfileThing = ({ agent, large = false }: { agent: Agent | null; large?: boolean }) => { 198 + const [profile, setProfile] = useState<any>(null); 199 + 200 + useEffect(() => { 201 + const fetchUser = async () => { 202 + const did = (agent as any)?.session?.did ?? (agent as any)?.assertDid; 203 + if (!did) return; 204 + try { 205 + const res = await agent!.getProfile({ actor: did }); 206 + setProfile(res.data); 207 + } catch (e) { console.error("Failed to fetch profile", e); } 208 + }; 209 + if (agent) fetchUser(); 210 + }, [agent]); 211 + 212 + if (!profile) { 213 + return ( // Skeleton loader 214 + <div className={`flex items-center gap-2.5 animate-pulse ${large ? 'mb-2' : ''}`}> 215 + <div className={`rounded-full bg-gray-300 dark:bg-gray-700 ${large ? 'w-12 h-12' : 'w-[30px] h-[30px]'}`} /> 216 + <div className="flex flex-col gap-2"> 217 + <div className={`bg-gray-300 dark:bg-gray-700 rounded ${large ? 'h-4 w-28' : 'h-3 w-20'}`} /> 218 + <div className={`bg-gray-300 dark:bg-gray-700 rounded ${large ? 'h-4 w-20' : 'h-3 w-16'}`} /> 219 + </div> 220 + </div> 221 + ); 222 + } 223 + 224 + return ( 225 + <div className={`flex flex-row items-center gap-2.5 ${large ? 'mb-2' : ''}`}> 226 + <img src={profile?.avatar} alt="avatar" className={`object-cover rounded-full ${large ? 'w-12 h-12' : 'w-[30px] h-[30px]'}`} /> 227 + <div className="flex flex-col items-start text-left"> 228 + <div className={`font-medium ${large ? 'text-gray-800 dark:text-gray-100 text-lg' : 'text-gray-800 dark:text-gray-100 text-sm'}`}>{profile?.displayName}</div> 229 + <div className={` ${large ? 'text-gray-500 dark:text-gray-400 text-sm' : 'text-gray-500 dark:text-gray-400 text-xs'}`}>@{profile?.handle}</div> 230 + </div> 231 + </div> 232 + ); 233 + };
+1 -1
src/components/UniversalPostRenderer.tsx
··· 954 954 } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 955 955 import { useEffect, useRef, useState } from "react"; 956 956 import ReactPlayer from "react-player"; 957 - import { useAuth } from "~/providers/PassAuthProvider"; 957 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 958 958 // import type { OutputSchema } from "@atproto/api/dist/client/types/app/bsky/feed/getFeed"; 959 959 // import type { 960 960 // ViewRecord,
+205
src/providers/UnifiedAuthProvider.tsx
··· 1 + // src/providers/UnifiedAuthProvider.tsx 2 + import React, { 3 + createContext, 4 + useState, 5 + useEffect, 6 + useContext, 7 + useCallback, 8 + } from "react"; 9 + // Import both Agent and the (soon to be deprecated) AtpAgent 10 + import { Agent, AtpAgent, type AtpSessionData } from "@atproto/api"; 11 + import { oauthClient } from "../utils/oauthClient"; // Adjust path if needed 12 + import { 13 + type OAuthSession, 14 + TokenInvalidError, 15 + TokenRefreshError, 16 + TokenRevokedError, 17 + } from "@atproto/oauth-client-browser"; 18 + 19 + // Define the unified status and authentication method 20 + type AuthStatus = "loading" | "signedIn" | "signedOut"; 21 + type AuthMethod = "password" | "oauth" | null; 22 + 23 + interface AuthContextValue { 24 + agent: Agent | null; // The agent is typed as the base class `Agent` 25 + status: AuthStatus; 26 + authMethod: AuthMethod; 27 + loginWithPassword: ( 28 + user: string, 29 + password: string, 30 + service?: string, 31 + ) => Promise<void>; 32 + loginWithOAuth: (handleOrPdsUrl: string) => Promise<void>; 33 + logout: () => Promise<void>; 34 + } 35 + 36 + const AuthContext = createContext<AuthContextValue>({} as AuthContextValue); 37 + 38 + export const UnifiedAuthProvider = ({ 39 + children, 40 + }: { 41 + children: React.ReactNode; 42 + }) => { 43 + // The state is typed as the base class `Agent`, which accepts both `Agent` and `AtpAgent` instances. 44 + const [agent, setAgent] = useState<Agent | null>(null); 45 + const [status, setStatus] = useState<AuthStatus>("loading"); 46 + const [authMethod, setAuthMethod] = useState<AuthMethod>(null); 47 + const [oauthSession, setOauthSession] = useState<OAuthSession | null>(null); 48 + 49 + // Unified Initialization Logic 50 + const initialize = useCallback(async () => { 51 + // --- 1. Try OAuth initialization first --- 52 + try { 53 + const oauthResult = await oauthClient.init(); 54 + if (oauthResult) { 55 + console.log("OAuth session restored."); 56 + const apiAgent = new Agent(oauthResult.session); // Standard Agent 57 + setAgent(apiAgent); 58 + setOauthSession(oauthResult.session); 59 + setAuthMethod("oauth"); 60 + setStatus("signedIn"); 61 + return; // Success 62 + } 63 + } catch (e) { 64 + console.error("OAuth init failed, checking password session.", e); 65 + } 66 + 67 + // --- 2. If no OAuth, try password-based session using AtpAgent --- 68 + try { 69 + const service = localStorage.getItem("service"); 70 + const sessionString = localStorage.getItem("sess"); 71 + 72 + if (service && sessionString) { 73 + console.log("Resuming password-based session using AtpAgent..."); 74 + // Use the original, working AtpAgent logic 75 + const apiAgent = new AtpAgent({ service }); 76 + const session: AtpSessionData = JSON.parse(sessionString); 77 + await apiAgent.resumeSession(session); 78 + 79 + console.log("Password-based session resumed successfully."); 80 + setAgent(apiAgent); // This works because AtpAgent is a subclass of Agent 81 + setAuthMethod("password"); 82 + setStatus("signedIn"); 83 + return; // Success 84 + } 85 + } catch (e) { 86 + console.error("Failed to resume password-based session.", e); 87 + localStorage.removeItem("sess"); 88 + localStorage.removeItem("service"); 89 + } 90 + 91 + // --- 3. If neither worked, user is signed out --- 92 + console.log("No active session found."); 93 + setStatus("signedOut"); 94 + setAgent(null); 95 + setAuthMethod(null); 96 + }, []); 97 + 98 + useEffect(() => { 99 + const handleOAuthSessionDeleted = ( 100 + event: CustomEvent<{ sub: string; cause: TokenRefreshError | TokenRevokedError | TokenInvalidError }>, 101 + ) => { 102 + console.error(`OAuth Session for ${event.detail.sub} was deleted.`, event.detail.cause); 103 + setAgent(null); 104 + setOauthSession(null); 105 + setAuthMethod(null); 106 + setStatus("signedOut"); 107 + }; 108 + 109 + oauthClient.addEventListener("deleted", handleOAuthSessionDeleted as EventListener); 110 + initialize(); 111 + 112 + return () => { 113 + oauthClient.removeEventListener("deleted", handleOAuthSessionDeleted as EventListener); 114 + }; 115 + }, [initialize]); 116 + 117 + // --- Login Methods --- 118 + const loginWithPassword = async ( 119 + user: string, 120 + password: string, 121 + service: string = "https://bsky.social", 122 + ) => { 123 + if (status !== "signedOut") return; 124 + setStatus("loading"); 125 + try { 126 + let sessionData: AtpSessionData | undefined; 127 + // Use the AtpAgent for its simple login and session persistence 128 + const apiAgent = new AtpAgent({ 129 + service, 130 + persistSession: (_evt, sess) => { 131 + sessionData = sess; 132 + }, 133 + }); 134 + await apiAgent.login({ identifier: user, password }); 135 + 136 + if (sessionData) { 137 + localStorage.setItem("service", service); 138 + localStorage.setItem("sess", JSON.stringify(sessionData)); 139 + setAgent(apiAgent); // Store the AtpAgent instance in our state 140 + setAuthMethod("password"); 141 + setStatus("signedIn"); 142 + console.log("Successfully logged in with password."); 143 + } else { 144 + throw new Error("Session data not persisted after login."); 145 + } 146 + } catch (e) { 147 + console.error("Password login failed:", e); 148 + setStatus("signedOut"); 149 + throw e; 150 + } 151 + }; 152 + 153 + const loginWithOAuth = useCallback(async (handleOrPdsUrl: string) => { 154 + if (status !== "signedOut") return; 155 + try { 156 + sessionStorage.setItem("postLoginRedirect", window.location.pathname + window.location.search); 157 + await oauthClient.signIn(handleOrPdsUrl); 158 + } catch (err) { 159 + console.error("OAuth sign-in aborted or failed:", err); 160 + } 161 + }, [status]); 162 + 163 + // --- Unified Logout --- 164 + const logout = useCallback(async () => { 165 + if (status !== "signedIn" || !agent) return; 166 + setStatus("loading"); 167 + 168 + try { 169 + if (authMethod === "oauth" && oauthSession) { 170 + await oauthClient.revoke(oauthSession.sub); 171 + console.log("OAuth session revoked."); 172 + } else if (authMethod === "password") { 173 + localStorage.removeItem("service"); 174 + localStorage.removeItem("sess"); 175 + // AtpAgent has its own logout methods 176 + await (agent as AtpAgent).com.atproto.server.deleteSession(); 177 + console.log("Password-based session deleted."); 178 + } 179 + } catch (e) { 180 + console.error("Logout failed:", e); 181 + } finally { 182 + setAgent(null); 183 + setAuthMethod(null); 184 + setOauthSession(null); 185 + setStatus("signedOut"); 186 + } 187 + }, [status, authMethod, agent, oauthSession]); 188 + 189 + return ( 190 + <AuthContext.Provider 191 + value={{ 192 + agent, 193 + status, 194 + authMethod, 195 + loginWithPassword, 196 + loginWithOAuth, 197 + logout, 198 + }} 199 + > 200 + {children} 201 + </AuthContext.Provider> 202 + ); 203 + }; 204 + 205 + export const useAuth = () => useContext(AuthContext);
+21
src/routeTree.gen.ts
··· 15 15 import { Route as FeedsRouteImport } from './routes/feeds' 16 16 import { Route as PathlessLayoutRouteImport } from './routes/_pathlessLayout' 17 17 import { Route as IndexRouteImport } from './routes/index' 18 + import { Route as CallbackIndexRouteImport } from './routes/callback/index' 18 19 import { Route as PathlessLayoutNestedLayoutRouteImport } from './routes/_pathlessLayout/_nested-layout' 19 20 import { Route as ProfileDidIndexRouteImport } from './routes/profile.$did/index' 20 21 import { Route as PathlessLayoutNestedLayoutRouteBRouteImport } from './routes/_pathlessLayout/_nested-layout/route-b' ··· 48 49 const IndexRoute = IndexRouteImport.update({ 49 50 id: '/', 50 51 path: '/', 52 + getParentRoute: () => rootRouteImport, 53 + } as any) 54 + const CallbackIndexRoute = CallbackIndexRouteImport.update({ 55 + id: '/callback/', 56 + path: '/callback/', 51 57 getParentRoute: () => rootRouteImport, 52 58 } as any) 53 59 const PathlessLayoutNestedLayoutRoute = ··· 84 90 '/notifications': typeof NotificationsRoute 85 91 '/search': typeof SearchRoute 86 92 '/settings': typeof SettingsRoute 93 + '/callback': typeof CallbackIndexRoute 87 94 '/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 88 95 '/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 89 96 '/profile/$did': typeof ProfileDidIndexRoute ··· 95 102 '/notifications': typeof NotificationsRoute 96 103 '/search': typeof SearchRoute 97 104 '/settings': typeof SettingsRoute 105 + '/callback': typeof CallbackIndexRoute 98 106 '/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 99 107 '/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 100 108 '/profile/$did': typeof ProfileDidIndexRoute ··· 109 117 '/search': typeof SearchRoute 110 118 '/settings': typeof SettingsRoute 111 119 '/_pathlessLayout/_nested-layout': typeof PathlessLayoutNestedLayoutRouteWithChildren 120 + '/callback/': typeof CallbackIndexRoute 112 121 '/_pathlessLayout/_nested-layout/route-a': typeof PathlessLayoutNestedLayoutRouteARoute 113 122 '/_pathlessLayout/_nested-layout/route-b': typeof PathlessLayoutNestedLayoutRouteBRoute 114 123 '/profile/$did/': typeof ProfileDidIndexRoute ··· 122 131 | '/notifications' 123 132 | '/search' 124 133 | '/settings' 134 + | '/callback' 125 135 | '/route-a' 126 136 | '/route-b' 127 137 | '/profile/$did' ··· 133 143 | '/notifications' 134 144 | '/search' 135 145 | '/settings' 146 + | '/callback' 136 147 | '/route-a' 137 148 | '/route-b' 138 149 | '/profile/$did' ··· 146 157 | '/search' 147 158 | '/settings' 148 159 | '/_pathlessLayout/_nested-layout' 160 + | '/callback/' 149 161 | '/_pathlessLayout/_nested-layout/route-a' 150 162 | '/_pathlessLayout/_nested-layout/route-b' 151 163 | '/profile/$did/' ··· 159 171 NotificationsRoute: typeof NotificationsRoute 160 172 SearchRoute: typeof SearchRoute 161 173 SettingsRoute: typeof SettingsRoute 174 + CallbackIndexRoute: typeof CallbackIndexRoute 162 175 ProfileDidIndexRoute: typeof ProfileDidIndexRoute 163 176 ProfileDidPostRkeyRoute: typeof ProfileDidPostRkeyRoute 164 177 } ··· 205 218 path: '/' 206 219 fullPath: '/' 207 220 preLoaderRoute: typeof IndexRouteImport 221 + parentRoute: typeof rootRouteImport 222 + } 223 + '/callback/': { 224 + id: '/callback/' 225 + path: '/callback' 226 + fullPath: '/callback' 227 + preLoaderRoute: typeof CallbackIndexRouteImport 208 228 parentRoute: typeof rootRouteImport 209 229 } 210 230 '/_pathlessLayout/_nested-layout': { ··· 282 302 NotificationsRoute: NotificationsRoute, 283 303 SearchRoute: SearchRoute, 284 304 SettingsRoute: SettingsRoute, 305 + CallbackIndexRoute: CallbackIndexRoute, 285 306 ProfileDidIndexRoute: ProfileDidIndexRoute, 286 307 ProfileDidPostRkeyRoute: ProfileDidPostRkeyRoute, 287 308 }
+6 -6
src/routes/__root.tsx
··· 21 21 import { NotFound } from "~/components/NotFound"; 22 22 import appCss from "~/styles/app.css?url"; 23 23 import { seo } from "~/utils/seo"; 24 - import { AuthProvider, useAuth } from "~/providers/PassAuthProvider"; 24 + import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider"; 25 25 import { PersistentStoreProvider } from "~/providers/PersistentStoreProvider"; 26 - import type AtpAgent from "@atproto/api"; 26 + import type Agent from "@atproto/api"; 27 27 import type { QueryClient } from "@tanstack/react-query"; 28 28 29 29 export const Route = createRootRouteWithContext<{ ··· 44 44 }), 45 45 ], 46 46 links: [ 47 - { rel: "stylesheet", href: appCss }, 48 47 { 49 48 rel: "apple-touch-icon", 50 49 sizes: "180x180", ··· 79 78 80 79 function RootComponent() { 81 80 return ( 82 - <AuthProvider> 81 + <UnifiedAuthProvider> 83 82 <PersistentStoreProvider> 84 83 <RootDocument> 85 84 <Outlet /> 86 85 </RootDocument> 87 86 </PersistentStoreProvider> 88 - </AuthProvider> 87 + </UnifiedAuthProvider> 89 88 ); 90 89 } 91 90 92 91 function RootDocument({ children }: { children: React.ReactNode }) { 93 92 const location = useLocation(); 94 93 const navigate = useNavigate(); 95 - const { agent, authed } = useAuth(); 94 + const { agent } = useAuth(); 95 + const authed = !!agent?.did; 96 96 const isHome = location.pathname === "/"; 97 97 const isNotifications = location.pathname.startsWith("/notifications"); 98 98 const isProfile = agent && ((location.pathname === (`/profile/${agent?.did}`)) || (location.pathname === (`/profile/${encodeURIComponent(agent?.did??"")}`)));
+13
src/routes/callback/index.tsx
··· 1 + import { createFileRoute, useNavigate } from '@tanstack/react-router' 2 + 3 + export const Route = createFileRoute('/callback/')({ 4 + component: RouteComponent, 5 + }) 6 + 7 + function RouteComponent() { 8 + const navigate = useNavigate() 9 + const redirectPath = sessionStorage.getItem('postLoginRedirect') || '/'; 10 + navigate({to:redirectPath}) 11 + sessionStorage.removeItem('postLoginRedirect'); 12 + return <div>Hello "/callback/"!</div> 13 + }
+9 -7
src/routes/index.tsx
··· 6 6 UniversalPostRendererATURILoader, 7 7 } from "~/components/UniversalPostRenderer"; 8 8 import * as React from "react"; 9 - import { useAuth } from "~/providers/PassAuthProvider"; 9 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 10 10 //import { usePersistentStore } from "~/providers/PersistentStoreProvider"; 11 11 import { 12 12 useQueryIdentity, ··· 99 99 function Home() { 100 100 const { 101 101 agent, 102 - loginStatus, 103 - login, 102 + status, 103 + authMethod, 104 + loginWithPassword, 105 + loginWithOAuth, 104 106 logout, 105 - loading: loadering, 106 - authed, 107 107 } = useAuth(); 108 + const authed = !!agent?.did; 108 109 109 110 useEffect(() => { 110 111 if (agent?.did) { ··· 112 113 } else { 113 114 store.set(authedAtom, false); 114 115 } 115 - }, [loginStatus, agent, authed]); 116 + }, [status, agent, authed]); 116 117 useEffect(() => { 117 118 if (agent) { 119 + // is it just me or is the type really weird here it should be Agent not AtpAgent 118 120 store.set(agentAtom, agent); 119 121 } else { 120 122 store.set(agentAtom, null); 121 123 } 122 - }, [loginStatus, agent, authed]); 124 + }, [status, agent, authed]); 123 125 124 126 //const { get, set } = usePersistentStore(); 125 127 // const [feed, setFeed] = React.useState<any[]>([]);
+2 -2
src/utils/atoms.ts
··· 1 - import type AtpAgent from "@atproto/api"; 1 + import type Agent from "@atproto/api"; 2 2 import { atom, createStore } from "jotai"; 3 3 import { atomWithStorage } from 'jotai/utils'; 4 4 ··· 21 21 {} 22 22 ); 23 23 24 - export const agentAtom = atom<AtpAgent|null>(null); 24 + export const agentAtom = atom<Agent|null>(null); 25 25 export const authedAtom = atom<boolean>(false);
+16
src/utils/oauthClient.ts
··· 1 + // src/helpers/oauthClient.ts 2 + import { BrowserOAuthClient, type ClientMetadata } from '@atproto/oauth-client-browser'; 3 + 4 + // This is your app's PDS for resolving handles if not provided. 5 + // You might need to host your own or use a public one. 6 + const handleResolverPDS = 'https://bsky.social'; 7 + 8 + // This assumes your client-metadata.json is in the /public folder 9 + // and will be served at the root of your domain. 10 + import clientMetadata from '../../public/client-metadata.json' assert { type: 'json' }; 11 + 12 + export const oauthClient = new BrowserOAuthClient({ 13 + // The type assertion is needed because the static import isn't strictly typed 14 + clientMetadata: clientMetadata as ClientMetadata, 15 + handleResolver: handleResolverPDS, 16 + });
+6 -6
src/utils/useQuery.ts
··· 332 332 333 333 export function constructFeedSkeletonQuery(options?: { 334 334 feedUri: string; 335 - agent?: ATPAPI.AtpAgent; 335 + agent?: ATPAPI.Agent; 336 336 isAuthed: boolean; 337 337 pdsUrl?: string; 338 338 feedServiceDid?: string; ··· 372 372 373 373 export function useQueryFeedSkeleton(options?: { 374 374 feedUri: string; 375 - agent?: ATPAPI.AtpAgent; 375 + agent?: ATPAPI.Agent; 376 376 isAuthed: boolean; 377 377 pdsUrl?: string; 378 378 feedServiceDid?: string; ··· 380 380 return useQuery(constructFeedSkeletonQuery(options)); 381 381 } 382 382 383 - export function constructPreferencesQuery(agent?: ATPAPI.AtpAgent | undefined, pdsUrl?: string | undefined) { 383 + export function constructPreferencesQuery(agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined) { 384 384 return queryOptions({ 385 385 queryKey: ['preferences', agent?.did], 386 386 queryFn: async () => { ··· 393 393 }); 394 394 } 395 395 export function useQueryPreferences(options: { 396 - agent?: ATPAPI.AtpAgent | undefined, pdsUrl?: string | undefined 396 + agent?: ATPAPI.Agent | undefined, pdsUrl?: string | undefined 397 397 }) { 398 398 return useQuery(constructPreferencesQuery(options.agent, options.pdsUrl)); 399 399 } ··· 498 498 499 499 export function constructInfiniteFeedSkeletonQuery(options: { 500 500 feedUri: string; 501 - agent?: ATPAPI.AtpAgent; 501 + agent?: ATPAPI.Agent; 502 502 isAuthed: boolean; 503 503 pdsUrl?: string; 504 504 feedServiceDid?: string; ··· 537 537 538 538 export function useInfiniteQueryFeedSkeleton(options: { 539 539 feedUri: string; 540 - agent?: ATPAPI.AtpAgent; 540 + agent?: ATPAPI.Agent; 541 541 isAuthed: boolean; 542 542 pdsUrl?: string; 543 543 feedServiceDid?: string;
+18
vite.config.ts
··· 1 1 import { defineConfig } from "vite"; 2 2 import viteReact from "@vitejs/plugin-react"; 3 3 import tailwindcss from "@tailwindcss/vite"; 4 + import { generateMetadataPlugin } from "./oauthdev.mts"; 5 + 6 + const PROD_URL = "https://reddwarf.whey.party" 7 + const DEV_URL = "https://local3768forumtest.whey.party" 8 + 9 + function shp(url: string): string { 10 + return url.replace(/^https?:\/\//, ''); 11 + } 4 12 5 13 import { TanStackRouterVite } from "@tanstack/router-plugin/vite"; 6 14 import { resolve } from "node:path"; ··· 8 16 // https://vitejs.dev/config/ 9 17 export default defineConfig({ 10 18 plugins: [ 19 + generateMetadataPlugin({ 20 + prod: PROD_URL, 21 + dev: DEV_URL, 22 + }), 11 23 TanStackRouterVite({ autoCodeSplitting: true }), 12 24 viteReact(), 13 25 tailwindcss(), ··· 21 33 "@": resolve(__dirname, "./src"), 22 34 "~": resolve(__dirname, "./src"), 23 35 }, 36 + }, 37 + server: { 38 + allowedHosts: [shp(PROD_URL),shp(DEV_URL)], 39 + }, 40 + css: { 41 + devSourcemap: true, 24 42 }, 25 43 });