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
28 changes: 21 additions & 7 deletions e2e/auth-signup-signin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,46 +3,60 @@ import { clearAuthState, enableE2eFlag, fillEmailPassword, resetAuth } from './h

const PASSWORD = 'KitAuthE2E!2026';
const HOME_URL = /\/main\/kit\/auth\/home/;
const SIGNIN_URL = /\/main\/kit\/auth\/signin/;
const CONFIRM_URL = /\/main\/kit\/auth\/confirm/;

test.describe('Kit Auth (Firebase + confirm bypass)', () => {
test.beforeEach(async ({ page }) => {
await enableE2eFlag(page);
});

test('signup with UUID email skips confirm and reaches home', async ({ page }) => {
test('signup with UUID email skips confirm and reaches home as user', async ({ page }) => {
const email = `kit-auth-e2e-${crypto.randomUUID()}@example.com`;
await resetAuth(page);

await page.goto('/main/kit/auth/signup');
await fillEmailPassword(page, email, PASSWORD);
await page.getByTestId('auth-signup').click();

// __E2E__ → allowWhen: unverified Firebase user must resolve as 'user', not 'confirm'.
await page.waitForURL(HOME_URL, { timeout: 30000 });
await expect(page).not.toHaveURL(CONFIRM_URL);
await expect(page.getByTestId('auth-home')).toBeVisible();
await expect(page.getByTestId('auth-state')).toHaveText(/user|anonymous/);
await expect(page.getByTestId('auth-email-display')).toContainText(email);
await expect(page.getByTestId('auth-state')).toHaveText('user');
await expect(page.getByTestId('auth-email-display')).toHaveText(email);
});

test('sign in after signup with the same UUID email', async ({ page }) => {
test('sign in reuses the account created in the same test', async ({ page }) => {
const email = `kit-auth-e2e-${crypto.randomUUID()}@example.com`;
await resetAuth(page);

await page.goto('/main/kit/auth/signup');
await fillEmailPassword(page, email, PASSWORD);
await page.getByTestId('auth-signup').click();
await page.waitForURL(HOME_URL, { timeout: 30000 });
await expect(page.getByTestId('auth-state')).toHaveText('user');
await expect(page.getByTestId('auth-email-display')).toHaveText(email);

// Session clear (IndexedDB) — next navigation must treat the client as signed out.
await clearAuthState(page);
await page.goto('/main/kit/auth/signin');
await page.goto('/main/kit/auth/home');
await expect(page).toHaveURL(SIGNIN_URL, { timeout: 15000 });

await page.goto('/main/kit/auth/signin');
await fillEmailPassword(page, email, PASSWORD);
await page.getByTestId('auth-signin').click();

await page.waitForURL(HOME_URL, { timeout: 30000 });
await expect(page).not.toHaveURL(CONFIRM_URL);
await expect(page.getByTestId('auth-home')).toBeVisible();
await expect(page.getByTestId('auth-email-display')).toContainText(email);
await expect(page.getByTestId('auth-state')).toHaveText('user');
await expect(page.getByTestId('auth-email-display')).toHaveText(email);

await page.getByTestId('auth-signout').click();
await page.waitForURL(/\/main\/kit\/auth\/signin/, { timeout: 15000 });
await expect(page).toHaveURL(SIGNIN_URL, { timeout: 15000 });
// Signed-out user must not stay on the authorized route.
await page.goto('/main/kit/auth/home');
await expect(page).toHaveURL(SIGNIN_URL, { timeout: 15000 });
});
});
11 changes: 10 additions & 1 deletion projects/demo/src/app/kit/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { inject, Injectable } from '@angular/core';
import type { Observable } from 'rxjs';
import { mergeMap } from 'rxjs/operators';
import { type KitAuthState, KitOverlayController } from '@rdlabo/ionic-angular-kit';
import {
KIT_LAST_AUTH_EMAIL_KEY,
KIT_THEME_STORAGE_KEY,
kitClearStoragePreservingKeys,
type KitAuthState,
KitOverlayController,
KitStorageService,
} from '@rdlabo/ionic-angular-kit';
import {
KIT_DEFAULT_AUTH_TEXT,
KIT_FIREBASE_AUTH,
Expand All @@ -21,6 +28,7 @@ import { environment } from '../../../environments/environment';
export class DemoAuthService {
readonly #auth = inject(KIT_FIREBASE_AUTH);
readonly #overlay = inject(KitOverlayController);
readonly #storage = inject(KitStorageService);

/** Stream of the 4-state auth model consumed by `provideKitAuth`. */
isAuth(isReload = false): Observable<KitAuthState> {
Expand Down Expand Up @@ -58,6 +66,7 @@ export class DemoAuthService {

signOut(): Promise<boolean> {
return kitSignOut(this.#auth, {
success: () => kitClearStoragePreservingKeys(this.#storage, [KIT_LAST_AUTH_EMAIL_KEY, KIT_THEME_STORAGE_KEY]),
error: (e) => this.#presentError(e),
});
}
Expand Down
32 changes: 27 additions & 5 deletions projects/kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ A small ergonomic kit for Ionic Angular applications. It provides:
- **KitOverlayController** — a unified presenter for Ionic Modal, Toast, and Alert
- **Auth guards** — functional `CanActivateFn` guards for a 4-state auth model
- **HTTP interceptor** — a fleet-canonical auth + retry + error-hook interceptor
- **KitAutofillDirective** — an iOS autofill workaround for `ion-input`
- **KitAuthInputDirective** — sign-in email remember/prefill + iOS autofill workaround for `ion-input`
- **kitClearStoragePreservingKeys** — `clear()` that restores selected keys (`KIT_LAST_AUTH_EMAIL_KEY`, `KIT_THEME_STORAGE_KEY`, …)

---

Expand Down Expand Up @@ -395,15 +396,36 @@ await reload.dismiss();

---

### KitAutofillDirective
### KitAuthInputDirective (`kitAuthInput`)

An iOS workaround for `ion-input` autofill (password managers, iCloud Keychain). Without it, autofilled values are not reflected in the Angular form model on iOS native.
Sign-in / sign-up conveniences on `ion-input`:

- `'email'` — remember + prefill the last well-formed address (and forget when the user clears it)
- `'email-remember'` — remember on change only (no prefill — use on sign-up)
- `'autofill'` — iOS autofill propagation only (password fields)

```html
<ion-input rdlaboAutofill formControlName="password" type="password" />
<ion-input type="email" autocomplete="email" kitAuthInput="email" [formField]="form.email" />
```

### kitClearStoragePreservingKeys

Fleet apps typically `storage.clear()` on sign-out. Pass keys that must survive (e.g. the last sign-in email):

```typescript
import {
KIT_LAST_AUTH_EMAIL_KEY,
KIT_THEME_STORAGE_KEY,
kitClearStoragePreservingKeys,
} from '@rdlabo/ionic-angular-kit';

await kitSignOut(auth, {
success: () =>
kitClearStoragePreservingKeys(this.storage, [KIT_LAST_AUTH_EMAIL_KEY, KIT_THEME_STORAGE_KEY]),
});
```

The directive is a no-op on non-iOS platforms.
It snapshots the listed keys, clears the store, then writes non-null values back.

---

Expand Down
2 changes: 1 addition & 1 deletion projects/kit/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@rdlabo/ionic-angular-kit",
"version": "0.0.26",
"version": "0.0.27",
"peerDependencies": {
"@angular/common": "^21.0.0",
"@angular/core": "^21.0.0",
Expand Down
2 changes: 1 addition & 1 deletion projects/kit/src/lib/storage/kit-auth-email-store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import {
kitIsValidEmail,
kitRecallEmail,
kitRememberEmail,
type KitEmailStore,
} from './kit-auth-email-store';
import type { KitEmailStore } from './kit-auth-email-store';

/** In-memory store that structurally satisfies `KitEmailStore`. */
const fakeStore = (): KitEmailStore & { map: Map<string, unknown> } => {
Expand Down
44 changes: 44 additions & 0 deletions projects/kit/src/lib/storage/kit-clear-storage.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { kitClearStoragePreservingKeys, type KitClearableStore } from './kit-clear-storage';

const fakeStore = (): KitClearableStore & { map: Map<string, unknown> } => {
const map = new Map<string, unknown>();
return {
map,
get: <T>(key: string) => Promise.resolve((map.get(key) ?? null) as T | null),
set: <T>(key: string, value: T) => {
map.set(key, value);
return Promise.resolve();
},
clear: () => {
map.clear();
return Promise.resolve();
},
};
};

describe('kitClearStoragePreservingKeys', () => {
it('clears other keys but restores the listed ones', async () => {
const store = fakeStore();
await store.set('token', 'secret');
await store.set('email', 'kept@example.com');
await store.set('theme', 'dark');
await kitClearStoragePreservingKeys(store, ['email', 'theme']);
expect(store.map.has('token')).toBe(false);
expect(store.map.get('email')).toBe('kept@example.com');
expect(store.map.get('theme')).toBe('dark');
});

it('skips keys that were absent before clear', async () => {
const store = fakeStore();
await store.set('token', 'secret');
await kitClearStoragePreservingKeys(store, ['missing', 'also-missing']);
expect(store.map.size).toBe(0);
});

it('with an empty keys list, behaves like a full clear', async () => {
const store = fakeStore();
await store.set('token', 'secret');
await kitClearStoragePreservingKeys(store, []);
expect(store.map.size).toBe(0);
});
});
44 changes: 44 additions & 0 deletions projects/kit/src/lib/storage/kit-clear-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Clear a key/value store while restoring selected keys afterward.
*
* @remarks
* Fleet apps often call `storage.clear()` on sign-out to drop session/token state. Values that
* should survive logout (for example {@link KIT_LAST_AUTH_EMAIL_KEY}) are passed in `keys`.
* Missing keys are skipped — only non-null values are written back.
*/

/** Minimal clearable store — structurally satisfied by {@link KitStorageService}. */
export interface KitClearableStore {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T): Promise<void>;
clear(): Promise<void>;
}

/**
* Wipe the store, then restore the given keys to their pre-clear values (when present).
*
* @param store - the app's storage (e.g. `KitStorageService`)
* @param keys - keys whose values should survive the clear
*
* @example
* ```ts
* import { KIT_LAST_AUTH_EMAIL_KEY, KIT_THEME_STORAGE_KEY, kitClearStoragePreservingKeys } from '@rdlabo/ionic-angular-kit';
*
* await kitSignOut(auth, {
* success: () =>
* kitClearStoragePreservingKeys(this.storage, [KIT_LAST_AUTH_EMAIL_KEY, KIT_THEME_STORAGE_KEY]),
* });
* ```
*/
export const kitClearStoragePreservingKeys = async (
store: KitClearableStore,
keys: readonly string[],
): Promise<void> => {
const preserved = await Promise.all(keys.map(async (key) => ({ key, value: await store.get<unknown>(key) })));
await store.clear();
for (const { key, value } of preserved) {
if (value !== null) {
await store.set(key, value);
}
}
};
17 changes: 17 additions & 0 deletions projects/kit/src/lib/storage/kit-storage-keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Canonical storage keys persisted via {@link KitStorageService} that fleet apps often need to
* keep across a sign-out `clear()`.
*
* @remarks
* `KIT_LAST_AUTH_EMAIL_KEY` lives in `kit-auth-email-store` and is exported from the package root.
* Import both keys from `@rdlabo/ionic-angular-kit` alongside {@link kitClearStoragePreservingKeys}.
*/

/**
* Light/dark preference for `provideKitTheme` / `KitThemeController`.
*
* @remarks
* Pass this as `provideKitTheme({ storageKey: KIT_THEME_STORAGE_KEY, … })` and include it in
* {@link kitClearStoragePreservingKeys} so logout does not reset the user's theme.
*/
export const KIT_THEME_STORAGE_KEY = 'theme';
4 changes: 4 additions & 0 deletions projects/kit/src/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
export * from './lib/storage/kit-storage.service';
// Remember/recall the last entered sign-in email (validated; storage-agnostic helpers).
export * from './lib/storage/kit-auth-email-store';
// Canonical keys (email + theme) for clear-preserving lists.
export * from './lib/storage/kit-storage-keys';
// Clear storage while restoring selected keys (e.g. last sign-in email on logout).
export * from './lib/storage/kit-clear-storage';

// Overlay: wrapper around the Ionic Modal / Toast / Alert controllers.
export * from './lib/overlay/overlay-config';
Expand Down
8 changes: 7 additions & 1 deletion projects/kit/theme/src/theme-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import { InjectionToken, makeEnvironmentProviders } from '@angular/core';
* palette passes `darkClasses: ['ion-palette-dark'], lightClasses: []`, while an app with an extra
* design-system palette adds its own classes (e.g. `darkClasses: ['ion-palette-dark', 'a2ui-dark']`,
* `lightClasses: ['a2ui-light']`).
*
* Prefer `KIT_THEME_STORAGE_KEY` (from `@rdlabo/ionic-angular-kit`) as `storageKey` so logout
* clear-preserving lists stay in sync.
*/
export interface KitThemeConfig {
/** Key under which the chosen theme (`'light'` | `'dark'`) is persisted via `KitStorageService`. */
Expand All @@ -36,10 +39,13 @@ export const KIT_THEME_CONFIG = new InjectionToken<KitThemeConfig>('@rdlabo/ioni
* @returns environment providers to add to the application's provider list
* @example
* ```ts
* import { KIT_THEME_STORAGE_KEY } from '@rdlabo/ionic-angular-kit';
* import { provideKitTheme } from '@rdlabo/ionic-angular-kit/theme';
*
* bootstrapApplication(AppComponent, {
* providers: [
* provideKitTheme({
* storageKey: StorageKeyEnum.theme,
* storageKey: KIT_THEME_STORAGE_KEY,
* darkClasses: ['ion-palette-dark', 'a2ui-dark'],
* lightClasses: ['a2ui-light'],
* }),
Expand Down