1157 lines
40 KiB
TypeScript
1157 lines
40 KiB
TypeScript
import { Component, OnInit, OnDestroy, ChangeDetectorRef, ViewChild } from '@angular/core';
|
||
import { ActivatedRoute } from '@angular/router';
|
||
import { SelectItem } from 'primeng/api';
|
||
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||
import { HttpErrorResponse } from '@angular/common/http';
|
||
import { of } from 'rxjs';
|
||
import { catchError, finalize, take } from 'rxjs/operators';
|
||
|
||
import { User, PartnerSystemUser, SatlocIntegration } from '../models/user.model';
|
||
import { PartnerService } from '@app/partners/services/partner.service';
|
||
import { Partner } from '@app/partners/models/partner.model';
|
||
import * as userActions from '../actions/account.actions';
|
||
import * as fromUsers from '../reducers';
|
||
|
||
import { RoleIds, Roles, globals, OperationalStatus, Labels, KnownPartnerCodes } from '@app/shared/global';
|
||
import { BaseComp } from '@app/shared/base/base.component';
|
||
import { ConstraintMessageComponent } from '@app/shared/constraint-message/constraint-message.component';
|
||
import { AccountEditorComponent } from '@app/shared/account-editor/account-editor.component';
|
||
import { PartnerUtilsService } from '@app/shared/services/partner-utils.service';
|
||
import { handlePartnerErr } from '@app/profile/common';
|
||
|
||
// Partner Integration Constants
|
||
export const VENDOR_SYSTEM_FIELD = 'vendorSystem';
|
||
export const SATLOC_VENDOR = KnownPartnerCodes.SATLOC;
|
||
|
||
@Component({
|
||
selector: 'agm-account-edit',
|
||
templateUrl: './account-edit.component.html',
|
||
styleUrls: ['./account-edit.component.css']
|
||
})
|
||
export class AccountEditComponent extends BaseComp implements OnInit, OnDestroy {
|
||
|
||
readonly globals = globals;
|
||
readonly Labels = Labels;
|
||
readonly VENDOR_SYSTEM_FIELD = VENDOR_SYSTEM_FIELD;
|
||
readonly SATLOC_VENDOR = SATLOC_VENDOR;
|
||
|
||
form: FormGroup;
|
||
selectedItem: User;
|
||
kinds: SelectItem[];
|
||
|
||
// ============================================================================
|
||
// VIEW CHILDREN
|
||
// ============================================================================
|
||
|
||
@ViewChild('accountTypeConstraint') accountTypeConstraint: ConstraintMessageComponent;
|
||
@ViewChild('vendorSystemConstraint') vendorSystemConstraint: ConstraintMessageComponent;
|
||
@ViewChild('accountEditor') accountEditor: AccountEditorComponent;
|
||
|
||
// ============================================================================
|
||
// PARTNER INTEGRATION PROPERTIES
|
||
// ============================================================================
|
||
|
||
// Partner system integration state
|
||
showVendorOptions: boolean = false;
|
||
selectedVendor: string = '';
|
||
|
||
// Partner system user data for dual-model approach
|
||
partnerSystemUser: PartnerSystemUser | null = null;
|
||
existingPartnerSystemUsers: PartnerSystemUser[] = [];
|
||
|
||
// Dynamic partner loading from API
|
||
partners: Partner[] = [];
|
||
vendorsLoading: boolean = false;
|
||
vendorOptions: SelectItem[] = [
|
||
{ label: $localize`:Select vendor dropdown option@@selectVendor:Select Partner System`, value: '' }
|
||
];
|
||
availableVendorOptions: SelectItem[] = [];
|
||
|
||
// Satloc integration properties
|
||
satlocLoading: boolean = false;
|
||
satlocError: string | null = null;
|
||
satlocIntegration: SatlocIntegration = {
|
||
enabled: false,
|
||
status: OperationalStatus.ERROR,
|
||
account_info: null,
|
||
credentials_stored: false,
|
||
last_error: null
|
||
};
|
||
|
||
// Save before test dialog state
|
||
showSaveBeforeTestDialog: boolean = false;
|
||
pendingTestAfterSave: boolean = false;
|
||
|
||
// Credential change tracking
|
||
credentialsChanged: boolean = false;
|
||
originalUsername: string = '';
|
||
originalPassword: string = '';
|
||
|
||
// Vendor change tracking for soft-lock confirmation
|
||
originalVendorSystem: string = '';
|
||
|
||
// Account Does Not Exist flow tracking
|
||
isAccountDoesNotExistFlow: boolean = false;
|
||
|
||
// Return navigation properties for vehicle-edit flow
|
||
returnTo: string | null = null;
|
||
vehicleId: string | null = null;
|
||
partnerId: string | null = null;
|
||
partnerCode: string | null = null;
|
||
customerId: string | null = null;
|
||
|
||
// Post-save validation state
|
||
postSaveValidationInProgress: boolean = false;
|
||
postSaveValidationSuccess: boolean = false;
|
||
postSaveValidationError: boolean = false;
|
||
postSaveErrorMessage: string | null = null;
|
||
|
||
private _account: User | PartnerSystemUser;
|
||
private _isNew: boolean;
|
||
|
||
get account(): User | PartnerSystemUser {
|
||
return this._account;
|
||
}
|
||
|
||
set account(account: User | PartnerSystemUser) {
|
||
this._account = account;
|
||
this.selectedItem = Object.assign({}, account);
|
||
|
||
const isPartnerSystemUser = this.isPartnerSystemUser(account);
|
||
const isPartnerAccount = account.kind === RoleIds.PARTNER;
|
||
const accountType = account.kind;
|
||
|
||
this.form.patchValue({
|
||
profile: this.selectedItem,
|
||
account: { active: this.selectedItem.active, username: this.selectedItem.username, password: this.selectedItem.password },
|
||
kind: accountType,
|
||
[VENDOR_SYSTEM_FIELD]: this.getVendorFromAccount(account)
|
||
});
|
||
|
||
if (this.isAccountDoesNotExistFlow && this.isNew) {
|
||
const partnerLabel = this.partnerCode || (this.partnerId ? this.getPartnerLabelFromId(this.partnerId) : null);
|
||
|
||
if (partnerLabel) {
|
||
const matchingVendor = this.vendorOptions.find(vendor =>
|
||
vendor.label.toLowerCase() === partnerLabel.toLowerCase()
|
||
);
|
||
|
||
if (matchingVendor) {
|
||
this.selectedVendor = matchingVendor.label;
|
||
this.form.patchValue({
|
||
[VENDOR_SYSTEM_FIELD]: matchingVendor.value
|
||
});
|
||
} else {
|
||
this.selectedVendor = partnerLabel;
|
||
this.form.patchValue({
|
||
[VENDOR_SYSTEM_FIELD]: partnerLabel
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
this.showVendorOptions = isPartnerAccount || isPartnerSystemUser || this.isAccountDoesNotExistFlow;
|
||
this.selectedVendor = this.getVendorFromAccount(account);
|
||
|
||
if (this.isPartnerSystemUser(account)) {
|
||
const partnerSystemUser = account as PartnerSystemUser;
|
||
// Use getVendorFromAccount which now prefers partner ObjectId → partnerCode
|
||
const vendorType = this.getVendorFromAccount(account) || SATLOC_VENDOR;
|
||
this.selectedVendor = vendorType;
|
||
|
||
if (this.partnerUtils.isSatlocPartner(vendorType)) {
|
||
this.satlocIntegration = {
|
||
enabled: true,
|
||
status: partnerSystemUser.syncStatus === OperationalStatus.ACTIVE ? OperationalStatus.ACTIVE : OperationalStatus.ERROR,
|
||
account_info: null,
|
||
credentials_stored: true,
|
||
last_error: partnerSystemUser.syncStatus === OperationalStatus.ERROR ? $localize`:Connection error message@@connectionError:Connection error` : null
|
||
};
|
||
}
|
||
}
|
||
|
||
if (isPartnerAccount) {
|
||
const vendorType = this.getVendorFromAccount(account);
|
||
this.selectedVendor = vendorType;
|
||
|
||
if (this.partnerUtils.isSatlocPartner(vendorType)) {
|
||
this.satlocIntegration = {
|
||
enabled: true,
|
||
status: OperationalStatus.ERROR,
|
||
account_info: null,
|
||
credentials_stored: true,
|
||
last_error: null
|
||
};
|
||
}
|
||
}
|
||
|
||
this.updateAvailableVendorOptions();
|
||
|
||
// Store original vendor for soft-lock confirmation (WI-1)
|
||
if (!this.isNew) {
|
||
this.originalVendorSystem = this.selectedVendor;
|
||
}
|
||
|
||
if (isPartnerAccount || isPartnerSystemUser) {
|
||
this.form.get(this.VENDOR_SYSTEM_FIELD)?.setValidators([Validators.required]);
|
||
this.form.get(this.VENDOR_SYSTEM_FIELD)?.updateValueAndValidity();
|
||
|
||
}
|
||
|
||
// WI-1: Soft lock - Only disable for special flow, enable for existing accounts
|
||
if (!this.isNew || (this.isAccountDoesNotExistFlow && this.isNew)) {
|
||
this.form.get('kind')?.disable();
|
||
// Only disable vendor for special flow, not for regular existing accounts
|
||
if (this.isAccountDoesNotExistFlow) {
|
||
this.form.get(this.VENDOR_SYSTEM_FIELD)?.disable();
|
||
} else {
|
||
this.form.get(this.VENDOR_SYSTEM_FIELD)?.enable();
|
||
}
|
||
} else {
|
||
this.form.get('kind')?.enable();
|
||
this.form.get(this.VENDOR_SYSTEM_FIELD)?.enable();
|
||
}
|
||
}
|
||
|
||
private updateVendorFieldState(): void {
|
||
// WI-1: Soft lock - Enable vendor field for existing accounts with confirmation dialog
|
||
// Previously: disabled for existing accounts to prevent changes
|
||
// Now: enabled with confirmation dialog when changed (see onVendorChange)
|
||
if (this.isAccountDoesNotExistFlow) {
|
||
// Special flow: vendor is pre-configured, keep disabled
|
||
this.form.get(this.VENDOR_SYSTEM_FIELD)?.disable();
|
||
} else {
|
||
// All other cases: enable the field (new accounts OR existing accounts)
|
||
this.form.get(this.VENDOR_SYSTEM_FIELD)?.enable();
|
||
}
|
||
}
|
||
|
||
get isNew(): boolean {
|
||
return this._isNew;
|
||
}
|
||
|
||
get isCurrentAccountPartnerSystemUser(): boolean {
|
||
return this.form?.get('kind')?.value === RoleIds.PARTNER_SYSTEM_USER;
|
||
}
|
||
|
||
// ============================================================================
|
||
// DISABLED STATES FEEDBACK
|
||
// ============================================================================
|
||
|
||
get shouldShowAccountTypeDisabledMessage(): boolean {
|
||
return ((!this.isNew) || (this.isAccountDoesNotExistFlow && this.isNew)) && this.form?.get('kind')?.disabled;
|
||
}
|
||
|
||
get shouldShowVendorSystemDisabledMessage(): boolean {
|
||
// WI-4: Only show disabled message for special flow (accountDoesNotExist)
|
||
// Regular existing accounts now use soft-lock with confirmation dialog
|
||
return this.isAccountDoesNotExistFlow && this.form?.get(this.VENDOR_SYSTEM_FIELD)?.disabled && this.showVendorOptions;
|
||
}
|
||
|
||
/**
|
||
* Get context-aware constraint message for account type field
|
||
*/
|
||
get accountTypeConstraintMessage(): string {
|
||
if (this.isAccountDoesNotExistFlow && this.isNew) {
|
||
return Labels.ACCOUNT_TYPE_FLOW_DISABLED_MESSAGE;
|
||
}
|
||
return Labels.ACCOUNT_TYPE_DISABLED_MESSAGE;
|
||
}
|
||
|
||
/**
|
||
* Get context-aware constraint message for vendor system field
|
||
*/
|
||
get vendorSystemConstraintMessage(): string {
|
||
if (this.isAccountDoesNotExistFlow && this.isNew) {
|
||
return Labels.VENDOR_SYSTEM_FLOW_DISABLED_MESSAGE;
|
||
}
|
||
return Labels.VENDOR_SYSTEM_DISABLED_MESSAGE;
|
||
}
|
||
|
||
/**
|
||
* Get context-aware constraint title for account type field
|
||
*/
|
||
get accountTypeConstraintTitle(): string {
|
||
if (this.isAccountDoesNotExistFlow && this.isNew) {
|
||
return Labels.ACCOUNT_TYPE_FLOW_DISABLED_TITLE;
|
||
}
|
||
return Labels.ACCOUNT_TYPE_DISABLED_TITLE;
|
||
}
|
||
|
||
/**
|
||
* Get context-aware constraint title for vendor system field
|
||
*/
|
||
get vendorSystemConstraintTitle(): string {
|
||
if (this.isAccountDoesNotExistFlow && this.isNew) {
|
||
return Labels.VENDOR_SYSTEM_FLOW_DISABLED_TITLE;
|
||
}
|
||
return Labels.VENDOR_SYSTEM_DISABLED_TITLE;
|
||
}
|
||
|
||
constructor(
|
||
private readonly route: ActivatedRoute,
|
||
private readonly fb: FormBuilder,
|
||
private readonly partnerService: PartnerService,
|
||
public readonly partnerUtils: PartnerUtilsService,
|
||
private readonly cdr: ChangeDetectorRef
|
||
) {
|
||
super();
|
||
|
||
this.form = this.fb.group({
|
||
profile: [],
|
||
account: [],
|
||
kind: [],
|
||
[VENDOR_SYSTEM_FIELD]: ['']
|
||
});
|
||
|
||
this.kinds = [
|
||
{ label: Roles[RoleIds.APP_ADM], value: RoleIds.APP_ADM },
|
||
{ label: Roles[RoleIds.OFFICER], value: RoleIds.OFFICER },
|
||
{ label: Roles[RoleIds.INSPECTOR], value: RoleIds.INSPECTOR }
|
||
];
|
||
|
||
this.availableVendorOptions = [...this.vendorOptions];
|
||
}
|
||
|
||
// Type guard for PartnerSystemUser
|
||
private isPartnerSystemUser(account: User | PartnerSystemUser): account is PartnerSystemUser {
|
||
return account.kind === RoleIds.PARTNER_SYSTEM_USER;
|
||
}
|
||
|
||
/**
|
||
* Look up a loaded partner by ObjectId and return its partnerCode.
|
||
* Returns null if partners are not yet loaded or the partner is not found.
|
||
*/
|
||
private getPartnerCodeFromPartnerId(partnerId: string | { _id: string } | null | undefined): string | null {
|
||
if (!partnerId || !this.partners?.length) return null;
|
||
const id = typeof partnerId === 'string' ? partnerId : (partnerId as any)._id;
|
||
const partner = this.partners.find(p => p._id === id);
|
||
return partner?.partnerCode || null;
|
||
}
|
||
|
||
// Helper to safely get vendor from account
|
||
private getVendorFromAccount(account: User | PartnerSystemUser): string {
|
||
if (this.isPartnerSystemUser(account)) {
|
||
const partnerSystemUser = account as PartnerSystemUser;
|
||
|
||
// Prefer partner ObjectId → partnerCode (authoritative canonical value)
|
||
if (partnerSystemUser.partner) {
|
||
const rawPartnerId = typeof partnerSystemUser.partner === 'string'
|
||
? partnerSystemUser.partner
|
||
: (partnerSystemUser.partner as any)._id;
|
||
const partnerCode = this.getPartnerCodeFromPartnerId(rawPartnerId);
|
||
if (partnerCode) return partnerCode;
|
||
}
|
||
|
||
// If partner is already a populated object with partnerCode
|
||
if (typeof partnerSystemUser.partner === 'object' && (partnerSystemUser.partner as any).partnerCode) {
|
||
return (partnerSystemUser.partner as any).partnerCode;
|
||
}
|
||
|
||
return '';
|
||
} else if (account.kind === RoleIds.PARTNER) {
|
||
return SATLOC_VENDOR;
|
||
} else {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
ngOnInit() {
|
||
this.sub$ = this.route.queryParams.subscribe(params => {
|
||
this.returnTo = params['returnTo'] || null;
|
||
this.vehicleId = params['vehicleId'] || null;
|
||
this.partnerId = params['partner'] || null;
|
||
this.partnerCode = params['partnerCode'] || null;
|
||
this.customerId = params['customerId'] || null;
|
||
this.isAccountDoesNotExistFlow = params['accountDoesNotExist'] === 'true' || params['accountDoesNotExist'] === true;
|
||
|
||
if (this.isAccountDoesNotExistFlow) {
|
||
const hasPartnerSystemUser = this.kinds.some(kind => kind.value === RoleIds.PARTNER_SYSTEM_USER);
|
||
if (!hasPartnerSystemUser) {
|
||
this.kinds.push({
|
||
label: Labels.PARTNER_SYSTEM_LABEL,
|
||
value: RoleIds.PARTNER_SYSTEM_USER
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
this.sub$ = this.route.data.subscribe((data) => {
|
||
const account = data[0] as User || null;
|
||
|
||
if (account) {
|
||
this._isNew = (account._id === '0');
|
||
if (this.isNew) {
|
||
if (!account.kind) {
|
||
account.kind = this.kinds[0].value;
|
||
}
|
||
account.parent = this.authSvc.byPUserId;
|
||
|
||
if (this.isAccountDoesNotExistFlow) {
|
||
account.kind = RoleIds.PARTNER_SYSTEM_USER;
|
||
}
|
||
|
||
// Ensure all partner system users default to active
|
||
if (account.kind === RoleIds.PARTNER_SYSTEM_USER) {
|
||
account.active = true;
|
||
}
|
||
}
|
||
this.account = account;
|
||
|
||
if (!this.isNew && account) {
|
||
this.originalUsername = account.username || '';
|
||
this.originalPassword = account.password || '';
|
||
|
||
// testAuth is triggered once by updateSelectedVendorAfterLoading() after vendor options load
|
||
}
|
||
}
|
||
});
|
||
|
||
this.loadVendorOptions();
|
||
|
||
this.sub$.add(this.appActions.ofTypes([
|
||
userActions.CREATE_SUCCESS,
|
||
userActions.UPDATE_SUCCESS
|
||
])
|
||
.subscribe((action) => {
|
||
const savedAccount = action['payload'];
|
||
|
||
if (this.shouldPerformPostSaveValidation(savedAccount)) {
|
||
this.performPostSaveValidation(savedAccount);
|
||
} else {
|
||
this.handleNormalSaveSuccess(action);
|
||
}
|
||
}));
|
||
|
||
// Form change detection - track credential modifications
|
||
const accountControl = this.form.get('account');
|
||
|
||
if (accountControl) {
|
||
this.sub$.add(
|
||
accountControl.valueChanges.subscribe((accountValue) => {
|
||
if (accountValue && !this.isNew) {
|
||
const currentUsername = accountValue.username || '';
|
||
const currentPassword = accountValue.password || '';
|
||
|
||
this.credentialsChanged =
|
||
(currentUsername !== this.originalUsername) ||
|
||
(currentPassword !== this.originalPassword);
|
||
|
||
if (!this.postSaveValidationError) {
|
||
this.clearPostSaveValidation();
|
||
}
|
||
}
|
||
})
|
||
);
|
||
}
|
||
}
|
||
|
||
ngOnDestroy() {
|
||
super.ngOnDestroy();
|
||
}
|
||
|
||
onAccountTypeChange(selectedType: any): void {
|
||
if (!this.isNew && this.account &&
|
||
(this.account.kind === RoleIds.PARTNER || this.account.kind === RoleIds.PARTNER_SYSTEM_USER)) {
|
||
return;
|
||
}
|
||
|
||
this.showVendorOptions = (selectedType === RoleIds.PARTNER || selectedType === RoleIds.PARTNER_SYSTEM_USER);
|
||
|
||
// Ensure partner system users are always active by default
|
||
if (selectedType === RoleIds.PARTNER_SYSTEM_USER && this.account) {
|
||
this.account.active = true;
|
||
}
|
||
|
||
if (!this.showVendorOptions) {
|
||
this.form.patchValue({
|
||
[VENDOR_SYSTEM_FIELD]: ''
|
||
});
|
||
this.selectedVendor = null;
|
||
this.updateAvailableVendorOptions();
|
||
} else {
|
||
this.form.get(VENDOR_SYSTEM_FIELD)?.setValidators([Validators.required]);
|
||
this.form.get(VENDOR_SYSTEM_FIELD)?.updateValueAndValidity();
|
||
this.updateAvailableVendorOptions();
|
||
}
|
||
}
|
||
|
||
onVendorChange(selectedVendor: any): void {
|
||
// WI-1: Soft lock - Show confirmation dialog when changing vendor for existing accounts
|
||
if (!this.isNew && this.originalVendorSystem && this.originalVendorSystem !== selectedVendor) {
|
||
this.confirmSvc.confirm({
|
||
header: Labels.VENDOR_CHANGE_CONFIRM_TITLE,
|
||
message: Labels.VENDOR_CHANGE_CONFIRM_MESSAGE,
|
||
acceptLabel: globals.yes,
|
||
rejectLabel: globals.no,
|
||
accept: () => {
|
||
// Allow the change
|
||
this.selectedVendor = selectedVendor;
|
||
this.form.get(VENDOR_SYSTEM_FIELD)?.updateValueAndValidity();
|
||
},
|
||
reject: () => {
|
||
// Revert to original value
|
||
this.form.get(VENDOR_SYSTEM_FIELD)?.setValue(this.originalVendorSystem);
|
||
this.selectedVendor = this.originalVendorSystem;
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
this.selectedVendor = selectedVendor;
|
||
this.form.get(VENDOR_SYSTEM_FIELD)?.updateValueAndValidity();
|
||
}
|
||
|
||
// ============================================================================
|
||
// PARTNER CONNECTION TESTING
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Test partner connection for partner system user accounts.
|
||
*/
|
||
onTestPartnerConnection(): void {
|
||
if (this.isNew) {
|
||
return;
|
||
}
|
||
|
||
// Check if credentials have changed but not saved
|
||
if (this.credentialsChanged && !this.isNew) {
|
||
this.showSaveBeforeTestDialog = true;
|
||
return;
|
||
}
|
||
|
||
if (!this.isPartnerSystemUser(this.account)) {
|
||
const errorMsg = Labels.CONNECTION_TEST_ONLY_AVAILABLE_FOR_PARTNER_USERS;
|
||
this.satlocError = errorMsg;
|
||
this.satlocIntegration.status = OperationalStatus.ERROR;
|
||
return;
|
||
}
|
||
|
||
const formAccount = this.form.get('account')?.value;
|
||
const username = formAccount?.username;
|
||
const password = formAccount?.password;
|
||
|
||
if (!username || !password) {
|
||
const errorMsg = Labels.MISSING_USERNAME_PASSWORD_FOR_CONNECTION_TEST;
|
||
this.satlocError = errorMsg;
|
||
this.satlocIntegration.status = OperationalStatus.ERROR;
|
||
return;
|
||
}
|
||
|
||
const account = this.account;
|
||
// 'customer' is a Mongoose virtual stripped by .lean() — fall back to 'parent' (the real DB field)
|
||
const customerId = (typeof account?.customer === 'string' ? account.customer : (account?.customer as any)?._id)
|
||
?? (typeof (account as any)?.parent === 'string' ? (account as any).parent : ((account as any)?.parent as any)?._id);
|
||
const partnerId = typeof account?.partner === 'string' ? account.partner : (account?.partner as any)?._id;
|
||
|
||
if (!customerId || !partnerId) {
|
||
const errorMsg = Labels.MISSING_CUSTOMER_PARTNER_ID_FOR_CONNECTION_TEST;
|
||
this.satlocError = errorMsg;
|
||
this.satlocIntegration.status = OperationalStatus.ERROR;
|
||
return;
|
||
}
|
||
|
||
this.satlocLoading = true;
|
||
this.satlocError = null;
|
||
|
||
const testAuthObservable = this.partnerService.testPartnerAuth(customerId, partnerId, username, password);
|
||
|
||
testAuthObservable
|
||
.pipe(
|
||
finalize(() => {
|
||
this.satlocLoading = false;
|
||
})
|
||
)
|
||
.subscribe({
|
||
next: (result: any) => {
|
||
const isSuccess = this.partnerService.isAuthenticationSuccessful(result);
|
||
|
||
if (isSuccess) {
|
||
this.satlocIntegration = {
|
||
enabled: true,
|
||
status: OperationalStatus.ACTIVE,
|
||
account_info: null,
|
||
credentials_stored: true,
|
||
last_error: null
|
||
};
|
||
this.satlocError = null;
|
||
} else {
|
||
// Use centralized error handler to extract .tag and map to localized message
|
||
const errorResult = handlePartnerErr(result);
|
||
this.satlocIntegration.status = OperationalStatus.ERROR;
|
||
this.satlocError = errorResult.message;
|
||
}
|
||
},
|
||
error: (error) => {
|
||
// Use centralized error handler for HTTP errors
|
||
const errorResult = handlePartnerErr(error);
|
||
this.satlocIntegration.status = OperationalStatus.ERROR;
|
||
this.satlocError = errorResult.message;
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* User confirmed saving and testing with modified credentials.
|
||
*/
|
||
onConfirmSaveBeforeTest(): void {
|
||
this.showSaveBeforeTestDialog = false;
|
||
this.pendingTestAfterSave = true;
|
||
this.saveAccount();
|
||
}
|
||
|
||
/**
|
||
* User cancelled save before test dialog.
|
||
*/
|
||
onCancelSaveBeforeTest(): void {
|
||
this.showSaveBeforeTestDialog = false;
|
||
this.pendingTestAfterSave = false;
|
||
}
|
||
|
||
/**
|
||
* Determine if post-save validation is required for this account.
|
||
*/
|
||
private shouldPerformPostSaveValidation(savedAccount: User | PartnerSystemUser): boolean {
|
||
if (!this.isPartnerSystemUser(savedAccount)) {
|
||
return false;
|
||
}
|
||
|
||
if (!this.isNew && !this.credentialsChanged) {
|
||
return false;
|
||
}
|
||
|
||
const partnerSystemUser = savedAccount as PartnerSystemUser;
|
||
// 'customer' is a Mongoose virtual stripped by .lean() — fall back to 'parent' (the real DB field)
|
||
const customerId = (typeof partnerSystemUser?.customer === 'string' ? partnerSystemUser.customer : (partnerSystemUser?.customer as any)?._id)
|
||
?? (typeof (partnerSystemUser as any)?.parent === 'string' ? (partnerSystemUser as any).parent : ((partnerSystemUser as any)?.parent as any)?._id);
|
||
const partnerId = typeof partnerSystemUser?.partner === 'string'
|
||
? partnerSystemUser.partner
|
||
: (partnerSystemUser?.partner as any)?._id;
|
||
|
||
const hasRequiredData = !!(customerId && partnerId && partnerSystemUser.username && partnerSystemUser.password);
|
||
|
||
return hasRequiredData;
|
||
}
|
||
|
||
/**
|
||
* Perform post-save credential validation.
|
||
*/
|
||
private performPostSaveValidation(savedAccount: PartnerSystemUser): void {
|
||
this.postSaveValidationInProgress = true;
|
||
this.postSaveValidationSuccess = false;
|
||
this.postSaveValidationError = false;
|
||
this.postSaveErrorMessage = null;
|
||
|
||
// <20> Capture original "new" state before modifying it
|
||
const wasNewAccount = this.isNew;
|
||
|
||
// <20>🔄 CRITICAL: Transition from "new" mode to "edit" mode after account creation
|
||
// This ensures that if post-save validation fails, the user can:
|
||
// 1. Test credentials again (test connection button works)
|
||
// 2. See save dialog when testing with modified credentials (Phase 1-3)
|
||
// 3. Re-save the account with corrected credentials
|
||
if (wasNewAccount) {
|
||
this.account = savedAccount;
|
||
this._isNew = false;
|
||
this.originalUsername = savedAccount.username || '';
|
||
this.originalPassword = savedAccount.password || '';
|
||
this.credentialsChanged = false;
|
||
|
||
// Mark username as saved in account editor to prevent "username taken" error
|
||
if (this.accountEditor && savedAccount.username) {
|
||
this.accountEditor.markUsernameAsSaved(savedAccount.username);
|
||
}
|
||
}
|
||
|
||
// 'customer' is a Mongoose virtual stripped by .lean() — fall back to 'parent' (the real DB field)
|
||
const customerId = (typeof savedAccount?.customer === 'string' ? savedAccount.customer : (savedAccount?.customer as any)?._id)
|
||
?? (typeof (savedAccount as any)?.parent === 'string' ? (savedAccount as any).parent : ((savedAccount as any)?.parent as any)?._id);
|
||
const partnerId = typeof savedAccount?.partner === 'string'
|
||
? savedAccount.partner
|
||
: (savedAccount?.partner as any)?._id;
|
||
|
||
if (!customerId || !partnerId || !savedAccount.username || !savedAccount.password) {
|
||
this.postSaveValidationInProgress = false;
|
||
|
||
if (wasNewAccount) {
|
||
this.postSaveValidationError = true;
|
||
this.postSaveErrorMessage = 'Unable to validate credentials: missing customer or partner information.';
|
||
this.account = savedAccount;
|
||
this._isNew = false;
|
||
} else {
|
||
this.handleNormalSaveSuccess({ payload: savedAccount });
|
||
}
|
||
return;
|
||
}
|
||
|
||
const testAuthObservable = this.partnerService.testPartnerAuth(
|
||
customerId,
|
||
partnerId,
|
||
savedAccount.username,
|
||
savedAccount.password
|
||
);
|
||
|
||
testAuthObservable
|
||
.pipe(
|
||
catchError(error => {
|
||
return of({
|
||
authSuccess: false,
|
||
success: false,
|
||
error: error
|
||
});
|
||
}),
|
||
finalize(() => {
|
||
this.cdr.detectChanges();
|
||
})
|
||
)
|
||
.subscribe({
|
||
next: (result: any) => {
|
||
const isSuccess = this.partnerService.isAuthenticationSuccessful(result);
|
||
|
||
if (isSuccess) {
|
||
this.postSaveValidationInProgress = false;
|
||
this.postSaveValidationSuccess = true;
|
||
this.postSaveValidationError = false;
|
||
this.postSaveErrorMessage = null;
|
||
|
||
this.credentialsChanged = false;
|
||
this.originalUsername = savedAccount.username || '';
|
||
this.originalPassword = savedAccount.password || '';
|
||
|
||
// Update satlocIntegration status to show success icon
|
||
this.satlocIntegration = {
|
||
enabled: true,
|
||
status: OperationalStatus.ACTIVE,
|
||
account_info: null,
|
||
credentials_stored: true,
|
||
last_error: null
|
||
};
|
||
this.satlocError = null;
|
||
|
||
// Navigation behavior depends on whether this was a new account or existing account
|
||
if (wasNewAccount) {
|
||
// New account created from vehicle-edit flow: Navigate back immediately
|
||
// This provides seamless flow when creating missing partner accounts
|
||
if (this.returnTo === 'vehicle-edit') {
|
||
this.account = savedAccount;
|
||
this.handleVehicleEditReturnFlow(userActions.CREATE_SUCCESS);
|
||
} else {
|
||
// New account from normal flow: Stay on page to show success status
|
||
// User can see validation succeeded before navigating away manually
|
||
this.store.dispatch(new userActions.Select(savedAccount));
|
||
}
|
||
} else {
|
||
// Existing account: Navigate back to account list (normal save flow)
|
||
// Credentials were changed and validated successfully, so navigate away
|
||
this.handleNormalSaveSuccess({ payload: savedAccount });
|
||
}
|
||
} else {
|
||
this.handlePostSaveValidationFailure(result, savedAccount);
|
||
}
|
||
},
|
||
error: (error) => {
|
||
this.handlePostSaveValidationFailure(error, savedAccount);
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Handle post-save validation failure.
|
||
* Unwraps error structure if needed and extracts localized error message.
|
||
*/
|
||
private handlePostSaveValidationFailure(error: any, savedAccount?: PartnerSystemUser): void {
|
||
// Unwrap error if it was wrapped by catchError operator
|
||
// catchError wraps as: { authSuccess: false, success: false, error: HttpErrorResponse }
|
||
const actualError = error?.error instanceof HttpErrorResponse ? error.error : error;
|
||
|
||
const errorResult = handlePartnerErr(actualError);
|
||
|
||
this.postSaveValidationInProgress = false;
|
||
this.postSaveValidationSuccess = false;
|
||
this.postSaveValidationError = true;
|
||
this.postSaveErrorMessage = errorResult.message;
|
||
|
||
// Update satlocIntegration status to show error icon
|
||
this.satlocIntegration.status = OperationalStatus.ERROR;
|
||
this.satlocError = errorResult.message;
|
||
|
||
if (savedAccount) {
|
||
this.account = savedAccount;
|
||
this.originalUsername = savedAccount.username || '';
|
||
this.originalPassword = savedAccount.password || '';
|
||
this.credentialsChanged = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Normal save success handling.
|
||
*/
|
||
private handleNormalSaveSuccess(action: any): void {
|
||
const savedAccount = action.payload;
|
||
|
||
// If save was triggered by "Save and Test" dialog, run test connection
|
||
if (this.pendingTestAfterSave) {
|
||
this.pendingTestAfterSave = false;
|
||
this.account = savedAccount;
|
||
this._isNew = false;
|
||
this.originalUsername = savedAccount.username || '';
|
||
this.originalPassword = savedAccount.password || '';
|
||
this.credentialsChanged = false;
|
||
|
||
// Mark username as saved in account editor to prevent "username taken" error
|
||
if (this.accountEditor && savedAccount.username) {
|
||
this.accountEditor.markUsernameAsSaved(savedAccount.username);
|
||
}
|
||
|
||
// Trigger test connection after brief delay to ensure state is updated
|
||
setTimeout(() => {
|
||
this.onTestPartnerConnection();
|
||
}, 200);
|
||
return;
|
||
}
|
||
|
||
if (this.returnTo === 'vehicle-edit') {
|
||
this.account = savedAccount;
|
||
// Mark username as saved in account editor to prevent "username taken" error
|
||
if (this.accountEditor && savedAccount.username) {
|
||
this.accountEditor.markUsernameAsSaved(savedAccount.username);
|
||
}
|
||
this.handleVehicleEditReturnFlow(action.type);
|
||
} else {
|
||
this.store.dispatch(new userActions.Select(savedAccount));
|
||
this.goBack();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Clear post-save validation state.
|
||
*/
|
||
private clearPostSaveValidation(): void {
|
||
this.postSaveValidationSuccess = false;
|
||
this.postSaveValidationError = false;
|
||
this.postSaveErrorMessage = null;
|
||
}
|
||
|
||
private loadVendorOptions(): void {
|
||
this.vendorsLoading = true;
|
||
this.form.get(this.VENDOR_SYSTEM_FIELD)?.disable();
|
||
|
||
this.partnerService.getPartners()
|
||
.pipe(
|
||
catchError(error => {
|
||
return of([]);
|
||
}),
|
||
finalize(() => {
|
||
this.vendorsLoading = false;
|
||
this.updateVendorFieldState();
|
||
})
|
||
)
|
||
.subscribe((partners: Partner[]) => {
|
||
this.partners = partners.filter(p => p.active);
|
||
|
||
const partnerVendorOptions = this.partners
|
||
.filter(partner => partner.partnerCode)
|
||
.map(partner => ({
|
||
label: partner.partnerCode!,
|
||
value: partner.partnerCode!
|
||
}));
|
||
|
||
const satlocOption = { label: $localize`:Satloc vendor option@@satloc:Satloc`, value: SATLOC_VENDOR };
|
||
|
||
const hasSatloc = partnerVendorOptions.some(option =>
|
||
this.partnerUtils.isSatlocPartner(option.value)
|
||
);
|
||
|
||
let partnerLabelOption = null;
|
||
if (this.isAccountDoesNotExistFlow) {
|
||
const partnerLabel = this.partnerCode || (this.partnerId ? this.getPartnerLabelFromId(this.partnerId) : null);
|
||
|
||
if (partnerLabel) {
|
||
const hasPartnerLabel = partnerVendorOptions.some(option =>
|
||
option.value.toLowerCase() === partnerLabel.toLowerCase()
|
||
);
|
||
|
||
if (!hasPartnerLabel) {
|
||
partnerLabelOption = {
|
||
label: partnerLabel,
|
||
value: partnerLabel
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
this.vendorOptions = [
|
||
{ label: Labels.SELECT_PARTNER_SYSTEM, value: '' },
|
||
...(!hasSatloc ? [satlocOption] : []),
|
||
...(partnerLabelOption ? [partnerLabelOption] : []),
|
||
...partnerVendorOptions
|
||
];
|
||
|
||
this.availableVendorOptions = [...this.vendorOptions];
|
||
this.updateAvailableVendorOptions();
|
||
this.updateSelectedVendorAfterLoading();
|
||
this.loadExistingPartnerSystemUsers();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Get partner label (partnerCode) from partner ID
|
||
* Used to derive vendor selection for Account Does Not Exist flow
|
||
*/
|
||
private getPartnerLabelFromId(partnerId: string): string | null {
|
||
if (!partnerId || !this.partners) {
|
||
return null;
|
||
}
|
||
|
||
const partner = this.partners.find(p => p._id === partnerId);
|
||
return partner?.partnerCode || partner?.name || null;
|
||
}
|
||
|
||
/**
|
||
* Updates selectedVendor after vendors are dynamically loaded
|
||
* Handles case-insensitive matching between stored vendor and loaded vendor options
|
||
*/
|
||
private updateSelectedVendorAfterLoading(): void {
|
||
if (!this.account) return;
|
||
|
||
const storedVendor = this.getVendorFromAccount(this.account);
|
||
|
||
if (storedVendor) {
|
||
const matchingVendorOption = this.vendorOptions.find(vendor =>
|
||
vendor.value && this.partnerUtils.matchesPartnerCode(vendor.value, storedVendor)
|
||
);
|
||
|
||
if (matchingVendorOption) {
|
||
this.selectedVendor = matchingVendorOption.value;
|
||
|
||
this.form.patchValue({
|
||
[VENDOR_SYSTEM_FIELD]: matchingVendorOption.value
|
||
});
|
||
|
||
if (!this.isNew && this.selectedVendor && !this.satlocLoading && this.isPartnerSystemUser(this.account)) {
|
||
setTimeout(() => {
|
||
this.onTestPartnerConnection();
|
||
}, 200);
|
||
}
|
||
|
||
this.updateAvailableVendorOptions();
|
||
}
|
||
}
|
||
|
||
if (this.isAccountDoesNotExistFlow) {
|
||
const partnerLabel = this.partnerCode || (this.partnerId ? this.getPartnerLabelFromId(this.partnerId) : null);
|
||
|
||
if (partnerLabel) {
|
||
const matchingVendor = this.vendorOptions.find(vendor =>
|
||
vendor.value && vendor.value.toLowerCase() === partnerLabel.toLowerCase()
|
||
);
|
||
|
||
if (matchingVendor) {
|
||
this.selectedVendor = matchingVendor.value;
|
||
|
||
if (!this.form.get(VENDOR_SYSTEM_FIELD)?.disabled) {
|
||
this.form.patchValue({
|
||
[VENDOR_SYSTEM_FIELD]: matchingVendor.value
|
||
});
|
||
}
|
||
|
||
this.updateAvailableVendorOptions();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private loadExistingPartnerSystemUsers(): void {
|
||
// PSUs are already in the NgRx store from POST /users/search — read from store
|
||
// instead of making N additional GET /api/partners/systemUsers calls.
|
||
this.store.select(fromUsers.getAllUsers)
|
||
.pipe(take(1))
|
||
.subscribe(users => {
|
||
this.existingPartnerSystemUsers = users.filter(
|
||
u => u.kind === RoleIds.PARTNER_SYSTEM_USER
|
||
) as PartnerSystemUser[];
|
||
this.updateAvailableVendorOptions();
|
||
});
|
||
}
|
||
|
||
private updateAvailableVendorOptions(): void {
|
||
// Use partner ObjectId → partnerCode for deduplication (authoritative)
|
||
const existingVendorTypes = this.existingPartnerSystemUsers.map(su => {
|
||
if (su.partner) {
|
||
const rawId = typeof su.partner === 'string' ? su.partner : (su.partner as any)._id;
|
||
return this.getPartnerCodeFromPartnerId(rawId);
|
||
}
|
||
return null;
|
||
}).filter(vendor => vendor);
|
||
|
||
this.availableVendorOptions = this.vendorOptions.filter(option => {
|
||
if (!option.value) return true;
|
||
|
||
if (!this.isNew && this.selectedVendor &&
|
||
this.partnerUtils.matchesPartnerCode(this.selectedVendor, option.value)) return true;
|
||
|
||
// Guard: always keep the edited account's own vendor type, even when selectedVendor is falsy
|
||
if (!this.isNew && this.account?._id) {
|
||
const thisAccount = this.existingPartnerSystemUsers.find(su => su._id === (this.account as any)?._id);
|
||
// Use partner ObjectId for this account's own vendor type lookup (authoritative)
|
||
let thisAccountVendor: string | null = null;
|
||
if (thisAccount?.partner) {
|
||
const rawId = typeof thisAccount.partner === 'string' ? thisAccount.partner : (thisAccount.partner as any)._id;
|
||
thisAccountVendor = this.getPartnerCodeFromPartnerId(rawId);
|
||
}
|
||
if (thisAccountVendor && option.value &&
|
||
this.partnerUtils.matchesPartnerCode(thisAccountVendor, option.value)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
const isVendorAlreadyExists = existingVendorTypes.some(existingVendor =>
|
||
existingVendor && option.value &&
|
||
this.partnerUtils.matchesPartnerCode(existingVendor, option.value)
|
||
);
|
||
return !isVendorAlreadyExists;
|
||
});
|
||
|
||
this.updateAccountTypeOptions();
|
||
}
|
||
|
||
private updateAccountTypeOptions(): void {
|
||
const hasAvailableVendors = this.availableVendorOptions.length > 1;
|
||
let partnerSystemLabel = Labels.PARTNER_SYSTEM_LABEL;
|
||
|
||
// Always enable PARTNER_SYSTEM_USER option, but show constraint when no vendors available
|
||
if (!hasAvailableVendors) {
|
||
partnerSystemLabel += $localize` (All Partner System configured)`;
|
||
}
|
||
|
||
this.kinds = [
|
||
{ label: Roles[RoleIds.APP_ADM], value: RoleIds.APP_ADM },
|
||
{ label: Roles[RoleIds.OFFICER], value: RoleIds.OFFICER },
|
||
{ label: Roles[RoleIds.INSPECTOR], value: RoleIds.INSPECTOR },
|
||
{
|
||
label: partnerSystemLabel,
|
||
value: RoleIds.PARTNER_SYSTEM_USER,
|
||
disabled: false // Always enabled
|
||
}
|
||
];
|
||
}
|
||
|
||
saveAccount() {
|
||
if (!this.form || !this.form.valid) return;
|
||
|
||
const formValue = this.form.getRawValue();
|
||
|
||
if ((formValue.kind === RoleIds.PARTNER_SYSTEM_USER) &&
|
||
(!formValue[VENDOR_SYSTEM_FIELD] || formValue[VENDOR_SYSTEM_FIELD] === '')) {
|
||
this.form.get(VENDOR_SYSTEM_FIELD)?.markAsTouched();
|
||
return;
|
||
}
|
||
|
||
const userObj = Object.assign(
|
||
this.selectedItem,
|
||
formValue.profile,
|
||
formValue.account,
|
||
{ kind: formValue.kind }
|
||
);
|
||
|
||
if (formValue.kind === RoleIds.PARTNER_SYSTEM_USER) {
|
||
const selectedVendorType = formValue[VENDOR_SYSTEM_FIELD];
|
||
|
||
const partnerConfig = {
|
||
vendorSystemType: selectedVendorType,
|
||
vendorConfiguration: this.buildVendorConfiguration(selectedVendorType)
|
||
};
|
||
|
||
const enhancedUserObj = {
|
||
...userObj,
|
||
partnerConfig
|
||
};
|
||
|
||
const enhancedAction = this._isNew
|
||
? new userActions.Create(enhancedUserObj)
|
||
: new userActions.Update(enhancedUserObj);
|
||
|
||
this.store.dispatch(enhancedAction);
|
||
} else {
|
||
const enhancedAction = this._isNew
|
||
? new userActions.Create(userObj)
|
||
: new userActions.Update(userObj);
|
||
|
||
this.store.dispatch(enhancedAction);
|
||
}
|
||
}
|
||
|
||
private buildVendorConfiguration(vendorType: string): any {
|
||
const baseConfig = {
|
||
companyId: null,
|
||
apiKey: null,
|
||
apiSecret: null
|
||
};
|
||
|
||
switch (vendorType) {
|
||
case SATLOC_VENDOR:
|
||
return {
|
||
...baseConfig
|
||
};
|
||
default:
|
||
return baseConfig;
|
||
}
|
||
}
|
||
|
||
private async handleVehicleEditReturnFlow(actionType?: string): Promise<void> {
|
||
if (!this.partnerId || !this.customerId || !this.vehicleId) {
|
||
this.goBack();
|
||
return;
|
||
}
|
||
|
||
if (actionType === userActions.CREATE_SUCCESS) {
|
||
this.router.navigate(['/entities/aircraft', this.vehicleId], {
|
||
queryParams: {
|
||
connectionTestResult: 'success',
|
||
message: Labels.PARTNER_ACCOUNT_CREATED_SUCCESSFULLY
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const account = this.account;
|
||
if (!account.username || !account.password) {
|
||
throw new Error(Labels.ACCOUNT_MISSING_CREDENTIALS_FOR_CONNECTION_TEST);
|
||
}
|
||
|
||
const authResult = await this.partnerService.testPartnerAuth(
|
||
this.customerId,
|
||
this.partnerId,
|
||
account.username,
|
||
account.password
|
||
).toPromise();
|
||
|
||
// Use centralized success check (handles { ok: true }, { authSuccess: true }, or { success: true })
|
||
if (this.partnerService.isAuthenticationSuccessful(authResult)) {
|
||
// Success - navigate back to vehicle-edit
|
||
this.router.navigate(['/entities/aircraft', this.vehicleId], {
|
||
queryParams: {
|
||
connectionTestResult: 'success',
|
||
message: Labels.ACCOUNT_AUTHENTICATION_SUCCESSFUL
|
||
}
|
||
});
|
||
} else {
|
||
// Authentication failed - stay on page and show error
|
||
const errorResult = handlePartnerErr(authResult);
|
||
this.satlocIntegration.status = OperationalStatus.ERROR;
|
||
this.satlocError = errorResult.message;
|
||
}
|
||
} catch (error) {
|
||
// Error during auth test - stay on page and show error
|
||
const errorResult = handlePartnerErr(error);
|
||
this.satlocIntegration.status = OperationalStatus.ERROR;
|
||
this.satlocError = errorResult.message;
|
||
}
|
||
}
|
||
|
||
goBack() {
|
||
this.router.navigate(['../', { id: this.account._id }]);
|
||
}
|
||
}
|