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