Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion LOCAL_DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ This guide walks you through running FirebaseUI Web locally so you can test feat

Make sure you have these installed:

- [Node.js](https://nodejs.org/) v18+ locally; **CI uses v24**use Node 24 for pre-push verification to match CI ([change-authoring-verification.md](developer-docs/playbooks/change-authoring-verification.md))
- [Node.js](https://nodejs.org/) v24.15+ locally; **CI uses v24.18**match CI for pre-push verification ([change-authoring-verification.md](developer-docs/playbooks/change-authoring-verification.md))
- [pnpm](https://pnpm.io/) — if you have Node.js 18+, [corepack](https://nodejs.org/api/corepack.html) can install pnpm on demand when you run it
- [Firebase CLI](https://firebase.google.com/docs/cli):
```bash
Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,9 @@ const ui = initializeUI({

#### `autoUpgradeAnonymousUsers`

The `autoUpgradeAnonymousUsers` behavior will automatically upgrade a user who is anonymously authenticated with your application upon a successful sign in (including OAuth). You can optionally provide a callback to handle an upgrade (such as merging account data). During the async callback, the UI will stay in a pending state.
The `autoUpgradeAnonymousUsers` behavior will automatically upgrade a user who is anonymously authenticated with your application upon a successful sign in (including OAuth). You can optionally provide callbacks to handle successful upgrades and failed upgrade attempts. During async callbacks, the UI will stay in a pending state.

When an upgrade succeeds, the anonymous user's UID is preserved and the new credential is linked to that user. When an upgrade fails (for example, because an OAuth credential is already linked to another account), `onUpgradeFailure` receives the original error and the anonymous user's `oldUserId` so your app can decide whether to migrate anonymous user data into the existing account. Return `"handled"` from `onUpgradeFailure` to suppress the default FirebaseUI error. Return `undefined`, omit the callback, or throw from the callback to preserve the default error behavior.

```ts
import { autoUpgradeAnonymousUsers } from '@firebase-oss/ui-core';
Expand All @@ -346,7 +348,12 @@ const ui = initializeUI({
behaviors: [autoUpgradeAnonymousUsers({
async onUpgrade(ui, oldUserId, credential) {
// Some account upgrade logic.
}
},
async onUpgradeFailure({ ui, oldUserId, error, credential, provider }) {
// Optional merge-conflict handling.
// Return "handled" if your app handled the failure and FirebaseUI
// should not show the default error.
},
})],
});
```
Expand Down
1 change: 1 addition & 0 deletions developer-docs/architecture/testing-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ timestamp: 2026-07-03T00:00:00Z
| Compile (packages + examples) | `pnpm build` | [CONTRIBUTING.md](../../CONTRIBUTING.md) | TS, bundler, framework build errors |
| Lint / format | `pnpm lint:check`, `pnpm format:check` | [CONTRIBUTING.md](../../CONTRIBUTING.md) | Style regressions |
| Example smoke (browser + HTTP) | `pnpm test:e2e` | [AD-3](../decisions.md#ad-3-playwright-example-smoke-tests-mvp-scope-dev-server), [AD-4](../decisions.md#ad-4-playwright-managed-dev-servers-serial-shared-emulator), [AD-6](../decisions.md#ad-6-custom-auth-server-binds-4001-for-e2e) | Dev-server UI interactivity (five UI examples) and `custom-auth-server` HTTP boot |
| Anonymous upgrade integration | `pnpm test:e2e:react`, `pnpm test:e2e:angular` | [`anonymous-upgrade.spec.ts`](../../e2e/tests/anonymous-upgrade.spec.ts) | React/Angular credential upgrades, conflict callbacks, handled errors, and provider redirect state |
| Security audit | `pnpm audit` | [playbooks/dependency-update-verification.md](../playbooks/dependency-update-verification.md) | Known CVEs in lockfile |

# CI today
Expand Down
2 changes: 1 addition & 1 deletion developer-docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Example apps are verified with **Playwright smoke tests**, not full user-flow e2

## AD-4: Playwright-managed dev servers, serial, shared emulator

Each example has a **unique e2e port** — see [examples-inventory.md](architecture/examples-inventory.md). Playwright's **top-level** `webServer` (one at a time, selected via `E2E_PROJECT`) starts each UI example's dev server, waits for its URL, and stops it — replacing hand-rolled preflight/postflight/PID scripts. Execution is **serial** (`workers: 1`) for resource predictability and clean logs; because ports are unique, parallelization is possible later but deferred ([Deferred](work-queues/playwright-e2e-smoke.md#deferred)). **`globalSetup`** builds packages (`build:packages`, asserting `dist/` exists — examples consume built `@firebase-oss/ui-*`), ensures a single Auth emulator on `:9099` for the whole run (reuse-aware), and starts it when not already running; **`globalTeardown`** stops what globalSetup started unless the serial runner is mid-suite (`E2E_KEEP_EMULATOR=1`). Angular reuses its package `start` semantics (`pnpm clean && ng serve`) rather than duplicating the `.angular/cache` clean.
Each example has a **unique e2e port** — see [examples-inventory.md](architecture/examples-inventory.md). Playwright's **top-level** `webServer` (one at a time, selected via `E2E_PROJECT`) starts each UI example's dev server, waits for its URL, and stops it — replacing hand-rolled preflight/postflight/PID scripts. Execution is **serial** (`workers: 1`) for resource predictability and clean logs; because ports are unique, parallelization is possible later but deferred ([Deferred](work-queues/playwright-e2e-smoke.md#deferred)). Supported root entrypoints build packages **before Playwright starts the dev server** (`test:e2e` once for the suite; `test:e2e:<example>` once for that project), preventing Vite/Angular from observing partially rebuilt `dist/` files. **`globalSetup`** asserts the required package artifacts exist, ensures a single Auth emulator on `:9099` for the whole run (reuse-aware), and starts it when not already running; **`globalTeardown`** stops what globalSetup started unless the serial runner is mid-suite (`E2E_KEEP_EMULATOR=1`). Angular reuses its package `start` semantics (`pnpm clean && ng serve`) rather than duplicating the `.angular/cache` clean.

---

Expand Down
2 changes: 1 addition & 1 deletion developer-docs/work-queues/playwright-e2e-smoke.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ scripts/e2e-run.mjs (pnpm test:e2e)

- **Serial:** `workers: 1`; `E2E_PROJECT` selects both the single Playwright project and the top-level `webServer`, so one dev server is up at a time.
- **Emulator:** `globalSetup` ensures `:9099` is reachable (starts via `npx firebase-tools` when not already running); serial runner sets `E2E_KEEP_EMULATOR=1` so teardown does not stop it between examples; `pnpm emulators` remains the first-class manual target ([LOCAL_DEVELOPMENT.md](../../LOCAL_DEVELOPMENT.md)).
- **Per-example debug:** `pnpm test:e2e:react` runs one project; `globalSetup` starts the emulator with reuse if not already running.
- **Per-example debug:** `pnpm test:e2e:react` builds packages before Playwright starts Vite, then runs one project; `globalSetup` asserts `dist/` and starts the emulator with reuse if not already running.

# Example ports, commands, and root scripts

Expand Down
21 changes: 2 additions & 19 deletions e2e/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,24 +97,11 @@ function assertPackagesBuilt(): void {

if (missing.length > 0) {
throw new Error(
`Expected built package artifacts are missing after build:packages:\n${missing.map((entry) => ` - ${entry}`).join("\n")}`
`Expected built package artifacts are missing. Run pnpm build:packages before Playwright:\n${missing.map((entry) => ` - ${entry}`).join("\n")}`
);
}
}

function runBuildPackages(): void {
try {
execSync("pnpm build:packages", {
cwd: REPO_ROOT,
stdio: "inherit",
});
} catch {
throw new Error("pnpm build:packages failed — examples require built @firebase-oss/ui-* packages");
}

assertPackagesBuilt();
}

function writeEmulatorState(state: EmulatorState): void {
mkdirSync(STATE_DIR, { recursive: true });
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
Expand Down Expand Up @@ -161,11 +148,7 @@ function startAuthEmulator(version: string): ChildProcess {
}

export default async function globalSetup(): Promise<void> {
if (process.env.E2E_SKIP_BUILD_PACKAGES === "1") {
assertPackagesBuilt();
} else {
runBuildPackages();
}
assertPackagesBuilt();

if (await isAuthEmulatorReachable()) {
const existing = readEmulatorState();
Expand Down
175 changes: 175 additions & 0 deletions e2e/tests/anonymous-upgrade.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import type { APIRequestContext, Page } from "@playwright/test";
import { enUs } from "@firebase-oss/ui-translations";
import { exampleMeta, type UiExampleMeta } from "../fixtures/example-meta";
import { expect, test } from "../fixtures/test-harness";

const AUTH_EMULATOR_BASE_URL = "http://127.0.0.1:9099";
const FIREBASE_PROJECT_ID = "fir-ui-rework";
const E2E_SCENARIO_PARAM = "e2eAnonymousUpgrade";
const ANONYMOUS_USER_ID_KEY = "firebaseui:e2e:anonymous-user-id";
const UPGRADE_RESULT_KEY = "firebaseui:e2e:upgrade-result";
const UPGRADE_FAILURE_KEY = "firebaseui:e2e:upgrade-failure";
const REDIRECT_USER_ID_KEY = "fbui:upgrade:oldUserId";
const PASSWORD = "e2e-password-123";

type UpgradeResult = {
oldUserId: string;
newUserId: string;
};

type UpgradeFailure = {
oldUserId: string;
code: string;
kind: "credential" | "provider";
};

const projectsUnderTest = ["react", "angular-example"] as const;

function scenarioUrl(scenario: "default" | "handled" | "redirect"): string {
return `/screens/sign-in-auth-screen-w-oauth?${E2E_SCENARIO_PARAM}=${scenario}`;
}

function uniqueEmail(projectName: string, label: string): string {
return `${projectName}-${label}-${crypto.randomUUID()}@example.test`;
}

async function createEmailUser(request: APIRequestContext, email: string): Promise<void> {
const response = await request.post(
`${AUTH_EMULATOR_BASE_URL}/identitytoolkit.googleapis.com/v1/accounts:signUp?key=fake-api-key`,
{
data: { email, password: PASSWORD, returnSecureToken: true },
}
);

expect(response.ok(), await response.text()).toBe(true);
}

async function clearEmulatorUsers(request: APIRequestContext): Promise<void> {
const response = await request.delete(
`${AUTH_EMULATOR_BASE_URL}/emulator/v1/projects/${FIREBASE_PROJECT_ID}/accounts`
);
expect(response.ok(), await response.text()).toBe(true);
}

async function waitForAnonymousUser(page: Page): Promise<string> {
await expect
.poll(() => page.evaluate((key) => window.localStorage.getItem(key), ANONYMOUS_USER_ID_KEY))
.not.toBeNull();

return page.evaluate((key) => window.localStorage.getItem(key) as string, ANONYMOUS_USER_ID_KEY);
}

async function submitCredentials(page: Page, email: string): Promise<void> {
await page.getByLabel(enUs.translations.labels.emailAddress).fill(email);
await page.getByLabel(enUs.translations.labels.password).fill(PASSWORD);
await page.getByRole("button", { name: enUs.translations.labels.signIn, exact: true }).click();
}

async function readStorage<T>(page: Page, key: string): Promise<T | null> {
return page.evaluate((storageKey) => {
const value = window.localStorage.getItem(storageKey);
return value ? JSON.parse(value) : null;
}, key);
}

async function expectUpgradePreservedUser(page: Page, anonymousUserId: string): Promise<void> {
await expect.poll(() => readStorage<UpgradeResult>(page, UPGRADE_RESULT_KEY)).not.toBeNull();

const result = await readStorage<UpgradeResult>(page, UPGRADE_RESULT_KEY);
expect(result).toEqual({ oldUserId: anonymousUserId, newUserId: anonymousUserId });
}

async function completeGoogleRedirect(page: Page, email: string): Promise<void> {
await page.getByRole("button", { name: enUs.translations.labels.signInWithGoogle }).click();

// The emulator widget paints its account chooser before Angular Material attaches handlers.
await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: "Add new account" }).click();
await expect(page.locator("#email-input")).toBeVisible();
await page.locator("#email-input").fill(email);
await page.locator("#display-name-input").fill("Playwright User");
await page.getByRole("button", { name: /sign in with google/i }).click();
}

for (const projectName of projectsUnderTest) {
const meta = exampleMeta[projectName] as UiExampleMeta;

test.describe(`anonymous upgrade (${projectName})`, () => {
test.beforeEach(({}, testInfo) => {
test.skip(testInfo.project.name !== projectName, `runs only on the ${projectName} project`);
});

test("credential upgrade preserves uid and fires onUpgrade", async ({ page }) => {
await page.goto(scenarioUrl("default"));
const anonymousUserId = await waitForAnonymousUser(page);

await submitCredentials(page, uniqueEmail(meta.name, "success"));

await expectUpgradePreservedUser(page, anonymousUserId);
});

test("credential conflict fires onUpgradeFailure and shows the default error", async ({ page, request }) => {
const email = uniqueEmail(meta.name, "default-conflict");
await createEmailUser(request, email);
await page.goto(scenarioUrl("default"));
const anonymousUserId = await waitForAnonymousUser(page);

await submitCredentials(page, email);

await expect(page.getByText(enUs.translations.errors.emailAlreadyInUse)).toBeVisible();
await expect.poll(() => readStorage<UpgradeFailure>(page, UPGRADE_FAILURE_KEY)).not.toBeNull();
expect(await readStorage<UpgradeFailure>(page, UPGRADE_FAILURE_KEY)).toEqual({
oldUserId: anonymousUserId,
code: "auth/email-already-in-use",
kind: "credential",
});
});

test("handled credential conflict suppresses the default error", async ({ page, request }) => {
const email = uniqueEmail(meta.name, "handled-conflict");
await createEmailUser(request, email);
await page.goto(scenarioUrl("handled"));
const anonymousUserId = await waitForAnonymousUser(page);

await submitCredentials(page, email);

await expect.poll(() => readStorage<UpgradeFailure>(page, UPGRADE_FAILURE_KEY)).not.toBeNull();
expect(await readStorage<UpgradeFailure>(page, UPGRADE_FAILURE_KEY)).toEqual({
oldUserId: anonymousUserId,
code: "auth/email-already-in-use",
kind: "credential",
});
await expect(page.getByText(enUs.translations.errors.emailAlreadyInUse)).toHaveCount(0);
});

test("provider redirect preserves anonymous upgrade state across the round trip", async ({ page, request }) => {
// Keep the emulator IdP chooser deterministic even when a developer reuses an existing emulator.
await clearEmulatorUsers(request);
await page.goto(scenarioUrl("redirect"));
const anonymousUserId = await waitForAnonymousUser(page);

await completeGoogleRedirect(page, uniqueEmail(meta.name, "redirect"));

await expectUpgradePreservedUser(page, anonymousUserId);
await expect
.poll(() => page.evaluate((key) => window.localStorage.getItem(key), REDIRECT_USER_ID_KEY))
.toBeNull();
});
});
}
53 changes: 51 additions & 2 deletions examples/angular/src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ import { provideClientHydration, withEventReplay } from "@angular/platform-brows
import { provideFirebaseApp, initializeApp } from "@angular/fire/app";
import { provideAuth, getAuth, connectAuthEmulator } from "@angular/fire/auth";
import { provideFirebaseUI, provideFirebaseUIPolicies } from "@firebase-oss/ui-angular";
import { initializeUI } from "@firebase-oss/ui-core";
import {
autoAnonymousLogin,
autoUpgradeAnonymousUsers,
initializeUI,
providerRedirectStrategy,
} from "@firebase-oss/ui-core";
import type { FirebaseApp } from "firebase/app";

const firebaseConfig = {
apiKey: "AIzaSyCvMftIUCD9lUQ3BzIrimfSfBbCUQYZf-I",
Expand All @@ -34,6 +40,49 @@ const firebaseConfig = {
appId: "1:200312857118:web:94e3f69b0e0a4a863f040f",
};

function initializeExampleUI(app: FirebaseApp) {
const e2eAnonymousUpgradeScenario =
isDevMode() && typeof window !== "undefined"
? new URLSearchParams(window.location.search).get("e2eAnonymousUpgrade")
: null;

const behaviors = e2eAnonymousUpgradeScenario
? [
autoAnonymousLogin(),
autoUpgradeAnonymousUsers({
onUpgrade: (_ui, oldUserId, credential) => {
window.localStorage.setItem(
"firebaseui:e2e:upgrade-result",
JSON.stringify({ oldUserId, newUserId: credential.user.uid })
);
},
onUpgradeFailure: ({ oldUserId, error, credential }) => {
const code = error && typeof error === "object" && "code" in error ? String(error.code) : "unknown";
window.localStorage.setItem(
"firebaseui:e2e:upgrade-failure",
JSON.stringify({ oldUserId, code, kind: credential ? "credential" : "provider" })
);

return e2eAnonymousUpgradeScenario === "handled" ? "handled" : undefined;
},
}),
...(e2eAnonymousUpgradeScenario === "redirect" ? [providerRedirectStrategy()] : []),
]
: [];

const ui = initializeUI({ app, behaviors });

if (e2eAnonymousUpgradeScenario) {
ui.get().auth.onAuthStateChanged((user) => {
if (user?.isAnonymous) {
window.localStorage.setItem("firebaseui:e2e:anonymous-user-id", user.uid);
}
});
}

return ui;
}

export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
Expand All @@ -48,7 +97,7 @@ export const appConfig: ApplicationConfig = {
}
return auth;
}),
provideFirebaseUI((apps) => initializeUI({ app: apps[0] })),
provideFirebaseUI((apps) => initializeExampleUI(apps[0])),
provideFirebaseUIPolicies(() => ({
termsOfServiceUrl: "https://www.google.com",
privacyPolicyUrl: "https://www.google.com",
Expand Down
Loading