feat(church-profile): AI 設定 tab (provider/model/key) with masked keys

This commit is contained in:
Chris Chen
2026-06-25 13:29:52 -07:00
parent 120240ad0c
commit 26259c252d
3 changed files with 79 additions and 5 deletions
@@ -49,6 +49,11 @@ export interface ChurchProfileDto {
email: string | null; website: string | null; address: string | null; city: string | null; email: string | null; website: string | null; address: string | null; city: string | null;
state: string | null; zipCode: string | null; bankName: string | null; state: string | null; zipCode: string | null; bankName: string | null;
bankAccountNumber: string | null; bankRoutingNumber: string | null; nextCheckNumber: number; bankAccountNumber: string | null; bankRoutingNumber: string | null; nextCheckNumber: number;
aiProvider: string;
claudeModel: string | null; claudeApiKeyMasked: string | null;
geminiModel: string | null; geminiApiKeyMasked: string | null;
} }
export type UpdateChurchProfileRequest = Omit<ChurchProfileDto, 'id'>; export type UpdateChurchProfileRequest =
Omit<ChurchProfileDto, 'id' | 'claudeApiKeyMasked' | 'geminiApiKeyMasked'>
& { claudeApiKey: string | null; geminiApiKey: string | null };
@@ -82,5 +82,52 @@
<app-notification-settings-tab></app-notification-settings-tab> <app-notification-settings-tab></app-notification-settings-tab>
</ng-template> </ng-template>
</kendo-tabstrip-tab> </kendo-tabstrip-tab>
<!-- ── Tab 4: AI Settings (Settings permission) ───────────────────────── -->
<kendo-tabstrip-tab title="AI 設定 / AI Settings" *appHasPermission="settingsPermission">
<ng-template kendoTabContent>
<div *ngIf="model" class="max-w-3xl pt-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1 md:col-span-2">
AI Provider / AI 供應商
<kendo-dropdownlist
[data]="aiProviders" textField="text" valueField="value" [valuePrimitive]="true"
[(ngModel)]="model.aiProvider"></kendo-dropdownlist>
</label>
<label class="flex flex-col gap-1">
Claude Model / Claude 模型
<kendo-textbox [(ngModel)]="model.claudeModel"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Claude API Key / Claude 金鑰
<input kendoTextBox type="password" autocomplete="new-password"
[(ngModel)]="claudeApiKeyInput"
[placeholder]="model.claudeApiKeyMasked || 'Enter key / 輸入金鑰'" />
</label>
<label class="flex flex-col gap-1">
Gemini Model / Gemini 模型
<kendo-textbox [(ngModel)]="model.geminiModel"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Gemini API Key / Gemini 金鑰
<input kendoTextBox type="password" autocomplete="new-password"
[(ngModel)]="geminiApiKeyInput"
[placeholder]="model.geminiApiKeyMasked || 'Enter key / 輸入金鑰'" />
</label>
<p class="md:col-span-2 text-sm" style="color:#6b7280;">
Leave a key blank to keep the saved one. / 金鑰留空表示沿用已儲存的設定。
</p>
</div>
<div class="flex items-center gap-3 mt-4">
<button kendoButton themeColor="primary" [disabled]="saving" (click)="save()">Save / 儲存</button>
<span class="text-sm" style="color:#065f46;">{{ savedMsg }}</span>
</div>
</div>
</ng-template>
</kendo-tabstrip-tab>
</kendo-tabstrip> </kendo-tabstrip>
</div> </div>
@@ -4,8 +4,9 @@ import { FormsModule } from '@angular/forms';
import { ButtonsModule } from '@progress/kendo-angular-buttons'; import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { InputsModule } from '@progress/kendo-angular-inputs'; import { InputsModule } from '@progress/kendo-angular-inputs';
import { LayoutModule } from '@progress/kendo-angular-layout'; import { LayoutModule } from '@progress/kendo-angular-layout';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { DisbursementApiService } from '../../services/disbursement-api.service'; import { DisbursementApiService } from '../../services/disbursement-api.service';
import { ChurchProfileDto } from '../../models/disbursement.model'; import { ChurchProfileDto, UpdateChurchProfileRequest } from '../../models/disbursement.model';
import { HasPermissionDirective } from '../../../../core/directives/has-permission.directive'; import { HasPermissionDirective } from '../../../../core/directives/has-permission.directive';
import { PermissionModules } from '../../../../core/models/permission.model'; import { PermissionModules } from '../../../../core/models/permission.model';
import { SiteSettingsTabComponent } from '../../../settings/components/site-settings-tab/site-settings-tab.component'; import { SiteSettingsTabComponent } from '../../../settings/components/site-settings-tab/site-settings-tab.component';
@@ -15,7 +16,7 @@ import { NotificationSettingsTabComponent } from '../../../settings/components/n
selector: 'app-church-profile-page', selector: 'app-church-profile-page',
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, FormsModule, ButtonsModule, InputsModule, LayoutModule, CommonModule, FormsModule, ButtonsModule, InputsModule, LayoutModule, DropDownsModule,
HasPermissionDirective, SiteSettingsTabComponent, NotificationSettingsTabComponent, HasPermissionDirective, SiteSettingsTabComponent, NotificationSettingsTabComponent,
], ],
templateUrl: './church-profile-page.component.html', templateUrl: './church-profile-page.component.html',
@@ -25,6 +26,15 @@ export class ChurchProfilePageComponent implements OnInit {
saving = false; saving = false;
savedMsg = ''; savedMsg = '';
/** Bound to the password inputs; blank means "keep the saved key". Reset after each save. */
claudeApiKeyInput = '';
geminiApiKeyInput = '';
readonly aiProviders = [
{ text: 'Claude', value: 'Claude' },
{ text: 'Gemini', value: 'Gemini' },
];
/** Settings module gates the Site / Notification tabs. */ /** Settings module gates the Site / Notification tabs. */
readonly settingsPermission = { module: PermissionModules.Settings, action: 'read' as const }; readonly settingsPermission = { module: PermissionModules.Settings, action: 'read' as const };
@@ -38,9 +48,21 @@ export class ChurchProfilePageComponent implements OnInit {
if (!this.model || this.saving) return; if (!this.model || this.saving) return;
this.saving = true; this.saving = true;
this.savedMsg = ''; this.savedMsg = '';
const { id, ...req } = this.model; const { id, claudeApiKeyMasked, geminiApiKeyMasked, ...rest } = this.model;
const req: UpdateChurchProfileRequest = {
...rest,
claudeApiKey: this.claudeApiKeyInput.trim() || null,
geminiApiKey: this.geminiApiKeyInput.trim() || null,
};
this.api.updateChurchProfile(req).subscribe({ this.api.updateChurchProfile(req).subscribe({
next: () => { this.saving = false; this.savedMsg = 'Saved / 已儲存'; }, next: () => {
this.saving = false;
this.savedMsg = 'Saved / 已儲存';
// Clear the key inputs and reload so the masked placeholders reflect the new keys.
this.claudeApiKeyInput = '';
this.geminiApiKeyInput = '';
this.api.getChurchProfile().subscribe(p => (this.model = p));
},
error: () => { error: () => {
// Error message is shown globally by httpErrorInterceptor. // Error message is shown globally by httpErrorInterceptor.
this.saving = false; this.saving = false;