+18
-2
README.md
+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
-1
index.html
+48
oauthdev.mts
+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
+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
-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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
});