+
+
+
+
+
Email (SMTP) / 電子郵件
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ testEmailMsg }}
+
+
+
+
+
+
+
+
+
Line / Line 通知
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ testLineMsg }}
+
+
+
+
+
+ {{ savedMsg }}
+
+
diff --git a/APP/src/app/features/settings/components/notification-settings-tab/notification-settings-tab.component.ts b/APP/src/app/features/settings/components/notification-settings-tab/notification-settings-tab.component.ts
new file mode 100644
index 0000000..ae422a4
--- /dev/null
+++ b/APP/src/app/features/settings/components/notification-settings-tab/notification-settings-tab.component.ts
@@ -0,0 +1,106 @@
+import { Component, OnInit } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { ButtonsModule } from '@progress/kendo-angular-buttons';
+import { InputsModule } from '@progress/kendo-angular-inputs';
+import { SettingsApiService } from '../../services/settings-api.service';
+import {
+ NotificationSettingDto, UpdateNotificationSettingRequest, NotificationResult,
+} from '../../models/settings.model';
+
+@Component({
+ selector: 'app-notification-settings-tab',
+ standalone: true,
+ imports: [CommonModule, FormsModule, ButtonsModule, InputsModule],
+ templateUrl: './notification-settings-tab.component.html',
+})
+export class NotificationSettingsTabComponent implements OnInit {
+ model: NotificationSettingDto | null = null;
+ saving = false;
+ savedMsg = '';
+
+ // Write-only secret inputs — blank means "keep the stored value".
+ smtpPassword = '';
+ lineToken = '';
+ lineSecret = '';
+
+ // Test-send state.
+ testEmailTo = '';
+ testEmailMsg = '';
+ testingEmail = false;
+
+ testLineMemberId: number | null = null;
+ testLineGroupId: number | null = null;
+ testLineMsg = '';
+ testingLine = false;
+
+ constructor(private api: SettingsApiService) {}
+
+ ngOnInit(): void {
+ this.load();
+ }
+
+ private load(): void {
+ this.api.getNotification().subscribe(n => (this.model = n));
+ }
+
+ save(): void {
+ if (!this.model || this.saving) return;
+ this.saving = true;
+ this.savedMsg = '';
+ const m = this.model;
+ const request: UpdateNotificationSettingRequest = {
+ enableEmail: m.enableEmail,
+ smtpHost: m.smtpHost,
+ smtpPort: m.smtpPort,
+ smtpUseSsl: m.smtpUseSsl,
+ smtpUser: m.smtpUser,
+ fromAddress: m.fromAddress,
+ fromName: m.fromName,
+ smtpPassword: this.smtpPassword || null,
+ enableLine: m.enableLine,
+ lineChannelAccessToken: this.lineToken || null,
+ lineChannelSecret: this.lineSecret || null,
+ };
+ this.api.updateNotification(request).subscribe({
+ next: () => {
+ this.saving = false;
+ this.savedMsg = 'Saved / 已儲存';
+ // Clear secret inputs and refresh the "configured" flags.
+ this.smtpPassword = this.lineToken = this.lineSecret = '';
+ this.load();
+ },
+ error: () => { this.saving = false; },
+ });
+ }
+
+ sendTestEmail(): void {
+ if (this.testingEmail) return;
+ this.testingEmail = true;
+ this.testEmailMsg = '';
+ this.api.testEmail({ toAddress: this.testEmailTo || null }).subscribe({
+ next: result => { this.testingEmail = false; this.testEmailMsg = this.describe(result); },
+ error: err => { this.testingEmail = false; this.testEmailMsg = this.errorText(err); },
+ });
+ }
+
+ sendTestLine(): void {
+ if (this.testingLine) return;
+ this.testingLine = true;
+ this.testLineMsg = '';
+ this.api.testLine({ memberId: this.testLineMemberId, groupId: this.testLineGroupId }).subscribe({
+ next: result => { this.testingLine = false; this.testLineMsg = this.describe(result); },
+ error: err => { this.testingLine = false; this.testLineMsg = this.errorText(err); },
+ });
+ }
+
+ private describe(result: NotificationResult): string {
+ if (result.failedCount > 0)
+ return `Failed / 失敗:${result.failures.map(f => f.error).join('; ')}`;
+ return result.sentCount > 0 ? 'Sent ✓ / 已發送' : 'Nothing sent / 未發送';
+ }
+
+ private errorText(err: { error?: { message?: string } }): string {
+ return err?.error?.message ?? 'Failed / 失敗';
+ }
+}
diff --git a/APP/src/app/features/settings/components/site-settings-tab/site-settings-tab.component.html b/APP/src/app/features/settings/components/site-settings-tab/site-settings-tab.component.html
new file mode 100644
index 0000000..6d2ca90
--- /dev/null
+++ b/APP/src/app/features/settings/components/site-settings-tab/site-settings-tab.component.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ savedMsg }}
+
+
diff --git a/APP/src/app/features/settings/components/site-settings-tab/site-settings-tab.component.ts b/APP/src/app/features/settings/components/site-settings-tab/site-settings-tab.component.ts
new file mode 100644
index 0000000..2f7eac8
--- /dev/null
+++ b/APP/src/app/features/settings/components/site-settings-tab/site-settings-tab.component.ts
@@ -0,0 +1,42 @@
+import { Component, OnInit } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { ButtonsModule } from '@progress/kendo-angular-buttons';
+import { InputsModule } from '@progress/kendo-angular-inputs';
+import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
+import { SettingsApiService } from '../../services/settings-api.service';
+import { SiteSettingDto } from '../../models/settings.model';
+
+@Component({
+ selector: 'app-site-settings-tab',
+ standalone: true,
+ imports: [CommonModule, FormsModule, ButtonsModule, InputsModule, DropDownsModule],
+ templateUrl: './site-settings-tab.component.html',
+})
+export class SiteSettingsTabComponent implements OnInit {
+ model: SiteSettingDto | null = null;
+ saving = false;
+ savedMsg = '';
+
+ readonly languages = [
+ { text: 'English', value: 'en' },
+ { text: '中文 Chinese', value: 'zh' },
+ ];
+
+ constructor(private api: SettingsApiService) {}
+
+ ngOnInit(): void {
+ this.api.getSite().subscribe(s => (this.model = s));
+ }
+
+ save(): void {
+ if (!this.model || this.saving) return;
+ this.saving = true;
+ this.savedMsg = '';
+ this.api.updateSite(this.model).subscribe({
+ next: () => { this.saving = false; this.savedMsg = 'Saved / 已儲存'; },
+ // Errors surface globally via httpErrorInterceptor.
+ error: () => { this.saving = false; },
+ });
+ }
+}
diff --git a/APP/src/app/features/settings/models/settings.model.ts b/APP/src/app/features/settings/models/settings.model.ts
new file mode 100644
index 0000000..dfb3350
--- /dev/null
+++ b/APP/src/app/features/settings/models/settings.model.ts
@@ -0,0 +1,66 @@
+// Mirrors ROLAC.API.DTOs.Settings — site + notification settings edited from the
+// Church Profile tabbed page (Settings permission module).
+
+export interface SiteSettingDto {
+ siteTitle: string;
+ siteTitleZh: string | null;
+ defaultLanguage: string; // 'en' | 'zh'
+ timeZone: string;
+ dateFormat: string;
+ currency: string;
+}
+
+export type UpdateSiteSettingRequest = SiteSettingDto;
+
+export interface NotificationSettingDto {
+ enableEmail: boolean;
+ smtpHost: string;
+ smtpPort: number;
+ smtpUseSsl: boolean;
+ smtpUser: string;
+ fromAddress: string;
+ fromName: string;
+ /** True when a password is stored — secrets themselves are never returned. */
+ hasSmtpPassword: boolean;
+
+ enableLine: boolean;
+ hasLineChannelAccessToken: boolean;
+ hasLineChannelSecret: boolean;
+
+ /** Read-only webhook URL to register in the Line console. */
+ webhookUrl: string;
+}
+
+export interface UpdateNotificationSettingRequest {
+ enableEmail: boolean;
+ smtpHost: string;
+ smtpPort: number;
+ smtpUseSsl: boolean;
+ smtpUser: string;
+ fromAddress: string | null;
+ fromName: string | null;
+ /** Leave blank/omit to keep the stored password. */
+ smtpPassword?: string | null;
+
+ enableLine: boolean;
+ /** Leave blank/omit to keep the stored token. */
+ lineChannelAccessToken?: string | null;
+ /** Leave blank/omit to keep the stored secret. */
+ lineChannelSecret?: string | null;
+}
+
+export interface TestEmailRequest {
+ toAddress?: string | null;
+}
+
+export interface TestLineRequest {
+ memberId?: number | null;
+ groupId?: number | null;
+}
+
+/** Mirrors ROLAC.API NotificationResult. */
+export interface NotificationResult {
+ sentCount: number;
+ failedCount: number;
+ failures: { target: string; error: string }[];
+}
diff --git a/APP/src/app/features/settings/services/settings-api.service.ts b/APP/src/app/features/settings/services/settings-api.service.ts
new file mode 100644
index 0000000..38521e5
--- /dev/null
+++ b/APP/src/app/features/settings/services/settings-api.service.ts
@@ -0,0 +1,42 @@
+import { Injectable } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { Observable } from 'rxjs';
+import { ApiConfigService } from '../../../core/services/api-config.service';
+import {
+ SiteSettingDto, UpdateSiteSettingRequest,
+ NotificationSettingDto, UpdateNotificationSettingRequest,
+ TestEmailRequest, TestLineRequest, NotificationResult,
+} from '../models/settings.model';
+
+@Injectable({ providedIn: 'root' })
+export class SettingsApiService {
+ private readonly endpoint: string;
+
+ constructor(private http: HttpClient, apiConfig: ApiConfigService) {
+ this.endpoint = apiConfig.getApiUrl('settings');
+ }
+
+ getSite(): Observable