Skip to content

Commit ae49d0b

Browse files
guguclaude
andcommitted
feat: add dashboard permissions to policy form and improve editor UX
- Add dashboard permission actions (read, create, edit, delete) to the policy form with dashboard selector - Remove "Cedar" from dialog title, now just "Policy — {group}" - Add Cedar format hint with link in the code editor tab - Parse dashboard items from cedar policy text for code→form conversion Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0bbe8a2 commit ae49d0b

9 files changed

Lines changed: 210 additions & 30 deletions

frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.css

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@
55
margin-bottom: 12px;
66
}
77

8+
.cedar-hint {
9+
margin: 0 0 8px;
10+
font-size: 12px;
11+
color: var(--mdc-theme-text-secondary-on-background, rgba(0, 0, 0, 0.54));
12+
}
13+
14+
.cedar-hint a {
15+
color: inherit;
16+
text-decoration: underline;
17+
}
18+
819
.code-editor-box {
920
height: 300px;
1021
border: 1px solid var(--mdc-outlined-text-field-outline-color, rgba(0, 0, 0, 0.38));

frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.html

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<h1 mat-dialog-title>Cedar policy — {{ data.groupTitle }}</h1>
1+
<h1 mat-dialog-title>Policy — {{ data.groupTitle }}</h1>
22
<mat-dialog-content>
33
<div class="editor-mode-toggle">
44
<mat-button-toggle-group [value]="editorMode" (change)="onEditorModeChange($event.value)">
@@ -11,18 +11,22 @@ <h1 mat-dialog-title>Cedar policy — {{ data.groupTitle }}</h1>
1111
<app-cedar-policy-list
1212
[policies]="policyItems"
1313
[availableTables]="availableTables"
14-
[loading]="tablesLoading"
14+
[availableDashboards]="availableDashboards"
15+
[loading]="loading"
1516
(policiesChange)="onPolicyItemsChange($event)">
1617
</app-cedar-policy-list>
1718
</div>
1819

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>
20+
<div *ngIf="editorMode === 'code'">
21+
<p class="cedar-hint">Edit policy in <a href="https://www.cedarpolicy.com/en" target="_blank" rel="noopener">Cedar</a> format</p>
22+
<div class="code-editor-box">
23+
<ngs-code-editor
24+
[theme]="codeEditorTheme"
25+
[codeModel]="cedarPolicyModel"
26+
[options]="codeEditorOptions"
27+
(valueChanged)="onCedarPolicyChange($event)">
28+
</ngs-code-editor>
29+
</div>
2630
</div>
2731
</mat-dialog-content>
2832
<mat-dialog-actions align="end">

frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.spec.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { provideHttpClient } from '@angular/common/http';
2-
import { NO_ERRORS_SCHEMA } from '@angular/core';
2+
import { NO_ERRORS_SCHEMA, signal } from '@angular/core';
33
import { ComponentFixture, TestBed } from '@angular/core/testing';
44
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
55
import { MatSnackBarModule } from '@angular/material/snack-bar';
@@ -8,6 +8,7 @@ import { provideRouter } from '@angular/router';
88
import { CodeEditorModule } from '@ngstack/code-editor';
99
import { Angulartics2Module } from 'angulartics2';
1010
import { of } from 'rxjs';
11+
import { DashboardsService } from 'src/app/services/dashboards.service';
1112
import { TablesService } from 'src/app/services/tables.service';
1213
import { MockCodeEditorComponent } from 'src/app/testing/code-editor.mock';
1314
import { CedarPolicyEditorDialogComponent } from './cedar-policy-editor-dialog.component';
@@ -16,6 +17,7 @@ describe('CedarPolicyEditorDialogComponent', () => {
1617
let component: CedarPolicyEditorDialogComponent;
1718
let fixture: ComponentFixture<CedarPolicyEditorDialogComponent>;
1819
let tablesService: TablesService;
20+
let dashboardsService: Partial<DashboardsService>;
1921

2022
const mockDialogRef = {
2123
close: () => {},
@@ -43,6 +45,13 @@ describe('CedarPolicyEditorDialogComponent', () => {
4345
].join('\n');
4446

4547
beforeEach(() => {
48+
dashboardsService = {
49+
dashboards: signal([
50+
{ id: 'dash-1', name: 'Sales', description: null, connection_id: 'conn-123', created_at: '', updated_at: '' },
51+
]).asReadonly(),
52+
setActiveConnection: vi.fn(),
53+
};
54+
4655
TestBed.configureTestingModule({
4756
imports: [
4857
MatDialogModule,
@@ -59,6 +68,7 @@ describe('CedarPolicyEditorDialogComponent', () => {
5968
useValue: { groupId: 'group-123', groupTitle: 'Test Group', cedarPolicy: cedarPolicyWithConnection },
6069
},
6170
{ provide: MatDialogRef, useValue: mockDialogRef },
71+
{ provide: DashboardsService, useValue: dashboardsService },
6272
],
6373
})
6474
.overrideComponent(CedarPolicyEditorDialogComponent, {
@@ -83,7 +93,7 @@ describe('CedarPolicyEditorDialogComponent', () => {
8393
expect(tablesService.fetchTables).toHaveBeenCalled();
8494
expect(component.allTables.length).toBe(2);
8595
expect(component.availableTables.length).toBe(2);
86-
expect(component.tablesLoading).toBe(false);
96+
expect(component.loading).toBe(false);
8797
});
8898

8999
it('should pre-populate policy items from existing cedar policy', () => {

frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,23 @@ import { MatButtonModule } from '@angular/material/button';
44
import { MatButtonToggleModule } from '@angular/material/button-toggle';
55
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
66
import { CodeEditorModule, CodeEditorService } from '@ngstack/code-editor';
7+
import { forkJoin } from 'rxjs';
78
import { registerCedarLanguage } from 'src/app/lib/cedar-monaco-language';
89
import { CedarPolicyItem, permissionsToPolicyItems, policyItemsToCedarPolicy } from 'src/app/lib/cedar-policy-items';
9-
import { parseCedarPolicy } from 'src/app/lib/cedar-policy-parser';
10+
import { parseCedarDashboardItems, parseCedarPolicy } from 'src/app/lib/cedar-policy-parser';
1011
import { normalizeTableName } from 'src/app/lib/normalize';
12+
import { Dashboard } from 'src/app/models/dashboard';
1113
import { TablePermission } from 'src/app/models/user';
1214
import { ConnectionsService } from 'src/app/services/connections.service';
15+
import { DashboardsService } from 'src/app/services/dashboards.service';
1316
import { TablesService } from 'src/app/services/tables.service';
1417
import { UiSettingsService } from 'src/app/services/ui-settings.service';
1518
import { UsersService } from 'src/app/services/users.service';
16-
import { AvailableTable, CedarPolicyListComponent } from '../cedar-policy-list/cedar-policy-list.component';
19+
import {
20+
AvailableDashboard,
21+
AvailableTable,
22+
CedarPolicyListComponent,
23+
} from '../cedar-policy-list/cedar-policy-list.component';
1724
import { CedarPolicyEditorDialogComponent as Self } from './cedar-policy-editor-dialog.component';
1825

1926
export interface CedarPolicyEditorDialogData {
@@ -36,8 +43,9 @@ export class CedarPolicyEditorDialogComponent implements OnInit {
3643
public editorMode: 'form' | 'code' = 'form';
3744
public policyItems: CedarPolicyItem[] = [];
3845
public availableTables: AvailableTable[] = [];
46+
public availableDashboards: AvailableDashboard[] = [];
3947
public allTables: TablePermission[] = [];
40-
public tablesLoading: boolean = true;
48+
public loading: boolean = true;
4149

4250
public cedarPolicyModel: object;
4351
public codeEditorOptions = {
@@ -55,6 +63,7 @@ export class CedarPolicyEditorDialogComponent implements OnInit {
5563
private _usersService: UsersService,
5664
private _uiSettings: UiSettingsService,
5765
private _tablesService: TablesService,
66+
private _dashboardsService: DashboardsService,
5867
private _editorService: CodeEditorService,
5968
) {
6069
this.codeEditorTheme = this._uiSettings.isDarkMode ? 'vs-dark' : 'vs';
@@ -70,7 +79,9 @@ export class CedarPolicyEditorDialogComponent implements OnInit {
7079
value: this.cedarPolicy,
7180
};
7281

73-
this._tablesService.fetchTables(this.connectionID).subscribe((tables) => {
82+
this._dashboardsService.setActiveConnection(this.connectionID);
83+
84+
forkJoin([this._tablesService.fetchTables(this.connectionID)]).subscribe(([tables]) => {
7485
this.allTables = tables.map((t) => ({
7586
tableName: t.table,
7687
display_name: t.display_name || normalizeTableName(t.table),
@@ -86,11 +97,18 @@ export class CedarPolicyEditorDialogComponent implements OnInit {
8697
tableName: t.table,
8798
displayName: t.display_name || normalizeTableName(t.table),
8899
}));
89-
this.tablesLoading = false;
100+
101+
this.availableDashboards = this._dashboardsService.dashboards().map((d: Dashboard) => ({
102+
id: d.id,
103+
name: d.name,
104+
}));
105+
106+
this.loading = false;
90107

91108
if (this.cedarPolicy) {
92109
const parsed = parseCedarPolicy(this.cedarPolicy, this.connectionID, this.data.groupId, this.allTables);
93-
this.policyItems = permissionsToPolicyItems(parsed);
110+
const dashboardItems = parseCedarDashboardItems(this.cedarPolicy, this.connectionID);
111+
this.policyItems = [...permissionsToPolicyItems(parsed), ...dashboardItems];
94112
}
95113
});
96114
}
@@ -115,7 +133,8 @@ export class CedarPolicyEditorDialogComponent implements OnInit {
115133
};
116134
} else {
117135
const parsed = parseCedarPolicy(this.cedarPolicy, this.connectionID, this.data.groupId, this.allTables);
118-
this.policyItems = permissionsToPolicyItems(parsed);
136+
const dashboardItems = parseCedarDashboardItems(this.cedarPolicy, this.connectionID);
137+
this.policyItems = [...permissionsToPolicyItems(parsed), ...dashboardItems];
119138
}
120139

121140
this.editorMode = mode;

frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.html

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
<span *ngIf="policy.tableName" class="policy-item__table">
1515
— {{ getTableDisplayName(policy.tableName) }}
1616
</span>
17+
<span *ngIf="policy.dashboardId" class="policy-item__table">
18+
— {{ getDashboardDisplayName(policy.dashboardId) }}
19+
</span>
1720
</div>
1821
<div class="policy-item__actions">
1922
<button mat-icon-button type="button" (click)="startEdit(i)" matTooltip="Edit">
@@ -49,9 +52,19 @@
4952
</mat-select>
5053
</mat-form-field>
5154

55+
<mat-form-field *ngIf="editNeedsDashboard" appearance="outline" class="policy-field">
56+
<mat-label>Dashboard</mat-label>
57+
<mat-select [(ngModel)]="editDashboardId" [ngModelOptions]="{standalone: true}">
58+
<mat-option value="__new__">New dashboards</mat-option>
59+
<mat-option *ngFor="let dashboard of availableDashboards" [value]="dashboard.id">
60+
{{ dashboard.name }}
61+
</mat-option>
62+
</mat-select>
63+
</mat-form-field>
64+
5265
<div class="policy-item__edit-actions">
5366
<button mat-button color="primary" type="button" (click)="saveEdit(i)"
54-
[disabled]="!editAction || (editNeedsTable && !editTableName)">
67+
[disabled]="!editAction || (editNeedsTable && !editTableName) || (editNeedsDashboard && !editDashboardId)">
5568
Save
5669
</button>
5770
<button mat-button type="button" (click)="cancelEdit()">Cancel</button>
@@ -84,9 +97,19 @@
8497
</mat-select>
8598
</mat-form-field>
8699

100+
<mat-form-field *ngIf="needsDashboard" appearance="outline" class="policy-field">
101+
<mat-label>Dashboard</mat-label>
102+
<mat-select [(ngModel)]="newDashboardId" [ngModelOptions]="{standalone: true}">
103+
<mat-option value="__new__">New dashboards</mat-option>
104+
<mat-option *ngFor="let dashboard of availableDashboards" [value]="dashboard.id">
105+
{{ dashboard.name }}
106+
</mat-option>
107+
</mat-select>
108+
</mat-form-field>
109+
87110
<div class="policy-item__edit-actions">
88111
<button mat-button color="primary" type="button" (click)="addPolicy()"
89-
[disabled]="!newAction || (needsTable && !newTableName)">
112+
[disabled]="!newAction || (needsTable && !newTableName) || (needsDashboard && !newDashboardId)">
90113
Add
91114
</button>
92115
<button mat-button type="button" (click)="resetAddForm()">Cancel</button>

frontend/src/app/components/users/cedar-policy-list/cedar-policy-list.component.spec.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ describe('CedarPolicyListComponent', () => {
1212
{ tableName: 'orders', displayName: 'Orders' },
1313
];
1414

15+
const fakeDashboards = [
16+
{ id: 'dash-1', name: 'Sales Dashboard' },
17+
{ id: 'dash-2', name: 'Analytics' },
18+
];
19+
1520
beforeEach(async () => {
1621
await TestBed.configureTestingModule({
1722
imports: [CedarPolicyListComponent, FormsModule, BrowserAnimationsModule],
@@ -20,6 +25,7 @@ describe('CedarPolicyListComponent', () => {
2025
fixture = TestBed.createComponent(CedarPolicyListComponent);
2126
component = fixture.componentInstance;
2227
component.availableTables = fakeTables;
28+
component.availableDashboards = fakeDashboards;
2329
fixture.detectChanges();
2430
});
2531

@@ -149,4 +155,38 @@ describe('CedarPolicyListComponent', () => {
149155
expect(component.newAction).toBe('');
150156
expect(component.newTableName).toBe('');
151157
});
158+
159+
it('should add a dashboard policy with dashboardId', () => {
160+
component.showAddForm = true;
161+
component.newAction = 'dashboard:read';
162+
component.newDashboardId = 'dash-1';
163+
component.addPolicy();
164+
165+
expect(component.policies.length).toBe(1);
166+
expect(component.policies[0].action).toBe('dashboard:read');
167+
expect(component.policies[0].dashboardId).toBe('dash-1');
168+
});
169+
170+
it('should not add dashboard policy without dashboard id', () => {
171+
component.showAddForm = true;
172+
component.newAction = 'dashboard:edit';
173+
component.newDashboardId = '';
174+
component.addPolicy();
175+
176+
expect(component.policies.length).toBe(0);
177+
});
178+
179+
it('should detect needsDashboard correctly', () => {
180+
component.newAction = 'connection:read';
181+
expect(component.needsDashboard).toBe(false);
182+
183+
component.newAction = 'dashboard:read';
184+
expect(component.needsDashboard).toBe(true);
185+
});
186+
187+
it('should return correct dashboard display names', () => {
188+
expect(component.getDashboardDisplayName('dash-1')).toBe('Sales Dashboard');
189+
expect(component.getDashboardDisplayName('unknown')).toBe('unknown');
190+
expect(component.getDashboardDisplayName('__new__')).toBe('New dashboards');
191+
});
152192
});

0 commit comments

Comments
 (0)