Просмотр исходного кода

feat: expose secure admin password reset

彭峰 1 месяц назад
Родитель
Сommit
8f6170d38e

+ 1 - 0
docs/migration/admin-migration-status.md

@@ -15,6 +15,7 @@
 - 后台“全部数据类”目录由管理员云函数实时返回白名单,所有已开放规范化类均可从界面进入动态列表/编辑页;`_Session`、`Function` 等敏感系统类不进入目录。
 - 动态编辑器对 Object/Array 执行 JSON 类型校验,错误格式会阻止保存;Relation、File、GeoPoint 等无法安全通用写入的 Parse 类型由前后端共同设为只读,避免结构化字段被字符串覆盖。
 - 动态资源表按 `objectId/name/title/username` 等业务字段稳定优先展示,总页数采用向上取整;只读类使用查看抽屉且不显示保存/删除操作。
+- `_User` 详情抽屉已接入专用 `resetPassword` 管理员云函数,执行 8 位最小长度与二次确认;新密码不进入通用资源字段或前端持久化存储。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

+ 10 - 0
src/app/admin/pages/admin-resource.component.html

@@ -30,6 +30,16 @@
           @else { <input [type]="field.type === 'Number' ? 'number' : field.type === 'Date' ? 'datetime-local' : 'text'" [ngModel]="inputValue(field)" (ngModelChange)="setField(field, $event)" [placeholder]="field.type === 'Pointer' ? (field.targetClass + ' objectId') : field.name" /> }
         </label>
       } }
+      @if (className() === '_User' && draftId()) {
+        <section class="password-reset">
+          <header><lucide-icon [img]="icons.KeyRound" [size]="18" /><div><strong>重置登录密码</strong><span>通过专用云函数写入,不进入通用资料字段</span></div></header>
+          <label><span>新密码 <small>至少 8 位</small></span><input type="password" autocomplete="new-password" [ngModel]="newPassword()" (ngModelChange)="newPassword.set($event)" /></label>
+          <label><span>确认密码</span><input type="password" autocomplete="new-password" [ngModel]="confirmPassword()" (ngModelChange)="confirmPassword.set($event)" /></label>
+          @if (passwordError()) { <div class="password-status error">{{ passwordError() }}</div> }
+          @if (passwordMessage()) { <div class="password-status success">{{ passwordMessage() }}</div> }
+          <button type="button" [disabled]="resettingPassword()" (click)="resetPassword()">{{ resettingPassword() ? '重置中…' : '确认重置密码' }}</button>
+        </section>
+      }
     </div>
     <footer><button type="button" (click)="closeEditor()">{{ schema()?.writable ? '取消' : '关闭' }}</button>@if (schema()?.writable) { <button class="primary" type="button" [disabled]="saving()" (click)="save()">{{ saving() ? '保存中…' : '保存记录' }}</button> }</footer>
   </aside>

+ 1 - 0
src/app/admin/pages/admin-resource.component.scss

@@ -5,4 +5,5 @@
 .pagination { display: flex; align-items: center; justify-content: space-between; padding: 14px 17px; border-top: 1px solid #edf0f4; color: #7a8798; font-size: 12px; }.pagination div { display: flex; gap: 8px; }.pagination button { min-height: 34px; }.pagination button:disabled { opacity: .45; cursor: default; }
 .empty { display: grid; place-items: center; gap: 10px; min-height: 300px; color: #7f8b9c; }.inline-error { padding: 10px 17px; color: #b33f48; background: #fff2f3; font-size: 12px; }.spin { animation: spin .8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } }
 .drawer-mask { position: fixed; inset: 0; z-index: 50; border: 0; background: rgba(11,24,42,.42); }.editor-drawer { position: fixed; top: 0; right: 0; z-index: 60; display: grid; grid-template-rows: auto 1fr auto; width: min(560px, 94vw); height: 100vh; background: white; box-shadow: -18px 0 50px rgba(17,36,62,.18); }.editor-drawer header, .editor-drawer footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 18px 22px; border-bottom: 1px solid #e8ecf2; }.editor-drawer header div { display: grid; gap: 3px; }.editor-drawer header span { color: #8290a3; font-size: 11px; }.editor-drawer header strong { font-size: 18px; }.editor-drawer header button { display: grid; place-items: center; width: 34px; height: 34px; border: 0; border-radius: 8px; background: #f2f5f8; cursor: pointer; }.editor-fields { display: grid; align-content: start; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 17px; overflow: auto; padding: 22px; }.editor-fields label { display: grid; align-content: start; gap: 7px; color: #445165; font-size: 12px; }.editor-fields label:has(textarea) { grid-column: 1 / -1; }.editor-fields small { color: #95a0af; font-weight: 400; }.editor-fields input:not([type=checkbox]), .editor-fields textarea { width: 100%; box-sizing: border-box; padding: 10px 11px; border: 1px solid #dce3eb; border-radius: 8px; outline: 0; background: #fbfcfd; font: inherit; }.editor-fields input:focus, .editor-fields textarea:focus { border-color: #44a681; box-shadow: 0 0 0 3px rgba(68,166,129,.1); }.editor-fields textarea { resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }.editor-fields textarea.invalid { border-color: #cf5961; box-shadow: 0 0 0 3px rgba(207,89,97,.1); }.editor-fields .field-error { color: #b33f48; }.editor-drawer footer { justify-content: flex-end; border-top: 1px solid #e8ecf2; border-bottom: 0; }
+.password-reset { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr 1fr; gap: 13px; margin-top: 4px; padding: 16px; border: 1px solid #dce8e3; border-radius: 11px; background: #f7fbf9; }.password-reset > header { grid-column: 1 / -1; justify-content: flex-start; padding: 0 0 12px; border-bottom: 1px solid #dfeae5; color: #28795b; }.password-reset > header div { gap: 2px; }.password-reset > header strong { font-size: 13px; }.password-reset > header span { font-size: 10px; }.password-reset > button { grid-column: 1 / -1; justify-self: end; padding: 8px 13px; border: 0; border-radius: 8px; color: white; background: #287e5e; cursor: pointer; }.password-reset > button:disabled { opacity: .55; }.password-status { grid-column: 1 / -1; font-size: 11px; }.password-status.error { color: #b33f48; }.password-status.success { color: #237454; }
 @media (max-width: 700px) { .resource-header { align-items: start; }.resource-header span { display: none; }.header-actions button:first-child { display: none; }.editor-fields { grid-template-columns: 1fr; }.editor-fields label:has(textarea) { grid-column: auto; }.pagination > span { display: none; }.pagination { justify-content: flex-end; } }

+ 11 - 0
src/app/admin/pages/admin-resource.component.spec.ts

@@ -53,4 +53,15 @@ describe('AdminResourceComponent', () => {
     expect(component.columns().map((field) => field.name)).toEqual(['objectId', 'title', 'misc']);
     expect(component.pageCount()).toBe(2);
   });
+
+  it('uses the dedicated cloud operation to reset an existing user password', async () => {
+    functions.admin.and.resolveTo({ objectId: 'user-object' });
+    component.className.set('_User');
+    component.draftId.set('user-object');
+    component.newPassword.set('SafePass123');
+    component.confirmPassword.set('SafePass123');
+    await component.resetPassword();
+    expect(functions.admin).toHaveBeenCalledWith('resetPassword', { className: '_User', objectId: 'user-object', newPassword: 'SafePass123' });
+    expect(component.passwordMessage()).toContain('已安全重置');
+  });
 });

+ 23 - 5
src/app/admin/pages/admin-resource.component.ts

@@ -3,7 +3,7 @@ import { ChangeDetectionStrategy, Component, DestroyRef, OnInit, computed, injec
 import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
 import { FormsModule } from '@angular/forms';
 import { ActivatedRoute } from '@angular/router';
-import { ChevronLeft, ChevronRight, Eye, LoaderCircle, LucideAngularModule, Pencil, Plus, RefreshCw, Search, Trash2, X } from 'lucide-angular';
+import { ChevronLeft, ChevronRight, Eye, KeyRound, LoaderCircle, LucideAngularModule, Pencil, Plus, RefreshCw, Search, Trash2, X } from 'lucide-angular';
 import { AdminClassSchema, AdminFieldSchema, ResourcePage } from '../admin.models';
 import { CloudFunctionsService } from '../cloud-functions.service';
 
@@ -21,6 +21,7 @@ export class AdminResourceComponent implements OnInit {
   readonly page = signal<ResourcePage | null>(null);
   readonly loading = signal(true);
   readonly saving = signal(false);
+  readonly resettingPassword = signal(false);
   readonly error = signal('');
   readonly search = signal('');
   readonly editorOpen = signal(false);
@@ -28,7 +29,11 @@ export class AdminResourceComponent implements OnInit {
   readonly draft = signal<Record<string, unknown>>({});
   readonly fieldErrors = signal<Record<string, string>>({});
   readonly fieldInputs = signal<Record<string, string>>({});
-  readonly icons = { ChevronLeft, ChevronRight, Eye, LoaderCircle, Pencil, Plus, RefreshCw, Search, Trash2, X };
+  readonly newPassword = signal('');
+  readonly confirmPassword = signal('');
+  readonly passwordMessage = signal('');
+  readonly passwordError = signal('');
+  readonly icons = { ChevronLeft, ChevronRight, Eye, KeyRound, LoaderCircle, Pencil, Plus, RefreshCw, Search, Trash2, X };
   readonly columns = computed(() => {
     const fields = this.schema()?.fields ?? [];
     const preferred = ['objectId', 'name', 'title', 'username', 'realName', 'mobile', 'sourceKey', 'status', 'createdAt', 'updatedAt'];
@@ -57,16 +62,16 @@ export class AdminResourceComponent implements OnInit {
     finally { this.loading.set(false); }
   }
 
-  openCreate(): void { this.draftId.set(''); this.draft.set({}); this.fieldErrors.set({}); this.fieldInputs.set({}); this.editorOpen.set(true); }
+  openCreate(): void { this.draftId.set(''); this.draft.set({}); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.editorOpen.set(true); }
   async openEdit(row: Record<string, unknown>): Promise<void> {
     const objectId = String(row['objectId'] ?? '');
     this.error.set('');
     try {
       const detail = await this.functions.admin<Record<string, unknown>>('get', { className: this.className(), objectId });
-      this.draftId.set(objectId); this.draft.set(detail); this.fieldErrors.set({}); this.fieldInputs.set({}); this.editorOpen.set(true);
+      this.draftId.set(objectId); this.draft.set(detail); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.editorOpen.set(true);
     } catch (error) { this.error.set(error instanceof Error ? error.message : '详情加载失败'); }
   }
-  closeEditor(): void { this.editorOpen.set(false); this.draft.set({}); this.draftId.set(''); this.fieldErrors.set({}); this.fieldInputs.set({}); }
+  closeEditor(): void { this.editorOpen.set(false); this.draft.set({}); this.draftId.set(''); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); }
   setField(field: AdminFieldSchema, raw: unknown): void {
     let value = raw;
     if (field.type === 'Number') value = raw === '' ? null : Number(raw);
@@ -102,6 +107,18 @@ export class AdminResourceComponent implements OnInit {
     } catch (error) { this.error.set(error instanceof Error ? error.message : '保存失败'); }
     finally { this.saving.set(false); }
   }
+  async resetPassword(): Promise<void> {
+    this.passwordError.set(''); this.passwordMessage.set('');
+    if (this.className() !== '_User' || !this.draftId()) { this.passwordError.set('只能重置现有用户密码'); return; }
+    if (this.newPassword().length < 8) { this.passwordError.set('新密码至少 8 位'); return; }
+    if (this.newPassword() !== this.confirmPassword()) { this.passwordError.set('两次输入的密码不一致'); return; }
+    this.resettingPassword.set(true);
+    try {
+      await this.functions.admin('resetPassword', { className: '_User', objectId: this.draftId(), newPassword: this.newPassword() });
+      this.newPassword.set(''); this.confirmPassword.set(''); this.passwordMessage.set('密码已安全重置');
+    } catch (error) { this.passwordError.set(error instanceof Error ? error.message : '密码重置失败'); }
+    finally { this.resettingPassword.set(false); }
+  }
   async remove(row: Record<string, unknown>): Promise<void> {
     const objectId = String(row['objectId'] ?? '');
     if (!objectId || !confirm(`确认删除 ${this.className()} / ${objectId}?`)) return;
@@ -116,4 +133,5 @@ export class AdminResourceComponent implements OnInit {
     }
     return String(value);
   }
+  private resetPasswordFields(): void { this.newPassword.set(''); this.confirmPassword.set(''); this.passwordMessage.set(''); this.passwordError.set(''); }
 }