diff --git a/frontend/src/app/components/dashboard/db-table-view/db-table-actions/db-table-actions.component.css b/frontend/src/app/components/dashboard/db-table-view/db-table-actions/db-table-actions.component.css index 62e0ee49e..7e4cb388d 100644 --- a/frontend/src/app/components/dashboard/db-table-view/db-table-actions/db-table-actions.component.css +++ b/frontend/src/app/components/dashboard/db-table-view/db-table-actions/db-table-actions.component.css @@ -519,15 +519,15 @@ } .example-trigger__icon-box--custom { - background: color-mix(in srgb, #c084fc, transparent 80%); - color: #6d28d9; + background: color-mix(in srgb, var(--alternative-color), transparent 80%); + color: var(--alternative-color); } @media (prefers-color-scheme: dark) { .example-trigger__icon-box--add { background: color-mix(in srgb, var(--success-color), transparent 88%); color: var(--success-color); } .example-trigger__icon-box--update { background: color-mix(in srgb, var(--info-color), transparent 88%); color: var(--info-color); } .example-trigger__icon-box--delete { background: color-mix(in srgb, var(--error-color), transparent 88%); color: var(--error-color); } - .example-trigger__icon-box--custom { background: rgba(192, 132, 252, 0.12); color: #c084fc; } + .example-trigger__icon-box--custom { background: color-mix(in srgb, var(--alternative-color), transparent 88%); color: var(--alternative-color); } } .example-trigger__text { 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 index 6427b3615..17807de4b 100644 --- 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 @@ -19,12 +19,13 @@ .form-parse-warning { display: flex; align-items: center; - gap: 8px; - padding: 12px; + gap: 16px; + padding: 12px 16px; + border: 1px solid var(--warning-color); margin-bottom: 12px; border-radius: 4px; - background: var(--mdc-theme-warning-container, #fff3e0); - color: var(--mdc-theme-on-warning-container, #e65100); + background: var(--warning-background-color); + color: var(--warning-color); font-size: 13px; } @@ -32,6 +33,10 @@ flex-shrink: 0; } +.dialog-actions-spacer { + flex: 1; +} + .code-editor-box { height: 300px; border: 1px solid var(--mdc-outlined-text-field-outline-color, rgba(0, 0, 0, 0.38)); 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 index 1a9704896..4218f6c6b 100644 --- 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 @@ -1,44 +1,51 @@

Policy — {{ data.groupTitle }}

- -
- - Form - Code - -
+
+ +
+ + Form + Code + +
-
- warning - This policy uses advanced Cedar syntax that cannot be represented in form mode. Please use the code editor. -
+
+ warning + This policy uses advanced Cedar syntax that cannot be represented in form mode. Please use the code editor. +
-
- - -
+
+ + +
-
-

Edit policy in Cedar format

-
- - +
+

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 index 10dec0345..e3541f92d 100644 --- 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 @@ -21,6 +21,9 @@ describe('CedarPolicyEditorDialogComponent', () => { const mockDialogRef = { close: () => {}, + backdropClick: () => of(), + keydownEvents: () => of(), + disableClose: false, }; const fakeTables = [ 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 index 337260af5..a10f69954 100644 --- 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 @@ -1,6 +1,7 @@ import { NgIf } from '@angular/common'; -import { Component, DestroyRef, Inject, inject, OnInit } from '@angular/core'; +import { Component, DestroyRef, ElementRef, Inject, inject, OnInit, ViewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; @@ -36,6 +37,7 @@ export interface CedarPolicyEditorDialogData { styleUrls: ['./cedar-policy-editor-dialog.component.css'], imports: [ NgIf, + FormsModule, MatDialogModule, MatButtonModule, MatButtonToggleModule, @@ -57,6 +59,9 @@ export class CedarPolicyEditorDialogComponent implements OnInit { public loading: boolean = true; public formParseError: boolean = false; + @ViewChild(CedarPolicyListComponent) policyList?: CedarPolicyListComponent; + @ViewChild('dialogContent', { read: ElementRef }) dialogContent?: ElementRef; + public cedarPolicyModel: object; public codeEditorOptions = { minimap: { enabled: false }, @@ -80,6 +85,16 @@ export class CedarPolicyEditorDialogComponent implements OnInit { ) { this.codeEditorTheme = this._uiSettings.isDarkMode ? 'vs-dark' : 'vs'; this._editorService.loaded.pipe(take(1)).subscribe(({ monaco }) => registerCedarLanguage(monaco)); + + this.dialogRef.disableClose = true; + this.dialogRef.backdropClick().pipe(takeUntilDestroyed(this._destroyRef)).subscribe(() => { + this.confirmClose(); + }); + this.dialogRef.keydownEvents().pipe(takeUntilDestroyed(this._destroyRef)).subscribe((event) => { + if (event.key === 'Escape') { + this.confirmClose(); + } + }); } ngOnInit(): void { @@ -155,7 +170,22 @@ export class CedarPolicyEditorDialogComponent implements OnInit { this.editorMode = mode; } + confirmClose() { + if (this.editorMode === 'form' && this.policyList?.hasPendingChanges()) { + const discard = confirm('You have an unsaved policy in the form. Discard it and close?'); + if (!discard) return; + this.policyList.discardPending(); + } + this.dialogRef.close(); + } + savePolicy() { + if (this.editorMode === 'form' && this.policyList?.hasPendingChanges()) { + const discard = confirm('You have an unsaved policy in the form. Discard it and save?'); + if (!discard) return; + this.policyList.discardPending(); + } + this.submitting = true; if (this.editorMode === 'form') { @@ -182,6 +212,17 @@ export class CedarPolicyEditorDialogComponent implements OnInit { }); } + onAddPolicyClick() { + if (!this.policyList) return; + this.policyList.showAddForm = true; + setTimeout(() => { + const el = this.dialogContent?.nativeElement; + if (el) { + el.scrollTop = el.scrollHeight; + } + }); + } + private _parseCedarToPolicyItems(): CedarPolicyItem[] { const parsed = parseCedarPolicy(this.cedarPolicy, this.connectionID, this.data.groupId, this.allTables); const dashboardItems = parseCedarDashboardItems(this.cedarPolicy, this.connectionID); 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 index 453e80003..7a3e2ecbe 100644 --- 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 @@ -1,7 +1,7 @@ .policy-list { display: flex; flex-direction: column; - gap: 4px; + gap: 16px; } .empty-state { @@ -10,43 +10,189 @@ padding: 12px 0; } +/* ── Group ── */ + +.policy-group { + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 12px; + overflow: hidden; +} + +@media (prefers-color-scheme: dark) { + .policy-group { + border-color: rgba(255, 255, 255, 0.12); + } +} + +.policy-group__header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + background: rgba(0, 0, 0, 0.03); + cursor: pointer; + user-select: none; +} + +.policy-group__header:hover { + background: rgba(0, 0, 0, 0.06); +} + +@media (prefers-color-scheme: dark) { + .policy-group__header { + background: rgba(0, 0, 0, 0.2); + } + .policy-group__header:hover { + background: rgba(255, 255, 255, 0.07); + } +} + +.policy-group__icon-box { + width: 28px; + height: 28px; + border-radius: 7px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.policy-group__icon-box mat-icon { + font-size: 18px; + width: 18px; + height: 18px; +} + +.policy-group__icon-box--general { + background: color-mix(in srgb, var(--warning-color), transparent 85%); + color: var(--warning-color); +} + +.policy-group__icon-box--connection { + background: color-mix(in srgb, var(--alternative-color), transparent 85%); + color: var(--alternative-color); +} + +.policy-group__icon-box--group { + background: color-mix(in srgb, #06b6d4, transparent 85%); + color: #0891b2; +} + +.policy-group__icon-box--table { + background: color-mix(in srgb, var(--success-color), transparent 85%); + color: var(--success-color); +} + +.policy-group__icon-box--dashboard { + background: color-mix(in srgb, var(--info-color), transparent 85%); + color: var(--info-color); +} + +@media (prefers-color-scheme: dark) { + .policy-group__icon-box--general { background: rgba(245, 158, 11, 0.15); color: #fbbf24; } + .policy-group__icon-box--connection { background: rgba(139, 92, 246, 0.15); color: #a78bfa; } + .policy-group__icon-box--group { background: rgba(6, 182, 212, 0.15); color: #22d3ee; } + .policy-group__icon-box--table { background: rgba(16, 185, 129, 0.15); color: #34d399; } + .policy-group__icon-box--dashboard { background: rgba(59, 130, 246, 0.15); color: #60a5fa; } +} + +.policy-group__label { + font-size: 13px; + font-weight: 600; +} + +.policy-group__count { + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 600; + padding: 2px 7px; + border-radius: 10px; + background: rgba(0, 0, 0, 0.08); + color: rgba(0, 0, 0, 0.64); + margin-left: auto; + height: 20px; + width: 20px; +} + +.policy-group__chevron { + font-size: 20px; + width: 20px; + height: 20px; + opacity: 0.4; + transform: rotate(-90deg); + transition: transform 0.2s ease; +} + +.policy-group__chevron--collapsed { + transform: rotate(0deg); +} + +@media (prefers-color-scheme: dark) { + .policy-group__count { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.64); + } +} + +.policy-group__items { + display: flex; + flex-direction: column; +} + +/* ── Policy item ── */ + .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)); + padding: 8px 16px; + border-top: 1px solid rgba(0, 0, 0, 0.06); +} + +@media (prefers-color-scheme: dark) { + .policy-item { + border-top-color: rgba(255, 255, 255, 0.06); + } } .policy-item--add { - border-style: dashed; + border: 1px dashed rgba(0, 0, 0, 0.15); + border-radius: 12px; + padding: 10px 16px; +} + +@media (prefers-color-scheme: dark) { + .policy-item--add { + border-color: rgba(255, 255, 255, 0.15); + } } .policy-item__content { display: flex; align-items: center; - gap: 8px; + gap: 6px; 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__action-icon { + font-size: 16px; + width: 16px; + height: 16px; + opacity: 0.5; } .policy-item__label { - font-size: 14px; + font-size: 13px; font-weight: 500; } .policy-item__table { - font-size: 14px; - opacity: 0.7; + font-size: 13px; + opacity: 0.55; + font-style: italic; } .policy-item__actions { @@ -75,7 +221,26 @@ padding-top: 8px; } +/* ── Option hints ── */ + +/* .policy-option--used { + background: color-mix(in srgb, #3b82f6, transparent 92%); +} */ + +.policy-option--used[data-hint]::after { + content: attr(data-hint); + font-size: 11px; + opacity: 0.5; + margin-left: 8px; + font-style: italic; +} + +/* @media (prefers-color-scheme: dark) { + .policy-option--used { + background: rgba(59, 130, 246, 0.1); + } +} */ + .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 index 97d3d4726..13a13e042 100644 --- 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 @@ -5,72 +5,89 @@ No policies defined. Add a policy to grant permissions.
-
- - -
- security - {{ getActionLabel(policy.action) }} - - — {{ getTableDisplayName(policy.tableName) }} - - - — {{ getDashboardDisplayName(policy.dashboardId) }} - +
+
+
+ {{ group.icon }}
-
- - -
- + {{ group.label }} + {{ group.policies.length }} + expand_more +
- - -
- - Action - - - - {{ action.label }} - - - - +
+
+ + +
+ {{ getActionIcon(entry.item.action) }} + {{ getShortActionLabel(entry.item.action) }} + + {{ getTableDisplayName(entry.item.tableName) }} + + + {{ getDashboardDisplayName(entry.item.dashboardId) }} + +
+
+ + +
+
- - Table - - All tables - - {{ table.displayName }} - - - + + +
+ + Action + + + + {{ action.label }} + + + + - - Dashboard - - All dashboards - - {{ dashboard.name }} - - - + + Table + + All tables + + {{ table.displayName }} + + + -
- - -
+ + Dashboard + + All dashboards + + {{ dashboard.name }} + + + + +
+ + +
+
+
- +
@@ -79,8 +96,8 @@ Action - - + + {{ action.label }} @@ -91,7 +108,9 @@ Table All tables - + {{ table.displayName }} @@ -101,7 +120,9 @@ Dashboard All dashboards - + {{ dashboard.name }} @@ -117,8 +138,4 @@
-
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 index 1e9fb8dec..1cae2d0d6 100644 --- 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 @@ -1,12 +1,12 @@ import { CommonModule } from '@angular/common'; -import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } 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 { CedarPolicyItem, PolicyActionGroup, 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 { @@ -19,6 +19,14 @@ export interface AvailableDashboard { name: string; } +export interface PolicyGroup { + label: string; + description: string; + icon: string; + colorClass: string; + policies: { item: CedarPolicyItem; originalIndex: number }[]; +} + @Component({ selector: 'app-cedar-policy-list', imports: [ @@ -34,7 +42,7 @@ export interface AvailableDashboard { templateUrl: './cedar-policy-list.component.html', styleUrls: ['./cedar-policy-list.component.css'], }) -export class CedarPolicyListComponent { +export class CedarPolicyListComponent implements OnChanges { @Input() policies: CedarPolicyItem[] = []; @Input() availableTables: AvailableTable[] = []; @Input() availableDashboards: AvailableDashboard[] = []; @@ -51,8 +59,22 @@ export class CedarPolicyListComponent { editTableName = ''; editDashboardId = ''; + collapsedGroups = new Set(); + availableActions = POLICY_ACTIONS; - actionGroups = POLICY_ACTION_GROUPS; + + groupedPolicies: PolicyGroup[] = []; + addActionGroups: PolicyActionGroup[] = []; + editActionGroups: PolicyActionGroup[] = []; + + usedTables = new Map(); + usedDashboards = new Map(); + + ngOnChanges(changes: SimpleChanges): void { + if (changes['policies']) { + this._refreshViews(); + } + } get needsTable(): boolean { return this.availableActions.find((a) => a.value === this.newAction)?.needsTable ?? false; @@ -70,6 +92,50 @@ export class CedarPolicyListComponent { return this.availableActions.find((a) => a.value === this.editAction)?.needsDashboard ?? false; } + toggleGroup(label: string) { + if (this.collapsedGroups.has(label)) { + this.collapsedGroups.delete(label); + } else { + this.collapsedGroups.add(label); + } + } + + isCollapsed(label: string): boolean { + return this.collapsedGroups.has(label); + } + + trackByGroup(_index: number, group: PolicyGroup): string { + return group.label; + } + + trackByPolicy(_index: number, entry: { item: CedarPolicyItem; originalIndex: number }): number { + return entry.originalIndex; + } + + trackByActionGroup(_index: number, group: PolicyActionGroup): string { + return group.group; + } + + trackByAction(_index: number, action: { value: string }): string { + return action.value; + } + + getTableUsedHint(tableName: string): string { + return this.usedTables.get(tableName)?.join(', ') || ''; + } + + getDashboardUsedHint(dashboardId: string): string { + return this.usedDashboards.get(dashboardId)?.join(', ') || ''; + } + + getActionIcon(action: string): string { + return this._actionIcons[action] || 'security'; + } + + getShortActionLabel(action: string): string { + return this._shortLabels[action] || action; + } + getActionLabel(action: string): string { return this.availableActions.find((a) => a.value === action)?.label || action; } @@ -84,11 +150,28 @@ export class CedarPolicyListComponent { return this.availableDashboards.find((d) => d.id === dashboardId)?.name || dashboardId; } + hasPendingChanges(): boolean { + return (this.showAddForm && !!this.newAction) || this.editingIndex !== null; + } + + discardPending() { + if (this.showAddForm) this.resetAddForm(); + if (this.editingIndex !== null) this.cancelEdit(); + } + addPolicy() { if (!this.newAction) return; if (this.needsTable && !this.newTableName) return; if (this.needsDashboard && !this.newDashboardId) return; + const duplicate = this.policies.some((p) => { + if (p.action !== this.newAction) return false; + if (this.needsTable) return p.tableName === this.newTableName; + if (this.needsDashboard) return p.dashboardId === this.newDashboardId; + return true; + }); + if (duplicate) return; + const item: CedarPolicyItem = { action: this.newAction }; if (this.needsTable) { item.tableName = this.newTableName; @@ -99,11 +182,13 @@ export class CedarPolicyListComponent { this.policies = [...this.policies, item]; this.policiesChange.emit(this.policies); this.resetAddForm(); + this._refreshViews(); } removePolicy(index: number) { this.policies = this.policies.filter((_, i) => i !== index); this.policiesChange.emit(this.policies); + this._refreshViews(); } startEdit(index: number) { @@ -111,6 +196,7 @@ export class CedarPolicyListComponent { this.editAction = this.policies[index].action; this.editTableName = this.policies[index].tableName || ''; this.editDashboardId = this.policies[index].dashboardId || ''; + this.editActionGroups = this._buildFilteredGroups(index); } saveEdit(index: number) { @@ -127,6 +213,7 @@ export class CedarPolicyListComponent { this.policies = updated; this.policiesChange.emit(this.policies); this.editingIndex = null; + this._refreshViews(); } cancelEdit() { @@ -139,4 +226,105 @@ export class CedarPolicyListComponent { this.newTableName = ''; this.newDashboardId = ''; } + + private _groupConfig = [ + { prefix: '*', label: 'General', description: 'Full access to everything', icon: 'admin_panel_settings', colorClass: 'general' }, + { prefix: 'connection:', label: 'Connection', description: 'Connection settings access', icon: 'cable', colorClass: 'connection' }, + { prefix: 'group:', label: 'Group', description: 'User group management', icon: 'group', colorClass: 'group' }, + { prefix: 'table:', label: 'Table', description: 'Table data operations', icon: 'table_chart', colorClass: 'table' }, + { prefix: 'dashboard:', label: 'Dashboard', description: 'Dashboard access', icon: 'dashboard', colorClass: 'dashboard' }, + ]; + + private _actionIcons: Record = { + '*': 'shield', + 'connection:read': 'visibility', + 'connection:edit': 'edit', + 'group:read': 'visibility', + 'group:edit': 'settings', + 'table:*': 'shield', + 'table:read': 'visibility', + 'table:add': 'add_circle', + 'table:edit': 'edit', + 'table:delete': 'delete', + 'dashboard:*': 'shield', + 'dashboard:read': 'visibility', + 'dashboard:create': 'add_circle', + 'dashboard:edit': 'edit', + 'dashboard:delete': 'delete', + }; + + private _shortLabels: Record = { + '*': 'Full access', + 'connection:read': 'Read', + 'connection:edit': 'Full access', + 'group:read': 'Read', + 'group:edit': 'Manage', + 'table:*': 'Full access', + 'table:read': 'Read', + 'table:add': 'Add', + 'table:edit': 'Edit', + 'table:delete': 'Delete', + 'dashboard:*': 'Full access', + 'dashboard:read': 'Read', + 'dashboard:create': 'Create', + 'dashboard:edit': 'Edit', + 'dashboard:delete': 'Delete', + }; + + private _refreshViews() { + this.groupedPolicies = this._groupConfig + .map((cfg) => ({ + label: cfg.label, + description: cfg.description, + icon: cfg.icon, + colorClass: cfg.colorClass, + policies: this.policies + .map((item, i) => ({ item, originalIndex: i })) + .filter(({ item }) => + cfg.prefix === '*' ? item.action === '*' : item.action.startsWith(cfg.prefix), + ), + })) + .filter((g) => g.policies.length > 0); + + this.addActionGroups = this._buildFilteredGroups(-1); + + this.usedTables = new Map(); + this.usedDashboards = new Map(); + for (const p of this.policies) { + if (p.tableName) { + const labels = this.usedTables.get(p.tableName) || []; + labels.push(this._shortLabels[p.action] || p.action); + this.usedTables.set(p.tableName, labels); + } + if (p.dashboardId) { + const labels = this.usedDashboards.get(p.dashboardId) || []; + labels.push(this._shortLabels[p.action] || p.action); + this.usedDashboards.set(p.dashboardId, labels); + } + } + } + + private _buildFilteredGroups(excludeIndex: number): PolicyActionGroup[] { + const existingSimple = new Set( + this.policies + .filter((p, i) => { + if (i === excludeIndex) return false; + const def = this.availableActions.find((a) => a.value === p.action); + return def && !def.needsTable && !def.needsDashboard; + }) + .map((p) => p.action), + ); + + return POLICY_ACTION_GROUPS + .map((group) => ({ + ...group, + actions: group.actions.filter((action) => { + if (!action.needsTable && !action.needsDashboard) { + return !existingSimple.has(action.value); + } + return true; + }), + })) + .filter((group) => group.actions.length > 0); + } } diff --git a/frontend/src/app/components/users/users.component.css b/frontend/src/app/components/users/users.component.css index 212c5e825..a5f857f28 100644 --- a/frontend/src/app/components/users/users.component.css +++ b/frontend/src/app/components/users/users.component.css @@ -22,18 +22,31 @@ header { font-size: 2em; } +::ng-deep .mat-expansion-panel-header { + --mat-expansion-header-collapsed-state-height: auto; + --mat-expansion-header-expanded-state-height: auto; + height: auto !important; + min-height: 48px; + padding-top: 12px !important; + padding-bottom: 12px !important; +} + .mat-expansion-panel-header-title { align-items: center; + flex-wrap: wrap; + row-gap: 0; + line-height: 1.3; } .title-edit-button { - display: none; + visibility: hidden; color: var(--mat-expansion-header-description-color); margin-left: 12px; } .group-actions { - display: none; + visibility: hidden; + display: flex; flex-grow: 0 !important; justify-content: flex-end; align-items: center; @@ -41,12 +54,69 @@ header { .group-item:hover .title-edit-button, .mat-expanded .title-edit-button { - display: block; + visibility: visible; } .group-item:hover .group-actions, .mat-expanded .group-actions { + visibility: visible; +} + +.group-system-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(--alternative-color), transparent 88%); + color: var(--alternative-color); + margin-left: 8px; +} + +/* ── Members preview ── */ + +.group-members-preview { display: flex; + align-items: center; + gap: 0; + flex-basis: 100%; + margin-top: -2px; +} + +.group-avatar { + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--color-accentedPalette-100); + color: var(--color-accentedPalette-500); + font-size: 9px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + margin-left: -3px; + border: 1.5px solid var(--mat-expansion-container-background-color, #fff); + flex-shrink: 0; +} + +.group-avatar:first-child { + margin-left: 0; +} + +@media (prefers-color-scheme: dark) { + .group-avatar { + background: var(--color-accentedPalette-700); + color: var(--color-accentedPalette-100); + border-color: var(--mat-expansion-container-background-color, #1e1e1e); + } +} + +.group-members-count { + font-size: 11px; + opacity: 0.45; + margin-left: 6px; + white-space: nowrap; } .user { diff --git a/frontend/src/app/components/users/users.component.html b/frontend/src/app/components/users/users.component.html index 6efc9bb28..ec5da08cb 100644 --- a/frontend/src/app/components/users/users.component.html +++ b/frontend/src/app/components/users/users.component.html @@ -18,6 +18,7 @@

User groups

{{ groupItem.group.title }} + system + + + {{ getUserInitials(user) }} + + {{ groupUsers.length }} {{ groupUsers.length === 1 ? 'member' : 'members' }} + diff --git a/frontend/src/app/components/users/users.component.ts b/frontend/src/app/components/users/users.component.ts index 60b3b1816..f6a33e572 100644 --- a/frontend/src/app/components/users/users.component.ts +++ b/frontend/src/app/components/users/users.component.ts @@ -178,6 +178,23 @@ export class UsersComponent implements OnInit, OnDestroy { this.companyMembersWithoutAccess = differenceBy(this.companyMembers, allGroupUsers, 'email'); } + getGroupUsers(groupId: string): GroupUser[] | null { + const val = this.users[groupId]; + if (!val || val === 'empty') return null; + return val; + } + + getUserInitials(user: GroupUser): string { + // biome-ignore lint/suspicious/noExplicitAny: name comes from API but not typed + const name = (user as any).name as string | undefined; + if (name) { + const parts = name.trim().split(/\s+/); + if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase(); + return parts[0][0].toUpperCase(); + } + return user.email[0].toUpperCase(); + } + openCreateUsersGroupDialog(event) { event.preventDefault(); event.stopImmediatePropagation(); diff --git a/frontend/src/app/models/ai-suggestions.ts b/frontend/src/app/models/ai-suggestions.ts new file mode 100644 index 000000000..9e9f8bac5 --- /dev/null +++ b/frontend/src/app/models/ai-suggestions.ts @@ -0,0 +1,87 @@ +// Input interfaces for AI Suggestions + +export interface AISuggestionColumn { + name: string; + type: string; + nullable: boolean; + unique?: boolean; + indexed?: boolean; +} + +export interface AISuggestionRelation { + type: 'fk'; + from_column: string; + to_table: string; + to_column: string; + label?: string; +} + +export interface AISuggestionLightStats { + top_values?: Record; + null_rate?: Record; + min_max?: Record; +} + +export interface AISuggestionTableContext { + name: string; + description?: string; + row_count?: number; + columns: AISuggestionColumn[]; + relations: AISuggestionRelation[]; + light_stats?: AISuggestionLightStats; +} + +export interface AISuggestionFilter { + column: string; + op: string; + value: any; +} + +export interface AISuggestionUIContext { + active_filters?: AISuggestionFilter[]; + selected_row?: Record; + selected_column?: string; +} + +export interface AISuggestionConversation { + last_user_message?: string; + last_2_messages?: { role: string; text: string }[]; +} + +export interface AISuggestionHistory { + recently_shown_ids?: string[]; + recently_clicked_ids?: string[]; +} + +export interface AISuggestionInput { + table: AISuggestionTableContext; + ui_context?: AISuggestionUIContext; + conversation?: AISuggestionConversation; + suggestion_history?: AISuggestionHistory; +} + +// Output interfaces for AI Suggestions + +export interface AITableSuggestion { + id: string; + title: string; + message: string; + why?: string; + confidence?: number; + risk?: 'low' | 'medium' | 'high'; +} + +export interface AINavigationSuggestion { + id: string; + title: string; + target_table: string; + prefilled_query: string; + why?: string; + confidence?: number; +} + +export interface AISuggestionOutput { + intent?: string[]; + table_suggestions: AITableSuggestion[]; + navigation_suggestions?: AINavigationSuggestion[]; +} diff --git a/frontend/src/app/services/ai-suggestions.service.ts b/frontend/src/app/services/ai-suggestions.service.ts new file mode 100644 index 000000000..097e2b3a5 --- /dev/null +++ b/frontend/src/app/services/ai-suggestions.service.ts @@ -0,0 +1,353 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; +import { + AISuggestionInput, + AISuggestionOutput, + AISuggestionTableContext, + AISuggestionColumn, + AISuggestionRelation, + AISuggestionUIContext, + AISuggestionConversation, + AISuggestionHistory, + AITableSuggestion, +} from '../models/ai-suggestions'; +import { TableField, TableForeignKey } from '../models/table'; + +@Injectable({ + providedIn: 'root' +}) +export class AISuggestionsService { + + private suggestionsSubject = new BehaviorSubject(null); + public suggestions$ = this.suggestionsSubject.asObservable(); + + private historySubject = new BehaviorSubject({ + recently_shown_ids: [], + recently_clicked_ids: [] + }); + + constructor() {} + + /** + * Build table context from existing table data + */ + buildTableContext( + tableName: string, + displayName: string, + structure: TableField[], + foreignKeys: TableForeignKey[], + rowCount?: number + ): AISuggestionTableContext { + const columns: AISuggestionColumn[] = structure.map(field => ({ + name: field.column_name, + type: field.data_type, + nullable: field.allow_null, + unique: false, + indexed: false + })); + + const relations: AISuggestionRelation[] = foreignKeys.map(fk => ({ + type: 'fk' as const, + from_column: fk.column_name, + to_table: fk.referenced_table_name, + to_column: fk.referenced_column_name, + label: `${tableName}.${fk.column_name} -> ${fk.referenced_table_name}.${fk.referenced_column_name}` + })); + + return { + name: tableName, + description: displayName !== tableName ? displayName : undefined, + row_count: rowCount, + columns, + relations + }; + } + + /** + * Build UI context from current state + */ + buildUIContext( + activeFilters?: Record, + selectedRow?: Record, + selectedColumn?: string + ): AISuggestionUIContext { + const filters = activeFilters + ? Object.entries(activeFilters).map(([column, filterValue]) => { + const op = Object.keys(filterValue)[0] || 'eq'; + const value = Object.values(filterValue)[0]; + return { column, op, value }; + }) + : undefined; + + return { + active_filters: filters, + selected_row: selectedRow, + selected_column: selectedColumn + }; + } + + /** + * Build conversation context + */ + buildConversationContext( + messagesChain: { type: string; text: string }[] + ): AISuggestionConversation { + const lastUserMessage = [...messagesChain] + .reverse() + .find(m => m.type === 'user')?.text; + + const last2Messages = messagesChain.slice(-2).map(m => ({ + role: m.type === 'user' ? 'user' : 'assistant', + text: m.text + })); + + return { + last_user_message: lastUserMessage, + last_2_messages: last2Messages.length > 0 ? last2Messages : undefined + }; + } + + /** + * Build full suggestion input + */ + buildSuggestionInput( + tableContext: AISuggestionTableContext, + uiContext?: AISuggestionUIContext, + conversationContext?: AISuggestionConversation + ): AISuggestionInput { + return { + table: tableContext, + ui_context: uiContext, + conversation: conversationContext, + suggestion_history: this.historySubject.value + }; + } + + /** + * Generate local suggestions based on table context (fallback when no AI) + */ + generateLocalSuggestions(input: AISuggestionInput): AISuggestionOutput { + const suggestions: AITableSuggestion[] = []; + const { table, ui_context } = input; + + // Find datetime columns for trend analysis + const dateColumns = table.columns.filter(c => + c.type.toLowerCase().includes('date') || + c.type.toLowerCase().includes('time') || + c.name.toLowerCase().includes('created') || + c.name.toLowerCase().includes('updated') + ); + + // Find status/categorical columns + const statusColumns = table.columns.filter(c => + c.name.toLowerCase().includes('status') || + c.name.toLowerCase().includes('state') || + c.name.toLowerCase().includes('type') + ); + + // Find email/identifier columns + const identifierColumns = table.columns.filter(c => + c.name.toLowerCase().includes('email') || + c.name.toLowerCase().includes('name') || + c.name.toLowerCase() === 'id' + ); + + // Find numeric columns + const numericColumns = table.columns.filter(c => + c.type.toLowerCase().includes('int') || + c.type.toLowerCase().includes('decimal') || + c.type.toLowerCase().includes('numeric') || + c.type.toLowerCase().includes('float') + ); + + // Basic suggestions + suggestions.push({ + id: 'recent_rows', + title: 'Последние записи', + message: 'Покажи последние 10 записей в таблице', + confidence: 0.9, + risk: 'low' + }); + + suggestions.push({ + id: 'table_overview', + title: 'Обзор таблицы', + message: 'Дай краткий обзор структуры таблицы и основных данных', + confidence: 0.85, + risk: 'low' + }); + + // Status distribution if status column exists + if (statusColumns.length > 0) { + const col = statusColumns[0]; + suggestions.push({ + id: `group_by_${col.name}`, + title: `Распределение по ${col.name}`, + message: `Покажи распределение записей по полю "${col.name}" с количеством в каждой группе`, + confidence: 0.8, + risk: 'low' + }); + } + + // Trend analysis if date column exists + if (dateColumns.length > 0) { + const col = dateColumns[0]; + suggestions.push({ + id: `trend_by_${col.name}`, + title: `Тренд по ${col.name}`, + message: `Покажи тренд создания записей по полю "${col.name}" по месяцам`, + confidence: 0.75, + risk: 'low' + }); + } + + // Duplicates check if identifier column exists + if (identifierColumns.length > 0) { + const col = identifierColumns.find(c => c.name.toLowerCase().includes('email')) || identifierColumns[0]; + suggestions.push({ + id: `duplicates_by_${col.name}`, + title: `Дубликаты по ${col.name}`, + message: `Найди все дубликаты по полю "${col.name}" и покажи группы с количеством`, + confidence: 0.7, + risk: 'low' + }); + } + + // Null analysis + const nullableColumns = table.columns.filter(c => c.nullable); + if (nullableColumns.length > 0) { + suggestions.push({ + id: 'nulls_overview', + title: 'Анализ пустых значений', + message: 'Покажи статистику по NULL значениям во всех колонках', + confidence: 0.7, + risk: 'low' + }); + } + + // Statistics for numeric columns + if (numericColumns.length > 0) { + const col = numericColumns.find(c => + c.name.toLowerCase().includes('amount') || + c.name.toLowerCase().includes('price') || + c.name.toLowerCase().includes('total') + ) || numericColumns[0]; + suggestions.push({ + id: `stats_${col.name}`, + title: `Статистика по ${col.name}`, + message: `Рассчитай статистику (min, max, avg, sum) для поля "${col.name}"`, + confidence: 0.7, + risk: 'low' + }); + } + + // Context-aware suggestions based on active filters + if (ui_context?.active_filters && ui_context.active_filters.length > 0) { + suggestions.push({ + id: 'filtered_count', + title: 'Количество по фильтру', + message: 'Сколько записей соответствует текущему фильтру?', + confidence: 0.85, + risk: 'low' + }); + } + + // Take top 5-7 suggestions + const finalSuggestions = suggestions.slice(0, 7); + + return { + intent: this._detectIntent(input), + table_suggestions: finalSuggestions, + navigation_suggestions: this._generateNavigationSuggestions(input) + }; + } + + /** + * Record that a suggestion was clicked + */ + recordSuggestionClick(suggestionId: string): void { + const history = this.historySubject.value; + const clicked = [...(history.recently_clicked_ids || []), suggestionId].slice(-10); + this.historySubject.next({ + ...history, + recently_clicked_ids: clicked + }); + } + + /** + * Clear suggestion history + */ + clearHistory(): void { + this.historySubject.next({ + recently_shown_ids: [], + recently_clicked_ids: [] + }); + } + + /** + * Update shown history + */ + private updateShownHistory(shownIds: string[]): void { + const history = this.historySubject.value; + const shown = [...(history.recently_shown_ids || []), ...shownIds].slice(-20); + this.historySubject.next({ + ...history, + recently_shown_ids: shown + }); + } + + /** + * Detect user intent from context + */ + private _detectIntent(input: AISuggestionInput): string[] { + const intents: string[] = []; + const { ui_context, conversation } = input; + + const lastMessage = conversation?.last_user_message?.toLowerCase() || ''; + + if (lastMessage.includes('покажи') || lastMessage.includes('найди') || lastMessage.includes('где')) { + intents.push('SEARCH'); + } + if (lastMessage.includes('сколько') || lastMessage.includes('группир') || lastMessage.includes('распределен')) { + intents.push('SEGMENT'); + } + if (lastMessage.includes('дублик') || lastMessage.includes('null') || lastMessage.includes('пуст')) { + intents.push('QUALITY'); + } + if (lastMessage.includes('обнови') || lastMessage.includes('измени')) { + intents.push('UPDATE_HELP'); + } + + if (intents.length === 0) { + intents.push('EXPLORE'); + } + + return intents; + } + + /** + * Generate navigation suggestions for related tables + */ + private _generateNavigationSuggestions(input: AISuggestionInput) { + const { table, ui_context } = input; + const suggestions = []; + + if (table.relations.length > 0 && ui_context?.selected_row) { + for (const relation of table.relations.slice(0, 3)) { + const fkValue = ui_context.selected_row[relation.from_column]; + if (fkValue) { + suggestions.push({ + id: `nav_to_${relation.to_table}`, + title: `Открыть ${relation.to_table}`, + target_table: relation.to_table, + prefilled_query: `Покажи запись с ${relation.to_column} = ${fkValue}`, + why: `Связь ${relation.label}`, + confidence: 0.7 + }); + } + } + } + + return suggestions; + } +} diff --git a/frontend/src/app/services/users.service.ts b/frontend/src/app/services/users.service.ts index 4818ce135..e8984238e 100644 --- a/frontend/src/app/services/users.service.ts +++ b/frontend/src/app/services/users.service.ts @@ -69,7 +69,7 @@ export class UsersService { return this._http.post(`/connection/cedar-policy/${connectionID}`, { cedarPolicy, groupId }).pipe( map((res) => { this.groups.next({ action: 'save policy', groupId }); - this._notifications.showSuccessSnackbar('Cedar policy has been saved.'); + this._notifications.showSuccessSnackbar('Policy has been saved.'); return res; }), catchError((err) => { diff --git a/frontend/src/assets/app.ico b/frontend/src/assets/app.ico new file mode 100644 index 000000000..23e81b472 Binary files /dev/null and b/frontend/src/assets/app.ico differ diff --git a/frontend/src/custom-theme.scss b/frontend/src/custom-theme.scss index abb5bc346..645095eb7 100644 --- a/frontend/src/custom-theme.scss +++ b/frontend/src/custom-theme.scss @@ -32,11 +32,13 @@ html { --warning-color: #f79008; --info-color: #296ee9; --success-color: #1b5e20; + --alternative-color: #6d28d9; --error-background-color: color-mix(in hsl, var(--error-color), transparent 95%); --warning-background-color: color-mix(in hsl, var(--warning-color), transparent 95%); --info-background-color: color-mix(in hsl, var(--info-color), transparent 95%); --success-background-color: color-mix(in hsl, var(--success-color), transparent 95%); + --alternative-background-color: color-mix(in hsl, var(--alternative-color), transparent 95%); } @media (prefers-color-scheme: dark) {