Scrapboard.org client
1"use client";
2
3import {
4 createContext,
5 useContext,
6 useEffect,
7 useState,
8 useCallback,
9 ReactNode,
10} from "react";
11import {
12 BrowserOAuthClient,
13 AtprotoDohHandleResolver,
14 type OAuthSession,
15} from "@atproto/oauth-client-browser";
16import { Agent } from "@atproto/api";
17import { th } from "zod/v4/locales";
18
19type AuthContextType = {
20 session: OAuthSession | null;
21 agent: Agent;
22 loading: boolean;
23 login: (handle: string) => Promise<void>;
24 logout: () => void;
25};
26
27const AuthContext = createContext<AuthContextType | null>(null);
28
29export function AuthProvider({ children }: { children: ReactNode }) {
30 const defaultAgent = new Agent({ service: "https://bsky.social" });
31 const [session, setSession] = useState<OAuthSession | null>(null);
32 const [agent, setAgent] = useState<Agent>(defaultAgent);
33 const [loading, setLoading] = useState(true);
34 const [client, setClient] = useState<BrowserOAuthClient | null>(null);
35
36 useEffect(() => {
37 const initClient = async () => {
38 const isDev = process.env.NODE_ENV === "development";
39
40 const c = isDev
41 ? new BrowserOAuthClient({
42 handleResolver: "https://bsky.social",
43 clientMetadata: {
44 client_name: "Statusphere React App",
45 client_id: `http://localhost?scope=${encodeURI(
46 "atproto transition:generic transition:chat.bsky"
47 )}`,
48 client_uri: "http://127.0.0.1:3000",
49 redirect_uris: ["http://127.0.0.1:3000"],
50 scope: "atproto transition:generic",
51 grant_types: ["authorization_code", "refresh_token"],
52 response_types: ["code"],
53 application_type: "web",
54 token_endpoint_auth_method: "none",
55 dpop_bound_access_tokens: true,
56 }, // loopback client
57 })
58 : await BrowserOAuthClient.load({
59 handleResolver: "https://bsky.social",
60 clientId: "https://pin.to.it/client-metadata.json",
61 });
62
63 setClient(c);
64
65 try {
66 const result = await c.init();
67 if (result?.session) {
68 console.log("session found", result);
69 localStorage.setItem("did", result.session.did);
70 const ag = new Agent(result.session);
71 setSession(result.session);
72 setAgent(ag);
73 const prefs = await ag.getPreferences();
74 if (!prefs) return;
75 } else {
76 const did = localStorage.getItem("did");
77
78 console.log("restoring", did);
79 if (did != null) {
80 const result = await c.restore(did);
81 const ag = new Agent(result);
82 setSession(result);
83 setAgent(ag);
84 }
85 }
86 } catch (err) {
87 console.error("OAuth init failed", err);
88 } finally {
89 setLoading(false);
90 }
91
92 c.addEventListener("deleted", (event: CustomEvent) => {
93 console.warn("Session invalidated", event.detail);
94 setSession(null);
95 setAgent(defaultAgent);
96 });
97 };
98
99 initClient();
100 }, []);
101
102 const login = useCallback(
103 async (handle: string) => {
104 if (!client) return;
105 try {
106 await client.signIn(handle, {
107 scope: "atproto transition:generic",
108 ui_locales: "en", // Only supported by some OAuth servers (requires OpenID Connect support + i18n support)
109 signal: new AbortController().signal,
110 });
111 } catch (e) {
112 console.warn("Login aborted or failed", e);
113 throw new Error(
114 `Login failed: ${e instanceof Error ? e.message : "Unknown error"}`
115 );
116 }
117 },
118 [client]
119 );
120
121 const logout = useCallback(() => {
122 if (client && session) {
123 client.revoke(session.sub);
124 setSession(null);
125 setAgent(defaultAgent);
126 // refresh page
127 window.location.reload();
128 }
129 }, [client, session]);
130
131 return (
132 <AuthContext.Provider value={{ session, agent, loading, login, logout }}>
133 {children}
134 </AuthContext.Provider>
135 );
136}
137
138export function useAuth() {
139 const ctx = useContext(AuthContext);
140 if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
141 return ctx;
142}