Skip to content

Commit 0bbe8a2

Browse files
guguclaude
andcommitted
refactor: separate cedar policy editor into dedicated dialog using new endpoint
Revert cedar policy editing from group create/edit dialogs and introduce a standalone CedarPolicyEditorDialogComponent that uses the dedicated POST /connection/cedar-policy/:connectionId endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d024506 commit 0bbe8a2

17 files changed

Lines changed: 350 additions & 462 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
.editor-mode-toggle {
2+
display: flex;
3+
align-items: center;
4+
justify-content: space-between;
5+
margin-bottom: 12px;
6+
}
7+
8+
.code-editor-box {
9+
height: 300px;
10+
border: 1px solid var(--mdc-outlined-text-field-outline-color, rgba(0, 0, 0, 0.38));
11+
border-radius: 4px;
12+
overflow: hidden;
13+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<h1 mat-dialog-title>Cedar policy — {{ data.groupTitle }}</h1>
2+
<mat-dialog-content>
3+
<div class="editor-mode-toggle">
4+
<mat-button-toggle-group [value]="editorMode" (change)="onEditorModeChange($event.value)">
5+
<mat-button-toggle value="form">Form</mat-button-toggle>
6+
<mat-button-toggle value="code">Code</mat-button-toggle>
7+
</mat-button-toggle-group>
8+
</div>
9+
10+
<div *ngIf="editorMode === 'form'">
11+
<app-cedar-policy-list
12+
[policies]="policyItems"
13+
[availableTables]="availableTables"
14+
[loading]="tablesLoading"
15+
(policiesChange)="onPolicyItemsChange($event)">
16+
</app-cedar-policy-list>
17+
</div>
18+
19+
<div *ngIf="editorMode === 'code'" class="code-editor-box">
20+
<ngs-code-editor
21+
[theme]="codeEditorTheme"
22+
[codeModel]="cedarPolicyModel"
23+
[options]="codeEditorOptions"
24+
(valueChanged)="onCedarPolicyChange($event)">
25+
</ngs-code-editor>
26+
</div>
27+
</mat-dialog-content>
28+
<mat-dialog-actions align="end">
29+
<button type="button" mat-flat-button mat-dialog-close>Cancel</button>
30+
<button type="button" mat-flat-button color="primary"
31+
[disabled]="submitting"
32+
(click)="savePolicy()">
33+
Save
34+
</button>
35+
</mat-dialog-actions>
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { provideHttpClient } from '@angular/common/http';
2+
import { NO_ERRORS_SCHEMA } from '@angular/core';
3+
import { ComponentFixture, TestBed } from '@angular/core/testing';
4+
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
5+
import { MatSnackBarModule } from '@angular/material/snack-bar';
6+
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
7+
import { provideRouter } from '@angular/router';
8+
import { CodeEditorModule } from '@ngstack/code-editor';
9+
import { Angulartics2Module } from 'angulartics2';
10+
import { of } from 'rxjs';
11+
import { TablesService } from 'src/app/services/tables.service';
12+
import { MockCodeEditorComponent } from 'src/app/testing/code-editor.mock';
13+
import { CedarPolicyEditorDialogComponent } from './cedar-policy-editor-dialog.component';
14+
15+
describe('CedarPolicyEditorDialogComponent', () => {
16+
let component: CedarPolicyEditorDialogComponent;
17+
let fixture: ComponentFixture<CedarPolicyEditorDialogComponent>;
18+
let tablesService: TablesService;
19+
20+
const mockDialogRef = {
21+
close: () => {},
22+
};
23+
24+
const fakeTables = [
25+
{
26+
table: 'customers',
27+
display_name: 'Customers',
28+
permissions: { visibility: true, readonly: false, add: true, delete: true, edit: true },
29+
},
30+
{
31+
table: 'orders',
32+
display_name: 'Orders',
33+
permissions: { visibility: true, readonly: false, add: true, delete: false, edit: true },
34+
},
35+
];
36+
37+
const cedarPolicyWithConnection = [
38+
'permit(',
39+
' principal,',
40+
' action == RocketAdmin::Action::"connection:read",',
41+
' resource == RocketAdmin::Connection::"conn-123"',
42+
');',
43+
].join('\n');
44+
45+
beforeEach(() => {
46+
TestBed.configureTestingModule({
47+
imports: [
48+
MatDialogModule,
49+
MatSnackBarModule,
50+
BrowserAnimationsModule,
51+
Angulartics2Module.forRoot({}),
52+
CedarPolicyEditorDialogComponent,
53+
],
54+
providers: [
55+
provideHttpClient(),
56+
provideRouter([]),
57+
{
58+
provide: MAT_DIALOG_DATA,
59+
useValue: { groupId: 'group-123', groupTitle: 'Test Group', cedarPolicy: cedarPolicyWithConnection },
60+
},
61+
{ provide: MatDialogRef, useValue: mockDialogRef },
62+
],
63+
})
64+
.overrideComponent(CedarPolicyEditorDialogComponent, {
65+
remove: { imports: [CodeEditorModule] },
66+
add: { imports: [MockCodeEditorComponent], schemas: [NO_ERRORS_SCHEMA] },
67+
})
68+
.compileComponents();
69+
70+
tablesService = TestBed.inject(TablesService);
71+
vi.spyOn(tablesService, 'fetchTables').mockReturnValue(of(fakeTables));
72+
73+
fixture = TestBed.createComponent(CedarPolicyEditorDialogComponent);
74+
component = fixture.componentInstance;
75+
fixture.detectChanges();
76+
});
77+
78+
it('should create', () => {
79+
expect(component).toBeTruthy();
80+
});
81+
82+
it('should load tables on init', () => {
83+
expect(tablesService.fetchTables).toHaveBeenCalled();
84+
expect(component.allTables.length).toBe(2);
85+
expect(component.availableTables.length).toBe(2);
86+
expect(component.tablesLoading).toBe(false);
87+
});
88+
89+
it('should pre-populate policy items from existing cedar policy', () => {
90+
expect(component.policyItems.length).toBeGreaterThan(0);
91+
expect(component.policyItems.some((item) => item.action === 'connection:read')).toBe(true);
92+
});
93+
94+
it('should start in form mode', () => {
95+
expect(component.editorMode).toBe('form');
96+
});
97+
98+
it('should switch to code mode', () => {
99+
component.onEditorModeChange('code');
100+
expect(component.editorMode).toBe('code');
101+
expect(component.cedarPolicy).toBeTruthy();
102+
});
103+
});
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { NgIf } from '@angular/common';
2+
import { Component, Inject, OnInit } from '@angular/core';
3+
import { MatButtonModule } from '@angular/material/button';
4+
import { MatButtonToggleModule } from '@angular/material/button-toggle';
5+
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
6+
import { CodeEditorModule, CodeEditorService } from '@ngstack/code-editor';
7+
import { registerCedarLanguage } from 'src/app/lib/cedar-monaco-language';
8+
import { CedarPolicyItem, permissionsToPolicyItems, policyItemsToCedarPolicy } from 'src/app/lib/cedar-policy-items';
9+
import { parseCedarPolicy } from 'src/app/lib/cedar-policy-parser';
10+
import { normalizeTableName } from 'src/app/lib/normalize';
11+
import { TablePermission } from 'src/app/models/user';
12+
import { ConnectionsService } from 'src/app/services/connections.service';
13+
import { TablesService } from 'src/app/services/tables.service';
14+
import { UiSettingsService } from 'src/app/services/ui-settings.service';
15+
import { UsersService } from 'src/app/services/users.service';
16+
import { AvailableTable, CedarPolicyListComponent } from '../cedar-policy-list/cedar-policy-list.component';
17+
import { CedarPolicyEditorDialogComponent as Self } from './cedar-policy-editor-dialog.component';
18+
19+
export interface CedarPolicyEditorDialogData {
20+
groupId: string;
21+
groupTitle: string;
22+
cedarPolicy?: string | null;
23+
}
24+
25+
@Component({
26+
selector: 'app-cedar-policy-editor-dialog',
27+
templateUrl: './cedar-policy-editor-dialog.component.html',
28+
styleUrls: ['./cedar-policy-editor-dialog.component.css'],
29+
imports: [NgIf, MatDialogModule, MatButtonModule, MatButtonToggleModule, CodeEditorModule, CedarPolicyListComponent],
30+
})
31+
export class CedarPolicyEditorDialogComponent implements OnInit {
32+
public connectionID: string;
33+
public cedarPolicy: string = '';
34+
public submitting: boolean = false;
35+
36+
public editorMode: 'form' | 'code' = 'form';
37+
public policyItems: CedarPolicyItem[] = [];
38+
public availableTables: AvailableTable[] = [];
39+
public allTables: TablePermission[] = [];
40+
public tablesLoading: boolean = true;
41+
42+
public cedarPolicyModel: object;
43+
public codeEditorOptions = {
44+
minimap: { enabled: false },
45+
automaticLayout: true,
46+
scrollBeyondLastLine: false,
47+
wordWrap: 'on',
48+
};
49+
public codeEditorTheme: string;
50+
51+
constructor(
52+
@Inject(MAT_DIALOG_DATA) public data: CedarPolicyEditorDialogData,
53+
public dialogRef: MatDialogRef<Self>,
54+
private _connections: ConnectionsService,
55+
private _usersService: UsersService,
56+
private _uiSettings: UiSettingsService,
57+
private _tablesService: TablesService,
58+
private _editorService: CodeEditorService,
59+
) {
60+
this.codeEditorTheme = this._uiSettings.isDarkMode ? 'vs-dark' : 'vs';
61+
this._editorService.loaded.subscribe(({ monaco }) => registerCedarLanguage(monaco));
62+
}
63+
64+
ngOnInit(): void {
65+
this.connectionID = this._connections.currentConnectionID;
66+
this.cedarPolicy = this.data.cedarPolicy || '';
67+
this.cedarPolicyModel = {
68+
language: 'cedar',
69+
uri: `cedar-policy-${this.data.groupId}.cedar`,
70+
value: this.cedarPolicy,
71+
};
72+
73+
this._tablesService.fetchTables(this.connectionID).subscribe((tables) => {
74+
this.allTables = tables.map((t) => ({
75+
tableName: t.table,
76+
display_name: t.display_name || normalizeTableName(t.table),
77+
accessLevel: {
78+
visibility: false,
79+
readonly: false,
80+
add: false,
81+
delete: false,
82+
edit: false,
83+
},
84+
}));
85+
this.availableTables = tables.map((t) => ({
86+
tableName: t.table,
87+
displayName: t.display_name || normalizeTableName(t.table),
88+
}));
89+
this.tablesLoading = false;
90+
91+
if (this.cedarPolicy) {
92+
const parsed = parseCedarPolicy(this.cedarPolicy, this.connectionID, this.data.groupId, this.allTables);
93+
this.policyItems = permissionsToPolicyItems(parsed);
94+
}
95+
});
96+
}
97+
98+
onCedarPolicyChange(value: string) {
99+
this.cedarPolicy = value;
100+
}
101+
102+
onPolicyItemsChange(items: CedarPolicyItem[]) {
103+
this.policyItems = items;
104+
}
105+
106+
onEditorModeChange(mode: 'form' | 'code') {
107+
if (mode === this.editorMode) return;
108+
109+
if (mode === 'code') {
110+
this.cedarPolicy = policyItemsToCedarPolicy(this.policyItems, this.connectionID, this.data.groupId);
111+
this.cedarPolicyModel = {
112+
language: 'cedar',
113+
uri: `cedar-policy-${this.data.groupId}-${Date.now()}.cedar`,
114+
value: this.cedarPolicy,
115+
};
116+
} else {
117+
const parsed = parseCedarPolicy(this.cedarPolicy, this.connectionID, this.data.groupId, this.allTables);
118+
this.policyItems = permissionsToPolicyItems(parsed);
119+
}
120+
121+
this.editorMode = mode;
122+
}
123+
124+
savePolicy() {
125+
this.submitting = true;
126+
127+
if (this.editorMode === 'form') {
128+
this.cedarPolicy = policyItemsToCedarPolicy(this.policyItems, this.connectionID, this.data.groupId);
129+
}
130+
131+
if (!this.cedarPolicy) {
132+
this.submitting = false;
133+
this.dialogRef.close();
134+
return;
135+
}
136+
137+
this._usersService.saveCedarPolicy(this.connectionID, this.data.groupId, this.cedarPolicy).subscribe(
138+
() => {
139+
this.submitting = false;
140+
this.dialogRef.close();
141+
},
142+
() => {},
143+
() => {
144+
this.submitting = false;
145+
},
146+
);
147+
}
148+
}
Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,3 @@
11
.mat-mdc-form-field {
22
width: 100%;
33
}
4-
5-
.cedar-policy-section {
6-
margin-top: 8px;
7-
}
8-
9-
.cedar-policy-label {
10-
display: block;
11-
font-size: 14px;
12-
color: var(--mdc-theme-text-secondary-on-background, rgba(0, 0, 0, 0.6));
13-
margin-bottom: 0;
14-
}
15-
16-
.editor-mode-toggle {
17-
display: flex;
18-
align-items: center;
19-
justify-content: space-between;
20-
margin-bottom: 12px;
21-
}
22-
23-
.code-editor-box {
24-
height: 200px;
25-
border: 1px solid var(--mdc-outlined-text-field-outline-color, rgba(0, 0, 0, 0.38));
26-
border-radius: 4px;
27-
overflow: hidden;
28-
}

frontend/src/app/components/users/group-add-dialog/group-add-dialog.component.html

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,33 +6,6 @@ <h1 mat-dialog-title>Create group of users</h1>
66
<input matInput [(ngModel)]="groupTitle" name="title" #title="ngModel" required data-testid="group-title-input">
77
<mat-error *ngIf="title.errors?.required && (title.invalid && title.touched)">Title should not be empty.</mat-error>
88
</mat-form-field>
9-
10-
<div class="cedar-policy-section">
11-
<div class="editor-mode-toggle">
12-
<mat-button-toggle-group [value]="editorMode" (change)="onEditorModeChange($event.value)">
13-
<mat-button-toggle value="form">Form</mat-button-toggle>
14-
<mat-button-toggle value="code">Code</mat-button-toggle>
15-
</mat-button-toggle-group>
16-
</div>
17-
18-
<div *ngIf="editorMode === 'form'">
19-
<app-cedar-policy-list
20-
[policies]="policyItems"
21-
[availableTables]="availableTables"
22-
[loading]="tablesLoading"
23-
(policiesChange)="onPolicyItemsChange($event)">
24-
</app-cedar-policy-list>
25-
</div>
26-
27-
<div *ngIf="editorMode === 'code'" class="code-editor-box">
28-
<ngs-code-editor
29-
[theme]="codeEditorTheme"
30-
[codeModel]="cedarPolicyModel"
31-
[options]="codeEditorOptions"
32-
(valueChanged)="onCedarPolicyChange($event)">
33-
</ngs-code-editor>
34-
</div>
35-
</div>
369
</mat-dialog-content>
3710
<mat-dialog-actions align="end">
3811
<button type="button" mat-flat-button mat-dialog-close>Cancel</button>

0 commit comments

Comments
 (0)