Skip to content
Merged
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
4 changes: 3 additions & 1 deletion angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,9 @@
"**/*.spec.ts",
"../printer/src/**/*.spec.ts",
"../theme/src/**/*.spec.ts",
"../review/src/**/*.spec.ts"
"../review/src/**/*.spec.ts",
"../auth-firebase/src/**/*.spec.ts",
"../auth-firebase/social/src/**/*.spec.ts"
]
},
"configurations": {
Expand Down
5,090 changes: 3,377 additions & 1,713 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@
"@angular/build": "^21.0.0",
"@angular/cli": "^21.0.0",
"@angular/compiler-cli": "^21.0.0",
"@angular/fire": "^21.0.0-rc.0",
"@capacitor-community/apple-sign-in": "github:rdlabo/apple-sign-in#cap8_build",
"@capacitor-community/facebook-login": "^8.0.0",
"@capacitor-community/in-app-review": "^8.0.0",
"@capacitor/cli": ">=6.0.0 <9.0.0",
"@capacitor/haptics": "^8.0.2",
Expand All @@ -71,6 +74,7 @@
"child_process": "^1.0.2",
"dom-to-image-more": "^3.10.0",
"eslint": "^9.39.4",
"firebase": "^11.10.0",
"husky": "^8.0.3",
"jsdom": "^26.0.0",
"lint-staged": "^15.2.0",
Expand Down
54 changes: 54 additions & 0 deletions projects/kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,60 @@ await BrotherPrint.printImage({ ...settings, port: channel.port, channelInfo: ch

---

### Firebase auth (`@rdlabo/ionic-angular-kit/auth-firebase`)

A secondary entry point so only apps that use it pull in `@angular/fire` and `firebase`. It exists to **isolate `@angular/fire`**: the SDK is touched in exactly one place — the DI provider — so apps import `KIT_FIREBASE_AUTH` and call these functions, never `@angular/fire` directly. That keeps the eventual `@angular/fire` → modular `firebase/auth` swap provider-local.

**Design principle: the kit performs no UI.** Every function runs the Firebase operation and nothing else; loading overlays, prompts and error alerts are app side effects. The flow functions take the uniform lifecycle hooks `{ before, success, error, finally }` and, rather than throwing, resolve value flows to `null` and boolean flows to `false`, handing the raw error to the `error` hook so the app presents it from its own dictionary. For anything the functions don't express, drop down to `firebase/auth` directly.

```typescript
// app.config.ts — @angular/fire lives only here
provideKitFirebase({ firebaseConfig: environment.firebase }),
provideKitFirebaseAnalytics(),
```

```typescript
import { inject, Injectable } from '@angular/core';
import {
KIT_FIREBASE_AUTH, kitSignIn, kitSignOut, kitResolveAuthStatus, kitReauthWithRetry,
} from '@rdlabo/ionic-angular-kit/auth-firebase';
import { updatePassword } from 'firebase/auth'; // escape hatch for the reauth mutation

@Injectable({ providedIn: 'root' })
export class AuthService {
readonly #auth = inject(KIT_FIREBASE_AUTH);

// Simple flow: hooks carry the app's side effects; errors go to the app's own dictionary.
signIn(email: string, password: string) {
return kitSignIn(this.#auth, email, password, {
error: (e) => this.presentError(e),
success: () => this.nav.navigateRoot('/'),
});
}

// Re-auth: the kit owns only the re-auth + wrong-password-retry mechanic; the app supplies
// the password prompt and the loading overlay, and catches the thrown (non-wrong-password) error.
async changePassword(currentEmail: string, newPassword: string) {
const ok = await kitReauthWithRetry(this.#auth, currentEmail, {
prompt: (retry) => this.promptPassword(retry),
mutate: (user) => updatePassword(user, newPassword),
withLoading: (run) => this.withLoading(run),
}).catch((e) => (this.presentError(e), false));
if (ok) this.overlay.alertClose({ header: 'Saved', message: '…' });
}
}
```

Surface:

- **DI** — `KIT_FIREBASE_AUTH` (`InjectionToken<Auth>`), `provideKitFirebase({ firebaseConfig })`, `provideKitFirebaseAnalytics()`.
- **Flow functions** (uniform hooks + no-throw null/false) — `kitSignIn`, `kitSignUp` (create + send verification), `kitSignOut`, `kitSendPasswordReset`, `kitSendEmailVerification`, `kitUnlinkProvider`.
- **Mechanics** — `kitReauthWithRetry` (app injects `prompt` / `withLoading` / `mutate`; boolean result, non-wrong-password errors thrown), `kitResolveAuthStatus` (`'user' | 'confirm' | 'required'` from the user; social counts as verified; `allowWhen` bypass), `kitAuthState`, `kitGetIdToken`.
- **Error dictionary** — `KIT_DEFAULT_AUTH_TEXT` (importable canonical constant; the kit does not present it — the app renders its own alert).
- **Social** (`@rdlabo/ionic-angular-kit/auth-firebase/social`, separate nested entry to isolate the Capacitor plugins) — `kitFacebookLogin`, `kitAppleLogin`, `kitFacebookLogout`; options carry the same `{ before, success, error, finally }` hooks (`success` receives the identity payload for a backend call).

---

## Consumer Vitest setup notes

When testing a consumer app that declares `@rdlabo/ionic-angular-kit` as a `file:` symlink dependency, add the following to your `vitest.config.ts`:
Expand Down
6 changes: 6 additions & 0 deletions projects/kit/auth-firebase/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
"lib": {
"entryFile": "src/public-api.ts"
}
}
6 changes: 6 additions & 0 deletions projects/kit/auth-firebase/social/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json",
"lib": {
"entryFile": "src/public-api.ts"
}
}
180 changes: 180 additions & 0 deletions projects/kit/auth-firebase/social/src/kit-social.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import type { Auth } from 'firebase/auth';
import { kitAppleLogin, kitFacebookLogin } from './kit-social';

const signInWithCredential = vi.fn();
const linkWithCredential = vi.fn();
const reauthenticateWithCredential = vi.fn();
const signInWithPopup = vi.fn();
const linkWithPopup = vi.fn();
const reauthenticateWithPopup = vi.fn();

const isNativePlatform = vi.fn();
const getPlatform = vi.fn();
const facebookLogin = vi.fn();
const appleAuthorize = vi.fn();

vi.mock('firebase/auth', () => ({
signInWithCredential: (...a: unknown[]) => signInWithCredential(...a),
linkWithCredential: (...a: unknown[]) => linkWithCredential(...a),
reauthenticateWithCredential: (...a: unknown[]) => reauthenticateWithCredential(...a),
signInWithPopup: (...a: unknown[]) => signInWithPopup(...a),
linkWithPopup: (...a: unknown[]) => linkWithPopup(...a),
reauthenticateWithPopup: (...a: unknown[]) => reauthenticateWithPopup(...a),
EmailAuthProvider: { credential: (email: string, password: string) => ({ email, password }) },
FacebookAuthProvider: { credential: (t: string) => ({ fb: t }) },
OAuthProvider: class {
id: string;
constructor(id: string) {
this.id = id;
}
credential(o: unknown) {
return { oauth: o, providerId: this.id };
}
addScope() {}
static credentialFromResult() {
return { idToken: 'id-token', accessToken: 'access-token' };
}
},
}));

vi.mock('@capacitor/core', () => ({
Capacitor: { isNativePlatform: () => isNativePlatform(), getPlatform: () => getPlatform() },
}));
vi.mock('@capacitor-community/facebook-login', () => ({
FacebookLogin: { login: (...a: unknown[]) => facebookLogin(...a) },
}));
vi.mock('@capacitor-community/apple-sign-in', () => ({
SignInWithApple: { authorize: (...a: unknown[]) => appleAuthorize(...a) },
}));

const fbError = (code: string) => Object.assign(new Error(code), { code });
const authWith = (currentUser: unknown): Auth => ({ currentUser }) as unknown as Auth;

const hooks = () => ({
before: vi.fn().mockResolvedValue(undefined),
success: vi.fn().mockResolvedValue(undefined),
error: vi.fn().mockResolvedValue(undefined),
finally: vi.fn().mockResolvedValue(undefined),
});

beforeEach(() => {
isNativePlatform.mockReturnValue(true);
getPlatform.mockReturnValue('android');
});
afterEach(() => vi.clearAllMocks());

describe('kitFacebookLogin', () => {
it("mode 'new' signs in, then runs before → success (with payload) → finally", async () => {
facebookLogin.mockResolvedValueOnce({ accessToken: { token: 'tok' } });
signInWithCredential.mockResolvedValueOnce({ user: { uid: 'u1' } });
const h = hooks();

const res = await kitFacebookLogin(authWith(null), { mode: 'new', permissions: [], ...h });

expect(res).toEqual({ status: true });
expect(signInWithCredential).toHaveBeenCalled();
expect(h.before).toHaveBeenCalledTimes(1);
expect(h.success).toHaveBeenCalledWith({ accessToken: 'tok', mode: 'new' });
expect(h.error).not.toHaveBeenCalled();
expect(h.finally).toHaveBeenCalledTimes(1);
});

it('returns {status:false} when the plugin login is cancelled/fails', async () => {
facebookLogin.mockResolvedValueOnce(undefined);
const h = hooks();
const res = await kitFacebookLogin(authWith(null), { mode: 'new', permissions: [], ...h });
expect(res).toEqual({ status: false });
expect(signInWithCredential).not.toHaveBeenCalled();
});

it("classifies 'already-in-use' and calls error without success (finally still runs)", async () => {
facebookLogin.mockResolvedValueOnce({ accessToken: { token: 'tok' } });
signInWithCredential.mockRejectedValueOnce(fbError('auth/credential-already-in-use'));
const h = hooks();
const res = await kitFacebookLogin(authWith(null), { mode: 'new', permissions: [], ...h });
expect(res).toEqual({ status: false });
expect(h.error).toHaveBeenCalledWith('already-in-use', expect.anything());
expect(h.success).not.toHaveBeenCalled();
expect(h.finally).toHaveBeenCalledTimes(1);
});

it("uses the iOS OIDC nonce path (OAuthProvider) on native iOS", async () => {
isNativePlatform.mockReturnValue(true);
getPlatform.mockReturnValue('ios');
facebookLogin.mockResolvedValueOnce({ accessToken: { token: 'tok' } });
signInWithCredential.mockResolvedValueOnce({});
const h = hooks();
await kitFacebookLogin(authWith(null), { mode: 'new', permissions: [], ...h });
const cred = signInWithCredential.mock.calls[0][1] as { providerId?: string };
expect(cred.providerId).toBe('facebook.com'); // OAuthProvider credential, not FacebookAuthProvider
});

it("mode 'link' links then afterCredential + onSuccess", async () => {
facebookLogin.mockResolvedValueOnce({ accessToken: { token: 'tok' } });
linkWithCredential.mockResolvedValueOnce({});
const h = hooks();
const res = await kitFacebookLogin(authWith({ uid: 'u1' }), { mode: 'link', permissions: [], ...h });
expect(res).toEqual({ status: true });
expect(linkWithCredential).toHaveBeenCalled();
expect(h.success).toHaveBeenCalledWith({ accessToken: 'tok', mode: 'link' });
});

it("mode 'credential' re-auths then links the email credential", async () => {
facebookLogin.mockResolvedValueOnce({ accessToken: { token: 'tok' } });
reauthenticateWithCredential.mockResolvedValueOnce({});
linkWithCredential.mockResolvedValueOnce({});
const h = hooks();
const res = await kitFacebookLogin(authWith({ uid: 'u1' }), {
mode: 'credential',
emailLogin: { email: 'e@x.com', password: 'pw' },
permissions: [],
...h,
});
expect(res).toEqual({ status: true });
expect(reauthenticateWithCredential).toHaveBeenCalled();
expect(linkWithCredential).toHaveBeenCalledWith({ uid: 'u1' }, { email: 'e@x.com', password: 'pw' });
});
});

describe('kitAppleLogin', () => {
it('native: authorizes, applies credential, success gets the apple response', async () => {
isNativePlatform.mockReturnValue(true);
appleAuthorize.mockResolvedValueOnce({ response: { identityToken: 'it', email: 'a@b.com' } });
signInWithCredential.mockResolvedValueOnce({});
const h = hooks();
const res = await kitAppleLogin(authWith(null), { mode: 'new', ...h });
expect(res).toEqual({ status: true });
expect(h.success).toHaveBeenCalledWith({
response: expect.objectContaining({ identityToken: 'it', email: 'a@b.com' }),
mode: 'new',
});
expect(h.finally).toHaveBeenCalledTimes(1);
});

it('native: cancelled authorize → {status:false}', async () => {
isNativePlatform.mockReturnValue(true);
appleAuthorize.mockResolvedValueOnce(undefined);
const h = hooks();
expect(await kitAppleLogin(authWith(null), { mode: 'new', ...h })).toEqual({ status: false });
expect(signInWithCredential).not.toHaveBeenCalled();
});

it("web 'new': uses signInWithPopup, synthesizes the response, routes errors to error", async () => {
isNativePlatform.mockReturnValue(false);
signInWithPopup.mockResolvedValueOnce({ user: { email: 'a@b.com' } });
const h = hooks();
const res = await kitAppleLogin(authWith(null), { mode: 'new', ...h });
expect(res).toEqual({ status: true });
expect(signInWithPopup).toHaveBeenCalled();
expect(h.success).toHaveBeenCalledWith({
response: expect.objectContaining({ email: 'a@b.com', identityToken: 'id-token' }),
mode: 'new',
});

signInWithPopup.mockRejectedValueOnce(fbError('auth/popup-closed-by-user'));
const h2 = hooks();
expect(await kitAppleLogin(authWith(null), { mode: 'new', ...h2 })).toEqual({ status: false });
expect(h2.error).toHaveBeenCalledWith('other', expect.anything());
expect(h2.finally).toHaveBeenCalledTimes(1);
});
});
Loading