diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 545b4ef..81a3c1e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -63,3 +63,35 @@ jobs: cache-dependency-path: '**/package-lock.json' - run: npm ci - run: npm run test:actions + e2e: + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.57.0-noble + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 24 + cache: npm + cache-dependency-path: '**/package-lock.json' + - name: Cache Angular + uses: actions/cache@v5 + with: + path: .angular/cache + key: ${{ runner.os }}-angular-${{ hashFiles('package-lock.json') }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-angular-${{ hashFiles('package-lock.json') }}- + ${{ runner.os }}-angular- + - run: npm ci + - name: Run e2e tests + run: npm run e2e + env: + CI: true + HOME: /root + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 72c1aef..fb8db43 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,9 @@ Thumbs.db # Generated files projects/scroll-header/css projects/ionic-theme-ios26/css + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/README.md b/README.md index 69e5400..95a170f 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,20 @@ Sponsoring means you directly contribute to new features, improvements, and main | package name | description | path | |-------------------------------------|--------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------| +| @rdlabo/ionic-angular-kit | Auth guards, Firebase flows, storage, overlay, HTTP interceptor, and other fleet helpers. | [/projects/kit](https://github.com/rdlabo-team/ionic-angular-library/tree/main/projects/kit#readme) | | @rdlabo/ionic-angular-photo-editor | This is a photo editor and viewer for modal page of Ionic Angular project using Capacitor. | [/project/photo-editor](https://github.com/rdlabo-team/ionic-angular-library/tree/main/projects/photo-editor#readme) | | @rdlabo/ionic-angular-scroll-header | This is directive for scroll with Header. | [/project/scroll-header](https://github.com/rdlabo-team/ionic-angular-library/tree/main/projects/scroll-header#readme) | | @rdlabo/ngx-cdk-scroll-strategies | This is directive for virtual scroll of dynamic item size. | [/project/scroll-strategies](https://github.com/rdlabo-team/ionic-angular-library/tree/main/projects/scroll-strategies#readme) | +### Kit Auth demo + +The demo app includes a **Kit** tab with a Firebase Auth harness (`/main/kit/auth`). + +1. Fill `projects/demo/src/environments/environment.ts` (`firebase`). +2. `npm start` — open the Kit tab. +3. `npm run e2e` — Playwright signs up with a UUID email; `window.__E2E__` skips email confirmation. +4. `npm run cap` — copy a production build to iOS/Android for device checks (e.g. `kitAuthInput` autofill). + ## sponsors diff --git a/angular.json b/angular.json index 03559a7..ba6229e 100644 --- a/angular.json +++ b/angular.json @@ -34,7 +34,13 @@ }, "configurations": { "production": { - "outputHashing": "all" + "outputHashing": "all", + "fileReplacements": [ + { + "replace": "projects/demo/src/environments/environment.ts", + "with": "projects/demo/src/environments/environment.prod.ts" + } + ] }, "development": { "optimization": false, diff --git a/e2e/auth-signup-signin.spec.ts b/e2e/auth-signup-signin.spec.ts new file mode 100644 index 0000000..9f0f9d8 --- /dev/null +++ b/e2e/auth-signup-signin.spec.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; +import { clearAuthState, enableE2eFlag, fillEmailPassword, resetAuth } from './helpers'; + +const PASSWORD = 'KitAuthE2E!2026'; +const HOME_URL = /\/main\/kit\/auth\/home/; + +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 }) => { + 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-home')).toBeVisible(); + await expect(page.getByTestId('auth-state')).toHaveText(/user|anonymous/); + await expect(page.getByTestId('auth-email-display')).toContainText(email); + }); + + test('sign in after signup with the same UUID email', 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 clearAuthState(page); + 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.getByTestId('auth-home')).toBeVisible(); + await expect(page.getByTestId('auth-email-display')).toContainText(email); + + await page.getByTestId('auth-signout').click(); + await page.waitForURL(/\/main\/kit\/auth\/signin/, { timeout: 15000 }); + }); +}); diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 0000000..49c8137 --- /dev/null +++ b/e2e/helpers.ts @@ -0,0 +1,54 @@ +import type { Page } from '@playwright/test'; + +/** Must run before any navigation so `environment.e2e` sees `__E2E__` at module load. */ +export async function enableE2eFlag(page: Page): Promise { + await page.addInitScript(() => { + (window as { __E2E__?: boolean }).__E2E__ = true; + }); +} + +export async function fillEmailPassword(page: Page, email: string, password: string): Promise { + const emailInput = page.getByTestId('auth-email').locator('input').or(page.locator('input[type="email"]')).first(); + await emailInput.waitFor({ state: 'visible', timeout: 15000 }); + await emailInput.fill(email); + + const passwordInput = page.getByTestId('auth-password').locator('input').or(page.locator('input[type="password"]')).first(); + await passwordInput.fill(password); +} + +/** + * Clear Firebase Auth persistence (IndexedDB) plus web storage. + * localStorage alone is not enough — Firebase keeps the session in IndexedDB. + */ +export async function clearAuthState(page: Page): Promise { + await page.evaluate(async () => { + try { + localStorage.clear(); + sessionStorage.clear(); + } catch { + // ignore + } + try { + const dbs = (await indexedDB.databases?.()) ?? []; + await Promise.all( + dbs.map(({ name }) => + name + ? new Promise((resolve) => { + const req = indexedDB.deleteDatabase(name); + req.onsuccess = req.onerror = req.onblocked = () => resolve(); + }) + : Promise.resolve(), + ), + ); + } catch { + // ignore + } + }); +} + +export async function resetAuth(page: Page): Promise { + await enableE2eFlag(page); + await page.goto('/main/kit/auth'); + await clearAuthState(page); + await page.goto('/main/kit/auth'); +} diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..cbce20b --- /dev/null +++ b/netlify.toml @@ -0,0 +1,9 @@ +# Demo deploys to https://rdlabo-ionic-angular-library.netlify.app/ +# +# Firebase web apiKey is a public client identifier (access is enforced by Auth / +# App Check / Security Rules), not a server secret. Netlify smart detection flags +# the `AIza…` pattern anyway — omit it so deploy preview / production can build. + +[build.environment] + SECRETS_SCAN_SMART_DETECTION_OMIT_VALUES = "AIzaSyBuGDgJy26KfViIjusAxVwHhyAbQTYKoAw" + SECRETS_SCAN_OMIT_PATHS = "projects/demo/src/environments/**" diff --git a/package-lock.json b/package-lock.json index 71fe22b..413ed83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "@eslint/js": "^9.39.4", "@ionic/angular-toolkit": "^12.3.0", "@ionic/storage-angular": "^4.0.0", + "@playwright/test": "^1.57.0", "@rdlabo/capacitor-brotherprint": "^8.1.1", "angular-eslint": "21.4.0", "child_process": "^1.0.2", @@ -8369,6 +8370,22 @@ "node": ">=20.0.0" } }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", @@ -21172,6 +21189,53 @@ "node": ">=16.0.0" } }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/plist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", diff --git a/package.json b/package.json index c77d668..b1bb473 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,9 @@ "release": "np --no-tests --no-publish", "lint": "ng lint", "test:watch": "ng test", - "test:actions": "vitest run --config .github/actions/vitest.config.mjs" + "test:actions": "vitest run --config .github/actions/vitest.config.mjs", + "e2e": "playwright test", + "e2e:ui": "playwright test --ui" }, "private": false, "dependencies": { @@ -71,6 +73,7 @@ "@eslint/js": "^9.39.4", "@ionic/angular-toolkit": "^12.3.0", "@ionic/storage-angular": "^4.0.0", + "@playwright/test": "^1.57.0", "@rdlabo/capacitor-brotherprint": "^8.1.1", "angular-eslint": "21.4.0", "child_process": "^1.0.2", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..ac9e8c9 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Auth demo e2e for @rdlabo/ionic-angular-kit. + * Injects `window.__E2E__` before app scripts so `environment.e2e` enables confirm bypass. + */ +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env['CI'], + retries: process.env['CI'] ? 2 : 0, + workers: process.env['CI'] ? 1 : undefined, + reporter: 'html', + timeout: process.env['CI'] ? 60000 : 30000, + use: { + baseURL: process.env['PLAYWRIGHT_TEST_BASE_URL'] ?? 'http://localhost:4200', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + // Other demo tabs need prebuilt libs; kit itself resolves via tsconfig paths. + command: 'npm run prebuild && npx ng serve demo --configuration=development --port 4200 --host 0.0.0.0', + url: 'http://localhost:4200', + reuseExistingServer: !process.env['CI'], + timeout: 300000, + }, +}); diff --git a/projects/demo/src/app/app.config.ts b/projects/demo/src/app/app.config.ts index fcafa2e..d3c17fc 100644 --- a/projects/demo/src/app/app.config.ts +++ b/projects/demo/src/app/app.config.ts @@ -1,9 +1,34 @@ -import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core'; +import type { ApplicationConfig } from '@angular/core'; +import { importProvidersFrom, inject, provideZonelessChangeDetection } from '@angular/core'; import { provideRouter } from '@angular/router'; +import { provideIonicAngular } from '@ionic/angular/standalone'; +import { IonicStorageModule } from '@ionic/storage-angular'; +import { provideKitAuth, provideKitOverlay } from '@rdlabo/ionic-angular-kit'; +import { provideKitFirebase } from '@rdlabo/ionic-angular-kit/auth-firebase'; import { routes } from './app.routes'; -import { provideIonicAngular } from '@ionic/angular/standalone'; +import { DemoAuthService } from './kit/auth/auth.service'; +import { environment } from '../environments/environment'; export const appConfig: ApplicationConfig = { - providers: [provideZonelessChangeDetection(), provideRouter(routes), provideIonicAngular({ useSetInputAPI: true })], + providers: [ + provideZonelessChangeDetection(), + provideRouter(routes), + provideIonicAngular({ useSetInputAPI: true }), + importProvidersFrom(IonicStorageModule.forRoot({ name: '__kit_demo_db' })), + provideKitFirebase({ firebaseConfig: environment.firebase }), + provideKitOverlay({ labels: { close: 'Close', cancel: 'Cancel' } }), + provideKitAuth(() => { + const auth = inject(DemoAuthService); + return { + authState: () => auth.isAuth(), + redirects: { + whenAuthorized: '/main/kit/auth/home', + whenConfirming: '/main/kit/auth/confirm', + whenNotConfirming: '/main/kit/auth/signin', + whenUnauthorized: '/main/kit/auth/signin', + }, + }; + }), + ], }; diff --git a/projects/demo/src/app/kit/auth/auth.routes.ts b/projects/demo/src/app/kit/auth/auth.routes.ts new file mode 100644 index 0000000..b141d92 --- /dev/null +++ b/projects/demo/src/app/kit/auth/auth.routes.ts @@ -0,0 +1,44 @@ +import { Routes } from '@angular/router'; +import { + kitRequireAuthorizedGuard, + kitRequireConfirmingGuard, + kitRequiredUnauthorizedGuard, +} from '@rdlabo/ionic-angular-kit'; +import { AuthPage } from './pages/auth/auth.page'; +import { ConfirmPage } from './pages/confirm/confirm.page'; +import { HomePage } from './pages/home/home.page'; +import { ResetPasswordPage } from './pages/reset-password/reset-password.page'; +import { SigninPage } from './pages/signin/signin.page'; +import { SignupPage } from './pages/signup/signup.page'; + +export const routes: Routes = [ + { + path: '', + component: AuthPage, + }, + { + path: 'signin', + component: SigninPage, + canActivate: [kitRequiredUnauthorizedGuard], + }, + { + path: 'signup', + component: SignupPage, + canActivate: [kitRequiredUnauthorizedGuard], + }, + { + path: 'confirm', + component: ConfirmPage, + canActivate: [kitRequireConfirmingGuard], + }, + { + path: 'home', + component: HomePage, + canActivate: [kitRequireAuthorizedGuard], + }, + { + path: 'reset', + component: ResetPasswordPage, + canActivate: [kitRequiredUnauthorizedGuard], + }, +]; diff --git a/projects/demo/src/app/kit/auth/auth.service.ts b/projects/demo/src/app/kit/auth/auth.service.ts new file mode 100644 index 0000000..14b936c --- /dev/null +++ b/projects/demo/src/app/kit/auth/auth.service.ts @@ -0,0 +1,90 @@ +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_DEFAULT_AUTH_TEXT, + KIT_FIREBASE_AUTH, + kitAuthState, + kitResolveAuthStatus, + kitSendEmailVerification, + kitSendPasswordReset, + kitSignIn, + kitSignInAnonymously, + kitSignOut, + kitSignUp, + type User, +} from '@rdlabo/ionic-angular-kit/auth-firebase'; +import { environment } from '../../../environments/environment'; + +@Injectable({ providedIn: 'root' }) +export class DemoAuthService { + readonly #auth = inject(KIT_FIREBASE_AUTH); + readonly #overlay = inject(KitOverlayController); + + /** Stream of the 4-state auth model consumed by `provideKitAuth`. */ + isAuth(isReload = false): Observable { + return kitAuthState(this.#auth).pipe( + mergeMap(async (user) => { + if (isReload) { + await this.#auth.currentUser?.reload(); + } + if (user?.isAnonymous) { + return 'anonymous' as const; + } + return kitResolveAuthStatus(user, { + allowWhen: () => environment.e2e, + }); + }), + ); + } + + getState(): Observable { + return kitAuthState(this.#auth); + } + + signIn(email: string, password: string): Promise { + return kitSignIn(this.#auth, email, password, { + error: (e) => this.#presentError(e), + }); + } + + signUp(email: string, password: string): Promise { + return kitSignUp(this.#auth, email, password, { + success: () => void this.#overlay.presentToast({ message: 'Verification email sent' }), + error: (e) => this.#presentError(e), + }); + } + + signOut(): Promise { + return kitSignOut(this.#auth, { + error: (e) => this.#presentError(e), + }); + } + + signInAnonymously(): Promise { + return kitSignInAnonymously(this.#auth, { + error: (e) => this.#presentError(e), + }); + } + + sendPasswordReset(email: string): Promise { + return kitSendPasswordReset(this.#auth, email, { + success: () => void this.#overlay.presentToast({ message: 'Password reset email sent' }), + error: (e) => this.#presentError(e), + }); + } + + sendEmailVerification(): Promise { + return kitSendEmailVerification(this.#auth, { + success: () => void this.#overlay.presentToast({ message: 'Verification email sent' }), + error: (e) => this.#presentError(e), + }); + } + + #presentError(e: unknown): void { + const code = typeof e === 'object' && e && 'code' in e ? String((e as { code: string }).code) : ''; + const msg = KIT_DEFAULT_AUTH_TEXT.errors[code] ?? KIT_DEFAULT_AUTH_TEXT.fallbackError; + void this.#overlay.alertClose(msg); + } +} diff --git a/projects/demo/src/app/kit/auth/pages/auth/auth.page.html b/projects/demo/src/app/kit/auth/pages/auth/auth.page.html new file mode 100644 index 0000000..69a8fa5 --- /dev/null +++ b/projects/demo/src/app/kit/auth/pages/auth/auth.page.html @@ -0,0 +1,39 @@ + + + + + + Auth + + + + + @if (!firebaseConfigured) { + + Firebase config is empty. Fill environment.firebase before sign-in / e2e. + + } + +

+ Current state: + {{ authState() }} +

+ + + + Sign in + + + Sign up + + + Confirm (email) + + + Home (authorized) + + + Reset password + + +
diff --git a/projects/demo/src/app/kit/auth/pages/auth/auth.page.ts b/projects/demo/src/app/kit/auth/pages/auth/auth.page.ts new file mode 100644 index 0000000..8cf7156 --- /dev/null +++ b/projects/demo/src/app/kit/auth/pages/auth/auth.page.ts @@ -0,0 +1,41 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { RouterLink } from '@angular/router'; +import { + IonBackButton, + IonButtons, + IonContent, + IonHeader, + IonItem, + IonLabel, + IonList, + IonNote, + IonTitle, + IonToolbar, +} from '@ionic/angular/standalone'; +import { DemoAuthService } from '../../auth.service'; +import { environment } from '../../../../../environments/environment'; + +@Component({ + selector: 'app-kit-auth', + templateUrl: './auth.page.html', + imports: [ + IonHeader, + IonToolbar, + IonTitle, + IonButtons, + IonBackButton, + IonContent, + IonList, + IonItem, + IonLabel, + IonNote, + RouterLink, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AuthPage { + readonly #auth = inject(DemoAuthService); + readonly authState = toSignal(this.#auth.isAuth(), { initialValue: 'required' as const }); + readonly firebaseConfigured = !!environment.firebase.apiKey; +} diff --git a/projects/demo/src/app/kit/auth/pages/confirm/confirm.page.html b/projects/demo/src/app/kit/auth/pages/confirm/confirm.page.html new file mode 100644 index 0000000..56b5d74 --- /dev/null +++ b/projects/demo/src/app/kit/auth/pages/confirm/confirm.page.html @@ -0,0 +1,20 @@ + + + + + + Confirm email + + + + + + We sent a verification link to {{ email() }}. Open it, then this page will continue + automatically. With window.__E2E__ the confirm step is skipped. + + +
+ Resend verification + Sign out +
+
diff --git a/projects/demo/src/app/kit/auth/pages/confirm/confirm.page.ts b/projects/demo/src/app/kit/auth/pages/confirm/confirm.page.ts new file mode 100644 index 0000000..3c5678b --- /dev/null +++ b/projects/demo/src/app/kit/auth/pages/confirm/confirm.page.ts @@ -0,0 +1,53 @@ +import type { OnDestroy } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { + IonBackButton, + IonButton, + IonButtons, + IonContent, + IonHeader, + IonNote, + IonTitle, + IonToolbar, + NavController, +} from '@ionic/angular/standalone'; +import type { Subscription } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { firstValueFrom, timer } from 'rxjs'; +import { DemoAuthService } from '../../auth.service'; + +@Component({ + selector: 'app-kit-confirm', + templateUrl: './confirm.page.html', + imports: [IonHeader, IonToolbar, IonButtons, IonBackButton, IonTitle, IonContent, IonNote, IonButton], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfirmPage implements OnDestroy { + readonly #auth = inject(DemoAuthService); + readonly #navCtrl = inject(NavController); + readonly email = toSignal(this.#auth.getState().pipe(map((u) => u?.email ?? '')), { initialValue: '' }); + readonly #poll: Subscription; + + constructor() { + this.#poll = timer(0, 2000).subscribe(async () => { + const state = await firstValueFrom(this.#auth.isAuth(true)); + if (state === 'user') { + void this.#navCtrl.navigateRoot('/main/kit/auth/home'); + } + }); + } + + ngOnDestroy(): void { + this.#poll.unsubscribe(); + } + + sendVerify(): void { + void this.#auth.sendEmailVerification(); + } + + async signOut(): Promise { + await this.#auth.signOut(); + void this.#navCtrl.navigateRoot('/main/kit/auth/signin'); + } +} diff --git a/projects/demo/src/app/kit/auth/pages/home/home.page.html b/projects/demo/src/app/kit/auth/pages/home/home.page.html new file mode 100644 index 0000000..a3d62d7 --- /dev/null +++ b/projects/demo/src/app/kit/auth/pages/home/home.page.html @@ -0,0 +1,18 @@ + + + + + + Home + + + + +

Authorized area

+

+ State: {{ authState() }} +

+

Signed in as: {{ email() }}

+ + Sign out +
diff --git a/projects/demo/src/app/kit/auth/pages/home/home.page.ts b/projects/demo/src/app/kit/auth/pages/home/home.page.ts new file mode 100644 index 0000000..ab6b5ad --- /dev/null +++ b/projects/demo/src/app/kit/auth/pages/home/home.page.ts @@ -0,0 +1,35 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { + IonBackButton, + IonButton, + IonButtons, + IonContent, + IonHeader, + IonTitle, + IonToolbar, + NavController, +} from '@ionic/angular/standalone'; +import { map } from 'rxjs/operators'; +import { DemoAuthService } from '../../auth.service'; + +@Component({ + selector: 'app-kit-home', + templateUrl: './home.page.html', + imports: [IonHeader, IonToolbar, IonButtons, IonBackButton, IonTitle, IonContent, IonButton], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class HomePage { + readonly #auth = inject(DemoAuthService); + readonly #navCtrl = inject(NavController); + + readonly authState = toSignal(this.#auth.isAuth(), { initialValue: 'required' as const }); + readonly email = toSignal(this.#auth.getState().pipe(map((u) => u?.email ?? (u?.isAnonymous ? '(anonymous)' : ''))), { + initialValue: '', + }); + + async signOut(): Promise { + await this.#auth.signOut(); + void this.#navCtrl.navigateRoot('/main/kit/auth/signin'); + } +} diff --git a/projects/demo/src/app/kit/auth/pages/reset-password/reset-password.page.html b/projects/demo/src/app/kit/auth/pages/reset-password/reset-password.page.html new file mode 100644 index 0000000..b81b366 --- /dev/null +++ b/projects/demo/src/app/kit/auth/pages/reset-password/reset-password.page.html @@ -0,0 +1,37 @@ + + + + + + Reset password + + + + +
+ + + + + + +
+ + @if (isLoading()) { + + } @else { + Send reset email + } + +
+
+
diff --git a/projects/demo/src/app/kit/auth/pages/reset-password/reset-password.page.ts b/projects/demo/src/app/kit/auth/pages/reset-password/reset-password.page.ts new file mode 100644 index 0000000..aa509e6 --- /dev/null +++ b/projects/demo/src/app/kit/auth/pages/reset-password/reset-password.page.ts @@ -0,0 +1,51 @@ +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { form, FormField, required } from '@angular/forms/signals'; +import { + IonBackButton, + IonButton, + IonButtons, + IonContent, + IonHeader, + IonInput, + IonItem, + IonList, + IonSpinner, + IonTitle, + IonToolbar, +} from '@ionic/angular/standalone'; +import { KitAuthInputDirective } from '@rdlabo/ionic-angular-kit'; +import { DemoAuthService } from '../../auth.service'; + +@Component({ + selector: 'app-kit-reset-password', + templateUrl: './reset-password.page.html', + imports: [ + FormField, + KitAuthInputDirective, + IonHeader, + IonToolbar, + IonButtons, + IonBackButton, + IonTitle, + IonContent, + IonList, + IonItem, + IonInput, + IonButton, + IonSpinner, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ResetPasswordPage { + readonly credentials = signal({ email: '' }); + readonly resetForm = form(this.credentials, (s) => { + required(s.email); + }); + readonly isLoading = signal(false); + readonly #auth = inject(DemoAuthService); + + async doReset(): Promise { + this.isLoading.set(true); + await this.#auth.sendPasswordReset(this.credentials().email).finally(() => this.isLoading.set(false)); + } +} diff --git a/projects/demo/src/app/kit/auth/pages/signin/signin.page.html b/projects/demo/src/app/kit/auth/pages/signin/signin.page.html new file mode 100644 index 0000000..ef7c307 --- /dev/null +++ b/projects/demo/src/app/kit/auth/pages/signin/signin.page.html @@ -0,0 +1,59 @@ + + + + + + Sign in + + + + +
+ + + + + + + + + +
+ + @if (isLoading()) { + + } @else { + Sign in + } + +
+
+ +
+ Create account + Forgot password +
+ +
+ + Continue anonymously + +
+
diff --git a/projects/demo/src/app/kit/auth/pages/signin/signin.page.ts b/projects/demo/src/app/kit/auth/pages/signin/signin.page.ts new file mode 100644 index 0000000..5064b4b --- /dev/null +++ b/projects/demo/src/app/kit/auth/pages/signin/signin.page.ts @@ -0,0 +1,79 @@ +import type { OnDestroy } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { form, FormField, required } from '@angular/forms/signals'; +import { RouterLink } from '@angular/router'; +import { + IonBackButton, + IonButton, + IonButtons, + IonContent, + IonHeader, + IonInput, + IonItem, + IonList, + IonSpinner, + IonTitle, + IonToolbar, + NavController, +} from '@ionic/angular/standalone'; +import { KitAuthInputDirective } from '@rdlabo/ionic-angular-kit'; +import type { Subscription } from 'rxjs'; +import { DemoAuthService } from '../../auth.service'; + +@Component({ + selector: 'app-kit-signin', + templateUrl: './signin.page.html', + imports: [ + FormField, + KitAuthInputDirective, + RouterLink, + IonHeader, + IonToolbar, + IonButtons, + IonBackButton, + IonTitle, + IonContent, + IonList, + IonItem, + IonInput, + IonButton, + IonSpinner, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SigninPage implements OnDestroy { + readonly credentials = signal({ email: '', password: '' }); + readonly loginForm = form(this.credentials, (s) => { + required(s.email); + required(s.password); + }); + readonly isLoading = signal(false); + + readonly #auth = inject(DemoAuthService); + readonly #navCtrl = inject(NavController); + readonly #authSub: Subscription; + + constructor() { + this.#authSub = this.#auth.isAuth().subscribe((state) => { + if (state === 'user' || state === 'anonymous') { + void this.#navCtrl.navigateRoot('/main/kit/auth/home'); + } else if (state === 'confirm') { + void this.#navCtrl.navigateForward('/main/kit/auth/confirm'); + } + }); + } + + ngOnDestroy(): void { + this.#authSub.unsubscribe(); + } + + async doSignIn(): Promise { + this.isLoading.set(true); + await this.#auth.signIn(this.credentials().email, this.credentials().password).finally(() => this.isLoading.set(false)); + } + + async doAnonymous(): Promise { + this.isLoading.set(true); + await this.#auth.signInAnonymously().finally(() => this.isLoading.set(false)); + } +} diff --git a/projects/demo/src/app/kit/auth/pages/signup/signup.page.html b/projects/demo/src/app/kit/auth/pages/signup/signup.page.html new file mode 100644 index 0000000..06bf402 --- /dev/null +++ b/projects/demo/src/app/kit/auth/pages/signup/signup.page.html @@ -0,0 +1,48 @@ + + + + + + Sign up + + + + +
+ + + + + + + + + +
+ + @if (isLoading()) { + + } @else { + Create account + } + +
+
+
diff --git a/projects/demo/src/app/kit/auth/pages/signup/signup.page.ts b/projects/demo/src/app/kit/auth/pages/signup/signup.page.ts new file mode 100644 index 0000000..3a2cd5d --- /dev/null +++ b/projects/demo/src/app/kit/auth/pages/signup/signup.page.ts @@ -0,0 +1,72 @@ +import type { OnDestroy } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { form, FormField, required } from '@angular/forms/signals'; +import { + IonBackButton, + IonButton, + IonButtons, + IonContent, + IonHeader, + IonInput, + IonItem, + IonList, + IonSpinner, + IonTitle, + IonToolbar, + NavController, +} from '@ionic/angular/standalone'; +import { KitAuthInputDirective } from '@rdlabo/ionic-angular-kit'; +import type { Subscription } from 'rxjs'; +import { DemoAuthService } from '../../auth.service'; + +@Component({ + selector: 'app-kit-signup', + templateUrl: './signup.page.html', + imports: [ + FormField, + KitAuthInputDirective, + IonHeader, + IonToolbar, + IonButtons, + IonBackButton, + IonTitle, + IonContent, + IonList, + IonItem, + IonInput, + IonButton, + IonSpinner, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SignupPage implements OnDestroy { + readonly credentials = signal({ email: '', password: '' }); + readonly loginForm = form(this.credentials, (s) => { + required(s.email); + required(s.password); + }); + readonly isLoading = signal(false); + + readonly #auth = inject(DemoAuthService); + readonly #navCtrl = inject(NavController); + readonly #authSub: Subscription; + + constructor() { + this.#authSub = this.#auth.isAuth().subscribe((state) => { + if (state === 'user' || state === 'anonymous') { + void this.#navCtrl.navigateRoot('/main/kit/auth/home'); + } else if (state === 'confirm') { + void this.#navCtrl.navigateForward('/main/kit/auth/confirm'); + } + }); + } + + ngOnDestroy(): void { + this.#authSub.unsubscribe(); + } + + async doSignUp(): Promise { + this.isLoading.set(true); + await this.#auth.signUp(this.credentials().email, this.credentials().password).finally(() => this.isLoading.set(false)); + } +} diff --git a/projects/demo/src/app/kit/kit.routes.ts b/projects/demo/src/app/kit/kit.routes.ts new file mode 100644 index 0000000..8031810 --- /dev/null +++ b/projects/demo/src/app/kit/kit.routes.ts @@ -0,0 +1,13 @@ +import { Routes } from '@angular/router'; +import { KitPage } from './pages/kit/kit.page'; + +export const routes: Routes = [ + { + path: '', + component: KitPage, + }, + { + path: 'auth', + loadChildren: () => import('./auth/auth.routes').then((m) => m.routes), + }, +]; diff --git a/projects/demo/src/app/kit/pages/kit/kit.page.html b/projects/demo/src/app/kit/pages/kit/kit.page.html new file mode 100644 index 0000000..6deff03 --- /dev/null +++ b/projects/demo/src/app/kit/pages/kit/kit.page.html @@ -0,0 +1,20 @@ + + + Kit + + + + + + Demo for @rdlabo/ionic-angular-kit. Set Firebase web config in + projects/demo/src/environments/environment.ts. Run + npm run e2e for Playwright (confirm skipped via __E2E__), or + npm run cap for a native device build. + + + + + Auth + + + diff --git a/projects/demo/src/app/kit/pages/kit/kit.page.ts b/projects/demo/src/app/kit/pages/kit/kit.page.ts new file mode 100644 index 0000000..d86275b --- /dev/null +++ b/projects/demo/src/app/kit/pages/kit/kit.page.ts @@ -0,0 +1,20 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { + IonContent, + IonHeader, + IonItem, + IonLabel, + IonList, + IonNote, + IonTitle, + IonToolbar, +} from '@ionic/angular/standalone'; + +@Component({ + selector: 'app-kit', + templateUrl: './kit.page.html', + imports: [IonHeader, IonToolbar, IonTitle, IonContent, IonList, IonItem, IonLabel, IonNote, RouterLink], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class KitPage {} diff --git a/projects/demo/src/app/tabs/tabs.page.html b/projects/demo/src/app/tabs/tabs.page.html index acc78ef..a8fa412 100644 --- a/projects/demo/src/app/tabs/tabs.page.html +++ b/projects/demo/src/app/tabs/tabs.page.html @@ -19,5 +19,10 @@ DynamicSizeScroll + + + + Kit + diff --git a/projects/demo/src/app/tabs/tabs.routes.ts b/projects/demo/src/app/tabs/tabs.routes.ts index 4227ea8..0412860 100644 --- a/projects/demo/src/app/tabs/tabs.routes.ts +++ b/projects/demo/src/app/tabs/tabs.routes.ts @@ -25,6 +25,10 @@ export const routes: Routes = [ path: 'scroll-strategies', loadChildren: () => import('../scroll-strategies/scroll-strategies.routes').then((m) => m.routes), }, + { + path: 'kit', + loadChildren: () => import('../kit/kit.routes').then((m) => m.routes), + }, { path: '', redirectTo: '/main/photo-editor', diff --git a/projects/demo/src/environments/environment.prod.ts b/projects/demo/src/environments/environment.prod.ts new file mode 100644 index 0000000..afaa2b8 --- /dev/null +++ b/projects/demo/src/environments/environment.prod.ts @@ -0,0 +1,12 @@ +export const environment = { + production: true, + e2e: false, + firebase: { + apiKey: 'AIzaSyBuGDgJy26KfViIjusAxVwHhyAbQTYKoAw', + authDomain: 'ionic-angular-library.firebaseapp.com', + projectId: 'ionic-angular-library', + storageBucket: 'ionic-angular-library.firebasestorage.app', + messagingSenderId: '626402728972', + appId: '1:626402728972:web:0bd88eaeaf2402c9932229', + }, +}; diff --git a/projects/demo/src/environments/environment.ts b/projects/demo/src/environments/environment.ts new file mode 100644 index 0000000..5d16a76 --- /dev/null +++ b/projects/demo/src/environments/environment.ts @@ -0,0 +1,16 @@ +export const environment = { + production: false, + /** + * Playwright が `window.__E2E__` を inject したときだけ true。 + * `allowWhen` でメール未確認でも `'user'` 扱いにし、confirm をスキップする。 + */ + e2e: typeof window !== 'undefined' && (window as { __E2E__?: boolean }).__E2E__ === true, + firebase: { + apiKey: 'AIzaSyBuGDgJy26KfViIjusAxVwHhyAbQTYKoAw', + authDomain: 'ionic-angular-library.firebaseapp.com', + projectId: 'ionic-angular-library', + storageBucket: 'ionic-angular-library.firebasestorage.app', + messagingSenderId: '626402728972', + appId: '1:626402728972:web:0bd88eaeaf2402c9932229', + }, +};