@agent-native/core 0.68.1 → 0.68.3
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/README.md +4 -4
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +16 -0
- package/corpus/core/docs/content/authentication.md +16 -2
- package/corpus/core/docs/content/template-analytics.md +14 -9
- package/corpus/core/docs/content/template-clips.md +35 -2
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/cli/pr-visual-recap-workflow.ts +1 -1
- package/corpus/core/src/client/AgentPanel.tsx +2 -4
- package/corpus/core/src/client/CommandMenu.tsx +98 -3
- package/corpus/core/src/client/ConnectBuilderCard.tsx +11 -2
- package/corpus/core/src/client/index.ts +2 -0
- package/corpus/core/src/extensions/content-patch.ts +12 -2
- package/corpus/core/src/oauth-tokens/google-refresh.ts +32 -17
- package/corpus/core/src/provider-api/index.ts +37 -17
- package/corpus/core/src/server/auth.ts +6 -8
- package/corpus/core/src/server/better-auth-instance.ts +19 -4
- package/corpus/core/src/server/core-routes-plugin.ts +157 -5
- package/corpus/core/src/server/google-oauth-credentials.ts +51 -0
- package/corpus/core/src/server/index.ts +8 -0
- package/corpus/core/src/server/onboarding-html.ts +2 -1
- package/corpus/core/src/server/social-og-image.ts +6 -1
- package/corpus/core/src/vite/client.ts +83 -1
- package/corpus/templates/analytics/app/routes/$.tsx +9 -0
- package/corpus/templates/calendar/app/components/calendar/DayView.tsx +33 -1
- package/corpus/templates/calendar/app/components/calendar/EventAttendeesSection.tsx +11 -3
- package/corpus/templates/calendar/app/components/calendar/EventCard.tsx +25 -2
- package/corpus/templates/calendar/app/components/calendar/EventDetailPanel.tsx +12 -0
- package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +7 -2
- package/corpus/templates/calendar/app/components/calendar/GoogleConnectBanner.tsx +37 -78
- package/corpus/templates/calendar/app/components/calendar/WeekView.tsx +33 -1
- package/corpus/templates/calendar/app/components/layout/Sidebar.tsx +46 -3
- package/corpus/templates/calendar/app/hooks/use-google-auth.ts +203 -2
- package/corpus/templates/calendar/app/lib/event-colors.ts +1 -4
- package/corpus/templates/calendar/app/lib/rsvp-status.tsx +6 -0
- package/corpus/templates/calendar/app/pages/CalendarView.tsx +9 -4
- package/corpus/templates/calendar/app/pages/NotFound.tsx +1 -1
- package/corpus/templates/calendar/app/pages/Settings.tsx +25 -1
- package/corpus/templates/calendar/app/routes/_app.$.tsx +9 -0
- package/corpus/templates/calendar/server/handlers/google-auth.ts +152 -12
- package/corpus/templates/calendar/server/lib/google-calendar.ts +63 -14
- package/corpus/templates/calendar/server/plugins/auth.ts +4 -20
- package/corpus/templates/calendar/shared/api.ts +4 -0
- package/corpus/templates/clips/.agents/skills/recording/SKILL.md +17 -0
- package/corpus/templates/clips/actions/sync-calendars.ts +6 -7
- package/corpus/templates/clips/app/components/capture-install-options.tsx +157 -0
- package/corpus/templates/clips/app/components/library/library-layout.tsx +9 -8
- package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +4 -6
- package/corpus/templates/clips/app/lib/capture-install-options.ts +14 -0
- package/corpus/templates/clips/app/root.tsx +54 -2
- package/corpus/templates/clips/app/routes/_app.dictate.tsx +9 -13
- package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +5 -7
- package/corpus/templates/clips/app/routes/_app.meetings._index.tsx +5 -7
- package/corpus/templates/clips/app/routes/download.tsx +49 -0
- package/corpus/templates/clips/app/routes/record.tsx +4 -4
- package/corpus/templates/clips/app/routes/share.$shareId.tsx +5 -6
- package/corpus/templates/clips/chrome-extension/STORE_DRAFT.md +29 -0
- package/corpus/templates/clips/server/lib/calendar-event-meetings.ts +6 -7
- package/corpus/templates/clips/server/lib/google-calendar-client.ts +61 -0
- package/corpus/templates/clips/server/routes/_agent-native/google/auth-url.get.ts +19 -12
- package/corpus/templates/clips/server/routes/_agent-native/google/callback.get.ts +6 -6
- package/corpus/templates/content/vite.config.ts +37 -1
- package/corpus/templates/design/app/pages/NotFound.tsx +1 -1
- package/corpus/templates/design/app/routes/$.tsx +9 -0
- package/corpus/templates/mail/app/pages/NotFound.tsx +1 -1
- package/corpus/templates/mail/app/routes/$.tsx +9 -0
- package/corpus/templates/plan/app/global.css +2 -2
- package/corpus/templates/slides/app/pages/NotFound.tsx +1 -1
- package/corpus/templates/slides/app/routes/$.tsx +9 -0
- package/corpus/templates/slides/server/lib/google-docs-oauth.ts +81 -25
- package/corpus/templates/videos/app/pages/NotFound.tsx +1 -1
- package/dist/cli/pr-visual-recap-workflow.d.ts +1 -1
- package/dist/cli/pr-visual-recap-workflow.d.ts.map +1 -1
- package/dist/cli/pr-visual-recap-workflow.js +1 -1
- package/dist/cli/pr-visual-recap-workflow.js.map +1 -1
- package/dist/client/AgentPanel.d.ts +1 -1
- package/dist/client/AgentPanel.d.ts.map +1 -1
- package/dist/client/AgentPanel.js +1 -1
- package/dist/client/AgentPanel.js.map +1 -1
- package/dist/client/CommandMenu.d.ts +15 -2
- package/dist/client/CommandMenu.d.ts.map +1 -1
- package/dist/client/CommandMenu.js +46 -3
- package/dist/client/CommandMenu.js.map +1 -1
- package/dist/client/ConnectBuilderCard.d.ts.map +1 -1
- package/dist/client/ConnectBuilderCard.js +11 -2
- package/dist/client/ConnectBuilderCard.js.map +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/extensions/content-patch.d.ts.map +1 -1
- package/dist/extensions/content-patch.js +10 -2
- package/dist/extensions/content-patch.js.map +1 -1
- package/dist/oauth-tokens/google-refresh.d.ts.map +1 -1
- package/dist/oauth-tokens/google-refresh.js +29 -17
- package/dist/oauth-tokens/google-refresh.js.map +1 -1
- package/dist/provider-api/index.d.ts.map +1 -1
- package/dist/provider-api/index.js +28 -16
- package/dist/provider-api/index.js.map +1 -1
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +6 -6
- package/dist/server/auth.js.map +1 -1
- package/dist/server/better-auth-instance.d.ts.map +1 -1
- package/dist/server/better-auth-instance.js +18 -4
- package/dist/server/better-auth-instance.js.map +1 -1
- package/dist/server/core-routes-plugin.d.ts +5 -0
- package/dist/server/core-routes-plugin.d.ts.map +1 -1
- package/dist/server/core-routes-plugin.js +107 -5
- package/dist/server/core-routes-plugin.js.map +1 -1
- package/dist/server/google-oauth-credentials.d.ts +15 -0
- package/dist/server/google-oauth-credentials.d.ts.map +1 -0
- package/dist/server/google-oauth-credentials.js +34 -0
- package/dist/server/google-oauth-credentials.js.map +1 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/onboarding-html.d.ts.map +1 -1
- package/dist/server/onboarding-html.js +2 -1
- package/dist/server/onboarding-html.js.map +1 -1
- package/dist/server/social-og-image.d.ts.map +1 -1
- package/dist/server/social-og-image.js +5 -1
- package/dist/server/social-og-image.js.map +1 -1
- package/dist/vite/client.d.ts.map +1 -1
- package/dist/vite/client.js +83 -1
- package/dist/vite/client.js.map +1 -1
- package/docs/content/authentication.md +16 -2
- package/docs/content/template-analytics.md +14 -9
- package/docs/content/template-clips.md +35 -2
- package/package.json +1 -1
|
@@ -1,7 +1,36 @@
|
|
|
1
1
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
agentNativePath,
|
|
4
|
+
isInBuilderFrame,
|
|
5
|
+
oauthRedirectUri,
|
|
6
|
+
} from "@agent-native/core/client";
|
|
3
7
|
import type { GoogleAuthStatus } from "@shared/api";
|
|
4
|
-
import { useEffect } from "react";
|
|
8
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
9
|
+
|
|
10
|
+
export interface DesktopAuthIssue {
|
|
11
|
+
error?: string;
|
|
12
|
+
message?: string;
|
|
13
|
+
code?: string;
|
|
14
|
+
accountId?: string;
|
|
15
|
+
existingOwner?: string;
|
|
16
|
+
attemptedOwner?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface DesktopAuthResult {
|
|
20
|
+
token?: string;
|
|
21
|
+
email?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface DesktopAuthStartOptions {
|
|
25
|
+
addAccount?: boolean;
|
|
26
|
+
previousAccountCount?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface DesktopAuthOptions {
|
|
30
|
+
onError?: (issue: DesktopAuthIssue) => void;
|
|
31
|
+
onSuccess?: (result: DesktopAuthResult) => void | Promise<void>;
|
|
32
|
+
timeoutMs?: number;
|
|
33
|
+
}
|
|
5
34
|
|
|
6
35
|
function bodyError(
|
|
7
36
|
body: any,
|
|
@@ -134,6 +163,178 @@ export function useGoogleAddAccountUrl(enabled = false) {
|
|
|
134
163
|
return query;
|
|
135
164
|
}
|
|
136
165
|
|
|
166
|
+
export function useGoogleDesktopAuth(options: DesktopAuthOptions = {}) {
|
|
167
|
+
const { onError, onSuccess, timeoutMs = 120_000 } = options;
|
|
168
|
+
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
169
|
+
const [isPending, setIsPending] = useState(false);
|
|
170
|
+
const isDesktopGoogleAuth = useMemo(() => {
|
|
171
|
+
if (typeof navigator === "undefined") return false;
|
|
172
|
+
return (
|
|
173
|
+
/AgentNativeDesktop/i.test(navigator.userAgent) && !isInBuilderFrame()
|
|
174
|
+
);
|
|
175
|
+
}, []);
|
|
176
|
+
|
|
177
|
+
const clearPoll = useCallback(() => {
|
|
178
|
+
if (pollRef.current) {
|
|
179
|
+
clearInterval(pollRef.current);
|
|
180
|
+
pollRef.current = null;
|
|
181
|
+
}
|
|
182
|
+
}, []);
|
|
183
|
+
|
|
184
|
+
useEffect(() => clearPoll, [clearPoll]);
|
|
185
|
+
|
|
186
|
+
const startDesktopGoogleAuth = useCallback(
|
|
187
|
+
(startOptions: DesktopAuthStartOptions = {}) => {
|
|
188
|
+
if (!isDesktopGoogleAuth || typeof window === "undefined") return false;
|
|
189
|
+
|
|
190
|
+
clearPoll();
|
|
191
|
+
setIsPending(true);
|
|
192
|
+
const flowId =
|
|
193
|
+
globalThis.crypto?.randomUUID?.() ||
|
|
194
|
+
Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
195
|
+
const redirectUri = oauthRedirectUri("/_agent-native/google/callback");
|
|
196
|
+
const params = new URLSearchParams({
|
|
197
|
+
redirect_uri: redirectUri,
|
|
198
|
+
desktop: "1",
|
|
199
|
+
flow_id: flowId,
|
|
200
|
+
});
|
|
201
|
+
let popup: Window | null = null;
|
|
202
|
+
const reportError = (issue: DesktopAuthIssue) => {
|
|
203
|
+
clearPoll();
|
|
204
|
+
setIsPending(false);
|
|
205
|
+
onError?.(issue);
|
|
206
|
+
};
|
|
207
|
+
const openAuthUrl = (url: string) => {
|
|
208
|
+
if (popup && !popup.closed) {
|
|
209
|
+
popup.location.href = url;
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
popup = window.open(url, "_blank");
|
|
213
|
+
return !!popup;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const startedAt = Date.now();
|
|
217
|
+
const finish = async (result: DesktopAuthResult = {}) => {
|
|
218
|
+
clearPoll();
|
|
219
|
+
setIsPending(false);
|
|
220
|
+
await onSuccess?.(result);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
if (startOptions.addAccount) {
|
|
224
|
+
popup = window.open("about:blank", "_blank");
|
|
225
|
+
void (async () => {
|
|
226
|
+
try {
|
|
227
|
+
const { url } = await fetchJson<{ url: string }>(
|
|
228
|
+
agentNativePath(
|
|
229
|
+
`/_agent-native/google/add-account/auth-url?${params.toString()}`,
|
|
230
|
+
),
|
|
231
|
+
{ credentials: "include" },
|
|
232
|
+
);
|
|
233
|
+
if (!openAuthUrl(url)) {
|
|
234
|
+
reportError({
|
|
235
|
+
code: "popup_blocked",
|
|
236
|
+
message:
|
|
237
|
+
"Calendar could not open Google sign-in. Allow popups and try again.",
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
} catch (err) {
|
|
241
|
+
popup?.close();
|
|
242
|
+
reportError({
|
|
243
|
+
code: "desktop_auth_start_failed",
|
|
244
|
+
message:
|
|
245
|
+
err instanceof Error
|
|
246
|
+
? err.message
|
|
247
|
+
: "Could not start Google sign-in.",
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
})();
|
|
251
|
+
} else {
|
|
252
|
+
params.set("redirect", "1");
|
|
253
|
+
const opened = openAuthUrl(
|
|
254
|
+
`${window.location.origin}${agentNativePath(
|
|
255
|
+
"/_agent-native/google/auth-url",
|
|
256
|
+
)}?${params.toString()}`,
|
|
257
|
+
);
|
|
258
|
+
if (!opened) {
|
|
259
|
+
reportError({
|
|
260
|
+
code: "popup_blocked",
|
|
261
|
+
message:
|
|
262
|
+
"Calendar could not open Google sign-in. Allow popups and try again.",
|
|
263
|
+
});
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
pollRef.current = setInterval(async () => {
|
|
269
|
+
try {
|
|
270
|
+
const exchangeRes = await fetch(
|
|
271
|
+
agentNativePath(
|
|
272
|
+
`/_agent-native/auth/desktop-exchange?flow_id=${flowId}`,
|
|
273
|
+
),
|
|
274
|
+
{ credentials: "include" },
|
|
275
|
+
);
|
|
276
|
+
const exchange = await exchangeRes.json();
|
|
277
|
+
if (exchange?.error) {
|
|
278
|
+
clearPoll();
|
|
279
|
+
setIsPending(false);
|
|
280
|
+
onError?.(exchange);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (exchange?.token) {
|
|
284
|
+
await fetch(
|
|
285
|
+
agentNativePath(
|
|
286
|
+
`/_agent-native/auth/session?_session=${exchange.token}`,
|
|
287
|
+
),
|
|
288
|
+
{ credentials: "include" },
|
|
289
|
+
);
|
|
290
|
+
await finish({ token: exchange.token, email: exchange.email });
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
} catch {
|
|
294
|
+
// Keep polling; the status endpoint below may still observe success.
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
const statusRes = await fetch(
|
|
299
|
+
agentNativePath("/_agent-native/google/status"),
|
|
300
|
+
{ credentials: "include" },
|
|
301
|
+
);
|
|
302
|
+
if (statusRes.ok) {
|
|
303
|
+
const status = (await statusRes.json()) as GoogleAuthStatus;
|
|
304
|
+
const connected = startOptions.addAccount
|
|
305
|
+
? (status.accounts?.length ?? 0) >
|
|
306
|
+
(startOptions.previousAccountCount ?? 0)
|
|
307
|
+
: status.connected;
|
|
308
|
+
if (connected) {
|
|
309
|
+
await finish();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
} catch {
|
|
314
|
+
// Keep polling until the timeout.
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (Date.now() - startedAt > timeoutMs) {
|
|
318
|
+
reportError({
|
|
319
|
+
code: "desktop_auth_timeout",
|
|
320
|
+
message:
|
|
321
|
+
"Google sign-in timed out. Finish sign-in in the browser or try again.",
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}, 1500);
|
|
325
|
+
|
|
326
|
+
return true;
|
|
327
|
+
},
|
|
328
|
+
[clearPoll, isDesktopGoogleAuth, onError, onSuccess, timeoutMs],
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
isDesktopGoogleAuth,
|
|
333
|
+
isGoogleDesktopAuthPending: isPending,
|
|
334
|
+
startDesktopGoogleAuth,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
137
338
|
export function useDisconnectGoogle() {
|
|
138
339
|
const queryClient = useQueryClient();
|
|
139
340
|
return useMutation({
|
|
@@ -107,16 +107,13 @@ export function allOtherDeclined(event: CalendarEvent): boolean {
|
|
|
107
107
|
|
|
108
108
|
/**
|
|
109
109
|
* Returns a hex color for a calendar event based on its meeting type.
|
|
110
|
-
* Respects user-set colors
|
|
110
|
+
* Respects user-set colors first.
|
|
111
111
|
* For local (non-Google) events without a color, returns CSS var.
|
|
112
112
|
*/
|
|
113
113
|
export function getEventAutoColor(event: CalendarEvent): string {
|
|
114
114
|
// User/Google-set color takes priority
|
|
115
115
|
if (event.color) return event.color;
|
|
116
116
|
|
|
117
|
-
// Overlay events keep their overlay-assigned color
|
|
118
|
-
if (event.overlayEmail) return EVENT_CATEGORY_COLORS.fallback;
|
|
119
|
-
|
|
120
117
|
// Local events without a color use the theme primary
|
|
121
118
|
if (event.source !== "google") return "hsl(var(--primary))";
|
|
122
119
|
|
|
@@ -27,6 +27,12 @@ export function getRsvpStatusLabel(status?: string) {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
export function canInlineRsvp(
|
|
31
|
+
event: Pick<CalendarEvent, "source" | "overlayEmail">,
|
|
32
|
+
): boolean {
|
|
33
|
+
return event.source === "google" && !event.overlayEmail;
|
|
34
|
+
}
|
|
35
|
+
|
|
30
36
|
export function RsvpStatusIcon({
|
|
31
37
|
status,
|
|
32
38
|
className,
|
|
@@ -462,16 +462,21 @@ export default function CalendarView() {
|
|
|
462
462
|
// small non-blocking spinner instead of hiding everything behind a skeleton.
|
|
463
463
|
const eventsRefreshing = isFetching && !eventsLoading;
|
|
464
464
|
|
|
465
|
-
// Apply overlay
|
|
465
|
+
// Apply overlay ownership markers and filter hidden calendars
|
|
466
466
|
const events = useMemo(() => {
|
|
467
|
-
const
|
|
467
|
+
const ownerMap = new Map(overlayPeople.map((p) => [p.email, p]));
|
|
468
468
|
const sourceEvents = draftEvent
|
|
469
469
|
? [...rawEvents.filter((e) => e.id !== draftEvent.id), draftEvent]
|
|
470
470
|
: rawEvents;
|
|
471
471
|
return sourceEvents
|
|
472
472
|
.map((e) => {
|
|
473
|
-
if (e.overlayEmail &&
|
|
474
|
-
|
|
473
|
+
if (e.overlayEmail && ownerMap.has(e.overlayEmail)) {
|
|
474
|
+
const owner = ownerMap.get(e.overlayEmail);
|
|
475
|
+
return {
|
|
476
|
+
...e,
|
|
477
|
+
ownerColor: owner?.color,
|
|
478
|
+
ownerName: owner?.name,
|
|
479
|
+
};
|
|
475
480
|
}
|
|
476
481
|
const tempId = quickEditTempIds[e.id];
|
|
477
482
|
return tempId && !e._tempId ? { ...e, _tempId: tempId } : e;
|
|
@@ -10,7 +10,7 @@ export default function NotFound() {
|
|
|
10
10
|
}, [location.pathname]);
|
|
11
11
|
|
|
12
12
|
return (
|
|
13
|
-
<div className="min-h-
|
|
13
|
+
<div className="flex min-h-full w-full items-center justify-center bg-background px-4 py-12">
|
|
14
14
|
<div className="text-center">
|
|
15
15
|
<h1 className="text-6xl font-bold text-foreground/10 mb-2">404</h1>
|
|
16
16
|
<p className="text-sm text-muted-foreground mb-6">
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import {
|
|
32
32
|
useGoogleAuthStatus,
|
|
33
33
|
useGoogleAuthUrl,
|
|
34
|
+
useGoogleDesktopAuth,
|
|
34
35
|
useDisconnectGoogle,
|
|
35
36
|
} from "@/hooks/use-google-auth";
|
|
36
37
|
import {
|
|
@@ -45,6 +46,15 @@ export default function Settings() {
|
|
|
45
46
|
const updateSettings = useUpdateSettings();
|
|
46
47
|
const googleStatus = useGoogleAuthStatus();
|
|
47
48
|
const disconnectGoogle = useDisconnectGoogle();
|
|
49
|
+
const {
|
|
50
|
+
isDesktopGoogleAuth,
|
|
51
|
+
isGoogleDesktopAuthPending,
|
|
52
|
+
startDesktopGoogleAuth,
|
|
53
|
+
} = useGoogleDesktopAuth({
|
|
54
|
+
onError: (issue) =>
|
|
55
|
+
toast.error(issue.message || issue.error || "Google sign-in failed"),
|
|
56
|
+
onSuccess: () => window.location.reload(),
|
|
57
|
+
});
|
|
48
58
|
const zoomStatus = useZoomStatus();
|
|
49
59
|
const connectZoom = useConnectZoom();
|
|
50
60
|
const disconnectZoom = useDisconnectZoom();
|
|
@@ -81,6 +91,12 @@ export default function Settings() {
|
|
|
81
91
|
}
|
|
82
92
|
|
|
83
93
|
function handleConnect() {
|
|
94
|
+
if (isDesktopGoogleAuth) {
|
|
95
|
+
startDesktopGoogleAuth({
|
|
96
|
+
previousAccountCount: googleStatus.data?.accounts?.length ?? 0,
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
84
100
|
setWantAuthUrl(true);
|
|
85
101
|
}
|
|
86
102
|
|
|
@@ -179,7 +195,15 @@ export default function Settings() {
|
|
|
179
195
|
Disconnect
|
|
180
196
|
</Button>
|
|
181
197
|
) : (
|
|
182
|
-
<Button
|
|
198
|
+
<Button
|
|
199
|
+
size="sm"
|
|
200
|
+
onClick={handleConnect}
|
|
201
|
+
disabled={
|
|
202
|
+
authUrl.isLoading ||
|
|
203
|
+
authUrl.isFetching ||
|
|
204
|
+
isGoogleDesktopAuthPending
|
|
205
|
+
}
|
|
206
|
+
>
|
|
183
207
|
<IconExternalLink className="mr-1.5 h-3.5 w-3.5" />
|
|
184
208
|
Connect
|
|
185
209
|
</Button>
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
getSession,
|
|
10
10
|
isElectron,
|
|
11
11
|
getAppUrl,
|
|
12
|
+
resolveGoogleSignInCredentials,
|
|
12
13
|
resolveOAuthRedirectUri,
|
|
13
14
|
encodeOAuthState,
|
|
14
15
|
decodeOAuthState,
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
oauthErrorPage,
|
|
20
21
|
setDesktopExchange,
|
|
21
22
|
setDesktopExchangeError,
|
|
23
|
+
safeReturnPath,
|
|
22
24
|
} from "@agent-native/core/server";
|
|
23
25
|
import {
|
|
24
26
|
getAuthUrl,
|
|
@@ -29,6 +31,85 @@ import {
|
|
|
29
31
|
import { OAuthAccountOwnedByOtherUserError } from "@agent-native/core/oauth-tokens";
|
|
30
32
|
|
|
31
33
|
const OAUTH_STATE_APP_ID = process.env.APP_NAME || "calendar";
|
|
34
|
+
const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
35
|
+
const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
36
|
+
const GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo";
|
|
37
|
+
const GOOGLE_IDENTITY_SCOPES = [
|
|
38
|
+
"openid",
|
|
39
|
+
"https://www.googleapis.com/auth/userinfo.email",
|
|
40
|
+
"https://www.googleapis.com/auth/userinfo.profile",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
function resolveCalendarOAuthCredentials() {
|
|
44
|
+
const clientId = process.env.GOOGLE_CLIENT_ID;
|
|
45
|
+
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
|
|
46
|
+
if (!clientId || !clientSecret) return null;
|
|
47
|
+
return { clientId, clientSecret };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isCalendarConnectRequest(
|
|
51
|
+
query: Record<string, any>,
|
|
52
|
+
owner: string | undefined,
|
|
53
|
+
) {
|
|
54
|
+
return (
|
|
55
|
+
!!owner ||
|
|
56
|
+
query.calendar === "1" ||
|
|
57
|
+
query.calendar === "true" ||
|
|
58
|
+
query.product === "calendar"
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function exchangeIdentityCode(
|
|
63
|
+
code: string,
|
|
64
|
+
redirectUri: string,
|
|
65
|
+
): Promise<{
|
|
66
|
+
email: string;
|
|
67
|
+
id?: string;
|
|
68
|
+
name?: string;
|
|
69
|
+
}> {
|
|
70
|
+
const credentials = resolveGoogleSignInCredentials();
|
|
71
|
+
if (!credentials) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
"Google sign-in credentials are not configured. Set GOOGLE_SIGN_IN_CLIENT_ID and GOOGLE_SIGN_IN_CLIENT_SECRET.",
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const tokenRes = await fetch(GOOGLE_TOKEN_URL, {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
80
|
+
body: new URLSearchParams({
|
|
81
|
+
code,
|
|
82
|
+
client_id: credentials.clientId,
|
|
83
|
+
client_secret: credentials.clientSecret,
|
|
84
|
+
redirect_uri: redirectUri,
|
|
85
|
+
grant_type: "authorization_code",
|
|
86
|
+
}),
|
|
87
|
+
});
|
|
88
|
+
const tokens = await tokenRes.json();
|
|
89
|
+
if (!tokenRes.ok) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
tokens.error_description || tokens.error || "Token exchange failed",
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const userRes = await fetch(GOOGLE_USERINFO_URL, {
|
|
96
|
+
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
|
97
|
+
});
|
|
98
|
+
const user = await userRes.json();
|
|
99
|
+
const email = user.email as string | undefined;
|
|
100
|
+
if (!email) throw new Error("Could not get email from Google");
|
|
101
|
+
if (user.verified_email !== true) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
"Google account email is not verified. Please verify your email with Google and try again.",
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
email,
|
|
109
|
+
id: typeof user.id === "string" ? user.id : undefined,
|
|
110
|
+
name: typeof user.name === "string" ? user.name : undefined,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
32
113
|
|
|
33
114
|
function oauthRedirectResponse(url: string) {
|
|
34
115
|
// h3 v2 sendRedirect returns an object the framework shim can stringify as
|
|
@@ -93,14 +174,6 @@ function googleOAuthErrorResponse(
|
|
|
93
174
|
}
|
|
94
175
|
|
|
95
176
|
export const getGoogleAuthUrl = defineEventHandler(async (event: H3Event) => {
|
|
96
|
-
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
|
|
97
|
-
setResponseStatus(event, 422);
|
|
98
|
-
return {
|
|
99
|
-
error: "missing_credentials",
|
|
100
|
-
message:
|
|
101
|
-
"Google OAuth credentials are not configured. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET.",
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
177
|
try {
|
|
105
178
|
const q = getQuery(event);
|
|
106
179
|
const redirectUri = resolveOAuthRedirectUri(event);
|
|
@@ -116,17 +189,55 @@ export const getGoogleAuthUrl = defineEventHandler(async (event: H3Event) => {
|
|
|
116
189
|
const desktop =
|
|
117
190
|
isElectron(event) || q.desktop === "1" || q.desktop === "true";
|
|
118
191
|
const flowId = desktop ? (q.flow_id as string) || undefined : undefined;
|
|
192
|
+
const calendarConnect = isCalendarConnectRequest(q, owner);
|
|
193
|
+
const credentials = calendarConnect
|
|
194
|
+
? resolveCalendarOAuthCredentials()
|
|
195
|
+
: resolveGoogleSignInCredentials();
|
|
196
|
+
|
|
197
|
+
if (!credentials) {
|
|
198
|
+
setResponseStatus(event, 422);
|
|
199
|
+
return {
|
|
200
|
+
error: "missing_credentials",
|
|
201
|
+
message: calendarConnect
|
|
202
|
+
? "Google Calendar OAuth credentials are not configured. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET."
|
|
203
|
+
: "Google sign-in credentials are not configured. Set GOOGLE_SIGN_IN_CLIENT_ID and GOOGLE_SIGN_IN_CLIENT_SECRET.",
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (calendarConnect && !owner) {
|
|
208
|
+
setResponseStatus(event, 401);
|
|
209
|
+
return {
|
|
210
|
+
error: "not_authenticated",
|
|
211
|
+
message: "Sign in before connecting Google Calendar.",
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const requestedReturn =
|
|
216
|
+
typeof q.return === "string" ? safeReturnPath(q.return) : "/";
|
|
217
|
+
const returnUrl = requestedReturn !== "/" ? requestedReturn : undefined;
|
|
119
218
|
// Use the named-arg overload — the positional form previously passed
|
|
120
219
|
// `flowId` in the `returnUrl` slot, breaking desktop completion.
|
|
121
220
|
const state = encodeOAuthState({
|
|
122
221
|
redirectUri,
|
|
123
222
|
owner,
|
|
124
223
|
desktop,
|
|
125
|
-
addAccount:
|
|
224
|
+
addAccount: calendarConnect,
|
|
126
225
|
app: OAUTH_STATE_APP_ID,
|
|
226
|
+
returnUrl,
|
|
127
227
|
flowId,
|
|
128
228
|
});
|
|
129
|
-
|
|
229
|
+
|
|
230
|
+
const url = calendarConnect
|
|
231
|
+
? getAuthUrl(undefined, redirectUri, state)
|
|
232
|
+
: `${GOOGLE_AUTH_URL}?${new URLSearchParams({
|
|
233
|
+
client_id: credentials.clientId,
|
|
234
|
+
redirect_uri: redirectUri,
|
|
235
|
+
response_type: "code",
|
|
236
|
+
scope: GOOGLE_IDENTITY_SCOPES.join(" "),
|
|
237
|
+
access_type: "online",
|
|
238
|
+
prompt: "select_account",
|
|
239
|
+
state,
|
|
240
|
+
})}`;
|
|
130
241
|
if (q.redirect === "1") {
|
|
131
242
|
return oauthRedirectResponse(url);
|
|
132
243
|
}
|
|
@@ -172,7 +283,7 @@ export const handleGoogleCallback = defineEventHandler(
|
|
|
172
283
|
return { error: "Missing authorization code" };
|
|
173
284
|
}
|
|
174
285
|
|
|
175
|
-
const { redirectUri, owner: stateOwner, addAccount } = state;
|
|
286
|
+
const { redirectUri, owner: stateOwner, addAccount, returnUrl } = state;
|
|
176
287
|
|
|
177
288
|
// 1. Resolve owner (needs session context, before exchangeCode)
|
|
178
289
|
const { owner, hasProductionSession } = await resolveOAuthOwner(
|
|
@@ -180,7 +291,36 @@ export const handleGoogleCallback = defineEventHandler(
|
|
|
180
291
|
stateOwner,
|
|
181
292
|
);
|
|
182
293
|
|
|
183
|
-
|
|
294
|
+
if (!addAccount) {
|
|
295
|
+
const identity = await exchangeIdentityCode(code, redirectUri);
|
|
296
|
+
const { sessionToken } = await createOAuthSession(
|
|
297
|
+
event,
|
|
298
|
+
identity.email,
|
|
299
|
+
{
|
|
300
|
+
hasProductionSession,
|
|
301
|
+
desktop,
|
|
302
|
+
trackSignup: {
|
|
303
|
+
authProvider: "google",
|
|
304
|
+
authUserId: identity.id,
|
|
305
|
+
name: identity.name,
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
if (flowId && sessionToken) {
|
|
311
|
+
setDesktopExchange(flowId, sessionToken, identity.email);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return oauthCallbackResponse(event, identity.email, {
|
|
315
|
+
sessionToken,
|
|
316
|
+
desktop,
|
|
317
|
+
returnUrl,
|
|
318
|
+
flowId,
|
|
319
|
+
appName: "Calendar",
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// 2. Exchange code with Google (template-specific Calendar connect)
|
|
184
324
|
const email = await exchangeCode(code, undefined, redirectUri, owner);
|
|
185
325
|
|
|
186
326
|
// 3. Create session token (after we have the email)
|
|
@@ -46,21 +46,59 @@ interface GoogleTokens {
|
|
|
46
46
|
photoUrl?: string;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
interface GoogleOAuthCredentials {
|
|
50
|
+
clientId: string;
|
|
51
|
+
clientSecret: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
49
54
|
function optionalString(value: unknown): string | undefined {
|
|
50
55
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
function getOAuth2Credentials() {
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
if (!clientId || !clientSecret) {
|
|
59
|
+
const credentials = resolveGoogleProviderCredentialCandidates()[0];
|
|
60
|
+
if (!credentials) {
|
|
57
61
|
throw new Error(
|
|
58
62
|
"GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET must be set in environment",
|
|
59
63
|
);
|
|
60
64
|
}
|
|
65
|
+
return credentials;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getOAuth2RefreshCredentials() {
|
|
69
|
+
const candidates = resolveGoogleProviderCredentialCandidates();
|
|
70
|
+
if (!candidates.length) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
"GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET must be set in environment",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return candidates;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readCredentialPair(
|
|
79
|
+
clientIdKey: string,
|
|
80
|
+
clientSecretKey: string,
|
|
81
|
+
): GoogleOAuthCredentials | null {
|
|
82
|
+
const clientId = process.env[clientIdKey];
|
|
83
|
+
const clientSecret = process.env[clientSecretKey];
|
|
84
|
+
if (!clientId || !clientSecret) return null;
|
|
61
85
|
return { clientId, clientSecret };
|
|
62
86
|
}
|
|
63
87
|
|
|
88
|
+
function resolveGoogleProviderCredentialCandidates(): GoogleOAuthCredentials[] {
|
|
89
|
+
const primary = readCredentialPair(
|
|
90
|
+
"GOOGLE_CLIENT_ID",
|
|
91
|
+
"GOOGLE_CLIENT_SECRET",
|
|
92
|
+
);
|
|
93
|
+
const legacy = readCredentialPair(
|
|
94
|
+
"GOOGLE_LEGACY_CLIENT_ID",
|
|
95
|
+
"GOOGLE_LEGACY_CLIENT_SECRET",
|
|
96
|
+
);
|
|
97
|
+
if (!primary) return legacy ? [legacy] : [];
|
|
98
|
+
if (!legacy || legacy.clientId === primary.clientId) return [primary];
|
|
99
|
+
return [primary, legacy];
|
|
100
|
+
}
|
|
101
|
+
|
|
64
102
|
/**
|
|
65
103
|
* Permanent OAuth refresh failures Google can return. When we hit one of
|
|
66
104
|
* these, the refresh_token is dead — keeping the row around makes
|
|
@@ -316,17 +354,27 @@ async function getValidAccessToken(
|
|
|
316
354
|
);
|
|
317
355
|
}
|
|
318
356
|
try {
|
|
319
|
-
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
357
|
+
let lastRefreshError: any;
|
|
358
|
+
for (const { clientId, clientSecret } of getOAuth2RefreshCredentials()) {
|
|
359
|
+
try {
|
|
360
|
+
const oauth2 = createOAuth2Client(clientId, clientSecret, "");
|
|
361
|
+
const newTokens = await oauth2.refreshToken(tokens.refresh_token);
|
|
362
|
+
const merged = { ...tokens, ...newTokens };
|
|
363
|
+
await saveOAuthTokens(
|
|
364
|
+
"google",
|
|
365
|
+
accountId,
|
|
366
|
+
merged as unknown as Record<string, unknown>,
|
|
367
|
+
owner ?? accountId,
|
|
368
|
+
);
|
|
369
|
+
return merged.access_token;
|
|
370
|
+
} catch (err: any) {
|
|
371
|
+
lastRefreshError = err;
|
|
372
|
+
if (!isPermanentRefreshError(err?.message || "")) {
|
|
373
|
+
throw err;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
throw lastRefreshError;
|
|
330
378
|
} catch (err: any) {
|
|
331
379
|
if (isPermanentRefreshError(err?.message || "")) {
|
|
332
380
|
// Drop the dead row so isOAuthConnected returns false and the UI
|
|
@@ -860,6 +908,7 @@ export async function listOverlayEvents(
|
|
|
860
908
|
eventType: event.eventType || "default",
|
|
861
909
|
accountEmail: undefined,
|
|
862
910
|
overlayEmail,
|
|
911
|
+
...mapColor(event),
|
|
863
912
|
attendees: mapAttendees(event),
|
|
864
913
|
organizer: mapOrganizer(event),
|
|
865
914
|
createdAt: event.created || new Date().toISOString(),
|