From 97a90d637c54e4e37974e1bd0006cb5f6b7bd31f Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Wed, 25 Mar 2026 12:38:34 +0000 Subject: [PATCH 1/9] refactor: migrate cedar policy and group management to Angular signals Replace legacy Subject-based event bus in UsersService with httpResource for reactive data fetching and async ApiService methods for mutations. Migrate all dialog components, CedarPolicyList, CedarPolicyEditor, and UsersComponent to use signals, computed values, effects, and @if/@for control flow. Add name field to GroupUser interface. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cedar-policy-editor-dialog.component.html | 41 ++- ...dar-policy-editor-dialog.component.spec.ts | 38 +- .../cedar-policy-editor-dialog.component.ts | 160 +++++---- .../cedar-policy-list.component.html | 278 ++++++++------- .../cedar-policy-list.component.spec.ts | 84 +++-- .../cedar-policy-list.component.ts | 213 ++++++------ .../group-add-dialog.component.html | 6 +- .../group-add-dialog.component.spec.ts | 34 +- .../group-add-dialog.component.stories.ts | 4 +- .../group-add-dialog.component.ts | 56 ++- .../group-delete-dialog.component.html | 8 +- .../group-delete-dialog.component.spec.ts | 30 +- .../group-delete-dialog.component.ts | 51 ++- .../group-name-edit-dialog.component.html | 6 +- .../group-name-edit-dialog.component.spec.ts | 9 +- .../group-name-edit-dialog.component.ts | 39 +-- .../user-add-dialog.component.html | 37 +- .../user-add-dialog.component.spec.ts | 40 +-- .../user-add-dialog.component.ts | 65 ++-- .../user-delete-dialog.component.html | 17 +- .../user-delete-dialog.component.spec.ts | 28 +- .../user-delete-dialog.component.stories.ts | 4 +- .../user-delete-dialog.component.ts | 41 +-- .../app/components/users/users.component.html | 235 +++++++------ .../components/users/users.component.spec.ts | 106 +++--- .../app/components/users/users.component.ts | 214 +++++------- frontend/src/app/models/user.ts | 1 + .../src/app/services/users.service.spec.ts | 328 +++++++----------- frontend/src/app/services/users.service.ts | 235 +++++++------ 29 files changed, 1173 insertions(+), 1235 deletions(-) diff --git a/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.html b/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.html index 4218f6c6b..198676ffd 100644 --- a/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.html +++ b/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.html @@ -2,28 +2,30 @@

Policy — {{ data.groupTitle }}

- + Form Code
-
- warning - This policy uses advanced Cedar syntax that cannot be represented in form mode. Please use the code editor. -
+ @if (formParseError()) { +
+ warning + This policy uses advanced Cedar syntax that cannot be represented in form mode. Please use the code editor. +
+ } -
+ @if (editorMode() === 'form' && !formParseError()) { -
+ } -
+ @if (editorMode() === 'code') {

Edit policy in Cedar format

Policy — {{ data.groupTitle }} (valueChanged)="onCedarPolicyChange($event)">
-
+ }
- + @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..04b3394be 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 @@ -10,9 +10,22 @@ import { Angulartics2Module } from 'angulartics2'; import { of } from 'rxjs'; 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>; +}; + describe('CedarPolicyEditorDialogComponent', () => { let component: CedarPolicyEditorDialogComponent; let fixture: ComponentFixture; @@ -47,6 +60,10 @@ describe('CedarPolicyEditorDialogComponent', () => { ');', ].join('\n'); + const mockUsersService: Partial = { + saveCedarPolicy: vi.fn().mockResolvedValue(undefined), + }; + beforeEach(() => { dashboardsService = { dashboards: signal([ @@ -72,6 +89,7 @@ describe('CedarPolicyEditorDialogComponent', () => { }, { provide: MatDialogRef, useValue: mockDialogRef }, { provide: DashboardsService, useValue: dashboardsService }, + { provide: UsersService, useValue: mockUsersService }, ], }) .overrideComponent(CedarPolicyEditorDialogComponent, { @@ -93,24 +111,28 @@ 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', () => { + const testable = component as unknown as CedarPolicyEditorTestable; component.onEditorModeChange('code'); - expect(component.editorMode).toBe('code'); - expect(component.cedarPolicy).toBeTruthy(); + expect(testable.editorMode()).toBe('code'); + expect(testable.cedarPolicy()).toBeTruthy(); }); }); 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..66ea308a8 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'; @@ -36,7 +35,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 +45,25 @@ 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 _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); @ViewChild(CedarPolicyListComponent) policyList?: CedarPolicyListComponent; @ViewChild('dialogContent', { read: ElementRef }) dialogContent?: ElementRef; @@ -71,39 +77,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 +116,72 @@ 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); } onPolicyItemsChange(items: CedarPolicyItem[]) { - this.policyItems = items; + this.policyItems.set(items); } onEditorModeChange(mode: 'form' | 'code') { - if (mode === this.editorMode) return; + 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); } else { - this.formParseError = !canRepresentAsForm(this.cedarPolicy); - if (this.formParseError) return; - this.policyItems = this._parseCedarToPolicyItems(); + this.formParseError.set(!canRepresentAsForm(this.cedarPolicy())); + 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 +189,32 @@ 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; - }, - }); + try { + await this._usersService.saveCedarPolicy(this.connectionID, this.data.groupId, policy); + this.dialogRef.close(); + } finally { + this.submitting.set(false); + } } onAddPolicyClick() { @@ -224,8 +229,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..2d6fde242 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,13 +47,14 @@ 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(); + + // Form state - plain properties for ngModel binding showAddForm = false; newAction = ''; newTableName = ''; @@ -61,37 +67,52 @@ export class CedarPolicyListComponent implements OnChanges { collapsedGroups = new Set(); - availableActions = POLICY_ACTIONS; - - groupedPolicies: PolicyGroup[] = []; - addActionGroups: PolicyActionGroup[] = []; - editActionGroups: PolicyActionGroup[] = []; - - usedTables = new Map(); - usedDashboards = new Map(); + private _availableActions = POLICY_ACTIONS; - 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 +125,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 +142,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 +169,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 +185,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 +210,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 +232,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 +299,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..d4f5e11d0 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.email) { + + @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..f88fb4e46 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 @@ -3,32 +3,29 @@ 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 { 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'; describe('UserAddDialogComponent', () => { let component: UserAddDialogComponent; let fixture: ComponentFixture; - let usersService: UsersService; const mockDialogRef = { - close: () => {}, + close: vi.fn(), + }; + + const mockUsersService: Partial = { + addGroupUser: vi.fn().mockResolvedValue(undefined), }; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [ - RouterTestingModule, - MatSnackBarModule, - FormsModule, - Angulartics2Module.forRoot(), - UserAddDialogComponent, - ], + imports: [MatSnackBarModule, FormsModule, Angulartics2Module.forRoot(), UserAddDialogComponent], providers: [ provideHttpClient(), + provideRouter([]), { provide: MAT_DIALOG_DATA, useValue: { @@ -40,6 +37,7 @@ describe('UserAddDialogComponent', () => { }, }, { provide: MatDialogRef, useValue: mockDialogRef }, + { provide: UsersService, useValue: mockUsersService }, ], }).compileComponents(); }); @@ -47,7 +45,6 @@ describe('UserAddDialogComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(UserAddDialogComponent); component = fixture.componentInstance; - usersService = TestBed.inject(UsersService); fixture.detectChanges(); }); @@ -55,18 +52,15 @@ describe('UserAddDialogComponent', () => { expect(component).toBeTruthy(); }); - it('should call add user service', () => { - component.groupUserEmail = 'user@test.com'; - const fakeAddUser = vi.spyOn(usersService, 'addGroupUser').mockReturnValue(of()); - // vi.spyOn(mockDialogRef, 'close'); + it('should call add user service', async () => { + const testable = component as unknown as UserAddDialogComponent & { + groupUserEmail: string; + joinGroupUser: () => Promise; + }; + testable.groupUserEmail = 'user@test.com'; - component.joinGroupUser(); - expect(fakeAddUser).toHaveBeenCalledWith('12345678-123', 'user@test.com'); + await testable.joinGroupUser(); - // fixture.detectChanges(); - // fixture.whenStable().then(() => { - // expect(component.dialogRef.close).toHaveBeenCalled(); - // expect(component.submitting).toBe(false); - // }); + expect(mockUsersService.addGroupUser).toHaveBeenCalledWith('12345678-123', 'user@test.com'); }); }); 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..66dcca22e 100644 --- a/frontend/src/app/components/users/users.component.html +++ b/frontend/src/app/components/users/users.component.html @@ -1,127 +1,126 @@

User groups

- + @if (connectionAccessLevel !== 'none') { + + }
- + @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) { + + + + {{ groupItem.group.title }} + @if (groupItem.group.title === 'Admin') { + system + } + @if (isPermitted(groupItem.accessLevel) && 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 (isPermitted(groupItem.accessLevel) && groupItem.group.title !== 'Admin') { + + + } + @if (isPermitted(groupItem.accessLevel)) { + + } + + - -

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 && isPermitted(groupItem.accessLevel)) { + + } +
+
+ } + } +
+
+ } +
+ } -
- 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.email; 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..6ea114859 100644 --- a/frontend/src/app/components/users/users.component.spec.ts +++ b/frontend/src/app/components/users/users.component.spec.ts @@ -1,10 +1,10 @@ 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 { 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 +15,6 @@ import { UsersComponent } from './users.component'; describe('UsersComponent', () => { let component: UsersComponent; let fixture: ComponentFixture; - let usersService: UsersService; let dialog: MatDialog; const fakeGroup = { @@ -24,17 +23,53 @@ 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(), + }; + 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 }, + ], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(UsersComponent); component = fixture.componentInstance; - usersService = TestBed.inject(UsersService); dialog = TestBed.inject(MatDialog); fixture.detectChanges(); }); @@ -58,33 +93,6 @@ describe('UsersComponent', () => { 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 +142,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..5972fd82c 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,8 @@ 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 { 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 +28,6 @@ import { UserDeleteDialogComponent } from './user-delete-dialog/user-delete-dial @Component({ selector: 'app-users', imports: [ - NgIf, - NgForOf, - NgClass, - CommonModule, MatButtonModule, MatIconModule, MatListModule, @@ -46,147 +41,103 @@ 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); + 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 { + // Group-level changes: refresh groups resource, then users + 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._usersService.setActiveConnection(this.connectionID); - this._company.fetchCompanyMembers(this.currentUser.company.id).subscribe((members) => { - this.companyMembers = members; - }); - }); + this._userService.cast.pipe(takeUntilDestroyed(this._destroyRef)).subscribe((user) => { + this.currentUser.set(user); - 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), - }); - } + this._company.fetchCompanyMembers(user.company.id).subscribe((members) => { + this.companyMembers.set(members); + }); }); } - ngOnDestroy() { - this.usersSubscription.unsubscribe(); - } - get connectionAccessLevel() { return this._connections.currentConnectionAccessLevel || 'none'; } - isPermitted(accessLevel) { + isPermitted(accessLevel: string) { 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); - }); - - // 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), - }); - }); - } - - // 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'; - } - }), - ); - } - - getCompanyMembersWithoutAccess() { - const allGroupUsers = Object.values(this.users).flat(); - this.companyMembersWithoutAccess = differenceBy(this.companyMembers, allGroupUsers, 'email'); - } - 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 +146,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 +163,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 +184,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/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/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..b05d2a7e3 100644 --- a/frontend/src/app/services/users.service.ts +++ b/frontend/src/app/services/users.service.ts @@ -1,77 +1,147 @@ -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._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 +188,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; - }), - ); - } } From cba862fe82e24df3daf3460779a40df8e98f28a2 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Wed, 25 Mar 2026 14:33:18 +0000 Subject: [PATCH 2/9] feat: add cedar-wasm policy validation and permission service Add @cedar-policy/cedar-wasm for client-side Cedar policy validation and authorization. Extract shared WASM loading into CedarWasmService, add input validation to the policy editor dialog, fix connection:edit escalation bug, and introduce CedarPermissionService with signal-based canI(action, resourceType, resourceId) for reactive permission checks. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/angular.json | 5 + frontend/package.json | 1 + .../cedar-policy-editor-dialog.component.css | 28 + .../cedar-policy-editor-dialog.component.html | 10 + ...dar-policy-editor-dialog.component.spec.ts | 72 ++- .../cedar-policy-editor-dialog.component.ts | 25 +- frontend/src/app/lib/cedar-policy-items.ts | 7 +- .../app/lib/cedar-policy-roundtrip.spec.ts | 555 ++++++++++++++++++ .../services/cedar-permission.service.spec.ts | 270 +++++++++ .../app/services/cedar-permission.service.ts | 80 +++ .../app/services/cedar-validator.service.ts | 36 ++ .../src/app/services/cedar-wasm.service.ts | 24 + frontend/yarn.lock | 8 + 13 files changed, 1113 insertions(+), 8 deletions(-) create mode 100644 frontend/src/app/lib/cedar-policy-roundtrip.spec.ts create mode 100644 frontend/src/app/services/cedar-permission.service.spec.ts create mode 100644 frontend/src/app/services/cedar-permission.service.ts create mode 100644 frontend/src/app/services/cedar-validator.service.ts create mode 100644 frontend/src/app/services/cedar-wasm.service.ts 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/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.css b/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.css index 17807de4b..20b1c57dd 100644 --- a/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.css +++ b/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.css @@ -43,3 +43,31 @@ border-radius: 4px; overflow: hidden; } + +.code-editor-box ngs-code-editor { + display: block; + height: 100%; +} + +.cedar-validation-errors { + display: flex; + align-items: flex-start; + gap: 16px; + padding: 12px 16px; + border: 1px solid var(--mat-sys-error, #b00020); + margin-top: 8px; + border-radius: 4px; + background: color-mix(in srgb, var(--mat-sys-error, #b00020) 8%, transparent); + color: var(--mat-sys-error, #b00020); + font-size: 13px; +} + +.cedar-validation-errors mat-icon { + flex-shrink: 0; +} + +.cedar-validation-errors__messages { + display: flex; + flex-direction: column; + gap: 4px; +} diff --git a/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.html b/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.html index 198676ffd..d82d46ca1 100644 --- a/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.html +++ b/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.html @@ -35,6 +35,16 @@

Policy — {{ data.groupTitle }}

(valueChanged)="onCedarPolicyChange($event)">
+ @if (validationErrors().length > 0) { +
+ error +
+ @for (error of validationErrors(); track error) { + {{ error }} + } +
+
+ } } 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 04b3394be..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,6 +8,7 @@ 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'; @@ -24,6 +25,8 @@ type CedarPolicyEditorTestable = CedarPolicyEditorDialogComponent & { policyItems: ReturnType>; editorMode: ReturnType>; cedarPolicy: ReturnType>; + validationErrors: ReturnType>; + submitting: ReturnType>; }; describe('CedarPolicyEditorDialogComponent', () => { @@ -64,7 +67,12 @@ describe('CedarPolicyEditorDialogComponent', () => { 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: '' }, @@ -90,6 +98,7 @@ describe('CedarPolicyEditorDialogComponent', () => { { provide: MatDialogRef, useValue: mockDialogRef }, { provide: DashboardsService, useValue: dashboardsService }, { provide: UsersService, useValue: mockUsersService }, + { provide: CedarValidatorService, useValue: mockCedarValidator }, ], }) .overrideComponent(CedarPolicyEditorDialogComponent, { @@ -129,10 +138,69 @@ describe('CedarPolicyEditorDialogComponent', () => { expect(testable.editorMode()).toBe('form'); }); - it('should switch to code mode', () => { + it('should switch to code mode', async () => { const testable = component as unknown as CedarPolicyEditorTestable; - component.onEditorModeChange('code'); + await component.onEditorModeChange('code'); expect(testable.editorMode()).toBe('code'); expect(testable.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 66ea308a8..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 @@ -12,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'; @@ -51,6 +52,7 @@ export class CedarPolicyEditorDialogComponent implements OnInit { private _tablesService = inject(TablesService); private _dashboardsService = inject(DashboardsService); private _editorService = inject(CodeEditorService); + private _cedarValidator = inject(CedarValidatorService); private _destroyRef = inject(DestroyRef); protected connectionID: string; @@ -64,6 +66,8 @@ export class CedarPolicyEditorDialogComponent implements OnInit { 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; @@ -154,13 +158,14 @@ export class CedarPolicyEditorDialogComponent implements OnInit { onCedarPolicyChange(value: string) { this.cedarPolicy.set(value); + this.validationErrors.set([]); } onPolicyItemsChange(items: CedarPolicyItem[]) { this.policyItems.set(items); } - onEditorModeChange(mode: 'form' | 'code') { + async onEditorModeChange(mode: 'form' | 'code') { if (mode === this.editorMode()) return; if (mode === 'code') { @@ -171,8 +176,16 @@ export class CedarPolicyEditorDialogComponent implements OnInit { value: this.cedarPolicy(), }; this.formParseError.set(false); + this.validationErrors.set([]); } else { - this.formParseError.set(!canRepresentAsForm(this.cedarPolicy())); + 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()); } @@ -209,6 +222,14 @@ export class CedarPolicyEditorDialogComponent implements OnInit { return; } + 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(); diff --git a/frontend/src/app/lib/cedar-policy-items.ts b/frontend/src/app/lib/cedar-policy-items.ts index cc2aeb652..dcbbf19fd 100644 --- a/frontend/src/app/lib/cedar-policy-items.ts +++ b/frontend/src/app/lib/cedar-policy-items.ts @@ -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' }); } 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/services/cedar-permission.service.spec.ts b/frontend/src/app/services/cedar-permission.service.spec.ts new file mode 100644 index 000000000..877e4bf3c --- /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 false 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(false); + }); + + it('canI signal returns false 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(false); + expect(mockIsAuthorized).not.toHaveBeenCalled(); + }); + + it('canI signal returns false 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(false); + 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 → false + const sig = service.canI('connection:read', 'Connection', CONN_ID); + expect(sig()).toBe(false); + + // 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..9dbc611eb --- /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 { + const cedar = this._wasmModule(); + const mergedPolicies = this._mergedPolicies(); + const userId = this._userId(); + + if (!cedar || !mergedPolicies || !userId) return false; + + 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/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 From 3a692f08e916749d14fb6518b15bd68c7aa72230 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Wed, 25 Mar 2026 15:48:05 +0000 Subject: [PATCH 3/9] refactor: replace old permission checks with CedarPermissionService.canI Migrate connection-level and group-level permission guards from ConnectionsService properties (connectionAccessLevel, groupsAccessLevel, isPermitted) to reactive CedarPermissionService.canI() signals across Dashboard, ConnectionSettings, ConnectDB, and Users components. Load groups globally on navigation so canI works app-wide. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../connect-db/connect-db.component.html | 2 +- .../connect-db/connect-db.component.ts | 14 +- .../connection-settings.component.html | 6 +- .../connection-settings.component.ts | 17 ++- .../dashboard/dashboard.component.html | 4 +- .../dashboard/dashboard.component.ts | 132 ++++++++++-------- .../app/components/users/users.component.html | 10 +- .../components/users/users.component.spec.ts | 22 +-- .../app/components/users/users.component.ts | 12 +- .../app/services/connections.service.spec.ts | 31 ++-- .../src/app/services/connections.service.ts | 15 +- 11 files changed, 153 insertions(+), 112 deletions(-) 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 diff --git a/frontend/src/app/components/dashboard/dashboard.component.ts b/frontend/src/app/components/dashboard/dashboard.component.ts index 56cfdbf82..8fc298265 100644 --- a/frontend/src/app/components/dashboard/dashboard.component.ts +++ b/frontend/src/app/components/dashboard/dashboard.component.ts @@ -1,6 +1,6 @@ import { SelectionModel } from '@angular/cdk/collections'; import { CommonModule } from '@angular/common'; -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component, inject, OnDestroy, OnInit } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; @@ -17,6 +17,7 @@ import { ServerError } from 'src/app/models/alert'; import { CustomEvent, TableProperties } from 'src/app/models/table'; import { ConnectionSettingsUI, UiSettings } from 'src/app/models/ui-settings'; import { User } 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 { TableRowService } from 'src/app/services/table-row.service'; @@ -112,7 +113,16 @@ export class DashboardComponent implements OnInit, OnDestroy { public dialog: MatDialog, private title: Title, private angulartics2: Angulartics2, - ) {} + ) { + this.canEditConnection = this._permissions.canI( + 'connection:edit', + 'Connection', + this._connections.currentConnectionID, + ); + } + + private _permissions = inject(CedarPermissionService); + protected canEditConnection: ReturnType; get currentConnectionAccessLevel() { return this._connections.currentConnectionAccessLevel; @@ -159,66 +169,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/users/users.component.html b/frontend/src/app/components/users/users.component.html index 66dcca22e..b9edd6d4f 100644 --- a/frontend/src/app/components/users/users.component.html +++ b/frontend/src/app/components/users/users.component.html @@ -1,7 +1,7 @@

User groups

- @if (connectionAccessLevel !== 'none') { + @if (canCreateGroup()) { } - @if (isPermitted(groupItem.accessLevel)) { + @if (canManageGroup(groupItem.group.id)()) {
@@ -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.ts b/frontend/src/app/components/dashboard/db-table-view/saved-filters-panel/saved-filters-panel.component.ts index 6b38723f9..d8e0c5daa 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 @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { Component, EventEmitter, Input, inject, OnDestroy, OnInit, Output } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatChipsModule } from '@angular/material/chips'; @@ -21,7 +21,7 @@ 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 { CedarPermissionService } from 'src/app/services/cedar-permission.service'; 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 +60,13 @@ export class SavedFiltersPanelComponent implements OnInit, OnDestroy { @Output() filterSelected = new EventEmitter(); @Input() resetSelection: boolean = false; - @Input() accessLevel: AccessLevel; + private _permissions = inject(CedarPermissionService); + private _connectionsForPermissions = inject(ConnectionsService); + protected canEditConnection = this._permissions.canI( + 'connection:edit', + 'Connection', + this._connectionsForPermissions.currentConnectionID, + ); 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 (canManageGroup(groupItem.group.id)()) { + @if (canManage()) { @if (data.availableMembers.length) { -