diff --git a/frontend/angular.json b/frontend/angular.json index bf4ae783b..f6c38e8b9 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -27,6 +27,11 @@ "glob": "**/*", "input": "./node_modules/monaco-editor/min", "output": "./assets/monaco" + }, + { + "glob": "cedar_wasm_bg.wasm", + "input": "./node_modules/@cedar-policy/cedar-wasm/web", + "output": "./assets/cedar-wasm" } ], "styles": [ diff --git a/frontend/package.json b/frontend/package.json index 10c6212b8..02730fe23 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,7 @@ "@angular/platform-browser-dynamic": "~20.3.16", "@angular/router": "~20.3.16", "@brumeilde/ngx-theme": "^1.2.1", + "@cedar-policy/cedar-wasm": "^4.9.1", "@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/noto-sans": "^5.2.10", "@jsonurl/jsonurl": "^1.1.8", diff --git a/frontend/src/app/components/connect-db/connect-db.component.html b/frontend/src/app/components/connect-db/connect-db.component.html index fc00cb029..cbb775e47 100644 --- a/frontend/src/app/components/connect-db/connect-db.component.html +++ b/frontend/src/app/components/connect-db/connect-db.component.html @@ -333,7 +333,7 @@

-
+
+ Report a bug @@ -147,7 +147,7 @@

Rocketadmin can not find any tables

Log changes in tables
-
+
+ Report a bug @@ -89,7 +89,6 @@

Rocketadmin can not find any tables

[connectionID]="connectionID" [selectedTable]="selectedTableName" [uiSettings]="uiSettings" - [accessLevel]="currentConnectionAccessLevel" (expandSidebar)="toggleSideBar()"> @@ -97,7 +96,7 @@

Rocketadmin can not find any tables

-
+
auto_awesome
New: AI Configuration @@ -122,7 +121,6 @@

Rocketadmin can not find any tables

[selection]="selection" [connectionID]="connectionID" [isTestConnection]="currentConnectionIsTest" - [accessLevel]="currentConnectionAccessLevel" [tableFolders]="tableFolders" (openFilters)="openTableFilters($event)" (removeFilter)="removeFilter($event)" diff --git a/frontend/src/app/components/dashboard/dashboard.component.spec.ts b/frontend/src/app/components/dashboard/dashboard.component.spec.ts index a9c869990..1f8f135d2 100644 --- a/frontend/src/app/components/dashboard/dashboard.component.spec.ts +++ b/frontend/src/app/components/dashboard/dashboard.component.spec.ts @@ -24,6 +24,7 @@ describe('DashboardComponent', () => { return AccessLevel.None; }, getTablesFolders: () => of([]), + canEditConnection: () => false, }; const fakeRouter = { navigate: vi.fn().mockReturnValue(Promise.resolve('')), diff --git a/frontend/src/app/components/dashboard/dashboard.component.ts b/frontend/src/app/components/dashboard/dashboard.component.ts index 4179439f3..a2ca9e898 100644 --- a/frontend/src/app/components/dashboard/dashboard.component.ts +++ b/frontend/src/app/components/dashboard/dashboard.component.ts @@ -117,6 +117,8 @@ export class DashboardComponent implements OnInit, OnDestroy { private angulartics2: Angulartics2, ) {} + protected canEditConnection = () => this._connections.canEditConnection(); + get currentConnectionAccessLevel() { return this._connections.currentConnectionAccessLevel; } @@ -166,66 +168,68 @@ export class DashboardComponent implements OnInit, OnDestroy { getData() { console.log('getData'); - this._tables.fetchTablesFolders(this.connectionID).subscribe((res) => { - const tables = res.find((item) => item.category_id === 'all-tables-kitten')?.tables || []; - - this.tableFolders = res; - - if (tables && tables.length === 0) { - this.noTablesError = true; - this.loading = false; - this.title.setTitle(`No tables | ${this._company.companyTabTitle || 'Rocketadmin'}`); - } else if (tables) { - this.formatTableNames(); - this.route.paramMap - .pipe( - map((params: ParamMap) => { - let tableName = params.get('table-name'); - if (tableName) { - this.selectedTableName = tableName; - this.setTable(tableName); - console.log('setTable from getData paramMap'); - this.title.setTitle( - `${this.selectedTableDisplayName} table | ${this._company.companyTabTitle || 'Rocketadmin'}`, - ); - this.selection.clear(); - } else { - if (this.defaultTableToOpen) { - tableName = this.defaultTableToOpen; + this._tables.fetchTablesFolders(this.connectionID).subscribe( + (res) => { + const tables = res.find((item) => item.category_id === 'all-tables-kitten')?.tables || []; + + this.tableFolders = res; + + if (tables && tables.length === 0) { + this.noTablesError = true; + this.loading = false; + this.title.setTitle(`No tables | ${this._company.companyTabTitle || 'Rocketadmin'}`); + } else if (tables) { + this.formatTableNames(); + this.route.paramMap + .pipe( + map((params: ParamMap) => { + let tableName = params.get('table-name'); + if (tableName) { + this.selectedTableName = tableName; + this.setTable(tableName); + console.log('setTable from getData paramMap'); + this.title.setTitle( + `${this.selectedTableDisplayName} table | ${this._company.companyTabTitle || 'Rocketadmin'}`, + ); + this.selection.clear(); } else { - tableName = this.allTables[0]?.table; + if (this.defaultTableToOpen) { + tableName = this.defaultTableToOpen; + } else { + tableName = this.allTables[0]?.table; + } + this.router.navigate([`/dashboard/${this.connectionID}/${tableName}`], { replaceUrl: true }); + this.selectedTableName = tableName; } - this.router.navigate([`/dashboard/${this.connectionID}/${tableName}`], { replaceUrl: true }); - this.selectedTableName = tableName; - } - }), - ) - .subscribe(); - this._tableRow.cast.subscribe((arg) => { - if (arg === 'delete row' && this.selectedTableName) { - this.setTable(this.selectedTableName); - console.log('setTable from getData _tableRow cast'); - this.selection.clear(); - } - }); - this._tables.cast.subscribe((arg) => { - if ((arg === 'delete rows' || arg === 'import') && this.selectedTableName) { - this.setTable(this.selectedTableName); - console.log('setTable from getData _tables cast'); - this.selection.clear(); - } - if (arg === 'activate actions') { - this.selection.clear(); - } - }); - } - }, - (err) => { - this.isServerError = true; - this.serverError = { abstract: err.error?.message || err.message, details: err.error?.originalMessage }; - this.loading = false; - this.title.setTitle(`Error | ${this._company.companyTabTitle || 'Rocketadmin'}`); - }); + }), + ) + .subscribe(); + this._tableRow.cast.subscribe((arg) => { + if (arg === 'delete row' && this.selectedTableName) { + this.setTable(this.selectedTableName); + console.log('setTable from getData _tableRow cast'); + this.selection.clear(); + } + }); + this._tables.cast.subscribe((arg) => { + if ((arg === 'delete rows' || arg === 'import') && this.selectedTableName) { + this.setTable(this.selectedTableName); + console.log('setTable from getData _tables cast'); + this.selection.clear(); + } + if (arg === 'activate actions') { + this.selection.clear(); + } + }); + } + }, + (err) => { + this.isServerError = true; + this.serverError = { abstract: err.error?.message || err.message, details: err.error?.originalMessage }; + this.loading = false; + this.title.setTitle(`Error | ${this._company.companyTabTitle || 'Rocketadmin'}`); + }, + ); } formatTableNames() { diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-view.component.html b/frontend/src/app/components/dashboard/db-table-view/db-table-view.component.html index 299cb757b..0d0e546c8 100644 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-view.component.html +++ b/frontend/src/app/components/dashboard/db-table-view/db-table-view.component.html @@ -20,7 +20,7 @@

{{ displayName }}

-
@@ -32,11 +32,11 @@

{{ displayName }}

{{action.title}} - - @@ -70,7 +70,7 @@

{{ displayName }}

AI insights -
{{ displayName }}

Filter
-
-
@@ -207,7 +209,6 @@

{{ displayName }}

[tableWidgets]="tableData.widgets" [tableForeignKeys]="tableData.foreignKeys" [tableTypes]="tableData.tableTypes" - [accessLevel]="accessLevel" (filterSelected)="onFilterSelected($event)" > @@ -220,9 +221,9 @@

{{ displayName }}

+ [style.--lastColumnWidth]="actionsColumnWidth"> @@ -249,7 +250,7 @@

{{ displayName }}

{{ tableData.dataNormalizedColumns[column] }} -
- - -
-
{{ displayName }} (click)="stashUrlParams(); posthog.capture('Dashboard: edit row is clicked')"> create - {{ displayName }} (click)="stashUrlParams(); posthog.capture('Dashboard: duplicate row is clicked')"> difference - - diff --git a/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-panel.component.spec.ts b/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-panel.component.spec.ts index 0276d39b4..ebb68d033 100644 --- a/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-panel.component.spec.ts +++ b/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-panel.component.spec.ts @@ -79,6 +79,7 @@ describe('SavedFiltersPanelComponent', () => { get currentConnection() { return { type: 'postgres' }; }, + canEditConnection: () => false, }; await TestBed.configureTestingModule({ diff --git a/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-panel.component.ts b/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-panel.component.ts index 6b38723f9..7f46838cd 100644 --- a/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-panel.component.ts +++ b/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-panel.component.ts @@ -21,7 +21,6 @@ import { filterTypes } from 'src/app/consts/filter-types'; import { UIwidgets } from 'src/app/consts/record-edit-types'; import { normalizeTableName } from 'src/app/lib/normalize'; import { TableField, TableForeignKey } from 'src/app/models/table'; -import { AccessLevel } from 'src/app/models/user'; import { ConnectionsService } from 'src/app/services/connections.service'; import { TablesService } from 'src/app/services/tables.service'; import { SavedFiltersDialogComponent } from './saved-filters-dialog/saved-filters-dialog.component'; @@ -60,7 +59,7 @@ export class SavedFiltersPanelComponent implements OnInit, OnDestroy { @Output() filterSelected = new EventEmitter(); @Input() resetSelection: boolean = false; - @Input() accessLevel: AccessLevel; + protected canEditConnection = () => this._connections.canEditConnection(); private dynamicColumnValueDebounceTimer: any = null; diff --git a/frontend/src/app/components/dashboard/db-tables-data-source.ts b/frontend/src/app/components/dashboard/db-tables-data-source.ts index 910949816..59422650c 100644 --- a/frontend/src/app/components/dashboard/db-tables-data-source.ts +++ b/frontend/src/app/components/dashboard/db-tables-data-source.ts @@ -10,7 +10,6 @@ import { normalizeFieldName } from 'src/app/lib/normalize'; import { getTableTypes } from 'src/app/lib/setup-table-row-structure'; import { Alert, AlertActionType, AlertType } from 'src/app/models/alert'; import { CustomAction, CustomActionType, CustomEvent, TableField, TableForeignKey, Widget } from 'src/app/models/table'; -import { AccessLevel } from 'src/app/models/user'; import { ConnectionsService } from 'src/app/services/connections.service'; import { TableRowService } from 'src/app/services/table-row.service'; import { TablesService } from 'src/app/services/tables.service'; @@ -68,14 +67,12 @@ export class TablesDataSource implements DataSource { referenced_on_column_name: '', referenced_by: [], }; - public permissions; public isExportAllowed: boolean; public isImportAllowed: boolean; public canDelete: boolean; public isEmptyTable: boolean; public tableActions: CustomAction[]; public tableBulkActions: CustomAction[]; - public actionsColumnWidth: string; public largeDataset: boolean; public identityColumn: string; @@ -85,7 +82,7 @@ export class TablesDataSource implements DataSource { constructor( private _tables: TablesService, - private _connections: ConnectionsService, + _connections: ConnectionsService, private _tableRow: TableRowService, ) {} @@ -207,7 +204,7 @@ export class TablesDataSource implements DataSource { try { parsedParams = JSON5.parse(widget.widget_params); - } catch (error) { + } catch (_error) { parsedParams = {}; } @@ -267,21 +264,16 @@ export class TablesDataSource implements DataSource { }); this.dataColumns = this.columns.map((column) => column.title); - this.dataNormalizedColumns = this.columns.reduce( - (normalizedColumns, column) => ( - (normalizedColumns[column.title] = column.normalizedTitle), normalizedColumns - ), - {}, - ); + this.dataNormalizedColumns = this.columns.reduce((normalizedColumns, column) => { + normalizedColumns[column.title] = column.normalizedTitle; + return normalizedColumns; + }, {}); this.displayedDataColumns = filter(this.columns, (column) => column.selected === true).map( (column) => column.title, ); - this.permissions = res.table_permissions.accessLevel; if (this.keyAttributes.length) { - this.actionsColumnWidth = this.getActionsColumnWidth(this.tableActions, this.permissions); this.displayedColumns = ['select', ...this.displayedDataColumns, 'actions']; } else { - this.actionsColumnWidth = '0'; this.displayedColumns = [...this.displayedDataColumns]; this.alert_primaryKeysInfo = { id: 10000, @@ -314,12 +306,7 @@ export class TablesDataSource implements DataSource { } const widgetsConfigured = res.widgets?.length; - if ( - !res.configured && - !widgetsConfigured && - this._connections.connectionAccessLevel !== AccessLevel.None && - this._connections.connectionAccessLevel !== AccessLevel.Readonly - ) + if (!res.configured && !widgetsConfigured) this.alert_settingsInfo = { id: 10001, type: AlertType.Info, @@ -350,14 +337,7 @@ export class TablesDataSource implements DataSource { } } - getActionsColumnWidth(actions, permissions) { - const defaultActionsCount = permissions.edit + permissions.add + (!!permissions.delete && !!this.canDelete); - const totalActionsCount = actions.length + defaultActionsCount; - const lengthValue = totalActionsCount * 30 + 32; - return totalActionsCount === 0 ? '0' : `${lengthValue}px`; - } - - changleColumnList(connectionId: string, tableName: string) { + changleColumnList(_connectionId: string, _tableName: string) { this.displayedDataColumns = filter(this.columns, (column) => column.selected === true).map( (column) => column.title, ); diff --git a/frontend/src/app/components/dashboard/db-tables-list/db-tables-list.component.html b/frontend/src/app/components/dashboard/db-tables-list/db-tables-list.component.html index 7d7003c09..82497fc66 100644 --- a/frontend/src/app/components/dashboard/db-tables-list/db-tables-list.component.html +++ b/frontend/src/app/components/dashboard/db-tables-list/db-tables-list.component.html @@ -59,7 +59,7 @@ -
+
-
- @@ -95,10 +95,10 @@
+ [class.drag-over]="canEditConnection() && dragOverFolder !== 'all-tables-kitten' && dragOverFolder === folder.id" + (dragover)="canEditConnection() && onFolderDragOver($event, folder.id)" + (dragleave)="canEditConnection() && onFolderDragLeave($event, folder.id)" + (drop)="canEditConnection() && onFolderDrop($event, folder)">
@@ -115,7 +115,7 @@
- - @@ -157,9 +157,9 @@ routerLink="/dashboard/{{connectionID}}/{{table.table}}" [queryParams]="{page_index: 0, page_size: 30}" [ngClass]="{'table-link_active': selectedTable === table.table}" - [draggable]="accessLevel === 'edit'" - (dragstart)="accessLevel === 'edit' && onTableDragStart($event, table)" - (dragend)="accessLevel === 'edit' && onTableDragEnd($event)" + [draggable]="canEditConnection()" + (dragstart)="canEditConnection() && onTableDragStart($event, table)" + (dragend)="canEditConnection() && onTableDragEnd($event)" (click)="closeSidebar()"> {{getTableName(table)}} @@ -169,7 +169,7 @@

No tables in this folder

- - - + @if (editorMode() === 'form' && !formParseError() && !loading() && !policyList?.showAddForm) { + + } diff --git a/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.spec.ts b/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.spec.ts index e3541f92d..8a1dc80e9 100644 --- a/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.spec.ts +++ b/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.spec.ts @@ -8,11 +8,27 @@ import { provideRouter } from '@angular/router'; import { CodeEditorModule } from '@ngstack/code-editor'; import { Angulartics2Module } from 'angulartics2'; import { of } from 'rxjs'; +import { CedarValidatorService } from 'src/app/services/cedar-validator.service'; import { DashboardsService } from 'src/app/services/dashboards.service'; import { TablesService } from 'src/app/services/tables.service'; +import { UsersService } from 'src/app/services/users.service'; import { MockCodeEditorComponent } from 'src/app/testing/code-editor.mock'; import { CedarPolicyEditorDialogComponent } from './cedar-policy-editor-dialog.component'; +type CedarPolicyEditorTestable = CedarPolicyEditorDialogComponent & { + // biome-ignore lint/suspicious/noExplicitAny: test helper type for accessing protected signals + allTables: ReturnType>; + // biome-ignore lint/suspicious/noExplicitAny: test helper type for accessing protected signals + availableTables: ReturnType>; + loading: ReturnType>; + // biome-ignore lint/suspicious/noExplicitAny: test helper type for accessing protected signals + policyItems: ReturnType>; + editorMode: ReturnType>; + cedarPolicy: ReturnType>; + validationErrors: ReturnType>; + submitting: ReturnType>; +}; + describe('CedarPolicyEditorDialogComponent', () => { let component: CedarPolicyEditorDialogComponent; let fixture: ComponentFixture; @@ -47,7 +63,16 @@ describe('CedarPolicyEditorDialogComponent', () => { ');', ].join('\n'); + const mockUsersService: Partial = { + saveCedarPolicy: vi.fn().mockResolvedValue(undefined), + }; + + let mockCedarValidator: Partial; + beforeEach(() => { + mockCedarValidator = { + validate: vi.fn().mockResolvedValue({ valid: true, errors: [] }), + }; dashboardsService = { dashboards: signal([ { id: 'dash-1', name: 'Sales', description: null, connection_id: 'conn-123', created_at: '', updated_at: '' }, @@ -72,6 +97,8 @@ describe('CedarPolicyEditorDialogComponent', () => { }, { provide: MatDialogRef, useValue: mockDialogRef }, { provide: DashboardsService, useValue: dashboardsService }, + { provide: UsersService, useValue: mockUsersService }, + { provide: CedarValidatorService, useValue: mockCedarValidator }, ], }) .overrideComponent(CedarPolicyEditorDialogComponent, { @@ -93,24 +120,87 @@ describe('CedarPolicyEditorDialogComponent', () => { }); it('should load tables on init', () => { + const testable = component as unknown as CedarPolicyEditorTestable; expect(tablesService.fetchTables).toHaveBeenCalled(); - expect(component.allTables.length).toBe(2); - expect(component.availableTables.length).toBe(2); - expect(component.loading).toBe(false); + expect(testable.allTables().length).toBe(2); + expect(testable.availableTables().length).toBe(2); + expect(testable.loading()).toBe(false); }); it('should pre-populate policy items from existing cedar policy', () => { - expect(component.policyItems.length).toBeGreaterThan(0); - expect(component.policyItems.some((item) => item.action === 'connection:read')).toBe(true); + const testable = component as unknown as CedarPolicyEditorTestable; + expect(testable.policyItems().length).toBeGreaterThan(0); + expect(testable.policyItems().some((item) => item.action === 'connection:read')).toBe(true); }); it('should start in form mode', () => { - expect(component.editorMode).toBe('form'); + const testable = component as unknown as CedarPolicyEditorTestable; + expect(testable.editorMode()).toBe('form'); + }); + + it('should switch to code mode', async () => { + const testable = component as unknown as CedarPolicyEditorTestable; + await component.onEditorModeChange('code'); + expect(testable.editorMode()).toBe('code'); + expect(testable.cedarPolicy()).toBeTruthy(); }); - it('should switch to code mode', () => { - component.onEditorModeChange('code'); - expect(component.editorMode).toBe('code'); - expect(component.cedarPolicy).toBeTruthy(); + it('should block save when cedar-wasm reports invalid policy', async () => { + const testable = component as unknown as CedarPolicyEditorTestable; + await component.onEditorModeChange('code'); + testable.cedarPolicy.set('invalid policy text {{{'); + (mockCedarValidator.validate as ReturnType).mockResolvedValueOnce({ + valid: false, + errors: ['unexpected token'], + }); + + await component.savePolicy(); + + expect(testable.validationErrors()).toEqual(['unexpected token']); + expect(mockUsersService.saveCedarPolicy).not.toHaveBeenCalled(); + expect(testable.submitting()).toBe(false); + }); + + it('should allow save when cedar-wasm reports valid policy', async () => { + const testable = component as unknown as CedarPolicyEditorTestable; + await component.onEditorModeChange('code'); + + await component.savePolicy(); + + expect(testable.validationErrors()).toEqual([]); + expect(mockUsersService.saveCedarPolicy).toHaveBeenCalled(); + }); + + it('should block switching to form mode when policy is invalid', async () => { + const testable = component as unknown as CedarPolicyEditorTestable; + await component.onEditorModeChange('code'); + testable.cedarPolicy.set('broken ;;;'); + (mockCedarValidator.validate as ReturnType).mockResolvedValueOnce({ + valid: false, + errors: ['parse error at line 1'], + }); + + await component.onEditorModeChange('form'); + + expect(testable.editorMode()).toBe('code'); + expect(testable.validationErrors()).toEqual(['parse error at line 1']); + }); + + it('should clear validation errors when user edits policy text', async () => { + const testable = component as unknown as CedarPolicyEditorTestable; + testable.validationErrors.set(['some old error']); + + component.onCedarPolicyChange('permit(principal, action, resource);'); + + expect(testable.validationErrors()).toEqual([]); + }); + + it('should clear validation errors when switching to code mode', async () => { + const testable = component as unknown as CedarPolicyEditorTestable; + testable.validationErrors.set(['stale error']); + + await component.onEditorModeChange('code'); + + expect(testable.validationErrors()).toEqual([]); }); }); diff --git a/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.ts b/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.ts index a10f69954..0b7952dd1 100644 --- a/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.ts +++ b/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.ts @@ -1,5 +1,4 @@ -import { NgIf } from '@angular/common'; -import { Component, DestroyRef, ElementRef, Inject, inject, OnInit, ViewChild } from '@angular/core'; +import { Component, DestroyRef, ElementRef, Inject, inject, OnInit, signal, ViewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; @@ -13,6 +12,7 @@ import { CedarPolicyItem, permissionsToPolicyItems, policyItemsToCedarPolicy } f import { canRepresentAsForm, parseCedarDashboardItems, parseCedarPolicy } from 'src/app/lib/cedar-policy-parser'; import { normalizeTableName } from 'src/app/lib/normalize'; import { TablePermission } from 'src/app/models/user'; +import { CedarValidatorService } from 'src/app/services/cedar-validator.service'; import { ConnectionsService } from 'src/app/services/connections.service'; import { DashboardsService } from 'src/app/services/dashboards.service'; import { TablesService } from 'src/app/services/tables.service'; @@ -36,7 +36,6 @@ export interface CedarPolicyEditorDialogData { templateUrl: './cedar-policy-editor-dialog.component.html', styleUrls: ['./cedar-policy-editor-dialog.component.css'], imports: [ - NgIf, FormsModule, MatDialogModule, MatButtonModule, @@ -47,17 +46,28 @@ export interface CedarPolicyEditorDialogData { ], }) export class CedarPolicyEditorDialogComponent implements OnInit { - public connectionID: string; - public cedarPolicy: string = ''; - public submitting: boolean = false; - - public editorMode: 'form' | 'code' = 'form'; - public policyItems: CedarPolicyItem[] = []; - public availableTables: AvailableTable[] = []; - public availableDashboards: AvailableDashboard[] = []; - public allTables: TablePermission[] = []; - public loading: boolean = true; - public formParseError: boolean = false; + private _connections = inject(ConnectionsService); + private _usersService = inject(UsersService); + private _uiSettings = inject(UiSettingsService); + private _tablesService = inject(TablesService); + private _dashboardsService = inject(DashboardsService); + private _editorService = inject(CodeEditorService); + private _cedarValidator = inject(CedarValidatorService); + private _destroyRef = inject(DestroyRef); + + protected connectionID: string; + protected cedarPolicy = signal(''); + protected submitting = signal(false); + + protected editorMode = signal<'form' | 'code'>('form'); + protected policyItems = signal([]); + protected availableTables = signal([]); + protected availableDashboards = signal([]); + protected allTables = signal([]); + protected loading = signal(true); + protected formParseError = signal(false); + protected validationErrors = signal([]); + protected validating = signal(false); @ViewChild(CedarPolicyListComponent) policyList?: CedarPolicyListComponent; @ViewChild('dialogContent', { read: ElementRef }) dialogContent?: ElementRef; @@ -71,39 +81,37 @@ export class CedarPolicyEditorDialogComponent implements OnInit { }; public codeEditorTheme: string; - private _destroyRef = inject(DestroyRef); - constructor( @Inject(MAT_DIALOG_DATA) public data: CedarPolicyEditorDialogData, public dialogRef: MatDialogRef, - private _connections: ConnectionsService, - private _usersService: UsersService, - private _uiSettings: UiSettingsService, - private _tablesService: TablesService, - private _dashboardsService: DashboardsService, - private _editorService: CodeEditorService, ) { this.codeEditorTheme = this._uiSettings.isDarkMode ? 'vs-dark' : 'vs'; this._editorService.loaded.pipe(take(1)).subscribe(({ monaco }) => registerCedarLanguage(monaco)); this.dialogRef.disableClose = true; - this.dialogRef.backdropClick().pipe(takeUntilDestroyed(this._destroyRef)).subscribe(() => { - this.confirmClose(); - }); - this.dialogRef.keydownEvents().pipe(takeUntilDestroyed(this._destroyRef)).subscribe((event) => { - if (event.key === 'Escape') { + this.dialogRef + .backdropClick() + .pipe(takeUntilDestroyed(this._destroyRef)) + .subscribe(() => { this.confirmClose(); - } - }); + }); + this.dialogRef + .keydownEvents() + .pipe(takeUntilDestroyed(this._destroyRef)) + .subscribe((event) => { + if (event.key === 'Escape') { + this.confirmClose(); + } + }); } ngOnInit(): void { this.connectionID = this._connections.currentConnectionID; - this.cedarPolicy = this.data.cedarPolicy || ''; + this.cedarPolicy.set(this.data.cedarPolicy || ''); this.cedarPolicyModel = { language: 'cedar', uri: `cedar-policy-${this.data.groupId}.cedar`, - value: this.cedarPolicy, + value: this.cedarPolicy(), }; this._dashboardsService.setActiveConnection(this.connectionID); @@ -112,66 +120,81 @@ export class CedarPolicyEditorDialogComponent implements OnInit { .fetchTables(this.connectionID) .pipe(takeUntilDestroyed(this._destroyRef)) .subscribe((tables) => { - this.allTables = []; - this.availableTables = []; + const newAllTables: TablePermission[] = []; + const newAvailableTables: AvailableTable[] = []; for (const t of tables) { const displayName = t.display_name || normalizeTableName(t.table); - this.allTables.push({ + newAllTables.push({ tableName: t.table, display_name: displayName, accessLevel: { visibility: false, readonly: false, add: false, delete: false, edit: false }, }); - this.availableTables.push({ tableName: t.table, displayName }); + newAvailableTables.push({ tableName: t.table, displayName }); } - this.availableDashboards = this._dashboardsService.dashboards().map((d) => ({ - id: d.id, - name: d.name, - })); + this.allTables.set(newAllTables); + this.availableTables.set(newAvailableTables); + + this.availableDashboards.set( + this._dashboardsService.dashboards().map((d) => ({ + id: d.id, + name: d.name, + })), + ); - this.loading = false; + this.loading.set(false); - if (this.cedarPolicy) { - this.formParseError = !canRepresentAsForm(this.cedarPolicy); - if (this.formParseError) { - this.editorMode = 'code'; + const policy = this.cedarPolicy(); + if (policy) { + this.formParseError.set(!canRepresentAsForm(policy)); + if (this.formParseError()) { + this.editorMode.set('code'); } else { - this.policyItems = this._parseCedarToPolicyItems(); + this.policyItems.set(this._parseCedarToPolicyItems()); } } }); } onCedarPolicyChange(value: string) { - this.cedarPolicy = value; + this.cedarPolicy.set(value); + this.validationErrors.set([]); } onPolicyItemsChange(items: CedarPolicyItem[]) { - this.policyItems = items; + this.policyItems.set(items); } - onEditorModeChange(mode: 'form' | 'code') { - if (mode === this.editorMode) return; + async onEditorModeChange(mode: 'form' | 'code') { + if (mode === this.editorMode()) return; if (mode === 'code') { - this.cedarPolicy = policyItemsToCedarPolicy(this.policyItems, this.connectionID, this.data.groupId); + this.cedarPolicy.set(policyItemsToCedarPolicy(this.policyItems(), this.connectionID, this.data.groupId)); this.cedarPolicyModel = { language: 'cedar', uri: `cedar-policy-${this.data.groupId}-${Date.now()}.cedar`, - value: this.cedarPolicy, + value: this.cedarPolicy(), }; - this.formParseError = false; + this.formParseError.set(false); + this.validationErrors.set([]); } else { - this.formParseError = !canRepresentAsForm(this.cedarPolicy); - if (this.formParseError) return; - this.policyItems = this._parseCedarToPolicyItems(); + const policy = this.cedarPolicy(); + const validation = await this._cedarValidator.validate(policy); + if (!validation.valid) { + this.validationErrors.set(validation.errors); + return; + } + this.validationErrors.set([]); + this.formParseError.set(!canRepresentAsForm(policy)); + if (this.formParseError()) return; + this.policyItems.set(this._parseCedarToPolicyItems()); } - this.editorMode = mode; + this.editorMode.set(mode); } confirmClose() { - if (this.editorMode === 'form' && this.policyList?.hasPendingChanges()) { + if (this.editorMode() === 'form' && this.policyList?.hasPendingChanges()) { const discard = confirm('You have an unsaved policy in the form. Discard it and close?'); if (!discard) return; this.policyList.discardPending(); @@ -179,37 +202,40 @@ export class CedarPolicyEditorDialogComponent implements OnInit { this.dialogRef.close(); } - savePolicy() { - if (this.editorMode === 'form' && this.policyList?.hasPendingChanges()) { + async savePolicy() { + if (this.editorMode() === 'form' && this.policyList?.hasPendingChanges()) { const discard = confirm('You have an unsaved policy in the form. Discard it and save?'); if (!discard) return; this.policyList.discardPending(); } - this.submitting = true; + this.submitting.set(true); - if (this.editorMode === 'form') { - this.cedarPolicy = policyItemsToCedarPolicy(this.policyItems, this.connectionID, this.data.groupId); + if (this.editorMode() === 'form') { + this.cedarPolicy.set(policyItemsToCedarPolicy(this.policyItems(), this.connectionID, this.data.groupId)); } - if (!this.cedarPolicy) { - this.submitting = false; + const policy = this.cedarPolicy(); + if (!policy) { + this.submitting.set(false); this.dialogRef.close(); return; } - this._usersService - .saveCedarPolicy(this.connectionID, this.data.groupId, this.cedarPolicy) - .pipe(takeUntilDestroyed(this._destroyRef)) - .subscribe({ - next: () => { - this.submitting = false; - this.dialogRef.close(); - }, - complete: () => { - this.submitting = false; - }, - }); + const validation = await this._cedarValidator.validate(policy); + if (!validation.valid) { + this.validationErrors.set(validation.errors); + this.submitting.set(false); + return; + } + this.validationErrors.set([]); + + try { + await this._usersService.saveCedarPolicy(this.connectionID, this.data.groupId, policy); + this.dialogRef.close(); + } finally { + this.submitting.set(false); + } } onAddPolicyClick() { @@ -224,8 +250,9 @@ export class CedarPolicyEditorDialogComponent implements OnInit { } private _parseCedarToPolicyItems(): CedarPolicyItem[] { - const parsed = parseCedarPolicy(this.cedarPolicy, this.connectionID, this.data.groupId, this.allTables); - const dashboardItems = parseCedarDashboardItems(this.cedarPolicy, this.connectionID); + const policy = this.cedarPolicy(); + const parsed = parseCedarPolicy(policy, this.connectionID, this.data.groupId, this.allTables()); + const dashboardItems = parseCedarDashboardItems(policy, this.connectionID); return [...permissionsToPolicyItems(parsed), ...dashboardItems]; } } diff --git a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.html b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.html index 13a13e042..14b9bd5c1 100644 --- a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.html +++ b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.html @@ -1,141 +1,179 @@
- + @if (loading()) { + + } -
- No policies defined. Add a policy to grant permissions. -
+ @if (!loading() && policies().length === 0 && !showAddForm) { +
+ No policies defined. Add a policy to grant permissions. +
+ } -
-
-
- {{ group.icon }} + @for (group of groupedPolicies(); track group.label) { +
+
+
+ {{ group.icon }} +
+ {{ group.label }} + {{ group.policies.length }} + expand_more
- {{ group.label }} - {{ group.policies.length }} - expand_more -
-
-
- - -
- {{ getActionIcon(entry.item.action) }} - {{ getShortActionLabel(entry.item.action) }} - - {{ getTableDisplayName(entry.item.tableName) }} - - - {{ getDashboardDisplayName(entry.item.dashboardId) }} - -
-
- - -
-
+ @if (!isCollapsed(group.label)) { +
+ @for (entry of group.policies; track entry.originalIndex) { +
+ @if (editingIndex !== entry.originalIndex) { + +
+ {{ getActionIcon(entry.item.action) }} + {{ getShortActionLabel(entry.item.action) }} + @if (entry.item.tableName) { + + {{ getTableDisplayName(entry.item.tableName) }} + + } + @if (entry.item.dashboardId) { + + {{ getDashboardDisplayName(entry.item.dashboardId) }} + + } +
+
+ + +
+ } @else { + +
+ + Action + + @for (actionGroup of editActionGroups; track actionGroup.group) { + + @for (action of actionGroup.actions; track action.value) { + + {{ action.label }} + + } + + } + + + + @if (editNeedsTable) { + + Table + + All tables + @for (table of availableTables(); track table.tableName) { + + {{ table.displayName }} + + } + + + } + + @if (editNeedsDashboard) { + + Dashboard + + All dashboards + @for (dashboard of availableDashboards(); track dashboard.id) { + + {{ dashboard.name }} + + } + + + } - - -
- - Action - - - +
+ + +
+
+ } +
+ } +
+ } +
+ } + + + @if (showAddForm) { +
+
+ + Action + + @for (group of addActionGroups(); track group.group) { + + @for (action of group.actions; track action.value) { + {{ action.label }} - - - + } + + } + + - - Table - - All tables - + Table + + All tables + @for (table of availableTables(); track table.tableName) { + {{ table.displayName }} - - + } + + + } - - Dashboard - - All dashboards - + Dashboard + + All dashboards + @for (dashboard of availableDashboards(); track dashboard.id) { + {{ dashboard.name }} - - - -
- - -
-
- -
-
-
- - -
-
- - Action - - - - {{ action.label }} - - - - - - - Table - - All tables - - {{ table.displayName }} - - - - - - Dashboard - - All dashboards - - {{ dashboard.name }} - - - + } + + + } -
- - +
+ + +
-
+ }
diff --git a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.spec.ts b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.spec.ts index bfd4367ea..24e4650cc 100644 --- a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.spec.ts +++ b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.spec.ts @@ -24,8 +24,8 @@ describe('CedarPolicyListComponent', () => { fixture = TestBed.createComponent(CedarPolicyListComponent); component = fixture.componentInstance; - component.availableTables = fakeTables; - component.availableDashboards = fakeDashboards; + fixture.componentRef.setInput('availableTables', fakeTables); + fixture.componentRef.setInput('availableDashboards', fakeDashboards); fixture.detectChanges(); }); @@ -34,70 +34,89 @@ describe('CedarPolicyListComponent', () => { }); it('should add a policy', () => { - const emitSpy = vi.spyOn(component.policiesChange, 'emit'); + let emitted: { action: string; tableName?: string; dashboardId?: string }[] | null = null; + component.policiesChange.subscribe((v) => (emitted = v)); + component.showAddForm = true; component.newAction = 'connection:read'; component.addPolicy(); - expect(component.policies.length).toBe(1); - expect(component.policies[0].action).toBe('connection:read'); - expect(emitSpy).toHaveBeenCalled(); + expect(emitted).not.toBeNull(); + expect(emitted!.length).toBe(1); + expect(emitted![0].action).toBe('connection:read'); expect(component.showAddForm).toBe(false); }); it('should add a table policy with tableName', () => { + let emitted: { action: string; tableName?: string; dashboardId?: string }[] | null = null; + component.policiesChange.subscribe((v) => (emitted = v)); + component.showAddForm = true; component.newAction = 'table:read'; component.newTableName = 'customers'; component.addPolicy(); - expect(component.policies.length).toBe(1); - expect(component.policies[0].action).toBe('table:read'); - expect(component.policies[0].tableName).toBe('customers'); + expect(emitted!.length).toBe(1); + expect(emitted![0].action).toBe('table:read'); + expect(emitted![0].tableName).toBe('customers'); }); it('should add a table policy with wildcard tableName', () => { + let emitted: { action: string; tableName?: string; dashboardId?: string }[] | null = null; + component.policiesChange.subscribe((v) => (emitted = v)); + component.showAddForm = true; component.newAction = 'table:edit'; component.newTableName = '*'; component.addPolicy(); - expect(component.policies.length).toBe(1); - expect(component.policies[0].action).toBe('table:edit'); - expect(component.policies[0].tableName).toBe('*'); + expect(emitted!.length).toBe(1); + expect(emitted![0].action).toBe('table:edit'); + expect(emitted![0].tableName).toBe('*'); }); it('should not add policy without action', () => { + let emitted = false; + component.policiesChange.subscribe(() => (emitted = true)); + component.showAddForm = true; component.newAction = ''; component.addPolicy(); - expect(component.policies.length).toBe(0); + expect(emitted).toBe(false); }); it('should not add table policy without table name', () => { + let emitted = false; + component.policiesChange.subscribe(() => (emitted = true)); + component.showAddForm = true; component.newAction = 'table:read'; component.newTableName = ''; component.addPolicy(); - expect(component.policies.length).toBe(0); + expect(emitted).toBe(false); }); it('should remove a policy', () => { - component.policies = [{ action: 'connection:read' }, { action: 'group:read' }]; - const emitSpy = vi.spyOn(component.policiesChange, 'emit'); + fixture.componentRef.setInput('policies', [{ action: 'connection:read' }, { action: 'group:read' }]); + fixture.detectChanges(); + + let emitted: { action: string; tableName?: string; dashboardId?: string }[] | null = null; + component.policiesChange.subscribe((v) => (emitted = v)); component.removePolicy(0); - expect(component.policies.length).toBe(1); - expect(component.policies[0].action).toBe('group:read'); - expect(emitSpy).toHaveBeenCalled(); + expect(emitted!.length).toBe(1); + expect(emitted![0].action).toBe('group:read'); }); it('should start and save edit', () => { - component.policies = [{ action: 'connection:read' }]; - const emitSpy = vi.spyOn(component.policiesChange, 'emit'); + fixture.componentRef.setInput('policies', [{ action: 'connection:read' }]); + fixture.detectChanges(); + + let emitted: { action: string; tableName?: string; dashboardId?: string }[] | null = null; + component.policiesChange.subscribe((v) => (emitted = v)); component.startEdit(0); expect(component.editingIndex).toBe(0); @@ -106,19 +125,20 @@ describe('CedarPolicyListComponent', () => { component.editAction = 'connection:edit'; component.saveEdit(0); - expect(component.policies[0].action).toBe('connection:edit'); + expect(emitted![0].action).toBe('connection:edit'); expect(component.editingIndex).toBeNull(); - expect(emitSpy).toHaveBeenCalled(); }); it('should cancel edit', () => { - component.policies = [{ action: 'connection:read' }]; + fixture.componentRef.setInput('policies', [{ action: 'connection:read' }]); + fixture.detectChanges(); + component.startEdit(0); component.editAction = 'connection:edit'; component.cancelEdit(); expect(component.editingIndex).toBeNull(); - expect(component.policies[0].action).toBe('connection:read'); + expect(component.policies()[0].action).toBe('connection:read'); }); it('should return correct action labels', () => { @@ -157,23 +177,29 @@ describe('CedarPolicyListComponent', () => { }); it('should add a dashboard policy with dashboardId', () => { + let emitted: { action: string; tableName?: string; dashboardId?: string }[] | null = null; + component.policiesChange.subscribe((v) => (emitted = v)); + component.showAddForm = true; component.newAction = 'dashboard:read'; component.newDashboardId = 'dash-1'; component.addPolicy(); - expect(component.policies.length).toBe(1); - expect(component.policies[0].action).toBe('dashboard:read'); - expect(component.policies[0].dashboardId).toBe('dash-1'); + expect(emitted!.length).toBe(1); + expect(emitted![0].action).toBe('dashboard:read'); + expect(emitted![0].dashboardId).toBe('dash-1'); }); it('should not add dashboard policy without dashboard id', () => { + let emitted = false; + component.policiesChange.subscribe(() => (emitted = true)); + component.showAddForm = true; component.newAction = 'dashboard:edit'; component.newDashboardId = ''; component.addPolicy(); - expect(component.policies.length).toBe(0); + expect(emitted).toBe(false); }); it('should detect needsDashboard correctly', () => { diff --git a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.ts b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.ts index 1cae2d0d6..1e0d9edb3 100644 --- a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.ts +++ b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.ts @@ -1,12 +1,17 @@ import { CommonModule } from '@angular/common'; -import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; +import { Component, computed, input, output } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatSelectModule } from '@angular/material/select'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { CedarPolicyItem, PolicyActionGroup, POLICY_ACTION_GROUPS, POLICY_ACTIONS } from 'src/app/lib/cedar-policy-items'; +import { + CedarPolicyItem, + POLICY_ACTION_GROUPS, + POLICY_ACTIONS, + PolicyActionGroup, +} from 'src/app/lib/cedar-policy-items'; import { ContentLoaderComponent } from '../../ui-components/content-loader/content-loader.component'; export interface AvailableTable { @@ -42,12 +47,12 @@ export interface PolicyGroup { templateUrl: './cedar-policy-list.component.html', styleUrls: ['./cedar-policy-list.component.css'], }) -export class CedarPolicyListComponent implements OnChanges { - @Input() policies: CedarPolicyItem[] = []; - @Input() availableTables: AvailableTable[] = []; - @Input() availableDashboards: AvailableDashboard[] = []; - @Input() loading: boolean = false; - @Output() policiesChange = new EventEmitter(); +export class CedarPolicyListComponent { + readonly policies = input([]); + readonly availableTables = input([]); + readonly availableDashboards = input([]); + readonly loading = input(false); + readonly policiesChange = output(); showAddForm = false; newAction = ''; @@ -61,37 +66,52 @@ export class CedarPolicyListComponent implements OnChanges { collapsedGroups = new Set(); - availableActions = POLICY_ACTIONS; + private _availableActions = POLICY_ACTIONS; - groupedPolicies: PolicyGroup[] = []; - addActionGroups: PolicyActionGroup[] = []; - editActionGroups: PolicyActionGroup[] = []; - - usedTables = new Map(); - usedDashboards = new Map(); - - ngOnChanges(changes: SimpleChanges): void { - if (changes['policies']) { - this._refreshViews(); - } - } + // Computed derived views + protected groupedPolicies = computed(() => this._computeGroupedPolicies()); + protected addActionGroups = computed(() => this._buildFilteredGroups(-1)); get needsTable(): boolean { - return this.availableActions.find((a) => a.value === this.newAction)?.needsTable ?? false; + return this._availableActions.find((a) => a.value === this.newAction)?.needsTable ?? false; } get needsDashboard(): boolean { - return this.availableActions.find((a) => a.value === this.newAction)?.needsDashboard ?? false; + return this._availableActions.find((a) => a.value === this.newAction)?.needsDashboard ?? false; } get editNeedsTable(): boolean { - return this.availableActions.find((a) => a.value === this.editAction)?.needsTable ?? false; + return this._availableActions.find((a) => a.value === this.editAction)?.needsTable ?? false; } get editNeedsDashboard(): boolean { - return this.availableActions.find((a) => a.value === this.editAction)?.needsDashboard ?? false; + return this._availableActions.find((a) => a.value === this.editAction)?.needsDashboard ?? false; } + protected usedTables = computed(() => { + const map = new Map(); + for (const p of this.policies()) { + if (p.tableName) { + const labels = map.get(p.tableName) || []; + labels.push(this._shortLabels[p.action] || p.action); + map.set(p.tableName, labels); + } + } + return map; + }); + + protected usedDashboards = computed(() => { + const map = new Map(); + for (const p of this.policies()) { + if (p.dashboardId) { + const labels = map.get(p.dashboardId) || []; + labels.push(this._shortLabels[p.action] || p.action); + map.set(p.dashboardId, labels); + } + } + return map; + }); + toggleGroup(label: string) { if (this.collapsedGroups.has(label)) { this.collapsedGroups.delete(label); @@ -104,28 +124,12 @@ export class CedarPolicyListComponent implements OnChanges { return this.collapsedGroups.has(label); } - trackByGroup(_index: number, group: PolicyGroup): string { - return group.label; - } - - trackByPolicy(_index: number, entry: { item: CedarPolicyItem; originalIndex: number }): number { - return entry.originalIndex; - } - - trackByActionGroup(_index: number, group: PolicyActionGroup): string { - return group.group; - } - - trackByAction(_index: number, action: { value: string }): string { - return action.value; - } - getTableUsedHint(tableName: string): string { - return this.usedTables.get(tableName)?.join(', ') || ''; + return this.usedTables().get(tableName)?.join(', ') || ''; } getDashboardUsedHint(dashboardId: string): string { - return this.usedDashboards.get(dashboardId)?.join(', ') || ''; + return this.usedDashboards().get(dashboardId)?.join(', ') || ''; } getActionIcon(action: string): string { @@ -137,17 +141,17 @@ export class CedarPolicyListComponent implements OnChanges { } getActionLabel(action: string): string { - return this.availableActions.find((a) => a.value === action)?.label || action; + return this._availableActions.find((a) => a.value === action)?.label || action; } getTableDisplayName(tableName: string): string { if (tableName === '*') return 'All tables'; - return this.availableTables.find((t) => t.tableName === tableName)?.displayName || tableName; + return this.availableTables().find((t) => t.tableName === tableName)?.displayName || tableName; } getDashboardDisplayName(dashboardId: string): string { if (dashboardId === '*') return 'All dashboards'; - return this.availableDashboards.find((d) => d.id === dashboardId)?.name || dashboardId; + return this.availableDashboards().find((d) => d.id === dashboardId)?.name || dashboardId; } hasPendingChanges(): boolean { @@ -164,7 +168,8 @@ export class CedarPolicyListComponent implements OnChanges { if (this.needsTable && !this.newTableName) return; if (this.needsDashboard && !this.newDashboardId) return; - const duplicate = this.policies.some((p) => { + const currentPolicies = this.policies(); + const duplicate = currentPolicies.some((p) => { if (p.action !== this.newAction) return false; if (this.needsTable) return p.tableName === this.newTableName; if (this.needsDashboard) return p.dashboardId === this.newDashboardId; @@ -179,24 +184,24 @@ export class CedarPolicyListComponent implements OnChanges { if (this.needsDashboard) { item.dashboardId = this.newDashboardId; } - this.policies = [...this.policies, item]; - this.policiesChange.emit(this.policies); + this.policiesChange.emit([...currentPolicies, item]); this.resetAddForm(); - this._refreshViews(); } removePolicy(index: number) { - this.policies = this.policies.filter((_, i) => i !== index); - this.policiesChange.emit(this.policies); - this._refreshViews(); + this.policiesChange.emit(this.policies().filter((_, i) => i !== index)); } startEdit(index: number) { + const p = this.policies()[index]; this.editingIndex = index; - this.editAction = this.policies[index].action; - this.editTableName = this.policies[index].tableName || ''; - this.editDashboardId = this.policies[index].dashboardId || ''; - this.editActionGroups = this._buildFilteredGroups(index); + this.editAction = p.action; + this.editTableName = p.tableName || ''; + this.editDashboardId = p.dashboardId || ''; + } + + get editActionGroups(): PolicyActionGroup[] { + return this._buildFilteredGroups(this.editingIndex ?? -1); } saveEdit(index: number) { @@ -204,16 +209,14 @@ export class CedarPolicyListComponent implements OnChanges { if (this.editNeedsTable && !this.editTableName) return; if (this.editNeedsDashboard && !this.editDashboardId) return; - const updated = [...this.policies]; + const updated = [...this.policies()]; updated[index] = { action: this.editAction, tableName: this.editNeedsTable ? this.editTableName : undefined, dashboardId: this.editNeedsDashboard ? this.editDashboardId : undefined, }; - this.policies = updated; - this.policiesChange.emit(this.policies); + this.policiesChange.emit(updated); this.editingIndex = null; - this._refreshViews(); } cancelEdit() { @@ -228,11 +231,35 @@ export class CedarPolicyListComponent implements OnChanges { } private _groupConfig = [ - { prefix: '*', label: 'General', description: 'Full access to everything', icon: 'admin_panel_settings', colorClass: 'general' }, - { prefix: 'connection:', label: 'Connection', description: 'Connection settings access', icon: 'cable', colorClass: 'connection' }, + { + prefix: '*', + label: 'General', + description: 'Full access to everything', + icon: 'admin_panel_settings', + colorClass: 'general', + }, + { + prefix: 'connection:', + label: 'Connection', + description: 'Connection settings access', + icon: 'cable', + colorClass: 'connection', + }, { prefix: 'group:', label: 'Group', description: 'User group management', icon: 'group', colorClass: 'group' }, - { prefix: 'table:', label: 'Table', description: 'Table data operations', icon: 'table_chart', colorClass: 'table' }, - { prefix: 'dashboard:', label: 'Dashboard', description: 'Dashboard access', icon: 'dashboard', colorClass: 'dashboard' }, + { + prefix: 'table:', + label: 'Table', + description: 'Table data operations', + icon: 'table_chart', + colorClass: 'table', + }, + { + prefix: 'dashboard:', + label: 'Dashboard', + description: 'Dashboard access', + icon: 'dashboard', + colorClass: 'dashboard', + }, ]; private _actionIcons: Record = { @@ -271,60 +298,41 @@ export class CedarPolicyListComponent implements OnChanges { 'dashboard:delete': 'Delete', }; - private _refreshViews() { - this.groupedPolicies = this._groupConfig + private _computeGroupedPolicies(): PolicyGroup[] { + const policies = this.policies(); + return this._groupConfig .map((cfg) => ({ label: cfg.label, description: cfg.description, icon: cfg.icon, colorClass: cfg.colorClass, - policies: this.policies + policies: policies .map((item, i) => ({ item, originalIndex: i })) - .filter(({ item }) => - cfg.prefix === '*' ? item.action === '*' : item.action.startsWith(cfg.prefix), - ), + .filter(({ item }) => (cfg.prefix === '*' ? item.action === '*' : item.action.startsWith(cfg.prefix))), })) .filter((g) => g.policies.length > 0); - - this.addActionGroups = this._buildFilteredGroups(-1); - - this.usedTables = new Map(); - this.usedDashboards = new Map(); - for (const p of this.policies) { - if (p.tableName) { - const labels = this.usedTables.get(p.tableName) || []; - labels.push(this._shortLabels[p.action] || p.action); - this.usedTables.set(p.tableName, labels); - } - if (p.dashboardId) { - const labels = this.usedDashboards.get(p.dashboardId) || []; - labels.push(this._shortLabels[p.action] || p.action); - this.usedDashboards.set(p.dashboardId, labels); - } - } } private _buildFilteredGroups(excludeIndex: number): PolicyActionGroup[] { + const policies = this.policies(); const existingSimple = new Set( - this.policies + policies .filter((p, i) => { if (i === excludeIndex) return false; - const def = this.availableActions.find((a) => a.value === p.action); + const def = this._availableActions.find((a) => a.value === p.action); return def && !def.needsTable && !def.needsDashboard; }) .map((p) => p.action), ); - return POLICY_ACTION_GROUPS - .map((group) => ({ - ...group, - actions: group.actions.filter((action) => { - if (!action.needsTable && !action.needsDashboard) { - return !existingSimple.has(action.value); - } - return true; - }), - })) - .filter((group) => group.actions.length > 0); + return POLICY_ACTION_GROUPS.map((group) => ({ + ...group, + actions: group.actions.filter((action) => { + if (!action.needsTable && !action.needsDashboard) { + return !existingSimple.has(action.value); + } + return true; + }), + })).filter((group) => group.actions.length > 0); } } diff --git a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.html b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.html index dd7483afe..3cb5c4d36 100644 --- a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.html +++ b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.html @@ -4,13 +4,15 @@

Create group of users

Enter group title - Title should not be empty. + @if (title.errors?.required && (title.invalid && title.touched)) { + Title should not be empty. + } diff --git a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.spec.ts b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.spec.ts index 12b36c54c..dc315ec80 100644 --- a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.spec.ts +++ b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.spec.ts @@ -1,23 +1,32 @@ import { provideHttpClient } from '@angular/common/http'; +import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; - import { FormsModule } from '@angular/forms'; import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatSnackBarModule } from '@angular/material/snack-bar'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { provideRouter } from '@angular/router'; import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; import { UsersService } from 'src/app/services/users.service'; import { GroupAddDialogComponent } from './group-add-dialog.component'; +type GroupAddTestable = GroupAddDialogComponent & { + submitting: ReturnType>; + connectionID: string; + groupTitle: string; + addGroup: () => Promise; +}; + describe('GroupAddDialogComponent', () => { let component: GroupAddDialogComponent; let fixture: ComponentFixture; - let usersService: UsersService; const mockDialogRef = { - close: () => {}, + close: vi.fn(), + }; + + const mockUsersService: Partial = { + createGroup: vi.fn().mockResolvedValue({ id: 'new-group', title: 'Sellers' }), }; beforeEach(() => { @@ -35,6 +44,7 @@ describe('GroupAddDialogComponent', () => { provideRouter([]), { provide: MAT_DIALOG_DATA, useValue: {} }, { provide: MatDialogRef, useValue: mockDialogRef }, + { provide: UsersService, useValue: mockUsersService }, ], }).compileComponents(); }); @@ -42,7 +52,6 @@ describe('GroupAddDialogComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(GroupAddDialogComponent); component = fixture.componentInstance; - usersService = TestBed.inject(UsersService); fixture.detectChanges(); }); @@ -50,15 +59,14 @@ describe('GroupAddDialogComponent', () => { expect(component).toBeTruthy(); }); - it('should call create user group service', () => { - component.groupTitle = 'Sellers'; - component.connectionID = '12345678'; - const fakeCreateUsersGroup = vi.spyOn(usersService, 'createUsersGroup').mockReturnValue(of()); - vi.spyOn(mockDialogRef, 'close'); + it('should call create group service', async () => { + const testable = component as unknown as GroupAddTestable; + testable.connectionID = '12345678'; + testable.groupTitle = 'Sellers'; - component.addGroup(); + await testable.addGroup(); - expect(fakeCreateUsersGroup).toHaveBeenCalledWith('12345678', 'Sellers'); - expect(component.submitting).toBe(false); + expect(mockUsersService.createGroup).toHaveBeenCalledWith('12345678', 'Sellers'); + expect(testable.submitting()).toBe(false); }); }); diff --git a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.stories.ts b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.stories.ts index 6c0b04621..cab8c2f38 100644 --- a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.stories.ts +++ b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.stories.ts @@ -1,7 +1,6 @@ import { MatDialogRef } from '@angular/material/dialog'; import { applicationConfig, type Meta, type StoryObj } from '@storybook/angular'; import { Angulartics2 } from 'angulartics2'; -import { of } from 'rxjs'; import { ConnectionsService } from 'src/app/services/connections.service'; import { UsersService } from 'src/app/services/users.service'; import { GroupAddDialogComponent } from './group-add-dialog.component'; @@ -11,8 +10,7 @@ const mockConnectionsService: Partial = { }; const mockUsersService: Partial = { - cast: of([]), - createUsersGroup: () => of(null as any), + createGroup: () => Promise.resolve(null), }; const mockAngulartics: Partial = { diff --git a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.ts b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.ts index 5e0bbf9f4..1b4d27a25 100644 --- a/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.ts +++ b/frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.ts @@ -1,5 +1,4 @@ -import { NgIf } from '@angular/common'; -import { Component, OnInit } from '@angular/core'; +import { Component, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; @@ -12,42 +11,31 @@ import { UsersService } from 'src/app/services/users.service'; @Component({ selector: 'app-group-add-dialog', - imports: [NgIf, FormsModule, MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule], + imports: [FormsModule, MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule], templateUrl: './group-add-dialog.component.html', styleUrls: ['./group-add-dialog.component.css'], }) -export class GroupAddDialogComponent implements OnInit { - public connectionID: string; - public groupTitle: string = ''; - public submitting: boolean = false; +export class GroupAddDialogComponent { + private _connections = inject(ConnectionsService); + private _usersService = inject(UsersService); + private _angulartics2 = inject(Angulartics2); + protected dialogRef = inject>(MatDialogRef); - constructor( - private _connections: ConnectionsService, - public _usersService: UsersService, - public dialogRef: MatDialogRef, - private angulartics2: Angulartics2, - ) {} + protected connectionID = this._connections.currentConnectionID; + protected groupTitle = ''; + protected submitting = signal(false); - ngOnInit(): void { - this.connectionID = this._connections.currentConnectionID; - this._usersService.cast.subscribe(); - } - - addGroup() { - this.submitting = true; - this._usersService.createUsersGroup(this.connectionID, this.groupTitle).subscribe( - (res) => { - this.submitting = false; - this.dialogRef.close(res); - this.angulartics2.eventTrack.next({ - action: 'User groups: user groups was created successfully', - }); - posthog.capture('User groups: user groups was created successfully'); - }, - () => {}, - () => { - this.submitting = false; - }, - ); + async addGroup() { + this.submitting.set(true); + try { + const res = await this._usersService.createGroup(this.connectionID, this.groupTitle); + this.dialogRef.close(res); + this._angulartics2.eventTrack.next({ + action: 'User groups: user groups was created successfully', + }); + posthog.capture('User groups: user groups was created successfully'); + } finally { + this.submitting.set(false); + } } } diff --git a/frontend/src/app/components/users/group-delete-dialog/group-delete-dialog.component.html b/frontend/src/app/components/users/group-delete-dialog/group-delete-dialog.component.html index ac4a73a4f..87de184ac 100644 --- a/frontend/src/app/components/users/group-delete-dialog/group-delete-dialog.component.html +++ b/frontend/src/app/components/users/group-delete-dialog/group-delete-dialog.component.html @@ -5,10 +5,10 @@

Confirm users group delete

Please confirm.

- - + - \ No newline at end of file + diff --git a/frontend/src/app/components/users/group-delete-dialog/group-delete-dialog.component.spec.ts b/frontend/src/app/components/users/group-delete-dialog/group-delete-dialog.component.spec.ts index 9b5b32e94..6a0357aaf 100644 --- a/frontend/src/app/components/users/group-delete-dialog/group-delete-dialog.component.spec.ts +++ b/frontend/src/app/components/users/group-delete-dialog/group-delete-dialog.component.spec.ts @@ -1,19 +1,26 @@ import { provideHttpClient } from '@angular/common/http'; +import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatSnackBarModule } from '@angular/material/snack-bar'; import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; import { UsersService } from 'src/app/services/users.service'; import { GroupDeleteDialogComponent } from './group-delete-dialog.component'; +type GroupDeleteTestable = GroupDeleteDialogComponent & { + submitting: ReturnType>; +}; + describe('GroupDeleteDialogComponent', () => { let component: GroupDeleteDialogComponent; let fixture: ComponentFixture; - let usersService: UsersService; const mockDialogRef = { - close: () => {}, + close: vi.fn(), + }; + + const mockUsersService: Partial = { + deleteGroup: vi.fn().mockResolvedValue(undefined), }; beforeEach(async () => { @@ -21,8 +28,9 @@ describe('GroupDeleteDialogComponent', () => { imports: [MatSnackBarModule, Angulartics2Module.forRoot(), GroupDeleteDialogComponent], providers: [ provideHttpClient(), - { provide: MAT_DIALOG_DATA, useValue: {} }, + { provide: MAT_DIALOG_DATA, useValue: { id: '12345678-123', title: 'Test' } }, { provide: MatDialogRef, useValue: mockDialogRef }, + { provide: UsersService, useValue: mockUsersService }, ], }).compileComponents(); }); @@ -30,7 +38,6 @@ describe('GroupDeleteDialogComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(GroupDeleteDialogComponent); component = fixture.componentInstance; - usersService = TestBed.inject(UsersService); fixture.detectChanges(); }); @@ -38,13 +45,12 @@ describe('GroupDeleteDialogComponent', () => { expect(component).toBeTruthy(); }); - it('should call delete user group service', () => { - const fakeDeleteUsersGroup = vi.spyOn(usersService, 'deleteUsersGroup').mockReturnValue(of()); - vi.spyOn(mockDialogRef, 'close'); + it('should call delete user group service', async () => { + const testable = component as unknown as GroupDeleteTestable; + + await testable.deleteUsersGroup('12345678-123'); - component.deleteUsersGroup('12345678-123'); - expect(fakeDeleteUsersGroup).toHaveBeenCalledWith('12345678-123'); - // expect(component.dialogRef.close).toHaveBeenCalled(); - expect(component.submitting).toBe(false); + expect(mockUsersService.deleteGroup).toHaveBeenCalledWith('12345678-123'); + expect(testable.submitting()).toBe(false); }); }); diff --git a/frontend/src/app/components/users/group-delete-dialog/group-delete-dialog.component.ts b/frontend/src/app/components/users/group-delete-dialog/group-delete-dialog.component.ts index bd21cd675..be54dcb85 100644 --- a/frontend/src/app/components/users/group-delete-dialog/group-delete-dialog.component.ts +++ b/frontend/src/app/components/users/group-delete-dialog/group-delete-dialog.component.ts @@ -1,5 +1,4 @@ -import { CommonModule } from '@angular/common'; -import { Component, Inject, OnInit } from '@angular/core'; +import { Component, Inject, inject, signal } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { Angulartics2 } from 'angulartics2'; @@ -10,37 +9,29 @@ import { UsersService } from 'src/app/services/users.service'; selector: 'app-group-delete-dialog', templateUrl: './group-delete-dialog.component.html', styleUrls: ['./group-delete-dialog.component.css'], - imports: [CommonModule, MatDialogModule, MatButtonModule], + imports: [MatDialogModule, MatButtonModule], }) -export class GroupDeleteDialogComponent implements OnInit { - public submitting: boolean = false; +export class GroupDeleteDialogComponent { + private _usersService = inject(UsersService); + private _angulartics2 = inject(Angulartics2); + protected dialogRef = inject>(MatDialogRef); - constructor( - @Inject(MAT_DIALOG_DATA) public data: any, - private _usersService: UsersService, - public dialogRef: MatDialogRef, - private angulartics2: Angulartics2, - ) {} + protected submitting = signal(false); - ngOnInit(): void { - this._usersService.cast.subscribe(); - } + // biome-ignore lint/suspicious/noExplicitAny: legacy dialog data type + constructor(@Inject(MAT_DIALOG_DATA) public data: any) {} - deleteUsersGroup(id: string) { - this.submitting = true; - this._usersService.deleteUsersGroup(id).subscribe( - () => { - this.dialogRef.close(); - this.submitting = false; - this.angulartics2.eventTrack.next({ - action: 'User groups: user group was deleted successfully', - }); - posthog.capture('User groups: user group was deleted successfully'); - }, - () => {}, - () => { - this.submitting = false; - }, - ); + async deleteUsersGroup(id: string) { + this.submitting.set(true); + try { + await this._usersService.deleteGroup(id); + this.dialogRef.close(); + this._angulartics2.eventTrack.next({ + action: 'User groups: user group was deleted successfully', + }); + posthog.capture('User groups: user group was deleted successfully'); + } finally { + this.submitting.set(false); + } } } diff --git a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.html b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.html index e42551ba0..3a25b3f8d 100644 --- a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.html +++ b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.html @@ -4,13 +4,15 @@

Change group name

Change group name - Title should not be empty. + @if (title.errors?.required && (title.invalid && title.touched)) { + Title should not be empty. + } diff --git a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.spec.ts b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.spec.ts index 75aeaf474..57721c04d 100644 --- a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.spec.ts +++ b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.spec.ts @@ -1,12 +1,12 @@ import { provideHttpClient } from '@angular/common/http'; import { ComponentFixture, TestBed } from '@angular/core/testing'; - import { FormsModule } from '@angular/forms'; import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatSnackBarModule } from '@angular/material/snack-bar'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { provideRouter } from '@angular/router'; import { Angulartics2Module } from 'angulartics2'; +import { UsersService } from 'src/app/services/users.service'; import { GroupNameEditDialogComponent } from './group-name-edit-dialog.component'; describe('GroupNameEditDialogComponent', () => { @@ -14,7 +14,11 @@ describe('GroupNameEditDialogComponent', () => { let fixture: ComponentFixture; const mockDialogRef = { - close: () => {}, + close: vi.fn(), + }; + + const mockUsersService: Partial = { + editGroupName: vi.fn().mockResolvedValue(undefined), }; beforeEach(() => { @@ -35,6 +39,7 @@ describe('GroupNameEditDialogComponent', () => { useValue: { id: 'test-id', title: 'Test Group' }, }, { provide: MatDialogRef, useValue: mockDialogRef }, + { provide: UsersService, useValue: mockUsersService }, ], }).compileComponents(); diff --git a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.ts b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.ts index 4a07cd8b1..307170d33 100644 --- a/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.ts +++ b/frontend/src/app/components/users/group-name-edit-dialog/group-name-edit-dialog.component.ts @@ -1,5 +1,4 @@ -import { NgIf } from '@angular/common'; -import { Component, Inject } from '@angular/core'; +import { Component, Inject, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; @@ -12,34 +11,26 @@ import { GroupNameEditDialogComponent as Self } from './group-name-edit-dialog.c selector: 'app-group-name-edit-dialog', templateUrl: './group-name-edit-dialog.component.html', styleUrls: ['./group-name-edit-dialog.component.css'], - imports: [NgIf, MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule, FormsModule], + imports: [MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule, FormsModule], }) export class GroupNameEditDialogComponent { - public groupTitle: string = ''; - public submitting: boolean = false; + private _usersService = inject(UsersService); + protected dialogRef = inject>(MatDialogRef); - constructor( - @Inject(MAT_DIALOG_DATA) public group: { id: string; title: string }, - public _usersService: UsersService, - public dialogRef: MatDialogRef, - ) {} + protected groupTitle: string; + protected submitting = signal(false); - ngOnInit(): void { + constructor(@Inject(MAT_DIALOG_DATA) public group: { id: string; title: string }) { this.groupTitle = this.group.title; - this._usersService.cast.subscribe(); } - addGroup() { - this.submitting = true; - this._usersService.editUsersGroupName(this.group.id, this.groupTitle).subscribe( - () => { - this.submitting = false; - this.dialogRef.close(); - }, - () => {}, - () => { - this.submitting = false; - }, - ); + async addGroup() { + this.submitting.set(true); + try { + await this._usersService.editGroupName(this.group.id, this.groupTitle); + this.dialogRef.close(); + } finally { + this.submitting.set(false); + } } } diff --git a/frontend/src/app/components/users/user-add-dialog/user-add-dialog.component.html b/frontend/src/app/components/users/user-add-dialog/user-add-dialog.component.html index e5f0ff28f..5a468467c 100644 --- a/frontend/src/app/components/users/user-add-dialog/user-add-dialog.component.html +++ b/frontend/src/app/components/users/user-add-dialog/user-add-dialog.component.html @@ -1,14 +1,16 @@

Add user to {{ data.group.title }} group

-
+ @if (data.availableMembers.length) { Select user - - {{member.name}} ({{member.email}}) - + @for (member of data.availableMembers; track member.id) { + + @if (member.name) { {{member.name}} ( }{{member.email}}@if (member.name) { ) } + + }

@@ -16,24 +18,27 @@

Add user to {{ data.group.title }} group Company before assigning them to a group.

-

-

- All your company members are already in this group. To add a new one first add them to your company. -

+ } + @if (data.availableMembers.length === 0) { +

+ All your company members are already in this group. To add a new one first add them to your company. +

+ }
- - + @if (data.availableMembers.length) { + + } @else { Open Company page - - + + } diff --git a/frontend/src/app/components/users/user-add-dialog/user-add-dialog.component.spec.ts b/frontend/src/app/components/users/user-add-dialog/user-add-dialog.component.spec.ts index af8a5ce81..f34a693e9 100644 --- a/frontend/src/app/components/users/user-add-dialog/user-add-dialog.component.spec.ts +++ b/frontend/src/app/components/users/user-add-dialog/user-add-dialog.component.spec.ts @@ -1,72 +1,203 @@ import { provideHttpClient } from '@angular/common/http'; +import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormsModule } from '@angular/forms'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { RouterTestingModule } from '@angular/router/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { provideRouter } from '@angular/router'; import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; import { UsersService } from 'src/app/services/users.service'; import { UserAddDialogComponent } from './user-add-dialog.component'; +type UserAddTestable = UserAddDialogComponent & { + submitting: ReturnType>; + groupUserEmail: string; + joinGroupUser: () => Promise; +}; + +const fakeMembers = [ + { id: 'user-1', email: 'alice@test.com', name: 'Alice Smith' }, + { id: 'user-2', email: 'bob@test.com', name: 'Bob Jones' }, + { id: 'user-3', email: 'charlie@test.com', name: null }, +]; + +const fakeGroup = { + id: '12345678-abcd-1234-efgh-123456789012', + title: 'Developers', +}; + describe('UserAddDialogComponent', () => { let component: UserAddDialogComponent; let fixture: ComponentFixture; - let usersService: UsersService; + let mockDialogRef: { close: ReturnType }; + let mockUsersService: Partial; - const mockDialogRef = { - close: () => {}, - }; + function createComponent(dialogData: { availableMembers: typeof fakeMembers; group: typeof fakeGroup }) { + mockDialogRef = { close: vi.fn() }; + mockUsersService = { + addGroupUser: vi.fn().mockResolvedValue(undefined), + }; - beforeEach(async () => { - await TestBed.configureTestingModule({ + TestBed.configureTestingModule({ imports: [ - RouterTestingModule, MatSnackBarModule, FormsModule, + BrowserAnimationsModule, Angulartics2Module.forRoot(), UserAddDialogComponent, ], providers: [ provideHttpClient(), - { - provide: MAT_DIALOG_DATA, - useValue: { - availableMembers: [], - group: { - id: '12345678-123', - title: 'Test Group', - }, - }, - }, + provideRouter([]), + { provide: MAT_DIALOG_DATA, useValue: dialogData }, { provide: MatDialogRef, useValue: mockDialogRef }, + { provide: UsersService, useValue: mockUsersService }, ], }).compileComponents(); - }); - beforeEach(() => { fixture = TestBed.createComponent(UserAddDialogComponent); component = fixture.componentInstance; - usersService = TestBed.inject(UsersService); fixture.detectChanges(); - }); + } + + describe('with available members', () => { + beforeEach(() => { + createComponent({ availableMembers: fakeMembers, group: fakeGroup }); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display group title in dialog header', () => { + const title = fixture.nativeElement.querySelector('[mat-dialog-title]'); + expect(title.textContent).toContain('Developers'); + }); + + it('should render a select dropdown with available members', () => { + const select = fixture.nativeElement.querySelector('mat-select'); + expect(select).toBeTruthy(); + }); + + it('should not show the "all members already in group" message', () => { + const text = fixture.nativeElement.textContent; + expect(text).not.toContain('All your company members are already in this group'); + }); + + it('should show the "Add users to Company" hint', () => { + const text = fixture.nativeElement.textContent; + expect(text).toContain('Add users to the'); + expect(text).toContain('Company'); + }); + + it('should render an Add submit button', () => { + const buttons: HTMLButtonElement[] = Array.from(fixture.nativeElement.querySelectorAll('button')); + const addBtn = buttons.find((b) => b.textContent?.trim() === 'Add'); + expect(addBtn).toBeTruthy(); + expect(addBtn!.type).toBe('submit'); + }); + + it('should disable Add button when no user is selected (form invalid)', async () => { + await fixture.whenStable(); + fixture.detectChanges(); + const buttons: HTMLButtonElement[] = Array.from(fixture.nativeElement.querySelectorAll('button')); + const addBtn = buttons.find((b) => b.textContent?.trim() === 'Add'); + expect(addBtn!.disabled).toBe(true); + }); + + it('should call addGroupUser with correct groupId and email', async () => { + const testable = component as unknown as UserAddTestable; + testable.groupUserEmail = 'alice@test.com'; + + await testable.joinGroupUser(); + + expect(mockUsersService.addGroupUser).toHaveBeenCalledWith( + '12345678-abcd-1234-efgh-123456789012', + 'alice@test.com', + ); + }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should close dialog on successful submission', async () => { + const testable = component as unknown as UserAddTestable; + testable.groupUserEmail = 'bob@test.com'; + + await testable.joinGroupUser(); + + expect(mockDialogRef.close).toHaveBeenCalled(); + }); + + it('should reset submitting to false after successful submission', async () => { + const testable = component as unknown as UserAddTestable; + testable.groupUserEmail = 'alice@test.com'; + + await testable.joinGroupUser(); + + expect(testable.submitting()).toBe(false); + }); + + it('should reset submitting to false when service throws', async () => { + (mockUsersService.addGroupUser as ReturnType).mockRejectedValueOnce(new Error('Network error')); + const testable = component as unknown as UserAddTestable; + testable.groupUserEmail = 'alice@test.com'; + + await testable.joinGroupUser().catch(() => {}); + + expect(testable.submitting()).toBe(false); + }); + + it('should not close dialog when service throws', async () => { + (mockUsersService.addGroupUser as ReturnType).mockRejectedValueOnce(new Error('Network error')); + const testable = component as unknown as UserAddTestable; + testable.groupUserEmail = 'alice@test.com'; + + await testable.joinGroupUser().catch(() => {}); + + expect(mockDialogRef.close).not.toHaveBeenCalled(); + }); + + it('should not show "Open Company page" link when members are available', () => { + const links: HTMLAnchorElement[] = Array.from(fixture.nativeElement.querySelectorAll('a')); + const companyPageLink = links.find((a) => a.textContent?.trim() === 'Open Company page'); + expect(companyPageLink).toBeFalsy(); + }); }); - it('should call add user service', () => { - component.groupUserEmail = 'user@test.com'; - const fakeAddUser = vi.spyOn(usersService, 'addGroupUser').mockReturnValue(of()); - // vi.spyOn(mockDialogRef, 'close'); + describe('with no available members', () => { + beforeEach(() => { + createComponent({ availableMembers: [], group: fakeGroup }); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show "all members already in group" message', () => { + const text = fixture.nativeElement.textContent; + expect(text).toContain('All your company members are already in this group'); + }); + + it('should not render a select dropdown', () => { + const select = fixture.nativeElement.querySelector('mat-select'); + expect(select).toBeFalsy(); + }); + + it('should not render an Add button', () => { + const buttons: HTMLButtonElement[] = Array.from(fixture.nativeElement.querySelectorAll('button')); + const addBtn = buttons.find((b) => b.textContent?.trim() === 'Add'); + expect(addBtn).toBeFalsy(); + }); - component.joinGroupUser(); - expect(fakeAddUser).toHaveBeenCalledWith('12345678-123', 'user@test.com'); + it('should show "Open Company page" link', () => { + const links: HTMLAnchorElement[] = Array.from(fixture.nativeElement.querySelectorAll('a')); + const companyPageLink = links.find((a) => a.textContent?.trim() === 'Open Company page'); + expect(companyPageLink).toBeTruthy(); + }); - // fixture.detectChanges(); - // fixture.whenStable().then(() => { - // expect(component.dialogRef.close).toHaveBeenCalled(); - // expect(component.submitting).toBe(false); - // }); + it('should link "Open Company page" to /company', () => { + const links: HTMLAnchorElement[] = Array.from(fixture.nativeElement.querySelectorAll('a')); + const companyPageLink = links.find((a) => a.textContent?.trim() === 'Open Company page'); + expect(companyPageLink?.getAttribute('href')).toBe('/company'); + }); }); }); diff --git a/frontend/src/app/components/users/user-add-dialog/user-add-dialog.component.ts b/frontend/src/app/components/users/user-add-dialog/user-add-dialog.component.ts index 64027a9a9..c3cc44498 100644 --- a/frontend/src/app/components/users/user-add-dialog/user-add-dialog.component.ts +++ b/frontend/src/app/components/users/user-add-dialog/user-add-dialog.component.ts @@ -1,5 +1,4 @@ -import { NgForOf, NgIf } from '@angular/common'; -import { Component, Inject, OnInit } from '@angular/core'; +import { Component, Inject, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; @@ -8,56 +7,36 @@ import { MatSelectModule } from '@angular/material/select'; import { RouterModule } from '@angular/router'; import { Angulartics2 } from 'angulartics2'; import posthog from 'posthog-js'; -import { CompanyService } from 'src/app/services/company.service'; -import { UserService } from 'src/app/services/user.service'; import { UsersService } from 'src/app/services/users.service'; @Component({ selector: 'app-user-add-dialog', templateUrl: './user-add-dialog.component.html', styleUrls: ['./user-add-dialog.component.css'], - imports: [ - NgIf, - NgForOf, - MatDialogModule, - FormsModule, - MatFormFieldModule, - MatSelectModule, - MatButtonModule, - RouterModule, - ], + imports: [MatDialogModule, FormsModule, MatFormFieldModule, MatSelectModule, MatButtonModule, RouterModule], }) -export class UserAddDialogComponent implements OnInit { - public submitting: boolean = false; - public groupUserEmail: string = ''; - public availableMembers = null; +export class UserAddDialogComponent { + private _usersService = inject(UsersService); + private _angulartics2 = inject(Angulartics2); + private _dialogRef = inject>(MatDialogRef); - constructor( - @Inject(MAT_DIALOG_DATA) public data: any, - private _usersService: UsersService, - _userService: UserService, - _company: CompanyService, - private angulartics2: Angulartics2, - private dialogRef: MatDialogRef, - ) {} + protected submitting = signal(false); + protected groupUserEmail = ''; - ngOnInit(): void {} + // biome-ignore lint/suspicious/noExplicitAny: legacy dialog data type + constructor(@Inject(MAT_DIALOG_DATA) public data: any) {} - joinGroupUser() { - this.submitting = true; - this._usersService.addGroupUser(this.data.group.id, this.groupUserEmail).subscribe( - (_res) => { - this.dialogRef.close(); - this.submitting = false; - this.angulartics2.eventTrack.next({ - action: 'User groups: user was added to group successfully', - }); - posthog.capture('User groups: user was added to group successfully'); - }, - () => {}, - () => { - this.submitting = false; - }, - ); + async joinGroupUser() { + this.submitting.set(true); + try { + await this._usersService.addGroupUser(this.data.group.id, this.groupUserEmail); + this._dialogRef.close(); + this._angulartics2.eventTrack.next({ + action: 'User groups: user was added to group successfully', + }); + posthog.capture('User groups: user was added to group successfully'); + } finally { + this.submitting.set(false); + } } } diff --git a/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.html b/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.html index c5b6661ad..9972b7b17 100644 --- a/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.html +++ b/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.html @@ -2,27 +2,26 @@

Confirm users delete

You are going to delete - + @if (data.user.name) { {{ data.user.name }} - ({{ data.user.email }}) - - + ({{ data.user.email }}) + } @else { {{ data.user.email }} - + } from {{ data.group.title }} group.


Please confirm.

- - - \ No newline at end of file + diff --git a/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.spec.ts b/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.spec.ts index 40c0bb7e4..e2019cc92 100644 --- a/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.spec.ts +++ b/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.spec.ts @@ -1,18 +1,25 @@ import { provideHttpClient } from '@angular/common/http'; +import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { of } from 'rxjs'; import { UsersService } from 'src/app/services/users.service'; import { UserDeleteDialogComponent } from './user-delete-dialog.component'; +type UserDeleteTestable = UserDeleteDialogComponent & { + submitting: ReturnType>; +}; + describe('UserDeleteDialogComponent', () => { let component: UserDeleteDialogComponent; let fixture: ComponentFixture; - let usersService: UsersService; const mockDialogRef = { - close: () => {}, + close: vi.fn(), + }; + + const mockUsersService: Partial = { + deleteGroupUser: vi.fn().mockResolvedValue(undefined), }; beforeEach(async () => { @@ -22,6 +29,7 @@ describe('UserDeleteDialogComponent', () => { provideHttpClient(), { provide: MAT_DIALOG_DATA, useValue: { user: { email: 'user@test.com' }, group: { id: '12345678-123' } } }, { provide: MatDialogRef, useValue: mockDialogRef }, + { provide: UsersService, useValue: mockUsersService }, ], }).compileComponents(); }); @@ -29,7 +37,6 @@ describe('UserDeleteDialogComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(UserDeleteDialogComponent); component = fixture.componentInstance; - usersService = TestBed.inject(UsersService); fixture.detectChanges(); }); @@ -37,13 +44,12 @@ describe('UserDeleteDialogComponent', () => { expect(component).toBeTruthy(); }); - it('should call delete user service', () => { - const fakeDeleteUser = vi.spyOn(usersService, 'deleteGroupUser').mockReturnValue(of()); - vi.spyOn(mockDialogRef, 'close'); + it('should call delete user service', async () => { + const testable = component as unknown as UserDeleteTestable; + + await testable.deleteGroupUser(); - component.deleteGroupUser(); - expect(fakeDeleteUser).toHaveBeenCalledWith('user@test.com', '12345678-123'); - // expect(component.dialogRef.close).toHaveBeenCalled(); - expect(component.submitting).toBe(false); + expect(mockUsersService.deleteGroupUser).toHaveBeenCalledWith('user@test.com', '12345678-123'); + expect(testable.submitting()).toBe(false); }); }); diff --git a/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.stories.ts b/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.stories.ts index 267aefa62..115dfe139 100644 --- a/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.stories.ts +++ b/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.stories.ts @@ -1,12 +1,10 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { applicationConfig, type Meta, type StoryObj } from '@storybook/angular'; -import { of } from 'rxjs'; import { UsersService } from 'src/app/services/users.service'; import { UserDeleteDialogComponent } from './user-delete-dialog.component'; const mockUsersService: Partial = { - cast: of([]), - deleteGroupUser: () => of(null as any), + deleteGroupUser: () => Promise.resolve(), }; const meta: Meta = { diff --git a/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.ts b/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.ts index e9619afa4..fab1502a8 100644 --- a/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.ts +++ b/frontend/src/app/components/users/user-delete-dialog/user-delete-dialog.component.ts @@ -1,5 +1,4 @@ -import { CommonModule } from '@angular/common'; -import { Component, Inject, OnInit } from '@angular/core'; +import { Component, Inject, inject, signal } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { UsersService } from 'src/app/services/users.service'; @@ -8,32 +7,24 @@ import { UsersService } from 'src/app/services/users.service'; selector: 'app-user-delete-dialog', templateUrl: './user-delete-dialog.component.html', styleUrls: ['./user-delete-dialog.component.css'], - imports: [CommonModule, MatButtonModule, MatDialogModule], + imports: [MatButtonModule, MatDialogModule], }) -export class UserDeleteDialogComponent implements OnInit { - public submitting: boolean = false; +export class UserDeleteDialogComponent { + private _usersService = inject(UsersService); + protected dialogRef = inject>(MatDialogRef); - constructor( - @Inject(MAT_DIALOG_DATA) public data: any, - private _usersService: UsersService, - public dialogRef: MatDialogRef, - ) {} + protected submitting = signal(false); - ngOnInit(): void { - this._usersService.cast.subscribe(); - } + // biome-ignore lint/suspicious/noExplicitAny: legacy dialog data type + constructor(@Inject(MAT_DIALOG_DATA) public data: any) {} - deleteGroupUser() { - this.submitting = true; - this._usersService.deleteGroupUser(this.data.user.email, this.data.group.id).subscribe( - () => { - this.dialogRef.close(); - this.submitting = false; - }, - () => {}, - () => { - this.submitting = false; - }, - ); + async deleteGroupUser() { + this.submitting.set(true); + try { + await this._usersService.deleteGroupUser(this.data.user.email, this.data.group.id); + this.dialogRef.close(); + } finally { + this.submitting.set(false); + } } } diff --git a/frontend/src/app/components/users/users.component.html b/frontend/src/app/components/users/users.component.html index ec5da08cb..0c96ddac8 100644 --- a/frontend/src/app/components/users/users.component.html +++ b/frontend/src/app/components/users/users.component.html @@ -1,127 +1,127 @@

User groups

- + @if (canCreateGroup()) { + + }
- + @if (groups() === null) { + + } - - - - - {{ groupItem.group.title }} - system - - - - {{ getUserInitials(user) }} - - {{ groupUsers.length }} {{ groupUsers.length === 1 ? 'member' : 'members' }} - - - - - - - - + @if (groups(); as groupsList) { + + @for (groupItem of groupsList; track groupItem.group.id) { + @let canManage = canManageGroup(groupItem.group.id); + + + + {{ groupItem.group.title }} + @if (groupItem.group.title === 'Admin') { + system + } + @if (canManage() && groupItem.group.title !== 'Admin') { + + } + @if (getGroupUsers(groupItem.group.id); as groupUsersList) { + + @for (user of groupUsersList.slice(0, 3); track user.email) { + + {{ getUserInitials(user) }} + + } + {{ groupUsersList.length }} {{ groupUsersList.length === 1 ? 'member' : 'members' }} + + } + + + @if (canManage() && groupItem.group.title !== 'Admin') { + + + } + @if (canManage()) { + + } + + - -

No users in the group

- - -
- {{user.name}} ({{user.email}}) - - {{user.email}} - - -
-
-
-
-
+ @if (groupUsers()[groupItem.group.id] === undefined) { + + } + @if (groupUsers()[groupItem.group.id] === 'empty') { +

No users in the group

+ } + + @if (getGroupUsers(groupItem.group.id); as usersList) { + @for (user of usersList; track user.email) { + +
+ @if (user.name) { + {{user.name}} ({{user.email}}) + } @else { + {{user.email}} + } + @if (currentUser()?.email !== user.email && canManage()) { + + } +
+
+ } + } +
+
+ } +
+ } -
- Company members who do NOT have access to this connection: - - {{member.name}} ({{member.email}}) {{!last ? ', ' : ''}} - -
- - + @if (companyMembersWithoutAccess().length > 0) { +
+ Company members who do NOT have access to this connection: + @for (member of companyMembersWithoutAccess(); track member.id; let last = $last) { + + {{member.name}} ({{member.email}}) {{!last ? ', ' : ''}} + + } +
+ }
- - diff --git a/frontend/src/app/components/users/users.component.spec.ts b/frontend/src/app/components/users/users.component.spec.ts index 118580873..327289fb1 100644 --- a/frontend/src/app/components/users/users.component.spec.ts +++ b/frontend/src/app/components/users/users.component.spec.ts @@ -1,10 +1,11 @@ import { provideHttpClient } from '@angular/common/http'; +import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatSnackBarModule } from '@angular/material/snack-bar'; import { provideRouter } from '@angular/router'; import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; +import { CedarPermissionService } from 'src/app/services/cedar-permission.service'; import { UsersService } from 'src/app/services/users.service'; import { GroupAddDialogComponent } from './group-add-dialog/group-add-dialog.component'; import { GroupDeleteDialogComponent } from './group-delete-dialog/group-delete-dialog.component'; @@ -15,7 +16,6 @@ import { UsersComponent } from './users.component'; describe('UsersComponent', () => { let component: UsersComponent; let fixture: ComponentFixture; - let usersService: UsersService; let dialog: MatDialog; const fakeGroup = { @@ -24,17 +24,59 @@ describe('UsersComponent', () => { isMain: true, }; + const mockGroups = signal([ + { + group: { + id: 'a9a97cf1-cb2f-454b-a74e-0075dd07ad92', + title: 'Admin', + isMain: true, + }, + accessLevel: 'edit', + }, + { + group: { + id: '77154868-eaf0-4a53-9693-0470182d0971', + title: 'Sellers', + isMain: false, + }, + accessLevel: 'edit', + }, + ]); + + const mockUsersService: Partial = { + groups: mockGroups.asReadonly() as any, + groupsLoading: signal(false).asReadonly() as any, + groupUsers: signal({}).asReadonly() as any, + groupsUpdated: signal('').asReadonly() as any, + setActiveConnection: vi.fn(), + refreshGroups: vi.fn(), + clearGroupsUpdated: vi.fn(), + fetchGroupUsers: vi.fn().mockResolvedValue([]), + fetchAllGroupUsers: vi.fn().mockResolvedValue(undefined), + fetchConnectionUsers: vi.fn(), + }; + + const mockPermissions: Partial = { + canI: () => signal(true).asReadonly(), + ready: signal(true).asReadonly(), + }; + beforeEach(() => { TestBed.configureTestingModule({ imports: [MatSnackBarModule, MatDialogModule, Angulartics2Module.forRoot(), UsersComponent], - providers: [provideHttpClient(), provideRouter([]), { provide: MatDialogRef, useValue: {} }], + providers: [ + provideHttpClient(), + provideRouter([]), + { provide: MatDialogRef, useValue: {} }, + { provide: UsersService, useValue: mockUsersService }, + { provide: CedarPermissionService, useValue: mockPermissions }, + ], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(UsersComponent); component = fixture.componentInstance; - usersService = TestBed.inject(UsersService); dialog = TestBed.inject(MatDialog); fixture.detectChanges(); }); @@ -43,48 +85,6 @@ describe('UsersComponent', () => { expect(component).toBeTruthy(); }); - it('should permit action if access level is fullaccess', () => { - const isPermitted = component.isPermitted('fullaccess'); - expect(isPermitted).toBe(true); - }); - - it('should permit action if access level is edit', () => { - const isPermitted = component.isPermitted('edit'); - expect(isPermitted).toBe(true); - }); - - it('should not permit action if access level is none', () => { - const isPermitted = component.isPermitted('none'); - expect(isPermitted).toBe(false); - }); - - it('should set list of groups', () => { - const mockGroups = [ - { - group: { - id: '77154868-eaf0-4a53-9693-0470182d0971', - title: 'Sellers', - isMain: false, - }, - accessLevel: 'edit', - }, - { - group: { - id: 'a9a97cf1-cb2f-454b-a74e-0075dd07ad92', - title: 'Admin', - isMain: true, - }, - accessLevel: 'edit', - }, - ]; - component.connectionID = '12345678'; - - vi.spyOn(usersService, 'fetchConnectionGroups').mockReturnValue(of(mockGroups)); - - component.getUsersGroups(); - expect(component.groups).toEqual(mockGroups); - }); - it('should open create group dialog', () => { const fakeCreateUsersGroupOpen = vi.spyOn(dialog, 'open'); const event = { preventDefault: vi.fn(), stopImmediatePropagation: vi.fn() } as unknown as Event; @@ -134,38 +134,8 @@ describe('UsersComponent', () => { }); }); - it('should set users list of group in users object', async () => { - const mockGroupUsersList = [ - { - id: 'user-12345678', - createdAt: '2021-11-17T16:07:13.955Z', - gclid: null, - isActive: true, - stripeId: 'cus_87654321', - email: 'user1@test.com', - }, - { - id: 'user-87654321', - createdAt: '2021-10-01T13:43:02.034Z', - gclid: null, - isActive: true, - stripeId: 'cus_12345678', - email: 'user2@test.com', - }, - ]; - - vi.spyOn(usersService, 'fetcGroupUsers').mockReturnValue(of(mockGroupUsersList)); - - await component.fetchAndPopulateGroupUsers('12345678').toPromise(); - expect(component.users['12345678']).toEqual(mockGroupUsersList); - }); - - it("should set 'empty' value in users object", async () => { - const mockGroupUsersList = []; - - vi.spyOn(usersService, 'fetcGroupUsers').mockReturnValue(of(mockGroupUsersList)); - - await component.fetchAndPopulateGroupUsers('12345678').toPromise(); - expect(component.users['12345678']).toEqual('empty'); + it('should return null for group users that are not loaded', () => { + const result = component.getGroupUsers('nonexistent-id'); + expect(result).toBeNull(); }); }); diff --git a/frontend/src/app/components/users/users.component.ts b/frontend/src/app/components/users/users.component.ts index f6a33e572..54bcc4bcc 100644 --- a/frontend/src/app/components/users/users.component.ts +++ b/frontend/src/app/components/users/users.component.ts @@ -1,5 +1,5 @@ -import { CommonModule, NgClass, NgForOf, NgIf } from '@angular/common'; -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component, computed, DestroyRef, effect, inject, OnInit, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; import { MatAccordion, MatExpansionModule } from '@angular/material/expansion'; @@ -10,9 +10,9 @@ import { Title } from '@angular/platform-browser'; import { Angulartics2, Angulartics2OnModule } from 'angulartics2'; import { differenceBy } from 'lodash-es'; import posthog from 'posthog-js'; -import { forkJoin, Observable, Subscription, take, tap } from 'rxjs'; -import { Connection } from 'src/app/models/connection'; -import { GroupUser, User, UserGroup, UserGroupInfo } from 'src/app/models/user'; +import { take } from 'rxjs'; +import { GroupUser, User, UserGroup } from 'src/app/models/user'; +import { CedarPermissionService } from 'src/app/services/cedar-permission.service'; import { CompanyService } from 'src/app/services/company.service'; import { ConnectionsService } from 'src/app/services/connections.service'; import { UserService } from 'src/app/services/user.service'; @@ -29,10 +29,6 @@ import { UserDeleteDialogComponent } from './user-delete-dialog/user-delete-dial @Component({ selector: 'app-users', imports: [ - NgIf, - NgForOf, - NgClass, - CommonModule, MatButtonModule, MatIconModule, MatListModule, @@ -46,147 +42,101 @@ import { UserDeleteDialogComponent } from './user-delete-dialog/user-delete-dial templateUrl: './users.component.html', styleUrls: ['./users.component.css'], }) -export class UsersComponent implements OnInit, OnDestroy { +export class UsersComponent implements OnInit { + private _usersService = inject(UsersService); + private _userService = inject(UserService); + private _connections = inject(ConnectionsService); + private _company = inject(CompanyService); + private _dialog = inject(MatDialog); + private _title = inject(Title); + private _destroyRef = inject(DestroyRef); + private _permissions = inject(CedarPermissionService); + protected posthog = posthog; - public users: { [key: string]: GroupUser[] | 'empty' } = {}; - public currentUser: User; - public groups: UserGroupInfo[] | null = null; - public currentConnection: Connection; - public connectionID: string | null = null; - public companyMembers: []; + protected connectionID: string | null = null; + + protected currentUser = signal(null); // biome-ignore lint/suspicious/noExplicitAny: legacy company member type - public companyMembersWithoutAccess: any = []; - private usersSubscription: Subscription; - - constructor( - private _usersService: UsersService, - private _userService: UserService, - private _connections: ConnectionsService, - private _company: CompanyService, - public dialog: MatDialog, - private title: Title, - _angulartics2: Angulartics2, - ) {} + protected companyMembers = signal([]); + + protected groups = computed(() => { + const g = this._usersService.groups(); + return g.length > 0 || !this._usersService.groupsLoading() ? g : null; + }); + + protected groupUsers = this._usersService.groupUsers; + + protected companyMembersWithoutAccess = computed(() => { + const members = this.companyMembers(); + const users = this.groupUsers(); + const allGroupUsers = Object.values(users).flat(); + return differenceBy(members, allGroupUsers, 'email'); + }); + + constructor() { + // React to group update events + effect(() => { + const action = this._usersService.groupsUpdated(); + if (!action) return; + + if (action === 'user-added' || action === 'user-deleted') { + // Refresh all group users to get updated data + const currentGroups = this._usersService.groups(); + if (currentGroups.length) { + this._usersService.fetchAllGroupUsers(currentGroups); + } + } else if (action !== 'policy-saved') { + // Group-level changes: refresh groups resource, then users + // (policy-saved already refreshes in UsersService.saveCedarPolicy) + this._usersService.refreshGroups(); + } + this._usersService.clearGroupsUpdated(); + }); + + // Fetch group users when groups load + effect(() => { + const groups = this._usersService.groups(); + if (groups.length > 0) { + this._usersService.fetchAllGroupUsers(groups); + } + }); + } ngOnInit() { + this.connectionID = this._connections.currentConnectionID; + this._connections .getCurrentConnectionTitle() .pipe(take(1)) .subscribe((connectionTitle) => { - this.title.setTitle( + this._title.setTitle( `User permissions - ${connectionTitle} | ${this._company.companyTabTitle || 'Rocketadmin'}`, ); }); - this.connectionID = this._connections.currentConnectionID; - this.getUsersGroups(); - - this._userService.cast.subscribe((user) => { - this.currentUser = user; - - this._company.fetchCompanyMembers(this.currentUser.company.id).subscribe((members) => { - this.companyMembers = members; - }); - }); - - this.usersSubscription = this._usersService.cast.subscribe((arg) => { - if ( - arg.action === 'add group' || - arg.action === 'delete group' || - arg.action === 'edit group name' || - arg.action === 'save policy' - ) { - this.getUsersGroups(); - } else if (arg.action === 'add user' || arg.action === 'delete user') { - this.fetchAndPopulateGroupUsers(arg.groupId).subscribe({ - next: (updatedUsers) => { - // `this.users[groupId]` is now updated. - // `updatedUsers` is the raw array from the server (if you need it). - this.getCompanyMembersWithoutAccess(); - - console.log(`Group ${arg.groupId} updated:`, updatedUsers); - }, - error: (err) => console.error(`Failed to update group ${arg.groupId}:`, err), - }); - } - }); - } - - ngOnDestroy() { - this.usersSubscription.unsubscribe(); - } - - get connectionAccessLevel() { - return this._connections.currentConnectionAccessLevel || 'none'; - } - - isPermitted(accessLevel) { - return accessLevel === 'fullaccess' || accessLevel === 'edit'; - } - - getUsersGroups() { - // biome-ignore lint/suspicious/noExplicitAny: legacy service return type - this._usersService.fetchConnectionGroups(this.connectionID).subscribe((groups: any) => { - // Sort Admin to the front - this.groups = groups.sort((a, b) => { - if (a.group.title === 'Admin') return -1; - if (b.group.title === 'Admin') return 1; - return 0; - }); - // Create an array of Observables based on each group - const groupRequests = this.groups.map((groupItem) => { - return this.fetchAndPopulateGroupUsers(groupItem.group.id); - }); + this._userService.cast.pipe(takeUntilDestroyed(this._destroyRef)).subscribe((user) => { + this.currentUser.set(user); - // Wait until all these Observables complete - forkJoin(groupRequests).subscribe({ - next: (_results) => { - // Here, 'results' is an array of the user arrays from each group. - // By this point, this.users[...] is updated for ALL groups. - // Update any shared state - this.getCompanyMembersWithoutAccess(); - }, - error: (err) => console.error('Error in group fetch:', err), + this._company.fetchCompanyMembers(user.company.id).subscribe((members) => { + this.companyMembers.set(members); }); }); } - // biome-ignore lint/suspicious/noExplicitAny: legacy service return type - fetchAndPopulateGroupUsers(groupId: string): Observable { - return this._usersService.fetcGroupUsers(groupId).pipe( - // biome-ignore lint/suspicious/noExplicitAny: legacy service return type - tap((res: any[]) => { - if (res.length) { - let groupUsers = [...res]; - const userIndex = groupUsers.findIndex((user) => user.email === this.currentUser.email); - - if (userIndex !== -1) { - const user = groupUsers.splice(userIndex, 1)[0]; - groupUsers.unshift(user); - } - - this.users[groupId] = groupUsers; - } else { - this.users[groupId] = 'empty'; - } - }), - ); - } + protected canCreateGroup = this._permissions.canI('group:edit', 'Group', this._connections.currentConnectionID); - getCompanyMembersWithoutAccess() { - const allGroupUsers = Object.values(this.users).flat(); - this.companyMembersWithoutAccess = differenceBy(this.companyMembers, allGroupUsers, 'email'); + canManageGroup(groupId: string) { + return this._permissions.canI('group:edit', 'Group', groupId); } getGroupUsers(groupId: string): GroupUser[] | null { - const val = this.users[groupId]; + const val = this.groupUsers()[groupId]; if (!val || val === 'empty') return null; return val; } getUserInitials(user: GroupUser): string { - // biome-ignore lint/suspicious/noExplicitAny: name comes from API but not typed - const name = (user as any).name as string | undefined; + const name = user.name; if (name) { const parts = name.trim().split(/\s+/); if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase(); @@ -195,15 +145,15 @@ export class UsersComponent implements OnInit, OnDestroy { return user.email[0].toUpperCase(); } - openCreateUsersGroupDialog(event) { + openCreateUsersGroupDialog(event: Event) { event.preventDefault(); event.stopImmediatePropagation(); - const dialogRef = this.dialog.open(GroupAddDialogComponent, { + const dialogRef = this._dialog.open(GroupAddDialogComponent, { width: '25em', }); dialogRef.afterClosed().subscribe((createdGroup) => { if (createdGroup) { - this.dialog.open(CedarPolicyEditorDialogComponent, { + this._dialog.open(CedarPolicyEditorDialogComponent, { width: '40em', data: { groupId: createdGroup.id, groupTitle: createdGroup.title, cedarPolicy: null }, }); @@ -212,15 +162,20 @@ export class UsersComponent implements OnInit, OnDestroy { } openAddUserDialog(group: UserGroup) { - const availableMembers = differenceBy(this.companyMembers, this.users[group.id] as [], 'email'); - this.dialog.open(UserAddDialogComponent, { + const groupUsersList = this.groupUsers()[group.id]; + const availableMembers = differenceBy( + this.companyMembers(), + groupUsersList === 'empty' ? [] : ((groupUsersList as []) ?? []), + 'email', + ); + this._dialog.open(UserAddDialogComponent, { width: '25em', data: { availableMembers, group }, }); } openDeleteGroupDialog(group: UserGroup) { - this.dialog.open(GroupDeleteDialogComponent, { + this._dialog.open(GroupDeleteDialogComponent, { width: '25em', data: group, }); @@ -228,21 +183,21 @@ export class UsersComponent implements OnInit, OnDestroy { openEditGroupNameDialog(e: Event, group: UserGroup) { e.stopPropagation(); - this.dialog.open(GroupNameEditDialogComponent, { + this._dialog.open(GroupNameEditDialogComponent, { width: '25em', data: group, }); } openCedarPolicyDialog(group: UserGroup) { - this.dialog.open(CedarPolicyEditorDialogComponent, { + this._dialog.open(CedarPolicyEditorDialogComponent, { width: '40em', data: { groupId: group.id, groupTitle: group.title, cedarPolicy: group.cedarPolicy }, }); } openDeleteUserDialog(user: GroupUser, group: UserGroup) { - this.dialog.open(UserDeleteDialogComponent, { + this._dialog.open(UserDeleteDialogComponent, { width: '25em', data: { user, group }, }); diff --git a/frontend/src/app/lib/cedar-policy-items.ts b/frontend/src/app/lib/cedar-policy-items.ts index cc2aeb652..e0390e338 100644 --- a/frontend/src/app/lib/cedar-policy-items.ts +++ b/frontend/src/app/lib/cedar-policy-items.ts @@ -52,7 +52,7 @@ export const POLICY_ACTION_GROUPS: PolicyActionGroup[] = [ actions: [ { value: 'dashboard:*', label: 'Full dashboard access', needsTable: false, needsDashboard: true }, { value: 'dashboard:read', label: 'Dashboard read', needsTable: false, needsDashboard: true }, - { value: 'dashboard:create', label: 'Dashboard create', needsTable: false, needsDashboard: true }, + { value: 'dashboard:create', label: 'Dashboard create', needsTable: false, needsDashboard: false }, { value: 'dashboard:edit', label: 'Dashboard edit', needsTable: false, needsDashboard: true }, { value: 'dashboard:delete', label: 'Dashboard delete', needsTable: false, needsDashboard: true }, ], @@ -66,10 +66,9 @@ export function permissionsToPolicyItems(permissions: Permissions): CedarPolicyI const connAccess = permissions.connection.accessLevel; if (connAccess === AccessLevel.Edit) { - items.push({ action: '*' }); - return items; - } - if (connAccess === AccessLevel.Readonly) { + items.push({ action: 'connection:read' }); + items.push({ action: 'connection:edit' }); + } else if (connAccess === AccessLevel.Readonly) { items.push({ action: 'connection:read' }); } @@ -137,6 +136,8 @@ export function policyItemsToCedarPolicy(items: CedarPolicyItem[], connectionId: if (item.action.startsWith('table:')) { resource = buildResourceRef('Table', connectionId, item.tableName); + } else if (item.action === 'dashboard:create') { + resource = `resource == RocketAdmin::Connection::"${connectionId}"`; } else if (item.action.startsWith('dashboard:')) { resource = buildResourceRef('Dashboard', connectionId, item.dashboardId); } else if (item.action.startsWith('group:')) { diff --git a/frontend/src/app/lib/cedar-policy-parser.ts b/frontend/src/app/lib/cedar-policy-parser.ts index 8f938297d..d90dedea9 100644 --- a/frontend/src/app/lib/cedar-policy-parser.ts +++ b/frontend/src/app/lib/cedar-policy-parser.ts @@ -126,9 +126,13 @@ export function parseCedarDashboardItems(policyText: string, connectionId: strin for (const permit of permits) { if (!permit.action || !permit.action.startsWith('dashboard:')) continue; - const dashboardId = extractResourceSuffix(permit.resourceId, connectionId); - if (dashboardId) { - items.push({ action: permit.action, dashboardId }); + if (permit.action === 'dashboard:create') { + items.push({ action: permit.action }); + } else { + const dashboardId = extractResourceSuffix(permit.resourceId, connectionId); + if (dashboardId) { + items.push({ action: permit.action, dashboardId }); + } } } diff --git a/frontend/src/app/lib/cedar-policy-roundtrip.spec.ts b/frontend/src/app/lib/cedar-policy-roundtrip.spec.ts new file mode 100644 index 000000000..77dacb672 --- /dev/null +++ b/frontend/src/app/lib/cedar-policy-roundtrip.spec.ts @@ -0,0 +1,555 @@ +import { checkParsePolicySet, initSync } from '@cedar-policy/cedar-wasm/web'; +import { AccessLevel, Permissions, TablePermission } from '../models/user'; +import { CedarPolicyItem, permissionsToPolicyItems, policyItemsToCedarPolicy } from './cedar-policy-items'; +import { canRepresentAsForm, parseCedarDashboardItems, parseCedarPolicy } from './cedar-policy-parser'; + +const CONNECTION_ID = 'conn-abc-123'; +const GROUP_ID = 'group-xyz-789'; + +const AVAILABLE_TABLES: TablePermission[] = [ + { + tableName: 'users', + display_name: 'Users', + accessLevel: { visibility: false, readonly: false, add: false, delete: false, edit: false }, + }, + { + tableName: 'orders', + display_name: 'Orders', + accessLevel: { visibility: false, readonly: false, add: false, delete: false, edit: false }, + }, + { + tableName: 'products', + display_name: 'Products', + accessLevel: { visibility: false, readonly: false, add: false, delete: false, edit: false }, + }, +]; + +function assertValidCedar(policyText: string, label: string) { + const result = checkParsePolicySet({ + staticPolicies: policyText, + templates: {}, + templateLinks: [], + }); + if (result.type !== 'success') { + throw new Error( + `${label}: invalid Cedar policy.\nErrors: ${JSON.stringify(result.errors)}\nPolicy:\n${policyText}`, + ); + } +} + +function roundTrip( + cedarText: string, + availableTables: TablePermission[] = AVAILABLE_TABLES, +): { permissions: Permissions; items: CedarPolicyItem[]; reserialized: string } { + const permissions = parseCedarPolicy(cedarText, CONNECTION_ID, GROUP_ID, availableTables); + const items = permissionsToPolicyItems(permissions); + const dashboardItems = parseCedarDashboardItems(cedarText, CONNECTION_ID); + const allItems = [...items, ...dashboardItems]; + const reserialized = policyItemsToCedarPolicy(allItems, CONNECTION_ID, GROUP_ID); + return { permissions, items: allItems, reserialized }; +} + +describe('Cedar policy deserialization => serialization round-trip', () => { + beforeAll(async () => { + const wasmBytes = await fetch('/assets/cedar-wasm/cedar_wasm_bg.wasm').then((r) => r.arrayBuffer()); + initSync({ module: new WebAssembly.Module(wasmBytes) }); + }); + + describe('connection edit policy', () => { + const connectionEditPolicy = [ + 'permit(', + ' principal,', + ' action == RocketAdmin::Action::"connection:edit",', + ' resource == RocketAdmin::Connection::"conn-abc-123"', + ');', + ].join('\n'); + + it('original policy should be valid Cedar', () => { + assertValidCedar(connectionEditPolicy, 'original'); + }); + + it('should round-trip to valid Cedar', () => { + const { reserialized } = roundTrip(connectionEditPolicy); + assertValidCedar(reserialized, 'reserialized'); + }); + + it('should preserve connection edit access level', () => { + const { permissions } = roundTrip(connectionEditPolicy); + expect(permissions.connection.accessLevel).toBe(AccessLevel.Edit); + }); + + it('re-serialized policy should parse back to the same permissions', () => { + const { reserialized } = roundTrip(connectionEditPolicy); + const reparsed = parseCedarPolicy(reserialized, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); + const original = parseCedarPolicy(connectionEditPolicy, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); + expect(reparsed.connection.accessLevel).toBe(original.connection.accessLevel); + expect(reparsed.group.accessLevel).toBe(original.group.accessLevel); + expect(reparsed.tables).toEqual(original.tables); + }); + }); + + describe('connection read-only policy', () => { + const connectionReadPolicy = [ + 'permit(', + ' principal,', + ' action == RocketAdmin::Action::"connection:read",', + ' resource == RocketAdmin::Connection::"conn-abc-123"', + ');', + ].join('\n'); + + it('should round-trip to valid Cedar', () => { + const { reserialized } = roundTrip(connectionReadPolicy); + assertValidCedar(reserialized, 'reserialized'); + }); + + it('should preserve connection readonly access level', () => { + const { permissions } = roundTrip(connectionReadPolicy); + expect(permissions.connection.accessLevel).toBe(AccessLevel.Readonly); + }); + + it('re-serialized policy should parse back identically', () => { + const { reserialized } = roundTrip(connectionReadPolicy); + const reparsed = parseCedarPolicy(reserialized, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); + expect(reparsed.connection.accessLevel).toBe(AccessLevel.Readonly); + }); + }); + + describe('full access (wildcard) policy', () => { + const fullAccessPolicy = ['permit(', ' principal,', ' action,', ' resource', ');'].join('\n'); + + it('should round-trip to valid Cedar', () => { + const { reserialized } = roundTrip(fullAccessPolicy); + assertValidCedar(reserialized, 'reserialized'); + }); + + it('should parse as full access', () => { + const { permissions } = roundTrip(fullAccessPolicy); + expect(permissions.connection.accessLevel).toBe(AccessLevel.Edit); + expect(permissions.group.accessLevel).toBe(AccessLevel.Edit); + for (const table of permissions.tables) { + expect(table.accessLevel.visibility).toBe(true); + expect(table.accessLevel.add).toBe(true); + expect(table.accessLevel.edit).toBe(true); + expect(table.accessLevel.delete).toBe(true); + } + }); + + it('re-serialized policy should parse back to full access', () => { + const { reserialized } = roundTrip(fullAccessPolicy); + const reparsed = parseCedarPolicy(reserialized, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); + expect(reparsed.connection.accessLevel).toBe(AccessLevel.Edit); + expect(reparsed.group.accessLevel).toBe(AccessLevel.Edit); + }); + }); + + describe('group permissions policy', () => { + const groupPolicy = [ + 'permit(', + ' principal,', + ' action == RocketAdmin::Action::"group:read",', + ' resource == RocketAdmin::Group::"group-xyz-789"', + ');', + '', + 'permit(', + ' principal,', + ' action == RocketAdmin::Action::"group:edit",', + ' resource == RocketAdmin::Group::"group-xyz-789"', + ');', + ].join('\n'); + + it('should round-trip to valid Cedar', () => { + const { reserialized } = roundTrip(groupPolicy); + assertValidCedar(reserialized, 'reserialized'); + }); + + it('should preserve group edit access level', () => { + const { permissions } = roundTrip(groupPolicy); + expect(permissions.group.accessLevel).toBe(AccessLevel.Edit); + }); + + it('re-serialized policy should parse back identically', () => { + const { reserialized } = roundTrip(groupPolicy); + const reparsed = parseCedarPolicy(reserialized, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); + expect(reparsed.group.accessLevel).toBe(AccessLevel.Edit); + }); + }); + + describe('single table read policy', () => { + const tableReadPolicy = [ + 'permit(', + ' principal,', + ' action == RocketAdmin::Action::"table:read",', + ' resource == RocketAdmin::Table::"conn-abc-123/users"', + ');', + ].join('\n'); + + it('should round-trip to valid Cedar', () => { + const { reserialized } = roundTrip(tableReadPolicy); + assertValidCedar(reserialized, 'reserialized'); + }); + + it('should parse users table as visible and readonly', () => { + const { permissions } = roundTrip(tableReadPolicy); + const usersTable = permissions.tables.find((t) => t.tableName === 'users'); + expect(usersTable).toBeTruthy(); + expect(usersTable!.accessLevel.visibility).toBe(true); + expect(usersTable!.accessLevel.readonly).toBe(true); + expect(usersTable!.accessLevel.add).toBe(false); + expect(usersTable!.accessLevel.edit).toBe(false); + expect(usersTable!.accessLevel.delete).toBe(false); + }); + + it('other tables should have no access', () => { + const { permissions } = roundTrip(tableReadPolicy); + const ordersTable = permissions.tables.find((t) => t.tableName === 'orders'); + expect(ordersTable!.accessLevel.visibility).toBe(false); + expect(ordersTable!.accessLevel.readonly).toBe(false); + }); + + it('re-serialized policy should parse back identically', () => { + const { reserialized } = roundTrip(tableReadPolicy); + const reparsed = parseCedarPolicy(reserialized, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); + const original = parseCedarPolicy(tableReadPolicy, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); + expect(reparsed.tables).toEqual(original.tables); + }); + }); + + describe('table full access (action in [...]) policy', () => { + const tableFullAccessPolicy = [ + 'permit(', + ' principal,', + ' action in [RocketAdmin::Action::"table:read", RocketAdmin::Action::"table:add", RocketAdmin::Action::"table:edit", RocketAdmin::Action::"table:delete"],', + ' resource == RocketAdmin::Table::"conn-abc-123/orders"', + ');', + ].join('\n'); + + it('should round-trip to valid Cedar', () => { + const { reserialized } = roundTrip(tableFullAccessPolicy); + assertValidCedar(reserialized, 'reserialized'); + }); + + it('should parse orders table with full access', () => { + const { permissions } = roundTrip(tableFullAccessPolicy); + const ordersTable = permissions.tables.find((t) => t.tableName === 'orders'); + expect(ordersTable!.accessLevel.visibility).toBe(true); + expect(ordersTable!.accessLevel.add).toBe(true); + expect(ordersTable!.accessLevel.edit).toBe(true); + expect(ordersTable!.accessLevel.delete).toBe(true); + }); + + it('re-serialized policy should parse back identically', () => { + const { reserialized } = roundTrip(tableFullAccessPolicy); + const reparsed = parseCedarPolicy(reserialized, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); + const original = parseCedarPolicy(tableFullAccessPolicy, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); + expect(reparsed.tables).toEqual(original.tables); + }); + }); + + describe('wildcard table permissions (all tables)', () => { + const wildcardTablePolicy = [ + 'permit(', + ' principal,', + ' action == RocketAdmin::Action::"table:read",', + ' resource', + ');', + ].join('\n'); + + it('should round-trip to valid Cedar', () => { + const { reserialized } = roundTrip(wildcardTablePolicy); + assertValidCedar(reserialized, 'reserialized'); + }); + + it('should apply read to all tables', () => { + const { permissions } = roundTrip(wildcardTablePolicy); + for (const table of permissions.tables) { + expect(table.accessLevel.visibility).toBe(true); + expect(table.accessLevel.readonly).toBe(true); + } + }); + + it('re-serialized policy should parse back identically', () => { + const { reserialized } = roundTrip(wildcardTablePolicy); + const reparsed = parseCedarPolicy(reserialized, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); + const original = parseCedarPolicy(wildcardTablePolicy, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); + expect(reparsed.tables).toEqual(original.tables); + }); + }); + + describe('mixed policy (connection + group + tables)', () => { + const mixedPolicy = [ + 'permit(', + ' principal,', + ' action == RocketAdmin::Action::"connection:read",', + ' resource == RocketAdmin::Connection::"conn-abc-123"', + ');', + '', + 'permit(', + ' principal,', + ' action == RocketAdmin::Action::"group:read",', + ' resource == RocketAdmin::Group::"group-xyz-789"', + ');', + '', + 'permit(', + ' principal,', + ' action == RocketAdmin::Action::"table:read",', + ' resource == RocketAdmin::Table::"conn-abc-123/users"', + ');', + '', + 'permit(', + ' principal,', + ' action in [RocketAdmin::Action::"table:read", RocketAdmin::Action::"table:edit"],', + ' resource == RocketAdmin::Table::"conn-abc-123/orders"', + ');', + ].join('\n'); + + it('original policy should be valid Cedar', () => { + assertValidCedar(mixedPolicy, 'original'); + }); + + it('should round-trip to valid Cedar', () => { + const { reserialized } = roundTrip(mixedPolicy); + assertValidCedar(reserialized, 'reserialized'); + }); + + it('should preserve all access levels', () => { + const { permissions } = roundTrip(mixedPolicy); + expect(permissions.connection.accessLevel).toBe(AccessLevel.Readonly); + expect(permissions.group.accessLevel).toBe(AccessLevel.Readonly); + + const usersTable = permissions.tables.find((t) => t.tableName === 'users'); + expect(usersTable!.accessLevel.visibility).toBe(true); + expect(usersTable!.accessLevel.readonly).toBe(true); + expect(usersTable!.accessLevel.edit).toBe(false); + + const ordersTable = permissions.tables.find((t) => t.tableName === 'orders'); + expect(ordersTable!.accessLevel.visibility).toBe(true); + expect(ordersTable!.accessLevel.edit).toBe(true); + }); + + it('re-serialized policy should parse back to the same permissions', () => { + const { reserialized } = roundTrip(mixedPolicy); + const reparsed = parseCedarPolicy(reserialized, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); + const original = parseCedarPolicy(mixedPolicy, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); + expect(reparsed.connection.accessLevel).toBe(original.connection.accessLevel); + expect(reparsed.group.accessLevel).toBe(original.group.accessLevel); + expect(reparsed.tables).toEqual(original.tables); + }); + + it('should be representable as form', () => { + expect(canRepresentAsForm(mixedPolicy)).toBe(true); + }); + }); + + describe('dashboard permissions policy', () => { + const dashboardPolicy = [ + 'permit(', + ' principal,', + ' action == RocketAdmin::Action::"dashboard:read",', + ' resource == RocketAdmin::Dashboard::"conn-abc-123/dash-001"', + ');', + '', + 'permit(', + ' principal,', + ' action == RocketAdmin::Action::"dashboard:edit",', + ' resource == RocketAdmin::Dashboard::"conn-abc-123/dash-001"', + ');', + ].join('\n'); + + it('should round-trip to valid Cedar', () => { + const { reserialized } = roundTrip(dashboardPolicy); + assertValidCedar(reserialized, 'reserialized'); + }); + + it('should parse dashboard items correctly', () => { + const dashboardItems = parseCedarDashboardItems(dashboardPolicy, CONNECTION_ID); + expect(dashboardItems.length).toBe(2); + expect(dashboardItems.some((item) => item.action === 'dashboard:read')).toBe(true); + expect(dashboardItems.some((item) => item.action === 'dashboard:edit')).toBe(true); + expect(dashboardItems.every((item) => item.dashboardId === 'dash-001')).toBe(true); + }); + + it('re-serialized policy should parse back to the same dashboard items', () => { + const { reserialized } = roundTrip(dashboardPolicy); + const originalItems = parseCedarDashboardItems(dashboardPolicy, CONNECTION_ID); + const reparsedItems = parseCedarDashboardItems(reserialized, CONNECTION_ID); + expect(reparsedItems).toEqual(originalItems); + }); + }); + + describe('multiple table-specific permissions', () => { + const multiTablePolicy = [ + 'permit(', + ' principal,', + ' action == RocketAdmin::Action::"table:read",', + ' resource == RocketAdmin::Table::"conn-abc-123/users"', + ');', + '', + 'permit(', + ' principal,', + ' action == RocketAdmin::Action::"table:add",', + ' resource == RocketAdmin::Table::"conn-abc-123/users"', + ');', + '', + 'permit(', + ' principal,', + ' action == RocketAdmin::Action::"table:read",', + ' resource == RocketAdmin::Table::"conn-abc-123/orders"', + ');', + '', + 'permit(', + ' principal,', + ' action == RocketAdmin::Action::"table:delete",', + ' resource == RocketAdmin::Table::"conn-abc-123/orders"', + ');', + ].join('\n'); + + it('should round-trip to valid Cedar', () => { + const { reserialized } = roundTrip(multiTablePolicy); + assertValidCedar(reserialized, 'reserialized'); + }); + + it('should parse per-table permissions correctly', () => { + const { permissions } = roundTrip(multiTablePolicy); + + const usersTable = permissions.tables.find((t) => t.tableName === 'users'); + expect(usersTable!.accessLevel.visibility).toBe(true); + expect(usersTable!.accessLevel.add).toBe(true); + expect(usersTable!.accessLevel.edit).toBe(false); + + const ordersTable = permissions.tables.find((t) => t.tableName === 'orders'); + expect(ordersTable!.accessLevel.visibility).toBe(true); + expect(ordersTable!.accessLevel.delete).toBe(true); + expect(ordersTable!.accessLevel.add).toBe(false); + + const productsTable = permissions.tables.find((t) => t.tableName === 'products'); + expect(productsTable!.accessLevel.visibility).toBe(false); + }); + + it('re-serialized policy should parse back identically', () => { + const { reserialized } = roundTrip(multiTablePolicy); + const reparsed = parseCedarPolicy(reserialized, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); + const original = parseCedarPolicy(multiTablePolicy, CONNECTION_ID, GROUP_ID, AVAILABLE_TABLES); + expect(reparsed.tables).toEqual(original.tables); + }); + }); + + describe('canRepresentAsForm validation', () => { + it('forbid statements should not be representable', () => { + const policy = 'forbid(principal, action, resource);'; + expect(canRepresentAsForm(policy)).toBe(false); + }); + + it('when clauses should not be representable', () => { + const policy = [ + 'permit(', + ' principal,', + ' action == RocketAdmin::Action::"table:read",', + ' resource', + ') when { principal.isAdmin };', + ].join('\n'); + expect(canRepresentAsForm(policy)).toBe(false); + }); + + it('unless clauses should not be representable', () => { + const policy = [ + 'permit(', + ' principal,', + ' action == RocketAdmin::Action::"table:read",', + ' resource', + ') unless { principal.isGuest };', + ].join('\n'); + expect(canRepresentAsForm(policy)).toBe(false); + }); + + it('empty policy should be representable', () => { + expect(canRepresentAsForm('')).toBe(true); + }); + + it('standard permit policies should be representable', () => { + const policy = [ + 'permit(', + ' principal,', + ' action == RocketAdmin::Action::"connection:read",', + ' resource == RocketAdmin::Connection::"conn-abc-123"', + ');', + ].join('\n'); + expect(canRepresentAsForm(policy)).toBe(true); + }); + }); + + describe('cedar-wasm validation of all generated policies', () => { + it('connection:read item generates valid Cedar', () => { + const items: CedarPolicyItem[] = [{ action: 'connection:read' }]; + const policy = policyItemsToCedarPolicy(items, CONNECTION_ID, GROUP_ID); + assertValidCedar(policy, 'connection:read'); + }); + + it('connection:edit item generates valid Cedar', () => { + const items: CedarPolicyItem[] = [{ action: 'connection:edit' }]; + const policy = policyItemsToCedarPolicy(items, CONNECTION_ID, GROUP_ID); + assertValidCedar(policy, 'connection:edit'); + }); + + it('group:read item generates valid Cedar', () => { + const items: CedarPolicyItem[] = [{ action: 'group:read' }]; + const policy = policyItemsToCedarPolicy(items, CONNECTION_ID, GROUP_ID); + assertValidCedar(policy, 'group:read'); + }); + + it('group:edit item generates valid Cedar', () => { + const items: CedarPolicyItem[] = [{ action: 'group:edit' }]; + const policy = policyItemsToCedarPolicy(items, CONNECTION_ID, GROUP_ID); + assertValidCedar(policy, 'group:edit'); + }); + + it('table:read with specific table generates valid Cedar', () => { + const items: CedarPolicyItem[] = [{ action: 'table:read', tableName: 'users' }]; + const policy = policyItemsToCedarPolicy(items, CONNECTION_ID, GROUP_ID); + assertValidCedar(policy, 'table:read specific'); + }); + + it('table:* with specific table generates valid Cedar', () => { + const items: CedarPolicyItem[] = [{ action: 'table:*', tableName: 'users' }]; + const policy = policyItemsToCedarPolicy(items, CONNECTION_ID, GROUP_ID); + assertValidCedar(policy, 'table:* specific'); + }); + + it('table:read with wildcard generates valid Cedar', () => { + const items: CedarPolicyItem[] = [{ action: 'table:read', tableName: '*' }]; + const policy = policyItemsToCedarPolicy(items, CONNECTION_ID, GROUP_ID); + assertValidCedar(policy, 'table:read wildcard'); + }); + + it('dashboard:read with specific dashboard generates valid Cedar', () => { + const items: CedarPolicyItem[] = [{ action: 'dashboard:read', dashboardId: 'dash-001' }]; + const policy = policyItemsToCedarPolicy(items, CONNECTION_ID, GROUP_ID); + assertValidCedar(policy, 'dashboard:read specific'); + }); + + it('dashboard:* with specific dashboard generates valid Cedar', () => { + const items: CedarPolicyItem[] = [{ action: 'dashboard:*', dashboardId: 'dash-001' }]; + const policy = policyItemsToCedarPolicy(items, CONNECTION_ID, GROUP_ID); + assertValidCedar(policy, 'dashboard:* specific'); + }); + + it('full access (*) generates valid Cedar', () => { + const items: CedarPolicyItem[] = [{ action: '*' }]; + const policy = policyItemsToCedarPolicy(items, CONNECTION_ID, GROUP_ID); + assertValidCedar(policy, 'full access'); + }); + + it('mixed items generate valid Cedar', () => { + const items: CedarPolicyItem[] = [ + { action: 'connection:read' }, + { action: 'group:read' }, + { action: 'group:edit' }, + { action: 'table:read', tableName: 'users' }, + { action: 'table:add', tableName: 'users' }, + { action: 'table:read', tableName: '*' }, + { action: 'dashboard:read', dashboardId: 'dash-001' }, + ]; + const policy = policyItemsToCedarPolicy(items, CONNECTION_ID, GROUP_ID); + assertValidCedar(policy, 'mixed items'); + }); + }); +}); diff --git a/frontend/src/app/models/user.ts b/frontend/src/app/models/user.ts index 64013c95e..39e42b6a7 100644 --- a/frontend/src/app/models/user.ts +++ b/frontend/src/app/models/user.ts @@ -41,6 +41,7 @@ export interface GroupUser { isActive: boolean; stripeId: string; email: string; + name?: string; } export enum SubscriptionPlans { diff --git a/frontend/src/app/services/cedar-permission.service.spec.ts b/frontend/src/app/services/cedar-permission.service.spec.ts new file mode 100644 index 000000000..302f34cfe --- /dev/null +++ b/frontend/src/app/services/cedar-permission.service.spec.ts @@ -0,0 +1,270 @@ +import { signal, WritableSignal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { BehaviorSubject } from 'rxjs'; +import { User, UserGroupInfo } from '../models/user'; +import { CedarPermissionService } from './cedar-permission.service'; +import { CedarWasmService } from './cedar-wasm.service'; +import { UserService } from './user.service'; +import { UsersService } from './users.service'; + +const CONN_ID = 'conn-abc'; +const USER_ID = 'user-1'; + +function makeGroup(id: string, cedarPolicy: string | null, userIds: string[]): UserGroupInfo { + return { + group: { + id, + title: `Group ${id}`, + isMain: false, + cedarPolicy, + users: userIds.map((uid) => ({ + id: uid, + isActive: true, + email: `${uid}@test.com`, + name: uid, + is_2fa_enabled: false, + role: 'MEMBER' as never, + })), + }, + accessLevel: 'edit', + }; +} + +function permitPolicy(action: string, resourceType: string, resourceId: string): string { + return [ + 'permit(', + ' principal,', + ` action == RocketAdmin::Action::"${action}",`, + ` resource == RocketAdmin::${resourceType}::"${resourceId}"`, + ');', + ].join('\n'); +} + +describe('CedarPermissionService', () => { + let service: CedarPermissionService; + let groupsSignal: WritableSignal; + let userSubject: BehaviorSubject; + let mockIsAuthorized: ReturnType; + let wasmLoaded: () => void; + + beforeEach(() => { + groupsSignal = signal([]); + userSubject = new BehaviorSubject(null); + + mockIsAuthorized = vi.fn().mockReturnValue({ + type: 'success', + response: { decision: 'deny', diagnostics: { reason: [], errors: [] } }, + warnings: [], + }); + + let resolveWasm: (mod: unknown) => void; + const wasmPromise = new Promise((resolve) => { + resolveWasm = resolve; + }); + wasmLoaded = () => + resolveWasm!({ + isAuthorized: mockIsAuthorized, + checkParsePolicySet: vi.fn(), + }); + + TestBed.configureTestingModule({ + providers: [ + CedarPermissionService, + { + provide: CedarWasmService, + useValue: { load: () => wasmPromise }, + }, + { + provide: UserService, + useValue: { cast: userSubject.asObservable() }, + }, + { + provide: UsersService, + useValue: { groups: groupsSignal.asReadonly() }, + }, + ], + }); + + service = TestBed.inject(CedarPermissionService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('canI signal returns null when WASM not loaded', () => { + userSubject.next({ id: USER_ID } as User); + groupsSignal.set([makeGroup('g1', permitPolicy('connection:read', 'Connection', CONN_ID), [USER_ID])]); + + const sig = service.canI('connection:read', 'Connection', CONN_ID); + expect(sig()).toBe(null); + }); + + it('canI signal returns null when no user', async () => { + wasmLoaded(); + await TestBed.inject(CedarWasmService).load(); + + groupsSignal.set([makeGroup('g1', permitPolicy('connection:read', 'Connection', CONN_ID), [USER_ID])]); + + const sig = service.canI('connection:read', 'Connection', CONN_ID); + expect(sig()).toBe(null); + expect(mockIsAuthorized).not.toHaveBeenCalled(); + }); + + it('canI signal returns null when user has no groups', async () => { + wasmLoaded(); + await TestBed.inject(CedarWasmService).load(); + userSubject.next({ id: USER_ID } as User); + + const sig = service.canI('connection:read', 'Connection', CONN_ID); + expect(sig()).toBe(null); + expect(mockIsAuthorized).not.toHaveBeenCalled(); + }); + + it('canI signal returns true when policy permits the action', async () => { + wasmLoaded(); + await TestBed.inject(CedarWasmService).load(); + userSubject.next({ id: USER_ID } as User); + groupsSignal.set([makeGroup('g1', permitPolicy('connection:read', 'Connection', CONN_ID), [USER_ID])]); + + mockIsAuthorized.mockReturnValue({ + type: 'success', + response: { decision: 'allow', diagnostics: { reason: ['policy0'], errors: [] } }, + warnings: [], + }); + + const sig = service.canI('connection:read', 'Connection', CONN_ID); + expect(sig()).toBe(true); + expect(mockIsAuthorized).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { type: 'RocketAdmin::User', id: USER_ID }, + action: { type: 'RocketAdmin::Action', id: 'connection:read' }, + resource: { type: 'RocketAdmin::Connection', id: CONN_ID }, + }), + ); + }); + + it('canI signal returns false when policy denies the action', async () => { + wasmLoaded(); + await TestBed.inject(CedarWasmService).load(); + userSubject.next({ id: USER_ID } as User); + groupsSignal.set([makeGroup('g1', permitPolicy('connection:read', 'Connection', CONN_ID), [USER_ID])]); + + mockIsAuthorized.mockReturnValue({ + type: 'success', + response: { decision: 'deny', diagnostics: { reason: [], errors: [] } }, + warnings: [], + }); + + const sig = service.canI('connection:edit', 'Connection', CONN_ID); + expect(sig()).toBe(false); + }); + + it('merges policies from multiple groups', async () => { + wasmLoaded(); + await TestBed.inject(CedarWasmService).load(); + userSubject.next({ id: USER_ID } as User); + + const policy1 = permitPolicy('connection:read', 'Connection', CONN_ID); + const policy2 = permitPolicy('table:read', 'Table', `${CONN_ID}/users`); + groupsSignal.set([makeGroup('g1', policy1, [USER_ID]), makeGroup('g2', policy2, [USER_ID])]); + + mockIsAuthorized.mockReturnValue({ + type: 'success', + response: { decision: 'allow', diagnostics: { reason: [], errors: [] } }, + warnings: [], + }); + + service.canI('connection:read', 'Connection', CONN_ID)(); + + const call = mockIsAuthorized.mock.calls[0][0]; + expect(call.policies.staticPolicies).toContain('connection:read'); + expect(call.policies.staticPolicies).toContain('table:read'); + }); + + it('ignores groups the user does not belong to', async () => { + wasmLoaded(); + await TestBed.inject(CedarWasmService).load(); + userSubject.next({ id: USER_ID } as User); + + const userPolicy = permitPolicy('connection:read', 'Connection', CONN_ID); + const otherPolicy = permitPolicy('connection:edit', 'Connection', CONN_ID); + groupsSignal.set([makeGroup('g1', userPolicy, [USER_ID]), makeGroup('g2', otherPolicy, ['other-user'])]); + + mockIsAuthorized.mockReturnValue({ + type: 'success', + response: { decision: 'allow', diagnostics: { reason: [], errors: [] } }, + warnings: [], + }); + + service.canI('connection:read', 'Connection', CONN_ID)(); + + const call = mockIsAuthorized.mock.calls[0][0]; + expect(call.policies.staticPolicies).toContain('connection:read'); + expect(call.policies.staticPolicies).not.toContain('connection:edit'); + }); + + it('signal auto-refreshes when groups change', async () => { + wasmLoaded(); + await TestBed.inject(CedarWasmService).load(); + userSubject.next({ id: USER_ID } as User); + + // Initially no groups → null (not yet determined) + const sig = service.canI('connection:read', 'Connection', CONN_ID); + expect(sig()).toBe(null); + + // Add a group with a permit policy + groupsSignal.set([makeGroup('g1', permitPolicy('connection:read', 'Connection', CONN_ID), [USER_ID])]); + + mockIsAuthorized.mockReturnValue({ + type: 'success', + response: { decision: 'allow', diagnostics: { reason: [], errors: [] } }, + warnings: [], + }); + + // Same signal should now return true + expect(sig()).toBe(true); + }); + + it('same args return same signal instance', () => { + const sig1 = service.canI('connection:read', 'Connection', CONN_ID); + const sig2 = service.canI('connection:read', 'Connection', CONN_ID); + expect(sig1).toBe(sig2); + }); + + it('different args return different signal instances', () => { + const sig1 = service.canI('connection:read', 'Connection', CONN_ID); + const sig2 = service.canI('connection:edit', 'Connection', CONN_ID); + expect(sig1).not.toBe(sig2); + }); + + it('ready signal reflects state correctly', async () => { + expect(service.ready()).toBe(false); + + wasmLoaded(); + await TestBed.inject(CedarWasmService).load(); + expect(service.ready()).toBe(false); // no user yet + + userSubject.next({ id: USER_ID } as User); + expect(service.ready()).toBe(false); // no groups yet + + groupsSignal.set([makeGroup('g1', permitPolicy('connection:read', 'Connection', CONN_ID), [USER_ID])]); + expect(service.ready()).toBe(true); + }); + + it('returns false on isAuthorized failure', async () => { + wasmLoaded(); + await TestBed.inject(CedarWasmService).load(); + userSubject.next({ id: USER_ID } as User); + groupsSignal.set([makeGroup('g1', permitPolicy('connection:read', 'Connection', CONN_ID), [USER_ID])]); + + mockIsAuthorized.mockReturnValue({ + type: 'failure', + errors: [{ message: 'some error' }], + warnings: [], + }); + + const sig = service.canI('connection:read', 'Connection', CONN_ID); + expect(sig()).toBe(false); + }); +}); diff --git a/frontend/src/app/services/cedar-permission.service.ts b/frontend/src/app/services/cedar-permission.service.ts new file mode 100644 index 000000000..117befe87 --- /dev/null +++ b/frontend/src/app/services/cedar-permission.service.ts @@ -0,0 +1,80 @@ +import { computed, Injectable, inject, Signal, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { User } from '../models/user'; +import { CedarWasmService } from './cedar-wasm.service'; +import { UserService } from './user.service'; +import { UsersService } from './users.service'; + +type CedarWasmModule = typeof import('@cedar-policy/cedar-wasm/web'); + +@Injectable({ providedIn: 'root' }) +export class CedarPermissionService { + private _cedarWasm = inject(CedarWasmService); + private _userService = inject(UserService); + private _usersService = inject(UsersService); + + private _currentUser = toSignal(this._userService.cast, { initialValue: null as User | null }); + private _userId = computed(() => this._currentUser()?.id || null); + private _wasmModule = signal(null); + + private _mergedPolicies = computed(() => { + const userId = this._userId(); + const groups = this._usersService.groups(); + if (!userId || !groups.length) return ''; + + const userGroups = groups.filter((gi) => gi.group.users?.some((u) => u.id === userId)); + + return userGroups + .map((gi) => gi.group.cedarPolicy) + .filter((p): p is string => !!p && p.trim().length > 0) + .join('\n\n'); + }); + + public readonly ready = computed(() => !!this._wasmModule() && !!this._mergedPolicies() && !!this._userId()); + + private _signals = new Map>(); + + constructor() { + this._cedarWasm.load().then((mod) => { + this._wasmModule.set(mod); + }); + } + + canI(action: string, resourceType: string, resourceId: string): Signal { + const key = `${action}|${resourceType}|${resourceId}`; + let sig = this._signals.get(key); + if (!sig) { + sig = computed(() => this._evaluate(action, resourceType, resourceId)); + this._signals.set(key, sig); + } + return sig; + } + + private _evaluate(action: string, resourceType: string, resourceId: string): boolean | null { + const cedar = this._wasmModule(); + const mergedPolicies = this._mergedPolicies(); + const userId = this._userId(); + + if (!cedar || !mergedPolicies || !userId) return null; + + const result = cedar.isAuthorized({ + principal: { type: 'RocketAdmin::User', id: userId }, + action: { type: 'RocketAdmin::Action', id: action }, + resource: { type: `RocketAdmin::${resourceType}`, id: resourceId }, + context: {}, + policies: { + staticPolicies: mergedPolicies, + templates: {}, + templateLinks: [], + }, + entities: [], + }); + + if (result.type === 'success') { + return result.response.decision === 'allow'; + } + + console.warn('Cedar authorization failed:', result.errors); + return false; + } +} diff --git a/frontend/src/app/services/cedar-validator.service.ts b/frontend/src/app/services/cedar-validator.service.ts new file mode 100644 index 000000000..e2ea0274b --- /dev/null +++ b/frontend/src/app/services/cedar-validator.service.ts @@ -0,0 +1,36 @@ +import { Injectable, inject } from '@angular/core'; +import { CedarWasmService } from './cedar-wasm.service'; + +export interface CedarValidationResult { + valid: boolean; + errors: string[]; +} + +@Injectable({ providedIn: 'root' }) +export class CedarValidatorService { + private _cedarWasm = inject(CedarWasmService); + + async validate(policyText: string): Promise { + if (!policyText.trim()) { + return { valid: true, errors: [] }; + } + + const cedar = await this._cedarWasm.load(); + const result = cedar.checkParsePolicySet({ + staticPolicies: policyText, + templates: {}, + templateLinks: [], + }); + + if (result.type === 'success') { + return { valid: true, errors: [] }; + } + + const errors = (result.errors ?? []).map((e: unknown) => { + if (typeof e === 'string') return e; + if (e && typeof e === 'object' && 'message' in e) return (e as { message: string }).message; + return String(e); + }); + return { valid: false, errors }; + } +} diff --git a/frontend/src/app/services/cedar-wasm.service.ts b/frontend/src/app/services/cedar-wasm.service.ts new file mode 100644 index 000000000..fde14bbd7 --- /dev/null +++ b/frontend/src/app/services/cedar-wasm.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; + +type CedarWasmModule = typeof import('@cedar-policy/cedar-wasm/web'); + +@Injectable({ providedIn: 'root' }) +export class CedarWasmService { + private _module: CedarWasmModule | null = null; + private _loading: Promise | null = null; + + async load(): Promise { + if (this._module) return this._module; + if (this._loading) return this._loading; + + this._loading = (async () => { + const cedar = await import('@cedar-policy/cedar-wasm/web'); + const wasmBytes = await fetch('/assets/cedar-wasm/cedar_wasm_bg.wasm').then((r) => r.arrayBuffer()); + cedar.initSync({ module: new WebAssembly.Module(wasmBytes) }); + this._module = cedar; + return cedar; + })(); + + return this._loading; + } +} diff --git a/frontend/src/app/services/connections.service.spec.ts b/frontend/src/app/services/connections.service.spec.ts index d6ebf7dd0..8ca5e44e1 100644 --- a/frontend/src/app/services/connections.service.spec.ts +++ b/frontend/src/app/services/connections.service.spec.ts @@ -1,5 +1,6 @@ import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { MatDialogModule } from '@angular/material/dialog'; import { MatSnackBarModule } from '@angular/material/snack-bar'; @@ -8,9 +9,11 @@ import { of } from 'rxjs'; import { AlertActionType, AlertType } from '../models/alert'; import { ConnectionType, DBtype } from '../models/connection'; import { AccessLevel } from '../models/user'; +import { CedarPermissionService } from './cedar-permission.service'; import { ConnectionsService } from './connections.service'; import { MasterPasswordService } from './master-password.service'; import { NotificationsService } from './notifications.service'; +import { UsersService } from './users.service'; describe('ConnectionsService', () => { let httpMock: HttpTestingController; @@ -18,6 +21,8 @@ describe('ConnectionsService', () => { let fakeNotifications; let fakeMasterPassword; + let mockCanI: ReturnType; + let mockPermissions: Partial; const connectionCredsApp = { title: 'Test connection via SSH tunnel to mySQL', @@ -102,6 +107,11 @@ describe('ConnectionsService', () => { showMasterPasswordDialog: vi.fn(), checkMasterPassword: vi.fn(), }; + mockCanI = vi.fn().mockReturnValue(signal(false).asReadonly()); + mockPermissions = { + canI: mockCanI, + ready: signal(false).asReadonly(), + }; TestBed.configureTestingModule({ imports: [MatSnackBarModule, MatDialogModule], @@ -118,6 +128,14 @@ describe('ConnectionsService', () => { provide: MasterPasswordService, useValue: fakeMasterPassword, }, + { + provide: UsersService, + useValue: { setActiveConnection: vi.fn() }, + }, + { + provide: CedarPermissionService, + useValue: mockPermissions, + }, ], }); @@ -257,14 +275,11 @@ describe('ConnectionsService', () => { expect(service.visibleTabs).toEqual(['dashboard', 'dashboards', 'audit']); }); - it('should get visible tabs dashboard, dashboards, audit and permissions if groupsAccessLevel is true', () => { - service.groupsAccessLevel = true; - expect(service.visibleTabs).toEqual(['dashboard', 'dashboards', 'audit', 'permissions']); - }); - - it('should get visible tabs dashboard, dashboards, audit, connection-settings and edit-db if connectionAccessLevel is edit', () => { - service.connectionAccessLevel = AccessLevel.Edit; - expect(service.visibleTabs).toEqual(['dashboard', 'dashboards', 'audit', 'connection-settings', 'edit-db']); + it('should get visible tabs with permissions if canI group:read returns true', () => { + mockCanI.mockReturnValue(signal(true).asReadonly()); + expect(service.visibleTabs).toContain('permissions'); + expect(service.visibleTabs).toContain('connection-settings'); + expect(service.visibleTabs).toContain('edit-db'); }); it('should call fetchConnections', () => { diff --git a/frontend/src/app/services/connections.service.ts b/frontend/src/app/services/connections.service.ts index f88872747..57eec67e8 100644 --- a/frontend/src/app/services/connections.service.ts +++ b/frontend/src/app/services/connections.service.ts @@ -7,8 +7,10 @@ import { catchError, filter, map } from 'rxjs/operators'; import { AlertActionType, AlertType } from '../models/alert'; import { Connection, ConnectionSettings, ConnectionType, DBtype } from '../models/connection'; import { AccessLevel } from '../models/user'; +import { CedarPermissionService } from './cedar-permission.service'; import { MasterPasswordService } from './master-password.service'; import { NotificationsService } from './notifications.service'; +import { UsersService } from './users.service'; interface LogParams { connectionID: string; @@ -69,6 +71,8 @@ export class ConnectionsService { private router: Router, private _notifications: NotificationsService, private _masterPassword: MasterPasswordService, + private _usersService: UsersService, + private _permissions: CedarPermissionService, public _themeService: NgxThemeService>, ) { this.connection = { ...this.connectionInitialState }; @@ -84,6 +88,9 @@ export class ConnectionsService { this.currentPage = this.router.routerState.snapshot.root.firstChild.url[0].path; this.setConnectionID(urlConnectionID); this.setConnectionInfo(urlConnectionID); + if (urlConnectionID) { + this._usersService.setActiveConnection(urlConnectionID); + } this._notifications.resetAlert(); }); } @@ -116,10 +123,16 @@ export class ConnectionsService { return this.currentPage; } + canEditConnection() { + return this._permissions.canI('connection:edit', 'Connection', this.connectionID)(); + } + get visibleTabs() { - let tabs = ['dashboard', 'dashboards', 'audit']; - if (this.groupsAccessLevel) tabs.push('permissions'); - if (this.isPermitted(this.connectionAccessLevel)) tabs.push('connection-settings', 'edit-db'); + const tabs = ['dashboard', 'dashboards', 'audit']; + if (this._permissions.canI('group:read', 'Group', this.connectionID)()) tabs.push('permissions'); + if (this.canEditConnection()) { + tabs.push('connection-settings', 'edit-db'); + } return tabs; } diff --git a/frontend/src/app/services/users.service.spec.ts b/frontend/src/app/services/users.service.spec.ts index b865070b5..79bda52b2 100644 --- a/frontend/src/app/services/users.service.spec.ts +++ b/frontend/src/app/services/users.service.spec.ts @@ -4,14 +4,21 @@ import { TestBed } from '@angular/core/testing'; import { MatSnackBarModule } from '@angular/material/snack-bar'; import { provideRouter } from '@angular/router'; import { AccessLevel } from '../models/user'; +import { ApiService } from './api.service'; import { NotificationsService } from './notifications.service'; import { UsersService } from './users.service'; describe('UsersService', () => { let service: UsersService; let httpMock: HttpTestingController; - - let fakeNotifications; + let fakeNotifications: { showErrorSnackbar: ReturnType; showSuccessSnackbar: ReturnType }; + let mockApi: { + resource: ReturnType; + get: ReturnType; + post: ReturnType; + put: ReturnType; + delete: ReturnType; + }; const groupNetwork = { title: 'Managers', @@ -107,16 +114,27 @@ describe('UsersService', () => { showSuccessSnackbar: vi.fn(), }; + mockApi = { + resource: vi.fn().mockReturnValue({ + value: () => undefined, + isLoading: () => false, + error: () => null, + reload: vi.fn(), + }), + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }; + TestBed.configureTestingModule({ imports: [MatSnackBarModule], providers: [ provideHttpClient(), provideHttpClientTesting(), provideRouter([]), - { - provide: NotificationsService, - useValue: fakeNotifications, - }, + { provide: NotificationsService, useValue: fakeNotifications }, + { provide: ApiService, useValue: mockApi }, ], }); @@ -128,137 +146,144 @@ describe('UsersService', () => { expect(service).toBeTruthy(); }); - it('should call fetchConnectionUsers', () => { - let isSubscribeCalled = false; - const usersNetwork = [ - { - id: '83f35e11-6499-470e-9ccb-08b6d9393943', - isActive: true, - email: 'lyubov+fghj@voloshko.com', - createdAt: '2021-07-21T14:35:17.270Z', - }, - ]; + // Signal-based service tests - service.fetchConnectionUsers('12345678').subscribe((res) => { - expect(res).toEqual(usersNetwork); - isSubscribeCalled = true; - }); + it('should set active connection', () => { + service.setActiveConnection('conn-123'); + expect(mockApi.resource).toHaveBeenCalled(); + }); - const req = httpMock.expectOne(`/connection/users/12345678`); - expect(req.request.method).toBe('GET'); - req.flush(usersNetwork); + it('should create group via ApiService', async () => { + mockApi.post.mockResolvedValue(groupNetwork); - expect(isSubscribeCalled).toBe(true); + const result = await service.createGroup('12345678', 'Managers'); + + expect(mockApi.post).toHaveBeenCalledWith( + '/connection/group/12345678', + { title: 'Managers' }, + { successMessage: 'Group of users has been created.' }, + ); + expect(result).toEqual(groupNetwork); + expect(service.groupsUpdated()).toBe('group-added'); }); - it('should fall fetchConnectionUsers and show Error snackbar', async () => { - const fetchConnectionUsers = service.fetchConnectionUsers('12345678').toPromise(); + it('should delete group via ApiService', async () => { + mockApi.delete.mockResolvedValue({}); - const req = httpMock.expectOne(`/connection/users/12345678`); - expect(req.request.method).toBe('GET'); - req.flush(fakeError, { status: 400, statusText: '' }); - await fetchConnectionUsers; + await service.deleteGroup('group12345678'); - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); + expect(mockApi.delete).toHaveBeenCalledWith('/group/group12345678', { successMessage: 'Group has been removed.' }); + expect(service.groupsUpdated()).toBe('group-deleted'); }); - it('should call fetchConnectionGroups', () => { - let isSubscribeCalled = false; - const groupsNetwork = [ - { - group: { - id: '014fa4ae-f56f-4084-ac24-58296641678b', - title: 'Admin', - isMain: true, - }, - accessLevel: 'edit', - }, - ]; + it('should edit group name via ApiService', async () => { + mockApi.put.mockResolvedValue({}); - service.fetchConnectionGroups('12345678').subscribe((res) => { - expect(res).toEqual(groupsNetwork); - isSubscribeCalled = true; - }); + await service.editGroupName('group-id', 'New Title'); - const req = httpMock.expectOne(`/connection/groups/12345678`); - expect(req.request.method).toBe('GET'); - req.flush(groupsNetwork); + expect(mockApi.put).toHaveBeenCalledWith( + '/group/title', + { title: 'New Title', groupId: 'group-id' }, + { successMessage: 'Group name has been updated.' }, + ); + expect(service.groupsUpdated()).toBe('group-renamed'); + }); - expect(isSubscribeCalled).toBe(true); + it('should save cedar policy via ApiService', async () => { + mockApi.post.mockResolvedValue({}); + + await service.saveCedarPolicy('conn-123', 'group-123', 'permit(...)'); + + expect(mockApi.post).toHaveBeenCalledWith( + '/connection/cedar-policy/conn-123', + { cedarPolicy: 'permit(...)', groupId: 'group-123' }, + { successMessage: 'Policy has been saved.' }, + ); + expect(service.groupsUpdated()).toBe('policy-saved'); }); - it('should fall fetchConnectionGroups and show Error snackbar', async () => { - // Updated test case - const fetchConnectionGroups = service.fetchConnectionGroups('12345678').toPromise(); + it('should add group user via ApiService', async () => { + mockApi.put.mockResolvedValue({}); - const req = httpMock.expectOne(`/connection/groups/12345678`); - expect(req.request.method).toBe('GET'); - req.flush(fakeError, { status: 400, statusText: '' }); - await fetchConnectionGroups; + await service.addGroupUser('group12345678', 'eric.cartman@south.park'); - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); + expect(mockApi.put).toHaveBeenCalledWith( + '/group/user', + { email: 'eric.cartman@south.park', groupId: 'group12345678' }, + { successMessage: 'User has been added to group.' }, + ); + expect(service.groupsUpdated()).toBe('user-added'); }); - it('should call fetcGroupUsers', () => { - let isSubscribeCalled = false; - const groupUsersNetwork = [ - { - id: '83f35e11-6499-470e-9ccb-08b6d9393943', - createdAt: '2021-07-21T14:35:17.270Z', - gclid: null, - isActive: true, - email: 'lyubov+fghj@voloshko.com', - }, - ]; + it('should delete group user via ApiService', async () => { + mockApi.put.mockResolvedValue({}); - service.fetcGroupUsers('12345678').subscribe((res) => { - expect(res).toEqual(groupUsersNetwork); - isSubscribeCalled = true; - }); + await service.deleteGroupUser('eric.cartman@south.park', 'group12345678'); - const req = httpMock.expectOne(`/group/users/12345678`); - expect(req.request.method).toBe('GET'); - req.flush(groupUsersNetwork); + expect(mockApi.put).toHaveBeenCalledWith( + '/group/user/delete', + { email: 'eric.cartman@south.park', groupId: 'group12345678' }, + { successMessage: 'User has been removed from group.' }, + ); + expect(service.groupsUpdated()).toBe('user-deleted'); + }); - expect(isSubscribeCalled).toBe(true); + it('should fetch group users and update signal', async () => { + const mockUsers = [{ id: 'user-1', createdAt: '', gclid: null, isActive: true, stripeId: '', email: 'a@b.com' }]; + mockApi.get.mockResolvedValue(mockUsers); + + const result = await service.fetchGroupUsers('group-123'); + + expect(mockApi.get).toHaveBeenCalledWith('/group/users/group-123'); + expect(result).toEqual(mockUsers); + expect(service.groupUsers()['group-123']).toEqual(mockUsers); }); - it('should fall fetchConnectionGroups and show Error snackbar', async () => { - // Updated test case - const fetchConnectionGroups = service.fetcGroupUsers('12345678').toPromise(); + it('should set group users to empty when no users returned', async () => { + mockApi.get.mockResolvedValue([]); - const req = httpMock.expectOne(`/group/users/12345678`); - expect(req.request.method).toBe('GET'); - req.flush(fakeError, { status: 400, statusText: '' }); - await fetchConnectionGroups; + await service.fetchGroupUsers('group-123'); - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); + expect(service.groupUsers()['group-123']).toBe('empty'); + }); + + it('should clear groups updated signal', () => { + service.clearGroupsUpdated(); + expect(service.groupsUpdated()).toBe(''); }); - it('should call createUsersGroup', () => { + // Legacy Observable method tests + + it('should call fetchConnectionUsers', () => { let isSubscribeCalled = false; + const usersNetwork = [ + { + id: '83f35e11-6499-470e-9ccb-08b6d9393943', + isActive: true, + email: 'lyubov+fghj@voloshko.com', + createdAt: '2021-07-21T14:35:17.270Z', + }, + ]; - service.createUsersGroup('12345678', 'Managers').subscribe((_res) => { - expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith('Group of users has been created.'); + service.fetchConnectionUsers('12345678').subscribe((res) => { + expect(res).toEqual(usersNetwork); isSubscribeCalled = true; }); - const req = httpMock.expectOne(`/connection/group/12345678`); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual({ title: 'Managers' }); - req.flush(groupNetwork); + const req = httpMock.expectOne(`/connection/users/12345678`); + expect(req.request.method).toBe('GET'); + req.flush(usersNetwork); expect(isSubscribeCalled).toBe(true); }); - it('should fall createUsersGroup and show Error snackbar', async () => { - // Updated test case - const createUsersGroup = service.createUsersGroup('12345678', 'Managers').toPromise(); + it('should fail fetchConnectionUsers and show Error snackbar', async () => { + const fetchConnectionUsers = service.fetchConnectionUsers('12345678').toPromise(); - const req = httpMock.expectOne(`/connection/group/12345678`); - expect(req.request.method).toBe('POST'); + const req = httpMock.expectOne(`/connection/users/12345678`); + expect(req.request.method).toBe('GET'); req.flush(fakeError, { status: 400, statusText: '' }); - await createUsersGroup; + await fetchConnectionUsers; expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); }); @@ -278,8 +303,7 @@ describe('UsersService', () => { expect(isSubscribeCalled).toBe(true); }); - it('should fall fetchPermission and show Error snackbar', async () => { - // Updated test case + it('should fail fetchPermission and show Error snackbar', async () => { const fetchPermission = service.fetchPermission('12345678', 'group12345678').toPromise(); const req = httpMock.expectOne(`/connection/permissions?connectionId=12345678&groupId=group12345678`); @@ -306,8 +330,7 @@ describe('UsersService', () => { expect(isSubscribeCalled).toBe(true); }); - it('should fall updatePermission and show Error snackbar', async () => { - // Updated test case + it('should fail updatePermission and show Error snackbar', async () => { const updatePermission = service.updatePermission('12345678', permissionsApp).toPromise(); const req = httpMock.expectOne(`/permissions/1c042912-326d-4fc5-bb0c-10da88dd37c4?connectionId=12345678`); @@ -317,103 +340,4 @@ describe('UsersService', () => { expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); }); - - it('should call addGroupUser and show Success snackbar', () => { - let isSubscribeCalled = false; - - service.addGroupUser('group12345678', 'eric.cartman@south.park').subscribe((_res) => { - // expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith('User has been added to group.'); - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne(`/group/user`); - expect(req.request.method).toBe('PUT'); - expect(req.request.body).toEqual({ - email: 'eric.cartman@south.park', - groupId: 'group12345678', - }); - req.flush(groupNetwork); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should fall addGroupUser and show Error snackbar', async () => { - // Updated test case - const addGroupUser = service.addGroupUser('group12345678', 'eric.cartman@south.park').toPromise(); - - const req = httpMock.expectOne(`/group/user`); - expect(req.request.method).toBe('PUT'); - req.flush(fakeError, { status: 400, statusText: '' }); - await addGroupUser; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); - }); - - it('should call deleteUsersGroup and show Success snackbar', () => { - let isSubscribeCalled = false; - - const deleteGroup = { - raw: [], - affected: 1, - }; - - service.deleteUsersGroup('group12345678').subscribe((_res) => { - expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith('Group has been removed.'); - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne(`/group/group12345678`); - expect(req.request.method).toBe('DELETE'); - req.flush(deleteGroup); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should fall deleteUsersGroup and show Error snackbar', async () => { - // Updated test case - const deleteUsersGroup = service.deleteUsersGroup('group12345678').toPromise(); - - const req = httpMock.expectOne(`/group/group12345678`); - expect(req.request.method).toBe('DELETE'); - req.flush(fakeError, { status: 400, statusText: '' }); - await deleteUsersGroup; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); - }); - - it('should call deleteGroupUser and show Success snackbar', () => { - let isSubscribeCalled = false; - - const deleteGroup = { - raw: [], - affected: 1, - }; - - service.deleteGroupUser('eric.cartman@south.park', 'group12345678').subscribe((_res) => { - expect(fakeNotifications.showSuccessSnackbar).toHaveBeenCalledWith('User has been removed from group.'); - isSubscribeCalled = true; - }); - - const req = httpMock.expectOne(`/group/user/delete`); - expect(req.request.method).toBe('PUT'); - expect(req.request.body).toEqual({ - email: 'eric.cartman@south.park', - groupId: 'group12345678', - }); - req.flush(deleteGroup); - - expect(isSubscribeCalled).toBe(true); - }); - - it('should fall deleteGroupUser and show Error snackbar', async () => { - // Updated test case - const deleteGroupUser = service.deleteGroupUser('eric.cartman@south.park', 'group12345678').toPromise(); - - const req = httpMock.expectOne(`/group/user/delete`); - expect(req.request.method).toBe('PUT'); - req.flush(fakeError, { status: 400, statusText: '' }); - await deleteGroupUser; - - expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); - }); }); diff --git a/frontend/src/app/services/users.service.ts b/frontend/src/app/services/users.service.ts index e8984238e..d30bb2024 100644 --- a/frontend/src/app/services/users.service.ts +++ b/frontend/src/app/services/users.service.ts @@ -1,77 +1,148 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { EMPTY, Subject } from 'rxjs'; -import { catchError, map } from 'rxjs/operators'; -import { Permissions } from 'src/app/models/user'; +import { HttpClient, HttpResourceRef } from '@angular/common/http'; +import { computed, Injectable, inject, signal } from '@angular/core'; +import { catchError, EMPTY, map } from 'rxjs'; +import { GroupUser, Permissions, UserGroup, UserGroupInfo } from 'src/app/models/user'; +import { ApiService } from './api.service'; import { NotificationsService } from './notifications.service'; +export type GroupUpdateEvent = + | 'group-added' + | 'group-deleted' + | 'group-renamed' + | 'policy-saved' + | 'user-added' + | 'user-deleted' + | ''; + @Injectable({ providedIn: 'root', }) export class UsersService { - private groups = new Subject(); - public cast = this.groups.asObservable(); + private _api = inject(ApiService); + private _http = inject(HttpClient); + private _notifications = inject(NotificationsService); - constructor( - private _http: HttpClient, - private _notifications: NotificationsService, - ) {} + // Reactive groups fetching + private _activeConnectionId = signal(null); - fetchConnectionUsers(connectionID: string) { - return this._http.get(`/connection/users/${connectionID}`).pipe( - map((res) => res), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), + private _groupsUpdated = signal(''); + public readonly groupsUpdated = this._groupsUpdated.asReadonly(); + + private _groupsResource: HttpResourceRef = this._api.resource(() => { + const id = this._activeConnectionId(); + if (!id) return undefined; + return `/connection/groups/${id}`; + }); + + public readonly groups = computed(() => { + const raw = this._groupsResource.value() ?? []; + return [...raw].sort((a, b) => { + if (a.group.title === 'Admin') return -1; + if (b.group.title === 'Admin') return 1; + return 0; + }); + }); + public readonly groupsLoading = computed(() => this._groupsResource.isLoading()); + + // Group users - managed imperatively (per-group parallel fetch) + private _groupUsers = signal>({}); + public readonly groupUsers = this._groupUsers.asReadonly(); + + setActiveConnection(id: string): void { + this._activeConnectionId.set(id); + } + + refreshGroups(): void { + this._groupsResource.reload(); + } + + clearGroupsUpdated(): void { + this._groupsUpdated.set(''); + } + + async fetchGroupUsers(groupId: string): Promise { + const users = await this._api.get(`/group/users/${groupId}`); + const result = users ?? []; + this._groupUsers.update((current) => ({ + ...current, + [groupId]: result.length ? result : 'empty', + })); + return result; + } + + async fetchAllGroupUsers(groups: UserGroupInfo[]): Promise { + await Promise.all(groups.map((g) => this.fetchGroupUsers(g.group.id))); + } + + // Mutations + async createGroup(connectionId: string, title: string): Promise { + const group = await this._api.post( + `/connection/group/${connectionId}`, + { title }, + { + successMessage: 'Group of users has been created.', + }, ); + if (group) this._groupsUpdated.set('group-added'); + return group; } - fetchConnectionGroups(connectionID: string) { - return this._http.get(`/connection/groups/${connectionID}`).pipe( - map((res) => res), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), + async deleteGroup(groupId: string): Promise { + await this._api.delete(`/group/${groupId}`, { + successMessage: 'Group has been removed.', + }); + this._groupsUpdated.set('group-deleted'); + } + + async editGroupName(groupId: string, title: string): Promise { + await this._api.put( + '/group/title', + { title, groupId }, + { + successMessage: 'Group name has been updated.', + }, ); + this._groupsUpdated.set('group-renamed'); } - fetcGroupUsers(groupID: string) { - return this._http.get(`/group/users/${groupID}`).pipe( - map((res) => res), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), + async saveCedarPolicy(connectionId: string, groupId: string, cedarPolicy: string): Promise { + await this._api.post( + `/connection/cedar-policy/${connectionId}`, + { cedarPolicy, groupId }, + { + successMessage: 'Policy has been saved.', + }, ); + this.refreshGroups(); + this._groupsUpdated.set('policy-saved'); } - createUsersGroup(connectionID: string, title: string) { - return this._http.post(`/connection/group/${connectionID}`, { title }).pipe( - map((res) => { - this.groups.next({ action: 'add group', group: res }); - this._notifications.showSuccessSnackbar('Group of users has been created.'); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), + async addGroupUser(groupId: string, email: string): Promise { + await this._api.put( + '/group/user', + { email, groupId }, + { + successMessage: 'User has been added to group.', + }, ); + this._groupsUpdated.set('user-added'); } - saveCedarPolicy(connectionID: string, groupId: string, cedarPolicy: string) { - return this._http.post(`/connection/cedar-policy/${connectionID}`, { cedarPolicy, groupId }).pipe( - map((res) => { - this.groups.next({ action: 'save policy', groupId }); - this._notifications.showSuccessSnackbar('Policy has been saved.'); - return res; - }), + async deleteGroupUser(email: string, groupId: string): Promise { + await this._api.put( + '/group/user/delete', + { email, groupId }, + { + successMessage: 'User has been removed from group.', + }, + ); + this._groupsUpdated.set('user-deleted'); + } + + // Legacy Observable methods (kept for AuditComponent + permissions UI) + fetchConnectionUsers(connectionID: string) { + return this._http.get(`/connection/users/${connectionID}`).pipe( + map((res) => res), catchError((err) => { console.log(err); this._notifications.showErrorSnackbar(err.error?.message || err.message); @@ -118,61 +189,4 @@ export class UsersService { }), ); } - - addGroupUser(groupID: string, userEmail: string) { - return this._http.put(`/group/user`, { email: userEmail, groupId: groupID }).pipe( - map((res) => { - this.groups.next({ action: 'add user', groupId: groupID }); - this._notifications.showSuccessSnackbar('User has been added to group.'); - return res; - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), - ); - } - - editUsersGroupName(groupId: string, title: string) { - return this._http.put(`/group/title`, { title, groupId }).pipe( - map(() => { - this.groups.next({ action: 'edit group name', groupId: groupId }); - this._notifications.showSuccessSnackbar('Group name has been updated.'); - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), - ); - } - - deleteUsersGroup(groupID: string) { - return this._http.delete(`/group/${groupID}`).pipe( - map(() => { - this.groups.next({ action: 'delete group', groupId: groupID }); - this._notifications.showSuccessSnackbar('Group has been removed.'); - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), - ); - } - - deleteGroupUser(email: string, groupID: string) { - return this._http.put(`/group/user/delete`, { email: email, groupId: groupID }).pipe( - map(() => { - this.groups.next({ action: 'delete user', groupId: groupID }); - this._notifications.showSuccessSnackbar('User has been removed from group.'); - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || err.message); - return EMPTY; - }), - ); - } } diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 5766736e3..3070ef8fa 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2053,6 +2053,13 @@ __metadata: languageName: node linkType: hard +"@cedar-policy/cedar-wasm@npm:^4.9.1": + version: 4.9.1 + resolution: "@cedar-policy/cedar-wasm@npm:4.9.1" + checksum: 8cb134ec94a3c04c58b9020e9ef2348fca4439cdefb0d6e83371d030947427cbfb30912e8d081aa6291c7318a981dce093f0e474737b9b66dfac41c0cfa5b503 + languageName: node + linkType: hard + "@chainsafe/is-ip@npm:^2.0.1": version: 2.1.0 resolution: "@chainsafe/is-ip@npm:2.1.0" @@ -12970,6 +12977,7 @@ __metadata: "@angular/platform-browser-dynamic": ~20.3.16 "@angular/router": ~20.3.16 "@brumeilde/ngx-theme": ^1.2.1 + "@cedar-policy/cedar-wasm": ^4.9.1 "@fontsource/ibm-plex-mono": ^5.2.7 "@fontsource/noto-sans": ^5.2.10 "@jsonurl/jsonurl": ^1.1.8