Parcourir la source

feat: connect configurable SaaS authentication

gangvy il y a 1 mois
Parent
commit
e2fd3c34eb

+ 14 - 2
BACKEND-INTEGRATION.md

@@ -5,20 +5,32 @@ The bundled Demashi JSON remains the default so the reusable template can start
 ```html
 <script>
   window.__SAAS_VOC_CONFIG__ = {
+    apiAuthMode: 'parse',
     domesticDatasetMode: 'api',
     domesticVocPath: '/api/domestic-voc',
+    saasPath: '/api/saas',
     domesticWorkspaceId: 'demashi'
   };
 </script>
 ```
 
-API mode requests only:
+Local development keeps `apiAuthMode: 'disabled'`. Production must use `parse`; protected routes then require a valid Parse user and an active workspace membership returned by `/api/saas/context`.
+
+The frontend uses standard Parse username/password login. It no longer depends on the cross-border `user_login` Cloud Function or its RSA key. Parse session tokens are attached only to `/api/domestic-voc` and `/api/saas`; they are never attached to `/api/voc-e-commerce`.
+
+Primary API requests:
 
 ```text
 GET /api/domestic-voc/snapshot?workspaceId=demashi&platform=jd
+GET /api/saas/context
+GET /api/saas/workspaces/demashi/actions
+GET /api/saas/workspaces/demashi/alerts
 ```
 
-Local `ng serve` forwards `/api/domestic-voc` to `http://127.0.0.1:4400`. Fmode and database credentials remain server-side.
+Local `ng serve` forwards `/api/domestic-voc`, `/api/saas`, and `/parse` to `http://127.0.0.1:4400`. Fmode, Parse privileged keys, and database credentials remain server-side.
 
 The API response must keep the `DomesticDataset` contract in `src/app/core/models/domestic.models.ts`. Missing reviews remain an empty array and must not be replaced by inferred VOC output.
 
+## Runtime and dependency boundary
+
+Frontend and backend use Node.js 22.13 or newer within the Node 22 release line. The frontend Parse SDK is updated within major version 8, `ws` is overridden to 8.21.0, and the vulnerable XML parser chain removed by the normal audit fix. Remaining Angular 17, ECharts 5, and `xlsx` advisories require planned framework/library migration; do not use `npm audit fix --force` for the deployable template.

+ 28 - 110
package-lock.json

@@ -26,7 +26,6 @@
         "echarts": "^5.6.0",
         "file-saver": "^2.0.5",
         "fmode-ng": "^0.0.243",
-        "jsencrypt": "^3.5.4",
         "marked": "^17.0.4",
         "ngx-echarts": "^17.2.0",
         "parse": "^8.0.3",
@@ -52,7 +51,7 @@
         "webpack-bundle-analyzer": "^4.9.1"
       },
       "engines": {
-        "node": ">=18.13 <23",
+        "node": ">=22.13.0 <23",
         "npm": ">=9.0.0"
       }
     },
@@ -1542,13 +1541,12 @@
       }
     },
     "node_modules/@aws-sdk/xml-builder": {
-      "version": "3.972.2",
-      "resolved": "https://registry.npmmirror.com/@aws-sdk/xml-builder/-/xml-builder-3.972.2.tgz",
-      "integrity": "sha512-jGOOV/bV1DhkkUhHiZ3/1GZ67cZyOXaDb7d1rYD6ZiXf5V9tBNOcgqXwRRPvrCbYaFRa1pPMFb3ZjqjWpR3YfA==",
+      "version": "3.972.36",
+      "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.36.tgz",
+      "integrity": "sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==",
       "license": "Apache-2.0",
       "dependencies": {
-        "@smithy/types": "^4.12.0",
-        "fast-xml-parser": "5.2.5",
+        "@smithy/types": "^4.16.1",
         "tslib": "^2.6.2"
       },
       "engines": {
@@ -3224,12 +3222,12 @@
       }
     },
     "node_modules/@babel/runtime-corejs3": {
-      "version": "7.28.6",
-      "resolved": "https://registry.npmmirror.com/@babel/runtime-corejs3/-/runtime-corejs3-7.28.6.tgz",
-      "integrity": "sha512-kz2fAQ5UzjV7X7D3ySxmj3vRq89dTpqOZWv76Z6pNPztkwb/0Yj1Mtx1xFrYj6mbIHysxtBot8J4o0JLCblcFw==",
+      "version": "7.29.2",
+      "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.29.2.tgz",
+      "integrity": "sha512-Lc94FOD5+0aXhdb0Tdg3RUtqT6yWbI/BbFWvlaSJ3gAb9Ks+99nHRDKADVqC37er4eCB0fHyWT+y+K3QOvJKbw==",
       "license": "MIT",
       "dependencies": {
-        "core-js-pure": "^3.43.0"
+        "core-js-pure": "^3.48.0"
       },
       "engines": {
         "node": ">=6.9.0"
@@ -5332,9 +5330,9 @@
       }
     },
     "node_modules/@smithy/types": {
-      "version": "4.12.0",
-      "resolved": "https://registry.npmmirror.com/@smithy/types/-/types-4.12.0.tgz",
-      "integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==",
+      "version": "4.16.1",
+      "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz",
+      "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==",
       "license": "Apache-2.0",
       "dependencies": {
         "tslib": "^2.6.2"
@@ -7414,9 +7412,9 @@
       }
     },
     "node_modules/core-js-pure": {
-      "version": "3.48.0",
-      "resolved": "https://registry.npmmirror.com/core-js-pure/-/core-js-pure-3.48.0.tgz",
-      "integrity": "sha512-1slJgk89tWC51HQ1AEqG+s2VuwpTRr8ocu4n20QUcH1v9lAN0RXen0Q0AABa/DK1I7RrNWLucplOHMx8hfTGTw==",
+      "version": "3.49.0",
+      "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.49.0.tgz",
+      "integrity": "sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw==",
       "hasInstallScript": true,
       "license": "MIT",
       "funding": {
@@ -8533,24 +8531,6 @@
       "dev": true,
       "license": "MIT"
     },
-    "node_modules/fast-xml-parser": {
-      "version": "5.2.5",
-      "resolved": "https://registry.npmmirror.com/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
-      "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/NaturalIntelligence"
-        }
-      ],
-      "license": "MIT",
-      "dependencies": {
-        "strnum": "^2.1.0"
-      },
-      "bin": {
-        "fxparser": "src/cli/cli.js"
-      }
-    },
     "node_modules/fastq": {
       "version": "1.20.1",
       "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz",
@@ -10081,12 +10061,6 @@
         "js-yaml": "bin/js-yaml.js"
       }
     },
-    "node_modules/jsencrypt": {
-      "version": "3.5.4",
-      "resolved": "https://registry.npmjs.org/jsencrypt/-/jsencrypt-3.5.4.tgz",
-      "integrity": "sha512-kNjfYEMNASxrDGsmcSQh/rUTmcoRfSUkxnAz+MMywM8jtGu+fFEZ3nJjHM58zscVnwR0fYmG9sGkTDjqUdpiwA==",
-      "license": "MIT"
-    },
     "node_modules/jsesc": {
       "version": "3.1.0",
       "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz",
@@ -11928,20 +11902,20 @@
       }
     },
     "node_modules/parse": {
-      "version": "8.0.3",
-      "resolved": "https://registry.npmmirror.com/parse/-/parse-8.0.3.tgz",
-      "integrity": "sha512-WQPrnfnXy6/p25OFD6qOAVK9hIhhU882Nw1AW5RjAJbO2G7YqChJxBgL94aexsaTnP9ajVzjGISSQ+mESrkMIA==",
+      "version": "8.6.0",
+      "resolved": "https://registry.npmjs.org/parse/-/parse-8.6.0.tgz",
+      "integrity": "sha512-AZjc8yGo8/iTZFpCXWw/r1qNusiUGWtq9i92/u0jNd+Iupg3EJUSV/OOyTrCeav8NDyo92wVS5O3iKAYPlhlsA==",
       "license": "Apache-2.0",
       "dependencies": {
-        "@babel/runtime": "7.28.6",
-        "@babel/runtime-corejs3": "7.28.6",
+        "@babel/runtime": "7.29.2",
+        "@babel/runtime-corejs3": "7.29.2",
         "crypto-js": "4.2.0",
         "idb-keyval": "6.2.2",
         "react-native-crypto-js": "1.0.0",
-        "ws": "8.19.0"
+        "ws": "8.20.0"
       },
       "engines": {
-        "node": ">=20.19.0 <21 || >=22.12.0 <23 || >=24.1.0 <25"
+        "node": ">=20.19.0 <21 || >=22.13.0 <23 || >=24.1.0 <25"
       }
     },
     "node_modules/parse-json": {
@@ -11981,35 +11955,14 @@
       }
     },
     "node_modules/parse/node_modules/@babel/runtime": {
-      "version": "7.28.6",
-      "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.6.tgz",
-      "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
+      "version": "7.29.2",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
+      "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
       "license": "MIT",
       "engines": {
         "node": ">=6.9.0"
       }
     },
-    "node_modules/parse/node_modules/ws": {
-      "version": "8.19.0",
-      "resolved": "https://registry.npmmirror.com/ws/-/ws-8.19.0.tgz",
-      "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=10.0.0"
-      },
-      "peerDependencies": {
-        "bufferutil": "^4.0.1",
-        "utf-8-validate": ">=5.0.2"
-      },
-      "peerDependenciesMeta": {
-        "bufferutil": {
-          "optional": true
-        },
-        "utf-8-validate": {
-          "optional": true
-        }
-      }
-    },
     "node_modules/parse5": {
       "version": "7.3.0",
       "resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz",
@@ -14177,18 +14130,6 @@
         "node": ">=6"
       }
     },
-    "node_modules/strnum": {
-      "version": "2.1.2",
-      "resolved": "https://registry.npmmirror.com/strnum/-/strnum-2.1.2.tgz",
-      "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/NaturalIntelligence"
-        }
-      ],
-      "license": "MIT"
-    },
     "node_modules/supports-color": {
       "version": "7.2.0",
       "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz",
@@ -15428,28 +15369,6 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
-    "node_modules/webpack-bundle-analyzer/node_modules/ws": {
-      "version": "7.5.10",
-      "resolved": "https://registry.npmmirror.com/ws/-/ws-7.5.10.tgz",
-      "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8.3.0"
-      },
-      "peerDependencies": {
-        "bufferutil": "^4.0.1",
-        "utf-8-validate": "^5.0.2"
-      },
-      "peerDependenciesMeta": {
-        "bufferutil": {
-          "optional": true
-        },
-        "utf-8-validate": {
-          "optional": true
-        }
-      }
-    },
     "node_modules/webpack-dev-middleware": {
       "version": "6.1.2",
       "resolved": "https://registry.npmmirror.com/webpack-dev-middleware/-/webpack-dev-middleware-6.1.2.tgz",
@@ -15800,10 +15719,9 @@
       }
     },
     "node_modules/ws": {
-      "version": "8.18.3",
-      "resolved": "https://registry.npmmirror.com/ws/-/ws-8.18.3.tgz",
-      "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
-      "dev": true,
+      "version": "8.21.0",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+      "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
       "license": "MIT",
       "engines": {
         "node": ">=10.0.0"

+ 4 - 2
package.json

@@ -46,7 +46,6 @@
     "echarts": "^5.6.0",
     "file-saver": "^2.0.5",
     "fmode-ng": "^0.0.243",
-    "jsencrypt": "^3.5.4",
     "marked": "^17.0.4",
     "ngx-echarts": "^17.2.0",
     "parse": "^8.0.3",
@@ -72,9 +71,12 @@
     "webpack-bundle-analyzer": "^4.9.1"
   },
   "engines": {
-    "node": ">=18.13 <23",
+    "node": ">=22.13.0 <23",
     "npm": ">=9.0.0"
   },
+  "overrides": {
+    "ws": "8.21.0"
+  },
   "browserslist": [
     "last 2 Chrome versions",
     "last 2 Firefox versions",

+ 12 - 0
proxy.conf.json

@@ -10,5 +10,17 @@
     "secure": false,
     "changeOrigin": true,
     "logLevel": "debug"
+  },
+  "/api/saas": {
+    "target": "http://127.0.0.1:4400",
+    "secure": false,
+    "changeOrigin": true,
+    "logLevel": "debug"
+  },
+  "/parse": {
+    "target": "http://127.0.0.1:4400",
+    "secure": false,
+    "changeOrigin": true,
+    "logLevel": "debug"
   }
 }

+ 3 - 2
src/app/app.config.ts

@@ -1,15 +1,16 @@
 import { ApplicationConfig } from '@angular/core';
 import { provideRouter } from '@angular/router';
-import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
+import { provideHttpClient, withInterceptors, withInterceptorsFromDi } from '@angular/common/http';
 import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
 import { provideEchartsCore } from 'ngx-echarts';
 
 import { routes } from './app.routes';
+import { parseSessionInterceptor } from './core/interceptors/parse-session.interceptor';
 
 export const appConfig: ApplicationConfig = {
   providers: [
     provideRouter(routes),
-    provideHttpClient(withInterceptorsFromDi()),
+    provideHttpClient(withInterceptors([parseSessionInterceptor]), withInterceptorsFromDi()),
     provideAnimationsAsync(),
     provideEchartsCore({ echarts: () => import('./echarts').then((m) => m.default) }),
   ]

+ 10 - 0
src/app/app.routes.ts

@@ -1,4 +1,8 @@
 import { Routes } from '@angular/router';
+import { AuthGuard } from './core/guards/auth.guard';
+import { RUNTIME_CONFIG } from './core/config/runtime-config';
+
+const protectedRoutes = RUNTIME_CONFIG.apiAuthMode === 'parse' ? [AuthGuard] : [];
 
 export const routes: Routes = [
   { path: '', redirectTo: '/domestic/overview', pathMatch: 'full' },
@@ -8,6 +12,7 @@ export const routes: Routes = [
   },
   {
     path: 'domestic',
+    canActivate: protectedRoutes,
     children: [
       { path: '', redirectTo: 'overview', pathMatch: 'full' },
       {
@@ -67,6 +72,7 @@ export const routes: Routes = [
   },
   {
     path: 'voc-insight',
+    canActivate: protectedRoutes,
     loadComponent: () => import('../modules/voc-insight/voc-insight-shell.component').then((m) => m.VocInsightShellComponent),
     children: [
       { path: '', redirectTo: 'products', pathMatch: 'full' },
@@ -104,11 +110,13 @@ export const routes: Routes = [
   },
   {
     path: 'data-status',
+    canActivate: protectedRoutes,
     title: '数据接入状态',
     loadComponent: () => import('../modules/domestic-voc/data-status.component').then((m) => m.DomesticDataStatusComponent),
   },
   {
     path: 'return-analysis',
+    canActivate: protectedRoutes,
     children: [
       { path: '', redirectTo: 'overview', pathMatch: 'full' },
       {
@@ -133,6 +141,7 @@ export const routes: Routes = [
   },
   {
     path: 'product-development',
+    canActivate: protectedRoutes,
     children: [
       { path: '', redirectTo: 'trend', pathMatch: 'full' },
       {
@@ -169,6 +178,7 @@ export const routes: Routes = [
   },
   {
     path: 'action-suggestion',
+    canActivate: protectedRoutes,
     children: [
       { path: '', redirectTo: 'kanban', pathMatch: 'full' },
       {

+ 4 - 0
src/app/core/config/runtime-config.ts

@@ -2,7 +2,9 @@ export interface SaasVocRuntimeConfig {
   parseServerUrl: string;
   parseAppId: string;
   apiBaseUrl: string;
+  apiAuthMode: 'disabled' | 'parse';
   vocEcommercePath: string;
+  saasPath: string;
   domesticDatasetMode: 'case' | 'api';
   domesticVocPath: string;
   domesticWorkspaceId: string;
@@ -22,7 +24,9 @@ export const RUNTIME_CONFIG: Readonly<SaasVocRuntimeConfig> = Object.freeze({
   parseServerUrl: browserConfig.parseServerUrl || `${browserOrigin}/parse`,
   parseAppId: browserConfig.parseAppId || 'saas-voc-local',
   apiBaseUrl: browserConfig.apiBaseUrl || '',
+  apiAuthMode: browserConfig.apiAuthMode === 'parse' ? 'parse' : 'disabled',
   vocEcommercePath: browserConfig.vocEcommercePath || '/api/voc-e-commerce',
+  saasPath: browserConfig.saasPath || '/api/saas',
   domesticDatasetMode: browserConfig.domesticDatasetMode === 'api' ? 'api' : 'case',
   domesticVocPath: browserConfig.domesticVocPath || '/api/domestic-voc',
   domesticWorkspaceId: browserConfig.domesticWorkspaceId || 'demashi',

+ 12 - 4
src/app/core/guards/auth.guard.ts

@@ -6,14 +6,19 @@ import {
   UrlTree, 
   Router 
 } from '@angular/router';
-import { Observable } from 'rxjs';
+import { Observable, firstValueFrom } from 'rxjs';
 import { MSQParse } from '../../parse';
+import { RUNTIME_CONFIG } from '../config/runtime-config';
+import { SaasPlatformService } from '../services/saas-platform.service';
 
 @Injectable({
   providedIn: 'root'
 })
 export class AuthGuard implements CanActivate {
-  constructor(private router: Router) {}
+  constructor(
+    private router: Router,
+    private platform: SaasPlatformService,
+  ) {}
 
   canActivate(
     route: ActivatedRouteSnapshot,
@@ -23,10 +28,13 @@ export class AuthGuard implements CanActivate {
   }
 
   async checkLogin(url: string): Promise<boolean | UrlTree> {
+    if (RUNTIME_CONFIG.apiAuthMode === 'disabled') return true;
     try {
-      const currentUser = await MSQParse.User.current();
+      const currentUser = MSQParse.User.current();
       if (currentUser) {
-        return true;
+        const context = await firstValueFrom(this.platform.context());
+        if (context.workspaces.length) return true;
+        await MSQParse.User.logOut();
       }
     } catch (e) {
       console.error('Auth Check Error', e);

+ 13 - 0
src/app/core/interceptors/parse-session.interceptor.ts

@@ -0,0 +1,13 @@
+import { HttpInterceptorFn } from '@angular/common/http';
+import { RUNTIME_CONFIG } from '../config/runtime-config';
+import { MSQParse } from '../../parse';
+
+export const parseSessionInterceptor: HttpInterceptorFn = (request, next) => {
+  const protectedPaths = [RUNTIME_CONFIG.domesticVocPath, RUNTIME_CONFIG.saasPath]
+    .map((path) => `/${path.replace(/^\/+|\/+$/g, '')}`);
+  const isProtectedApi = protectedPaths.some((path) => request.url.includes(path));
+  if (RUNTIME_CONFIG.apiAuthMode !== 'parse' || !isProtectedApi) return next(request);
+  const sessionToken = MSQParse.User.current()?.getSessionToken();
+  if (!sessionToken) return next(request);
+  return next(request.clone({ setHeaders: { 'X-Parse-Session-Token': sessionToken } }));
+};

+ 1 - 0
src/app/core/services/index.ts

@@ -1,6 +1,7 @@
 // Core API Services
 export { HttpApiService } from './http-api.service';
 export { API_CONFIG } from './api.config';
+export { SaasPlatformService } from './saas-platform.service';
 
 // Domain API Services
 export { ProductApiService } from './product-api.service';

+ 105 - 0
src/app/core/services/saas-platform.service.ts

@@ -0,0 +1,105 @@
+import { HttpClient, HttpParams } from '@angular/common/http';
+import { Injectable, inject } from '@angular/core';
+import { Observable } from 'rxjs';
+import { RUNTIME_CONFIG } from '../config/runtime-config';
+
+export type WorkspaceRole = 'owner' | 'admin' | 'analyst' | 'viewer';
+
+export interface SaasWorkspace {
+  id: string;
+  name: string;
+  caseName: string;
+  status: 'active' | 'disabled';
+  role: WorkspaceRole;
+}
+
+export interface SaasContext {
+  principal: {
+    userId: string;
+    email: string;
+    displayName: string;
+    authMode: 'disabled' | 'parse';
+  };
+  workspaces: SaasWorkspace[];
+  capabilities: Record<string, boolean>;
+}
+
+export interface CursorPage<T> {
+  items: T[];
+  nextCursor: string | null;
+}
+
+export interface SaasActionItem {
+  id: string;
+  workspaceId: string;
+  actionType: 'general' | 'experience' | 'product' | 'strategy' | 'data_quality';
+  title: string;
+  description: string;
+  priority: 'critical' | 'high' | 'medium' | 'low';
+  status: 'open' | 'planned' | 'in_progress' | 'blocked' | 'completed' | 'cancelled';
+  productKey: string | null;
+  assigneeUserId: string | null;
+  dueAt: string | null;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface SaasAlertItem {
+  id: string;
+  workspaceId: string;
+  alertType: 'voc_risk' | 'sentiment_spike' | 'data_quality' | 'sync_failure' | 'quota';
+  severity: 'critical' | 'high' | 'medium' | 'low';
+  status: 'open' | 'acknowledged' | 'resolved' | 'dismissed';
+  productKey: string | null;
+  title: string;
+  summary: string;
+  detectedAt: string;
+}
+
+@Injectable({ providedIn: 'root' })
+export class SaasPlatformService {
+  private readonly http = inject(HttpClient);
+  private readonly baseUrl = `${RUNTIME_CONFIG.apiBaseUrl.replace(/\/+$/, '')}/${RUNTIME_CONFIG.saasPath.replace(/^\/+|\/+$/g, '')}`;
+
+  context(): Observable<SaasContext> {
+    return this.http.get<SaasContext>(`${this.baseUrl}/context`);
+  }
+
+  actions(workspaceId: string, status = '', cursor = '', limit = 25): Observable<CursorPage<SaasActionItem>> {
+    return this.http.get<CursorPage<SaasActionItem>>(`${this.workspaceUrl(workspaceId)}/actions`, {
+      params: this.pageParams(status, cursor, limit),
+    });
+  }
+
+  createAction(
+    workspaceId: string,
+    action: Pick<SaasActionItem, 'title'> & Partial<Omit<SaasActionItem, 'id' | 'workspaceId' | 'title' | 'createdAt' | 'updatedAt'>>,
+  ): Observable<{ action: SaasActionItem }> {
+    return this.http.post<{ action: SaasActionItem }>(`${this.workspaceUrl(workspaceId)}/actions`, action);
+  }
+
+  updateAction(
+    workspaceId: string,
+    actionId: string,
+    patch: Partial<Pick<SaasActionItem, 'title' | 'description' | 'priority' | 'status' | 'assigneeUserId' | 'dueAt'>>,
+  ): Observable<{ action: SaasActionItem }> {
+    return this.http.patch<{ action: SaasActionItem }>(`${this.workspaceUrl(workspaceId)}/actions/${encodeURIComponent(actionId)}`, patch);
+  }
+
+  alerts(workspaceId: string, status = '', cursor = '', limit = 25): Observable<CursorPage<SaasAlertItem>> {
+    return this.http.get<CursorPage<SaasAlertItem>>(`${this.workspaceUrl(workspaceId)}/alerts`, {
+      params: this.pageParams(status, cursor, limit),
+    });
+  }
+
+  private workspaceUrl(workspaceId: string): string {
+    return `${this.baseUrl}/workspaces/${encodeURIComponent(workspaceId)}`;
+  }
+
+  private pageParams(status: string, cursor: string, limit: number): HttpParams {
+    let params = new HttpParams().set('limit', limit);
+    if (status) params = params.set('status', status);
+    if (cursor) params = params.set('cursor', cursor);
+    return params;
+  }
+}

+ 30 - 60
src/modules/login/login.component.ts

@@ -1,106 +1,76 @@
-import { Component } from '@angular/core';
 import { CommonModule } from '@angular/common';
-import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
-import { Router, ActivatedRoute } from '@angular/router';
+import { Component } from '@angular/core';
+import { ActivatedRoute, Router } from '@angular/router';
+import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
+import { firstValueFrom } from 'rxjs';
 import { MSQParse } from '../../app/parse';
-import JSEncrypt from 'jsencrypt';
+import { SaasPlatformService } from '../../app/core/services/saas-platform.service';
+import { AsinSkuMappingService } from '../shared/services/asin-sku-mapping.service';
 import { PermissionService } from '../shared/services/permission.service';
 import { ProductDimensionService } from '../shared/services/product-dimension.service';
-import { AsinSkuMappingService } from '../shared/services/asin-sku-mapping.service';
 import { SharedDataCacheService } from '../shared/services/shared-data-cache.service';
 
-const RSA_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
-MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6NxiJAxajI0ZsQ7dC5zP
-x4UiCjTHQVjl1AagC2PNdatEQ1gKIgF/+C+QSp7SbpqKTISACWIyLGUxc4fH9/2n
-63o3lIlew8l2A+WSkw/2cissXKxYBHSzqsd86L8blhMZ0F6wZ51Da7jzAXkL0IC9
-wp4OYatIC66nRKrc+qgHyJrSoadis8G4/i80D5IgARFP+ES0oQeZu6y4LoC6xWCh
-BUSJM/wsBbW0IfHJSbsM6FbcbRQvRN3Nhek9756AcwqquXVynz5upWmSTt0pAQQv
-ApmE4vg8kJ2g4hjiLz+P+o2c876c9o6vppbZEXF5x8wjBHdHp3A3QkI/AbHx4GyA
-8QIDAQAB
------END PUBLIC KEY-----`;
-
 @Component({
   selector: 'app-login',
   standalone: true,
   imports: [CommonModule, ReactiveFormsModule],
   templateUrl: './login.component.html',
-  styleUrls: ['./login.component.scss']
+  styleUrls: ['./login.component.scss'],
 })
 export class LoginComponent {
   loginForm: FormGroup;
   isLoading = false;
   errorMessage = '';
-  returnUrl: string = '/voc-insight/products';
+  returnUrl = '/voc-insight/products';
 
   constructor(
-    private fb: FormBuilder,
-    private router: Router,
-    private route: ActivatedRoute,
-    private permissionService: PermissionService,
-    private productDimensionService: ProductDimensionService,
-    private asinSkuMappingService: AsinSkuMappingService,
-    private sharedDataCache: SharedDataCacheService
+    private readonly fb: FormBuilder,
+    private readonly router: Router,
+    private readonly route: ActivatedRoute,
+    private readonly platform: SaasPlatformService,
+    private readonly permissionService: PermissionService,
+    private readonly productDimensionService: ProductDimensionService,
+    private readonly asinSkuMappingService: AsinSkuMappingService,
+    private readonly sharedDataCache: SharedDataCacheService,
   ) {
     this.loginForm = this.fb.group({
       username: ['', [Validators.required, Validators.minLength(2)]],
-      password: ['', [Validators.required, Validators.minLength(6)]]
+      password: ['', [Validators.required, Validators.minLength(6)]],
     });
-
-    this.returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/voc-insight/products';
+    this.returnUrl = this.route.snapshot.queryParams['returnUrl'] || this.returnUrl;
   }
 
   async onSubmit(): Promise<void> {
-    await this.onLogin();
-  }
-
-  private async onLogin(): Promise<void> {
     if (!this.loginForm.valid) {
       this.loginForm.markAllAsTouched();
       return;
     }
+
     this.isLoading = true;
     this.errorMessage = '';
     const { username, password } = this.loginForm.value;
     try {
-      const encryptedUsername = this.encryptRSA(username);
-      const encryptedPassword = this.encryptRSA(password);
-
-      const response = await MSQParse.Cloud.run('user_login', {
-        username: encryptedUsername,
-        password: encryptedPassword
-      });
-
-      if (response.success && response.sessionToken) {
-        this.clearPermissionCaches();
-        await MSQParse.User.become(response.sessionToken);
-        this.clearPermissionCaches();
-        this.router.navigateByUrl(this.returnUrl);
-      } else {
-        this.errorMessage = response?.message || '登录失败,请检查账号密码';
+      await MSQParse.User.logIn(username, password);
+      const context = await firstValueFrom(this.platform.context());
+      if (!context.workspaces.length) {
+        await MSQParse.User.logOut();
+        throw new Error('当前账号尚未加入 VOC 工作区');
       }
-    } catch (error: any) {
-      console.error('登录请求失败:', error.message);
-      this.errorMessage = error?.message || '登录请求失败,请重试';
+      this.clearPermissionCaches();
+      await this.router.navigateByUrl(this.returnUrl);
+    } catch (error: unknown) {
+      const message = error instanceof Error ? error.message : '';
+      console.error('Login request failed:', message);
+      this.errorMessage = message || '登录失败,请检查账号和密码后重试';
     } finally {
       this.isLoading = false;
     }
   }
 
-
   private clearPermissionCaches(): void {
     this.permissionService.clearCache();
     this.productDimensionService.clearCache();
     this.asinSkuMappingService.clearCache();
     this.sharedDataCache.invalidateAll();
   }
-
-  encryptRSA(text: string): string {
-    const encryptor = new JSEncrypt();
-    encryptor.setPublicKey(RSA_PUBLIC_KEY);
-    const encrypted = encryptor.encrypt(text);
-    if (!encrypted) {
-      throw new Error('RSA 加密失败,请检查公钥格式');
-    }
-    return encrypted;
-  }
 }

+ 2 - 0
src/runtime-config.js

@@ -1,8 +1,10 @@
 (function (window) {
   window.__SAAS_VOC_CONFIG__ = Object.assign({
     apiBaseUrl: '',
+    apiAuthMode: 'disabled',
     domesticDatasetMode: 'api',
     domesticVocPath: '/api/domestic-voc',
+    saasPath: '/api/saas',
     domesticWorkspaceId: 'demashi',
   }, window.__SAAS_VOC_CONFIG__ || {});
 })(window);