diff --git a/package-lock.json b/package-lock.json
index a28ef3b1..01a473d1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -44,6 +44,7 @@
"@capacitor/network": "^8.0.1",
"@capacitor/preferences": "^8.0.1",
"@capacitor/status-bar": "^8.0.2",
+ "@capawesome/capacitor-google-sign-in": "^0.1.4",
"@capawesome/capacitor-live-update": "^8.3.0",
"@eslint/js": "^9.39.4",
"@ionic/angular-toolkit": "^13.0.0",
@@ -83,7 +84,7 @@
},
"dist/photo-editor": {
"name": "@rdlabo/ionic-angular-photo-editor",
- "version": "22.0.0-0",
+ "version": "22.0.0-8",
"dependencies": {
"tslib": "^2.3.0"
},
@@ -114,7 +115,7 @@
},
"dist/scroll-header": {
"name": "@rdlabo/ionic-angular-scroll-header",
- "version": "22.0.0-0",
+ "version": "22.0.0-8",
"dependencies": {
"tslib": "^2.3.0"
},
@@ -127,7 +128,7 @@
},
"dist/scroll-strategies": {
"name": "@rdlabo/ngx-cdk-scroll-strategies",
- "version": "22.0.0-0",
+ "version": "22.0.0-8",
"dependencies": {
"tslib": "^2.3.0"
},
@@ -2773,6 +2774,26 @@
"@capacitor/core": ">=8.0.0"
}
},
+ "node_modules/@capawesome/capacitor-google-sign-in": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/@capawesome/capacitor-google-sign-in/-/capacitor-google-sign-in-0.1.4.tgz",
+ "integrity": "sha512-+8+3mfWN1B0+qB5r1xUZpy0hrYkpEDD5XHxa5LQ5wwDIVKoFLj/orYGRf/RrsB1AbkSpRfZZ1N2U7C3gPcjAdA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/capawesome-team/"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/capawesome"
+ }
+ ],
+ "license": "MIT",
+ "peerDependencies": {
+ "@capacitor/core": ">=8.0.0"
+ }
+ },
"node_modules/@capawesome/capacitor-live-update": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/@capawesome/capacitor-live-update/-/capacitor-live-update-8.3.0.tgz",
diff --git a/package.json b/package.json
index b59b6652..e5fcae1b 100644
--- a/package.json
+++ b/package.json
@@ -79,6 +79,7 @@
"@capacitor/network": "^8.0.1",
"@capacitor/preferences": "^8.0.1",
"@capacitor/status-bar": "^8.0.2",
+ "@capawesome/capacitor-google-sign-in": "^0.1.4",
"@capawesome/capacitor-live-update": "^8.3.0",
"@eslint/js": "^9.39.4",
"@ionic/angular-toolkit": "^13.0.0",
@@ -90,8 +91,8 @@
"angular-eslint": "^22.1.0",
"child_process": "^1.0.2",
"dom-to-image-more": "^3.10.0",
- "eslint": "^9.39.4",
"esbuild": "^0.28.2",
+ "eslint": "^9.39.4",
"firebase": "^11.10.0",
"husky": "^8.0.3",
"ionicons": "^8.1.0",
diff --git a/projects/kit/README.md b/projects/kit/README.md
index 30158726..51929aac 100644
--- a/projects/kit/README.md
+++ b/projects/kit/README.md
@@ -29,7 +29,8 @@ import { disableHandler, KitStorageService } from '@rdlabo/ionic-angular-kit';
@Component({
selector: 'app-preferences-demo',
imports: [IonButton],
- template: `
{{ result() }}
`, + template: `{{ result() }}
`, }) export class PreferencesDemo { private readonly storage = inject(KitStorageService); @@ -59,19 +60,23 @@ The core package declares `@ionic/storage-angular` and Capacitor Core, App, Hapt Firebase, social login, Live Update, Preferences, Status Bar, in-app review, and printer/PDF dependencies are optional feature peers. Install only the dependencies used by the selected secondary entry points and follow each plugin's own compatibility range; some optional plugins support only Capacitor 8. +The `/auth-firebase/google` entry point uses `@capawesome/capacitor-google-sign-in` 0.1.x and therefore requires Capacitor 8. Applications remaining on Capacitor 7 can continue to use the core Kit and other compatible entry points, but cannot use this Google entry point. + ## Entry points -| Import | Responsibility | -| ----------------------------------------- | ---------------------------------------------------------------------------------- | -| `@rdlabo/ionic-angular-kit` | Storage, overlays, guards, HTTP, realtime, directives, keyboard, and utilities | -| `@rdlabo/ionic-angular-kit/offline` | **Experimental.** Scoped local replica, outbox, pull, replay, and request policies | -| `@rdlabo/ionic-angular-kit/theme` | Persisted light/dark theme and native status bar sync | -| `@rdlabo/ionic-angular-kit/forms` | Ionic error text and state classes for Angular Signal Forms | -| `@rdlabo/ionic-angular-kit/review` | Throttled native in-app review requests | -| `@rdlabo/ionic-angular-kit/printer` | DOM-to-PNG, Brother label, and PDF helpers | -| `@rdlabo/ionic-angular-kit/auth-firebase` | Firebase dependency wiring and authentication flows | -| `@rdlabo/ionic-angular-kit/app-update` | Atomic Angular service-worker update transitions | -| `@rdlabo/ionic-angular-kit/live-update` | Capawesome Live Update readiness provider | +| Import | Responsibility | +| ------------------------------------------------ | ---------------------------------------------------------------------------------- | +| `@rdlabo/ionic-angular-kit` | Storage, overlays, guards, HTTP, realtime, directives, keyboard, and utilities | +| `@rdlabo/ionic-angular-kit/offline` | **Experimental.** Scoped local replica, outbox, pull, replay, and request policies | +| `@rdlabo/ionic-angular-kit/theme` | Persisted light/dark theme and native status bar sync | +| `@rdlabo/ionic-angular-kit/forms` | Ionic error text and state classes for Angular Signal Forms | +| `@rdlabo/ionic-angular-kit/review` | Throttled native in-app review requests | +| `@rdlabo/ionic-angular-kit/printer` | DOM-to-PNG, Brother label, and PDF helpers | +| `@rdlabo/ionic-angular-kit/auth-firebase` | Firebase dependency wiring and authentication flows | +| `@rdlabo/ionic-angular-kit/auth-firebase/google` | Google popup/native sign-in, Firebase session linking, and logout | +| `@rdlabo/ionic-angular-kit/auth-firebase/social` | Apple and Facebook Firebase social-auth helpers | +| `@rdlabo/ionic-angular-kit/app-update` | Atomic Angular service-worker update transitions | +| `@rdlabo/ionic-angular-kit/live-update` | Capawesome Live Update readiness provider | Secondary entry points isolate optional native and SDK dependencies from the core bundle. diff --git a/projects/kit/auth-firebase/google/ng-package.json b/projects/kit/auth-firebase/google/ng-package.json new file mode 100644 index 00000000..d32ba70b --- /dev/null +++ b/projects/kit/auth-firebase/google/ng-package.json @@ -0,0 +1,3 @@ +{ + "lib": { "entryFile": "src/public-api.ts" } +} diff --git a/projects/kit/auth-firebase/google/src/kit-google.spec.ts b/projects/kit/auth-firebase/google/src/kit-google.spec.ts new file mode 100644 index 00000000..f1ce9987 --- /dev/null +++ b/projects/kit/auth-firebase/google/src/kit-google.spec.ts @@ -0,0 +1,239 @@ +import type { Auth } from 'firebase/auth'; +import { kitGoogleLogin, kitGoogleLogout } from './kit-google'; + +const initialize = vi.fn(); +const nativeSignIn = vi.fn(); +const signInWithCredential = vi.fn(); +const signInWithPopup = vi.fn(); +const linkWithCredential = vi.fn(); +const linkWithPopup = vi.fn(); +const reauthenticateWithCredential = vi.fn(); +const reauthenticateWithPopup = vi.fn(); +const signOut = vi.fn(); +const getPlatform = vi.fn(); + +vi.mock('@capawesome/capacitor-google-sign-in', () => ({ + ErrorCode: { SignInCanceled: 'SIGN_IN_CANCELED' }, + GoogleSignIn: { initialize: (...a: unknown[]) => initialize(...a), signIn: () => nativeSignIn(), signOut: () => signOut() }, +})); +vi.mock('@capacitor/core', () => ({ Capacitor: { getPlatform: () => getPlatform() } })); +vi.mock('firebase/auth', () => ({ + signInWithCredential: (...a: unknown[]) => signInWithCredential(...a), + signInWithPopup: (...a: unknown[]) => signInWithPopup(...a), + linkWithCredential: (...a: unknown[]) => linkWithCredential(...a), + linkWithPopup: (...a: unknown[]) => linkWithPopup(...a), + reauthenticateWithCredential: (...a: unknown[]) => reauthenticateWithCredential(...a), + reauthenticateWithPopup: (...a: unknown[]) => reauthenticateWithPopup(...a), + EmailAuthProvider: { credential: (email: string, password: string) => ({ email, password }) }, + GoogleAuthProvider: class { + static credential(idToken: string) { + return { idToken }; + } + static credentialFromResult(result: { idToken?: string }) { + return result.idToken ? { idToken: result.idToken } : null; + } + }, +})); + +const authWith = (user: unknown): Auth => ({ currentUser: user }) as Auth; +afterEach(() => vi.clearAllMocks()); + +it('uses Firebase popup on web and exchanges the ID token', async () => { + getPlatform.mockReturnValue('web'); + const user = { uid: 'web' }; + signInWithPopup.mockResolvedValue({ user, idToken: 'web-token' }); + const exchange = vi.fn(); + await expect(kitGoogleLogin(authWith(user), { mode: 'new', clientId: 'client', exchange })).resolves.toEqual({ status: true }); + expect(exchange).toHaveBeenCalledWith('web-token', user); +}); + +it('uses the native plugin token to establish the Firebase session', async () => { + getPlatform.mockReturnValue('ios'); + initialize.mockResolvedValue(undefined); + nativeSignIn.mockResolvedValue({ idToken: 'native-token' }); + const user = { uid: 'native' }; + signInWithCredential.mockResolvedValue({ user }); + const exchange = vi.fn(); + await expect(kitGoogleLogin(authWith(user), { mode: 'new', clientId: 'client', exchange })).resolves.toEqual({ status: true }); + expect(exchange).toHaveBeenCalledWith('native-token', user); +}); + +it('links Google to the current user on web and exchanges its token', async () => { + getPlatform.mockReturnValue('web'); + const user = { uid: 'linked' }; + linkWithPopup.mockResolvedValue({ user, idToken: 'link-token' }); + const exchange = vi.fn(); + await expect(kitGoogleLogin(authWith(user), { mode: 'link', clientId: 'client', exchange })).resolves.toEqual({ status: true }); + expect(linkWithPopup).toHaveBeenCalledWith(user, expect.anything()); + expect(exchange).toHaveBeenCalledWith('link-token', user); +}); + +it('links Google to the current user on native and exchanges its token', async () => { + getPlatform.mockReturnValue('android'); + initialize.mockResolvedValue(undefined); + nativeSignIn.mockResolvedValue({ idToken: 'native-link-token' }); + const user = { uid: 'native-linked' }; + linkWithCredential.mockResolvedValue({ user }); + const exchange = vi.fn(); + await expect(kitGoogleLogin(authWith(user), { mode: 'link', clientId: 'native-link-client', exchange })).resolves.toEqual({ + status: true, + }); + expect(linkWithCredential).toHaveBeenCalledWith(user, { idToken: 'native-link-token' }); + expect(exchange).toHaveBeenCalledWith('native-link-token', user); +}); + +it('reauthenticates and links a password in web credential mode without token exchange', async () => { + getPlatform.mockReturnValue('web'); + const user = { uid: 'credential-web' }; + reauthenticateWithPopup.mockResolvedValue({ user }); + linkWithCredential.mockResolvedValue({ user }); + const exchange = vi.fn(); + await expect( + kitGoogleLogin(authWith(user), { + mode: 'credential', + clientId: 'client', + emailLogin: { email: 'user@example.com', password: 'password' }, + exchange, + }), + ).resolves.toEqual({ status: true }); + expect(reauthenticateWithPopup).toHaveBeenCalledWith(user, expect.anything()); + expect(linkWithCredential).toHaveBeenCalledWith(user, { email: 'user@example.com', password: 'password' }); + expect(exchange).not.toHaveBeenCalled(); +}); + +it('reauthenticates and links a password in native credential mode without token exchange', async () => { + getPlatform.mockReturnValue('android'); + initialize.mockResolvedValue(undefined); + nativeSignIn.mockResolvedValue({ idToken: 'credential-token' }); + const user = { uid: 'credential-native' }; + reauthenticateWithCredential.mockResolvedValue({ user }); + linkWithCredential.mockResolvedValue({ user }); + const exchange = vi.fn(); + await expect( + kitGoogleLogin(authWith(user), { + mode: 'credential', + clientId: 'credential-client', + emailLogin: { email: 'user@example.com', password: 'password' }, + exchange, + }), + ).resolves.toEqual({ status: true }); + expect(reauthenticateWithCredential).toHaveBeenCalledWith(user, { idToken: 'credential-token' }); + expect(linkWithCredential).toHaveBeenCalledWith(user, { email: 'user@example.com', password: 'password' }); + expect(exchange).not.toHaveBeenCalled(); +}); + +it('reauthenticates the current native user without linking or exchange', async () => { + getPlatform.mockReturnValue('ios'); + initialize.mockResolvedValue(undefined); + nativeSignIn.mockResolvedValue({ idToken: 'reauth-token' }); + const user = { uid: 'reauthenticated' }; + reauthenticateWithCredential.mockResolvedValue({ user }); + const success = vi.fn(); + await expect(kitGoogleLogin(authWith(user), { mode: 'reauthenticate', clientId: 'reauth-client', success })).resolves.toEqual({ + status: true, + }); + expect(reauthenticateWithCredential).toHaveBeenCalledWith(user, { idToken: 'reauth-token' }); + expect(linkWithCredential).not.toHaveBeenCalled(); + expect(success).toHaveBeenCalledWith({ idToken: 'reauth-token', mode: 'reauthenticate', user }); +}); + +it('reauthenticates the current web user without linking or exchange', async () => { + getPlatform.mockReturnValue('web'); + const user = { uid: 'web-reauthenticated' }; + reauthenticateWithPopup.mockResolvedValue({ user, idToken: 'web-reauth-token' }); + const success = vi.fn(); + await expect(kitGoogleLogin(authWith(user), { mode: 'reauthenticate', clientId: 'client', success })).resolves.toEqual({ + status: true, + }); + expect(success).toHaveBeenCalledWith({ idToken: 'web-reauth-token', mode: 'reauthenticate', user }); + expect(linkWithCredential).not.toHaveBeenCalled(); +}); + +it('keeps the Firebase user when backend exchange rejects', async () => { + getPlatform.mockReturnValue('web'); + const user = { uid: 'session' }; + const auth = authWith(user); + signInWithPopup.mockResolvedValue({ user, idToken: 'web-token' }); + const failure = new Error('backend rejected'); + const error = vi.fn(); + await expect(kitGoogleLogin(auth, { mode: 'new', clientId: 'client', exchange: () => Promise.reject(failure), error })).resolves.toEqual({ + status: false, + }); + expect(auth.currentUser).toBe(user); + expect(error).toHaveBeenCalledWith('other', failure); +}); + +it('rejects a missing popup ID token', async () => { + getPlatform.mockReturnValue('web'); + const user = { uid: 'missing-token' }; + signInWithPopup.mockResolvedValue({ user }); + const error = vi.fn(); + await expect(kitGoogleLogin(authWith(user), { mode: 'new', clientId: 'client', error })).resolves.toEqual({ status: false }); + expect(error).toHaveBeenCalledWith('other', expect.objectContaining({ message: 'kit Google login: ID token is missing' })); +}); + +it('rejects a Firebase user change before exchange', async () => { + getPlatform.mockReturnValue('web'); + const original = { uid: 'original' }; + const changed = { uid: 'changed' }; + const auth = authWith(original); + signInWithPopup.mockImplementation(async () => { + (auth as unknown as { currentUser: unknown }).currentUser = changed; + return { user: original, idToken: 'web-token' }; + }); + const exchange = vi.fn(); + await expect(kitGoogleLogin(auth, { mode: 'new', clientId: 'client', exchange })).resolves.toEqual({ status: false }); + expect(exchange).not.toHaveBeenCalled(); +}); + +it('does not link a password when the current user changes during reauthentication', async () => { + getPlatform.mockReturnValue('web'); + const original = { uid: 'original-credential' }; + const auth = authWith(original); + reauthenticateWithPopup.mockImplementation(async () => { + (auth as unknown as { currentUser: unknown }).currentUser = { uid: 'changed-credential' }; + return { user: original }; + }); + await expect( + kitGoogleLogin(auth, { + mode: 'credential', + clientId: 'client', + emailLogin: { email: 'user@example.com', password: 'password' }, + }), + ).resolves.toEqual({ status: false }); + expect(linkWithCredential).not.toHaveBeenCalled(); +}); + +it('retries native initialization after an initialization failure', async () => { + getPlatform.mockReturnValue('ios'); + initialize.mockRejectedValueOnce(new Error('init failed')).mockResolvedValueOnce(undefined); + nativeSignIn.mockResolvedValue({ idToken: 'retry-token' }); + const user = { uid: 'retry' }; + signInWithCredential.mockResolvedValue({ user }); + const options = { mode: 'new' as const, clientId: 'retry-client' }; + await expect(kitGoogleLogin(authWith(user), options)).resolves.toEqual({ status: false }); + await expect(kitGoogleLogin(authWith(user), options)).resolves.toEqual({ status: true }); + expect(initialize).toHaveBeenCalledTimes(2); +}); + +it('classifies popup cancellation', async () => { + getPlatform.mockReturnValue('web'); + signInWithPopup.mockRejectedValue(Object.assign(new Error('cancelled'), { code: 'auth/popup-closed-by-user' })); + const error = vi.fn(); + await expect(kitGoogleLogin(authWith(null), { mode: 'new', clientId: 'client', error })).resolves.toEqual({ status: false }); + expect(error).toHaveBeenCalledWith('cancelled', expect.anything()); +}); + +it('classifies native cancellation', async () => { + getPlatform.mockReturnValue('android'); + initialize.mockResolvedValue(undefined); + nativeSignIn.mockRejectedValue(Object.assign(new Error('cancelled'), { code: 'SIGN_IN_CANCELED' })); + const error = vi.fn(); + await expect(kitGoogleLogin(authWith(null), { mode: 'new', clientId: 'cancel-client', error })).resolves.toEqual({ status: false }); + expect(error).toHaveBeenCalledWith('cancelled', expect.anything()); +}); + +it('makes plugin logout best effort', async () => { + signOut.mockRejectedValue(new Error('already signed out')); + await expect(kitGoogleLogout()).resolves.toBeUndefined(); +}); diff --git a/projects/kit/auth-firebase/google/src/kit-google.ts b/projects/kit/auth-firebase/google/src/kit-google.ts new file mode 100644 index 00000000..1c36590f --- /dev/null +++ b/projects/kit/auth-firebase/google/src/kit-google.ts @@ -0,0 +1,143 @@ +import { ErrorCode, GoogleSignIn } from '@capawesome/capacitor-google-sign-in'; +import { Capacitor } from '@capacitor/core'; +import type { Auth, User } from 'firebase/auth'; +import { + EmailAuthProvider, + GoogleAuthProvider, + linkWithCredential, + linkWithPopup, + reauthenticateWithCredential, + reauthenticateWithPopup, + signInWithCredential, + signInWithPopup, +} from 'firebase/auth'; + +export type KitGoogleLoginMode = + | { mode: 'new' } + | { mode: 'link' } + | { mode: 'reauthenticate' } + | { mode: 'credential'; emailLogin: { email: string; password: string } }; +export type KitGoogleErrorCategory = 'already-in-use' | 'cancelled' | 'other'; +export type KitGoogleLoginOptions = KitGoogleLoginMode & { + clientId: string; + before?: () => void | Promise