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 }}
+ + + +
+
+
+ {{ item.asset.name }} + {{ + 'Batch review status ' + item.status | 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 @@ { } -