iT邦幫忙

2026 iThome 鐵人賽

0
Build on Google AI

2026年,如何利用 Antigravity CLI、Gemini、各項技能及 MCP Server 建構基於 Firebase 的 Angular 應用系列 第 36 篇

Day 36: 使用 Email/Password 身分驗證保護 Angular Firebase AI 應用程式(第 3 部分:輕量級首頁畫面、延遲載入 CDK Dialog 與頁首導覽)

  • 分享至 

  • xImage
  •  

目前我們已經建立了負責身分驗證的 AuthService,以及讓使用者輸入電子郵件與密碼的 SignInModalComponent,接著我們需要一個輕量級的首頁畫面(HomeComponent)來將兩者串接起來。當使用者尚未通過身分驗證時,點擊首頁畫面上的 Sign In 按鈕會開啟 CDK 登入互動視窗;使用者輸入電子郵件與密碼後,即可完成身分驗證並導覽至 /dashboard 路由。

此外,HeaderComponent 不再僅顯示靜態文字,而是在左側邊緣顯示首頁圖示連結,並在右側邊緣依條件顯示登出(Sign-Out)按鈕。

未驗證使用者模式下的首頁畫面
圖說:未驗證狀態下的首頁畫面,在頁首左側顯示首頁圖示、主視覺卡片中顯示「Sign In」按鈕,以及下方的功能介紹卡片。


架構與核心概念

HomeComponent 與 HeaderComponent 共用 AuthService.isAuthenticated 計算型 Signal(Computed Signal),藉此在以下三種狀態之間協調使用者介面(UI):

  • 未驗證的 /home:HeaderComponent 僅顯示靠左對齊的首頁圖示連結(/home),而 HomeComponent 則渲染 Sign In 按鈕。
  • 延遲載入的 CDK Dialog(openSignInModal()):點擊 Sign In 會透過 Promise.all() 動態匯入 SignInModalComponent 與 Dialog(@angular/cdk/dialog),避免將 CDK 對話框與 Signal Forms 包含在初始的 main.js 打包檔案中。
  • 已驗證的 /dashboard 與 /home:一旦登入成功,HeaderComponent 會顯示靠右對齊的登出(Sign-Out)按鈕。當已驗證的使用者返回 /home 時,HomeComponent 會檢查 isAuthenticated(),並顯示 Launch Studio 連結(/dashboard)以取代 Sign In 按鈕。
flowchart LR
    subgraph Header["HeaderComponent (app-header)"]
        HOME_ICON["HomeIconComponent ([routerLink]='/home')"]
        AUTH_HDR{"isAuthenticated()?"}
        SIGNOUT_BTN["SignOutIconComponent (onSignOut -> /home)"]
        AUTH_HDR -- "true" --> SIGNOUT_BTN
    end

    subgraph Home["HomeComponent (/home)"]
        RESTORE["constructor: ensureAuth().catch(...)"]
        AUTH_HOME{"isAuthenticated()?"}
        SIGNIN_BTN["Button: Sign In (openSignInModal)"]
        LAUNCH_LINK["Link: Launch Studio ([routerLink]='/dashboard')"]
        RESTORE --> AUTH_HOME
        AUTH_HOME -- "false" --> SIGNIN_BTN
        AUTH_HOME -- "true" --> LAUNCH_LINK
    end

    subgraph LazyModal["Deferred Modal Chunk"]
        IMPORT["Promise.all([import(SignInModalComponent), import('@angular/cdk/dialog')])"]
        INJECT["runInInjectionContext(#injector, () => inject(Dialog).open(...))"]
        IMPORT --> INJECT
    end

    SIGNIN_BTN --> IMPORT
    INJECT -- "Auth Success" --> AUTH_HOME
    INJECT -- "Auth Success" --> AUTH_HDR

1. 更新 HeaderComponent 以加入首頁導覽與條件式登出按鈕

在 HeaderComponent 中,我們加入了首頁導覽連結與條件式登出按鈕:

  • 元件匯入項目:在 imports 陣列中匯入 HomeIconComponent、RouterLink 與 SignOutIconComponent。
  • 首頁連結:將 <app-home-icon /> 置於設定了 [routerLink]="homeLink"(APP_LINKS.HOME,即 /home)的 <a> 標籤內,點擊時即可導覽回首頁畫面。
  • 登出按鈕:僅在 isAuthenticated() 為 true 時顯示 <app-sign-out-icon /> 按鈕;點擊時會呼叫 onSignOut() 以登出使用者、更新 isAuthenticated(),並返回 /home。
// src/app/shared/ui/layout/header/header.component.ts
@Component({
  selector: 'app-header',
  imports: [HomeIconComponent, RouterLink, SignOutIconComponent],
})
export class HeaderComponent {
  readonly homeLink = APP_LINKS.HOME;
  readonly #authService = inject(AuthService);
  readonly #router = inject(Router);
  readonly isAuthenticated = this.#authService.isAuthenticated;

  async onSignOut(): Promise<void> {
    await this.#authService.signOut();
    await this.#router.navigate([APP_LINKS.HOME]);
  }
}
<!-- src/app/shared/ui/layout/header/header.component.html -->
<header>
  <a [routerLink]="homeLink" aria-label="Go to Home Screen">
    <app-home-icon />
  </a>
  <h1>Firebase AI Logic Obscure Fact Speech Generator</h1>
  @if (isAuthenticated()) {
    <button type="button" (click)="onSignOut()" aria-label="Sign out">
      <app-sign-out-icon />
    </button>
  }
</header>

2. 建置輕量級 HomeComponent 與延遲載入 CDK Dialog

2.1 在建構子中還原身分驗證狀態(ensureAuth().catch(...))

當 HomeComponent 被建構時,我們需要確保 Firebase Auth Emulator 與 Firebase Auth 已正確初始化。然而,constructor() 不能設為 async,因此我們必須使用 Promise 鏈(Promise chain)而非 async/await 來執行 this.#authService.ensureAuth()。

.catch() 區塊會記錄並靜默攔截任何錯誤,而不會重新拋出錯誤。由於這項背景任務不需要使用者介入處理,在此處顯示錯誤訊息只會造成使用者的困惑與不必要的恐慌。

// src/app/features/home/home.component.ts (Part 1: Fields & Constructor)
@Component({
  selector: 'app-home',
  imports: [RouterLink, ArrowRightIconComponent],
})
export class HomeComponent {
  readonly dashboard = APP_LINKS.DASHBOARD;
  readonly #authService = inject(AuthService);
  readonly isAuthenticated = this.#authService.isAuthenticated;
  readonly #injector = inject(Injector);
  #dialogRef: DialogRef<unknown, SignInModalComponent> | null = null;
  #isOpeningModal = false;

  constructor() {
    this.#authService.ensureAuth().catch((error) => console.error('Failed to restore auth session:', error));
  }

2.2 延遲載入 @angular/cdk/dialog 並使用 runInInjectionContext 解決 NG0203 注入環境錯誤

在 openSignInModal() 中開啟登入互動視窗時,我們在維持初始打包檔案精簡的同時,也需要保留 Angular 的注入環境(Injection Context):

  • 維持 main.js 輕量化:由於 HomeComponent 會被預先載入(Eagerly Loaded)至 main.js 打包檔案中,任何靜態匯入都會增加其原始大小與傳輸時間。
  • 延遲載入 @angular/cdk/dialog 與 NG0203 錯誤:我們透過在 openSignInModal() 中動態匯入 SignInModalComponent 與 @angular/cdk/dialog 來最佳化打包檔案。然而,在 await Promise.all(...) 之後呼叫 inject(Dialog) 會拋出 NG0203 錯誤,因為該呼叫是在有效的注入環境之外執行的。
  • 還原注入環境:我們使用 HomeComponent 的 Injector(this.#injector),將對話框開啟邏輯放在 runInInjectionContext 的回呼函式內執行,藉此解決此問題。
  • 為何選擇 runInInjectionContext 而非高階函式:使用 runInInjectionContext 讓我們能直接在箭頭函式內指派 this.#dialogRef = dialogRef,這比對高階函式傳入 .bind(this) 更加簡潔。

2.3 防止重複開啟互動視窗並在關閉時重設 this.#dialogRef(dialogRef.closed.subscribe)

在 openSignInModal() 中,我們會防止開啟重複的登入互動視窗,並在互動視窗關閉時清理對話框參考:

  • 防止重複開啟互動視窗:#isOpeningModal 與 #dialogRef 可防止同時開啟多個登入互動視窗;當其中任一值為真值(Truthy)時,代表互動視窗正在開啟或已經開啟,因此該方法會提早結束。
  • 延遲載入並開啟互動視窗:否則,我們會透過 Promise.all() 動態匯入 SignInModalComponent 與 @angular/cdk/dialog,注入 Dialog 以開啟互動視窗,並將其參考儲存於 this.#dialogRef 中。
  • 在關閉時重設 #dialogRef:訂閱 dialogRef.closed 會在互動視窗關閉時將 this.#dialogRef 重設為 null,確保之後能再次開啟對話框。
// src/app/features/home/home.component.ts (Part 2: openSignInModal)
  async openSignInModal(): Promise<void> {
    if (this.#isOpeningModal || this.#dialogRef) {
      return;
    }

    this.#isOpeningModal = true;
    try {
      const [{ SignInModalComponent }, { Dialog }] = await Promise.all([
        import('@/shared/ui/sign-in-modal/sign-in-modal.component'),
        import('@angular/cdk/dialog'),
      ]);
      runInInjectionContext(this.#injector, () => {
        const dialog = inject(Dialog);
        const dialogRef = dialog.open(SignInModalComponent, {
          backdropClass: ['bg-surface-card'],
        });

        this.#dialogRef = dialogRef;
        dialogRef.closed.subscribe(() => (this.#dialogRef = null));
      });
    } finally {
      this.#isOpeningModal = false;
    }
  }
}

2.4 home.component.html 中的條件式行動呼籲(CTA)渲染

在 home.component.html 中,isAuthenticated() 計算型 Signal 會決定要顯示哪一個按鈕——包括當使用者在網址列輸入 /home 或點擊 HeaderComponent 中的首頁圖示返回 /home 時:

  • 當 isAuthenticated() 為 true 時:首頁畫面會渲染 Launch Studio 連結([routerLink]="dashboard"),直接導覽至 /dashboard。
  • 當 isAuthenticated() 為 false 時:首頁畫面會渲染 Sign In 按鈕((click)="openSignInModal()"),點擊後會開啟 CDK 登入互動視窗。
<!-- src/app/features/home/home.component.html (CTA excerpt) -->
@if (isAuthenticated()) {
  <a [routerLink]="dashboard">
    <span>Launch Studio</span>
    <app-arrow-right-icon />
  </a>
} @else {
  <button type="button" (click)="openSignInModal()">
    <span>Sign In</span>
  </button>
}

已驗證使用者模式下的首頁畫面
圖說:已驗證狀態下的首頁畫面,在頁首左側顯示首頁圖示、右側顯示登出圖示,並在主視覺卡片中顯示「Launch Studio」按鈕。


重點回顧

在第 3 部分中,我們透過串接 HomeComponent、SignInModalComponent 與 HeaderComponent 完成了整個身分驗證流程:

  • HomeComponent 中的條件式操作:我們建置了輕量級的 HomeComponent,根據 isAuthenticated() Signal 依條件渲染 Sign In 與 Launch Studio 按鈕。當 isAuthenticated() 為 false 時顯示 Sign In 按鈕;為 true 時則顯示 Launch Studio。
  • 延遲載入的 CDK 登入互動視窗流程:點擊 Sign In 會開啟 CDK SignInModalComponent,讓使用者輸入電子郵件與密碼。當憑證有效時,互動視窗會將使用者導覽至 /dashboard 路由;否則會顯示身分驗證錯誤訊息。
  • 使用 runInInjectionContext 解決 NG0203:在 openSignInModal() 的 await Promise.all(...) 之後注入 Dialog 會觸發 Angular 的 NG0203 錯誤,因為非同步接續區塊是在注入環境之外執行的。我們透過使用 HomeComponent 的 Injector,在 runInInjectionContext 的回呼函式內執行對話框邏輯來解決此問題。
  • 頁首導覽與登出功能:HeaderComponent 加入了首頁導覽與登出功能。它會固定顯示首頁圖示,讓使用者在點擊時返回 /home 畫面;而條件式顯示的登出圖示則讓已驗證的使用者能從 Firebase Auth 登出,並將 isAuthenticated() Signal 重設為 false。

參考資源


上一篇
Day 35: 使用 Email/Password 身分驗證保護 Angular Firebase AI 應用程式(第 2 部分:Signal Forms 登入互動視窗)
系列文
2026年,如何利用 Antigravity CLI、Gemini、各項技能及 MCP Server 建構基於 Firebase 的 Angular 應用 共 36 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言