diff --git a/src/app/agents/new-agent/new-agent.component.html b/src/app/agents/new-agent/new-agent.component.html index 4643179e5..58be01040 100644 --- a/src/app/agents/new-agent/new-agent.component.html +++ b/src/app/agents/new-agent/new-agent.component.html @@ -2,16 +2,23 @@ - + @if (canReadAgentBinaries) { + + }
-

- Download the agent binary and execute it on the - client server. You can download an agent binary by clicking on the - action menu (...) in the agent binaries table above. -

+ @if (canReadAgentBinaries) { +

+ Download the agent binary and execute it on the + client server. You can download an agent binary by clicking on the + action menu (...) in the agent binaries table above. +

+ } @else { +

+ You do not have permission to view agent binaries. You can skip this step and proceed to register the agent. +

+ }
@@ -53,16 +60,17 @@
-

- Generate a voucher to register the agent. Note that once the voucher - is used it will be automatically deleted. -

- + @if (!allowMultiVoucher) { +

+ Generate a voucher to register the agent. Note that once the voucher + is used it will be automatically deleted. +

+ } @else {

Generate vouchers to register agents. Vouchers remain available when the "Register Multiple Agents Using Voucher(s)" option is enabled.

-
+ }
diff --git a/src/app/agents/new-agent/new-agent.component.spec.ts b/src/app/agents/new-agent/new-agent.component.spec.ts index 493cc604b..8c02951aa 100644 --- a/src/app/agents/new-agent/new-agent.component.spec.ts +++ b/src/app/agents/new-agent/new-agent.component.spec.ts @@ -1,3 +1,4 @@ +import { Perm } from '@constants/userpermissions.config'; import { of } from 'rxjs'; import { Clipboard } from '@angular/cdk/clipboard'; @@ -13,6 +14,7 @@ import { Router } from '@angular/router'; import { SERV } from '@services/main.config'; import { GlobalService } from '@services/main.service'; +import { PermissionService } from '@services/permission/permission.service'; import { AlertService } from '@services/shared/alert.service'; import { ConfigService } from '@services/shared/config.service'; @@ -29,7 +31,7 @@ import { mockResponse } from '@src/app/testing/mock-response'; standalone: false }) export class MockAgentBinariesTableComponent { - @Input() isSelectable: boolean; + @Input() isSelectable: boolean = false; } // Voucher table mock @@ -49,6 +51,7 @@ describe('NewAgentComponent', () => { let alertServiceSpy: jasmine.SpyObj; let configServiceSpy: jasmine.SpyObj; let globalServiceSpy: jasmine.SpyObj; + let permissionServiceSpy: jasmine.SpyObj; let routerSpy: jasmine.SpyObj; beforeEach(async () => { @@ -56,6 +59,8 @@ describe('NewAgentComponent', () => { alertServiceSpy = jasmine.createSpyObj('AlertService', ['showSuccessMessage']); configServiceSpy = jasmine.createSpyObj('ConfigService', ['getEndpoint']); globalServiceSpy = jasmine.createSpyObj('GlobalService', ['create', 'getAll']); + permissionServiceSpy = jasmine.createSpyObj('PermissionService', ['hasPermissionSync']); + permissionServiceSpy.hasPermissionSync.and.returnValue(true); routerSpy = jasmine.createSpyObj('Router', ['navigate']); // Provide default stub for configService.getEndpoint() @@ -81,6 +86,7 @@ describe('NewAgentComponent', () => { { provide: AlertService, useValue: alertServiceSpy }, { provide: ConfigService, useValue: configServiceSpy }, { provide: GlobalService, useValue: globalServiceSpy }, + { provide: PermissionService, useValue: permissionServiceSpy }, { provide: Router, useValue: routerSpy } ] }).compileComponents(); @@ -176,4 +182,43 @@ describe('NewAgentComponent', () => { component.ngOnDestroy(); expect(component.newVoucherSubscription.unsubscribe).toHaveBeenCalled(); }); + + it('should allow agent creation for a user without AccessGroup.READ permission', fakeAsync(() => { + // Simulate a user who lacks AccessGroup.READ but retains Agent.CREATE, Agent.READ and Voucher.READ + // This is the exact scenario described in issue #1955 + permissionServiceSpy.hasPermissionSync.and.callFake((perm: string) => perm !== Perm.GroupAccess.READ); + + fixture = TestBed.createComponent(NewAgentComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + // Component initializes without errors + expect(component).toBeTruthy(); + + // User can still create a voucher — the actual agent registration action + const voucher = 'fy7vjq56'; + component.form.controls['voucher'].setValue(voucher); + component.table = jasmine.createSpyObj('VouchersTableComponent', ['reload']); + globalServiceSpy.create.and.returnValue(of({} as any)); + + component.onSubmit(); + tick(); + + expect(globalServiceSpy.create).toHaveBeenCalledWith(SERV.VOUCHER, { voucher: voucher }); + expect(alertServiceSpy.showSuccessMessage).toHaveBeenCalledWith('New voucher successfully created!'); + })); + + it('should not make any access group API call during agent creation', () => { + // Agent creation does not require or fetch access groups — verifies no extraneous dependency + const calledEndpoints = globalServiceSpy.getAll.calls.allArgs().map((args: unknown[]) => args[0]); + expect(calledEndpoints).not.toContain(SERV.ACCESS_GROUPS); + }); + + it('should hide agent binaries table and avoid 403 when user lacks AgentBinary.READ permission', () => { + // Secondary fix: users without AgentBinary.READ won't trigger a 403 on the binaries endpoint + component.canReadAgentBinaries = false; + fixture.detectChanges(); + const table = fixture.nativeElement.querySelector('app-agent-binaries-table'); + expect(table).toBeNull(); + }); }); diff --git a/src/app/agents/new-agent/new-agent.component.ts b/src/app/agents/new-agent/new-agent.component.ts index 91d45a499..479c94c26 100644 --- a/src/app/agents/new-agent/new-agent.component.ts +++ b/src/app/agents/new-agent/new-agent.component.ts @@ -1,3 +1,4 @@ +import { Perm } from '@constants/userpermissions.config'; import { zConfigListResponse } from '@generated/api/zod'; import { Subscription } from 'rxjs'; import { VouchersTableComponent } from 'src/app/core/_components/tables/vouchers-table/vouchers-table.component'; @@ -13,6 +14,7 @@ import { Router } from '@angular/router'; import { JConfig } from '@models/configs.model'; import { SERV } from '@services/main.config'; +import { PermissionService } from '@services/permission/permission.service'; import { AlertService } from '@services/shared/alert.service'; import { VoucherForm } from '@src/app/agents/new-agent/new-agent.form'; @@ -32,6 +34,7 @@ export class NewAgentComponent implements OnInit, OnDestroy { private alertService = inject(AlertService); private cs = inject(ConfigService); private gs = inject(GlobalService); + private permissionService = inject(PermissionService); private router = inject(Router); form: FormGroup = new FormGroup({ @@ -40,6 +43,7 @@ export class NewAgentComponent implements OnInit, OnDestroy { agentURL: string; newVoucherSubscription: Subscription; allowMultiVoucher = false; + canReadAgentBinaries = this.permissionService.hasPermissionSync(Perm.AgentBinary.READ); @ViewChild('table') table: VouchersTableComponent; diff --git a/src/app/core/_components/tables/access-permission-groups-user-table/access-permission-groups-user-table.component.html b/src/app/core/_components/tables/access-permission-groups-user-table/access-permission-groups-user-table.component.html index ea7e718ed..d908eaa32 100644 --- a/src/app/core/_components/tables/access-permission-groups-user-table/access-permission-groups-user-table.component.html +++ b/src/app/core/_components/tables/access-permission-groups-user-table/access-permission-groups-user-table.component.html @@ -1,4 +1,5 @@ \ No newline at end of file + (editableCheckbox)="onCheckboxChange($event)" (exportActionClicked)="exportActionClicked($event)" + (allToggled)="onAllPermissionsToggled()" (rowToggled)="onRowToggled($event)" /> \ No newline at end of file diff --git a/src/app/core/_components/tables/access-permission-groups-user-table/access-permission-groups-user-table.component.ts b/src/app/core/_components/tables/access-permission-groups-user-table/access-permission-groups-user-table.component.ts index c84223ca1..3ecc65594 100644 --- a/src/app/core/_components/tables/access-permission-groups-user-table/access-permission-groups-user-table.component.ts +++ b/src/app/core/_components/tables/access-permission-groups-user-table/access-permission-groups-user-table.component.ts @@ -1,6 +1,7 @@ import { catchError } from 'rxjs'; -import { AfterViewInit, Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { AfterViewInit, Component, Input, OnDestroy, OnInit, inject } from '@angular/core'; import { DynamicModel } from '@models/base.model'; import { UserPermissions } from '@models/global-permission-group.model'; @@ -30,6 +31,8 @@ export class AccessPermissionGroupsUserTableComponent { @Input() accesspermgroupId = 0; + private http = inject(HttpClient); + tableColumns: HTTableColumn[] = []; dataSource: AccessPermissionGroupsExpandDataSource; expand = 'userMembers'; @@ -148,27 +151,112 @@ export class AccessPermissionGroupsUserTableComponent const capitalizedPerm = (editable['action'].match(/-(.*?)-/)?.[1] || '') .toLowerCase() .replace(/^\w/, (c) => c.toUpperCase()); - const keyPerm = String((editable.data as unknown as DynamicModel)['originalName']) + capitalizedPerm; + const keyPerm = 'perm' + String((editable.data as unknown as DynamicModel)['key']) + capitalizedPerm; const boolValue = value === 'true' ? true : value === 'false' ? false : Boolean(value); - // Payload - const payload = { - permissions: { [keyPerm]: boolValue } - }; - const request$ = this.gs.update(SERV.ACCESS_PERMISSIONS_GROUPS, this.accesspermgroupId, payload); + // Build the full permissions object from current datasource state, then apply the change + const allPermissions: Record = {}; + (this.dataSource.currentData as UserPermissions[]).forEach((perm) => { + allPermissions[`perm${perm.key}Create`] = perm.create; + allPermissions[`perm${perm.key}Read`] = perm.read; + allPermissions[`perm${perm.key}Update`] = perm.update; + allPermissions[`perm${perm.key}Delete`] = perm.delete; + }); + allPermissions[keyPerm] = boolValue; + + this.patchPermissions( + allPermissions, + `Changed permission in ${capitalizedPerm} on Permission Group #${this.accesspermgroupId}!` + ); + } + + /** + * Grants or revokes all 4 permissions (create/read/update/delete) for a single resource row. + * Direction is derived from the row's current data: if any permission is already true, + * revoke all; otherwise grant all. This avoids relying on the selection checkbox state + * which resets to unchecked after every reload. + */ + onRowToggled(event: { row: JUser | UserPermissions; checked: boolean }): void { + const perm = event.row as UserPermissions; + if (!('key' in perm) || !perm.key) return; + + const allGranted = perm.create && perm.read && perm.update && perm.delete; + const newValue = !allGranted; + + const allPermissions: Record = {}; + (this.dataSource.currentData as UserPermissions[]).forEach((p) => { + allPermissions[`perm${p.key}Create`] = p.create; + allPermissions[`perm${p.key}Read`] = p.read; + allPermissions[`perm${p.key}Update`] = p.update; + allPermissions[`perm${p.key}Delete`] = p.delete; + }); + allPermissions[`perm${perm.key}Create`] = newValue; + allPermissions[`perm${perm.key}Read`] = newValue; + allPermissions[`perm${perm.key}Update`] = newValue; + allPermissions[`perm${perm.key}Delete`] = newValue; + + const label = newValue + ? `Granted all permissions for ${perm.name} on Permission Group #${this.accesspermgroupId}` + : `Revoked all permissions for ${perm.name} on Permission Group #${this.accesspermgroupId}`; + this.patchPermissions(allPermissions, label); + } + + /** + * Grants or revokes all permissions for all resources at once. + * Direction is determined by the current data: if more than half the individual + * permission booleans are already true, the next click revokes; otherwise it grants. + * This correctly handles server-enforced always-false permissions (e.g. AgentError Create/Update) + * that prevent a strict "all === true" check from ever being satisfied. + */ + onAllPermissionsToggled(): void { + const currentPerms = this.dataSource.currentData as UserPermissions[]; + if (currentPerms.length === 0) return; + + const totalPerms = currentPerms.length * 4; + const grantedCount = currentPerms.reduce( + (count, p) => count + (p.create ? 1 : 0) + (p.read ? 1 : 0) + (p.update ? 1 : 0) + (p.delete ? 1 : 0), + 0 + ); + const newValue = grantedCount / totalPerms <= 0.5; // grant when ≤50% granted, revoke when >50% + + const allPermissions: Record = {}; + currentPerms.forEach((perm) => { + allPermissions[`perm${perm.key}Create`] = newValue; + allPermissions[`perm${perm.key}Read`] = newValue; + allPermissions[`perm${perm.key}Update`] = newValue; + allPermissions[`perm${perm.key}Delete`] = newValue; + }); + const label = newValue + ? `Granted all permissions on Permission Group #${this.accesspermgroupId}` + : `Revoked all permissions on Permission Group #${this.accesspermgroupId}`; + this.patchPermissions(allPermissions, label); + } + + /** + * Sends a PATCH to update the global permission group's permissions object. + * Uses a direct HTTP call with the exact JSON:API type the server expects. + */ + private patchPermissions(permissions: Record, successMessage: string): void { + const url = `${this.cs.getEndpoint()}${SERV.ACCESS_PERMISSIONS_GROUPS.URL}/${this.accesspermgroupId}`; + const body = { + data: { + type: 'globalPermissionGroup', + id: this.accesspermgroupId, + attributes: { permissions } + } + }; this.subscriptions.push( - request$ + this.http + .patch(url, body) .pipe( catchError((error) => { - this.alertService.showErrorMessage(`Failed to update permission!`); - console.error('Failed to update permission:', error); + this.alertService.showErrorMessage(`Failed to update permissions!`); + console.error('Failed to update permissions:', error); return []; }) ) .subscribe(() => { - this.alertService.showSuccessMessage( - `Changed permission in ${capitalizedPerm} on Permission Group #${this.accesspermgroupId}!` - ); + this.alertService.showSuccessMessage(successMessage); this.reload(); }) ); diff --git a/src/app/core/_components/tables/ht-table/ht-table.component.html b/src/app/core/_components/tables/ht-table/ht-table.component.html index 82cd63a63..4640cf70f 100644 --- a/src/app/core/_components/tables/ht-table/ht-table.component.html +++ b/src/app/core/_components/tables/ht-table/ht-table.component.html @@ -154,7 +154,7 @@ }" > @if (!isCmdTask) { - + } @else { diff --git a/src/app/core/_components/tables/ht-table/ht-table.component.ts b/src/app/core/_components/tables/ht-table/ht-table.component.ts index ea74862e5..4534cf0cb 100644 --- a/src/app/core/_components/tables/ht-table/ht-table.component.ts +++ b/src/app/core/_components/tables/ht-table/ht-table.component.ts @@ -216,6 +216,12 @@ export class HTTableComponent implements OnInit, AfterViewI /** Fetches user customizations */ @Output() backendSqlFilter: EventEmitter = new EventEmitter(); + /** Emits true when all rows become selected, false when all become deselected via the header checkbox */ + @Output() allToggled = new EventEmitter(); + + /** Emits when a single row checkbox is toggled, with the row data and new checked state */ + @Output() rowToggled = new EventEmitter<{ row: T; checked: boolean }>(); + private uiSettings: UISettingsUtilityClass; private subscriptions: Subscription = new Subscription(); @@ -511,6 +517,8 @@ export class HTTableComponent implements OnInit, AfterViewI */ toggleAll(): void { this.dataSource.toggleAll(); + this.cd.markForCheck(); + this.allToggled.emit(this.dataSource.isAllSelected()); } /** @@ -532,9 +540,13 @@ export class HTTableComponent implements OnInit, AfterViewI * * @param row - The row to toggle. */ - toggleSelect(row: T): void { + toggleSelect(row: T, checked?: boolean): void { if (this.isSelectable) { this.dataSource.toggleRow(row); + this.cd.markForCheck(); + if (checked !== undefined) { + this.rowToggled.emit({ row, checked }); + } } } diff --git a/src/app/core/_components/tables/ht-table/type/editable/editable-checkbox/ht-table-type-editable-checkbox.component.ts b/src/app/core/_components/tables/ht-table/type/editable/editable-checkbox/ht-table-type-editable-checkbox.component.ts index 9ea1b9654..60203160e 100644 --- a/src/app/core/_components/tables/ht-table/type/editable/editable-checkbox/ht-table-type-editable-checkbox.component.ts +++ b/src/app/core/_components/tables/ht-table/type/editable/editable-checkbox/ht-table-type-editable-checkbox.component.ts @@ -1,4 +1,13 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnChanges, + OnInit, + Output, + SimpleChanges +} from '@angular/core'; import { BaseModel } from '@models/base.model'; @@ -10,7 +19,7 @@ import { HTTableColumn, HTTableEditable } from '@components/tables/ht-table/ht-t changeDetection: ChangeDetectionStrategy.OnPush, standalone: false }) -export class HTTableTypeEditableCheckboxComponent implements OnInit { +export class HTTableTypeEditableCheckboxComponent implements OnInit, OnChanges { checkbox: HTTableEditable; original: string; @@ -24,7 +33,17 @@ export class HTTableTypeEditableCheckboxComponent implements OnInit { editMode = false; ngOnInit(): void { - if (this.tableColumn.checkbox) { + this.initCheckbox(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['element'] && !changes['element'].firstChange) { + this.initCheckbox(); + } + } + + private initCheckbox(): void { + if (this.tableColumn?.checkbox) { this.checkbox = this.tableColumn.checkbox(this.element); this.original = this.checkbox.value; } diff --git a/src/app/core/_datasources/access-permission-groups-expand.datasource.ts b/src/app/core/_datasources/access-permission-groups-expand.datasource.ts index 9dc30a2ee..084c7af70 100644 --- a/src/app/core/_datasources/access-permission-groups-expand.datasource.ts +++ b/src/app/core/_datasources/access-permission-groups-expand.datasource.ts @@ -15,6 +15,10 @@ export class AccessPermissionGroupsExpandDataSource extends BaseDataSource { + getRelationships( + serviceConfig: ServiceConfig, + id: number, + relType: string, + options?: { headers?: HttpHeaders } + ): Observable { return this.http - .get(this.cs.getEndpoint() + serviceConfig.URL + '/' + id + '/' + relType) + .get(this.cs.getEndpoint() + serviceConfig.URL + '/' + id + '/' + relType, options) .pipe(debounceTime(2000)); } diff --git a/src/app/core/_services/roles/agents/agent-role.service.ts b/src/app/core/_services/roles/agents/agent-role.service.ts index 5f6dc22bf..c94ed2a05 100644 --- a/src/app/core/_services/roles/agents/agent-role.service.ts +++ b/src/app/core/_services/roles/agents/agent-role.service.ts @@ -20,7 +20,7 @@ export class AgentRoleService extends RoleService { readChunk: [Perm.Task.READ, Perm.TaskWrapper.READ, Perm.Chunk.READ], readAccessGroup: [Perm.GroupAccess.READ], readError: [Perm.AgentError.READ], - create: [Perm.Agent.CREATE, Perm.Agent.READ, Perm.Voucher.READ, Perm.AgentBinary.READ], + create: [Perm.Agent.CREATE, Perm.Agent.READ, Perm.Voucher.READ], update: [Perm.Agent.UPDATE], updateAssignment: [Perm.AgentAssignment.UPDATE, Perm.AgentAssignment.READ, Perm.Task.READ, Perm.TaskWrapper.READ] }); diff --git a/src/app/core/_services/roles/hashlists/hashlist-role.service.ts b/src/app/core/_services/roles/hashlists/hashlist-role.service.ts index 15f8a6aa2..5eddaea6b 100644 --- a/src/app/core/_services/roles/hashlists/hashlist-role.service.ts +++ b/src/app/core/_services/roles/hashlists/hashlist-role.service.ts @@ -14,7 +14,7 @@ import { RoleService } from '@services/roles/base/role.service'; export class HashListRoleService extends RoleService { constructor(permissionService: PermissionService) { super(permissionService, { - create: [Perm.Hashlist.CREATE, Perm.Hashtype.READ, Perm.GroupAccess.READ], + create: [Perm.Hashlist.CREATE, Perm.Hashtype.READ], read: [Perm.Hashlist.READ], update: [Perm.Hashlist.UPDATE], tasks: [Perm.TaskWrapper.READ], diff --git a/src/app/files/new-files/new-files.component.spec.ts b/src/app/files/new-files/new-files.component.spec.ts index b37c0c763..28465365d 100644 --- a/src/app/files/new-files/new-files.component.spec.ts +++ b/src/app/files/new-files/new-files.component.spec.ts @@ -1,5 +1,6 @@ -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; +import { HttpHeaders } from '@angular/common/http'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ReactiveFormsModule } from '@angular/forms'; import { MatIconModule } from '@angular/material/icon'; @@ -208,17 +209,60 @@ describe('NewFilesComponent', () => { }); describe('Access group scoping', () => { - it('should fetch access groups via getRelationships for the current user, not getAll', () => { + it('should fetch access groups via getRelationships with X-Skip-Error-Dialog header', () => { setup('wordlist-new'); const gs = TestBed.inject(GlobalService) as unknown as MockGlobalService; - // Component must use getRelationships to get user-scoped access groups - expect(gs.getRelationships).toHaveBeenCalledWith(SERV.USERS, 1, RelationshipType.ACCESSGROUPS); + const callArgs = gs.getRelationships.calls.mostRecent().args; + expect(callArgs[0]).toEqual(SERV.USERS); + expect(callArgs[1]).toBe(1); + expect(callArgs[2]).toBe(RelationshipType.ACCESSGROUPS); + expect(callArgs[3]).toBeDefined(); + expect(callArgs[3].headers).toBeInstanceOf(HttpHeaders); + expect(callArgs[3].headers.get('X-Skip-Error-Dialog')).toBe('true'); // getAll must NOT be called — the component should not fetch all access groups expect(gs.getAll).not.toHaveBeenCalled(); }); + it('should fall back to default access group when getRelationships returns error (403)', async () => { + TestBed.overrideProvider(GlobalService, { + useValue: { + ...new MockGlobalService(), + getRelationships: jasmine + .createSpy('getRelationships') + .and.returnValue(throwError(() => new Error('403 Forbidden'))), + userId: 1 + } + }); + TestBed.overrideProvider(ActivatedRoute, { + useValue: { data: of({ kind: 'wordlist-new' }) } + }); + + fixture = TestBed.createComponent(NewFilesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component.selectAccessgroup.length).toBe(1); + expect(component.selectAccessgroup[0]).toEqual({ id: 1, name: 'Default' }); + expect(component.form.get('accessGroupId')!.value).toBe(1); + expect(component.form.get('accessGroupId')!.disabled).toBeTrue(); + expect(component.isLoading).toBeFalse(); + }); + + it('should fall back to default access group when response has empty data', async () => { + // Default mock already returns { data: [], included: [] } → empty → triggers fallback + setup('wordlist-new'); + await fixture.whenStable(); + + expect(component.selectAccessgroup.length).toBe(1); + expect(component.selectAccessgroup[0]).toEqual({ id: 1, name: 'Default' }); + expect(component.form.get('accessGroupId')!.value).toBe(1); + expect(component.form.get('accessGroupId')!.disabled).toBeTrue(); + expect(component.isLoading).toBeFalse(); + }); + it('should correctly transform access group API data to select options', () => { // Simulate deserialized access groups (what JsonAPISerializer would produce) const deserialized = [ diff --git a/src/app/files/new-files/new-files.component.ts b/src/app/files/new-files/new-files.component.ts index 9daedcf2b..ed21f6c2e 100644 --- a/src/app/files/new-files/new-files.component.ts +++ b/src/app/files/new-files/new-files.component.ts @@ -1,6 +1,7 @@ import { zAccessGroupListResponse } from '@generated/api/zod'; import { Subject, firstValueFrom, takeUntil } from 'rxjs'; +import { HttpHeaders } from '@angular/common/http'; import { ChangeDetectorRef, Component, OnDestroy, OnInit, inject } from '@angular/core'; import { FormGroup, Validators } from '@angular/forms'; import { MatCheckboxChange } from '@angular/material/checkbox'; @@ -176,23 +177,33 @@ export class NewFilesComponent implements OnInit, OnDestroy { */ async loadData() { this.isLoading = true; + const skipErrorHeaders = { headers: new HttpHeaders({ 'X-Skip-Error-Dialog': 'true' }) }; try { const response: ResponseWrapper = await firstValueFrom( - this.gs.getRelationships(SERV.USERS, this.gs.userId!, RelationshipType.ACCESSGROUPS) + this.gs.getRelationships(SERV.USERS, this.gs.userId!, RelationshipType.ACCESSGROUPS, skipErrorHeaders) ); const accessGroups: JAccessGroup[] = new JsonAPISerializer().deserialize(response, zAccessGroupListResponse); this.selectAccessgroup = transformSelectOptions(accessGroups, ACCESS_GROUP_FIELD_MAPPING); - } catch (error) { - console.error('Error fetching access groups:', error); + if (!this.selectAccessgroup || this.selectAccessgroup.length === 0) { + this.setDefaultAccessGroup(); + } + } catch { + this.setDefaultAccessGroup(); } finally { this.isLoading = false; this.changeDetectorRef.detectChanges(); } } + private setDefaultAccessGroup(): void { + this.selectAccessgroup = [{ id: 1, name: 'Default' }]; + this.form.patchValue({ accessGroupId: 1 }); + this.form.get('accessGroupId')?.disable(); + } + /** * Loads the list of files available on the server in the import directory. */ @@ -217,7 +228,7 @@ export class NewFilesComponent implements OnInit, OnDestroy { */ async onSubmit(): Promise { if (this.form.valid && !this.submitted) { - const form = this.onBeforeSubmit(this.form.value as FormGroup['value'], false); + const form = this.onBeforeSubmit(this.form.getRawValue(), false); this.isCreatingLoading = true; this.submitted = true; @@ -227,7 +238,7 @@ export class NewFilesComponent implements OnInit, OnDestroy { await firstValueFrom(this.gs.create(SERV.FILES, form.update)); // After successful creation, update form and show alert - this.onBeforeSubmit(this.form.value, true); + this.onBeforeSubmit(this.form.getRawValue(), true); this.alert.showSuccessMessage('New File created'); this.isCreatingLoading = false; this.submitted = false; @@ -293,7 +304,6 @@ export class NewFilesComponent implements OnInit, OnDestroy { sourceType: type, sourceData: '' }); - this.updateValidatorsBySourceType(type); // Load server import directory files only when switching to tab3 if (view === 'tab3' && this.serverFiles.length === 0) { @@ -332,50 +342,22 @@ export class NewFilesComponent implements OnInit, OnDestroy { this.alert.showErrorMessage('Please select a file to upload.'); return; } - const form = this.onBeforeSubmit(this.form.value as FormGroup['value'], false); + const form = this.onBeforeSubmit(this.form.getRawValue(), false); this.isCreatingLoading = true; for (let i = 0; i < files.length; i++) { this.uploadService .uploadFile(files[0], files[0].name, SERV.FILES, form.update, ['/files', this.redirect]) .pipe(takeUntil(this.fileUnsubscribe)) - .subscribe({ - next: (progress) => { - this.uploadProgress = progress; - this.changeDetectorRef.detectChanges(); - if (this.uploadProgress === 100) { - this.isCreatingLoading = false; - } - }, - error: (error) => { - this.uploadProgress = 0; + .subscribe((progress) => { + this.uploadProgress = progress; + this.changeDetectorRef.detectChanges(); + if (this.uploadProgress === 100) { this.isCreatingLoading = false; - this.alert.showErrorMessage(this.buildUploadErrorMessage(error)); - this.changeDetectorRef.detectChanges(); } }); } } - private buildUploadErrorMessage(error: unknown): string { - if (typeof error === 'string') { - return `Failed to upload file: ${error}`; - } - - if (error && typeof error === 'object') { - const errorObject = error as { error?: { title?: string; message?: string }; message?: string }; - const backendMessage = errorObject.error?.title || errorObject.error?.message; - if (backendMessage) { - return `Failed to upload file: ${backendMessage}`; - } - - if (errorObject.message) { - return `Failed to upload file: ${errorObject.message}`; - } - } - - return 'Failed to upload file.'; - } - /** * Handles the submission of selected server files for import. * @protected @@ -383,7 +365,6 @@ export class NewFilesComponent implements OnInit, OnDestroy { protected async onSubmitServerImport() { if (this.selectedServerFiles.size === 0) return; if (this.submitted) return; - if (this.form.invalid) return; this.isCreatingLoading = true; this.submitted = true; @@ -393,9 +374,9 @@ export class NewFilesComponent implements OnInit, OnDestroy { // Build form data for each server file const payload = { filename: file, - isSecret: this.form.value.isSecret || false, + isSecret: this.form.getRawValue().isSecret || false, fileType: this.filterType, - accessGroupId: this.form.value.accessGroupId, + accessGroupId: this.form.getRawValue().accessGroupId, sourceType: 'import', // IMPORTANT: tells backend to pick it from server import dir sourceData: file // some backends require this too }; @@ -408,9 +389,7 @@ export class NewFilesComponent implements OnInit, OnDestroy { await Promise.all(requests); this.alert.showSuccessMessage('Server files imported successfully!'); - // Reload server files - await this.loadServerFiles(); - this.selectedServerFiles.clear(); + void this.router.navigate(['/files', this.redirect]); } catch (error) { console.error('Error importing server files:', error); this.alert.showErrorMessage('Could not import selected files.'); diff --git a/src/app/hashlists/new-hashlist/new-hashlist.component.spec.ts b/src/app/hashlists/new-hashlist/new-hashlist.component.spec.ts index afb90c61f..a81fc6e93 100644 --- a/src/app/hashlists/new-hashlist/new-hashlist.component.spec.ts +++ b/src/app/hashlists/new-hashlist/new-hashlist.component.spec.ts @@ -1,5 +1,6 @@ -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; +import { HttpHeaders } from '@angular/common/http'; import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { ReactiveFormsModule } from '@angular/forms'; import { MatDialog, MatDialogRef } from '@angular/material/dialog'; @@ -245,7 +246,7 @@ describe('NewHashlistComponent', () => { tick(); const expectedPayload = { - ...component.form.value, + ...component.form.getRawValue(), sourceType: 'import', sourceData: 'hashes.txt' }; @@ -321,9 +322,47 @@ describe('NewHashlistComponent', () => { }); describe('Access group scoping', () => { - it('should fetch access groups via getRelationships for the current user, not getAll', () => { - expect(gsSpy.getRelationships).toHaveBeenCalledWith(SERV.USERS, 1, RelationshipType.ACCESSGROUPS); + it('should fetch access groups via getRelationships with X-Skip-Error-Dialog header', () => { + const callArgs = gsSpy.getRelationships.calls.mostRecent().args; + expect(callArgs[0]).toEqual(SERV.USERS); + expect(callArgs[1]).toBe(1); + expect(callArgs[2]).toBe(RelationshipType.ACCESSGROUPS); + expect(callArgs[3]!).toBeDefined(); + expect(callArgs[3]!.headers).toBeInstanceOf(HttpHeaders); + expect(callArgs[3]!.headers!.get('X-Skip-Error-Dialog')).toBe('true'); + + // getAll must NOT be called for access groups expect(gsSpy.getAll).not.toHaveBeenCalledWith(SERV.ACCESS_GROUPS); }); + + it('should fall back to default access group when getRelationships returns error (403)', () => { + // Re-create component with error response + gsSpy.getRelationships.and.returnValue(throwError(() => new Error('403 Forbidden'))); + fixture = TestBed.createComponent(NewHashlistComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component.selectAccessgroup.length).toBe(1); + expect(component.selectAccessgroup[0]).toEqual({ id: 1, name: 'Default' }); + expect(component.form.get('accessGroupId')!.value).toBe(1); + expect(component.form.get('accessGroupId')!.disabled).toBeTrue(); + expect(component.isLoadingAccessGroups).toBeFalse(); + }); + + it('should fall back to default access group when response has empty data', () => { + // Re-create component with empty access groups (full ResponseWrapper shape for Zod validation) + gsSpy.getRelationships.and.returnValue( + of({ jsonapi: { version: '1.1', ext: [] }, data: [], included: [] } as any) + ); + fixture = TestBed.createComponent(NewHashlistComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component.selectAccessgroup.length).toBe(1); + expect(component.selectAccessgroup[0]).toEqual({ id: 1, name: 'Default' }); + expect(component.form.get('accessGroupId')!.value).toBe(1); + expect(component.form.get('accessGroupId')!.disabled).toBeTrue(); + expect(component.isLoadingAccessGroups).toBeFalse(); + }); }); }); diff --git a/src/app/hashlists/new-hashlist/new-hashlist.component.ts b/src/app/hashlists/new-hashlist/new-hashlist.component.ts index 617de92f7..7b0d323ea 100644 --- a/src/app/hashlists/new-hashlist/new-hashlist.component.ts +++ b/src/app/hashlists/new-hashlist/new-hashlist.component.ts @@ -4,6 +4,7 @@ import { zAccessGroupListResponse, zConfigResponse, zHashTypeListResponse } from '@generated/api/zod'; import { Subject, Subscription, firstValueFrom, takeUntil } from 'rxjs'; +import { HttpHeaders } from '@angular/common/http'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit, inject } from '@angular/core'; import { FormGroup } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; @@ -166,13 +167,24 @@ export class NewHashlistComponent implements OnInit, OnDestroy { */ loadData(): void { this.loadConfigs(); + const skipErrorHeaders = { headers: new HttpHeaders({ 'X-Skip-Error-Dialog': 'true' }) }; const accessGroupSubscription = this.gs - .getRelationships(SERV.USERS, this.gs.userId!, RelationshipType.ACCESSGROUPS) - .subscribe((response: ResponseWrapper) => { - const accessGroups: JAccessGroup[] = new JsonAPISerializer().deserialize(response, zAccessGroupListResponse); - this.selectAccessgroup = transformSelectOptions(accessGroups, ACCESS_GROUP_FIELD_MAPPING); - this.isLoadingAccessGroups = false; - this.changeDetectorRef.detectChanges(); + .getRelationships(SERV.USERS, this.gs.userId!, RelationshipType.ACCESSGROUPS, skipErrorHeaders) + .subscribe({ + next: (response: ResponseWrapper) => { + const accessGroups: JAccessGroup[] = new JsonAPISerializer().deserialize(response, zAccessGroupListResponse); + this.selectAccessgroup = transformSelectOptions(accessGroups, ACCESS_GROUP_FIELD_MAPPING); + if (!this.selectAccessgroup || this.selectAccessgroup.length === 0) { + this.setDefaultAccessGroup(); + } + this.isLoadingAccessGroups = false; + this.changeDetectorRef.detectChanges(); + }, + error: () => { + this.setDefaultAccessGroup(); + this.isLoadingAccessGroups = false; + this.changeDetectorRef.detectChanges(); + } }); this.unsubscribeService.add(accessGroupSubscription); @@ -185,6 +197,12 @@ export class NewHashlistComponent implements OnInit, OnDestroy { this.unsubscribeService.add(hashtypesSubscription$); } + private setDefaultAccessGroup(): void { + this.selectAccessgroup = [{ id: 1, name: 'Default' }]; + this.form.patchValue({ accessGroupId: 1 }); + this.form.get('accessGroupId')?.disable(); + } + get sourceType() { return this.form.controls.sourceType.value; }