Retro Bulletin Board Systems on atproto. Web app and TUI. lazy mirror of alyraffauf/atbbs atbbs.xyz
forums python tui atproto bbs
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

at master 319 lines 8.7 kB view raw
1/** Browser OAuth for atbbs, backed by atcute. Components use useAuth(); 2 * route loaders await ensureAuthReady(). */ 3 4import { useSyncExternalStore } from "react"; 5import { Client } from "@atcute/client"; 6import { 7 configureOAuth, 8 createAuthorizationUrl, 9 deleteStoredSession, 10 finalizeAuthorization, 11 getSession, 12 OAuthUserAgent, 13} from "@atcute/oauth-browser-client"; 14import type { ActorResolver, ResolvedActor } from "@atcute/identity-resolver"; 15import type { ActorIdentifier } from "@atcute/lexicons/syntax"; 16import { resolveIdentity } from "./atproto"; 17 18// --- OAuth setup (deferred until config is available) --- 19 20/** Resolves handles via Slingshot so login attempts don't leak to Bluesky. */ 21class SlingshotActorResolver implements ActorResolver { 22 async resolve(actor: ActorIdentifier): Promise<ResolvedActor> { 23 const doc = await resolveIdentity(actor); 24 if (!doc.pds) throw new Error(`No PDS for ${actor}`); 25 return { 26 did: doc.did as ResolvedActor["did"], 27 handle: doc.handle as ResolvedActor["handle"], 28 pds: doc.pds, 29 }; 30 } 31} 32 33let oauthConfigured = false; 34let oauthScope = ""; 35 36async function initOAuth(): Promise<void> { 37 if (oauthConfigured) return; 38 39 let clientId: string; 40 let redirectUri: string; 41 42 if (import.meta.env.DEV) { 43 clientId = import.meta.env.VITE_OAUTH_CLIENT_ID; 44 redirectUri = import.meta.env.VITE_OAUTH_REDIRECT_URI; 45 oauthScope = import.meta.env.VITE_OAUTH_SCOPE; 46 } else { 47 const resp = await fetch("/config.json"); 48 const config = await resp.json(); 49 clientId = config.client_id; 50 redirectUri = config.redirect_uri; 51 oauthScope = config.scope; 52 } 53 54 configureOAuth({ 55 metadata: { client_id: clientId, redirect_uri: redirectUri }, 56 identityResolver: new SlingshotActorResolver(), 57 }); 58 oauthConfigured = true; 59} 60 61// --- Types --- 62 63export interface AuthUser { 64 did: string; 65 handle: string; 66 pdsUrl: string; 67} 68 69type Status = "loading" | "signedIn" | "signedOut"; 70 71type Did = `did:${string}:${string}`; 72 73const CURRENT_DID_KEY = "atbbs:current-did"; 74const CURRENT_HANDLE_KEY = "atbbs:current-handle"; 75const POST_LOGIN_KEY = "atbbs:post-login-redirect"; 76 77// --- Module-level auth state --- 78// 79// Intentionally outside React so both components (useAuth) and route 80// loaders (ensureAuthReady/getCurrentUser) can read it. 81 82let status: Status = "loading"; 83let currentUser: AuthUser | null = null; 84let currentAgent: Client | null = null; 85 86let initPromise: Promise<void> | null = null; 87let callbackPromise: Promise<void> | null = null; 88 89// --- Change notification (for useSyncExternalStore) --- 90 91const listeners = new Set<() => void>(); 92 93function notifyListeners() { 94 listeners.forEach((fn) => fn()); 95} 96 97function subscribeToChanges(callback: () => void) { 98 listeners.add(callback); 99 return () => listeners.delete(callback); 100} 101 102// --- Internal helpers --- 103 104async function setSignedIn(oauthAgent: OAuthUserAgent) { 105 const rpc = new Client({ handler: oauthAgent }); 106 const did = oauthAgent.sub; 107 108 // Cached handle covers offline restores; overwritten when Slingshot responds. 109 let handle = localStorage.getItem(CURRENT_HANDLE_KEY) ?? did; 110 let pdsUrl = ""; 111 try { 112 const doc = await resolveIdentity(did); 113 handle = doc.handle; 114 pdsUrl = doc.pds ?? ""; 115 localStorage.setItem(CURRENT_HANDLE_KEY, handle); 116 } catch { 117 // Offline; the visibilitychange listener below will retry on next focus. 118 } 119 120 currentAgent = rpc; 121 currentUser = { did, handle, pdsUrl }; 122 status = "signedIn"; 123 124 try { 125 localStorage.setItem(CURRENT_DID_KEY, did); 126 } catch { 127 // storage full or blocked — non-fatal 128 } 129} 130 131async function retryIdentityIfUnresolved() { 132 if (!currentUser) return; 133 if (currentUser.handle !== currentUser.did) return; 134 try { 135 const doc = await resolveIdentity(currentUser.did); 136 currentUser = { 137 did: currentUser.did, 138 handle: doc.handle, 139 pdsUrl: doc.pds ?? currentUser.pdsUrl, 140 }; 141 localStorage.setItem(CURRENT_HANDLE_KEY, doc.handle); 142 notifyListeners(); 143 } catch { 144 // still offline; next focus will try again 145 } 146} 147 148if (typeof document !== "undefined") { 149 document.addEventListener("visibilitychange", () => { 150 if (document.visibilityState === "visible") retryIdentityIfUnresolved(); 151 }); 152} 153 154function setSignedOut() { 155 currentUser = null; 156 currentAgent = null; 157 status = "signedOut"; 158} 159 160// --- Session restore (runs on page load) --- 161 162async function restoreSession(): Promise<void> { 163 try { 164 await initOAuth(); 165 const did = localStorage.getItem(CURRENT_DID_KEY); 166 if (!did) { 167 setSignedOut(); 168 return; 169 } 170 const session = await getSession(did as Did, { allowStale: true }); 171 await setSignedIn(new OAuthUserAgent(session)); 172 } catch (e) { 173 console.warn("Could not resume OAuth session:", e); 174 setSignedOut(); 175 } finally { 176 notifyListeners(); 177 } 178} 179 180/** Resolves once session restore has been attempted. */ 181export function ensureAuthReady(): Promise<void> { 182 if (!initPromise) initPromise = restoreSession(); 183 return initPromise; 184} 185 186// Start restoring immediately so it's already in flight by the time the 187// first loader fires. 188ensureAuthReady(); 189 190export function getCurrentUser(): AuthUser | null { 191 return currentUser; 192} 193 194// --- Login --- 195 196async function login(handle: string): Promise<void> { 197 // Remember where to send the user after the OAuth round-trip, but never 198 // back to /oauth/callback (that would loop). 199 try { 200 const here = window.location.pathname; 201 const dest = here.startsWith("/oauth/") ? "/" : here; 202 sessionStorage.setItem(POST_LOGIN_KEY, dest); 203 } catch { 204 // non-fatal 205 } 206 207 await initOAuth(); 208 const url = await createAuthorizationUrl({ 209 target: { type: "account", identifier: handle as `${string}.${string}` }, 210 scope: oauthScope, 211 }); 212 213 // Small pause so the browser flushes sessionStorage before navigating. 214 await new Promise((r) => setTimeout(r, 200)); 215 window.location.assign(url); 216} 217 218/** Returns (and clears) the path we stashed before the OAuth redirect. */ 219export function takePostLoginRedirect(): string | null { 220 try { 221 const path = sessionStorage.getItem(POST_LOGIN_KEY); 222 sessionStorage.removeItem(POST_LOGIN_KEY); 223 return path; 224 } catch { 225 return null; 226 } 227} 228 229// --- OAuth callback --- 230 231/** Exchanges the OAuth code for a session. Safe to call twice (StrictMode). */ 232export function completeAuthCallback(): Promise<void> { 233 if (callbackPromise) return callbackPromise; 234 callbackPromise = (async () => { 235 await initOAuth(); 236 237 const fromQuery = new URLSearchParams(location.search); 238 const fromHash = new URLSearchParams(location.hash.slice(1)); 239 const params = 240 fromQuery.get("code") || fromQuery.get("error") ? fromQuery : fromHash; 241 242 if (!params.get("code") && !params.get("error")) { 243 throw new Error("OAuth callback missing code/error parameter"); 244 } 245 246 // Scrub the code from the URL so a refresh doesn't re-exchange. 247 history.replaceState(null, "", location.pathname); 248 249 const { session } = await finalizeAuthorization(params); 250 await setSignedIn(new OAuthUserAgent(session)); 251 initPromise = Promise.resolve(); 252 notifyListeners(); 253 })(); 254 return callbackPromise; 255} 256 257// --- Logout --- 258 259async function logout(): Promise<void> { 260 if (currentUser) { 261 try { 262 const session = await getSession(currentUser.did as Did, { 263 allowStale: true, 264 }); 265 await new OAuthUserAgent(session).signOut(); 266 } catch { 267 try { 268 deleteStoredSession(currentUser.did as Did); 269 } catch { 270 // non-fatal 271 } 272 } 273 try { 274 localStorage.removeItem(CURRENT_DID_KEY); 275 localStorage.removeItem(CURRENT_HANDLE_KEY); 276 } catch { 277 // non-fatal 278 } 279 } 280 setSignedOut(); 281 notifyListeners(); 282} 283 284// --- React hook --- 285 286interface AuthSnapshot { 287 status: Status; 288 user: AuthUser | null; 289 agent: Client | null; 290} 291 292// useSyncExternalStore compares snapshots with Object.is, so we must 293// return a NEW object whenever any field changes. If we mutated the same 294// object in place, React would never see the change. 295let cachedSnapshot: AuthSnapshot = { 296 status, 297 user: currentUser, 298 agent: currentAgent, 299}; 300 301function getSnapshot(): AuthSnapshot { 302 if ( 303 cachedSnapshot.status !== status || 304 cachedSnapshot.user !== currentUser || 305 cachedSnapshot.agent !== currentAgent 306 ) { 307 cachedSnapshot = { status, user: currentUser, agent: currentAgent }; 308 } 309 return cachedSnapshot; 310} 311 312export function useAuth() { 313 const snapshot = useSyncExternalStore( 314 subscribeToChanges, 315 getSnapshot, 316 getSnapshot, 317 ); 318 return { ...snapshot, login, logout }; 319}