diff --git a/frontend/src/app/components/users/public-access-panel/public-access-panel.component.css b/frontend/src/app/components/users/public-access-panel/public-access-panel.component.css new file mode 100644 index 000000000..33b763e26 --- /dev/null +++ b/frontend/src/app/components/users/public-access-panel/public-access-panel.component.css @@ -0,0 +1,101 @@ +.public-access-panel { + margin-bottom: 20px; +} + +.mat-expansion-panel-header-title { + align-items: center; + row-gap: 0; + line-height: 1.3; +} + +.public-access-icon { + margin-right: 8px; + flex-shrink: 0; + opacity: 0.7; +} + +/* Mirrors .group-system-badge on the parent permissions page. */ +.public-access-badge { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 2px 6px; + border-radius: 10px; + background: color-mix(in srgb, var(--color-alternativePalette-500), transparent 88%); + color: var(--color-alternativePalette-500); + margin-left: 8px; + white-space: nowrap; +} + +.public-access-badge_on { + background: color-mix(in srgb, var(--color-warningPalette-500), transparent 88%); + color: var(--color-warningPalette-500); +} + +.public-access-warning { + display: flex; + align-items: flex-start; + gap: 16px; + padding: 12px 16px; + border: 1px solid var(--color-warningPalette-500); + border-radius: 4px; + background: var(--warning-background-color); + color: var(--color-warningPalette-500); + font-size: 13px; + margin-bottom: 16px; +} + +.public-access-warning mat-icon { + flex-shrink: 0; +} + +.public-access-tables { + list-style: none; + margin: 0; + padding: 0; +} + +.public-access-table { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + padding: 6px 0; +} + +.public-access-table_selected { + padding-bottom: 12px; +} + +.public-access-columns { + flex: 1 1 16em; + min-width: 12em; +} + +.public-access-columns-hint { + font-size: 12px; + opacity: 0.6; +} + +.public-access-actions { + display: flex; + align-items: center; + margin-top: 16px; +} + +.public-access-actions-spacer { + flex: 1; +} + +@media (width <= 600px) { + .public-access-table { + align-items: flex-start; + flex-direction: column; + gap: 4px; + } + + .public-access-columns { + width: 100%; + } +} diff --git a/frontend/src/app/components/users/public-access-panel/public-access-panel.component.html b/frontend/src/app/components/users/public-access-panel/public-access-panel.component.html new file mode 100644 index 000000000..061e12258 --- /dev/null +++ b/frontend/src/app/components/users/public-access-panel/public-access-panel.component.html @@ -0,0 +1,79 @@ + + + + public + Public access + + {{ statusLabel() }} + + + + + +
+ warning + + Anyone on the internet can read the tables listed below without signing in. + Public access is read-only — rows can never be added, edited or deleted. + +
+ + @if (tablesLoading() || loadingPermissions()) { + + } @else if (tables().length === 0) { +

No tables in this connection.

+ } @else { + + +
+ @if (canDisable()) { + + } + + +
+ } +
+
diff --git a/frontend/src/app/components/users/public-access-panel/public-access-panel.component.spec.ts b/frontend/src/app/components/users/public-access-panel/public-access-panel.component.spec.ts new file mode 100644 index 000000000..a32795894 --- /dev/null +++ b/frontend/src/app/components/users/public-access-panel/public-access-panel.component.spec.ts @@ -0,0 +1,239 @@ +import { Signal, signal, WritableSignal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { of, throwError } from 'rxjs'; +import { PublicPermissions } from 'src/app/models/user'; +import { ConnectionsService } from 'src/app/services/connections.service'; +import { TablesService } from 'src/app/services/tables.service'; +import { UsersService } from 'src/app/services/users.service'; +import { PublicAccessPanelComponent } from './public-access-panel.component'; + +type PublicAccessPanelTestable = PublicAccessPanelComponent & { + statusLabel: Signal; + selectedCount: Signal; + canDisable: Signal; + tablesLoading: WritableSignal; + submitting: WritableSignal; + tables: Signal>; +}; + +const CONNECTION_ID = '5a2d4e0c-6d3a-4b0f-9d1a-4a0f0a4c8b11'; + +const fakeTables = [ + { table: 'customers', display_name: 'Customers' }, + { table: 'orders', display_name: '' }, +]; + +const fakeStructure = { + structure: [{ column_name: 'id' }, { column_name: 'name' }, { column_name: 'secret' }], +}; + +describe('PublicAccessPanelComponent', () => { + let component: PublicAccessPanelComponent; + let testable: PublicAccessPanelTestable; + let fixture: ComponentFixture; + let publicPermissions: WritableSignal; + let mockUsersService: Partial; + let mockTablesService: Partial; + + beforeEach(async () => { + publicPermissions = signal({ enabled: false, tables: [] }); + + mockUsersService = { + publicPermissions: publicPermissions.asReadonly(), + publicPermissionsLoading: signal(false).asReadonly(), + loadPublicPermissions: vi.fn(), + savePublicPermissions: vi.fn().mockResolvedValue(undefined), + }; + + mockTablesService = { + fetchTables: vi.fn().mockReturnValue(of(fakeTables)), + fetchTableStructure: vi.fn().mockReturnValue(of(fakeStructure)), + }; + + const mockConnectionsService: Partial = { + get currentConnectionID() { + return CONNECTION_ID; + }, + }; + + await TestBed.configureTestingModule({ + imports: [BrowserAnimationsModule, PublicAccessPanelComponent], + providers: [ + { provide: UsersService, useValue: mockUsersService }, + { provide: TablesService, useValue: mockTablesService }, + { provide: ConnectionsService, useValue: mockConnectionsService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(PublicAccessPanelComponent); + component = fixture.componentInstance; + testable = component as PublicAccessPanelTestable; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should opt in to loading public permissions for this connection on init', () => { + expect(mockUsersService.loadPublicPermissions).toHaveBeenCalledWith(CONNECTION_ID); + }); + + it('should load the connection tables and fall back to a normalized display name', () => { + expect(mockTablesService.fetchTables).toHaveBeenCalledWith(CONNECTION_ID); + expect(testable.tablesLoading()).toBe(false); + expect(testable.tables()).toEqual([ + { tableName: 'customers', displayName: 'Customers' }, + { tableName: 'orders', displayName: 'Orders' }, + ]); + }); + + it('should report disabled status when nothing is selected', () => { + expect(testable.selectedCount()).toBe(0); + expect(testable.statusLabel()).toBe('Disabled'); + expect(testable.canDisable()).toBe(false); + }); + + it('should seed selection and columns from the stored permissions', () => { + publicPermissions.set({ + enabled: true, + tables: [{ tableName: 'customers', readableColumns: ['id', 'name'] }, { tableName: 'orders' }], + }); + fixture.detectChanges(); + + expect(component.isSelected('customers')).toBe(true); + expect(component.selectedColumns('customers')).toEqual(['id', 'name']); + expect(component.isSelected('orders')).toBe(true); + expect(component.selectedColumns('orders')).toEqual([]); + expect(testable.statusLabel()).toBe('2 tables'); + expect(testable.canDisable()).toBe(true); + }); + + it('should preload columns for every stored table, restricted or not', () => { + publicPermissions.set({ + enabled: true, + tables: [{ tableName: 'customers', readableColumns: ['id'] }, { tableName: 'orders' }], + }); + fixture.detectChanges(); + + // An unrestricted table still renders the column picker, so it needs its options too. + expect(mockTablesService.fetchTableStructure).toHaveBeenCalledWith(CONNECTION_ID, 'customers'); + expect(mockTablesService.fetchTableStructure).toHaveBeenCalledWith(CONNECTION_ID, 'orders'); + expect(component.availableColumns('orders')).toEqual(['id', 'name', 'secret']); + }); + + it('should singularize the status label for one table', () => { + publicPermissions.set({ enabled: true, tables: [{ tableName: 'customers' }] }); + fixture.detectChanges(); + + expect(testable.statusLabel()).toBe('1 table'); + }); + + it('should fetch columns lazily when a table is checked', () => { + component.toggleTable('customers', true); + + expect(mockTablesService.fetchTableStructure).toHaveBeenCalledWith(CONNECTION_ID, 'customers'); + expect(component.availableColumns('customers')).toEqual(['id', 'name', 'secret']); + expect(component.isLoadingColumns('customers')).toBe(false); + }); + + it('should not refetch columns for a table already loaded', () => { + component.toggleTable('customers', true); + component.toggleTable('customers', false); + component.toggleTable('customers', true); + + expect(mockTablesService.fetchTableStructure).toHaveBeenCalledTimes(1); + }); + + it('should save an empty column selection as undefined readableColumns', async () => { + component.toggleTable('customers', true); + await component.save(); + + expect(mockUsersService.savePublicPermissions).toHaveBeenCalledWith(CONNECTION_ID, [ + { tableName: 'customers', readableColumns: undefined }, + ]); + }); + + it('should save an explicit column whitelist', async () => { + component.toggleTable('customers', true); + component.setColumns('customers', ['id', 'name']); + await component.save(); + + expect(mockUsersService.savePublicPermissions).toHaveBeenCalledWith(CONNECTION_ID, [ + { tableName: 'customers', readableColumns: ['id', 'name'] }, + ]); + }); + + it('should drop a table from the payload when unchecked', async () => { + component.toggleTable('customers', true); + component.toggleTable('orders', true); + component.toggleTable('customers', false); + await component.save(); + + expect(mockUsersService.savePublicPermissions).toHaveBeenCalledWith(CONNECTION_ID, [ + { tableName: 'orders', readableColumns: undefined }, + ]); + }); + + it('should clear the loading state when the table list fails to load', () => { + mockTablesService.fetchTables = vi.fn().mockReturnValue(throwError(() => new Error('boom'))); + + const failing = TestBed.createComponent(PublicAccessPanelComponent); + failing.detectChanges(); + + expect((failing.componentInstance as PublicAccessPanelTestable).tablesLoading()).toBe(false); + }); + + it('should block editing while a save is in flight', async () => { + let resolveSave: () => void = () => {}; + mockUsersService.savePublicPermissions = vi + .fn() + .mockReturnValue(new Promise((resolve) => (resolveSave = resolve))); + + component.toggleTable('customers', true); + const saving = component.save(); + fixture.detectChanges(); + + // The re-seed after reload can only discard an edit made in this window, so it is closed off. + expect((component as PublicAccessPanelTestable).submitting()).toBe(true); + const host: HTMLElement = fixture.nativeElement; + fixture.nativeElement.querySelector('mat-expansion-panel-header').click(); + fixture.detectChanges(); + await fixture.whenStable(); + expect(host.querySelector('mat-checkbox input')?.hasAttribute('disabled')).toBe(true); + + resolveSave(); + await saving; + expect((component as PublicAccessPanelTestable).submitting()).toBe(false); + }); + + it('should render the table list and column picker once expanded', async () => { + publicPermissions.set({ enabled: true, tables: [{ tableName: 'customers', readableColumns: ['id'] }] }); + fixture.detectChanges(); + + // The body lives in an ng-template matExpansionPanelContent, so nothing above renders it. + fixture.nativeElement.querySelector('mat-expansion-panel-header').click(); + fixture.detectChanges(); + await fixture.whenStable(); + + const host: HTMLElement = fixture.nativeElement; + expect(host.querySelector('.public-access-warning')).toBeTruthy(); + expect(host.querySelectorAll('.public-access-table').length).toBe(2); + expect(host.textContent).toContain('Customers'); + expect(host.textContent).toContain('Orders'); + // Only the selected table exposes a column picker. + expect(host.querySelectorAll('.public-access-columns').length).toBe(1); + expect(host.textContent).toContain('Disable public access'); + }); + + it('should disable public access by saving an empty table list', async () => { + publicPermissions.set({ enabled: true, tables: [{ tableName: 'customers' }] }); + fixture.detectChanges(); + + await component.disablePublicAccess(); + + expect(mockUsersService.savePublicPermissions).toHaveBeenCalledWith(CONNECTION_ID, []); + expect(testable.selectedCount()).toBe(0); + }); +}); diff --git a/frontend/src/app/components/users/public-access-panel/public-access-panel.component.ts b/frontend/src/app/components/users/public-access-panel/public-access-panel.component.ts new file mode 100644 index 000000000..cd237ff45 --- /dev/null +++ b/frontend/src/app/components/users/public-access-panel/public-access-panel.component.ts @@ -0,0 +1,213 @@ +import { Component, computed, DestroyRef, effect, inject, OnInit, signal, untracked } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatExpansionModule } from '@angular/material/expansion'; +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 posthog from 'posthog-js'; +import { normalizeTableName } from 'src/app/lib/normalize'; +import { PublicTablePermission } from 'src/app/models/user'; +import { ConnectionsService } from 'src/app/services/connections.service'; +import { TablesService } from 'src/app/services/tables.service'; +import { UsersService } from 'src/app/services/users.service'; +import { ContentLoaderComponent } from '../../ui-components/content-loader/content-loader.component'; + +interface PublicAccessTable { + tableName: string; + displayName: string; +} + +// A selected table maps to its readable-column whitelist. An empty array means "all columns", +// matching the backend contract where an omitted/empty readableColumns grants every column. +type TableSelection = Map; + +@Component({ + selector: 'app-public-access-panel', + imports: [ + MatButtonModule, + MatCheckboxModule, + MatExpansionModule, + MatFormFieldModule, + MatIconModule, + MatSelectModule, + MatTooltipModule, + ContentLoaderComponent, + ], + templateUrl: './public-access-panel.component.html', + styleUrls: ['./public-access-panel.component.css'], +}) +export class PublicAccessPanelComponent implements OnInit { + private _usersService = inject(UsersService); + private _tablesService = inject(TablesService); + private _connections = inject(ConnectionsService); + private _destroyRef = inject(DestroyRef); + + protected posthog = posthog; + protected connectionID: string; + + protected publicPermissions = this._usersService.publicPermissions; + protected loadingPermissions = this._usersService.publicPermissionsLoading; + + protected tables = signal([]); + protected tablesLoading = signal(true); + protected submitting = signal(false); + + protected selection = signal(new Map()); + protected columnsByTable = signal>({}); + protected loadingColumns = signal>(new Set()); + + protected selectedCount = computed(() => this.selection().size); + + protected statusLabel = computed(() => { + const count = this.selectedCount(); + if (count === 0) return 'Disabled'; + return `${count} ${count === 1 ? 'table' : 'tables'}`; + }); + + // Public access is stored server-side as a derived value: a non-empty table list means + // enabled. `Disable public access` is only meaningful once something is actually stored. + protected canDisable = computed(() => this.publicPermissions().enabled); + + constructor() { + // Seed the local editing state whenever the server state (re)loads, so an external change + // to public access is picked up. Editing is blocked while a save is in flight (see the + // submitting() bindings in the template), which is the only window where this re-seed + // could otherwise discard an unsaved edit. + effect(() => { + const stored = this.publicPermissions().tables; + const seeded: TableSelection = new Map(); + for (const table of stored) { + seeded.set(table.tableName, table.readableColumns ?? []); + } + this.selection.set(seeded); + + // Every already-public table needs its columns, not just the restricted ones: an + // unrestricted table still renders the column picker, which would otherwise be empty. + // Kept untracked: _loadColumns both reads and writes the column signals, so tracking it + // would make a finished column fetch re-run this effect and clobber edits made meanwhile. + untracked(() => { + for (const table of stored) { + this._loadColumns(table.tableName); + } + }); + }); + } + + ngOnInit(): void { + this.connectionID = this._connections.currentConnectionID; + this._usersService.loadPublicPermissions(this.connectionID); + + // fetchTables does not swallow errors, so clear the loading state on failure too — + // otherwise the panel body is stuck on the content loader forever. + this._tablesService + .fetchTables(this.connectionID) + .pipe(takeUntilDestroyed(this._destroyRef)) + .subscribe({ + next: (tables) => { + this.tables.set( + tables.map((t) => ({ + tableName: t.table, + displayName: t.display_name || normalizeTableName(t.table), + })), + ); + this.tablesLoading.set(false); + }, + error: () => this.tablesLoading.set(false), + }); + } + + isSelected(tableName: string): boolean { + return this.selection().has(tableName); + } + + selectedColumns(tableName: string): string[] { + return this.selection().get(tableName) ?? []; + } + + availableColumns(tableName: string): string[] { + return this.columnsByTable()[tableName] ?? []; + } + + isLoadingColumns(tableName: string): boolean { + return this.loadingColumns().has(tableName); + } + + toggleTable(tableName: string, selected: boolean): void { + this.selection.update((current) => { + const next = new Map(current); + if (selected) { + next.set(tableName, []); + } else { + next.delete(tableName); + } + return next; + }); + if (selected) { + this._loadColumns(tableName); + } + } + + setColumns(tableName: string, columns: string[]): void { + this.selection.update((current) => { + const next = new Map(current); + next.set(tableName, columns); + return next; + }); + } + + async save(): Promise { + this.submitting.set(true); + try { + await this._usersService.savePublicPermissions(this.connectionID, this._buildPayload()); + } finally { + this.submitting.set(false); + } + } + + async disablePublicAccess(): Promise { + this.submitting.set(true); + try { + await this._usersService.savePublicPermissions(this.connectionID, []); + this.selection.set(new Map()); + } finally { + this.submitting.set(false); + } + } + + private _buildPayload(): PublicTablePermission[] { + return [...this.selection().entries()].map(([tableName, columns]) => ({ + tableName, + readableColumns: columns.length ? columns : undefined, + })); + } + + private _loadColumns(tableName: string): void { + if (this.columnsByTable()[tableName] || this.loadingColumns().has(tableName)) return; + + this.loadingColumns.update((current) => new Set(current).add(tableName)); + this._tablesService + .fetchTableStructure(this.connectionID, tableName) + .pipe(takeUntilDestroyed(this._destroyRef)) + .subscribe({ + next: (res) => { + this.columnsByTable.update((current) => ({ + ...current, + [tableName]: (res?.structure ?? []).map((field: { column_name: string }) => field.column_name), + })); + this._clearLoadingColumn(tableName); + }, + error: () => this._clearLoadingColumn(tableName), + }); + } + + private _clearLoadingColumn(tableName: string): void { + this.loadingColumns.update((current) => { + const next = new Set(current); + next.delete(tableName); + return next; + }); + } +} diff --git a/frontend/src/app/components/users/users.component.html b/frontend/src/app/components/users/users.component.html index 803628337..056ce8714 100644 --- a/frontend/src/app/components/users/users.component.html +++ b/frontend/src/app/components/users/users.component.html @@ -16,6 +16,10 @@

User groups

} + @if (canEditConnection()) { + + } + @if (groups(); as groupsList) { @for (groupItem of groupsList; track groupItem.group.id) { diff --git a/frontend/src/app/components/users/users.component.spec.ts b/frontend/src/app/components/users/users.component.spec.ts index 327289fb1..8c30349e2 100644 --- a/frontend/src/app/components/users/users.component.spec.ts +++ b/frontend/src/app/components/users/users.component.spec.ts @@ -5,7 +5,10 @@ import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dial import { MatSnackBarModule } from '@angular/material/snack-bar'; import { provideRouter } from '@angular/router'; import { Angulartics2Module } from 'angulartics2'; +import { of } from 'rxjs'; +import { PublicPermissions } from 'src/app/models/user'; import { CedarPermissionService } from 'src/app/services/cedar-permission.service'; +import { TablesService } from 'src/app/services/tables.service'; import { UsersService } from 'src/app/services/users.service'; import { GroupAddDialogComponent } from './group-add-dialog/group-add-dialog.component'; import { GroupDeleteDialogComponent } from './group-delete-dialog/group-delete-dialog.component'; @@ -54,6 +57,15 @@ describe('UsersComponent', () => { fetchGroupUsers: vi.fn().mockResolvedValue([]), fetchAllGroupUsers: vi.fn().mockResolvedValue(undefined), fetchConnectionUsers: vi.fn(), + publicPermissions: signal({ enabled: false, tables: [] }).asReadonly(), + publicPermissionsLoading: signal(false).asReadonly(), + loadPublicPermissions: vi.fn(), + savePublicPermissions: vi.fn().mockResolvedValue(undefined), + }; + + const mockTablesService: Partial = { + fetchTables: vi.fn().mockReturnValue(of([])), + fetchTableStructure: vi.fn().mockReturnValue(of({ structure: [] })), }; const mockPermissions: Partial = { @@ -69,6 +81,7 @@ describe('UsersComponent', () => { provideRouter([]), { provide: MatDialogRef, useValue: {} }, { provide: UsersService, useValue: mockUsersService }, + { provide: TablesService, useValue: mockTablesService }, { provide: CedarPermissionService, useValue: mockPermissions }, ], }).compileComponents(); diff --git a/frontend/src/app/components/users/users.component.ts b/frontend/src/app/components/users/users.component.ts index ceda8e065..f7f83fdb6 100644 --- a/frontend/src/app/components/users/users.component.ts +++ b/frontend/src/app/components/users/users.component.ts @@ -24,6 +24,7 @@ import { CedarPolicyEditorDialogComponent } from './cedar-policy-editor-dialog/c import { GroupAddDialogComponent } from './group-add-dialog/group-add-dialog.component'; import { GroupDeleteDialogComponent } from './group-delete-dialog/group-delete-dialog.component'; import { GroupNameEditDialogComponent } from './group-name-edit-dialog/group-name-edit-dialog.component'; +import { PublicAccessPanelComponent } from './public-access-panel/public-access-panel.component'; import { UserAddDialogComponent } from './user-add-dialog/user-add-dialog.component'; import { UserDeleteDialogComponent } from './user-delete-dialog/user-delete-dialog.component'; @@ -40,6 +41,7 @@ import { UserDeleteDialogComponent } from './user-delete-dialog/user-delete-dial Angulartics2OnModule, PlaceholderUserGroupsComponent, PlaceholderUserGroupComponent, + PublicAccessPanelComponent, ], templateUrl: './users.component.html', styleUrls: ['./users.component.css'], @@ -127,6 +129,13 @@ export class UsersComponent implements OnInit { protected canCreateGroup = this._permissions.canI('group:edit', 'Group', this._connections.currentConnectionID); + // Matches ConnectionEditGuard on the public-permissions endpoints. + protected canEditConnection = this._permissions.canI( + 'connection:edit', + 'Connection', + this._connections.currentConnectionID, + ); + canManageGroup(groupId: string) { return this._permissions.canI('group:edit', 'Group', groupId); } diff --git a/frontend/src/app/models/user.ts b/frontend/src/app/models/user.ts index 39e42b6a7..7f5fd60a0 100644 --- a/frontend/src/app/models/user.ts +++ b/frontend/src/app/models/user.ts @@ -97,6 +97,18 @@ export interface Permissions { tables: TablePermission[]; } +// Public (unauthenticated) access to a connection. Mirrors the backend +// PublicTablePermissionDto: an omitted/empty readableColumns means "all columns". +export interface PublicTablePermission { + tableName: string; + readableColumns?: string[]; +} + +export interface PublicPermissions { + enabled: boolean; + tables: PublicTablePermission[]; +} + export interface ApiKey { title: string; id: string; diff --git a/frontend/src/app/services/users.service.spec.ts b/frontend/src/app/services/users.service.spec.ts index 79bda52b2..3061517a6 100644 --- a/frontend/src/app/services/users.service.spec.ts +++ b/frontend/src/app/services/users.service.spec.ts @@ -340,4 +340,61 @@ describe('UsersService', () => { expect(fakeNotifications.showErrorSnackbar).toHaveBeenCalledWith(fakeError.message); }); + + describe('public permissions', () => { + // ApiService.resource is mocked, so the request factories it was handed are never run. + // Pull them back out and exercise the one that owns the public-permissions URL. + function publicPermissionsUrl(): string | undefined { + for (const [request] of mockApi.resource.mock.calls) { + const url = request(); + if (typeof url === 'string' && url.includes('/connection/public-permissions/')) return url; + } + return undefined; + } + + it('should not request public permissions until opted in', () => { + service.setActiveConnection('conn-a'); + + expect(publicPermissionsUrl()).toBeUndefined(); + }); + + it('should request public permissions once opted in for the active connection', () => { + service.setActiveConnection('conn-a'); + service.loadPublicPermissions('conn-a'); + + expect(publicPermissionsUrl()).toBe('/connection/public-permissions/conn-a'); + }); + + it('should not carry the opt-in over to another connection', () => { + service.setActiveConnection('conn-a'); + service.loadPublicPermissions('conn-a'); + service.setActiveConnection('conn-b'); + + expect(publicPermissionsUrl()).toBeUndefined(); + }); + + it('should save public permissions via ApiService', async () => { + mockApi.put.mockResolvedValue({ enabled: true, tables: [{ tableName: 'users' }] }); + + await service.savePublicPermissions('conn-a', [{ tableName: 'users', readableColumns: ['id', 'name'] }]); + + expect(mockApi.put).toHaveBeenCalledWith( + '/connection/public-permissions/conn-a', + { tables: [{ tableName: 'users', readableColumns: ['id', 'name'] }] }, + { successMessage: 'Public access has been updated.' }, + ); + }); + + it('should send an empty table list to disable public access', async () => { + mockApi.put.mockResolvedValue({ enabled: false, tables: [] }); + + await service.savePublicPermissions('conn-a', []); + + expect(mockApi.put).toHaveBeenCalledWith( + '/connection/public-permissions/conn-a', + { tables: [] }, + { successMessage: 'Public access has been updated.' }, + ); + }); + }); }); diff --git a/frontend/src/app/services/users.service.ts b/frontend/src/app/services/users.service.ts index 095e9be09..44406f2e5 100644 --- a/frontend/src/app/services/users.service.ts +++ b/frontend/src/app/services/users.service.ts @@ -3,7 +3,14 @@ import { computed, Injectable, inject, signal } from '@angular/core'; import { catchError, EMPTY, map } from 'rxjs'; import { PolicyAction, PolicyActionGroup } from 'src/app/lib/cedar-policy-items'; import { groupNameForAction, PERMISSION_GROUP_ORDER } from 'src/app/lib/permission-display'; -import { GroupUser, Permissions, UserGroup, UserGroupInfo } from 'src/app/models/user'; +import { + GroupUser, + Permissions, + PublicPermissions, + PublicTablePermission, + UserGroup, + UserGroupInfo, +} from 'src/app/models/user'; import { ApiService } from './api.service'; import { AuthService } from './auth.service'; import { NotificationsService } from './notifications.service'; @@ -70,6 +77,25 @@ export class UsersService { })); }); + // Public (unauthenticated) access. The endpoint is connection:edit guarded, while + // setActiveConnection is called for every URL carrying a connection id — so the request is + // gated on an explicit opt-in to keep it off pages where the user may lack that permission. + // The opt-in names its connection, so navigating to a different one does not refire the + // request against a connection the user may not administer. + private _publicPermissionsFor = signal(null); + + private _publicPermissionsResource: HttpResourceRef = + this._api.resource(() => { + const id = this._activeConnectionId(); + if (!id || this._publicPermissionsFor() !== id) return undefined; + return `/connection/public-permissions/${id}`; + }); + + public readonly publicPermissions = computed( + () => this._publicPermissionsResource.value() ?? { enabled: false, tables: [] }, + ); + public readonly publicPermissionsLoading = computed(() => this._publicPermissionsResource.isLoading()); + // Group users - managed imperatively (per-group parallel fetch) private _groupUsers = signal>({}); public readonly groupUsers = this._groupUsers.asReadonly(); @@ -86,6 +112,21 @@ export class UsersService { this._groupsUpdated.set(''); } + loadPublicPermissions(connectionId: string): void { + this._publicPermissionsFor.set(connectionId); + } + + async savePublicPermissions(connectionId: string, tables: PublicTablePermission[]): Promise { + await this._api.put( + `/connection/public-permissions/${connectionId}`, + { tables }, + { + successMessage: 'Public access has been updated.', + }, + ); + this._publicPermissionsResource.reload(); + } + async fetchGroupUsers(groupId: string): Promise { const users = await this._api.get(`/group/users/${groupId}`); const result = users ?? [];