Skip to content

Commit a7eacc0

Browse files
guguclaude
andcommitted
feat(frontend): allow editing public permissions from the permissions page
The backend already supported public (unauthenticated) access via GET/PUT /connection/public-permissions/:connectionId, but nothing in the UI reached those endpoints — public access could only be configured by calling the API by hand. Add a "Public access" expansion panel to /permissions/:connection-id: - status badge showing Disabled or the number of exposed tables - warning that listed tables are readable by anyone without signing in, and that access is read-only - checkbox per table plus a multi-select column whitelist (empty = all columns), with columns fetched lazily per table - Save, plus Disable public access which saves an empty table list "Enabled" stays a derived value rather than a stored flag, mirroring the backend, which computes it from whether the generated policy is non-empty. The public-permissions GET is connection:edit guarded while setActiveConnection runs for every URL carrying a connection id, so the resource is gated on an opt-in that names its connection. Without that, the request would fire on every page and 403 for non-admins. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 1bd62fa commit a7eacc0

10 files changed

Lines changed: 715 additions & 1 deletion
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
.public-access-panel {
2+
margin-bottom: 20px;
3+
}
4+
5+
.mat-expansion-panel-header-title {
6+
align-items: center;
7+
row-gap: 0;
8+
line-height: 1.3;
9+
}
10+
11+
.public-access-icon {
12+
margin-right: 8px;
13+
flex-shrink: 0;
14+
opacity: 0.7;
15+
}
16+
17+
/* Mirrors .group-system-badge on the parent permissions page. */
18+
.public-access-badge {
19+
font-size: 10px;
20+
font-weight: 600;
21+
text-transform: uppercase;
22+
letter-spacing: 0.5px;
23+
padding: 2px 6px;
24+
border-radius: 10px;
25+
background: color-mix(in srgb, var(--color-alternativePalette-500), transparent 88%);
26+
color: var(--color-alternativePalette-500);
27+
margin-left: 8px;
28+
white-space: nowrap;
29+
}
30+
31+
.public-access-badge_on {
32+
background: color-mix(in srgb, var(--color-warningPalette-500), transparent 88%);
33+
color: var(--color-warningPalette-500);
34+
}
35+
36+
.public-access-warning {
37+
display: flex;
38+
align-items: flex-start;
39+
gap: 16px;
40+
padding: 12px 16px;
41+
border: 1px solid var(--color-warningPalette-500);
42+
border-radius: 4px;
43+
background: var(--warning-background-color);
44+
color: var(--color-warningPalette-500);
45+
font-size: 13px;
46+
margin-bottom: 16px;
47+
}
48+
49+
.public-access-warning mat-icon {
50+
flex-shrink: 0;
51+
}
52+
53+
.public-access-tables {
54+
list-style: none;
55+
margin: 0;
56+
padding: 0;
57+
}
58+
59+
.public-access-table {
60+
display: flex;
61+
align-items: center;
62+
flex-wrap: wrap;
63+
gap: 12px;
64+
padding: 6px 0;
65+
}
66+
67+
.public-access-table_selected {
68+
padding-bottom: 12px;
69+
}
70+
71+
.public-access-columns {
72+
flex: 1 1 16em;
73+
min-width: 12em;
74+
}
75+
76+
.public-access-columns-hint {
77+
font-size: 12px;
78+
opacity: 0.6;
79+
}
80+
81+
.public-access-actions {
82+
display: flex;
83+
align-items: center;
84+
margin-top: 16px;
85+
}
86+
87+
.public-access-actions-spacer {
88+
flex: 1;
89+
}
90+
91+
@media (width <= 600px) {
92+
.public-access-table {
93+
align-items: flex-start;
94+
flex-direction: column;
95+
gap: 4px;
96+
}
97+
98+
.public-access-columns {
99+
width: 100%;
100+
}
101+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<mat-expansion-panel class="public-access-panel">
2+
<mat-expansion-panel-header>
3+
<mat-panel-title>
4+
<mat-icon fontSet="material-symbols-outlined" class="public-access-icon">public</mat-icon>
5+
<span>Public access</span>
6+
<span class="public-access-badge"
7+
[class.public-access-badge_on]="selectedCount() > 0">
8+
{{ statusLabel() }}
9+
</span>
10+
</mat-panel-title>
11+
</mat-expansion-panel-header>
12+
13+
<ng-template matExpansionPanelContent>
14+
<div class="public-access-warning">
15+
<mat-icon>warning</mat-icon>
16+
<span>
17+
Anyone on the internet can read the tables listed below without signing in.
18+
Public access is read-only — rows can never be added, edited or deleted.
19+
</span>
20+
</div>
21+
22+
@if (tablesLoading() || loadingPermissions()) {
23+
<app-content-loader></app-content-loader>
24+
} @else if (tables().length === 0) {
25+
<p class="body-2">No tables in this connection.</p>
26+
} @else {
27+
<ul class="public-access-tables">
28+
@for (table of tables(); track table.tableName) {
29+
@let selected = isSelected(table.tableName);
30+
<li class="public-access-table" [class.public-access-table_selected]="selected">
31+
<mat-checkbox
32+
[checked]="selected"
33+
(change)="toggleTable(table.tableName, $event.checked)">
34+
{{ table.displayName }}
35+
</mat-checkbox>
36+
37+
@if (selected) {
38+
@if (isLoadingColumns(table.tableName)) {
39+
<span class="public-access-columns-hint">Loading columns…</span>
40+
} @else {
41+
<mat-form-field class="public-access-columns" subscriptSizing="dynamic">
42+
<mat-label>Readable columns</mat-label>
43+
<mat-select multiple
44+
[value]="selectedColumns(table.tableName)"
45+
(valueChange)="setColumns(table.tableName, $event)"
46+
placeholder="All columns">
47+
@for (column of availableColumns(table.tableName); track column) {
48+
<mat-option [value]="column">{{ column }}</mat-option>
49+
}
50+
</mat-select>
51+
<mat-hint>Leave empty to expose all columns</mat-hint>
52+
</mat-form-field>
53+
}
54+
}
55+
</li>
56+
}
57+
</ul>
58+
59+
<div class="public-access-actions">
60+
@if (canDisable()) {
61+
<button type="button" mat-button
62+
matTooltip="Remove public access to every table"
63+
[disabled]="submitting()"
64+
(click)="disablePublicAccess(); posthog.capture('Users access: public access disabled')">
65+
Disable public access
66+
</button>
67+
}
68+
<span class="public-access-actions-spacer"></span>
69+
<button type="button" mat-flat-button color="primary"
70+
[disabled]="submitting()"
71+
(click)="save(); posthog.capture('Users access: public access saved')">
72+
Save
73+
</button>
74+
</div>
75+
}
76+
</ng-template>
77+
</mat-expansion-panel>
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { Signal, signal, WritableSignal } from '@angular/core';
2+
import { ComponentFixture, TestBed } from '@angular/core/testing';
3+
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
4+
import { of } from 'rxjs';
5+
import { PublicPermissions } from 'src/app/models/user';
6+
import { ConnectionsService } from 'src/app/services/connections.service';
7+
import { TablesService } from 'src/app/services/tables.service';
8+
import { UsersService } from 'src/app/services/users.service';
9+
import { PublicAccessPanelComponent } from './public-access-panel.component';
10+
11+
type PublicAccessPanelTestable = PublicAccessPanelComponent & {
12+
statusLabel: Signal<string>;
13+
selectedCount: Signal<number>;
14+
canDisable: Signal<boolean>;
15+
tablesLoading: WritableSignal<boolean>;
16+
tables: Signal<Array<{ tableName: string; displayName: string }>>;
17+
};
18+
19+
const CONNECTION_ID = '5a2d4e0c-6d3a-4b0f-9d1a-4a0f0a4c8b11';
20+
21+
const fakeTables = [
22+
{ table: 'customers', display_name: 'Customers' },
23+
{ table: 'orders', display_name: '' },
24+
];
25+
26+
const fakeStructure = {
27+
structure: [{ column_name: 'id' }, { column_name: 'name' }, { column_name: 'secret' }],
28+
};
29+
30+
describe('PublicAccessPanelComponent', () => {
31+
let component: PublicAccessPanelComponent;
32+
let testable: PublicAccessPanelTestable;
33+
let fixture: ComponentFixture<PublicAccessPanelComponent>;
34+
let publicPermissions: WritableSignal<PublicPermissions>;
35+
let mockUsersService: Partial<UsersService>;
36+
let mockTablesService: Partial<TablesService>;
37+
38+
beforeEach(async () => {
39+
publicPermissions = signal<PublicPermissions>({ enabled: false, tables: [] });
40+
41+
mockUsersService = {
42+
publicPermissions: publicPermissions.asReadonly(),
43+
publicPermissionsLoading: signal(false).asReadonly(),
44+
loadPublicPermissions: vi.fn(),
45+
savePublicPermissions: vi.fn().mockResolvedValue(undefined),
46+
};
47+
48+
mockTablesService = {
49+
fetchTables: vi.fn().mockReturnValue(of(fakeTables)),
50+
fetchTableStructure: vi.fn().mockReturnValue(of(fakeStructure)),
51+
};
52+
53+
const mockConnectionsService: Partial<ConnectionsService> = {
54+
get currentConnectionID() {
55+
return CONNECTION_ID;
56+
},
57+
};
58+
59+
await TestBed.configureTestingModule({
60+
imports: [BrowserAnimationsModule, PublicAccessPanelComponent],
61+
providers: [
62+
{ provide: UsersService, useValue: mockUsersService },
63+
{ provide: TablesService, useValue: mockTablesService },
64+
{ provide: ConnectionsService, useValue: mockConnectionsService },
65+
],
66+
}).compileComponents();
67+
68+
fixture = TestBed.createComponent(PublicAccessPanelComponent);
69+
component = fixture.componentInstance;
70+
testable = component as PublicAccessPanelTestable;
71+
fixture.detectChanges();
72+
});
73+
74+
it('should create', () => {
75+
expect(component).toBeTruthy();
76+
});
77+
78+
it('should opt in to loading public permissions for this connection on init', () => {
79+
expect(mockUsersService.loadPublicPermissions).toHaveBeenCalledWith(CONNECTION_ID);
80+
});
81+
82+
it('should load the connection tables and fall back to a normalized display name', () => {
83+
expect(mockTablesService.fetchTables).toHaveBeenCalledWith(CONNECTION_ID);
84+
expect(testable.tablesLoading()).toBe(false);
85+
expect(testable.tables()).toEqual([
86+
{ tableName: 'customers', displayName: 'Customers' },
87+
{ tableName: 'orders', displayName: 'Orders' },
88+
]);
89+
});
90+
91+
it('should report disabled status when nothing is selected', () => {
92+
expect(testable.selectedCount()).toBe(0);
93+
expect(testable.statusLabel()).toBe('Disabled');
94+
expect(testable.canDisable()).toBe(false);
95+
});
96+
97+
it('should seed selection and columns from the stored permissions', () => {
98+
publicPermissions.set({
99+
enabled: true,
100+
tables: [{ tableName: 'customers', readableColumns: ['id', 'name'] }, { tableName: 'orders' }],
101+
});
102+
fixture.detectChanges();
103+
104+
expect(component.isSelected('customers')).toBe(true);
105+
expect(component.selectedColumns('customers')).toEqual(['id', 'name']);
106+
expect(component.isSelected('orders')).toBe(true);
107+
expect(component.selectedColumns('orders')).toEqual([]);
108+
expect(testable.statusLabel()).toBe('2 tables');
109+
expect(testable.canDisable()).toBe(true);
110+
// Columns are only fetched for tables that already carry a whitelist.
111+
expect(mockTablesService.fetchTableStructure).toHaveBeenCalledWith(CONNECTION_ID, 'customers');
112+
expect(mockTablesService.fetchTableStructure).not.toHaveBeenCalledWith(CONNECTION_ID, 'orders');
113+
});
114+
115+
it('should singularize the status label for one table', () => {
116+
publicPermissions.set({ enabled: true, tables: [{ tableName: 'customers' }] });
117+
fixture.detectChanges();
118+
119+
expect(testable.statusLabel()).toBe('1 table');
120+
});
121+
122+
it('should fetch columns lazily when a table is checked', () => {
123+
component.toggleTable('customers', true);
124+
125+
expect(mockTablesService.fetchTableStructure).toHaveBeenCalledWith(CONNECTION_ID, 'customers');
126+
expect(component.availableColumns('customers')).toEqual(['id', 'name', 'secret']);
127+
expect(component.isLoadingColumns('customers')).toBe(false);
128+
});
129+
130+
it('should not refetch columns for a table already loaded', () => {
131+
component.toggleTable('customers', true);
132+
component.toggleTable('customers', false);
133+
component.toggleTable('customers', true);
134+
135+
expect(mockTablesService.fetchTableStructure).toHaveBeenCalledTimes(1);
136+
});
137+
138+
it('should save an empty column selection as undefined readableColumns', async () => {
139+
component.toggleTable('customers', true);
140+
await component.save();
141+
142+
expect(mockUsersService.savePublicPermissions).toHaveBeenCalledWith(CONNECTION_ID, [
143+
{ tableName: 'customers', readableColumns: undefined },
144+
]);
145+
});
146+
147+
it('should save an explicit column whitelist', async () => {
148+
component.toggleTable('customers', true);
149+
component.setColumns('customers', ['id', 'name']);
150+
await component.save();
151+
152+
expect(mockUsersService.savePublicPermissions).toHaveBeenCalledWith(CONNECTION_ID, [
153+
{ tableName: 'customers', readableColumns: ['id', 'name'] },
154+
]);
155+
});
156+
157+
it('should drop a table from the payload when unchecked', async () => {
158+
component.toggleTable('customers', true);
159+
component.toggleTable('orders', true);
160+
component.toggleTable('customers', false);
161+
await component.save();
162+
163+
expect(mockUsersService.savePublicPermissions).toHaveBeenCalledWith(CONNECTION_ID, [
164+
{ tableName: 'orders', readableColumns: undefined },
165+
]);
166+
});
167+
168+
it('should render the table list and column picker once expanded', async () => {
169+
publicPermissions.set({ enabled: true, tables: [{ tableName: 'customers', readableColumns: ['id'] }] });
170+
fixture.detectChanges();
171+
172+
// The body lives in an ng-template matExpansionPanelContent, so nothing above renders it.
173+
fixture.nativeElement.querySelector('mat-expansion-panel-header').click();
174+
fixture.detectChanges();
175+
await fixture.whenStable();
176+
177+
const host: HTMLElement = fixture.nativeElement;
178+
expect(host.querySelector('.public-access-warning')).toBeTruthy();
179+
expect(host.querySelectorAll('.public-access-table').length).toBe(2);
180+
expect(host.textContent).toContain('Customers');
181+
expect(host.textContent).toContain('Orders');
182+
// Only the selected table exposes a column picker.
183+
expect(host.querySelectorAll('.public-access-columns').length).toBe(1);
184+
expect(host.textContent).toContain('Disable public access');
185+
});
186+
187+
it('should disable public access by saving an empty table list', async () => {
188+
publicPermissions.set({ enabled: true, tables: [{ tableName: 'customers' }] });
189+
fixture.detectChanges();
190+
191+
await component.disablePublicAccess();
192+
193+
expect(mockUsersService.savePublicPermissions).toHaveBeenCalledWith(CONNECTION_ID, []);
194+
expect(testable.selectedCount()).toBe(0);
195+
});
196+
});

0 commit comments

Comments
 (0)