@@ -44,6 +44,7 @@
|
||||
"@angular/localize/init"
|
||||
],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"serviceWorker": "ngsw-config.json",
|
||||
"assets": [
|
||||
"src/assets",
|
||||
"src/manifest.webmanifest"
|
||||
|
||||
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+22
-2
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'), {}));
|
||||
}
|
||||
}
|
||||
+109
@@ -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;
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -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>
|
||||
|
||||
+2
-1
@@ -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 {}
|
||||
|
||||
+110
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user