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) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
-
\ 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
-