beads-map 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/BUILD_ID +1 -0
- package/.next/app-build-manifest.json +27 -0
- package/.next/app-path-routes-manifest.json +1 -0
- package/.next/build-manifest.json +32 -0
- package/.next/export-marker.json +1 -0
- package/.next/images-manifest.json +1 -0
- package/.next/next-minimal-server.js.nft.json +1 -0
- package/.next/next-server.js.nft.json +1 -0
- package/.next/package.json +1 -0
- package/.next/prerender-manifest.json +1 -0
- package/.next/react-loadable-manifest.json +8 -0
- package/.next/required-server-files.json +1 -0
- package/.next/routes-manifest.json +1 -0
- package/.next/server/app/_not-found/page.js +1 -0
- package/.next/server/app/_not-found/page.js.nft.json +1 -0
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
- package/.next/server/app/_not-found.html +1 -0
- package/.next/server/app/_not-found.meta +6 -0
- package/.next/server/app/_not-found.rsc +10 -0
- package/.next/server/app/api/beads/route.js +8 -0
- package/.next/server/app/api/beads/route.js.nft.json +1 -0
- package/.next/server/app/api/beads/stream/route.js +10 -0
- package/.next/server/app/api/beads/stream/route.js.nft.json +1 -0
- package/.next/server/app/api/beads.body +1 -0
- package/.next/server/app/api/beads.meta +1 -0
- package/.next/server/app/api/config/route.js +8 -0
- package/.next/server/app/api/config/route.js.nft.json +1 -0
- package/.next/server/app/api/config.body +1 -0
- package/.next/server/app/api/config.meta +1 -0
- package/.next/server/app/api/login/route.js +1 -0
- package/.next/server/app/api/login/route.js.nft.json +1 -0
- package/.next/server/app/api/logout/route.js +1 -0
- package/.next/server/app/api/logout/route.js.nft.json +1 -0
- package/.next/server/app/api/oauth/callback/route.js +1 -0
- package/.next/server/app/api/oauth/callback/route.js.nft.json +1 -0
- package/.next/server/app/api/oauth/client-metadata.json/route.js +1 -0
- package/.next/server/app/api/oauth/client-metadata.json/route.js.nft.json +1 -0
- package/.next/server/app/api/oauth/jwks.json/route.js +1 -0
- package/.next/server/app/api/oauth/jwks.json/route.js.nft.json +1 -0
- package/.next/server/app/api/records/route.js +1 -0
- package/.next/server/app/api/records/route.js.nft.json +1 -0
- package/.next/server/app/api/status/route.js +1 -0
- package/.next/server/app/api/status/route.js.nft.json +1 -0
- package/.next/server/app/index.html +1 -0
- package/.next/server/app/index.meta +5 -0
- package/.next/server/app/index.rsc +8 -0
- package/.next/server/app/page.js +24 -0
- package/.next/server/app/page.js.nft.json +1 -0
- package/.next/server/app/page_client-reference-manifest.js +1 -0
- package/.next/server/app-paths-manifest.json +14 -0
- package/.next/server/chunks/247.js +12 -0
- package/.next/server/chunks/251.js +2 -0
- package/.next/server/chunks/29.js +1 -0
- package/.next/server/chunks/343.js +1 -0
- package/.next/server/chunks/533.js +38 -0
- package/.next/server/chunks/590.js +6 -0
- package/.next/server/chunks/615.js +15 -0
- package/.next/server/chunks/696.js +25 -0
- package/.next/server/chunks/719.js +2 -0
- package/.next/server/chunks/739.js +1 -0
- package/.next/server/chunks/font-manifest.json +1 -0
- package/.next/server/font-manifest.json +1 -0
- package/.next/server/functions-config-manifest.json +1 -0
- package/.next/server/interception-route-rewrite-manifest.js +1 -0
- package/.next/server/middleware-build-manifest.js +1 -0
- package/.next/server/middleware-manifest.json +6 -0
- package/.next/server/middleware-react-loadable-manifest.js +1 -0
- package/.next/server/next-font-manifest.js +1 -0
- package/.next/server/next-font-manifest.json +1 -0
- package/.next/server/pages/404.html +1 -0
- package/.next/server/pages/500.html +1 -0
- package/.next/server/pages/_app.js +1 -0
- package/.next/server/pages/_app.js.nft.json +1 -0
- package/.next/server/pages/_document.js +1 -0
- package/.next/server/pages/_document.js.nft.json +1 -0
- package/.next/server/pages/_error.js +1 -0
- package/.next/server/pages/_error.js.nft.json +1 -0
- package/.next/server/pages-manifest.json +1 -0
- package/.next/server/server-reference-manifest.js +1 -0
- package/.next/server/server-reference-manifest.json +1 -0
- package/.next/server/webpack-runtime.js +1 -0
- package/.next/static/99eOjoTtoO32H-c1faxZ5/_buildManifest.js +1 -0
- package/.next/static/99eOjoTtoO32H-c1faxZ5/_ssgManifest.js +1 -0
- package/.next/static/chunks/149.a3e3a5dc03e21086.js +1 -0
- package/.next/static/chunks/2200cc46-7c93a0e00b0bb825.js +1 -0
- package/.next/static/chunks/666-fb778298a77f3754.js +1 -0
- package/.next/static/chunks/945-bf736d0119e7437b.js +2 -0
- package/.next/static/chunks/app/_not-found/page-b568fd9238f85f27.js +1 -0
- package/.next/static/chunks/app/layout-13e3cdaaa416edb6.js +1 -0
- package/.next/static/chunks/app/page-49d569c912d5af9d.js +1 -0
- package/.next/static/chunks/framework-6e06c675866dc992.js +1 -0
- package/.next/static/chunks/main-62aa0e18004db880.js +1 -0
- package/.next/static/chunks/main-app-8b0c4a1007dbb7f4.js +1 -0
- package/.next/static/chunks/pages/_app-0c3037849002a4aa.js +1 -0
- package/.next/static/chunks/pages/_error-a647cd2c75dc4dc7.js +1 -0
- package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- package/.next/static/chunks/webpack-c8b9ebfd35ae1d92.js +1 -0
- package/.next/static/css/10ef08b24212fe36.css +3 -0
- package/README.md +243 -0
- package/app/api/beads/route.ts +27 -0
- package/app/api/beads/stream/route.ts +83 -0
- package/app/api/config/route.ts +46 -0
- package/app/api/login/route.ts +42 -0
- package/app/api/logout/route.ts +14 -0
- package/app/api/oauth/callback/route.ts +94 -0
- package/app/api/oauth/client-metadata.json/route.ts +33 -0
- package/app/api/oauth/jwks.json/route.ts +32 -0
- package/app/api/records/route.ts +168 -0
- package/app/api/status/route.ts +25 -0
- package/app/globals.css +192 -0
- package/app/layout.tsx +30 -0
- package/app/page.tsx +1151 -0
- package/bin/beads-map.mjs +175 -0
- package/components/AllCommentsPanel.tsx +265 -0
- package/components/AuthButton.tsx +197 -0
- package/components/BeadsGraph.tsx +1539 -0
- package/components/CommentTooltip.tsx +310 -0
- package/components/GraphStats.tsx +121 -0
- package/components/HeartIcon.tsx +33 -0
- package/components/NodeDetail.tsx +741 -0
- package/components/StatusLegend.tsx +99 -0
- package/components/TimelineBar.tsx +116 -0
- package/hooks/useBeadsComments.ts +412 -0
- package/lib/agent.ts +29 -0
- package/lib/auth/client.ts +221 -0
- package/lib/auth.tsx +159 -0
- package/lib/diff-beads.ts +125 -0
- package/lib/discover.ts +228 -0
- package/lib/env.ts +28 -0
- package/lib/parse-beads.ts +232 -0
- package/lib/session.ts +52 -0
- package/lib/timeline.ts +138 -0
- package/lib/types.ts +202 -0
- package/lib/utils.ts +25 -0
- package/lib/watch-beads.ts +97 -0
- package/next.config.mjs +4 -0
- package/package.json +75 -0
- package/postcss.config.mjs +9 -0
- package/public/image.png +0 -0
- package/scripts/generate-jwk.js +38 -0
- package/tailwind.config.ts +41 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { NodeOAuthClient } from "@atproto/oauth-client-node";
|
|
2
|
+
import { JoseKey } from "@atproto/jwk-jose";
|
|
3
|
+
import { env } from "../env";
|
|
4
|
+
import { getRawSession } from "../session";
|
|
5
|
+
|
|
6
|
+
const oauthClientKey = "globalOAuthClient";
|
|
7
|
+
// In development, clear cached client on hot reload to pick up config changes
|
|
8
|
+
if (process.env.NODE_ENV !== "production") {
|
|
9
|
+
(global as Record<string, unknown>)[oauthClientKey] = null;
|
|
10
|
+
}
|
|
11
|
+
if (!(global as Record<string, unknown>)[oauthClientKey]) {
|
|
12
|
+
(global as Record<string, unknown>)[oauthClientKey] = null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* OAuth Client Configuration
|
|
17
|
+
*
|
|
18
|
+
* Supports two modes:
|
|
19
|
+
*
|
|
20
|
+
* 1. CONFIDENTIAL CLIENT (Production)
|
|
21
|
+
* - Requires ATPROTO_JWK_PRIVATE and PUBLIC_URL environment variables
|
|
22
|
+
* - Authenticates to auth servers using private key JWT
|
|
23
|
+
* - Longer session lifetimes
|
|
24
|
+
*
|
|
25
|
+
* 2. PUBLIC CLIENT (Development fallback)
|
|
26
|
+
* - Used when ATPROTO_JWK_PRIVATE is not set
|
|
27
|
+
* - No client authentication
|
|
28
|
+
* - Shorter session lifetimes
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// In-Memory Store with Cookie Sync
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
const globalStoreKey = "oauthSharedStore";
|
|
36
|
+
if (!(global as Record<string, unknown>)[globalStoreKey]) {
|
|
37
|
+
(global as Record<string, unknown>)[globalStoreKey] = new Map();
|
|
38
|
+
}
|
|
39
|
+
const sharedStore: Map<string, unknown> = (
|
|
40
|
+
global as Record<string, unknown>
|
|
41
|
+
)[globalStoreKey] as Map<string, unknown>;
|
|
42
|
+
|
|
43
|
+
// State store - in-memory only, used during short-lived OAuth flow
|
|
44
|
+
const stateStore = {
|
|
45
|
+
async get(key: string) {
|
|
46
|
+
return sharedStore.get(`state:${key}`);
|
|
47
|
+
},
|
|
48
|
+
async set(key: string, value: unknown) {
|
|
49
|
+
sharedStore.set(`state:${key}`, value);
|
|
50
|
+
},
|
|
51
|
+
async del(key: string) {
|
|
52
|
+
sharedStore.delete(`state:${key}`);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Session store - syncs with cookie for persistence
|
|
57
|
+
const sessionStore = {
|
|
58
|
+
async get(key: string) {
|
|
59
|
+
const memValue = sharedStore.get(`session:${key}`);
|
|
60
|
+
if (memValue) {
|
|
61
|
+
return memValue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const session = await getRawSession();
|
|
66
|
+
if (session.oauthSession && session.did === key) {
|
|
67
|
+
const parsed = JSON.parse(session.oauthSession);
|
|
68
|
+
sharedStore.set(`session:${key}`, parsed);
|
|
69
|
+
return parsed;
|
|
70
|
+
}
|
|
71
|
+
} catch (err) {
|
|
72
|
+
console.warn("Failed to restore OAuth session from cookie:", err);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return undefined;
|
|
76
|
+
},
|
|
77
|
+
async set(key: string, value: unknown) {
|
|
78
|
+
sharedStore.set(`session:${key}`, value);
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const session = await getRawSession();
|
|
82
|
+
session.oauthSession = JSON.stringify(value);
|
|
83
|
+
await session.save();
|
|
84
|
+
} catch (err) {
|
|
85
|
+
console.warn("Failed to save OAuth session to cookie:", err);
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
async del(key: string) {
|
|
89
|
+
sharedStore.delete(`session:${key}`);
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const session = await getRawSession();
|
|
93
|
+
session.oauthSession = undefined;
|
|
94
|
+
await session.save();
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.warn("Failed to clear OAuth session from cookie:", err);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// ============================================================================
|
|
102
|
+
// JWK Keyset Management
|
|
103
|
+
// ============================================================================
|
|
104
|
+
|
|
105
|
+
let cachedKeyset: Awaited<ReturnType<typeof JoseKey.fromImportable>>[] | null =
|
|
106
|
+
null;
|
|
107
|
+
|
|
108
|
+
async function getKeyset() {
|
|
109
|
+
if (cachedKeyset) {
|
|
110
|
+
return cachedKeyset;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const jwkPrivate = env.ATPROTO_JWK_PRIVATE;
|
|
114
|
+
if (!jwkPrivate) {
|
|
115
|
+
return null; // Public client mode
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const jwk = JSON.parse(jwkPrivate);
|
|
120
|
+
const key = await JoseKey.fromImportable(jwk, jwk.kid || "key-1");
|
|
121
|
+
cachedKeyset = [key];
|
|
122
|
+
return cachedKeyset;
|
|
123
|
+
} catch (err) {
|
|
124
|
+
console.error("Failed to parse ATPROTO_JWK_PRIVATE:", err);
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ============================================================================
|
|
130
|
+
// OAuth Client Factory
|
|
131
|
+
// ============================================================================
|
|
132
|
+
|
|
133
|
+
export const createClient = async () => {
|
|
134
|
+
const publicUrl = env.PUBLIC_URL;
|
|
135
|
+
// Must use 127.0.0.1 per RFC 8252 for ATProto OAuth localhost development
|
|
136
|
+
const localhostUrl = `http://127.0.0.1:${env.PORT}`;
|
|
137
|
+
const enc = encodeURIComponent;
|
|
138
|
+
|
|
139
|
+
// Detect if we're running on localhost (dev mode)
|
|
140
|
+
const isLocalDev = process.env.NODE_ENV !== "production";
|
|
141
|
+
|
|
142
|
+
// Use localhost URL in development, production URL otherwise
|
|
143
|
+
const url = isLocalDev ? localhostUrl : publicUrl || localhostUrl;
|
|
144
|
+
|
|
145
|
+
let keyset = null;
|
|
146
|
+
try {
|
|
147
|
+
// Only load keyset for production (confidential client)
|
|
148
|
+
if (!isLocalDev && publicUrl) {
|
|
149
|
+
keyset = await getKeyset();
|
|
150
|
+
}
|
|
151
|
+
} catch (err) {
|
|
152
|
+
console.error("Error getting keyset:", err);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const isConfidentialClient = keyset !== null && !!publicUrl && !isLocalDev;
|
|
156
|
+
|
|
157
|
+
// Build client metadata based on client type
|
|
158
|
+
const clientMetadata: Record<string, unknown> = {
|
|
159
|
+
client_name: "Beads Map",
|
|
160
|
+
client_uri: url,
|
|
161
|
+
dpop_bound_access_tokens: true,
|
|
162
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
163
|
+
response_types: ["code"],
|
|
164
|
+
scope: "atproto transition:generic",
|
|
165
|
+
application_type: "web",
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
if (isConfidentialClient) {
|
|
169
|
+
clientMetadata.client_id = `${publicUrl}/api/oauth/client-metadata.json`;
|
|
170
|
+
clientMetadata.redirect_uris = [`${publicUrl}/api/oauth/callback`];
|
|
171
|
+
clientMetadata.token_endpoint_auth_method = "private_key_jwt";
|
|
172
|
+
clientMetadata.token_endpoint_auth_signing_alg = "ES256";
|
|
173
|
+
clientMetadata.jwks_uri = `${publicUrl}/api/oauth/jwks.json`;
|
|
174
|
+
} else {
|
|
175
|
+
clientMetadata.client_id = `http://localhost?redirect_uri=${enc(`${url}/api/oauth/callback`)}&scope=${enc("atproto transition:generic")}`;
|
|
176
|
+
clientMetadata.redirect_uris = [`${url}/api/oauth/callback`];
|
|
177
|
+
clientMetadata.token_endpoint_auth_method = "none";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const clientConfig: Record<string, unknown> = {
|
|
181
|
+
clientMetadata,
|
|
182
|
+
stateStore,
|
|
183
|
+
sessionStore,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
if (keyset) {
|
|
187
|
+
clientConfig.keyset = keyset;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return new NodeOAuthClient(
|
|
191
|
+
clientConfig as ConstructorParameters<typeof NodeOAuthClient>[0]
|
|
192
|
+
);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export const getGlobalOAuthClient = async () => {
|
|
196
|
+
const currentClient = (global as Record<string, unknown>)[oauthClientKey];
|
|
197
|
+
if (!currentClient) {
|
|
198
|
+
try {
|
|
199
|
+
const newClient = await createClient();
|
|
200
|
+
(global as Record<string, unknown>)[oauthClientKey] = newClient;
|
|
201
|
+
return newClient;
|
|
202
|
+
} catch (err) {
|
|
203
|
+
console.error("Failed to create OAuth client:", err);
|
|
204
|
+
throw err;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return currentClient as NodeOAuthClient;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Get the JWKS (public keys) for the confidential client.
|
|
212
|
+
*/
|
|
213
|
+
export async function getJwks(): Promise<{ keys: unknown[] } | null> {
|
|
214
|
+
const client = await getGlobalOAuthClient();
|
|
215
|
+
|
|
216
|
+
if ("jwks" in client && client.jwks) {
|
|
217
|
+
return client.jwks as { keys: unknown[] };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return null;
|
|
221
|
+
}
|
package/lib/auth.tsx
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useState,
|
|
5
|
+
useEffect,
|
|
6
|
+
useCallback,
|
|
7
|
+
createContext,
|
|
8
|
+
useContext,
|
|
9
|
+
} from "react";
|
|
10
|
+
|
|
11
|
+
export interface AuthSession {
|
|
12
|
+
did: string;
|
|
13
|
+
handle: string;
|
|
14
|
+
displayName?: string;
|
|
15
|
+
avatar?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type AuthStatus = "idle" | "authorizing" | "authenticated" | "error";
|
|
19
|
+
|
|
20
|
+
interface AuthState {
|
|
21
|
+
status: AuthStatus;
|
|
22
|
+
session: AuthSession | null;
|
|
23
|
+
error: Error | null;
|
|
24
|
+
isLoading: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const AuthContext = createContext<{
|
|
28
|
+
state: AuthState;
|
|
29
|
+
login: (handle: string) => Promise<void>;
|
|
30
|
+
logout: () => Promise<void>;
|
|
31
|
+
} | null>(null);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Auth Provider - wraps the app to provide authentication state.
|
|
35
|
+
* Checks session status on mount via /api/status.
|
|
36
|
+
*/
|
|
37
|
+
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|
38
|
+
const [state, setState] = useState<AuthState>({
|
|
39
|
+
status: "idle",
|
|
40
|
+
session: null,
|
|
41
|
+
error: null,
|
|
42
|
+
isLoading: true,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Check session status on mount
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
let cancelled = false;
|
|
48
|
+
|
|
49
|
+
const checkStatus = async () => {
|
|
50
|
+
try {
|
|
51
|
+
const response = await fetch("/api/status");
|
|
52
|
+
if (response.ok) {
|
|
53
|
+
const data = await response.json();
|
|
54
|
+
if (data.did && !cancelled) {
|
|
55
|
+
setState({
|
|
56
|
+
status: "authenticated",
|
|
57
|
+
session: {
|
|
58
|
+
did: data.did,
|
|
59
|
+
handle: data.handle || data.did,
|
|
60
|
+
displayName: data.displayName,
|
|
61
|
+
avatar: data.avatar,
|
|
62
|
+
},
|
|
63
|
+
error: null,
|
|
64
|
+
isLoading: false,
|
|
65
|
+
});
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.error("Failed to check auth status:", error);
|
|
71
|
+
}
|
|
72
|
+
if (!cancelled) {
|
|
73
|
+
setState((prev) => ({ ...prev, isLoading: false }));
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
checkStatus();
|
|
78
|
+
return () => {
|
|
79
|
+
cancelled = true;
|
|
80
|
+
};
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
// Login - initiates OAuth flow, redirects to PDS authorization
|
|
84
|
+
const login = useCallback(async (handle: string) => {
|
|
85
|
+
setState((prev) => ({
|
|
86
|
+
...prev,
|
|
87
|
+
status: "authorizing",
|
|
88
|
+
isLoading: true,
|
|
89
|
+
error: null,
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const normalizedHandle = handle.includes(".")
|
|
94
|
+
? handle
|
|
95
|
+
: `${handle}.bsky.social`;
|
|
96
|
+
|
|
97
|
+
const response = await fetch("/api/login", {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: { "Content-Type": "application/json" },
|
|
100
|
+
body: JSON.stringify({
|
|
101
|
+
handle: normalizedHandle,
|
|
102
|
+
returnTo: window.location.pathname + window.location.search,
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
const data = await response.json();
|
|
108
|
+
throw new Error(data.error || "Login failed");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const data = await response.json();
|
|
112
|
+
window.location.href = data.redirectUrl;
|
|
113
|
+
} catch (err) {
|
|
114
|
+
const error = err instanceof Error ? err : new Error("Login failed");
|
|
115
|
+
setState({ status: "error", session: null, error, isLoading: false });
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}, []);
|
|
119
|
+
|
|
120
|
+
// Logout - clears server session
|
|
121
|
+
const logout = useCallback(async () => {
|
|
122
|
+
try {
|
|
123
|
+
await fetch("/api/logout", { method: "POST" });
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.error("Logout request failed:", error);
|
|
126
|
+
}
|
|
127
|
+
setState({ status: "idle", session: null, error: null, isLoading: false });
|
|
128
|
+
}, []);
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<AuthContext.Provider value={{ state, login, logout }}>
|
|
132
|
+
{children}
|
|
133
|
+
</AuthContext.Provider>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Hook to access auth state and actions.
|
|
139
|
+
* Must be used within an AuthProvider.
|
|
140
|
+
*/
|
|
141
|
+
export function useAuth() {
|
|
142
|
+
const context = useContext(AuthContext);
|
|
143
|
+
|
|
144
|
+
if (!context) {
|
|
145
|
+
throw new Error("useAuth must be used within an AuthProvider");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const { state, login, logout } = context;
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
status: state.status,
|
|
152
|
+
session: state.session,
|
|
153
|
+
error: state.error,
|
|
154
|
+
isLoading: state.isLoading,
|
|
155
|
+
isAuthenticated: state.status === "authenticated",
|
|
156
|
+
login,
|
|
157
|
+
logout,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { BeadsApiResponse, GraphLink } from "./types";
|
|
2
|
+
|
|
3
|
+
export interface NodeChange {
|
|
4
|
+
field: string; // e.g. "status", "priority", "title"
|
|
5
|
+
from: string; // previous value (stringified)
|
|
6
|
+
to: string; // new value (stringified)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface BeadsDiff {
|
|
10
|
+
addedNodeIds: Set<string>; // IDs of nodes not in old data
|
|
11
|
+
removedNodeIds: Set<string>; // IDs of nodes not in new data
|
|
12
|
+
changedNodes: Map<string, NodeChange[]>; // ID -> list of field changes
|
|
13
|
+
addedLinkKeys: Set<string>; // "source->target:type" keys
|
|
14
|
+
removedLinkKeys: Set<string>; // "source->target:type" keys
|
|
15
|
+
hasChanges: boolean; // true if anything changed at all
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build a stable key for a link.
|
|
20
|
+
* Links may have string or object source/target (after force-graph mutation).
|
|
21
|
+
*/
|
|
22
|
+
export function linkKey(link: GraphLink): string {
|
|
23
|
+
const src =
|
|
24
|
+
typeof link.source === "object"
|
|
25
|
+
? (link.source as { id: string }).id
|
|
26
|
+
: link.source;
|
|
27
|
+
const tgt =
|
|
28
|
+
typeof link.target === "object"
|
|
29
|
+
? (link.target as { id: string }).id
|
|
30
|
+
: link.target;
|
|
31
|
+
return `${src}->${tgt}:${link.type}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Compute the diff between old and new beads data.
|
|
36
|
+
* Compares nodes by ID and links by source->target:type key.
|
|
37
|
+
*/
|
|
38
|
+
export function diffBeadsData(
|
|
39
|
+
oldData: BeadsApiResponse | null,
|
|
40
|
+
newData: BeadsApiResponse
|
|
41
|
+
): BeadsDiff {
|
|
42
|
+
// If no old data, everything is "added"
|
|
43
|
+
if (!oldData) {
|
|
44
|
+
return {
|
|
45
|
+
addedNodeIds: new Set(newData.graphData.nodes.map((n) => n.id)),
|
|
46
|
+
removedNodeIds: new Set(),
|
|
47
|
+
changedNodes: new Map(),
|
|
48
|
+
addedLinkKeys: new Set(newData.graphData.links.map(linkKey)),
|
|
49
|
+
removedLinkKeys: new Set(),
|
|
50
|
+
hasChanges: true,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const oldNodeMap = new Map(
|
|
55
|
+
oldData.graphData.nodes.map((n) => [n.id, n])
|
|
56
|
+
);
|
|
57
|
+
const newNodeMap = new Map(
|
|
58
|
+
newData.graphData.nodes.map((n) => [n.id, n])
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Node diffs
|
|
62
|
+
const addedNodeIds = new Set<string>();
|
|
63
|
+
const removedNodeIds = new Set<string>();
|
|
64
|
+
const changedNodes = new Map<string, NodeChange[]>();
|
|
65
|
+
|
|
66
|
+
for (const [id, node] of newNodeMap) {
|
|
67
|
+
if (!oldNodeMap.has(id)) {
|
|
68
|
+
addedNodeIds.add(id);
|
|
69
|
+
} else {
|
|
70
|
+
const old = oldNodeMap.get(id)!;
|
|
71
|
+
const changes: NodeChange[] = [];
|
|
72
|
+
if (old.status !== node.status) {
|
|
73
|
+
changes.push({ field: "status", from: old.status, to: node.status });
|
|
74
|
+
}
|
|
75
|
+
if (old.priority !== node.priority) {
|
|
76
|
+
changes.push({
|
|
77
|
+
field: "priority",
|
|
78
|
+
from: String(old.priority),
|
|
79
|
+
to: String(node.priority),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (old.title !== node.title) {
|
|
83
|
+
changes.push({ field: "title", from: old.title, to: node.title });
|
|
84
|
+
}
|
|
85
|
+
if (changes.length > 0) {
|
|
86
|
+
changedNodes.set(id, changes);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const id of oldNodeMap.keys()) {
|
|
91
|
+
if (!newNodeMap.has(id)) {
|
|
92
|
+
removedNodeIds.add(id);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Link diffs
|
|
97
|
+
const oldLinkKeys = new Set(oldData.graphData.links.map(linkKey));
|
|
98
|
+
const newLinkKeys = new Set(newData.graphData.links.map(linkKey));
|
|
99
|
+
|
|
100
|
+
const addedLinkKeys = new Set<string>();
|
|
101
|
+
const removedLinkKeys = new Set<string>();
|
|
102
|
+
|
|
103
|
+
for (const key of newLinkKeys) {
|
|
104
|
+
if (!oldLinkKeys.has(key)) addedLinkKeys.add(key);
|
|
105
|
+
}
|
|
106
|
+
for (const key of oldLinkKeys) {
|
|
107
|
+
if (!newLinkKeys.has(key)) removedLinkKeys.add(key);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const hasChanges =
|
|
111
|
+
addedNodeIds.size > 0 ||
|
|
112
|
+
removedNodeIds.size > 0 ||
|
|
113
|
+
changedNodes.size > 0 ||
|
|
114
|
+
addedLinkKeys.size > 0 ||
|
|
115
|
+
removedLinkKeys.size > 0;
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
addedNodeIds,
|
|
119
|
+
removedNodeIds,
|
|
120
|
+
changedNodes,
|
|
121
|
+
addedLinkKeys,
|
|
122
|
+
removedLinkKeys,
|
|
123
|
+
hasChanges,
|
|
124
|
+
};
|
|
125
|
+
}
|