diff --git a/backend/src/entities/connection/connection.controller.ts b/backend/src/entities/connection/connection.controller.ts index a3e57631a..c34502312 100644 --- a/backend/src/entities/connection/connection.controller.ts +++ b/backend/src/entities/connection/connection.controller.ts @@ -22,11 +22,7 @@ import { AmplitudeEventTypeEnum, InTransactionEnum } from '../../enums/index.js' import { Messages } from '../../exceptions/text/messages.js'; import { processExceptionMessage } from '../../exceptions/utils/process-exception-message.js'; import { ConnectionEditGuard, ConnectionReadGuard } from '../../guards/index.js'; -import { - isConnectionTypeAgent, - slackPostMessage, - toPrettyErrorsMsg, -} from '../../helpers/index.js'; +import { isConnectionTypeAgent, slackPostMessage, toPrettyErrorsMsg } from '../../helpers/index.js'; import { SentryInterceptor } from '../../interceptors/index.js'; import { SuccessResponse } from '../../microservices/saas-microservice/data-structures/common-responce.ds.js'; import { AmplitudeService } from '../amplitude/amplitude.service.js'; @@ -689,5 +685,4 @@ export class ConnectionController { } return await this.unfreezeConnectionUseCase.execute({ connectionId, userId }, InTransactionEnum.ON); } - } diff --git a/backend/src/entities/connection/use-cases/create-group-in-connection.use.case.ts b/backend/src/entities/connection/use-cases/create-group-in-connection.use.case.ts index 22bb5ac71..647066985 100644 --- a/backend/src/entities/connection/use-cases/create-group-in-connection.use.case.ts +++ b/backend/src/entities/connection/use-cases/create-group-in-connection.use.case.ts @@ -36,15 +36,11 @@ export class CreateGroupInConnectionUseCase const foundUser = await this._dbContext.userRepository.findOneUserById(cognitoUserName); const newGroupEntity = buildNewGroupEntityForConnectionWithUser(connectionToUpdate, foundUser, title); const savedGroup = await this._dbContext.groupRepository.saveNewOrUpdatedGroup(newGroupEntity); - savedGroup.cedarPolicy = generateCedarPolicyForGroup( - connectionId, - false, - { - connection: { connectionId, accessLevel: AccessLevelEnum.none }, - group: { groupId: savedGroup.id, accessLevel: AccessLevelEnum.none }, - tables: [], - }, - ); + savedGroup.cedarPolicy = generateCedarPolicyForGroup(connectionId, false, { + connection: { connectionId, accessLevel: AccessLevelEnum.none }, + group: { groupId: savedGroup.id, accessLevel: AccessLevelEnum.none }, + tables: [], + }); await this._dbContext.groupRepository.saveNewOrUpdatedGroup(savedGroup); Cacher.invalidateCedarPolicyCache(connectionId); return buildFoundGroupResponseDto(savedGroup); 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 new file mode 100644 index 000000000..6427b3615 --- /dev/null +++ b/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.css @@ -0,0 +1,40 @@ +.editor-mode-toggle { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.cedar-hint { + margin: 0 0 8px; + font-size: 12px; + color: var(--mdc-theme-text-secondary-on-background, rgba(0, 0, 0, 0.54)); +} + +.cedar-hint a { + color: inherit; + text-decoration: underline; +} + +.form-parse-warning { + display: flex; + align-items: center; + gap: 8px; + padding: 12px; + margin-bottom: 12px; + border-radius: 4px; + background: var(--mdc-theme-warning-container, #fff3e0); + color: var(--mdc-theme-on-warning-container, #e65100); + font-size: 13px; +} + +.form-parse-warning mat-icon { + flex-shrink: 0; +} + +.code-editor-box { + height: 300px; + border: 1px solid var(--mdc-outlined-text-field-outline-color, rgba(0, 0, 0, 0.38)); + border-radius: 4px; + overflow: hidden; +} 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 new file mode 100644 index 000000000..1a9704896 --- /dev/null +++ b/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.html @@ -0,0 +1,44 @@ +

Policy — {{ data.groupTitle }}

+ +
+ + Form + Code + +
+ +
+ warning + This policy uses advanced Cedar syntax that cannot be represented in form mode. Please use the code editor. +
+ +
+ + +
+ +
+

Edit policy in Cedar format

+
+ + +
+
+
+ + + + 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 new file mode 100644 index 000000000..10dec0345 --- /dev/null +++ b/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.spec.ts @@ -0,0 +1,113 @@ +import { provideHttpClient } from '@angular/common/http'; +import { NO_ERRORS_SCHEMA, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +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 { CodeEditorModule } from '@ngstack/code-editor'; +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 { MockCodeEditorComponent } from 'src/app/testing/code-editor.mock'; +import { CedarPolicyEditorDialogComponent } from './cedar-policy-editor-dialog.component'; + +describe('CedarPolicyEditorDialogComponent', () => { + let component: CedarPolicyEditorDialogComponent; + let fixture: ComponentFixture; + let tablesService: TablesService; + let dashboardsService: Partial; + + const mockDialogRef = { + close: () => {}, + }; + + const fakeTables = [ + { + table: 'customers', + display_name: 'Customers', + permissions: { visibility: true, readonly: false, add: true, delete: true, edit: true }, + }, + { + table: 'orders', + display_name: 'Orders', + permissions: { visibility: true, readonly: false, add: true, delete: false, edit: true }, + }, + ]; + + const cedarPolicyWithConnection = [ + 'permit(', + ' principal,', + ' action == RocketAdmin::Action::"connection:read",', + ' resource == RocketAdmin::Connection::"conn-123"', + ');', + ].join('\n'); + + beforeEach(() => { + dashboardsService = { + dashboards: signal([ + { id: 'dash-1', name: 'Sales', description: null, connection_id: 'conn-123', created_at: '', updated_at: '' }, + ]).asReadonly(), + setActiveConnection: vi.fn(), + }; + + TestBed.configureTestingModule({ + imports: [ + MatDialogModule, + MatSnackBarModule, + BrowserAnimationsModule, + Angulartics2Module.forRoot({}), + CedarPolicyEditorDialogComponent, + ], + providers: [ + provideHttpClient(), + provideRouter([]), + { + provide: MAT_DIALOG_DATA, + useValue: { groupId: 'group-123', groupTitle: 'Test Group', cedarPolicy: cedarPolicyWithConnection }, + }, + { provide: MatDialogRef, useValue: mockDialogRef }, + { provide: DashboardsService, useValue: dashboardsService }, + ], + }) + .overrideComponent(CedarPolicyEditorDialogComponent, { + remove: { imports: [CodeEditorModule] }, + add: { imports: [MockCodeEditorComponent], schemas: [NO_ERRORS_SCHEMA] }, + }) + .compileComponents(); + + tablesService = TestBed.inject(TablesService); + vi.spyOn(tablesService, 'fetchTables').mockReturnValue(of(fakeTables)); + + fixture = TestBed.createComponent(CedarPolicyEditorDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should load tables on init', () => { + expect(tablesService.fetchTables).toHaveBeenCalled(); + expect(component.allTables.length).toBe(2); + expect(component.availableTables.length).toBe(2); + expect(component.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); + }); + + it('should start in form mode', () => { + expect(component.editorMode).toBe('form'); + }); + + it('should switch to code mode', () => { + component.onEditorModeChange('code'); + expect(component.editorMode).toBe('code'); + expect(component.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 new file mode 100644 index 000000000..337260af5 --- /dev/null +++ b/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.ts @@ -0,0 +1,190 @@ +import { NgIf } from '@angular/common'; +import { Component, DestroyRef, Inject, inject, OnInit } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatButtonModule } from '@angular/material/button'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { CodeEditorModule, CodeEditorService } from '@ngstack/code-editor'; +import { take } from 'rxjs'; +import { registerCedarLanguage } from 'src/app/lib/cedar-monaco-language'; +import { CedarPolicyItem, permissionsToPolicyItems, policyItemsToCedarPolicy } from 'src/app/lib/cedar-policy-items'; +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 { ConnectionsService } from 'src/app/services/connections.service'; +import { DashboardsService } from 'src/app/services/dashboards.service'; +import { TablesService } from 'src/app/services/tables.service'; +import { UiSettingsService } from 'src/app/services/ui-settings.service'; +import { UsersService } from 'src/app/services/users.service'; +import { + AvailableDashboard, + AvailableTable, + CedarPolicyListComponent, +} from '../cedar-policy-list/cedar-policy-list.component'; +import { CedarPolicyEditorDialogComponent as Self } from './cedar-policy-editor-dialog.component'; + +export interface CedarPolicyEditorDialogData { + groupId: string; + groupTitle: string; + cedarPolicy?: string | null; +} + +@Component({ + selector: 'app-cedar-policy-editor-dialog', + templateUrl: './cedar-policy-editor-dialog.component.html', + styleUrls: ['./cedar-policy-editor-dialog.component.css'], + imports: [ + NgIf, + MatDialogModule, + MatButtonModule, + MatButtonToggleModule, + MatIconModule, + CodeEditorModule, + CedarPolicyListComponent, + ], +}) +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; + + public cedarPolicyModel: object; + public codeEditorOptions = { + minimap: { enabled: false }, + automaticLayout: true, + scrollBeyondLastLine: false, + wordWrap: 'on', + }; + 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)); + } + + ngOnInit(): void { + this.connectionID = this._connections.currentConnectionID; + this.cedarPolicy = this.data.cedarPolicy || ''; + this.cedarPolicyModel = { + language: 'cedar', + uri: `cedar-policy-${this.data.groupId}.cedar`, + value: this.cedarPolicy, + }; + + this._dashboardsService.setActiveConnection(this.connectionID); + + this._tablesService + .fetchTables(this.connectionID) + .pipe(takeUntilDestroyed(this._destroyRef)) + .subscribe((tables) => { + this.allTables = []; + this.availableTables = []; + for (const t of tables) { + const displayName = t.display_name || normalizeTableName(t.table); + this.allTables.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 }); + } + + this.availableDashboards = this._dashboardsService.dashboards().map((d) => ({ + id: d.id, + name: d.name, + })); + + this.loading = false; + + if (this.cedarPolicy) { + this.formParseError = !canRepresentAsForm(this.cedarPolicy); + if (this.formParseError) { + this.editorMode = 'code'; + } else { + this.policyItems = this._parseCedarToPolicyItems(); + } + } + }); + } + + onCedarPolicyChange(value: string) { + this.cedarPolicy = value; + } + + onPolicyItemsChange(items: CedarPolicyItem[]) { + this.policyItems = items; + } + + onEditorModeChange(mode: 'form' | 'code') { + if (mode === this.editorMode) return; + + if (mode === 'code') { + this.cedarPolicy = policyItemsToCedarPolicy(this.policyItems, this.connectionID, this.data.groupId); + this.cedarPolicyModel = { + language: 'cedar', + uri: `cedar-policy-${this.data.groupId}-${Date.now()}.cedar`, + value: this.cedarPolicy, + }; + this.formParseError = false; + } else { + this.formParseError = !canRepresentAsForm(this.cedarPolicy); + if (this.formParseError) return; + this.policyItems = this._parseCedarToPolicyItems(); + } + + this.editorMode = mode; + } + + savePolicy() { + this.submitting = true; + + if (this.editorMode === 'form') { + this.cedarPolicy = policyItemsToCedarPolicy(this.policyItems, this.connectionID, this.data.groupId); + } + + if (!this.cedarPolicy) { + this.submitting = 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; + }, + }); + } + + private _parseCedarToPolicyItems(): CedarPolicyItem[] { + const parsed = parseCedarPolicy(this.cedarPolicy, this.connectionID, this.data.groupId, this.allTables); + const dashboardItems = parseCedarDashboardItems(this.cedarPolicy, this.connectionID); + return [...permissionsToPolicyItems(parsed), ...dashboardItems]; + } +} diff --git a/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.css b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.css new file mode 100644 index 000000000..453e80003 --- /dev/null +++ b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.css @@ -0,0 +1,81 @@ +.policy-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.empty-state { + opacity: 0.7; + font-size: 14px; + padding: 12px 0; +} + +.policy-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 8px; + border-radius: 4px; + border: 1px solid var(--mdc-outlined-text-field-outline-color, rgba(0, 0, 0, 0.12)); +} + +.policy-item--add { + border-style: dashed; +} + +.policy-item__content { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; +} + +.policy-item__icon { + font-size: 20px; + width: 20px; + height: 20px; + color: var(--color-accentedPalette-500, #1976d2); + flex-shrink: 0; +} + +.policy-item__label { + font-size: 14px; + font-weight: 500; +} + +.policy-item__table { + font-size: 14px; + opacity: 0.7; +} + +.policy-item__actions { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.policy-item__edit-form { + display: flex; + align-items: flex-start; + gap: 8px; + flex: 1; + flex-wrap: wrap; +} + +.policy-field { + flex: 1; + min-width: 180px; +} + +.policy-item__edit-actions { + display: flex; + align-items: center; + gap: 4px; + padding-top: 8px; +} + +.add-policy-button { + align-self: flex-start; + margin-top: 4px; +} 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 new file mode 100644 index 000000000..97d3d4726 --- /dev/null +++ b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.html @@ -0,0 +1,124 @@ +
+ + +
+ No policies defined. Add a policy to grant permissions. +
+ +
+ + +
+ security + {{ getActionLabel(policy.action) }} + + — {{ getTableDisplayName(policy.tableName) }} + + + — {{ getDashboardDisplayName(policy.dashboardId) }} + +
+
+ + +
+
+ + + +
+ + Action + + + + {{ action.label }} + + + + + + + Table + + All tables + + {{ table.displayName }} + + + + + + Dashboard + + All dashboards + + {{ 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 new file mode 100644 index 000000000..bfd4367ea --- /dev/null +++ b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.spec.ts @@ -0,0 +1,192 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { CedarPolicyListComponent } from './cedar-policy-list.component'; + +describe('CedarPolicyListComponent', () => { + let component: CedarPolicyListComponent; + let fixture: ComponentFixture; + + const fakeTables = [ + { tableName: 'customers', displayName: 'Customers' }, + { tableName: 'orders', displayName: 'Orders' }, + ]; + + const fakeDashboards = [ + { id: 'dash-1', name: 'Sales Dashboard' }, + { id: 'dash-2', name: 'Analytics' }, + ]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CedarPolicyListComponent, FormsModule, BrowserAnimationsModule], + }).compileComponents(); + + fixture = TestBed.createComponent(CedarPolicyListComponent); + component = fixture.componentInstance; + component.availableTables = fakeTables; + component.availableDashboards = fakeDashboards; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should add a policy', () => { + const emitSpy = vi.spyOn(component.policiesChange, 'emit'); + 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(component.showAddForm).toBe(false); + }); + + it('should add a table policy with tableName', () => { + 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'); + }); + + it('should add a table policy with wildcard tableName', () => { + 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('*'); + }); + + it('should not add policy without action', () => { + component.showAddForm = true; + component.newAction = ''; + component.addPolicy(); + + expect(component.policies.length).toBe(0); + }); + + it('should not add table policy without table name', () => { + component.showAddForm = true; + component.newAction = 'table:read'; + component.newTableName = ''; + component.addPolicy(); + + expect(component.policies.length).toBe(0); + }); + + it('should remove a policy', () => { + component.policies = [{ action: 'connection:read' }, { action: 'group:read' }]; + const emitSpy = vi.spyOn(component.policiesChange, 'emit'); + + component.removePolicy(0); + + expect(component.policies.length).toBe(1); + expect(component.policies[0].action).toBe('group:read'); + expect(emitSpy).toHaveBeenCalled(); + }); + + it('should start and save edit', () => { + component.policies = [{ action: 'connection:read' }]; + const emitSpy = vi.spyOn(component.policiesChange, 'emit'); + + component.startEdit(0); + expect(component.editingIndex).toBe(0); + expect(component.editAction).toBe('connection:read'); + + component.editAction = 'connection:edit'; + component.saveEdit(0); + + expect(component.policies[0].action).toBe('connection:edit'); + expect(component.editingIndex).toBeNull(); + expect(emitSpy).toHaveBeenCalled(); + }); + + it('should cancel edit', () => { + component.policies = [{ action: 'connection:read' }]; + component.startEdit(0); + component.editAction = 'connection:edit'; + component.cancelEdit(); + + expect(component.editingIndex).toBeNull(); + expect(component.policies[0].action).toBe('connection:read'); + }); + + it('should return correct action labels', () => { + expect(component.getActionLabel('*')).toBe('Full access (all permissions)'); + expect(component.getActionLabel('connection:read')).toBe('Connection read'); + expect(component.getActionLabel('table:edit')).toBe('Table edit'); + expect(component.getActionLabel('table:*')).toBe('Full table access'); + }); + + it('should return correct table display names', () => { + expect(component.getTableDisplayName('customers')).toBe('Customers'); + expect(component.getTableDisplayName('unknown')).toBe('unknown'); + expect(component.getTableDisplayName('*')).toBe('All tables'); + }); + + it('should detect needsTable correctly', () => { + component.newAction = 'connection:read'; + expect(component.needsTable).toBe(false); + + component.newAction = 'table:read'; + expect(component.needsTable).toBe(true); + + component.newAction = 'table:*'; + expect(component.needsTable).toBe(true); + }); + + it('should reset add form', () => { + component.showAddForm = true; + component.newAction = 'connection:read'; + component.newTableName = 'test'; + component.resetAddForm(); + + expect(component.showAddForm).toBe(false); + expect(component.newAction).toBe(''); + expect(component.newTableName).toBe(''); + }); + + it('should add a dashboard policy with dashboardId', () => { + 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'); + }); + + it('should not add dashboard policy without dashboard id', () => { + component.showAddForm = true; + component.newAction = 'dashboard:edit'; + component.newDashboardId = ''; + component.addPolicy(); + + expect(component.policies.length).toBe(0); + }); + + it('should detect needsDashboard correctly', () => { + component.newAction = 'connection:read'; + expect(component.needsDashboard).toBe(false); + + component.newAction = 'dashboard:read'; + expect(component.needsDashboard).toBe(true); + }); + + it('should return correct dashboard display names', () => { + expect(component.getDashboardDisplayName('dash-1')).toBe('Sales Dashboard'); + expect(component.getDashboardDisplayName('unknown')).toBe('unknown'); + expect(component.getDashboardDisplayName('*')).toBe('All dashboards'); + }); +}); 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 new file mode 100644 index 000000000..1e9fb8dec --- /dev/null +++ b/frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.ts @@ -0,0 +1,142 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, 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, POLICY_ACTION_GROUPS, POLICY_ACTIONS } from 'src/app/lib/cedar-policy-items'; +import { ContentLoaderComponent } from '../../ui-components/content-loader/content-loader.component'; + +export interface AvailableTable { + tableName: string; + displayName: string; +} + +export interface AvailableDashboard { + id: string; + name: string; +} + +@Component({ + selector: 'app-cedar-policy-list', + imports: [ + CommonModule, + FormsModule, + MatButtonModule, + MatFormFieldModule, + MatIconModule, + MatSelectModule, + MatTooltipModule, + ContentLoaderComponent, + ], + templateUrl: './cedar-policy-list.component.html', + styleUrls: ['./cedar-policy-list.component.css'], +}) +export class CedarPolicyListComponent { + @Input() policies: CedarPolicyItem[] = []; + @Input() availableTables: AvailableTable[] = []; + @Input() availableDashboards: AvailableDashboard[] = []; + @Input() loading: boolean = false; + @Output() policiesChange = new EventEmitter(); + + showAddForm = false; + newAction = ''; + newTableName = ''; + newDashboardId = ''; + + editingIndex: number | null = null; + editAction = ''; + editTableName = ''; + editDashboardId = ''; + + availableActions = POLICY_ACTIONS; + actionGroups = POLICY_ACTION_GROUPS; + + get needsTable(): boolean { + 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; + } + + get editNeedsTable(): boolean { + 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; + } + + getActionLabel(action: string): string { + 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; + } + + getDashboardDisplayName(dashboardId: string): string { + if (dashboardId === '*') return 'All dashboards'; + return this.availableDashboards.find((d) => d.id === dashboardId)?.name || dashboardId; + } + + addPolicy() { + if (!this.newAction) return; + if (this.needsTable && !this.newTableName) return; + if (this.needsDashboard && !this.newDashboardId) return; + + const item: CedarPolicyItem = { action: this.newAction }; + if (this.needsTable) { + item.tableName = this.newTableName; + } + if (this.needsDashboard) { + item.dashboardId = this.newDashboardId; + } + this.policies = [...this.policies, item]; + this.policiesChange.emit(this.policies); + this.resetAddForm(); + } + + removePolicy(index: number) { + this.policies = this.policies.filter((_, i) => i !== index); + this.policiesChange.emit(this.policies); + } + + startEdit(index: number) { + this.editingIndex = index; + this.editAction = this.policies[index].action; + this.editTableName = this.policies[index].tableName || ''; + this.editDashboardId = this.policies[index].dashboardId || ''; + } + + saveEdit(index: number) { + if (!this.editAction) return; + if (this.editNeedsTable && !this.editTableName) return; + if (this.editNeedsDashboard && !this.editDashboardId) return; + + 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.editingIndex = null; + } + + cancelEdit() { + this.editingIndex = null; + } + + resetAddForm() { + this.showAddForm = false; + this.newAction = ''; + this.newTableName = ''; + this.newDashboardId = ''; + } +} 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 e7dcdf429..dd7483afe 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 @@ -8,10 +8,10 @@

Create group of users

- - + - \ No newline at end of file + 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 00b6d07b8..12b36c54c 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 @@ -20,7 +20,7 @@ describe('GroupAddDialogComponent', () => { close: () => {}, }; - beforeEach(async () => { + beforeEach(() => { TestBed.configureTestingModule({ imports: [ MatSnackBarModule, @@ -59,7 +59,6 @@ describe('GroupAddDialogComponent', () => { component.addGroup(); expect(fakeCreateUsersGroup).toHaveBeenCalledWith('12345678', 'Sellers'); - // expect(component.dialogRef.close).toHaveBeenCalled(); expect(component.submitting).toBe(false); }); }); 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 9d0f2f249..5e0bbf9f4 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 @@ -36,9 +36,9 @@ export class GroupAddDialogComponent implements OnInit { addGroup() { this.submitting = true; this._usersService.createUsersGroup(this.connectionID, this.groupTitle).subscribe( - () => { + (res) => { this.submitting = false; - this.dialogRef.close(); + this.dialogRef.close(res); this.angulartics2.eventTrack.next({ action: 'User groups: user groups was created successfully', }); 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 a5e2e2619..e42551ba0 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 @@ -8,8 +8,8 @@

Change group name

- - + 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 834d4b22e..75aeaf474 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 @@ -5,6 +5,8 @@ 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 { GroupNameEditDialogComponent } from './group-name-edit-dialog.component'; describe('GroupNameEditDialogComponent', () => { @@ -17,13 +19,25 @@ describe('GroupNameEditDialogComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [MatDialogModule, MatSnackBarModule, FormsModule, BrowserAnimationsModule, GroupNameEditDialogComponent], + imports: [ + MatDialogModule, + MatSnackBarModule, + FormsModule, + BrowserAnimationsModule, + Angulartics2Module.forRoot({}), + GroupNameEditDialogComponent, + ], providers: [ provideHttpClient(), - { provide: MAT_DIALOG_DATA, useValue: {} }, + provideRouter([]), + { + provide: MAT_DIALOG_DATA, + useValue: { id: 'test-id', title: 'Test Group' }, + }, { provide: MatDialogRef, useValue: mockDialogRef }, ], }).compileComponents(); + fixture = TestBed.createComponent(GroupNameEditDialogComponent); component = fixture.componentInstance; fixture.detectChanges(); 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 9da466ab9..4a07cd8b1 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 @@ -6,7 +6,7 @@ import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/materia import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { UsersService } from 'src/app/services/users.service'; -import { GroupAddDialogComponent } from '../group-add-dialog/group-add-dialog.component'; +import { GroupNameEditDialogComponent as Self } from './group-name-edit-dialog.component'; @Component({ selector: 'app-group-name-edit-dialog', @@ -15,14 +15,13 @@ import { GroupAddDialogComponent } from '../group-add-dialog/group-add-dialog.co imports: [NgIf, MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule, FormsModule], }) export class GroupNameEditDialogComponent { - public connectionID: string; public groupTitle: string = ''; public submitting: boolean = false; constructor( - @Inject(MAT_DIALOG_DATA) public group: any, + @Inject(MAT_DIALOG_DATA) public group: { id: string; title: string }, public _usersService: UsersService, - public dialogRef: MatDialogRef, + public dialogRef: MatDialogRef, ) {} ngOnInit(): void { diff --git a/frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.css b/frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.css deleted file mode 100644 index 420ed8631..000000000 --- a/frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.css +++ /dev/null @@ -1,152 +0,0 @@ -.permissions-header { - display: flex; - align-items: center; - justify-content: space-between; - margin: 16px 0; -} - -.permissions-header::before { - display: none; -} - -.permissions-alert { - --alert-margin: 0 0 20px; - - top: 0; -} - -.permissions-form ::ng-deep .mat-mdc-dialog-content { - color: var(--mat-sidenav-content-text-color); -} - -.permissions { - display: grid; - grid-template-columns: auto 1fr; - grid-column-gap: 60px; - grid-row-gap: 16px; - align-items: center; - margin-bottom: 8px; -} - -.permissions__title { - margin-bottom: 0 !important; -} - -.permissions-toggle-group { - justify-self: flex-start; -} - -.tables-options { - display: flex; - align-items: center; - justify-content: flex-end; -} - -.tables-list { - position: relative; - grid-column: 1 / span 2; - display: grid; - grid-template-columns: 72px 1fr 72px repeat(3, 60px); - grid-row-gap: 8px; - align-items: center; - justify-items: center; - margin: 0 -8px; -} - -.tables-list__header { - grid-column: 1 / -1; - display: grid; - grid-template-columns: subgrid; - justify-items: center; - background-color: var(--color-accentedPalette-100); - padding: 8px 0; -} - -@media (prefers-color-scheme: dark) { - .tables-list__header { - background-color: var(--color-accentedPalette-800); - } -} - -.tables-list__item { - display: contents; - border-bottom: 1px solid rgba(0, 0, 0, 0.12); -} - -.tables-list__divider { - grid-column: 1 / -1; - width: 100%; -} - -.table-name-title { - justify-self: flex-start; - padding-left: 16px; -} - -.table-name { - --normalized-table-name-color: var(--mat-sidenav-content-text-color); - --orginal-table-name-color: rgba(0, 0, 0, 0.6); - - justify-self: flex-start; - display: flex; - flex-direction: column; - gap: 4px; - padding-left: 16px; -} - -@media (prefers-color-scheme: dark) { - .table-name { - --orginal-table-name-color: rgba(255, 255, 255, 0.75); - } -} - -.table-name_disabled { - --normalized-table-name-color: rgba(0, 0, 0, 0.38); - --orginal-table-name-color: rgba(0, 0, 0, 0.38); -} - -@media (prefers-color-scheme: dark) { - .table-name_disabled { - --normalized-table-name-color: rgba(255, 255, 255, 0.38); - --orginal-table-name-color: rgba(255, 255, 255, 0.38); - } -} - -.table-name__title { - color: var(--normalized-table-name-color); - font-size: 16px; - margin-bottom: -6px; -} - -.table-name__line { - color: var(--orginal-table-name-color); - font-size: 12px; -} - -.tables-overlay { - position: absolute; - background: rgba(255, 255, 255, 0.75); - box-sizing: border-box; - padding-top: 12px; - width: 100%; - height: 100%; - z-index: 2; - text-align: center; -} - -.tables-overlay__message { - background: rgba(255, 255, 255, 0.9); - box-shadow: 0 0 8px 8px rgba(255, 255, 255, 0.9); - padding: 8px; - display: block; - max-width: 32%; - margin: 0 auto; -} - -.visibilityIcon { - cursor: pointer; -} - -.visibilityIcon_visible { - color: var(--color-accentedPalette-500); -} diff --git a/frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.html b/frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.html deleted file mode 100644 index 715c1e953..000000000 --- a/frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.html +++ /dev/null @@ -1,130 +0,0 @@ -
-

- Permissions for {{ group.title }} group - - open_in_new - Docs - -

- - - - -
-

Connection credentials

- - None - ReadOnly - Full access - - -

User management

- - None - ReadOnly - Manage the list - - -

Tables

- Full access -
- - - -
- - -
-
-
-
Visibility
-
Table name
-
ReadOnly
-
Add
-
Delete
-
Edit
-
- -
-
-
- - -
- -
- {{table.display_name}} - {{table.tableName}} -
- - - -
- - -
-
- - -
-
- - -
-
- -
-
-
-
- - - - - - - - - - -
\ No newline at end of file diff --git a/frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.spec.ts b/frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.spec.ts deleted file mode 100644 index 54c1bf3d7..000000000 --- a/frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.spec.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { provideHttpClient } from '@angular/common/http'; -import { forwardRef } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatRadioModule } from '@angular/material/radio'; -import { MatSlideToggleModule } from '@angular/material/slide-toggle'; -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 { AccessLevel } from 'src/app/models/user'; -import { UsersService } from 'src/app/services/users.service'; -import { PermissionsAddDialogComponent } from './permissions-add-dialog.component'; - -describe('PermissionsAddDialogComponent', () => { - let component: PermissionsAddDialogComponent; - let fixture: ComponentFixture; - let usersService: UsersService; - - const mockDialogRef = { - close: () => {}, - }; - - const fakeCustomersPermissionsResponse = { - tableName: 'customers', - accessLevel: { - visibility: true, - readonly: false, - add: true, - delete: false, - edit: true, - }, - }; - - const fakeCustomersPermissionsApp = { - tableName: 'customers', - display_name: 'Customers', - accessLevel: { - visibility: true, - readonly: false, - add: true, - delete: false, - edit: true, - }, - }; - - const fakeOrdersPermissionsResponse = { - tableName: 'orders', - display_name: 'Created orders', - accessLevel: { - visibility: false, - readonly: false, - add: false, - delete: false, - edit: false, - }, - }; - - const fakeOrdersPermissionsApp = { - tableName: 'orders', - display_name: 'Created orders', - accessLevel: { - visibility: false, - readonly: false, - add: false, - delete: false, - edit: false, - }, - }; - - const fakeTablePermissionsResponse = [fakeCustomersPermissionsResponse, fakeOrdersPermissionsResponse]; - - const fakeTablePermissionsApp = [fakeCustomersPermissionsApp, fakeOrdersPermissionsApp]; - - const fakePermissionsResponse = { - connection: { - connectionId: '5e1092f8-4e50-4e6c-bad9-bd0b04d1af2a', - accessLevel: 'readonly', - }, - group: { - groupId: '77154868-eaf0-4a53-9693-0470182d0971', - accessLevel: 'edit', - }, - tables: fakeTablePermissionsResponse, - }; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - MatSnackBarModule, - FormsModule, - MatRadioModule, - MatSlideToggleModule, - MatCheckboxModule, - MatDialogModule, - BrowserAnimationsModule, - Angulartics2Module.forRoot(), - PermissionsAddDialogComponent, - ], - providers: [ - provideHttpClient(), - provideRouter([]), - { provide: MAT_DIALOG_DATA, useValue: {} }, - { provide: MatDialogRef, useValue: mockDialogRef }, - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => PermissionsAddDialogComponent), - multi: true, - }, - ], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(PermissionsAddDialogComponent); - component = fixture.componentInstance; - usersService = TestBed.inject(UsersService); - vi.spyOn(usersService, 'fetchPermission').mockReturnValue(of(fakePermissionsResponse)); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should set initial state of permissions', async () => { - vi.spyOn(usersService, 'fetchPermission').mockReturnValue(of(fakePermissionsResponse)); - - component.ngOnInit(); - fixture.detectChanges(); - await fixture.whenStable(); - - // crutch, i don't like it - component.tablesAccess = [...fakeTablePermissionsApp]; - - expect(component.connectionAccess).toEqual('readonly'); - expect(component.groupAccess).toEqual('edit'); - expect(component.tablesAccess).toEqual(fakeTablePermissionsApp); - }); - - it('should uncheck actions if table is readonly', () => { - component.tablesAccess = [...fakeTablePermissionsApp]; - - component.uncheckActions(component.tablesAccess[0]); - - expect(component.tablesAccess[0].accessLevel.readonly).toBe(false); - expect(component.tablesAccess[0].accessLevel.add).toBe(false); - expect(component.tablesAccess[0].accessLevel.delete).toBe(false); - expect(component.tablesAccess[0].accessLevel.edit).toBe(false); - }); - - it('should uncheck actions if table is invisible', () => { - component.tablesAccess = [...fakeTablePermissionsApp]; - - component.uncheckActions(component.tablesAccess[1]); - - expect(component.tablesAccess[1].accessLevel.readonly).toBe(false); - expect(component.tablesAccess[1].accessLevel.add).toBe(false); - expect(component.tablesAccess[1].accessLevel.delete).toBe(false); - expect(component.tablesAccess[1].accessLevel.edit).toBe(false); - }); - - it('should select all tables', () => { - component.tablesAccess = [...fakeTablePermissionsApp]; - - component.grantFullTableAccess(); - - expect(component.tablesAccess).toEqual([ - { - tableName: 'customers', - display_name: 'Customers', - accessLevel: { - visibility: true, - readonly: false, - add: true, - delete: true, - edit: true, - }, - }, - { - tableName: 'orders', - display_name: 'Created orders', - accessLevel: { - visibility: true, - readonly: false, - add: true, - delete: true, - edit: true, - }, - }, - ]); - }); - - it('should deselect all tables', () => { - component.tablesAccess = [...fakeTablePermissionsApp]; - - component.deselectAllTables(); - - expect(component.tablesAccess).toEqual([ - { - tableName: 'customers', - display_name: 'Customers', - accessLevel: { - visibility: false, - readonly: false, - add: false, - delete: false, - edit: false, - }, - }, - { - tableName: 'orders', - display_name: 'Created orders', - accessLevel: { - visibility: false, - readonly: false, - add: false, - delete: false, - edit: false, - }, - }, - ]); - }); - - it('should call add permissions service', () => { - component.connectionID = '12345678'; - component.connectionAccess = AccessLevel.Readonly; - component.group.id = '12345678-123'; - component.groupAccess = AccessLevel.Edit; - component.tablesAccess = [ - { - tableName: 'customers', - display_name: 'Customers', - accessLevel: { - visibility: true, - readonly: false, - add: true, - delete: true, - edit: true, - }, - }, - { - tableName: 'orders', - display_name: 'Created orders', - accessLevel: { - visibility: true, - readonly: false, - add: true, - delete: true, - edit: true, - }, - }, - ]; - - const fakseUpdatePermission = vi.spyOn(usersService, 'updatePermission').mockReturnValue(of()); - vi.spyOn(mockDialogRef, 'close'); - - component.addPermissions(); - - expect(fakseUpdatePermission).toHaveBeenCalledWith('12345678', { - connection: { - connectionId: '12345678', - accessLevel: AccessLevel.Readonly, - }, - group: { - groupId: '12345678-123', - accessLevel: AccessLevel.Edit, - }, - tables: [ - { - tableName: 'customers', - display_name: 'Customers', - accessLevel: { - visibility: true, - readonly: false, - add: true, - delete: true, - edit: true, - }, - }, - { - tableName: 'orders', - display_name: 'Created orders', - accessLevel: { - visibility: true, - readonly: false, - add: true, - delete: true, - edit: true, - }, - }, - ], - }); - // expect(component.dialogRef.close).toHaveBeenCalled(); - expect(component.submitting).toBe(false); - }); -}); diff --git a/frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.ts b/frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.ts deleted file mode 100644 index 5c33e1841..000000000 --- a/frontend/src/app/components/users/permissions-add-dialog/permissions-add-dialog.component.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, Inject, OnInit } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatButtonToggleModule } from '@angular/material/button-toggle'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MAT_DIALOG_DATA, MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatDividerModule } from '@angular/material/divider'; -import { MatIconModule } from '@angular/material/icon'; -import { MatSlideToggleModule } from '@angular/material/slide-toggle'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { Angulartics2 } from 'angulartics2'; -import posthog from 'posthog-js'; -import { normalizeTableName } from 'src/app/lib/normalize'; -import { AlertActionType, AlertType } from 'src/app/models/alert'; -import { AccessLevel, TablePermission, UserGroup } from 'src/app/models/user'; -import { ConnectionsService } from 'src/app/services/connections.service'; -import { UsersService } from 'src/app/services/users.service'; -import { AlertComponent } from '../../ui-components/alert/alert.component'; -import { ContentLoaderComponent } from '../../ui-components/content-loader/content-loader.component'; -import { GroupAddDialogComponent } from '../group-add-dialog/group-add-dialog.component'; -import { GroupDeleteDialogComponent } from '../group-delete-dialog/group-delete-dialog.component'; - -@Component({ - selector: 'app-permissions-add-dialog', - imports: [ - CommonModule, - FormsModule, - MatDialogModule, - MatButtonModule, - MatButtonToggleModule, - MatCheckboxModule, - MatIconModule, - MatSlideToggleModule, - MatTooltipModule, - MatDividerModule, - ContentLoaderComponent, - AlertComponent, - ], - templateUrl: './permissions-add-dialog.component.html', - styleUrls: ['./permissions-add-dialog.component.css'], -}) -export class PermissionsAddDialogComponent implements OnInit { - public submitting: boolean = false; - public loading: boolean = true; - public connectionAccess: AccessLevel; - public groupAccess: AccessLevel; - public tablesAccessOptions = 'select'; - public tablesAccess: TablePermission[] = []; - public connectionID: string; - public adminGroupAlert = { - id: 10000, - type: AlertType.Info, - message: "Admin group permissions can't be changed, create a new group to configure.", - actions: [ - { - type: AlertActionType.Button, - caption: 'New group', - action: () => this.handleOpenNewGroupPopup(), - }, - ], - }; - public connectionFullAccessAlert = { - id: 10000, - type: AlertType.Info, - message: - "Connection full access automatically means full access to group management, view, add, edit and delete all tables' rows.", - }; - - constructor( - @Inject(MAT_DIALOG_DATA) public group: UserGroup, - private _usersService: UsersService, - public dialogRef: MatDialogRef, - public dialog: MatDialog, - private _connections: ConnectionsService, - private angulartics2: Angulartics2, - ) {} - - ngOnInit(): void { - this.connectionID = this._connections.currentConnectionID; - this._usersService.fetchPermission(this.connectionID, this.group.id).subscribe((res) => { - this.connectionAccess = res.connection.accessLevel; - this.groupAccess = res.group.accessLevel; - this.tablesAccess = res.tables.map((table) => { - return { ...table, display_name: table.display_name || normalizeTableName(table.tableName) }; - }); - this.loading = false; - - if (this.group.title === 'Admin') this.grantFullTableAccess(); - }); - } - - uncheckActions(table: TablePermission) { - if (!table.accessLevel.visibility) table.accessLevel.readonly = false; - table.accessLevel.add = false; - table.accessLevel.delete = false; - table.accessLevel.edit = false; - } - - handleOpenNewGroupPopup() { - this.dialogRef.close('add_group'); - this.dialog.open(GroupAddDialogComponent, { - width: '25em', - }); - } - - grantFullTableAccess() { - this.tablesAccess.forEach((table) => { - table.accessLevel.add = true; - table.accessLevel.delete = true; - table.accessLevel.edit = true; - table.accessLevel.readonly = false; - table.accessLevel.visibility = true; - }); - } - - deselectAllTables() { - this.tablesAccess.forEach((table) => { - table.accessLevel.add = false; - table.accessLevel.delete = false; - table.accessLevel.edit = false; - table.accessLevel.readonly = false; - table.accessLevel.visibility = false; - }); - } - - handleConnectionAccessChange() { - if (this.connectionAccess === 'edit') { - this.groupAccess = AccessLevel.Edit; - this.grantFullTableAccess(); - } else { - this.deselectAllTables(); - } - } - - onVisibilityChange(event: Event, index: number) { - if (!this.tablesAccess[index].accessLevel.visibility) { - event.preventDefault(); - this.tablesAccess[index].accessLevel.readonly = !this.tablesAccess[index].accessLevel.readonly; - } - this.tablesAccess[index].accessLevel.visibility = true; - } - - onRecordActionPermissionChange(action: string, index: number) { - this.tablesAccess[index].accessLevel[action] = !this.tablesAccess[index].accessLevel[action]; - this.tablesAccess[index].accessLevel.readonly = false; - this.tablesAccess[index].accessLevel.visibility = true; - } - - addPermissions() { - this.submitting = true; - let permissions = { - connection: { - connectionId: this.connectionID, - accessLevel: this.connectionAccess, - }, - group: { - groupId: this.group.id, - accessLevel: this.groupAccess, - }, - tables: this.tablesAccess, - }; - this._usersService.updatePermission(this.connectionID, permissions).subscribe( - () => { - this.dialogRef.close(); - this.submitting = false; - this.angulartics2.eventTrack.next({ - action: 'User groups: user group permissions were updated successfully', - }); - posthog.capture('User groups: user group permissions were updated successfully'); - }, - () => {}, - () => { - this.submitting = false; - }, - ); - } -} diff --git a/frontend/src/app/components/users/users.component.html b/frontend/src/app/components/users/users.component.html index 541379c00..6efc9bb28 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

- - - -