Skip to content

Commit 5dc1aff

Browse files
committed
Merge branch '@invertase/v7-development' of https://github.com/firebase/firebaseui-web into @invertase/v7-development
2 parents 71e57df + ed920ea commit 5dc1aff

File tree

13 files changed

+663
-216
lines changed

13 files changed

+663
-216
lines changed

examples/react/src/firebase/firebase.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import { initializeApp, getApps } from "firebase/app";
2020
import { firebaseConfig } from "./config";
2121
import { connectAuthEmulator, getAuth } from "firebase/auth";
22-
import { autoAnonymousLogin, initializeUI, oneTapSignIn } from "@firebase-ui/core";
22+
import { autoAnonymousLogin, initializeUI, oneTapSignIn, countryCodes } from "@firebase-ui/core";
2323

2424
export const firebaseApp = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];
2525

@@ -32,6 +32,10 @@ export const ui = initializeUI({
3232
oneTapSignIn({
3333
clientId: "200312857118-lscdui98fkaq7ffr81446blafjn5o6r0.apps.googleusercontent.com",
3434
}),
35+
countryCodes({
36+
allowedCountries: ["US", "CA", "GB"],
37+
defaultCountry: "GB",
38+
}),
3539
],
3640
});
3741

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
},
5151
"dependencies": {
5252
"@firebase-ui/translations": "workspace:*",
53+
"libphonenumber-js": "^1.12.23",
5354
"nanostores": "catalog:",
5455
"qrcode-generator": "^2.0.4",
5556
"zod": "catalog:"
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import { countryCodesHandler, CountryCodesOptions } from "./country-codes";
3+
import { countryData } from "../country-data";
4+
5+
describe("countryCodesHandler", () => {
6+
describe("default behavior", () => {
7+
it("should return all countries when no options provided", () => {
8+
const result = countryCodesHandler();
9+
10+
expect(result.allowedCountries).toEqual(countryData);
11+
expect(result.defaultCountry).toEqual(countryData.find((country) => country.code === "US"));
12+
});
13+
14+
it("should return all countries when empty options provided", () => {
15+
const result = countryCodesHandler({});
16+
17+
expect(result.allowedCountries).toEqual(countryData);
18+
expect(result.defaultCountry).toEqual(countryData.find((country) => country.code === "US"));
19+
});
20+
});
21+
22+
describe("allowedCountries filtering", () => {
23+
it("should filter countries based on allowedCountries", () => {
24+
const options: CountryCodesOptions = {
25+
allowedCountries: ["US", "GB", "CA"],
26+
};
27+
28+
const result = countryCodesHandler(options);
29+
30+
expect(result.allowedCountries).toHaveLength(3);
31+
// Order is preserved from original countryData array, not from allowedCountries
32+
expect(result.allowedCountries.map((c) => c.code)).toEqual(["CA", "GB", "US"]);
33+
});
34+
35+
it("should handle single allowed country", () => {
36+
const options: CountryCodesOptions = {
37+
allowedCountries: ["US"],
38+
};
39+
40+
const result = countryCodesHandler(options);
41+
42+
expect(result.allowedCountries).toHaveLength(1);
43+
expect(result.allowedCountries[0]!.code).toBe("US");
44+
});
45+
46+
it("should handle empty allowedCountries array", () => {
47+
const options: CountryCodesOptions = {
48+
allowedCountries: [],
49+
};
50+
51+
const result = countryCodesHandler(options);
52+
53+
expect(result.allowedCountries).toEqual(countryData);
54+
});
55+
});
56+
57+
describe("defaultCountry setting", () => {
58+
it("should set default country when provided", () => {
59+
const options: CountryCodesOptions = {
60+
defaultCountry: "GB",
61+
};
62+
63+
const result = countryCodesHandler(options);
64+
65+
expect(result.defaultCountry.code).toBe("GB");
66+
expect(result.defaultCountry.name).toBe("United Kingdom");
67+
});
68+
69+
it("should default to US when no defaultCountry provided", () => {
70+
const result = countryCodesHandler();
71+
72+
expect(result.defaultCountry.code).toBe("US");
73+
});
74+
75+
it("should default to US when defaultCountry is undefined", () => {
76+
const options: CountryCodesOptions = {
77+
defaultCountry: undefined,
78+
};
79+
80+
const result = countryCodesHandler(options);
81+
82+
expect(result.defaultCountry.code).toBe("US");
83+
});
84+
});
85+
86+
describe("defaultCountry validation with allowedCountries", () => {
87+
it("should keep defaultCountry when it's in allowedCountries", () => {
88+
const options: CountryCodesOptions = {
89+
allowedCountries: ["US", "GB", "CA"],
90+
defaultCountry: "GB",
91+
};
92+
93+
const result = countryCodesHandler(options);
94+
95+
expect(result.defaultCountry.code).toBe("GB");
96+
expect(result.allowedCountries.map((c) => c.code)).toEqual(["CA", "GB", "US"]);
97+
});
98+
99+
it("should override defaultCountry when it's not in allowedCountries", () => {
100+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
101+
102+
const options: CountryCodesOptions = {
103+
allowedCountries: ["US", "GB", "CA"],
104+
defaultCountry: "FR", // France is not in allowed countries
105+
};
106+
107+
const result = countryCodesHandler(options);
108+
109+
expect(result.defaultCountry.code).toBe("CA"); // Should default to first allowed country (CA comes first in original array)
110+
expect(result.allowedCountries.map((c) => c.code)).toEqual(["CA", "GB", "US"]);
111+
expect(consoleSpy).toHaveBeenCalledWith(
112+
'The "defaultCountry" option is not in the "allowedCountries" list, the default country has been set to CA'
113+
);
114+
115+
consoleSpy.mockRestore();
116+
});
117+
118+
it("should override defaultCountry to first allowed country when not in list", () => {
119+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
120+
121+
const options: CountryCodesOptions = {
122+
allowedCountries: ["GB", "CA", "AU"], // US is not in this list
123+
defaultCountry: "US",
124+
};
125+
126+
const result = countryCodesHandler(options);
127+
128+
expect(result.defaultCountry.code).toBe("AU"); // Should default to first allowed country (AU comes first in original array)
129+
expect(consoleSpy).toHaveBeenCalledWith(
130+
'The "defaultCountry" option is not in the "allowedCountries" list, the default country has been set to AU'
131+
);
132+
133+
consoleSpy.mockRestore();
134+
});
135+
136+
it("should not warn when defaultCountry is in allowedCountries", () => {
137+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
138+
139+
const options: CountryCodesOptions = {
140+
allowedCountries: ["US", "GB", "CA"],
141+
defaultCountry: "CA",
142+
};
143+
144+
const result = countryCodesHandler(options);
145+
146+
expect(result.defaultCountry.code).toBe("CA");
147+
expect(consoleSpy).not.toHaveBeenCalled();
148+
149+
consoleSpy.mockRestore();
150+
});
151+
});
152+
153+
describe("edge cases", () => {
154+
it("should handle invalid country codes gracefully", () => {
155+
const options: CountryCodesOptions = {
156+
allowedCountries: ["US", "INVALID", "GB"] as any,
157+
};
158+
159+
const result = countryCodesHandler(options);
160+
161+
// Should only include valid countries
162+
expect(result.allowedCountries).toHaveLength(2);
163+
expect(result.allowedCountries.map((c) => c.code)).toEqual(["GB", "US"]);
164+
});
165+
166+
it("should handle case sensitivity", () => {
167+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
168+
169+
const options: CountryCodesOptions = {
170+
allowedCountries: ["us", "gb"] as any, // lowercase
171+
defaultCountry: "US", // This will trigger the validation logic
172+
};
173+
174+
const result = countryCodesHandler(options);
175+
176+
// Should fall back to all countries when no matches found
177+
expect(result.allowedCountries).toEqual(countryData);
178+
expect(consoleSpy).toHaveBeenCalledWith(
179+
'No countries matched the "allowedCountries" list, falling back to all countries'
180+
);
181+
182+
consoleSpy.mockRestore();
183+
});
184+
185+
it("should handle special country codes like Kosovo", () => {
186+
const options: CountryCodesOptions = {
187+
allowedCountries: ["XK", "US", "GB"],
188+
};
189+
190+
const result = countryCodesHandler(options);
191+
192+
expect(result.allowedCountries.length).toBeGreaterThan(2); // Kosovo has multiple entries
193+
expect(result.allowedCountries.some((c) => c.code === "XK")).toBe(true);
194+
expect(result.allowedCountries.some((c) => c.code === "US")).toBe(true);
195+
expect(result.allowedCountries.some((c) => c.code === "GB")).toBe(true);
196+
});
197+
});
198+
199+
describe("return type validation", () => {
200+
it("should return objects with correct structure", () => {
201+
const result = countryCodesHandler();
202+
203+
expect(result).toHaveProperty("allowedCountries");
204+
expect(result).toHaveProperty("defaultCountry");
205+
expect(Array.isArray(result.allowedCountries)).toBe(true);
206+
expect(typeof result.defaultCountry).toBe("object");
207+
208+
// Check structure of country objects
209+
result.allowedCountries.forEach((country) => {
210+
expect(country).toHaveProperty("name");
211+
expect(country).toHaveProperty("dialCode");
212+
expect(country).toHaveProperty("code");
213+
expect(country).toHaveProperty("emoji");
214+
});
215+
216+
expect(result.defaultCountry).toHaveProperty("name");
217+
expect(result.defaultCountry).toHaveProperty("dialCode");
218+
expect(result.defaultCountry).toHaveProperty("code");
219+
expect(result.defaultCountry).toHaveProperty("emoji");
220+
});
221+
});
222+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { CountryCode, countryData } from "../country-data";
2+
3+
export type CountryCodesOptions = {
4+
// The allowed countries are the countries that will be shown in the country selector
5+
// or `getCountries` is called.
6+
allowedCountries?: CountryCode[];
7+
// The default country is the country that will be selected by default when
8+
// the country selector is rendered, or `getDefaultCountry` is called.
9+
defaultCountry?: CountryCode;
10+
};
11+
12+
export const countryCodesHandler = (options?: CountryCodesOptions) => {
13+
// Determine allowed countries
14+
let allowedCountries = options?.allowedCountries?.length
15+
? countryData.filter((country) => options.allowedCountries!.includes(country.code))
16+
: countryData;
17+
18+
// If no countries match, fall back to all countries
19+
if (options?.allowedCountries?.length && allowedCountries.length === 0) {
20+
console.warn(`No countries matched the "allowedCountries" list, falling back to all countries`);
21+
allowedCountries = countryData;
22+
}
23+
24+
// Determine default country
25+
let defaultCountry = options?.defaultCountry
26+
? countryData.find((country) => country.code === options.defaultCountry)!
27+
: countryData.find((country) => country.code === "US")!;
28+
29+
// If default country is not in allowed countries, use first allowed country
30+
if (!allowedCountries.some((country) => country.code === defaultCountry.code)) {
31+
defaultCountry = allowedCountries[0]!;
32+
console.warn(
33+
`The "defaultCountry" option is not in the "allowedCountries" list, the default country has been set to ${defaultCountry.code}`
34+
);
35+
}
36+
37+
return {
38+
allowedCountries,
39+
defaultCountry,
40+
};
41+
};

packages/core/src/behaviors/index.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,8 +246,9 @@ describe("requireDisplayName", () => {
246246
describe("defaultBehaviors", () => {
247247
it("should include recaptchaVerification by default", () => {
248248
expect(defaultBehaviors).toHaveProperty("recaptchaVerification");
249-
expect(defaultBehaviors.recaptchaVerification).toHaveProperty("type", "callable");
250-
expect(typeof defaultBehaviors.recaptchaVerification.handler).toBe("function");
249+
expect(defaultBehaviors).toHaveProperty("providerSignInStrategy");
250+
expect(defaultBehaviors).toHaveProperty("providerLinkStrategy");
251+
expect(defaultBehaviors).toHaveProperty("countryCodes");
251252
});
252253

253254
it("should not include other behaviors by default", () => {

packages/core/src/behaviors/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as recaptchaHandlers from "./recaptcha";
66
import * as providerStrategyHandlers from "./provider-strategy";
77
import * as oneTapSignInHandlers from "./one-tap";
88
import * as requireDisplayNameHandlers from "./require-display-name";
9+
import * as countryCodesHandlers from "./country-codes";
910
import {
1011
callableBehavior,
1112
initBehavior,
@@ -35,6 +36,7 @@ type Registry = {
3536
(ui: FirebaseUIConfiguration) => ReturnType<typeof oneTapSignInHandlers.oneTapSignInHandler>
3637
>;
3738
requireDisplayName: CallableBehavior<typeof requireDisplayNameHandlers.requireDisplayNameHandler>;
39+
countryCodes: CallableBehavior<typeof countryCodesHandlers.countryCodesHandler>;
3840
};
3941

4042
export type Behavior<T extends keyof Registry = keyof Registry> = Pick<Registry, T>;
@@ -106,6 +108,12 @@ export function requireDisplayName(): Behavior<"requireDisplayName"> {
106108
};
107109
}
108110

111+
export function countryCodes(options?: countryCodesHandlers.CountryCodesOptions): Behavior<"countryCodes"> {
112+
return {
113+
countryCodes: callableBehavior(() => countryCodesHandlers.countryCodesHandler(options)),
114+
};
115+
}
116+
109117
export function hasBehavior<T extends keyof Registry>(ui: FirebaseUIConfiguration, key: T): boolean {
110118
return !!ui.behaviors[key];
111119
}
@@ -121,4 +129,5 @@ export function getBehavior<T extends keyof Registry>(ui: FirebaseUIConfiguratio
121129
export const defaultBehaviors: Behavior<"recaptchaVerification"> = {
122130
...recaptchaVerification(),
123131
...providerRedirectStrategy(),
132+
...countryCodes(),
124133
};

0 commit comments

Comments
 (0)