agmission/Development/client/src/app/accounts/account-edit/account-edit.component.ts

1157 lines
40 KiB
TypeScript
Raw Blame History

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 }]);
}
}