diff --git a/src/app/elements/elements.component.ts b/src/app/elements/elements.component.ts
index 4ddf5ea9b..9811b5866 100644
--- a/src/app/elements/elements.component.ts
+++ b/src/app/elements/elements.component.ts
@@ -25,6 +25,7 @@ import { ElementReplayAsciicastComponent } from '@app/elements/replay/asciicast/
import { ElementConnectorNecComponent } from '@app/elements/content/content-window/nec/nec.component';
import { ElementFaceMonitorComponent } from '@app/elements/connect/face-monitor/face-monitor.component';
import { ElementACLDialogComponent } from '@app/services/connect-token/acl-dialog/acl-dialog.component';
+import { ElementBatchACLDialogComponent } from '@app/services/connect-token/batch-acl-dialog/batch-acl-dialog.component';
import { ElementTreeFilterComponent } from '@app/elements/asset-tree/tree-filter/tree-filter.component';
import { ElementOrganizationComponent } from '@app/elements/left-bar/organization/organization.component';
import { ElementConnectorGuideComponent } from '@app/elements/content/content-window/guide/guide.component';
@@ -76,6 +77,7 @@ export const ElementComponents = [
DynamicFormComponent,
ElementsPartsComponent,
ElementACLDialogComponent,
+ ElementBatchACLDialogComponent,
ElementDialogAlertComponent,
ElementLoginExpiredDialogComponent,
ElementTermComponent
diff --git a/src/app/services/connect-token/batch-acl-dialog/batch-acl-dialog.component.html b/src/app/services/connect-token/batch-acl-dialog/batch-acl-dialog.component.html
new file mode 100644
index 000000000..f39ed6926
--- /dev/null
+++ b/src/app/services/connect-token/batch-acl-dialog/batch-acl-dialog.component.html
@@ -0,0 +1,50 @@
+
+
{{ titleKey | translate }}
+
+
+
+
+
+
+
+ {{ 'Assignees' | translate }}: {{ item.assignees }}
+
+
+
{{ item.detail }}
+
+
+
+
+ {{ 'Assignees' | translate }}: {{ singleItem.assignees }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/services/connect-token/batch-acl-dialog/batch-acl-dialog.component.scss b/src/app/services/connect-token/batch-acl-dialog/batch-acl-dialog.component.scss
new file mode 100644
index 000000000..46e6d871c
--- /dev/null
+++ b/src/app/services/connect-token/batch-acl-dialog/batch-acl-dialog.component.scss
@@ -0,0 +1,49 @@
+.batch-acl-dialog {
+ nz-alert {
+ display: block;
+ margin-bottom: 16px;
+ }
+}
+
+.review-list {
+ max-height: 420px;
+ overflow-y: auto;
+ border: 1px solid #f0f0f0;
+ border-radius: 4px;
+}
+
+.face-verify-frame {
+ width: 100%;
+ height: 480px;
+ margin-top: 16px;
+ border: 0;
+}
+
+.review-item {
+ padding: 12px 16px;
+ border-bottom: 1px solid #f0f0f0;
+
+ &:last-child {
+ border-bottom: 0;
+ }
+}
+
+.review-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.review-detail {
+ margin-top: 8px;
+ color: #666;
+
+ &.error {
+ color: #ff4d4f;
+ }
+}
+
+.single-detail {
+ margin-top: 16px;
+}
diff --git a/src/app/services/connect-token/batch-acl-dialog/batch-acl-dialog.component.ts b/src/app/services/connect-token/batch-acl-dialog/batch-acl-dialog.component.ts
new file mode 100644
index 000000000..c0988be6b
--- /dev/null
+++ b/src/app/services/connect-token/batch-acl-dialog/batch-acl-dialog.component.ts
@@ -0,0 +1,401 @@
+import { Component, Inject, OnDestroy, OnInit } from '@angular/core';
+import { HttpErrorResponse } from '@angular/common/http';
+import { NZ_MODAL_DATA, NzModalRef } from 'ng-zorro-antd/modal';
+import { NzNotificationService } from 'ng-zorro-antd/notification';
+import { writeText } from 'clipboard-polyfill';
+import { Asset, ConnectData, ConnectionToken } from '@app/model';
+import { HttpService } from '@app/services/http';
+import { I18nService } from '@app/services/i18n';
+import { Subscription } from 'rxjs';
+import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
+import { FaceService } from '@app/services/face';
+import { ActivatedRoute } from '@angular/router';
+
+type ReviewStatus =
+ | 'review'
+ | 'submitting'
+ | 'pending'
+ | 'verifying'
+ | 'approved'
+ | 'rejected'
+ | 'closed'
+ | 'failed';
+
+interface BatchReviewItem {
+ asset: Asset;
+ connectInfo: ConnectData;
+ error: HttpErrorResponse;
+ status: ReviewStatus;
+ resolve?: (token: ConnectionToken) => void;
+ resolved?: boolean;
+ token?: ConnectionToken;
+ detail?: string;
+ assignees?: string;
+}
+
+@Component({
+ standalone: false,
+ selector: 'elements-batch-acl-dialog',
+ templateUrl: 'batch-acl-dialog.component.html',
+ styleUrls: ['batch-acl-dialog.component.scss']
+})
+export class ElementBatchACLDialogComponent implements OnInit, OnDestroy {
+ items: BatchReviewItem[] = [];
+ code: string;
+ submitted = false;
+ faceVerifyUrl: SafeResourceUrl;
+ private timers = new Map();
+ private subscriptions = new Subscription();
+ private faceTimer: number;
+
+ constructor(
+ @Inject(NZ_MODAL_DATA) public data: any,
+ private _http: HttpService,
+ private _i18n: I18nService,
+ private _toastr: NzNotificationService,
+ private sanitizer: DomSanitizer,
+ private faceService: FaceService,
+ private _route: ActivatedRoute,
+ public dialogRef: NzModalRef
+ ) {}
+
+ ngOnInit() {
+ this.code = this.data.code;
+ this.items = this.data.items.map(item => this.buildItem(item));
+ }
+
+ ngOnDestroy() {
+ this.clearTimers();
+ clearInterval(this.faceTimer);
+ this.subscriptions.unsubscribe();
+ this.items.forEach(item => this.resolveItem(item, null));
+ }
+
+ get isReview(): boolean {
+ return this.code === 'acl_review';
+ }
+
+ get isFaceVerify(): boolean {
+ return ['acl_face_verify', 'acl_face_online'].includes(this.code);
+ }
+
+ get isActionable(): boolean {
+ return this.isReview || this.isFaceVerify;
+ }
+
+ get isBatch(): boolean {
+ return this.items.length > 1;
+ }
+
+ get singleItem(): BatchReviewItem {
+ return this.items[0];
+ }
+
+ get titleKey(): string {
+ if (this.isReview) {
+ return 'Login review';
+ }
+ if (this.isFaceVerify) {
+ return 'Face Verify';
+ }
+ return 'Login reminder';
+ }
+
+ get messageKey(): string {
+ if (!this.isBatch) {
+ if (this.singleItem && this.singleItem.detail) {
+ return this.singleItem.detail;
+ }
+ if (this.isReview) {
+ const reviewMessages = {
+ pending: 'Ticket review pending for login asset',
+ rejected: 'Ticket review rejected for login asset',
+ closed: 'Ticket review closed for login asset'
+ };
+ return (
+ reviewMessages[this.singleItem && this.singleItem.status] || 'Need review for login asset'
+ );
+ }
+ if (this.code === 'acl_face_online') {
+ return 'Face online required';
+ }
+ if (this.code === 'acl_face_verify') {
+ return this.singleItem && this.singleItem.status === 'verifying'
+ ? 'Please complete the face verification'
+ : 'Face verify required';
+ }
+ const messages = {
+ acl_reject: 'ACL reject login asset',
+ perm_account_invalid: 'Account not found'
+ };
+ return messages[this.code] || this.code;
+ }
+ if (this.isReview) {
+ return this.submitted ? 'Batch review submitted message' : 'Batch review message';
+ }
+ if (this.code === 'acl_face_online') {
+ return 'Batch face online message';
+ }
+ if (this.code === 'acl_face_verify') {
+ return 'Batch face verification message';
+ }
+ return 'Batch same type error message';
+ }
+
+ get actionTextKey(): string {
+ if (!this.isBatch) {
+ return 'Confirm';
+ }
+ return this.isReview ? 'Submit all reviews' : 'Start verification';
+ }
+
+ get closeTextKey(): string {
+ if (!this.isActionable || (this.submitted && !this.hasPending)) {
+ return 'Close';
+ }
+ return 'Cancel';
+ }
+
+ get hasPending(): boolean {
+ return this.items.some(item => ['submitting', 'pending', 'verifying'].includes(item.status));
+ }
+
+ get isSubmitting(): boolean {
+ return this.items.some(item => ['submitting', 'verifying'].includes(item.status));
+ }
+
+ get statusType(): { [key in ReviewStatus]: string } {
+ return {
+ review: 'warning',
+ submitting: 'processing',
+ pending: 'processing',
+ verifying: 'processing',
+ approved: 'success',
+ rejected: 'error',
+ closed: 'default',
+ failed: 'error'
+ };
+ }
+
+ addItem(data: any) {
+ const item = this.buildItem(data);
+ this.items.push(item);
+ if (!this.submitted) {
+ return;
+ }
+ if (this.isReview) {
+ this.createTicket(item);
+ } else if (this.isFaceVerify) {
+ this.verifyNextFace();
+ }
+ }
+
+ onConfirm() {
+ this.submitted = true;
+ if (this.isReview) {
+ this.items.forEach(item => this.createTicket(item));
+ return;
+ }
+ this.verifyNextFace();
+ }
+
+ onClose() {
+ this.items
+ .filter(item => item.status === 'pending' && item.token)
+ .forEach(item => this.closeTicket(item));
+ this.clearTimers();
+ clearInterval(this.faceTimer);
+ this.items.forEach(item => this.resolveItem(item, null));
+ this.dialogRef.close();
+ }
+
+ async onCopyLink(item: BatchReviewItem) {
+ await writeText(item.token.from_ticket_info.ticket_detail_page_url);
+ const message = await this._i18n.t('Copied');
+ this._toastr.success(message, '', { nzClass: 'custom-success-notification' });
+ }
+
+ private createTicket(item: BatchReviewItem) {
+ item.status = 'submitting';
+ this.subscriptions.add(
+ this._http.createConnectToken(item.asset, item.connectInfo, true).subscribe(
+ token => {
+ item.token = token;
+ if (!token.from_ticket) {
+ this.approveItem(item, token);
+ return;
+ }
+ item.status = 'pending';
+ item.assignees = token.from_ticket_info.assignees.join(', ') || '-';
+ this.checkTicket(item);
+ },
+ error => {
+ item.status = 'failed';
+ item.detail = this.getErrorDetail(error);
+ }
+ )
+ );
+ }
+
+ private verifyNextFace() {
+ if (this.items.some(item => ['submitting', 'verifying'].includes(item.status))) {
+ return;
+ }
+ this.faceVerifyUrl = null;
+ const item = this.items.find(candidate => candidate.status === 'review');
+ if (!item) {
+ return;
+ }
+ item.status = 'submitting';
+ let faceMonitorToken: string;
+ if (this.code === 'acl_face_online') {
+ faceMonitorToken =
+ this._route.snapshot.queryParamMap.get('face_monitor_token') || this.faceService.getToken();
+ }
+ this.subscriptions.add(
+ this._http
+ .createConnectToken(item.asset, item.connectInfo, false, true, faceMonitorToken)
+ .subscribe(
+ token => {
+ item.token = token;
+ if (!token.face_token) {
+ this.approveItem(item, token);
+ this.verifyNextFace();
+ return;
+ }
+ item.status = 'verifying';
+ this.faceVerifyUrl = this.sanitizer.bypassSecurityTrustResourceUrl(
+ '/facelive/capture?token=' + token.face_token
+ );
+ this.checkFaceState(item, token);
+ },
+ error => {
+ item.status = 'failed';
+ item.detail = this.getErrorDetail(error);
+ this.verifyNextFace();
+ }
+ )
+ );
+ }
+
+ private checkFaceState(item: BatchReviewItem, token: ConnectionToken) {
+ clearInterval(this.faceTimer);
+ this.faceTimer = window.setInterval(() => {
+ this.subscriptions.add(
+ this._http.getFaceVerifyState(token.face_token).subscribe(
+ async state => {
+ if (!state.is_finished) {
+ return;
+ }
+ clearInterval(this.faceTimer);
+ this.faceVerifyUrl = null;
+ if (state.success) {
+ this.approveItem(item, token);
+ const message = await this._i18n.t('Face verify success');
+ this._toastr.success(message, '', { nzClass: 'custom-success-notification' });
+ if (this.code === 'acl_face_online') {
+ this.faceService.openFaceMonitor();
+ }
+ } else {
+ item.status = 'failed';
+ item.detail = state.error_message;
+ }
+ this.verifyNextFace();
+ },
+ error => {
+ clearInterval(this.faceTimer);
+ this.faceVerifyUrl = null;
+ item.status = 'failed';
+ item.detail = this.getErrorDetail(error);
+ this.verifyNextFace();
+ }
+ )
+ );
+ }, 1000);
+ }
+
+ private checkTicket(item: BatchReviewItem) {
+ const checkApi = item.token.from_ticket_info.check_ticket_api;
+ const checkMethod = checkApi.method.toLowerCase();
+ const timer = window.setInterval(() => {
+ this.subscriptions.add(
+ this._http[checkMethod](checkApi.url).subscribe(
+ ticket => {
+ if (ticket.status.value !== 'closed') {
+ return;
+ }
+ this.clearTimer(item);
+ const state = ticket.state.value;
+ if (state === 'approved') {
+ this.approveItem(item, item.token);
+ } else if (state === 'rejected') {
+ item.status = 'rejected';
+ } else {
+ item.status = 'closed';
+ }
+ },
+ error => {
+ this.clearTimer(item);
+ item.status = 'failed';
+ item.detail = this.getErrorDetail(error);
+ }
+ )
+ );
+ }, 3000);
+ this.timers.set(item, timer);
+ }
+
+ private approveItem(item: BatchReviewItem, token: ConnectionToken) {
+ item.status = 'approved';
+ this.resolveItem(item, token);
+ if (!this.isBatch) {
+ window.setTimeout(() => this.dialogRef.close());
+ }
+ }
+
+ private resolveItem(item: BatchReviewItem, token: ConnectionToken) {
+ if (item.resolved) {
+ return;
+ }
+ item.resolved = true;
+ if (item.resolve) {
+ item.resolve(token);
+ }
+ }
+
+ private buildItem(data: any): BatchReviewItem {
+ return {
+ ...data,
+ status: this.isActionable ? 'review' : 'failed',
+ detail: this.isActionable ? null : this.getErrorDetail(data.error),
+ resolved: false
+ };
+ }
+
+ private closeTicket(item: BatchReviewItem) {
+ const closeApi = item.token.from_ticket_info.close_ticket_api;
+ const closeMethod = closeApi.method.toLowerCase();
+ this._http[closeMethod](closeApi.url).subscribe();
+ }
+
+ private clearTimer(item: BatchReviewItem) {
+ const timer = this.timers.get(item);
+ if (timer) {
+ clearInterval(timer);
+ this.timers.delete(item);
+ }
+ }
+
+ private clearTimers() {
+ this.timers.forEach(timer => clearInterval(timer));
+ this.timers.clear();
+ }
+
+ private getErrorDetail(error: HttpErrorResponse): string {
+ const body = error && error.error;
+ if (body && typeof body === 'object') {
+ return body.detail || body.code || JSON.stringify(body);
+ }
+ return (body || error.message || '').toString();
+ }
+}
diff --git a/src/app/services/connect-token/index.ts b/src/app/services/connect-token/index.ts
index e6c7068f0..f2498f70d 100644
--- a/src/app/services/connect-token/index.ts
+++ b/src/app/services/connect-token/index.ts
@@ -1,17 +1,50 @@
import {Injectable} from '@angular/core';
import {Asset, ConnectData, ConnectionToken} from '@app/model';
import {HttpService} from '@app/services/http';
-import {NzModalService} from 'ng-zorro-antd/modal';
+import {NzModalRef, NzModalService} from 'ng-zorro-antd/modal';
import {ElementACLDialogComponent} from './acl-dialog/acl-dialog.component';
+import {ElementBatchACLDialogComponent} from './batch-acl-dialog/batch-acl-dialog.component';
@Injectable()
export class ConnectTokenService {
+ private activeDialogs = new Map>();
+
constructor(private _http: HttpService,
private _dialog: NzModalService
) {
}
handleError(data, resolve) {
+ if (data.tokenAction === 'create' && data.asset) {
+ const code = data.code || 'unknown';
+ const activeDialog = this.activeDialogs.get(code);
+ if (activeDialog) {
+ const component = activeDialog.getContentComponent();
+ if (component) {
+ component.addItem({...data, resolve});
+ return;
+ }
+ this.activeDialogs.delete(code);
+ }
+ const dialogRef = this._dialog.create({
+ nzContent: ElementBatchACLDialogComponent,
+ nzWidth: '680px',
+ nzCentered: true,
+ nzClosable: false,
+ nzMaskClosable: false,
+ nzData: {
+ code,
+ items: [{...data, resolve}]
+ }
+ });
+ this.activeDialogs.set(code, dialogRef);
+ dialogRef.afterClose.subscribe(() => {
+ if (this.activeDialogs.get(code) === dialogRef) {
+ this.activeDialogs.delete(code);
+ }
+ });
+ return;
+ }
const dialogRef = this._dialog.create({
nzContent: ElementACLDialogComponent,
nzWidth: '450px',
diff --git a/src/assets/i18n/zh-hans.json b/src/assets/i18n/zh-hans.json
index bfd870e45..2c63c0851 100644
--- a/src/assets/i18n/zh-hans.json
+++ b/src/assets/i18n/zh-hans.json
@@ -1,3 +1,2 @@
{
}
-