From 0c79df2910ee9821f09d1e0bf244b11a10b26cf9 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Tue, 14 Jul 2026 17:40:16 +0900 Subject: [PATCH] feat(kit): 0.0.27 clear-preserving keys and KIT_THEME_STORAGE_KEY Add kitClearStoragePreservingKeys so logout clear can keep last-auth email and theme. Export KIT_THEME_STORAGE_KEY and bump kit to 0.0.27. Co-authored-by: Cursor --- e2e/auth-signup-signin.spec.ts | 28 +++++++++--- .../demo/src/app/kit/auth/auth.service.ts | 11 ++++- projects/kit/README.md | 32 +++++++++++--- projects/kit/package.json | 2 +- .../lib/storage/kit-auth-email-store.spec.ts | 2 +- .../src/lib/storage/kit-clear-storage.spec.ts | 44 +++++++++++++++++++ .../kit/src/lib/storage/kit-clear-storage.ts | 44 +++++++++++++++++++ .../kit/src/lib/storage/kit-storage-keys.ts | 17 +++++++ projects/kit/src/public-api.ts | 4 ++ projects/kit/theme/src/theme-config.ts | 8 +++- 10 files changed, 176 insertions(+), 16 deletions(-) create mode 100644 projects/kit/src/lib/storage/kit-clear-storage.spec.ts create mode 100644 projects/kit/src/lib/storage/kit-clear-storage.ts create mode 100644 projects/kit/src/lib/storage/kit-storage-keys.ts diff --git a/e2e/auth-signup-signin.spec.ts b/e2e/auth-signup-signin.spec.ts index 9f0f9d8..ae60adb 100644 --- a/e2e/auth-signup-signin.spec.ts +++ b/e2e/auth-signup-signin.spec.ts @@ -3,13 +3,15 @@ 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); @@ -17,13 +19,15 @@ test.describe('Kit Auth (Firebase + confirm bypass)', () => { 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); @@ -31,18 +35,28 @@ test.describe('Kit Auth (Firebase + confirm bypass)', () => { 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 }); }); }); diff --git a/projects/demo/src/app/kit/auth/auth.service.ts b/projects/demo/src/app/kit/auth/auth.service.ts index 14b936c..a2dbe42 100644 --- a/projects/demo/src/app/kit/auth/auth.service.ts +++ b/projects/demo/src/app/kit/auth/auth.service.ts @@ -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, @@ -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 { @@ -58,6 +66,7 @@ export class DemoAuthService { signOut(): Promise { return kitSignOut(this.#auth, { + success: () => kitClearStoragePreservingKeys(this.#storage, [KIT_LAST_AUTH_EMAIL_KEY, KIT_THEME_STORAGE_KEY]), error: (e) => this.#presentError(e), }); } diff --git a/projects/kit/README.md b/projects/kit/README.md index 131a22c..9b2f1b7 100644 --- a/projects/kit/README.md +++ b/projects/kit/README.md @@ -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`, …) --- @@ -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 - + +``` + +### 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. --- diff --git a/projects/kit/package.json b/projects/kit/package.json index 65f1297..20a0dd7 100644 --- a/projects/kit/package.json +++ b/projects/kit/package.json @@ -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", diff --git a/projects/kit/src/lib/storage/kit-auth-email-store.spec.ts b/projects/kit/src/lib/storage/kit-auth-email-store.spec.ts index fa647cf..6c72b81 100644 --- a/projects/kit/src/lib/storage/kit-auth-email-store.spec.ts +++ b/projects/kit/src/lib/storage/kit-auth-email-store.spec.ts @@ -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 } => { diff --git a/projects/kit/src/lib/storage/kit-clear-storage.spec.ts b/projects/kit/src/lib/storage/kit-clear-storage.spec.ts new file mode 100644 index 0000000..62fbef8 --- /dev/null +++ b/projects/kit/src/lib/storage/kit-clear-storage.spec.ts @@ -0,0 +1,44 @@ +import { kitClearStoragePreservingKeys, type KitClearableStore } from './kit-clear-storage'; + +const fakeStore = (): KitClearableStore & { map: Map } => { + const map = new Map(); + return { + map, + get: (key: string) => Promise.resolve((map.get(key) ?? null) as T | null), + set: (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); + }); +}); diff --git a/projects/kit/src/lib/storage/kit-clear-storage.ts b/projects/kit/src/lib/storage/kit-clear-storage.ts new file mode 100644 index 0000000..291952e --- /dev/null +++ b/projects/kit/src/lib/storage/kit-clear-storage.ts @@ -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(key: string): Promise; + set(key: string, value: T): Promise; + clear(): Promise; +} + +/** + * 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 => { + const preserved = await Promise.all(keys.map(async (key) => ({ key, value: await store.get(key) }))); + await store.clear(); + for (const { key, value } of preserved) { + if (value !== null) { + await store.set(key, value); + } + } +}; diff --git a/projects/kit/src/lib/storage/kit-storage-keys.ts b/projects/kit/src/lib/storage/kit-storage-keys.ts new file mode 100644 index 0000000..e154ee3 --- /dev/null +++ b/projects/kit/src/lib/storage/kit-storage-keys.ts @@ -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'; diff --git a/projects/kit/src/public-api.ts b/projects/kit/src/public-api.ts index d1a5a76..a018829 100644 --- a/projects/kit/src/public-api.ts +++ b/projects/kit/src/public-api.ts @@ -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'; diff --git a/projects/kit/theme/src/theme-config.ts b/projects/kit/theme/src/theme-config.ts index c2a7168..e1c2e0b 100644 --- a/projects/kit/theme/src/theme-config.ts +++ b/projects/kit/theme/src/theme-config.ts @@ -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`. */ @@ -36,10 +39,13 @@ export const KIT_THEME_CONFIG = new InjectionToken('@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'], * }),