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,
|
"when": 1783306049167,
|
||||||
"tag": "0007_same_marvel_zombies",
|
"tag": "0007_same_marvel_zombies",
|
||||||
"breakpoints": true
|
"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,
|
"when": 1783306047190,
|
||||||
"tag": "0030_legal_reaper",
|
"tag": "0030_legal_reaper",
|
||||||
"breakpoints": true
|
"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 { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
|
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
|
||||||
import { SelfHostedSetupWarning } from "@/client/features/gsc/SelfHostedSetupWarning";
|
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 { startGscLink } from "@/client/features/gsc/startGscLink";
|
||||||
import {
|
import {
|
||||||
disconnectGsc,
|
disconnectGsc,
|
||||||
@ -25,7 +28,9 @@ export function SearchConsoleConnectionCard({
|
|||||||
const hosted = isHostedClientAuthMode();
|
const hosted = isHostedClientAuthMode();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [picking, setPicking] = React.useState(false);
|
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 connectionKey = ["gscConnection", projectId];
|
||||||
const connectionQuery = useQuery({
|
const connectionQuery = useQuery({
|
||||||
@ -43,7 +48,13 @@ export function SearchConsoleConnectionCard({
|
|||||||
queryFn: () => listGscSites({ data: { projectId } }),
|
queryFn: () => listGscSites({ data: { projectId } }),
|
||||||
enabled: Boolean(showPicker && !selfHostedNeedsSetup),
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (!requiresReconnect) return;
|
if (!requiresReconnect) return;
|
||||||
@ -54,9 +65,23 @@ export function SearchConsoleConnectionCard({
|
|||||||
void queryClient.invalidateQueries({ queryKey: GRANT_STATUS_KEY });
|
void queryClient.invalidateQueries({ queryKey: GRANT_STATUS_KEY });
|
||||||
}, [requiresReconnect, queryClient, projectId]);
|
}, [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({
|
const setSiteMutation = useMutation({
|
||||||
mutationFn: (siteUrl: string) =>
|
mutationFn: (selected: GscSiteSelection) =>
|
||||||
setGscSite({ data: { projectId, siteUrl } }),
|
setGscSite({ data: { projectId, ...selected } }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
captureClientEvent("gsc:property_select");
|
captureClientEvent("gsc:property_select");
|
||||||
toast.success("Search Console connected");
|
toast.success("Search Console connected");
|
||||||
@ -80,6 +105,7 @@ export function SearchConsoleConnectionCard({
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Search Console disconnected");
|
toast.success("Search Console disconnected");
|
||||||
setPicking(false);
|
setPicking(false);
|
||||||
|
setSelection(null);
|
||||||
void queryClient.invalidateQueries({ queryKey: connectionKey });
|
void queryClient.invalidateQueries({ queryKey: connectionKey });
|
||||||
// Disconnect can drop the account-level grant server-side; keep the
|
// Disconnect can drop the account-level grant server-side; keep the
|
||||||
// shared grant-status cache (onboarding step + re-engagement nudge) honest.
|
// shared grant-status cache (onboarding step + re-engagement nudge) honest.
|
||||||
@ -120,7 +146,7 @@ export function SearchConsoleConnectionCard({
|
|||||||
siteUrl={connection?.siteUrl ?? ""}
|
siteUrl={connection?.siteUrl ?? ""}
|
||||||
connectedByEmail={connection?.connectedByEmail ?? null}
|
connectedByEmail={connection?.connectedByEmail ?? null}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
setSelectedSiteUrl(connection?.siteUrl ?? "");
|
setSelection(null);
|
||||||
setPicking(true);
|
setPicking(true);
|
||||||
}}
|
}}
|
||||||
onDisconnect={() => disconnectMutation.mutate()}
|
onDisconnect={() => disconnectMutation.mutate()}
|
||||||
@ -129,14 +155,13 @@ export function SearchConsoleConnectionCard({
|
|||||||
) : showPicker ? (
|
) : showPicker ? (
|
||||||
<SitePicker
|
<SitePicker
|
||||||
loading={sitesQuery.isLoading}
|
loading={sitesQuery.isLoading}
|
||||||
error={sitesQuery.isError || requiresReconnect}
|
error={sitesQuery.isError}
|
||||||
sites={sitesQuery.data?.sites ?? []}
|
accounts={accounts}
|
||||||
selectedSiteUrl={selectedSiteUrl}
|
selection={selection}
|
||||||
onSelect={setSelectedSiteUrl}
|
onSelect={setSelection}
|
||||||
onSave={() =>
|
onSave={() => selection && setSiteMutation.mutate(selection)}
|
||||||
selectedSiteUrl && setSiteMutation.mutate(selectedSiteUrl)
|
|
||||||
}
|
|
||||||
saving={setSiteMutation.isPending}
|
saving={setSiteMutation.isPending}
|
||||||
|
onRetry={() => void sitesQuery.refetch()}
|
||||||
onReconnect={handleConnect}
|
onReconnect={handleConnect}
|
||||||
secondaryAction={
|
secondaryAction={
|
||||||
connected
|
connected
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
|
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
|
||||||
|
import { startGscLink } from "@/client/features/gsc/startGscLink";
|
||||||
|
|
||||||
type SiteOption = {
|
type SiteOption = {
|
||||||
siteUrl: string;
|
siteUrl: string;
|
||||||
@ -7,6 +8,18 @@ type SiteOption = {
|
|||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AccountOption = {
|
||||||
|
accountId: string;
|
||||||
|
email: string | null;
|
||||||
|
requiresReconnect: boolean;
|
||||||
|
sites: SiteOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GscSiteSelection = {
|
||||||
|
accountId: string;
|
||||||
|
siteUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
type SecondaryAction = {
|
type SecondaryAction = {
|
||||||
label: string;
|
label: string;
|
||||||
onClick: () => void;
|
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 —
|
* Integrations card and the onboarding step. `secondaryAction` is optional —
|
||||||
* omit it where there's nothing to cancel/disconnect (e.g. onboarding).
|
* omit it where there's nothing to cancel/disconnect (e.g. onboarding).
|
||||||
*/
|
*/
|
||||||
export function SitePicker({
|
export function SitePicker({
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
sites,
|
accounts,
|
||||||
selectedSiteUrl,
|
selection,
|
||||||
onSelect,
|
onSelect,
|
||||||
onSave,
|
onSave,
|
||||||
saving,
|
saving,
|
||||||
|
onRetry,
|
||||||
onReconnect,
|
onReconnect,
|
||||||
secondaryAction,
|
secondaryAction,
|
||||||
}: {
|
}: {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: boolean;
|
error: boolean;
|
||||||
sites: SiteOption[];
|
accounts: AccountOption[];
|
||||||
selectedSiteUrl: string;
|
selection: GscSiteSelection | null;
|
||||||
onSelect: (siteUrl: string) => void;
|
onSelect: (selection: GscSiteSelection) => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
|
onRetry: () => void;
|
||||||
onReconnect: () => void;
|
onReconnect: () => void;
|
||||||
secondaryAction?: SecondaryAction;
|
secondaryAction?: SecondaryAction;
|
||||||
}) {
|
}) {
|
||||||
@ -49,6 +64,26 @@ export function SitePicker({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (error) {
|
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 (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-sm text-error">
|
<p className="text-sm text-error">
|
||||||
@ -65,6 +100,24 @@ export function SitePicker({
|
|||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
@ -73,33 +126,61 @@ export function SitePicker({
|
|||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
className="select select-bordered w-full max-w-md"
|
className="select select-bordered w-full max-w-md"
|
||||||
value={selectedSiteUrl}
|
value={selectedIndex >= 0 ? String(selectedIndex) : ""}
|
||||||
onChange={(e) => onSelect(e.target.value)}
|
onChange={(event) => {
|
||||||
|
const option = options[Number(event.target.value)];
|
||||||
|
if (option) onSelect(option);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<option value="" disabled>
|
<option value="" disabled>
|
||||||
Select a property…
|
Select a property…
|
||||||
</option>
|
</option>
|
||||||
{sites.map((site) => (
|
{healthyAccounts.map((account) => (
|
||||||
<option
|
<optgroup
|
||||||
key={site.siteUrl}
|
key={account.accountId}
|
||||||
value={site.siteUrl}
|
label={account.email ?? "Google account"}
|
||||||
disabled={!site.selectable}
|
|
||||||
>
|
>
|
||||||
{site.siteUrl}
|
{account.sites.length === 0 ? (
|
||||||
{site.selectable ? "" : " (no access)"}
|
<option disabled>No properties</option>
|
||||||
</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>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary btn-sm"
|
className="btn btn-primary btn-sm"
|
||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
disabled={!selectedSiteUrl || saving}
|
disabled={selectedIndex < 0 || saving}
|
||||||
>
|
>
|
||||||
{saving ? "Saving…" : "Save property"}
|
{saving ? "Saving…" : "Save property"}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => void startGscLink(window.location.href)}
|
||||||
|
>
|
||||||
|
Connect another Google account
|
||||||
|
</button>
|
||||||
{secondaryAction ? (
|
{secondaryAction ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -4,7 +4,10 @@ import { Check } from "lucide-react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
|
import { GoogleGlyph } from "@/client/features/gsc/GoogleGlyph";
|
||||||
import { SelfHostedSetupWarning } from "@/client/features/gsc/SelfHostedSetupWarning";
|
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 { startGscLink } from "@/client/features/gsc/startGscLink";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
import { captureClientEvent } from "@/client/lib/posthog";
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
@ -15,6 +18,8 @@ import {
|
|||||||
} from "@/serverFunctions/gsc";
|
} from "@/serverFunctions/gsc";
|
||||||
import { getProjects } from "@/serverFunctions/projects";
|
import { getProjects } from "@/serverFunctions/projects";
|
||||||
|
|
||||||
|
const GRANT_STATUS_KEY = ["gscGrantStatus"];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Onboarding step for connecting Google Search Console: link the account-level
|
* Onboarding step for connecting Google Search Console: link the account-level
|
||||||
* OAuth grant, then bind a verified property to the user's first project —
|
* 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. */
|
/** Connect + pick-a-property flow, scoped to a known project. */
|
||||||
function GscConnect({ projectId }: { projectId: string }) {
|
function GscConnect({ projectId }: { projectId: string }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [selectedSiteUrl, setSelectedSiteUrl] = React.useState("");
|
const [selection, setSelection] = React.useState<GscSiteSelection | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const connectionKey = ["gscConnection", projectId];
|
const connectionKey = ["gscConnection", projectId];
|
||||||
const connectionQuery = useQuery({
|
const connectionQuery = useQuery({
|
||||||
@ -65,7 +72,13 @@ function GscConnect({ projectId }: { projectId: string }) {
|
|||||||
queryFn: () => listGscSites({ data: { projectId } }),
|
queryFn: () => listGscSites({ data: { projectId } }),
|
||||||
enabled: hasGrant && !connected && !needsSetup,
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (!requiresReconnect) return;
|
if (!requiresReconnect) return;
|
||||||
@ -73,11 +86,12 @@ function GscConnect({ projectId }: { projectId: string }) {
|
|||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["gscConnection", projectId],
|
queryKey: ["gscConnection", projectId],
|
||||||
});
|
});
|
||||||
|
void queryClient.invalidateQueries({ queryKey: GRANT_STATUS_KEY });
|
||||||
}, [requiresReconnect, queryClient, projectId]);
|
}, [requiresReconnect, queryClient, projectId]);
|
||||||
|
|
||||||
const setSiteMutation = useMutation({
|
const setSiteMutation = useMutation({
|
||||||
mutationFn: (siteUrl: string) =>
|
mutationFn: (selected: GscSiteSelection) =>
|
||||||
setGscSite({ data: { projectId, siteUrl } }),
|
setGscSite({ data: { projectId, ...selected } }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
captureClientEvent("gsc:property_select");
|
captureClientEvent("gsc:property_select");
|
||||||
void queryClient.invalidateQueries({ queryKey: connectionKey });
|
void queryClient.invalidateQueries({ queryKey: connectionKey });
|
||||||
@ -113,14 +127,13 @@ function GscConnect({ projectId }: { projectId: string }) {
|
|||||||
return (
|
return (
|
||||||
<SitePicker
|
<SitePicker
|
||||||
loading={sitesQuery.isLoading}
|
loading={sitesQuery.isLoading}
|
||||||
error={sitesQuery.isError || requiresReconnect}
|
error={sitesQuery.isError}
|
||||||
sites={sitesQuery.data?.sites ?? []}
|
accounts={accounts}
|
||||||
selectedSiteUrl={selectedSiteUrl}
|
selection={selection}
|
||||||
onSelect={setSelectedSiteUrl}
|
onSelect={setSelection}
|
||||||
onSave={() =>
|
onSave={() => selection && setSiteMutation.mutate(selection)}
|
||||||
selectedSiteUrl && setSiteMutation.mutate(selectedSiteUrl)
|
|
||||||
}
|
|
||||||
saving={setSiteMutation.isPending}
|
saving={setSiteMutation.isPending}
|
||||||
|
onRetry={() => void sitesQuery.refetch()}
|
||||||
onReconnect={handleConnect}
|
onReconnect={handleConnect}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -22,6 +22,7 @@ export const gscConnections = sqliteTable(
|
|||||||
siteUrl: text("site_url").notNull(),
|
siteUrl: text("site_url").notNull(),
|
||||||
// Whose google-search-console grant getAccessToken should use.
|
// Whose google-search-console grant getAccessToken should use.
|
||||||
connectedByUserId: text("connected_by_user_id").notNull(),
|
connectedByUserId: text("connected_by_user_id").notNull(),
|
||||||
|
gscAccountId: text("gsc_account_id"),
|
||||||
connectedAccountEmail: text("connected_account_email"),
|
connectedAccountEmail: text("connected_account_email"),
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
|
|||||||
@ -25,6 +25,7 @@ export const gscConnections = pgTable(
|
|||||||
siteUrl: text("site_url").notNull(),
|
siteUrl: text("site_url").notNull(),
|
||||||
// Whose google-search-console grant getAccessToken should use.
|
// Whose google-search-console grant getAccessToken should use.
|
||||||
connectedByUserId: text("connected_by_user_id").notNull(),
|
connectedByUserId: text("connected_by_user_id").notNull(),
|
||||||
|
gscAccountId: text("gsc_account_id"),
|
||||||
connectedAccountEmail: text("connected_account_email"),
|
connectedAccountEmail: text("connected_account_email"),
|
||||||
createdAt: text("created_at").notNull().default(isoNow),
|
createdAt: text("created_at").notNull().default(isoNow),
|
||||||
updatedAt: text("updated_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",
|
"https://accounts.google.com/.well-known/openid-configuration",
|
||||||
scopes: [...GSC_OAUTH_SCOPES],
|
scopes: [...GSC_OAUTH_SCOPES],
|
||||||
accessType: "offline", // request a refresh token
|
accessType: "offline", // request a refresh token
|
||||||
prompt: "consent", // force refresh-token issuance on re-consent
|
prompt: "select_account consent",
|
||||||
pkce: true,
|
pkce: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { eq, sql } from "drizzle-orm";
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { gscConnections } from "@/db/schema";
|
import { gscConnections } from "@/db/schema";
|
||||||
|
|
||||||
@ -20,6 +20,7 @@ async function upsert(input: {
|
|||||||
organizationId: string;
|
organizationId: string;
|
||||||
siteUrl: string;
|
siteUrl: string;
|
||||||
connectedByUserId: string;
|
connectedByUserId: string;
|
||||||
|
gscAccountId: string;
|
||||||
connectedAccountEmail: string | null;
|
connectedAccountEmail: string | null;
|
||||||
}): Promise<GscConnection> {
|
}): Promise<GscConnection> {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
@ -31,7 +32,8 @@ async function upsert(input: {
|
|||||||
siteUrl: input.siteUrl,
|
siteUrl: input.siteUrl,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
connectedByUserId: input.connectedByUserId,
|
connectedByUserId: input.connectedByUserId,
|
||||||
connectedAccountEmail: input.connectedAccountEmail,
|
gscAccountId: input.gscAccountId,
|
||||||
|
connectedAccountEmail: sql`coalesce(${input.connectedAccountEmail}, ${gscConnections.connectedAccountEmail})`,
|
||||||
updatedAt: sql`(current_timestamp)`,
|
updatedAt: sql`(current_timestamp)`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -48,12 +50,19 @@ async function deleteByProjectId(projectId: string): Promise<void> {
|
|||||||
.where(eq(gscConnections.projectId, projectId));
|
.where(eq(gscConnections.projectId, projectId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether this user is still the connector for any project's GSC property. */
|
async function existsForConnectorAccount(
|
||||||
async function existsForConnector(userId: string): Promise<boolean> {
|
userId: string,
|
||||||
|
gscAccountId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({ id: gscConnections.id })
|
.select({ id: gscConnections.id })
|
||||||
.from(gscConnections)
|
.from(gscConnections)
|
||||||
.where(eq(gscConnections.connectedByUserId, userId))
|
.where(
|
||||||
|
and(
|
||||||
|
eq(gscConnections.connectedByUserId, userId),
|
||||||
|
eq(gscConnections.gscAccountId, gscAccountId),
|
||||||
|
),
|
||||||
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
return rows.length > 0;
|
return rows.length > 0;
|
||||||
}
|
}
|
||||||
@ -62,5 +71,5 @@ export const GscConnectionRepository = {
|
|||||||
getByProjectId,
|
getByProjectId,
|
||||||
upsert,
|
upsert,
|
||||||
deleteByProjectId,
|
deleteByProjectId,
|
||||||
existsForConnector,
|
existsForConnectorAccount,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -173,6 +173,7 @@ async function upsertGrant(input: {
|
|||||||
ctx.options.account?.encryptOAuthTokens
|
ctx.options.account?.encryptOAuthTokens
|
||||||
? symmetricEncrypt({ key: ctx.secretConfig, data: value })
|
? symmetricEncrypt({ key: ctx.secretConfig, data: value })
|
||||||
: value;
|
: value;
|
||||||
|
const googleAccountId = getGoogleAccountId(input.tokens);
|
||||||
|
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select({ id: account.id, refreshToken: account.refreshToken })
|
.select({ id: account.id, refreshToken: account.refreshToken })
|
||||||
@ -181,12 +182,13 @@ async function upsertGrant(input: {
|
|||||||
and(
|
and(
|
||||||
eq(account.userId, input.user.userId),
|
eq(account.userId, input.user.userId),
|
||||||
eq(account.providerId, GSC_OAUTH_PROVIDER_ID),
|
eq(account.providerId, GSC_OAUTH_PROVIDER_ID),
|
||||||
|
eq(account.accountId, googleAccountId),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
const accountValues = {
|
const accountValues = {
|
||||||
accountId: getGoogleAccountId(input.tokens),
|
accountId: googleAccountId,
|
||||||
providerId: GSC_OAUTH_PROVIDER_ID,
|
providerId: GSC_OAUTH_PROVIDER_ID,
|
||||||
userId: input.user.userId,
|
userId: input.user.userId,
|
||||||
accessToken: await encrypt(input.tokens.access_token),
|
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("response_type", "code");
|
||||||
url.searchParams.set("scope", GSC_OAUTH_SCOPES.join(" "));
|
url.searchParams.set("scope", GSC_OAUTH_SCOPES.join(" "));
|
||||||
url.searchParams.set("access_type", "offline");
|
url.searchParams.set("access_type", "offline");
|
||||||
url.searchParams.set("prompt", "consent");
|
url.searchParams.set("prompt", "select_account consent");
|
||||||
url.searchParams.set("state", state);
|
url.searchParams.set("state", state);
|
||||||
|
|
||||||
return url.toString();
|
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";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => {
|
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 {
|
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(),
|
upsert: vi.fn(),
|
||||||
getByProjectId: vi.fn(),
|
getByProjectId: vi.fn(),
|
||||||
deleteByProjectId: vi.fn(),
|
deleteByProjectId: vi.fn(),
|
||||||
existsForConnector: vi.fn(),
|
existsForConnectorAccount: vi.fn(),
|
||||||
dbDelete: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
|
|
||||||
GscApiError,
|
GscApiError,
|
||||||
GscTokenError,
|
GscTokenError,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("cloudflare:workers", () => ({ env: {} }));
|
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", () => ({
|
vi.mock("@/server/lib/gscClient", () => ({
|
||||||
createGscClient: () => ({ listSites: mocks.listSites }),
|
createGscClient: mocks.createGscClient,
|
||||||
GscApiError: mocks.GscApiError,
|
GscApiError: mocks.GscApiError,
|
||||||
GscTokenError: mocks.GscTokenError,
|
GscTokenError: mocks.GscTokenError,
|
||||||
}));
|
}));
|
||||||
@ -42,42 +80,98 @@ vi.mock("@/server/features/gsc/repositories/GscConnectionRepository", () => ({
|
|||||||
upsert: mocks.upsert,
|
upsert: mocks.upsert,
|
||||||
getByProjectId: mocks.getByProjectId,
|
getByProjectId: mocks.getByProjectId,
|
||||||
deleteByProjectId: mocks.deleteByProjectId,
|
deleteByProjectId: mocks.deleteByProjectId,
|
||||||
existsForConnector: mocks.existsForConnector,
|
existsForConnectorAccount: mocks.existsForConnectorAccount,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const baseInput = {
|
const baseInput = {
|
||||||
projectId: "p1",
|
projectId: "p1",
|
||||||
organizationId: "org1",
|
organizationId: "org1",
|
||||||
|
accountId: "sub-a",
|
||||||
userId: "u1",
|
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", () => {
|
describe("GscService.setSite", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
mocks.state.selectRows = [{ id: "grant-a", accountId: "sub-a" }];
|
||||||
mocks.listSites.mockReset();
|
mocks.listSites.mockReset();
|
||||||
|
mocks.getUserInfoEmail.mockReset();
|
||||||
|
mocks.createGscClient.mockClear();
|
||||||
mocks.upsert.mockReset();
|
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([
|
mocks.listSites.mockResolvedValue([
|
||||||
{ siteUrl: "https://x/", permissionLevel: "siteOwner" },
|
{ siteUrl: "https://x/", permissionLevel: "siteOwner" },
|
||||||
]);
|
]);
|
||||||
|
mocks.getUserInfoEmail.mockResolvedValue("client@example.com");
|
||||||
mocks.upsert.mockResolvedValue({ siteUrl: "https://x/" });
|
mocks.upsert.mockResolvedValue({ siteUrl: "https://x/" });
|
||||||
const { GscService } = await import("./GscService");
|
const { GscService } = await import("./GscService");
|
||||||
|
|
||||||
await GscService.setSite({ ...baseInput, siteUrl: "https://x/" });
|
await GscService.setSite({ ...baseInput, siteUrl: "https://x/" });
|
||||||
|
|
||||||
|
expect(mocks.createGscClient).toHaveBeenCalledWith({
|
||||||
|
userId: "u1",
|
||||||
|
gscAccountId: "sub-a",
|
||||||
|
});
|
||||||
expect(mocks.upsert).toHaveBeenCalledWith(
|
expect(mocks.upsert).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
projectId: "p1",
|
projectId: "p1",
|
||||||
siteUrl: "https://x/",
|
siteUrl: "https://x/",
|
||||||
connectedByUserId: "u1",
|
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 () => {
|
it("rejects an unverified property with FORBIDDEN", async () => {
|
||||||
mocks.listSites.mockResolvedValue([
|
mocks.listSites.mockResolvedValue([
|
||||||
{ siteUrl: "https://x/", permissionLevel: "siteUnverifiedUser" },
|
{ siteUrl: "https://x/", permissionLevel: "siteUnverifiedUser" },
|
||||||
@ -90,7 +184,7 @@ describe("GscService.setSite", () => {
|
|||||||
expect(mocks.upsert).not.toHaveBeenCalled();
|
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([
|
mocks.listSites.mockResolvedValue([
|
||||||
{ siteUrl: "https://x/", permissionLevel: "siteOwner" },
|
{ siteUrl: "https://x/", permissionLevel: "siteOwner" },
|
||||||
]);
|
]);
|
||||||
@ -105,11 +199,57 @@ describe("GscService.setSite", () => {
|
|||||||
|
|
||||||
describe("GscService.listSitesForUserWithGrantStatus", () => {
|
describe("GscService.listSitesForUserWithGrantStatus", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
mocks.state.selectRows = [
|
||||||
|
{ id: "grant-a", accountId: "sub-a" },
|
||||||
|
{ id: "grant-b", accountId: "sub-b" },
|
||||||
|
];
|
||||||
mocks.listSites.mockReset();
|
mocks.listSites.mockReset();
|
||||||
|
mocks.getUserInfoEmail.mockReset();
|
||||||
|
mocks.createGscClient.mockClear();
|
||||||
mocks.dbDelete.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([
|
mocks.listSites.mockResolvedValue([
|
||||||
{ siteUrl: "https://x/", permissionLevel: "siteOwner" },
|
{ siteUrl: "https://x/", permissionLevel: "siteOwner" },
|
||||||
]);
|
]);
|
||||||
@ -118,23 +258,20 @@ describe("GscService.listSitesForUserWithGrantStatus", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
GscService.listSitesForUserWithGrantStatus("u1"),
|
GscService.listSitesForUserWithGrantStatus("u1"),
|
||||||
).resolves.toEqual({
|
).resolves.toEqual({
|
||||||
sites: [{ siteUrl: "https://x/", permissionLevel: "siteOwner" }],
|
accounts: [
|
||||||
requiresReconnect: false,
|
{
|
||||||
|
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 () => {
|
it("marks a grant for reconnect on a GSC 403 without deleting it", async () => {
|
||||||
mocks.listSites.mockRejectedValue(new mocks.GscTokenError());
|
mocks.state.selectRows = [{ id: "grant-a", accountId: "sub-a" }];
|
||||||
const { GscService } = await import("./GscService");
|
mocks.getUserInfoEmail.mockResolvedValue("a@example.com");
|
||||||
|
|
||||||
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 () => {
|
|
||||||
mocks.listSites.mockRejectedValue(
|
mocks.listSites.mockRejectedValue(
|
||||||
new mocks.GscApiError(403, "Search Console denied access"),
|
new mocks.GscApiError(403, "Search Console denied access"),
|
||||||
);
|
);
|
||||||
@ -142,31 +279,112 @@ describe("GscService.listSitesForUserWithGrantStatus", () => {
|
|||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
GscService.listSitesForUserWithGrantStatus("u1"),
|
GscService.listSitesForUserWithGrantStatus("u1"),
|
||||||
).resolves.toEqual({ sites: [], requiresReconnect: true });
|
).resolves.toEqual({
|
||||||
expect(mocks.dbDelete).not.toHaveBeenCalled();
|
accounts: [
|
||||||
});
|
{
|
||||||
|
accountId: "sub-a",
|
||||||
it("asks for reconnect without unlinking on a 401", async () => {
|
email: null,
|
||||||
mocks.listSites.mockRejectedValue(
|
requiresReconnect: true,
|
||||||
new mocks.GscApiError(401, "unauthenticated"),
|
sites: [],
|
||||||
);
|
},
|
||||||
const { GscService } = await import("./GscService");
|
],
|
||||||
|
});
|
||||||
await expect(
|
expect(mocks.getUserInfoEmail).not.toHaveBeenCalled();
|
||||||
GscService.listSitesForUserWithGrantStatus("u1"),
|
|
||||||
).resolves.toEqual({ sites: [], requiresReconnect: true });
|
|
||||||
expect(mocks.dbDelete).not.toHaveBeenCalled();
|
expect(mocks.dbDelete).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps non-auth GSC API errors reportable", async () => {
|
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");
|
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");
|
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,
|
rateLimit,
|
||||||
);
|
);
|
||||||
expect(mocks.dbDelete).not.toHaveBeenCalled();
|
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(() => {
|
beforeEach(() => {
|
||||||
mocks.getByProjectId.mockReset();
|
mocks.getByProjectId.mockReset();
|
||||||
mocks.deleteByProjectId.mockReset().mockResolvedValue(undefined);
|
mocks.deleteByProjectId.mockReset().mockResolvedValue(undefined);
|
||||||
mocks.existsForConnector.mockReset();
|
mocks.existsForConnectorAccount.mockReset();
|
||||||
mocks.dbDelete.mockClear();
|
mocks.dbDelete.mockClear();
|
||||||
|
mocks.deleteWhere.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("unlinks the connector's grant when they disconnect their last project", async () => {
|
it("unlinks only the disconnected account when it is no longer used", async () => {
|
||||||
mocks.getByProjectId.mockResolvedValue({ connectedByUserId: "u1" });
|
mocks.getByProjectId.mockResolvedValue({
|
||||||
mocks.existsForConnector.mockResolvedValue(false);
|
connectedByUserId: "u1",
|
||||||
|
gscAccountId: "sub-b",
|
||||||
|
});
|
||||||
|
mocks.existsForConnectorAccount.mockResolvedValue(false);
|
||||||
const { GscService } = await import("./GscService");
|
const { GscService } = await import("./GscService");
|
||||||
|
|
||||||
await GscService.disconnect({ projectId: "p1", userId: "u1" });
|
await GscService.disconnect({ projectId: "p1", userId: "u1" });
|
||||||
|
|
||||||
expect(mocks.deleteByProjectId).toHaveBeenCalledWith("p1");
|
expect(mocks.deleteByProjectId).toHaveBeenCalledWith("p1");
|
||||||
expect(mocks.existsForConnector).toHaveBeenCalledWith("u1");
|
expect(mocks.existsForConnectorAccount).toHaveBeenCalledWith("u1", "sub-b");
|
||||||
expect(mocks.dbDelete).toHaveBeenCalled(); // grant unlinked
|
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 () => {
|
it("keeps the grant when the same account powers another project", async () => {
|
||||||
mocks.getByProjectId.mockResolvedValue({ connectedByUserId: "u1" });
|
mocks.getByProjectId.mockResolvedValue({
|
||||||
mocks.existsForConnector.mockResolvedValue(true);
|
connectedByUserId: "u1",
|
||||||
|
gscAccountId: "sub-b",
|
||||||
|
});
|
||||||
|
mocks.existsForConnectorAccount.mockResolvedValue(true);
|
||||||
const { GscService } = await import("./GscService");
|
const { GscService } = await import("./GscService");
|
||||||
|
|
||||||
await GscService.disconnect({ projectId: "p1", userId: "u1" });
|
await GscService.disconnect({ projectId: "p1", userId: "u1" });
|
||||||
@ -200,27 +429,40 @@ describe("GscService.disconnect", () => {
|
|||||||
expect(mocks.dbDelete).not.toHaveBeenCalled();
|
expect(mocks.dbDelete).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never revokes a grant when a different member disconnects the connection", async () => {
|
it("never revokes a grant when another member disconnects", async () => {
|
||||||
mocks.getByProjectId.mockResolvedValue({ connectedByUserId: "owner" });
|
mocks.getByProjectId.mockResolvedValue({
|
||||||
|
connectedByUserId: "owner",
|
||||||
|
gscAccountId: "sub-b",
|
||||||
|
});
|
||||||
const { GscService } = await import("./GscService");
|
const { GscService } = await import("./GscService");
|
||||||
|
|
||||||
await GscService.disconnect({ projectId: "p1", userId: "other-member" });
|
await GscService.disconnect({ projectId: "p1", userId: "other-member" });
|
||||||
|
|
||||||
expect(mocks.deleteByProjectId).toHaveBeenCalledWith("p1");
|
expect(mocks.existsForConnectorAccount).not.toHaveBeenCalled();
|
||||||
expect(mocks.existsForConnector).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.dbDelete).not.toHaveBeenCalled();
|
expect(mocks.dbDelete).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("unlinks the caller's dangling grant when no property was ever bound", async () => {
|
it("deletes no grants for a legacy null-account connection", async () => {
|
||||||
// Linked Google but never picked a property → no connection row. Disconnect
|
mocks.getByProjectId.mockResolvedValue({
|
||||||
// should still drop the caller's own grant.
|
connectedByUserId: "u1",
|
||||||
mocks.getByProjectId.mockResolvedValue(null);
|
gscAccountId: null,
|
||||||
mocks.existsForConnector.mockResolvedValue(false);
|
});
|
||||||
const { GscService } = await import("./GscService");
|
const { GscService } = await import("./GscService");
|
||||||
|
|
||||||
await GscService.disconnect({ projectId: "p1", userId: "u1" });
|
await GscService.disconnect({ projectId: "p1", userId: "u1" });
|
||||||
|
|
||||||
expect(mocks.existsForConnector).toHaveBeenCalledWith("u1");
|
expect(mocks.deleteByProjectId).toHaveBeenCalledWith("p1");
|
||||||
expect(mocks.dbDelete).toHaveBeenCalled(); // grant unlinked
|
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 = {
|
type GscSiteListResult = {
|
||||||
sites: GscSite[];
|
accounts: Array<{
|
||||||
requiresReconnect: boolean;
|
accountId: string;
|
||||||
|
email: string | null;
|
||||||
|
requiresReconnect: boolean;
|
||||||
|
sites: GscSite[];
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Thrown when a project has no connected GSC property. */
|
/** Thrown when a project has no connected GSC property. */
|
||||||
@ -65,15 +69,21 @@ async function userHasGrant(userId: string): Promise<boolean> {
|
|||||||
return rows.length > 0;
|
return rows.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** List verified properties available on a user's google-search-console grant. */
|
async function listGrantsForUser(userId: string) {
|
||||||
async function listSitesForUser(userId: string): Promise<GscSite[]> {
|
return db
|
||||||
return createGscClient({ userId }).listSites();
|
.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
|
/** 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
|
* minted (refresh token revoked or expired), or Google rejected the call
|
||||||
* (401/403). These surface a reconnect prompt instead of being routed through
|
* (401/403). These surface a reconnect prompt without fault logging. */
|
||||||
* error tracking. Other statuses (429, 5xx) are genuine faults and propagate. */
|
|
||||||
export function isExpectedGrantFailure(error: unknown): boolean {
|
export function isExpectedGrantFailure(error: unknown): boolean {
|
||||||
if (error instanceof GscTokenError) return true;
|
if (error instanceof GscTokenError) return true;
|
||||||
return (
|
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(
|
async function listSitesForUserWithGrantStatus(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<GscSiteListResult> {
|
): Promise<GscSiteListResult> {
|
||||||
try {
|
const grants = await listGrantsForUser(userId);
|
||||||
return { sites: await listSitesForUser(userId), requiresReconnect: false };
|
const accounts = await Promise.all(
|
||||||
} catch (error) {
|
grants.map(async (grant) => {
|
||||||
if (!isExpectedGrantFailure(error)) {
|
const client = createGscClient({
|
||||||
throw error;
|
userId,
|
||||||
}
|
gscAccountId: grant.accountId,
|
||||||
if (error instanceof GscTokenError) {
|
});
|
||||||
await unlinkUserGrant(userId);
|
|
||||||
}
|
try {
|
||||||
return { sites: [], requiresReconnect: true };
|
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
|
/** Map a verified property to a project. Rejects unverified properties and
|
||||||
@ -114,10 +143,22 @@ async function setSite(input: {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
siteUrl: string;
|
siteUrl: string;
|
||||||
|
accountId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
userEmail: string;
|
|
||||||
}): Promise<GscConnection> {
|
}): 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);
|
const match = sites.find((s) => s.siteUrl === input.siteUrl);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
@ -131,23 +172,33 @@ async function setSite(input: {
|
|||||||
"You don't have verified access to that Search Console property.",
|
"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({
|
return GscConnectionRepository.upsert({
|
||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
siteUrl: input.siteUrl,
|
siteUrl: input.siteUrl,
|
||||||
connectedByUserId: input.userId,
|
connectedByUserId: input.userId,
|
||||||
connectedAccountEmail: input.userEmail,
|
gscAccountId: input.accountId,
|
||||||
|
connectedAccountEmail,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove this user's google-search-console grant (stored OAuth tokens). */
|
async function unlinkUserGrant(
|
||||||
async function unlinkUserGrant(userId: string): Promise<void> {
|
userId: string,
|
||||||
|
gscAccountId: string,
|
||||||
|
): Promise<void> {
|
||||||
await db
|
await db
|
||||||
.delete(account)
|
.delete(account)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(account.userId, userId),
|
eq(account.userId, userId),
|
||||||
eq(account.providerId, GSC_OAUTH_PROVIDER_ID),
|
eq(account.providerId, GSC_OAUTH_PROVIDER_ID),
|
||||||
|
eq(account.accountId, gscAccountId),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -160,19 +211,16 @@ async function disconnect(input: {
|
|||||||
input.projectId,
|
input.projectId,
|
||||||
);
|
);
|
||||||
await GscConnectionRepository.deleteByProjectId(input.projectId);
|
await GscConnectionRepository.deleteByProjectId(input.projectId);
|
||||||
// Clean up the caller's *own* OAuth grant once none of their projects still
|
if (
|
||||||
// use it. Safe by construction: unlinkUserGrant only ever deletes the
|
connection?.gscAccountId &&
|
||||||
// caller's account row, never another member's. We skip cleanup only when the
|
connection.connectedByUserId === input.userId
|
||||||
// binding we removed belonged to a *different* member, so unbinding their
|
) {
|
||||||
// property never revokes the caller's unrelated grant. A null connection
|
const stillUsed = await GscConnectionRepository.existsForConnectorAccount(
|
||||||
// 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(
|
|
||||||
input.userId,
|
input.userId,
|
||||||
|
connection.gscAccountId,
|
||||||
);
|
);
|
||||||
if (!stillUsed) {
|
if (!stillUsed) {
|
||||||
await unlinkUserGrant(input.userId);
|
await unlinkUserGrant(input.userId, connection.gscAccountId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -188,7 +236,10 @@ async function getPerformance(
|
|||||||
throw new GscNotConnectedError(input.projectId);
|
throw new GscNotConnectedError(input.projectId);
|
||||||
}
|
}
|
||||||
const request = buildSearchAnalyticsRequest(input);
|
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);
|
const rows = await client.querySearchAnalytics(connection.siteUrl, request);
|
||||||
return {
|
return {
|
||||||
siteUrl: connection.siteUrl,
|
siteUrl: connection.siteUrl,
|
||||||
@ -225,7 +276,10 @@ async function inspectUrls(input: {
|
|||||||
if (!connection) {
|
if (!connection) {
|
||||||
throw new GscNotConnectedError(input.projectId);
|
throw new GscNotConnectedError(input.projectId);
|
||||||
}
|
}
|
||||||
const client = createGscClient({ userId: connection.connectedByUserId });
|
const client = createGscClient({
|
||||||
|
userId: connection.connectedByUserId,
|
||||||
|
gscAccountId: connection.gscAccountId ?? undefined,
|
||||||
|
});
|
||||||
const results: GscUrlInspection[] = [];
|
const results: GscUrlInspection[] = [];
|
||||||
for (const url of input.urls) {
|
for (const url of input.urls) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -39,6 +39,52 @@ describe("gscClient", () => {
|
|||||||
expect(init?.headers).toMatchObject({ Authorization: "Bearer tok_123" });
|
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 () => {
|
it("encodes the siteUrl in the searchAnalytics path (both property forms)", async () => {
|
||||||
mocks.fetch.mockImplementation(async () => jsonResponse({ rows: [] }));
|
mocks.fetch.mockImplementation(async () => jsonResponse({ rows: [] }));
|
||||||
const { createGscClient } = await import("./gscClient");
|
const { createGscClient } = await import("./gscClient");
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { getAuth } from "@/lib/auth";
|
|||||||
import { GSC_OAUTH_PROVIDER_ID } from "@/shared/gsc";
|
import { GSC_OAUTH_PROVIDER_ID } from "@/shared/gsc";
|
||||||
|
|
||||||
const GSC_API_BASE = "https://www.googleapis.com/webmasters/v3";
|
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. */
|
/** A GSC REST call returned a non-2xx status. `status` drives user-facing messaging. */
|
||||||
export class GscApiError extends Error {
|
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
|
* 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
|
* are minted (and auto-refreshed) by Better Auth from the connector's stored
|
||||||
* google-search-console grant. */
|
* google-search-console grant. */
|
||||||
export function createGscClient(opts: { userId: string }) {
|
export function createGscClient(opts: {
|
||||||
|
userId: string;
|
||||||
|
gscAccountId?: string;
|
||||||
|
}) {
|
||||||
async function getToken(): Promise<string> {
|
async function getToken(): Promise<string> {
|
||||||
let result: { accessToken?: string } | undefined;
|
let result: { accessToken?: string } | undefined;
|
||||||
try {
|
try {
|
||||||
@ -108,7 +112,11 @@ export function createGscClient(opts: { userId: string }) {
|
|||||||
// Works in every auth mode — self-hosted builds the same Better Auth
|
// Works in every auth mode — self-hosted builds the same Better Auth
|
||||||
// instance once BETTER_AUTH_SECRET is set.
|
// instance once BETTER_AUTH_SECRET is set.
|
||||||
result = await getAuth().api.getAccessToken({
|
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) {
|
} catch (error) {
|
||||||
throw new GscTokenError(
|
throw new GscTokenError(
|
||||||
@ -150,6 +158,11 @@ export function createGscClient(opts: { userId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
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. */
|
/** Webmasters API `sites.list` — the verified properties on the grant. */
|
||||||
async listSites(): Promise<GscSite[]> {
|
async listSites(): Promise<GscSite[]> {
|
||||||
const data = await request<{ siteEntry?: GscSite[] }>(
|
const data = await request<{ siteEntry?: GscSite[] }>(
|
||||||
|
|||||||
@ -110,9 +110,7 @@ describe("instrumentMcpToolHandler", () => {
|
|||||||
okResult({ items: [] }),
|
okResult({ items: [] }),
|
||||||
);
|
);
|
||||||
|
|
||||||
await runWithMcpToolAuthContext(authContext, () =>
|
await runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra));
|
||||||
wrapped({}, toolExtra),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mocks.captureServerEvent).toHaveBeenCalledTimes(1);
|
expect(mocks.captureServerEvent).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({
|
expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({
|
||||||
@ -134,9 +132,7 @@ describe("instrumentMcpToolHandler", () => {
|
|||||||
okResult({ items: "not-an-array" }),
|
okResult({ items: "not-an-array" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
await runWithMcpToolAuthContext(authContext, () =>
|
await runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra));
|
||||||
wrapped({}, toolExtra),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({
|
expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({
|
||||||
event: "mcp:tool_call",
|
event: "mcp:tool_call",
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
|
|
||||||
const projectScopedSchema = z.object({ projectId: z.string().min(1) });
|
const projectScopedSchema = z.object({ projectId: z.string().min(1) });
|
||||||
const setSiteSchema = projectScopedSchema.extend({
|
const setSiteSchema = projectScopedSchema.extend({
|
||||||
|
accountId: z.string().min(1),
|
||||||
siteUrl: z.string().min(1),
|
siteUrl: z.string().min(1),
|
||||||
});
|
});
|
||||||
const startSelfHostedLinkSchema = z.object({
|
const startSelfHostedLinkSchema = z.object({
|
||||||
@ -59,13 +60,27 @@ export const listGscSites = createServerFn({ method: "POST" })
|
|||||||
GscService.listSitesForUserWithGrantStatus(context.userId),
|
GscService.listSitesForUserWithGrantStatus(context.userId),
|
||||||
GscService.getConnection(context.projectId),
|
GscService.getConnection(context.projectId),
|
||||||
]);
|
]);
|
||||||
|
let legacySelectionMatched = false;
|
||||||
return {
|
return {
|
||||||
requiresReconnect: siteList.requiresReconnect,
|
accounts: siteList.accounts.map((grant) => ({
|
||||||
sites: siteList.sites.map((s) => ({
|
accountId: grant.accountId,
|
||||||
siteUrl: s.siteUrl,
|
email: grant.email,
|
||||||
permissionLevel: s.permissionLevel,
|
requiresReconnect: grant.requiresReconnect,
|
||||||
selectable: s.permissionLevel !== "siteUnverifiedUser",
|
sites: grant.sites.map((site) => {
|
||||||
isSelected: s.siteUrl === connection?.siteUrl,
|
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({
|
const connection = await GscService.setSite({
|
||||||
projectId: context.projectId,
|
projectId: context.projectId,
|
||||||
organizationId: context.organizationId,
|
organizationId: context.organizationId,
|
||||||
|
accountId: data.accountId,
|
||||||
siteUrl: data.siteUrl,
|
siteUrl: data.siteUrl,
|
||||||
userId: context.userId,
|
userId: context.userId,
|
||||||
userEmail: context.userEmail,
|
|
||||||
});
|
});
|
||||||
waitUntil(
|
waitUntil(
|
||||||
captureServerEvent({
|
captureServerEvent({
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user