Fix onboarding shared OAuth provider persistence (#1477)

This commit is contained in:
Konsti Wohlwend 2026-05-22 17:54:11 -07:00 committed by GitHub
parent 4f6eebd79f
commit 7e70b11d80
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 102 additions and 24 deletions

View File

@ -2,7 +2,9 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, waitFor } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
const mockUpdateConfig = vi.hoisted(() => vi.fn(async () => true));
vi.mock("@/components/design-components", () => ({
DesignCard: ({ children }: { children: ReactNode }) => <div>{children}</div>,
@ -82,7 +84,7 @@ vi.mock("@/lib/env", () => ({
}));
vi.mock("@/lib/config-update", () => ({
useUpdateConfig: () => vi.fn(async () => true),
useUpdateConfig: () => mockUpdateConfig,
}));
vi.mock("@stackframe/stack", () => ({
@ -91,7 +93,8 @@ vi.mock("@stackframe/stack", () => ({
}));
vi.mock("@stackframe/stack-shared/dist/utils/oauth", () => ({
allProviders: [],
allProviders: ["google", "github", "microsoft", "spotify"],
sharedProviders: ["google", "github", "microsoft", "spotify"],
}));
vi.mock("@stackframe/stack-shared/dist/utils/promises", () => ({
@ -142,6 +145,7 @@ import { ALL_APPS } from "@stackframe/stack-shared/dist/apps/apps-config";
afterEach(() => {
cleanup();
mockUpdateConfig.mockClear();
});
describe("ProjectOnboardingWizard", () => {
@ -226,4 +230,84 @@ describe("ProjectOnboardingWizard", () => {
});
expect(onComplete).not.toHaveBeenCalled();
});
it("persists shared OAuth providers selected during onboarding before completing", async () => {
const setStatus = vi.fn(async () => {});
const clearOnboardingState = vi.fn(async () => {});
const onComplete = vi.fn();
const app = {
setupPayments: vi.fn(async () => ({ url: "https://example.com" })),
useEmailThemes: () => [],
useStripeAccountInfo: () => null,
};
const project = {
id: "proj_123",
config: {
credentialEnabled: true,
magicLinkEnabled: false,
passkeyEnabled: false,
oauthProviders: [],
},
useConfig: () => ({
apps: {
installed: {
authentication: { enabled: true },
emails: { enabled: true },
payments: { enabled: false },
},
},
domains: {
trustedDomains: {},
},
emails: {
selectedThemeId: "default",
server: {},
},
}),
app,
};
render(
<ProjectOnboardingWizard
project={project as never}
status="welcome"
onboardingState={{
selected_config_choice: "create-new",
selected_apps: ["authentication", "emails"],
selected_sign_in_methods: ["credential", "google"],
selected_email_theme_id: "default",
selected_payments_country: "US",
}}
mode={null}
setMode={vi.fn()}
setStatus={setStatus}
setOnboardingState={vi.fn(async () => {})}
clearOnboardingState={clearOnboardingState}
onComplete={onComplete}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Get Started" }));
await waitFor(() => {
expect(mockUpdateConfig).toHaveBeenCalledTimes(2);
expect(mockUpdateConfig).toHaveBeenNthCalledWith(2, {
adminApp: app,
configUpdate: {
"auth.oauth.providers.google": {
type: "google",
isShared: true,
allowSignIn: true,
allowConnectedAccounts: true,
},
"auth.oauth.providers.github": null,
"auth.oauth.providers.microsoft": null,
},
pushable: false,
});
expect(setStatus).toHaveBeenCalledWith("completed");
expect(clearOnboardingState).toHaveBeenCalled();
expect(onComplete).toHaveBeenCalled();
});
});
});

View File

@ -33,7 +33,6 @@ import { AdminOwnedProject, AuthPage } from "@stackframe/stack";
import { type AppId } from "@stackframe/stack-shared/dist/apps/apps-config";
import { type EnvironmentConfigOverrideOverride } from "@stackframe/stack-shared/dist/config/schema";
import { projectOnboardingStatusValues, type ProjectOnboardingStatus } from "@stackframe/stack-shared/dist/schema-fields";
import { allProviders } from "@stackframe/stack-shared/dist/utils/oauth";
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -61,6 +60,7 @@ import {
PRIMARY_APP_IDS,
type ProjectOnboardingState,
REQUIRED_APP_IDS,
SHARED_OAUTH_SIGN_IN_METHODS,
SIGN_IN_METHODS,
type SignInMethod,
} from "./shared";
@ -236,8 +236,8 @@ export function ProjectOnboardingWizard(props: {
credentialEnabled: signInMethods.has("credential"),
magicLinkEnabled: signInMethods.has("magicLink"),
passkeyEnabled: signInMethods.has("passkey"),
oauthProviders: (allProviders as readonly string[])
.filter((providerId) => signInMethods.has(providerId as SignInMethod))
oauthProviders: SHARED_OAUTH_SIGN_IN_METHODS
.filter((providerId) => signInMethods.has(providerId))
.map((providerId) => ({ id: providerId, type: "shared" as const })),
},
};
@ -319,26 +319,16 @@ export function ProjectOnboardingWizard(props: {
}, [completeConfig.emails.selectedThemeId, isDevelopmentEnvironment, selectedApps, selectedEmailThemeId, signInMethods]);
const buildEnvironmentOAuthConfigUpdate = useCallback(() => {
return {
"auth.oauth.providers.google": signInMethods.has("google") ? {
type: "google",
const configUpdate: EnvironmentConfigOverrideOverride = {};
for (const providerId of SHARED_OAUTH_SIGN_IN_METHODS) {
configUpdate[`auth.oauth.providers.${providerId}`] = signInMethods.has(providerId) ? {
type: providerId,
isShared: true,
allowSignIn: true,
allowConnectedAccounts: true,
} : null,
"auth.oauth.providers.github": signInMethods.has("github") ? {
type: "github",
isShared: true,
allowSignIn: true,
allowConnectedAccounts: true,
} : null,
"auth.oauth.providers.microsoft": signInMethods.has("microsoft") ? {
type: "microsoft",
isShared: true,
allowSignIn: true,
allowConnectedAccounts: true,
} : null,
};
} : null;
}
return configUpdate;
}, [signInMethods]);
const finalizeOnboarding = useCallback(async () => {

View File

@ -2,6 +2,7 @@ import { stackAppInternalsSymbol } from "@/lib/stack-app-internals";
import { AdminOwnedProject } from "@stackframe/stack";
import { ALL_APPS, type AppId } from "@stackframe/stack-shared/dist/apps/apps-config";
import { projectOnboardingStatusValues, type ProjectOnboardingStatus } from "@stackframe/stack-shared/dist/schema-fields";
import { sharedProviders } from "@stackframe/stack-shared/dist/utils/oauth";
import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings";
const PROJECT_ONBOARDING_STATUSES = projectOnboardingStatusValues;
@ -23,7 +24,10 @@ export const REQUIRED_APP_IDS: AppId[] = ["authentication", "emails"];
export const PRIMARY_APP_IDS: AppId[] = ["authentication", "emails", "payments", "analytics"];
export const ALL_APP_IDS = Object.keys(ALL_APPS) as AppId[];
export const ONBOARDING_APP_IDS = ALL_APP_IDS.filter((appId) => ALL_APPS[appId].stage !== "alpha");
export const OAUTH_SIGN_IN_METHODS: SignInMethod[] = ["google", "github", "microsoft"];
export const OAUTH_SIGN_IN_METHODS = ["google", "github", "microsoft"] satisfies SignInMethod[];
export const SHARED_OAUTH_SIGN_IN_METHODS = sharedProviders.filter((provider): provider is (typeof sharedProviders)[number] & SignInMethod => {
return OAUTH_SIGN_IN_METHODS.some((method) => method === provider);
});
export type ProjectOnboardingState = {
selected_config_choice: OnboardingConfigChoice,