support PWA notification.
ci-cd-vm / ci-cd (push) Failing after 1m34s

This commit is contained in:
Chris Chen
2026-06-29 22:20:15 -07:00
parent 45d910b554
commit b9210f2501
32 changed files with 1054 additions and 12 deletions
+1
View File
@@ -44,6 +44,7 @@
"@angular/localize/init"
],
"tsConfig": "tsconfig.app.json",
"serviceWorker": "ngsw-config.json",
"assets": [
"src/assets",
"src/manifest.webmanifest"
+30
View File
@@ -0,0 +1,30 @@
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.html",
"/manifest.webmanifest",
"/*.css",
"/*.js"
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/assets/**",
"/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
]
}
}
]
}
+22 -2
View File
@@ -1,11 +1,11 @@
{
"name": "RBJ.Identity.App",
"name": "ROLAC.App",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "RBJ.Identity.App",
"name": "ROLAC.App",
"version": "0.0.0",
"dependencies": {
"@angular/animations": "^20.1.0",
@@ -16,6 +16,7 @@
"@angular/localize": "^20.1.6",
"@angular/platform-browser": "^20.1.0",
"@angular/router": "^20.1.0",
"@angular/service-worker": "^20.3.25",
"@microsoft/signalr": "^8.0.17",
"@progress/kendo-angular-buttons": "^20.0.0",
"@progress/kendo-angular-charts": "^20.0.0",
@@ -696,6 +697,25 @@
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/service-worker": {
"version": "20.3.25",
"resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-20.3.25.tgz",
"integrity": "sha512-E9fS9/dukaoOaXxaa6l2BNFvOIJS1j+t+h7ZqU2PITbSqB4F8Go4XBA1bvXBKYcJUX6oaByD0Mu2yy3+mznjGQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"bin": {
"ngsw-config": "ngsw-config.js"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@angular/core": "20.3.25",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
+2 -1
View File
@@ -30,6 +30,7 @@
"@angular/localize": "^20.1.6",
"@angular/platform-browser": "^20.1.0",
"@angular/router": "^20.1.0",
"@angular/service-worker": "^20.3.25",
"@microsoft/signalr": "^8.0.17",
"@progress/kendo-angular-buttons": "^20.0.0",
"@progress/kendo-angular-charts": "^20.0.0",
@@ -92,4 +93,4 @@
"tailwindcss": "^4.3.0",
"typescript": "~5.8.2"
}
}
}
+8 -1
View File
@@ -1,7 +1,8 @@
import { ApplicationConfig, APP_INITIALIZER } from '@angular/core';
import { ApplicationConfig, APP_INITIALIZER, isDevMode } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideServiceWorker } from '@angular/service-worker';
import { routes } from './app.routes';
import { authInterceptor } from './core/interceptors/auth.interceptor';
@@ -21,5 +22,11 @@ export const appConfig: ApplicationConfig = {
deps: [AuthService],
multi: true,
},
// Web Push needs the ngsw service worker active. Disabled in dev (it conflicts with the live
// reload); register it shortly after the app stabilises in production builds.
provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000',
}),
]
};
@@ -0,0 +1,94 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import { SwPush } from '@angular/service-worker';
import { Observable, firstValueFrom } from 'rxjs';
import { ApiConfigService } from './api-config.service';
interface VapidPublicKeyResponse {
publicKey: string;
}
/**
* Manages this device's Web Push subscription for the logged-in member: request permission,
* subscribe with the server's VAPID key, persist the subscription on the API, and clean up on
* opt-out. The ngsw service worker shows the notifications; here we only handle the click → route.
*/
@Injectable({ providedIn: 'root' })
export class PushNotificationService {
private readonly swPush = inject(SwPush);
private readonly http = inject(HttpClient);
private readonly api = inject(ApiConfigService);
private readonly router = inject(Router);
/** Emits the current subscription (or null). Lets the UI reflect enabled/disabled state. */
readonly subscription$: Observable<PushSubscription | null> = this.swPush.subscription;
constructor() {
// When a notification is clicked, bring the app to the route carried in its data.url.
if (this.swPush.isEnabled) {
this.swPush.notificationClicks.subscribe(({ notification }) => {
const url = notification.data?.['url'] as string | undefined;
if (url) {
this.router.navigateByUrl(url);
}
});
}
}
/**
* True when the service worker is active and the browser supports push. False in dev (the ngsw
* worker is disabled there) and on browsers/contexts without push — the UI should explain why.
*/
get isSupported(): boolean {
return this.swPush.isEnabled && 'PushManager' in window;
}
/** Whether the browser has already granted notification permission. */
get permission(): NotificationPermission {
return typeof Notification !== 'undefined' ? Notification.permission : 'denied';
}
/** Subscribe this device and store the subscription on the server. */
async enable(): Promise<void> {
if (!this.isSupported) {
throw new Error('此瀏覽器或環境不支援推播(開發模式下 service worker 為停用)。');
}
const { publicKey } = await firstValueFrom(
this.http.get<VapidPublicKeyResponse>(this.api.getApiUrl('push/vapid-public-key')),
);
if (!publicKey) {
throw new Error('伺服器尚未設定推播金鑰 (VAPID)。');
}
const subscription = await this.swPush.requestSubscription({ serverPublicKey: publicKey });
const json = subscription.toJSON();
await firstValueFrom(
this.http.post(this.api.getApiUrl('push/subscriptions'), {
endpoint: json.endpoint,
keys: { p256dh: json.keys?.['p256dh'], auth: json.keys?.['auth'] },
userAgent: navigator.userAgent,
}),
);
}
/** Unsubscribe this device and remove the subscription from the server. */
async disable(): Promise<void> {
const subscription = await firstValueFrom(this.swPush.subscription);
const endpoint = subscription?.endpoint;
if (subscription) {
await this.swPush.unsubscribe();
}
if (endpoint) {
await firstValueFrom(
this.http.request('delete', this.api.getApiUrl('push/subscriptions'), { body: { endpoint } }),
);
}
}
/** Ask the server to push a test notification to this member's devices. */
async sendTestToSelf(): Promise<void> {
await firstValueFrom(this.http.post(this.api.getApiUrl('push/test'), {}));
}
}
@@ -0,0 +1,109 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { PushNotificationService } from '../../../../core/services/push-notification.service';
import { ToastService } from '../../../../core/services/toast.service';
/**
* Lets the logged-in member turn Web Push on/off for the current device and fire a test
* notification. Shows an iOS install hint, since on iPhone push only works once the PWA is
* added to the Home Screen.
*/
@Component({
selector: 'app-push-notifications-card',
standalone: true,
imports: [CommonModule, ButtonsModule],
template: `
<div class="flex flex-col gap-3">
<p class="text-sm text-gray-500">
Get notified on this device. You can turn it off anytime.
</p>
<!-- iOS: must install to Home Screen first -->
<div *ngIf="showIosHint" class="rounded-md bg-amber-50 border border-amber-200 p-3 text-sm text-amber-800">
在 iPhone / iPad 上,請先用 Safari 的「分享 → 加入主畫面」安裝本 App,再從主畫面圖示開啟,才能啟用推播。
</div>
<div *ngIf="!supported && !showIosHint"
class="rounded-md bg-gray-50 border border-gray-200 p-3 text-sm text-gray-600">
此瀏覽器或環境不支援推播通知(開發模式下會停用)。
</div>
<div class="flex flex-wrap items-center gap-2">
<button *ngIf="!enabled" kendoButton themeColor="primary"
[disabled]="!supported || busy" (click)="enable()">
Enable notifications
</button>
<button *ngIf="enabled" kendoButton
[disabled]="busy" (click)="disable()">
Disable notifications
</button>
<button *ngIf="enabled" kendoButton themeColor="info"
[disabled]="busy" (click)="sendTest()">
Send test
</button>
<span *ngIf="enabled" class="text-sm text-green-700">● 已啟用 Enabled on this device</span>
</div>
</div>
`,
})
export class PushNotificationsCardComponent implements OnInit {
private readonly push = inject(PushNotificationService);
private readonly toast = inject(ToastService);
supported = false;
enabled = false;
busy = false;
showIosHint = false;
ngOnInit(): void {
this.supported = this.push.isSupported;
this.showIosHint = this.isIosNotInstalled();
this.push.subscription$.subscribe((subscription) => (this.enabled = !!subscription));
}
async enable(): Promise<void> {
this.busy = true;
try {
await this.push.enable();
this.toast.success('已啟用推播通知。Notifications enabled.');
} catch (err: unknown) {
this.toast.error(err instanceof Error ? err.message : '無法啟用推播通知。');
} finally {
this.busy = false;
}
}
async disable(): Promise<void> {
this.busy = true;
try {
await this.push.disable();
this.toast.success('已關閉推播通知。Notifications disabled.');
} catch {
this.toast.error('無法關閉推播通知。');
} finally {
this.busy = false;
}
}
async sendTest(): Promise<void> {
this.busy = true;
try {
await this.push.sendTestToSelf();
this.toast.success('測試通知已送出。Test notification sent.');
} catch {
this.toast.error('無法送出測試通知。');
} finally {
this.busy = false;
}
}
// iOS Safari, not running as an installed PWA — push is unavailable until added to Home Screen.
private isIosNotInstalled(): boolean {
const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent);
const isStandalone =
window.matchMedia('(display-mode: standalone)').matches ||
(navigator as unknown as { standalone?: boolean }).standalone === true;
return isIos && !isStandalone;
}
}
@@ -1,4 +1,4 @@
<div class="p-4 md:p-6">
<div class="p-4 md:p-6 flex flex-col gap-4">
<section class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 md:p-6 max-w-xl">
<h2 class="text-lg font-semibold mb-1">Change Password</h2>
<p class="text-sm text-gray-500 mb-4">
@@ -6,4 +6,9 @@
</p>
<app-change-password-form></app-change-password-form>
</section>
<section class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 md:p-6 max-w-xl">
<h2 class="text-lg font-semibold mb-1">Push Notifications</h2>
<app-push-notifications-card></app-push-notifications-card>
</section>
</div>
@@ -1,11 +1,12 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ChangePasswordFormComponent } from '../../components/change-password-form/change-password-form.component';
import { PushNotificationsCardComponent } from '../../components/push-notifications-card/push-notifications-card.component';
@Component({
selector: 'app-account-settings-page',
standalone: true,
imports: [CommonModule, ChangePasswordFormComponent],
imports: [CommonModule, ChangePasswordFormComponent, PushNotificationsCardComponent],
templateUrl: './account-settings-page.component.html',
})
export class AccountSettingsPageComponent {}
@@ -0,0 +1,110 @@
import { Component, Input, Output, EventEmitter, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { DialogsModule } from '@progress/kendo-angular-dialog';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { LabelModule } from '@progress/kendo-angular-label';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { MemberListItemDto, memberDisplayName } from '../../models/member.model';
import { ApiConfigService } from '../../../../core/services/api-config.service';
import { ToastService } from '../../../../core/services/toast.service';
interface NotificationResult {
sentCount: number;
failedCount: number;
}
/**
* Admin test action: compose a Web Push message and send it to a single member's devices. Used to
* verify one-to-one push delivery. Reports clearly when the member has no active subscription.
*/
@Component({
selector: 'app-send-push-dialog',
standalone: true,
imports: [
CommonModule, ReactiveFormsModule, DialogsModule, InputsModule, LabelModule, ButtonsModule,
],
template: `
<kendo-dialog title="Send Push Notification" (close)="onClose()" [width]="520" [maxWidth]="'95vw'">
<p class="k-mb-3">
Send a push notification to <strong>{{ memberName }}</strong>.
They must have enabled notifications on a device first.
</p>
<form [formGroup]="form" class="k-form k-form-vertical">
<kendo-formfield>
<kendo-label text="Title *"></kendo-label>
<kendo-textbox formControlName="title" [maxlength]="100"></kendo-textbox>
<kendo-formerror *ngIf="form.get('title')?.errors?.['required']">Required.</kendo-formerror>
</kendo-formfield>
<kendo-formfield>
<kendo-label text="Message *"></kendo-label>
<kendo-textarea formControlName="body" [rows]="3" resizable="vertical"></kendo-textarea>
<kendo-formerror *ngIf="form.get('body')?.errors?.['required']">Required.</kendo-formerror>
</kendo-formfield>
</form>
<kendo-dialog-actions>
<button kendoButton (click)="onClose()">Cancel</button>
<button kendoButton themeColor="primary" [disabled]="form.invalid || sending" (click)="send()">
Send
</button>
</kendo-dialog-actions>
</kendo-dialog>
`,
})
export class SendPushDialogComponent implements OnInit {
@Input({ required: true }) member!: MemberListItemDto;
@Output() cancelled = new EventEmitter<void>();
private readonly fb = inject(FormBuilder);
private readonly http = inject(HttpClient);
private readonly api = inject(ApiConfigService);
private readonly toast = inject(ToastService);
form!: FormGroup;
sending = false;
get memberName(): string { return memberDisplayName(this.member); }
ngOnInit(): void {
this.form = this.fb.group({
title: ['River of Life', [Validators.required]],
body: ['', [Validators.required]],
});
}
async send(): Promise<void> {
if (this.form.invalid) { this.form.markAllAsTouched(); return; }
this.sending = true;
try {
const result = await firstValueFrom(
this.http.post<NotificationResult>(this.api.getApiUrl('notifications/send-webpush'), {
memberIds: [this.member.id],
title: this.form.value.title,
body: this.form.value.body,
}),
);
if (result.sentCount > 0) {
this.toast.success(`已送出 ${result.sentCount} 則推播給 ${this.memberName}`);
this.onClose();
} else {
this.toast.error(`${this.memberName} 尚未在任何裝置啟用推播,無法送達。`);
}
} catch (err: unknown) {
const message = (err as { error?: { message?: string } })?.error?.message;
this.toast.error(message ?? '送出推播失敗。');
} finally {
this.sending = false;
}
}
onClose(): void {
this.cancelled.emit();
}
}
@@ -63,7 +63,7 @@
</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Actions" [width]="290">
<kendo-grid-column title="Actions" [width]="360">
<ng-template kendoGridCellTemplate let-row>
<div class="k-d-flex k-gap-2">
<button kendoButton size="small" (click)="openEditDialog(row)">Edit</button>
@@ -72,6 +72,8 @@
(click)="openCreateUserDialog(row)">+ Account</button>
<button *appHasPermission="['Users', 'write']" kendoButton size="small" themeColor="warning"
(click)="openInviteDialog(row)">Invite</button>
<button *appHasPermission="['Settings', 'write']" kendoButton size="small"
(click)="openPushDialog(row)">Push</button>
</div>
</ng-template>
</kendo-grid-column>
@@ -101,3 +103,10 @@
[member]="selectedMemberForInvite"
(cancelled)="closeInviteDialog()">
</app-invitation-dialog>
<!-- Send Push Dialog (one-to-one test) -->
<app-send-push-dialog
*ngIf="showPushDialog && selectedMemberForPush"
[member]="selectedMemberForPush"
(cancelled)="closePushDialog()">
</app-send-push-dialog>
@@ -10,6 +10,7 @@ import { MemberApiService } from '../../services/member-api.service';
import { MemberFormDialogComponent } from '../../components/member-form-dialog/member-form-dialog.component';
import { CreateUserDialogComponent } from '../../components/create-user-dialog/create-user-dialog.component';
import { InvitationDialogComponent } from '../../components/invitation-dialog/invitation-dialog.component';
import { SendPushDialogComponent } from '../../components/send-push-dialog/send-push-dialog.component';
import {
MemberListItemDto, MemberDto, CreateMemberRequest,
PagedResult, memberDisplayName
@@ -25,6 +26,7 @@ import { HasPermissionDirective } from '../../../../core/directives/has-permissi
CommonModule, FormsModule, GridModule, InputsModule,
ButtonsModule, IndicatorsModule, DropDownsModule,
MemberFormDialogComponent, CreateUserDialogComponent, InvitationDialogComponent,
SendPushDialogComponent,
PageHeaderActionsDirective, HasPermissionDirective,
],
templateUrl: './members-page.component.html',
@@ -50,9 +52,11 @@ export class MembersPageComponent implements OnInit {
showMemberDialog = false;
showCreateUserDialog = false;
showInviteDialog = false;
showPushDialog = false;
editingMember: MemberDto | null = null;
selectedMemberForUser: MemberListItemDto | null = null;
selectedMemberForInvite: MemberListItemDto | null = null;
selectedMemberForPush: MemberListItemDto | null = null;
readonly memberDisplayName = memberDisplayName;
@@ -158,4 +162,16 @@ export class MembersPageComponent implements OnInit {
// An invitation may have just created an account, so refresh the grid.
this.loadData();
}
// ── Send Push (one-to-one test) ──────────────────────────────────────────────
openPushDialog(member: MemberListItemDto): void {
this.selectedMemberForPush = member;
this.showPushDialog = true;
}
closePushDialog(): void {
this.showPushDialog = false;
this.selectedMemberForPush = null;
}
}