feat(gsc): support multiple Google accounts per user (#373)
This commit is contained in:
parent
f2c41b1db1
commit
dae0067233
13
drizzle-pg/0008_yummy_annihilus.sql
Normal file
13
drizzle-pg/0008_yummy_annihilus.sql
Normal file
@ -0,0 +1,13 @@
|
||||
ALTER TABLE "gsc_connections" ADD COLUMN "gsc_account_id" text;
|
||||
--> statement-breakpoint
|
||||
UPDATE gsc_connections SET gsc_account_id = (
|
||||
SELECT a.account_id FROM account a
|
||||
WHERE a.user_id = gsc_connections.connected_by_user_id
|
||||
AND a.provider_id = 'google-search-console'
|
||||
)
|
||||
WHERE gsc_account_id IS NULL
|
||||
AND (
|
||||
SELECT count(*) FROM account a2
|
||||
WHERE a2.user_id = gsc_connections.connected_by_user_id
|
||||
AND a2.provider_id = 'google-search-console'
|
||||
) = 1;
|
||||
3410
drizzle-pg/meta/0008_snapshot.json
Normal file
3410
drizzle-pg/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -57,6 +57,13 @@
|
||||
"when": 1783306049167,
|
||||
"tag": "0007_same_marvel_zombies",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1783630716543,
|
||||
"tag": "0008_yummy_annihilus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
13
drizzle/0031_furry_monster_badoon.sql
Normal file
13
drizzle/0031_furry_monster_badoon.sql
Normal file
@ -0,0 +1,13 @@
|
||||
ALTER TABLE `gsc_connections` ADD `gsc_account_id` text;
|
||||
--> statement-breakpoint
|
||||
UPDATE gsc_connections SET gsc_account_id = (
|
||||
SELECT a.account_id FROM account a
|
||||
WHERE a.user_id = gsc_connections.connected_by_user_id
|
||||
AND a.provider_id = 'google-search-console'
|
||||
)
|
||||
WHERE gsc_account_id IS NULL
|
||||
AND (
|
||||
SELECT count(*) FROM account a2
|
||||
WHERE a2.user_id = gsc_connections.connected_by_user_id
|
||||
AND a2.provider_id = 'google-search-console'
|
||||
) = 1;
|
||||
3093
drizzle/meta/0031_snapshot.json
Normal file
3093
drizzle/meta/0031_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -218,6 +218,13 @@
|
||||
"when": 1783306047190,
|
||||
"tag": "0030_legal_reaper",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 31,
|
||||
"version": "6",
|
||||
"when": 1783630714621,
|
||||
"tag": "0031_furry_monster_badoon",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -6,7 +6,10 @@ import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
|
||||
import { SelfHostedSetupWarning } from "@/client/features/gsc/SelfHostedSetupWarning";
|
||||
import { SitePicker } from "@/client/features/gsc/SitePicker";
|
||||
import {
|
||||
SitePicker,
|
||||
type GscSiteSelection,
|
||||
} from "@/client/features/gsc/SitePicker";
|
||||
import { startGscLink } from "@/client/features/gsc/startGscLink";
|
||||
import {
|
||||
disconnectGsc,
|
||||
@ -25,7 +28,9 @@ export function SearchConsoleConnectionCard({
|
||||
const hosted = isHostedClientAuthMode();
|
||||
const queryClient = useQueryClient();
|
||||
const [picking, setPicking] = React.useState(false);
|
||||
const [selectedSiteUrl, setSelectedSiteUrl] = React.useState<string>("");
|
||||
const [selection, setSelection] = React.useState<GscSiteSelection | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const connectionKey = ["gscConnection", projectId];
|
||||
const connectionQuery = useQuery({
|
||||
@ -43,7 +48,13 @@ export function SearchConsoleConnectionCard({
|
||||
queryFn: () => listGscSites({ data: { projectId } }),
|
||||
enabled: Boolean(showPicker && !selfHostedNeedsSetup),
|
||||
});
|
||||
const requiresReconnect = Boolean(sitesQuery.data?.requiresReconnect);
|
||||
const accounts = React.useMemo(
|
||||
() => sitesQuery.data?.accounts ?? [],
|
||||
[sitesQuery.data?.accounts],
|
||||
);
|
||||
const requiresReconnect = accounts.some(
|
||||
(account) => account.requiresReconnect,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!requiresReconnect) return;
|
||||
@ -54,9 +65,23 @@ export function SearchConsoleConnectionCard({
|
||||
void queryClient.invalidateQueries({ queryKey: GRANT_STATUS_KEY });
|
||||
}, [requiresReconnect, queryClient, projectId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selection) return;
|
||||
for (const account of accounts) {
|
||||
const selectedSite = account.sites.find((site) => site.isSelected);
|
||||
if (selectedSite) {
|
||||
setSelection({
|
||||
accountId: account.accountId,
|
||||
siteUrl: selectedSite.siteUrl,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [accounts, selection]);
|
||||
|
||||
const setSiteMutation = useMutation({
|
||||
mutationFn: (siteUrl: string) =>
|
||||
setGscSite({ data: { projectId, siteUrl } }),
|
||||
mutationFn: (selected: GscSiteSelection) =>
|
||||
setGscSite({ data: { projectId, ...selected } }),
|
||||
onSuccess: () => {
|
||||
captureClientEvent("gsc:property_select");
|
||||
toast.success("Search Console connected");
|
||||
@ -80,6 +105,7 @@ export function SearchConsoleConnectionCard({
|
||||
onSuccess: () => {
|
||||
toast.success("Search Console disconnected");
|
||||
setPicking(false);
|
||||
setSelection(null);
|
||||
void queryClient.invalidateQueries({ queryKey: connectionKey });
|
||||
// Disconnect can drop the account-level grant server-side; keep the
|
||||
// shared grant-status cache (onboarding step + re-engagement nudge) honest.
|
||||
@ -120,7 +146,7 @@ export function SearchConsoleConnectionCard({
|
||||
siteUrl={connection?.siteUrl ?? ""}
|
||||
connectedByEmail={connection?.connectedByEmail ?? null}
|
||||
onChange={() => {
|
||||
setSelectedSiteUrl(connection?.siteUrl ?? "");
|
||||
setSelection(null);
|
||||
setPicking(true);
|
||||
}}
|
||||
onDisconnect={() => disconnectMutation.mutate()}
|
||||
@ -129,14 +155,13 @@ export function SearchConsoleConnectionCard({
|
||||
) : showPicker ? (
|
||||
<SitePicker
|
||||
loading={sitesQuery.isLoading}
|
||||
error={sitesQuery.isError || requiresReconnect}
|
||||
sites={sitesQuery.data?.sites ?? []}
|
||||
selectedSiteUrl={selectedSiteUrl}
|
||||
onSelect={setSelectedSiteUrl}
|
||||
onSave={() =>
|
||||
selectedSiteUrl && setSiteMutation.mutate(selectedSiteUrl)
|
||||
}
|
||||
error={sitesQuery.isError}
|
||||
accounts={accounts}
|
||||
selection={selection}
|
||||
onSelect={setSelection}
|
||||
onSave={() => selection && setSiteMutation.mutate(selection)}
|
||||
saving={setSiteMutation.isPending}
|
||||
onRetry={() => void sitesQuery.refetch()}
|
||||
onReconnect={handleConnect}
|
||||
secondaryAction={
|
||||
connected
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
|
||||
import { startGscLink } from "@/client/features/gsc/startGscLink";
|
||||
|
||||
type SiteOption = {
|
||||
siteUrl: string;
|
||||
@ -7,6 +8,18 @@ type SiteOption = {
|
||||
isSelected: boolean;
|
||||
};
|
||||
|
||||
type AccountOption = {
|
||||
accountId: string;
|
||||
email: string | null;
|
||||
requiresReconnect: boolean;
|
||||
sites: SiteOption[];
|
||||
};
|
||||
|
||||
export type GscSiteSelection = {
|
||||
accountId: string;
|
||||
siteUrl: string;
|
||||
};
|
||||
|
||||
type SecondaryAction = {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
@ -15,28 +28,30 @@ type SecondaryAction = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Verified-property selector for a connected Google account. Shared by the
|
||||
* Verified-property selector for connected Google accounts. Shared by the
|
||||
* Integrations card and the onboarding step. `secondaryAction` is optional —
|
||||
* omit it where there's nothing to cancel/disconnect (e.g. onboarding).
|
||||
*/
|
||||
export function SitePicker({
|
||||
loading,
|
||||
error,
|
||||
sites,
|
||||
selectedSiteUrl,
|
||||
accounts,
|
||||
selection,
|
||||
onSelect,
|
||||
onSave,
|
||||
saving,
|
||||
onRetry,
|
||||
onReconnect,
|
||||
secondaryAction,
|
||||
}: {
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
sites: SiteOption[];
|
||||
selectedSiteUrl: string;
|
||||
onSelect: (siteUrl: string) => void;
|
||||
accounts: AccountOption[];
|
||||
selection: GscSiteSelection | null;
|
||||
onSelect: (selection: GscSiteSelection) => void;
|
||||
onSave: () => void;
|
||||
saving: boolean;
|
||||
onRetry: () => void;
|
||||
onReconnect: () => void;
|
||||
secondaryAction?: SecondaryAction;
|
||||
}) {
|
||||
@ -49,6 +64,26 @@ export function SitePicker({
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-error">
|
||||
Couldn't load your Search Console properties.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={onRetry}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const allAccountsRequireReconnect =
|
||||
accounts.length > 0 &&
|
||||
accounts.every((account) => account.requiresReconnect);
|
||||
if (allAccountsRequireReconnect) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-error">
|
||||
@ -65,6 +100,24 @@ export function SitePicker({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const healthyAccounts = accounts.filter(
|
||||
(account) => !account.requiresReconnect,
|
||||
);
|
||||
const options = healthyAccounts.flatMap((account) =>
|
||||
account.sites.map((site) => ({
|
||||
accountId: account.accountId,
|
||||
siteUrl: site.siteUrl,
|
||||
})),
|
||||
);
|
||||
const selectedIndex = selection
|
||||
? options.findIndex(
|
||||
(option) =>
|
||||
option.accountId === selection.accountId &&
|
||||
option.siteUrl === selection.siteUrl,
|
||||
)
|
||||
: -1;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<label className="block">
|
||||
@ -73,33 +126,61 @@ export function SitePicker({
|
||||
</span>
|
||||
<select
|
||||
className="select select-bordered w-full max-w-md"
|
||||
value={selectedSiteUrl}
|
||||
onChange={(e) => onSelect(e.target.value)}
|
||||
value={selectedIndex >= 0 ? String(selectedIndex) : ""}
|
||||
onChange={(event) => {
|
||||
const option = options[Number(event.target.value)];
|
||||
if (option) onSelect(option);
|
||||
}}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select a property…
|
||||
</option>
|
||||
{sites.map((site) => (
|
||||
<option
|
||||
key={site.siteUrl}
|
||||
value={site.siteUrl}
|
||||
disabled={!site.selectable}
|
||||
{healthyAccounts.map((account) => (
|
||||
<optgroup
|
||||
key={account.accountId}
|
||||
label={account.email ?? "Google account"}
|
||||
>
|
||||
{site.siteUrl}
|
||||
{site.selectable ? "" : " (no access)"}
|
||||
</option>
|
||||
{account.sites.length === 0 ? (
|
||||
<option disabled>No properties</option>
|
||||
) : (
|
||||
account.sites.map((site) => {
|
||||
const index = options.findIndex(
|
||||
(option) =>
|
||||
option.accountId === account.accountId &&
|
||||
option.siteUrl === site.siteUrl,
|
||||
);
|
||||
return (
|
||||
<option
|
||||
key={site.siteUrl}
|
||||
value={index}
|
||||
disabled={!site.selectable}
|
||||
>
|
||||
{site.siteUrl}
|
||||
{site.selectable ? "" : " (no access)"}
|
||||
</option>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={onSave}
|
||||
disabled={!selectedSiteUrl || saving}
|
||||
disabled={selectedIndex < 0 || saving}
|
||||
>
|
||||
{saving ? "Saving…" : "Save property"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => void startGscLink(window.location.href)}
|
||||
>
|
||||
Connect another Google account
|
||||
</button>
|
||||
{secondaryAction ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -4,7 +4,10 @@ import { Check } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
|
||||
import { SelfHostedSetupWarning } from "@/client/features/gsc/SelfHostedSetupWarning";
|
||||
import { SitePicker } from "@/client/features/gsc/SitePicker";
|
||||
import {
|
||||
SitePicker,
|
||||
type GscSiteSelection,
|
||||
} from "@/client/features/gsc/SitePicker";
|
||||
import { startGscLink } from "@/client/features/gsc/startGscLink";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
@ -15,6 +18,8 @@ import {
|
||||
} from "@/serverFunctions/gsc";
|
||||
import { getProjects } from "@/serverFunctions/projects";
|
||||
|
||||
const GRANT_STATUS_KEY = ["gscGrantStatus"];
|
||||
|
||||
/**
|
||||
* Onboarding step for connecting Google Search Console: link the account-level
|
||||
* OAuth grant, then bind a verified property to the user's first project —
|
||||
@ -47,7 +52,9 @@ export function SearchConsoleOnboardingStep() {
|
||||
/** Connect + pick-a-property flow, scoped to a known project. */
|
||||
function GscConnect({ projectId }: { projectId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedSiteUrl, setSelectedSiteUrl] = React.useState("");
|
||||
const [selection, setSelection] = React.useState<GscSiteSelection | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const connectionKey = ["gscConnection", projectId];
|
||||
const connectionQuery = useQuery({
|
||||
@ -65,7 +72,13 @@ function GscConnect({ projectId }: { projectId: string }) {
|
||||
queryFn: () => listGscSites({ data: { projectId } }),
|
||||
enabled: hasGrant && !connected && !needsSetup,
|
||||
});
|
||||
const requiresReconnect = Boolean(sitesQuery.data?.requiresReconnect);
|
||||
const accounts = React.useMemo(
|
||||
() => sitesQuery.data?.accounts ?? [],
|
||||
[sitesQuery.data?.accounts],
|
||||
);
|
||||
const requiresReconnect = accounts.some(
|
||||
(account) => account.requiresReconnect,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!requiresReconnect) return;
|
||||
@ -73,11 +86,12 @@ function GscConnect({ projectId }: { projectId: string }) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["gscConnection", projectId],
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: GRANT_STATUS_KEY });
|
||||
}, [requiresReconnect, queryClient, projectId]);
|
||||
|
||||
const setSiteMutation = useMutation({
|
||||
mutationFn: (siteUrl: string) =>
|
||||
setGscSite({ data: { projectId, siteUrl } }),
|
||||
mutationFn: (selected: GscSiteSelection) =>
|
||||
setGscSite({ data: { projectId, ...selected } }),
|
||||
onSuccess: () => {
|
||||
captureClientEvent("gsc:property_select");
|
||||
void queryClient.invalidateQueries({ queryKey: connectionKey });
|
||||
@ -113,14 +127,13 @@ function GscConnect({ projectId }: { projectId: string }) {
|
||||
return (
|
||||
<SitePicker
|
||||
loading={sitesQuery.isLoading}
|
||||
error={sitesQuery.isError || requiresReconnect}
|
||||
sites={sitesQuery.data?.sites ?? []}
|
||||
selectedSiteUrl={selectedSiteUrl}
|
||||
onSelect={setSelectedSiteUrl}
|
||||
onSave={() =>
|
||||
selectedSiteUrl && setSiteMutation.mutate(selectedSiteUrl)
|
||||
}
|
||||
error={sitesQuery.isError}
|
||||
accounts={accounts}
|
||||
selection={selection}
|
||||
onSelect={setSelection}
|
||||
onSave={() => selection && setSiteMutation.mutate(selection)}
|
||||
saving={setSiteMutation.isPending}
|
||||
onRetry={() => void sitesQuery.refetch()}
|
||||
onReconnect={handleConnect}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -22,6 +22,7 @@ export const gscConnections = sqliteTable(
|
||||
siteUrl: text("site_url").notNull(),
|
||||
// Whose google-search-console grant getAccessToken should use.
|
||||
connectedByUserId: text("connected_by_user_id").notNull(),
|
||||
gscAccountId: text("gsc_account_id"),
|
||||
connectedAccountEmail: text("connected_account_email"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
|
||||
@ -25,6 +25,7 @@ export const gscConnections = pgTable(
|
||||
siteUrl: text("site_url").notNull(),
|
||||
// Whose google-search-console grant getAccessToken should use.
|
||||
connectedByUserId: text("connected_by_user_id").notNull(),
|
||||
gscAccountId: text("gsc_account_id"),
|
||||
connectedAccountEmail: text("connected_account_email"),
|
||||
createdAt: text("created_at").notNull().default(isoNow),
|
||||
updatedAt: text("updated_at").notNull().default(isoNow),
|
||||
|
||||
@ -44,7 +44,7 @@ export function createBaseAuthConfig() {
|
||||
"https://accounts.google.com/.well-known/openid-configuration",
|
||||
scopes: [...GSC_OAUTH_SCOPES],
|
||||
accessType: "offline", // request a refresh token
|
||||
prompt: "consent", // force refresh-token issuance on re-consent
|
||||
prompt: "select_account consent",
|
||||
pkce: true,
|
||||
},
|
||||
],
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { gscConnections } from "@/db/schema";
|
||||
|
||||
@ -20,6 +20,7 @@ async function upsert(input: {
|
||||
organizationId: string;
|
||||
siteUrl: string;
|
||||
connectedByUserId: string;
|
||||
gscAccountId: string;
|
||||
connectedAccountEmail: string | null;
|
||||
}): Promise<GscConnection> {
|
||||
const [row] = await db
|
||||
@ -31,7 +32,8 @@ async function upsert(input: {
|
||||
siteUrl: input.siteUrl,
|
||||
organizationId: input.organizationId,
|
||||
connectedByUserId: input.connectedByUserId,
|
||||
connectedAccountEmail: input.connectedAccountEmail,
|
||||
gscAccountId: input.gscAccountId,
|
||||
connectedAccountEmail: sql`coalesce(${input.connectedAccountEmail}, ${gscConnections.connectedAccountEmail})`,
|
||||
updatedAt: sql`(current_timestamp)`,
|
||||
},
|
||||
})
|
||||
@ -48,12 +50,19 @@ async function deleteByProjectId(projectId: string): Promise<void> {
|
||||
.where(eq(gscConnections.projectId, projectId));
|
||||
}
|
||||
|
||||
/** Whether this user is still the connector for any project's GSC property. */
|
||||
async function existsForConnector(userId: string): Promise<boolean> {
|
||||
async function existsForConnectorAccount(
|
||||
userId: string,
|
||||
gscAccountId: string,
|
||||
): Promise<boolean> {
|
||||
const rows = await db
|
||||
.select({ id: gscConnections.id })
|
||||
.from(gscConnections)
|
||||
.where(eq(gscConnections.connectedByUserId, userId))
|
||||
.where(
|
||||
and(
|
||||
eq(gscConnections.connectedByUserId, userId),
|
||||
eq(gscConnections.gscAccountId, gscAccountId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
}
|
||||
@ -62,5 +71,5 @@ export const GscConnectionRepository = {
|
||||
getByProjectId,
|
||||
upsert,
|
||||
deleteByProjectId,
|
||||
existsForConnector,
|
||||
existsForConnectorAccount,
|
||||
};
|
||||
|
||||
@ -173,6 +173,7 @@ async function upsertGrant(input: {
|
||||
ctx.options.account?.encryptOAuthTokens
|
||||
? symmetricEncrypt({ key: ctx.secretConfig, data: value })
|
||||
: value;
|
||||
const googleAccountId = getGoogleAccountId(input.tokens);
|
||||
|
||||
const existing = await db
|
||||
.select({ id: account.id, refreshToken: account.refreshToken })
|
||||
@ -181,12 +182,13 @@ async function upsertGrant(input: {
|
||||
and(
|
||||
eq(account.userId, input.user.userId),
|
||||
eq(account.providerId, GSC_OAUTH_PROVIDER_ID),
|
||||
eq(account.accountId, googleAccountId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
const accountValues = {
|
||||
accountId: getGoogleAccountId(input.tokens),
|
||||
accountId: googleAccountId,
|
||||
providerId: GSC_OAUTH_PROVIDER_ID,
|
||||
userId: input.user.userId,
|
||||
accessToken: await encrypt(input.tokens.access_token),
|
||||
@ -274,7 +276,7 @@ export async function createSelfHostedGscAuthorizationUrl(input: {
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("scope", GSC_OAUTH_SCOPES.join(" "));
|
||||
url.searchParams.set("access_type", "offline");
|
||||
url.searchParams.set("prompt", "consent");
|
||||
url.searchParams.set("prompt", "select_account consent");
|
||||
url.searchParams.set("state", state);
|
||||
|
||||
return url.toString();
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
/* eslint-disable max-lines */
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
@ -18,22 +20,58 @@ const mocks = vi.hoisted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
const state: { selectRows: Array<{ id: string; accountId: string }> } = {
|
||||
selectRows: [],
|
||||
};
|
||||
type GscClientOptions = { userId: string; gscAccountId?: string };
|
||||
type GscSite = { siteUrl: string; permissionLevel: string };
|
||||
const listSites = vi.fn<(opts: GscClientOptions) => Promise<GscSite[]>>();
|
||||
const getUserInfoEmail =
|
||||
vi.fn<(opts: GscClientOptions) => Promise<string | null>>();
|
||||
const querySearchAnalytics =
|
||||
vi.fn<(opts: GscClientOptions) => Promise<never[]>>();
|
||||
const deleteWhere = vi
|
||||
.fn<(condition: SQL) => Promise<void>>()
|
||||
.mockResolvedValue(undefined);
|
||||
const dbSelect = vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => {
|
||||
const rows = state.selectRows;
|
||||
return Object.assign(Promise.resolve(rows), {
|
||||
limit: vi.fn().mockResolvedValue(rows),
|
||||
});
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
return {
|
||||
listSites: vi.fn(),
|
||||
state,
|
||||
dbSelect,
|
||||
deleteWhere,
|
||||
dbDelete: vi.fn(() => ({ where: deleteWhere })),
|
||||
listSites,
|
||||
getUserInfoEmail,
|
||||
querySearchAnalytics,
|
||||
createGscClient: vi.fn((opts: GscClientOptions) => ({
|
||||
listSites: () => listSites(opts),
|
||||
getUserInfoEmail: () => getUserInfoEmail(opts),
|
||||
querySearchAnalytics: () => querySearchAnalytics(opts),
|
||||
})),
|
||||
upsert: vi.fn(),
|
||||
getByProjectId: vi.fn(),
|
||||
deleteByProjectId: vi.fn(),
|
||||
existsForConnector: vi.fn(),
|
||||
dbDelete: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
|
||||
existsForConnectorAccount: vi.fn(),
|
||||
GscApiError,
|
||||
GscTokenError,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("cloudflare:workers", () => ({ env: {} }));
|
||||
vi.mock("@/db", () => ({ db: { delete: mocks.dbDelete } }));
|
||||
vi.mock("@/db", () => ({
|
||||
db: { select: mocks.dbSelect, delete: mocks.dbDelete },
|
||||
}));
|
||||
vi.mock("@/server/lib/gscClient", () => ({
|
||||
createGscClient: () => ({ listSites: mocks.listSites }),
|
||||
createGscClient: mocks.createGscClient,
|
||||
GscApiError: mocks.GscApiError,
|
||||
GscTokenError: mocks.GscTokenError,
|
||||
}));
|
||||
@ -42,42 +80,98 @@ vi.mock("@/server/features/gsc/repositories/GscConnectionRepository", () => ({
|
||||
upsert: mocks.upsert,
|
||||
getByProjectId: mocks.getByProjectId,
|
||||
deleteByProjectId: mocks.deleteByProjectId,
|
||||
existsForConnector: mocks.existsForConnector,
|
||||
existsForConnectorAccount: mocks.existsForConnectorAccount,
|
||||
},
|
||||
}));
|
||||
|
||||
const baseInput = {
|
||||
projectId: "p1",
|
||||
organizationId: "org1",
|
||||
accountId: "sub-a",
|
||||
userId: "u1",
|
||||
userEmail: "alice@example.com",
|
||||
};
|
||||
|
||||
function collectSqlParams(value: unknown): unknown[] {
|
||||
if (!value || typeof value !== "object") return [];
|
||||
if ("value" in value && "encoder" in value) {
|
||||
return [value.value];
|
||||
}
|
||||
if (!("queryChunks" in value) || !Array.isArray(value.queryChunks)) return [];
|
||||
return value.queryChunks.flatMap(collectSqlParams);
|
||||
}
|
||||
|
||||
describe("GscService.setSite", () => {
|
||||
beforeEach(() => {
|
||||
mocks.state.selectRows = [{ id: "grant-a", accountId: "sub-a" }];
|
||||
mocks.listSites.mockReset();
|
||||
mocks.getUserInfoEmail.mockReset();
|
||||
mocks.createGscClient.mockClear();
|
||||
mocks.upsert.mockReset();
|
||||
});
|
||||
|
||||
it("upserts a verified property using the connector's identity", async () => {
|
||||
it("upserts a verified property with the selected grant and userinfo email", async () => {
|
||||
mocks.listSites.mockResolvedValue([
|
||||
{ siteUrl: "https://x/", permissionLevel: "siteOwner" },
|
||||
]);
|
||||
mocks.getUserInfoEmail.mockResolvedValue("client@example.com");
|
||||
mocks.upsert.mockResolvedValue({ siteUrl: "https://x/" });
|
||||
const { GscService } = await import("./GscService");
|
||||
|
||||
await GscService.setSite({ ...baseInput, siteUrl: "https://x/" });
|
||||
|
||||
expect(mocks.createGscClient).toHaveBeenCalledWith({
|
||||
userId: "u1",
|
||||
gscAccountId: "sub-a",
|
||||
});
|
||||
expect(mocks.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectId: "p1",
|
||||
siteUrl: "https://x/",
|
||||
connectedByUserId: "u1",
|
||||
connectedAccountEmail: "alice@example.com",
|
||||
gscAccountId: "sub-a",
|
||||
connectedAccountEmail: "client@example.com",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("re-saves with a null email when userinfo is unavailable", async () => {
|
||||
mocks.listSites.mockResolvedValue([
|
||||
{ siteUrl: "https://x/", permissionLevel: "siteOwner" },
|
||||
]);
|
||||
mocks.getUserInfoEmail.mockRejectedValue(new Error("userinfo unavailable"));
|
||||
mocks.upsert.mockResolvedValue({
|
||||
siteUrl: "https://x/",
|
||||
connectedAccountEmail: "previous@example.com",
|
||||
});
|
||||
const { GscService } = await import("./GscService");
|
||||
|
||||
const result = await GscService.setSite({
|
||||
...baseInput,
|
||||
siteUrl: "https://x/",
|
||||
});
|
||||
|
||||
expect(mocks.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectedAccountEmail: null }),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
connectedAccountEmail: "previous@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a Google sub that is not one of the caller's grants", async () => {
|
||||
const { GscService } = await import("./GscService");
|
||||
|
||||
await expect(
|
||||
GscService.setSite({
|
||||
...baseInput,
|
||||
accountId: "foreign-sub",
|
||||
siteUrl: "https://x/",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
expect(mocks.createGscClient).not.toHaveBeenCalled();
|
||||
expect(mocks.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an unverified property with FORBIDDEN", async () => {
|
||||
mocks.listSites.mockResolvedValue([
|
||||
{ siteUrl: "https://x/", permissionLevel: "siteUnverifiedUser" },
|
||||
@ -90,7 +184,7 @@ describe("GscService.setSite", () => {
|
||||
expect(mocks.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a property not on the grant with NOT_FOUND", async () => {
|
||||
it("rejects a property not on the selected grant with NOT_FOUND", async () => {
|
||||
mocks.listSites.mockResolvedValue([
|
||||
{ siteUrl: "https://x/", permissionLevel: "siteOwner" },
|
||||
]);
|
||||
@ -105,11 +199,57 @@ describe("GscService.setSite", () => {
|
||||
|
||||
describe("GscService.listSitesForUserWithGrantStatus", () => {
|
||||
beforeEach(() => {
|
||||
mocks.state.selectRows = [
|
||||
{ id: "grant-a", accountId: "sub-a" },
|
||||
{ id: "grant-b", accountId: "sub-b" },
|
||||
];
|
||||
mocks.listSites.mockReset();
|
||||
mocks.getUserInfoEmail.mockReset();
|
||||
mocks.createGscClient.mockClear();
|
||||
mocks.dbDelete.mockClear();
|
||||
});
|
||||
|
||||
it("returns available sites when the grant is healthy", async () => {
|
||||
it("lists grants independently and never deletes a dead grant", async () => {
|
||||
mocks.getUserInfoEmail.mockImplementation(
|
||||
async ({ gscAccountId }: { gscAccountId?: string }) =>
|
||||
`${gscAccountId}@example.com`,
|
||||
);
|
||||
mocks.listSites.mockImplementation(
|
||||
async ({ gscAccountId }: { gscAccountId?: string }) => {
|
||||
if (gscAccountId === "sub-b") throw new mocks.GscTokenError();
|
||||
return [{ siteUrl: "https://x/", permissionLevel: "siteOwner" }];
|
||||
},
|
||||
);
|
||||
const { GscService } = await import("./GscService");
|
||||
|
||||
await expect(
|
||||
GscService.listSitesForUserWithGrantStatus("u1"),
|
||||
).resolves.toEqual({
|
||||
accounts: [
|
||||
{
|
||||
accountId: "sub-a",
|
||||
email: "sub-a@example.com",
|
||||
requiresReconnect: false,
|
||||
sites: [{ siteUrl: "https://x/", permissionLevel: "siteOwner" }],
|
||||
},
|
||||
{
|
||||
accountId: "sub-b",
|
||||
email: null,
|
||||
requiresReconnect: true,
|
||||
sites: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mocks.createGscClient).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.getUserInfoEmail).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ gscAccountId: "sub-b" }),
|
||||
);
|
||||
expect(mocks.dbDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps userinfo failures non-fatal", async () => {
|
||||
mocks.state.selectRows = [{ id: "grant-a", accountId: "sub-a" }];
|
||||
mocks.getUserInfoEmail.mockRejectedValue(new Error("userinfo unavailable"));
|
||||
mocks.listSites.mockResolvedValue([
|
||||
{ siteUrl: "https://x/", permissionLevel: "siteOwner" },
|
||||
]);
|
||||
@ -118,23 +258,20 @@ describe("GscService.listSitesForUserWithGrantStatus", () => {
|
||||
await expect(
|
||||
GscService.listSitesForUserWithGrantStatus("u1"),
|
||||
).resolves.toEqual({
|
||||
sites: [{ siteUrl: "https://x/", permissionLevel: "siteOwner" }],
|
||||
requiresReconnect: false,
|
||||
accounts: [
|
||||
{
|
||||
accountId: "sub-a",
|
||||
email: null,
|
||||
requiresReconnect: false,
|
||||
sites: [{ siteUrl: "https://x/", permissionLevel: "siteOwner" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mocks.dbDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unlinks the dead grant and asks for reconnect when no token can be minted", async () => {
|
||||
mocks.listSites.mockRejectedValue(new mocks.GscTokenError());
|
||||
const { GscService } = await import("./GscService");
|
||||
|
||||
await expect(
|
||||
GscService.listSitesForUserWithGrantStatus("u1"),
|
||||
).resolves.toEqual({ sites: [], requiresReconnect: true });
|
||||
expect(mocks.dbDelete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks for reconnect without unlinking on a 403 (revoked vs. quota is ambiguous)", async () => {
|
||||
it("marks a grant for reconnect on a GSC 403 without deleting it", async () => {
|
||||
mocks.state.selectRows = [{ id: "grant-a", accountId: "sub-a" }];
|
||||
mocks.getUserInfoEmail.mockResolvedValue("a@example.com");
|
||||
mocks.listSites.mockRejectedValue(
|
||||
new mocks.GscApiError(403, "Search Console denied access"),
|
||||
);
|
||||
@ -142,31 +279,112 @@ describe("GscService.listSitesForUserWithGrantStatus", () => {
|
||||
|
||||
await expect(
|
||||
GscService.listSitesForUserWithGrantStatus("u1"),
|
||||
).resolves.toEqual({ sites: [], requiresReconnect: true });
|
||||
expect(mocks.dbDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks for reconnect without unlinking on a 401", async () => {
|
||||
mocks.listSites.mockRejectedValue(
|
||||
new mocks.GscApiError(401, "unauthenticated"),
|
||||
);
|
||||
const { GscService } = await import("./GscService");
|
||||
|
||||
await expect(
|
||||
GscService.listSitesForUserWithGrantStatus("u1"),
|
||||
).resolves.toEqual({ sites: [], requiresReconnect: true });
|
||||
).resolves.toEqual({
|
||||
accounts: [
|
||||
{
|
||||
accountId: "sub-a",
|
||||
email: null,
|
||||
requiresReconnect: true,
|
||||
sites: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mocks.getUserInfoEmail).not.toHaveBeenCalled();
|
||||
expect(mocks.dbDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps non-auth GSC API errors reportable", async () => {
|
||||
mocks.getUserInfoEmail.mockImplementation(
|
||||
async ({ gscAccountId }: { gscAccountId?: string }) =>
|
||||
`${gscAccountId}@example.com`,
|
||||
);
|
||||
const rateLimit = new mocks.GscApiError(429, "slow down");
|
||||
mocks.listSites.mockRejectedValue(rateLimit);
|
||||
mocks.listSites.mockImplementation(
|
||||
async ({ gscAccountId }: { gscAccountId?: string }) => {
|
||||
if (gscAccountId === "sub-b") throw rateLimit;
|
||||
return [{ siteUrl: "https://x/", permissionLevel: "siteOwner" }];
|
||||
},
|
||||
);
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => undefined);
|
||||
const { GscService } = await import("./GscService");
|
||||
|
||||
await expect(GscService.listSitesForUserWithGrantStatus("u1")).rejects.toBe(
|
||||
await expect(
|
||||
GscService.listSitesForUserWithGrantStatus("u1"),
|
||||
).resolves.toEqual({
|
||||
accounts: [
|
||||
{
|
||||
accountId: "sub-a",
|
||||
email: "sub-a@example.com",
|
||||
requiresReconnect: false,
|
||||
sites: [{ siteUrl: "https://x/", permissionLevel: "siteOwner" }],
|
||||
},
|
||||
{
|
||||
accountId: "sub-b",
|
||||
email: null,
|
||||
requiresReconnect: true,
|
||||
sites: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
"Failed to list Search Console sites for account",
|
||||
"sub-b",
|
||||
rateLimit,
|
||||
);
|
||||
expect(mocks.dbDelete).not.toHaveBeenCalled();
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GscService.getPerformance", () => {
|
||||
beforeEach(() => {
|
||||
mocks.getByProjectId.mockReset();
|
||||
mocks.querySearchAnalytics.mockReset().mockResolvedValue([]);
|
||||
mocks.createGscClient.mockClear();
|
||||
});
|
||||
|
||||
it("uses the grant stored on the project connection", async () => {
|
||||
mocks.getByProjectId.mockResolvedValue({
|
||||
connectedByUserId: "u1",
|
||||
connectedAccountEmail: "a@example.com",
|
||||
gscAccountId: "sub-a",
|
||||
siteUrl: "https://x/",
|
||||
});
|
||||
const { GscService } = await import("./GscService");
|
||||
|
||||
await GscService.getPerformance({
|
||||
projectId: "p1",
|
||||
startDate: "2026-01-01",
|
||||
endDate: "2026-01-31",
|
||||
});
|
||||
|
||||
expect(mocks.createGscClient).toHaveBeenCalledWith({
|
||||
userId: "u1",
|
||||
gscAccountId: "sub-a",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes undefined for the legacy null-account fallback", async () => {
|
||||
mocks.getByProjectId.mockResolvedValue({
|
||||
connectedByUserId: "u1",
|
||||
connectedAccountEmail: null,
|
||||
gscAccountId: null,
|
||||
siteUrl: "https://x/",
|
||||
});
|
||||
const { GscService } = await import("./GscService");
|
||||
|
||||
await GscService.getPerformance({
|
||||
projectId: "p1",
|
||||
startDate: "2026-01-01",
|
||||
endDate: "2026-01-31",
|
||||
});
|
||||
|
||||
expect(mocks.createGscClient).toHaveBeenCalledWith({
|
||||
userId: "u1",
|
||||
gscAccountId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -174,25 +392,36 @@ describe("GscService.disconnect", () => {
|
||||
beforeEach(() => {
|
||||
mocks.getByProjectId.mockReset();
|
||||
mocks.deleteByProjectId.mockReset().mockResolvedValue(undefined);
|
||||
mocks.existsForConnector.mockReset();
|
||||
mocks.existsForConnectorAccount.mockReset();
|
||||
mocks.dbDelete.mockClear();
|
||||
mocks.deleteWhere.mockClear();
|
||||
});
|
||||
|
||||
it("unlinks the connector's grant when they disconnect their last project", async () => {
|
||||
mocks.getByProjectId.mockResolvedValue({ connectedByUserId: "u1" });
|
||||
mocks.existsForConnector.mockResolvedValue(false);
|
||||
it("unlinks only the disconnected account when it is no longer used", async () => {
|
||||
mocks.getByProjectId.mockResolvedValue({
|
||||
connectedByUserId: "u1",
|
||||
gscAccountId: "sub-b",
|
||||
});
|
||||
mocks.existsForConnectorAccount.mockResolvedValue(false);
|
||||
const { GscService } = await import("./GscService");
|
||||
|
||||
await GscService.disconnect({ projectId: "p1", userId: "u1" });
|
||||
|
||||
expect(mocks.deleteByProjectId).toHaveBeenCalledWith("p1");
|
||||
expect(mocks.existsForConnector).toHaveBeenCalledWith("u1");
|
||||
expect(mocks.dbDelete).toHaveBeenCalled(); // grant unlinked
|
||||
expect(mocks.existsForConnectorAccount).toHaveBeenCalledWith("u1", "sub-b");
|
||||
expect(mocks.dbDelete).toHaveBeenCalledTimes(1);
|
||||
const whereCondition = mocks.deleteWhere.mock.calls[0]?.[0];
|
||||
expect(collectSqlParams(whereCondition)).toEqual(
|
||||
expect.arrayContaining(["u1", "google-search-console", "sub-b"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the grant when the connector still has another connected project", async () => {
|
||||
mocks.getByProjectId.mockResolvedValue({ connectedByUserId: "u1" });
|
||||
mocks.existsForConnector.mockResolvedValue(true);
|
||||
it("keeps the grant when the same account powers another project", async () => {
|
||||
mocks.getByProjectId.mockResolvedValue({
|
||||
connectedByUserId: "u1",
|
||||
gscAccountId: "sub-b",
|
||||
});
|
||||
mocks.existsForConnectorAccount.mockResolvedValue(true);
|
||||
const { GscService } = await import("./GscService");
|
||||
|
||||
await GscService.disconnect({ projectId: "p1", userId: "u1" });
|
||||
@ -200,27 +429,40 @@ describe("GscService.disconnect", () => {
|
||||
expect(mocks.dbDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never revokes a grant when a different member disconnects the connection", async () => {
|
||||
mocks.getByProjectId.mockResolvedValue({ connectedByUserId: "owner" });
|
||||
it("never revokes a grant when another member disconnects", async () => {
|
||||
mocks.getByProjectId.mockResolvedValue({
|
||||
connectedByUserId: "owner",
|
||||
gscAccountId: "sub-b",
|
||||
});
|
||||
const { GscService } = await import("./GscService");
|
||||
|
||||
await GscService.disconnect({ projectId: "p1", userId: "other-member" });
|
||||
|
||||
expect(mocks.deleteByProjectId).toHaveBeenCalledWith("p1");
|
||||
expect(mocks.existsForConnector).not.toHaveBeenCalled();
|
||||
expect(mocks.existsForConnectorAccount).not.toHaveBeenCalled();
|
||||
expect(mocks.dbDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unlinks the caller's dangling grant when no property was ever bound", async () => {
|
||||
// Linked Google but never picked a property → no connection row. Disconnect
|
||||
// should still drop the caller's own grant.
|
||||
mocks.getByProjectId.mockResolvedValue(null);
|
||||
mocks.existsForConnector.mockResolvedValue(false);
|
||||
it("deletes no grants for a legacy null-account connection", async () => {
|
||||
mocks.getByProjectId.mockResolvedValue({
|
||||
connectedByUserId: "u1",
|
||||
gscAccountId: null,
|
||||
});
|
||||
const { GscService } = await import("./GscService");
|
||||
|
||||
await GscService.disconnect({ projectId: "p1", userId: "u1" });
|
||||
|
||||
expect(mocks.existsForConnector).toHaveBeenCalledWith("u1");
|
||||
expect(mocks.dbDelete).toHaveBeenCalled(); // grant unlinked
|
||||
expect(mocks.deleteByProjectId).toHaveBeenCalledWith("p1");
|
||||
expect(mocks.existsForConnectorAccount).not.toHaveBeenCalled();
|
||||
expect(mocks.dbDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes no grants when no property was bound", async () => {
|
||||
mocks.getByProjectId.mockResolvedValue(null);
|
||||
const { GscService } = await import("./GscService");
|
||||
|
||||
await GscService.disconnect({ projectId: "p1", userId: "u1" });
|
||||
|
||||
expect(mocks.existsForConnectorAccount).not.toHaveBeenCalled();
|
||||
expect(mocks.dbDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -33,8 +33,12 @@ type GscPerformanceResult = {
|
||||
};
|
||||
|
||||
type GscSiteListResult = {
|
||||
sites: GscSite[];
|
||||
requiresReconnect: boolean;
|
||||
accounts: Array<{
|
||||
accountId: string;
|
||||
email: string | null;
|
||||
requiresReconnect: boolean;
|
||||
sites: GscSite[];
|
||||
}>;
|
||||
};
|
||||
|
||||
/** Thrown when a project has no connected GSC property. */
|
||||
@ -65,15 +69,21 @@ async function userHasGrant(userId: string): Promise<boolean> {
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/** List verified properties available on a user's google-search-console grant. */
|
||||
async function listSitesForUser(userId: string): Promise<GscSite[]> {
|
||||
return createGscClient({ userId }).listSites();
|
||||
async function listGrantsForUser(userId: string) {
|
||||
return db
|
||||
.select({ id: account.id, accountId: account.accountId })
|
||||
.from(account)
|
||||
.where(
|
||||
and(
|
||||
eq(account.userId, userId),
|
||||
eq(account.providerId, GSC_OAUTH_PROVIDER_ID),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Expected ways a stored grant fails to reach Search Console: no token could be
|
||||
* minted (refresh token revoked or expired), or Google rejected the call
|
||||
* (401/403). These surface a reconnect prompt instead of being routed through
|
||||
* error tracking. Other statuses (429, 5xx) are genuine faults and propagate. */
|
||||
* (401/403). These surface a reconnect prompt without fault logging. */
|
||||
export function isExpectedGrantFailure(error: unknown): boolean {
|
||||
if (error instanceof GscTokenError) return true;
|
||||
return (
|
||||
@ -82,30 +92,49 @@ export function isExpectedGrantFailure(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** List properties for the picker UI. When the stored grant can't currently
|
||||
* reach GSC, return a reconnect signal instead of throwing, so an expected
|
||||
* external-auth failure doesn't land in error tracking.
|
||||
*
|
||||
* Only a GscTokenError unlinks the stored grant — the one unambiguous "this
|
||||
* grant is dead" signal (Better Auth couldn't mint/refresh a token, i.e. the
|
||||
* user revoked access or the refresh token expired). A bare 401/403 from
|
||||
* sites.list is left in place: Search Console also returns 403 for quota/rate
|
||||
* limits, so destroying the grant there would force needless reconnects across
|
||||
* every project on it. Reconnecting re-upserts the grant either way. */
|
||||
async function listSitesForUserWithGrantStatus(
|
||||
userId: string,
|
||||
): Promise<GscSiteListResult> {
|
||||
try {
|
||||
return { sites: await listSitesForUser(userId), requiresReconnect: false };
|
||||
} catch (error) {
|
||||
if (!isExpectedGrantFailure(error)) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof GscTokenError) {
|
||||
await unlinkUserGrant(userId);
|
||||
}
|
||||
return { sites: [], requiresReconnect: true };
|
||||
}
|
||||
const grants = await listGrantsForUser(userId);
|
||||
const accounts = await Promise.all(
|
||||
grants.map(async (grant) => {
|
||||
const client = createGscClient({
|
||||
userId,
|
||||
gscAccountId: grant.accountId,
|
||||
});
|
||||
|
||||
try {
|
||||
const sites = await client.listSites();
|
||||
let email: string | null = null;
|
||||
try {
|
||||
email = await client.getUserInfoEmail();
|
||||
} catch {
|
||||
email = null;
|
||||
}
|
||||
return {
|
||||
accountId: grant.accountId,
|
||||
email,
|
||||
requiresReconnect: false,
|
||||
sites,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!isExpectedGrantFailure(error)) {
|
||||
console.error(
|
||||
"Failed to list Search Console sites for account",
|
||||
grant.accountId,
|
||||
error,
|
||||
);
|
||||
}
|
||||
return {
|
||||
accountId: grant.accountId,
|
||||
email: null,
|
||||
requiresReconnect: true,
|
||||
sites: [],
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
return { accounts };
|
||||
}
|
||||
|
||||
/** Map a verified property to a project. Rejects unverified properties and
|
||||
@ -114,10 +143,22 @@ async function setSite(input: {
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
siteUrl: string;
|
||||
accountId: string;
|
||||
userId: string;
|
||||
userEmail: string;
|
||||
}): Promise<GscConnection> {
|
||||
const sites = await listSitesForUser(input.userId);
|
||||
const grants = await listGrantsForUser(input.userId);
|
||||
if (!grants.some((grant) => grant.accountId === input.accountId)) {
|
||||
throw new AppError(
|
||||
"NOT_FOUND",
|
||||
"That Google account isn't connected to your OpenSEO account.",
|
||||
);
|
||||
}
|
||||
|
||||
const client = createGscClient({
|
||||
userId: input.userId,
|
||||
gscAccountId: input.accountId,
|
||||
});
|
||||
const sites = await client.listSites();
|
||||
const match = sites.find((s) => s.siteUrl === input.siteUrl);
|
||||
if (!match) {
|
||||
throw new AppError(
|
||||
@ -131,23 +172,33 @@ async function setSite(input: {
|
||||
"You don't have verified access to that Search Console property.",
|
||||
);
|
||||
}
|
||||
let connectedAccountEmail: string | null = null;
|
||||
try {
|
||||
connectedAccountEmail = await client.getUserInfoEmail();
|
||||
} catch {
|
||||
connectedAccountEmail = null;
|
||||
}
|
||||
return GscConnectionRepository.upsert({
|
||||
projectId: input.projectId,
|
||||
organizationId: input.organizationId,
|
||||
siteUrl: input.siteUrl,
|
||||
connectedByUserId: input.userId,
|
||||
connectedAccountEmail: input.userEmail,
|
||||
gscAccountId: input.accountId,
|
||||
connectedAccountEmail,
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove this user's google-search-console grant (stored OAuth tokens). */
|
||||
async function unlinkUserGrant(userId: string): Promise<void> {
|
||||
async function unlinkUserGrant(
|
||||
userId: string,
|
||||
gscAccountId: string,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.delete(account)
|
||||
.where(
|
||||
and(
|
||||
eq(account.userId, userId),
|
||||
eq(account.providerId, GSC_OAUTH_PROVIDER_ID),
|
||||
eq(account.accountId, gscAccountId),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -160,19 +211,16 @@ async function disconnect(input: {
|
||||
input.projectId,
|
||||
);
|
||||
await GscConnectionRepository.deleteByProjectId(input.projectId);
|
||||
// Clean up the caller's *own* OAuth grant once none of their projects still
|
||||
// use it. Safe by construction: unlinkUserGrant only ever deletes the
|
||||
// caller's account row, never another member's. We skip cleanup only when the
|
||||
// binding we removed belonged to a *different* member, so unbinding their
|
||||
// property never revokes the caller's unrelated grant. A null connection
|
||||
// means the caller linked Google but never picked a property — that dangling
|
||||
// grant is theirs to drop.
|
||||
if (!connection || connection.connectedByUserId === input.userId) {
|
||||
const stillUsed = await GscConnectionRepository.existsForConnector(
|
||||
if (
|
||||
connection?.gscAccountId &&
|
||||
connection.connectedByUserId === input.userId
|
||||
) {
|
||||
const stillUsed = await GscConnectionRepository.existsForConnectorAccount(
|
||||
input.userId,
|
||||
connection.gscAccountId,
|
||||
);
|
||||
if (!stillUsed) {
|
||||
await unlinkUserGrant(input.userId);
|
||||
await unlinkUserGrant(input.userId, connection.gscAccountId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -188,7 +236,10 @@ async function getPerformance(
|
||||
throw new GscNotConnectedError(input.projectId);
|
||||
}
|
||||
const request = buildSearchAnalyticsRequest(input);
|
||||
const client = createGscClient({ userId: connection.connectedByUserId });
|
||||
const client = createGscClient({
|
||||
userId: connection.connectedByUserId,
|
||||
gscAccountId: connection.gscAccountId ?? undefined,
|
||||
});
|
||||
const rows = await client.querySearchAnalytics(connection.siteUrl, request);
|
||||
return {
|
||||
siteUrl: connection.siteUrl,
|
||||
@ -225,7 +276,10 @@ async function inspectUrls(input: {
|
||||
if (!connection) {
|
||||
throw new GscNotConnectedError(input.projectId);
|
||||
}
|
||||
const client = createGscClient({ userId: connection.connectedByUserId });
|
||||
const client = createGscClient({
|
||||
userId: connection.connectedByUserId,
|
||||
gscAccountId: connection.gscAccountId ?? undefined,
|
||||
});
|
||||
const results: GscUrlInspection[] = [];
|
||||
for (const url of input.urls) {
|
||||
try {
|
||||
|
||||
@ -39,6 +39,52 @@ describe("gscClient", () => {
|
||||
expect(init?.headers).toMatchObject({ Authorization: "Bearer tok_123" });
|
||||
});
|
||||
|
||||
it("targets the selected Better Auth grant by Google sub", async () => {
|
||||
mocks.fetch.mockResolvedValue(jsonResponse({ siteEntry: [] }));
|
||||
const { createGscClient } = await import("./gscClient");
|
||||
|
||||
await createGscClient({
|
||||
userId: "u1",
|
||||
gscAccountId: "google-sub-a",
|
||||
}).listSites();
|
||||
|
||||
expect(mocks.getAccessToken).toHaveBeenCalledWith({
|
||||
body: {
|
||||
providerId: "google-search-console",
|
||||
userId: "u1",
|
||||
accountId: "google-sub-a",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("omits accountId for the legacy null-account fallback", async () => {
|
||||
mocks.fetch.mockResolvedValue(jsonResponse({ siteEntry: [] }));
|
||||
const { createGscClient } = await import("./gscClient");
|
||||
|
||||
await createGscClient({ userId: "u1" }).listSites();
|
||||
|
||||
expect(mocks.getAccessToken).toHaveBeenCalledWith({
|
||||
body: { providerId: "google-search-console", userId: "u1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches the Google account email from userinfo", async () => {
|
||||
mocks.fetch.mockResolvedValue(
|
||||
jsonResponse({ email: "client@example.com" }),
|
||||
);
|
||||
const { createGscClient } = await import("./gscClient");
|
||||
|
||||
const email = await createGscClient({
|
||||
userId: "u1",
|
||||
gscAccountId: "google-sub-a",
|
||||
}).getUserInfoEmail();
|
||||
|
||||
expect(email).toBe("client@example.com");
|
||||
const [url, init] = mocks.fetch.mock.calls[0];
|
||||
expect(url).toBe("https://openidconnect.googleapis.com/v1/userinfo");
|
||||
expect(init?.headers).toMatchObject({ Authorization: "Bearer tok_123" });
|
||||
});
|
||||
|
||||
it("encodes the siteUrl in the searchAnalytics path (both property forms)", async () => {
|
||||
mocks.fetch.mockImplementation(async () => jsonResponse({ rows: [] }));
|
||||
const { createGscClient } = await import("./gscClient");
|
||||
|
||||
@ -2,6 +2,7 @@ import { getAuth } from "@/lib/auth";
|
||||
import { GSC_OAUTH_PROVIDER_ID } from "@/shared/gsc";
|
||||
|
||||
const GSC_API_BASE = "https://www.googleapis.com/webmasters/v3";
|
||||
const GOOGLE_USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo";
|
||||
|
||||
/** A GSC REST call returned a non-2xx status. `status` drives user-facing messaging. */
|
||||
export class GscApiError extends Error {
|
||||
@ -99,7 +100,10 @@ function messageForStatus(status: number, body: string): string {
|
||||
* meter credits — GSC is first-party data with no per-call cost. Access tokens
|
||||
* are minted (and auto-refreshed) by Better Auth from the connector's stored
|
||||
* google-search-console grant. */
|
||||
export function createGscClient(opts: { userId: string }) {
|
||||
export function createGscClient(opts: {
|
||||
userId: string;
|
||||
gscAccountId?: string;
|
||||
}) {
|
||||
async function getToken(): Promise<string> {
|
||||
let result: { accessToken?: string } | undefined;
|
||||
try {
|
||||
@ -108,7 +112,11 @@ export function createGscClient(opts: { userId: string }) {
|
||||
// Works in every auth mode — self-hosted builds the same Better Auth
|
||||
// instance once BETTER_AUTH_SECRET is set.
|
||||
result = await getAuth().api.getAccessToken({
|
||||
body: { providerId: GSC_OAUTH_PROVIDER_ID, userId: opts.userId },
|
||||
body: {
|
||||
providerId: GSC_OAUTH_PROVIDER_ID,
|
||||
userId: opts.userId,
|
||||
...(opts.gscAccountId ? { accountId: opts.gscAccountId } : {}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new GscTokenError(
|
||||
@ -150,6 +158,11 @@ export function createGscClient(opts: { userId: string }) {
|
||||
}
|
||||
|
||||
return {
|
||||
async getUserInfoEmail(): Promise<string | null> {
|
||||
const data = await request<{ email?: unknown }>(GOOGLE_USERINFO_URL);
|
||||
return typeof data.email === "string" ? data.email : null;
|
||||
},
|
||||
|
||||
/** Webmasters API `sites.list` — the verified properties on the grant. */
|
||||
async listSites(): Promise<GscSite[]> {
|
||||
const data = await request<{ siteEntry?: GscSite[] }>(
|
||||
|
||||
@ -110,9 +110,7 @@ describe("instrumentMcpToolHandler", () => {
|
||||
okResult({ items: [] }),
|
||||
);
|
||||
|
||||
await runWithMcpToolAuthContext(authContext, () =>
|
||||
wrapped({}, toolExtra),
|
||||
);
|
||||
await runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra));
|
||||
|
||||
expect(mocks.captureServerEvent).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({
|
||||
@ -134,9 +132,7 @@ describe("instrumentMcpToolHandler", () => {
|
||||
okResult({ items: "not-an-array" }),
|
||||
);
|
||||
|
||||
await runWithMcpToolAuthContext(authContext, () =>
|
||||
wrapped({}, toolExtra),
|
||||
);
|
||||
await runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra));
|
||||
|
||||
expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({
|
||||
event: "mcp:tool_call",
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
|
||||
const projectScopedSchema = z.object({ projectId: z.string().min(1) });
|
||||
const setSiteSchema = projectScopedSchema.extend({
|
||||
accountId: z.string().min(1),
|
||||
siteUrl: z.string().min(1),
|
||||
});
|
||||
const startSelfHostedLinkSchema = z.object({
|
||||
@ -59,13 +60,27 @@ export const listGscSites = createServerFn({ method: "POST" })
|
||||
GscService.listSitesForUserWithGrantStatus(context.userId),
|
||||
GscService.getConnection(context.projectId),
|
||||
]);
|
||||
let legacySelectionMatched = false;
|
||||
return {
|
||||
requiresReconnect: siteList.requiresReconnect,
|
||||
sites: siteList.sites.map((s) => ({
|
||||
siteUrl: s.siteUrl,
|
||||
permissionLevel: s.permissionLevel,
|
||||
selectable: s.permissionLevel !== "siteUnverifiedUser",
|
||||
isSelected: s.siteUrl === connection?.siteUrl,
|
||||
accounts: siteList.accounts.map((grant) => ({
|
||||
accountId: grant.accountId,
|
||||
email: grant.email,
|
||||
requiresReconnect: grant.requiresReconnect,
|
||||
sites: grant.sites.map((site) => {
|
||||
const isSelected = connection?.gscAccountId
|
||||
? connection.gscAccountId === grant.accountId &&
|
||||
connection.siteUrl === site.siteUrl
|
||||
: !legacySelectionMatched && connection?.siteUrl === site.siteUrl;
|
||||
if (!connection?.gscAccountId && isSelected) {
|
||||
legacySelectionMatched = true;
|
||||
}
|
||||
return {
|
||||
siteUrl: site.siteUrl,
|
||||
permissionLevel: site.permissionLevel,
|
||||
selectable: site.permissionLevel !== "siteUnverifiedUser",
|
||||
isSelected,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
};
|
||||
});
|
||||
@ -77,9 +92,9 @@ export const setGscSite = createServerFn({ method: "POST" })
|
||||
const connection = await GscService.setSite({
|
||||
projectId: context.projectId,
|
||||
organizationId: context.organizationId,
|
||||
accountId: data.accountId,
|
||||
siteUrl: data.siteUrl,
|
||||
userId: context.userId,
|
||||
userEmail: context.userEmail,
|
||||
});
|
||||
waitUntil(
|
||||
captureServerEvent({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user