Skip to content

Commit 13f355c

Browse files
chore(release): v0.18.0
GitOrigin-RevId: c437e4ba2dfb99d3ea8bb818c262d7c234f2bcd9
1 parent 7fb6ba2 commit 13f355c

235 files changed

Lines changed: 7247 additions & 1092 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.storybook/browser-runtime-stubs.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -449,8 +449,12 @@ export function resetRedisClientForTests() {
449449
return undefined;
450450
}
451451

452-
export async function resendSignInOtp(email: string) {
453-
return { email, ok: true, retryAfter: 60 };
452+
export async function resendSignInOtp(input: { email: string }) {
453+
return { email: input.email, ok: true, retryAfter: 60 };
454+
}
455+
456+
export async function requestLoginCode() {
457+
return { ok: true as const };
454458
}
455459

456460
export function unwrapActionResult<T>(result: {

.storybook/main.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,11 @@ const serverActionPattern =
4747
const runtimeStubPatterns = [
4848
/^@\/components\/shell\/keyword-search$/,
4949
/^@\/lib\/api\/ratelimit$/,
50-
/^@\/lib\/auth\/(auth|client|otp-resend|session)$/,
50+
/^@\/lib\/auth\/(auth|client|otp-resend|request-login-code|session)$/,
5151
/^@\/lib\/redis$/,
5252
/[\\/]components[\\/]shell[\\/]keyword-search\.ts$/,
5353
/[\\/]lib[\\/]api[\\/]ratelimit\.ts$/,
54-
/[\\/]lib[\\/]auth[\\/](auth|client|otp-resend|session)\.ts$/,
54+
/[\\/]lib[\\/]auth[\\/](auth|client|otp-resend|request-login-code|session)\.ts$/,
5555
/[\\/]lib[\\/]redis\.ts$/,
5656
];
5757
const prismaRuntimeStubPatterns = [
@@ -68,6 +68,7 @@ const runtimeAliases = {
6868
"@/lib/auth/auth": runtimeStubs,
6969
"@/lib/auth/client": runtimeStubs,
7070
"@/lib/auth/otp-resend": runtimeStubs,
71+
"@/lib/auth/request-login-code": runtimeStubs,
7172
"@/lib/api/ratelimit": runtimeStubs,
7273
"@/lib/auth/session": runtimeStubs,
7374
"@/lib/db/prisma": prismaRuntimeStub,

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## Unreleased
44

5+
## [0.18.0] - 2026-08-31
6+
7+
- Added a guided project setup checklist and clearer email-delivery failures for sign-in and team invitations.
8+
9+
- Clamped Search Console history imports to available data and retry new properties daily without spending requests on empty dates.
10+
511
## [0.17.0] - 2026-08-30
612

713
- Expanded GCS Insights with 16 months of Search Console history, daily sync, query and page drilldowns, GA4 sessions, and clearer connection controls.

app/(auth)/login/page.test.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { dynamic } from "./page";
66
const mocks = vi.hoisted(() => ({
77
getGitHubStars: vi.fn(),
88
getSession: vi.fn(),
9+
isEmailConfigured: vi.fn(),
10+
isFirstRun: vi.fn(),
911
getSignInCapacity: vi.fn(),
1012
loginForm: vi.fn(),
1113
}));
@@ -21,6 +23,8 @@ vi.mock("@/lib/auth/signin-capacity", () => ({
2123
}));
2224
vi.mock("@/lib/site/github-stars", () => ({ getGitHubStars: mocks.getGitHubStars }));
2325
vi.mock("@/lib/auth/session", () => ({ getSession: mocks.getSession }));
26+
vi.mock("@/lib/auth/first-run", () => ({ isFirstRun: mocks.isFirstRun }));
27+
vi.mock("@/lib/email/registry", () => ({ isEmailConfigured: mocks.isEmailConfigured }));
2428
vi.mock("@/lib/auth/auth", () => {
2529
throw new Error("The login page must not initialize the full auth server");
2630
});
@@ -41,6 +45,7 @@ type LoginFormProps = {
4145
dataResidencyMessage: string;
4246
demoEmail: string | null;
4347
devOtpCode: string | null;
48+
emailSignInUnavailable: boolean;
4449
enabledProviders: { github: boolean; google: boolean };
4550
legalConsentLinks: {
4651
privacyHref: string | null;
@@ -93,6 +98,8 @@ async function renderLoginPage(
9398

9499
beforeEach(() => {
95100
mocks.getSession.mockResolvedValue(null);
101+
mocks.isEmailConfigured.mockReturnValue(true);
102+
mocks.isFirstRun.mockResolvedValue(false);
96103
});
97104

98105
afterEach(() => {
@@ -257,6 +264,19 @@ describe("login page runtime rendering", () => {
257264
expect(props.returnTo).toBe("/app/settings?tab=access");
258265
});
259266

267+
it("pre-checks unavailable email sign-in for an established production instance", async () => {
268+
mocks.isEmailConfigured.mockReturnValue(false);
269+
mocks.isFirstRun.mockResolvedValue(false);
270+
271+
const { props } = await renderLoginPage({
272+
ALLOW_INSECURE_FIXED_OTP: undefined,
273+
DEMO_FIXED_OTP: undefined,
274+
NODE_ENV: "production",
275+
});
276+
277+
expect(props.emailSignInUnavailable).toBe(true);
278+
});
279+
260280
it("reflects the social providers configured at run time", async () => {
261281
const configured = await renderLoginPage({
262282
GITHUB_CLIENT_ID: "github-client",

app/(auth)/login/page.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { LoginForm } from "@/components/auth/LoginForm";
22
import { BrandLockup } from "@/components/ui";
3+
import { isEmailSignInUnavailable } from "@/lib/auth/email-sign-in-availability";
4+
import { isFirstRun } from "@/lib/auth/first-run";
35
import { returnToOrDefault } from "@/lib/auth/return-to";
46
import {
57
DEV_DEMO_EMAIL,
68
DEV_FIXED_OTP_CODE,
79
ENABLED_SOCIAL_PROVIDERS,
10+
FIXED_OTP_ENABLED,
811
} from "@/lib/auth/runtime-config";
912
import { getSession } from "@/lib/auth/session";
1013
import { getSignInCapacity } from "@/lib/auth/signin-capacity";
@@ -14,6 +17,7 @@ import {
1417
} from "@/lib/auth/signin-capacity-types";
1518
import { dataResidencyMessage, isCloud } from "@/lib/deployment/deployment";
1619
import { legalConsentLinks } from "@/lib/deployment/legal";
20+
import { isEmailConfigured } from "@/lib/email/registry";
1721
import { getGitHubStars } from "@/lib/site/github-stars";
1822
import { LICENSE } from "@/lib/site/site";
1923
import {
@@ -55,10 +59,17 @@ export default async function LoginPage({ searchParams }: Readonly<LoginPageProp
5559

5660
const capacityMiss: SignInCapacityMiss =
5761
error?.toLowerCase() === GOOGLE_CAPACITY_EXHAUSTED ? "google" : null;
58-
const [capacity, githubStars] = await Promise.all([
62+
const [capacity, githubStars, firstRun] = await Promise.all([
5963
isCloud ? getSignInCapacity() : Promise.resolve(null),
6064
getGitHubStars(),
65+
isFirstRun(),
6166
]);
67+
const emailSignInUnavailable = isEmailSignInUnavailable({
68+
firstRun,
69+
fixedOtpEnabled: FIXED_OTP_ENABLED,
70+
isEmailConfigured: isEmailConfigured(),
71+
production: process.env.NODE_ENV === "production",
72+
});
6273
const brandStats: { icon: typeof GithubLogo; label: string; tone?: string }[] = [
6374
...(githubStars ? [{ icon: GithubLogo, label: `${githubStars} stars` }] : []),
6475
{ icon: ShieldCheck, label: LICENSE, tone: "text-green-text" },
@@ -127,7 +138,9 @@ export default async function LoginPage({ searchParams }: Readonly<LoginPageProp
127138
demoEmail={DEV_DEMO_EMAIL}
128139
devOtpCode={DEV_FIXED_OTP_CODE}
129140
dataResidencyMessage={dataResidencyMessage()}
141+
emailSignInUnavailable={emailSignInUnavailable}
130142
enabledProviders={ENABLED_SOCIAL_PROVIDERS}
143+
humanVerificationRequired={isCloud}
131144
legalConsentLinks={legalConsentLinks()}
132145
returnTo={returnToOrDefault(next)}
133146
/>

app/app/(workspace)/[project]/backlinks/page.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ describe("BacklinksPage", () => {
3232
mocks.context.mockResolvedValue({
3333
costContext: { capCents: 5_000, spentCents: 0 },
3434
defaultTarget: "project.example",
35+
providerStatus: "connected",
3536
recentTargets: [],
3637
});
3738
mocks.analyze.mockResolvedValue({
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { render } from "@testing-library/react";
2+
import { describe, expect, it } from "vitest";
3+
import GettingStartedLoading from "./loading";
4+
5+
describe("GettingStartedLoading", () => {
6+
it("matches the final page anatomy without exposing loading copy", () => {
7+
const { container } = render(<GettingStartedLoading />);
8+
9+
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(12);
10+
expect(container.querySelectorAll("section")).toHaveLength(3);
11+
expect(container.querySelector('[data-testid="getting-started-loading-grid"]')).toHaveClass(
12+
"lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]",
13+
);
14+
expect(container.textContent).toBe("");
15+
});
16+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { PageContent } from "@/components/shell/PageContent";
2+
import { cn } from "@/lib/ui/cn";
3+
4+
function Bar({ className }: Readonly<{ className?: string }>) {
5+
return <div className={cn("animate-pulse rounded-control bg-bg-sunken", className)} />;
6+
}
7+
8+
const steps = ["create", "keywords", "source", "check"] as const;
9+
const cards = ["team", "ai", "github"] as const;
10+
11+
export default function GettingStartedLoading() {
12+
return (
13+
<PageContent aria-hidden className="grid gap-5">
14+
<div className="flex min-w-0 items-center gap-2">
15+
<Bar className="h-5 w-[74px]" />
16+
<Bar className="h-3.5 w-1 rounded-full" />
17+
<Bar className="size-[22px] shrink-0 rounded-full" />
18+
<Bar className="h-4 w-[62px]" />
19+
</div>
20+
21+
<div className="overflow-hidden rounded-card border border-border bg-bg-elev">
22+
<div
23+
className="grid min-w-0 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]"
24+
data-testid="getting-started-loading-grid"
25+
>
26+
<section className="min-w-0">
27+
{steps.map((step, index) => (
28+
<div
29+
className={cn(
30+
"flex min-h-[49px] items-center gap-3 px-5 py-3.5",
31+
index > 0 && "border-t border-border-soft",
32+
)}
33+
key={step}
34+
>
35+
<Bar className="size-5 shrink-0 rounded-full" />
36+
<Bar className={cn("h-4", index === 1 ? "w-[148px]" : "w-[126px]")} />
37+
</div>
38+
))}
39+
</section>
40+
<section className="hidden min-w-0 p-5 lg:flex">
41+
<div className="flex min-h-[260px] w-full flex-1 items-center justify-center rounded-card border border-dashed border-border-control bg-bg-sunken px-5 py-[22px]">
42+
<div className="flex w-full max-w-[280px] flex-col items-center gap-3">
43+
<Bar className="h-5 w-[78px] rounded-full bg-bg-elev" />
44+
<Bar className="h-4 w-[146px] bg-bg-elev" />
45+
<Bar className="h-3 w-full bg-bg-elev" />
46+
<Bar className="h-3 w-[82%] bg-bg-elev" />
47+
</div>
48+
</div>
49+
</section>
50+
</div>
51+
</div>
52+
53+
<section>
54+
<Bar className="h-2.5 w-[74px]" />
55+
<div className="mt-2.5 grid grid-cols-[repeat(auto-fit,minmax(190px,1fr))] gap-2.5">
56+
{cards.map((card) => (
57+
<div
58+
className="flex min-h-[90px] flex-col rounded-card border border-border bg-bg-elev px-4 py-3.5"
59+
key={card}
60+
>
61+
<Bar className="size-[17px]" />
62+
<Bar className="mt-2 h-3.5 w-[132px]" />
63+
<Bar className="mt-1 h-3 w-[82%]" />
64+
</div>
65+
))}
66+
</div>
67+
</section>
68+
</PageContent>
69+
);
70+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import type { ReactNode } from "react";
2+
import { renderToStaticMarkup } from "react-dom/server";
3+
import { beforeEach, describe, expect, it, vi } from "vitest";
4+
5+
const mocks = vi.hoisted(() => ({
6+
cookieValue: undefined as string | undefined,
7+
loadSetupContext: vi.fn(),
8+
querySession: vi.fn(),
9+
getCheckHealth: vi.fn(),
10+
getKeywordRows: vi.fn(),
11+
resolveSetupProgress: vi.fn(),
12+
}));
13+
14+
vi.mock("@/components/getting-started/GettingStartedHeaderProgress", () => ({
15+
GettingStartedHeaderProgress: (props: { completionMode: string }) => (
16+
<div data-header-mode={props.completionMode}>header</div>
17+
),
18+
}));
19+
vi.mock("@/components/getting-started/GettingStartedFirstCheckController", () => ({
20+
GettingStartedFirstCheckController: () => <div data-testid="checklist">checklist</div>,
21+
}));
22+
vi.mock("@/components/getting-started/GettingStartedCompletion", () => ({
23+
GettingStartedCompletion: (props: { acknowledged: boolean }) => (
24+
<div data-acknowledged={props.acknowledged}>completion</div>
25+
),
26+
}));
27+
vi.mock("@/components/getting-started/GoFurtherCards", () => ({
28+
GoFurtherCards: () => <div>go further</div>,
29+
}));
30+
vi.mock("@/components/shell/PageContent", () => ({
31+
PageContent: ({ children }: { children: ReactNode }) => <main>{children}</main>,
32+
}));
33+
vi.mock("@/lib/getting-started/setup-steps", () => ({
34+
resolveSetupProgress: mocks.resolveSetupProgress,
35+
}));
36+
vi.mock("@/lib/queries/_auth", () => ({ getQuerySession: mocks.querySession }));
37+
vi.mock("@/lib/queries/check-health", () => ({ getCheckHealth: mocks.getCheckHealth }));
38+
vi.mock("@/lib/queries/keywords", () => ({ getKeywordRows: mocks.getKeywordRows }));
39+
vi.mock("@/lib/queries/setup-context", () => ({ loadSetupContext: mocks.loadSetupContext }));
40+
vi.mock("next/headers", () => ({
41+
cookies: async () => ({ get: () => mocks.cookieValue && { value: mocks.cookieValue } }),
42+
}));
43+
44+
import GettingStartedPage from "./page";
45+
46+
const projectRef = "prj_abcdefghijklmnopqrstuvwx";
47+
const context = { project: { publicRef: projectRef } };
48+
49+
describe("getting started route", () => {
50+
beforeEach(() => {
51+
vi.clearAllMocks();
52+
mocks.cookieValue = undefined;
53+
mocks.querySession.mockResolvedValue({ user: { id: "user-1" } });
54+
mocks.loadSetupContext.mockResolvedValue(context);
55+
mocks.getCheckHealth.mockResolvedValue({
56+
providerRate: { overrideCents: 1, providerId: null },
57+
});
58+
mocks.getKeywordRows.mockResolvedValue([]);
59+
mocks.resolveSetupProgress.mockReturnValue({ doneCount: 4, steps: [], totalCount: 4 });
60+
});
61+
62+
it("loads and resolves the authoritative setup context once", async () => {
63+
const result = await GettingStartedPage({ params: Promise.resolve({ project: projectRef }) });
64+
const markup = renderToStaticMarkup(result);
65+
expect(mocks.loadSetupContext).toHaveBeenCalledOnce();
66+
expect(mocks.loadSetupContext).toHaveBeenCalledWith(projectRef);
67+
expect(mocks.resolveSetupProgress).toHaveBeenCalledOnce();
68+
expect(mocks.resolveSetupProgress).toHaveBeenCalledWith(context);
69+
expect(markup).toContain("go further");
70+
});
71+
72+
it("keeps completed state A across a second render without acknowledgement", async () => {
73+
const first = await GettingStartedPage({ params: Promise.resolve({ project: projectRef }) });
74+
const second = await GettingStartedPage({ params: Promise.resolve({ project: projectRef }) });
75+
expect(renderToStaticMarkup(first)).toContain('data-acknowledged="false"');
76+
expect(renderToStaticMarkup(second)).toContain('data-acknowledged="false"');
77+
});
78+
79+
it("renders state B after the acknowledgement cookie is present", async () => {
80+
const { addSetupAcknowledgement, serializeSetupAcknowledgements } = await import(
81+
"@/lib/getting-started/setup-acknowledgement"
82+
);
83+
mocks.cookieValue = serializeSetupAcknowledgements(
84+
addSetupAcknowledgement([], "user-1", projectRef),
85+
);
86+
const result = await GettingStartedPage({ params: Promise.resolve({ project: projectRef }) });
87+
const markup = renderToStaticMarkup(result);
88+
expect(markup).toContain('data-acknowledged="true"');
89+
expect(markup).toContain('data-header-mode="state-b"');
90+
});
91+
92+
it("renders the checklist before completion", async () => {
93+
mocks.resolveSetupProgress.mockReturnValue({ doneCount: 3, steps: [], totalCount: 4 });
94+
const result = await GettingStartedPage({ params: Promise.resolve({ project: projectRef }) });
95+
const markup = renderToStaticMarkup(result);
96+
expect(markup).toContain("checklist");
97+
expect(markup).not.toContain("completion");
98+
});
99+
});

0 commit comments

Comments
 (0)