From e8d7f28a5225c7632920f65dc5e8992a362b41af Mon Sep 17 00:00:00 2001 From: Karina Kharchenko Date: Mon, 23 Mar 2026 14:26:30 +0200 Subject: [PATCH 1/5] feat: improve permissions UI with grouped policies, member previews, and better UX Redesign cedar policy list with collapsible groups by category, duplicate detection, used-resource hints, and member avatar previews on group headers. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cedar-policy-editor-dialog.component.ts | 10 +- .../cedar-policy-list.component.css | 191 +++++++++- .../cedar-policy-list.component.html | 149 ++++---- .../cedar-policy-list.component.ts | 196 +++++++++- .../app/components/users/users.component.css | 76 +++- .../app/components/users/users.component.html | 10 +- .../app/components/users/users.component.ts | 17 + frontend/src/app/models/ai-suggestions.ts | 87 +++++ .../app/services/ai-suggestions.service.ts | 353 ++++++++++++++++++ frontend/src/app/services/users.service.ts | 2 +- frontend/src/assets/app.ico | Bin 0 -> 361102 bytes 11 files changed, 1001 insertions(+), 90 deletions(-) create mode 100644 frontend/src/app/models/ai-suggestions.ts create mode 100644 frontend/src/app/services/ai-suggestions.service.ts create mode 100644 frontend/src/assets/app.ico 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..4dd1cb9e6 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,5 +1,5 @@ import { NgIf } from '@angular/common'; -import { Component, DestroyRef, Inject, inject, OnInit } from '@angular/core'; +import { Component, DestroyRef, Inject, inject, OnInit, ViewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; @@ -57,6 +57,8 @@ export class CedarPolicyEditorDialogComponent implements OnInit { public loading: boolean = true; public formParseError: boolean = false; + @ViewChild(CedarPolicyListComponent) policyList?: CedarPolicyListComponent; + public cedarPolicyModel: object; public codeEditorOptions = { minimap: { enabled: false }, @@ -156,6 +158,12 @@ export class CedarPolicyEditorDialogComponent implements OnInit { } 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') { 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..d1fc37672 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,183 @@ padding: 12px 0; } +/* ── Group ── */ + +.policy-group { + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 12px; + overflow: hidden; +} + +@media (prefers-color-scheme: dark) { + .policy-group { + border-color: rgba(255, 255, 255, 0.1); + } +} + +.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(255, 255, 255, 0.04); + } + .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, #f59e0b, transparent 85%); + color: #b45309; +} + +.policy-group__icon-box--connection { + background: color-mix(in srgb, #8b5cf6, transparent 85%); + color: #6d28d9; +} + +.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, #10b981, transparent 85%); + color: #059669; +} + +.policy-group__icon-box--dashboard { + background: color-mix(in srgb, #3b82f6, transparent 85%); + color: #1d4ed8; +} + +@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 { + 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.6); + margin-left: auto; +} + +.policy-group__chevron { + font-size: 20px; + width: 20px; + height: 20px; + opacity: 0.4; + transition: transform 0.2s ease; +} + +.policy-group__chevron--collapsed { + transform: rotate(-90deg); +} + +@media (prefers-color-scheme: dark) { + .policy-group__count { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.6); + } +} + +.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 +215,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..48ca87239 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 }} 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..765b61f3e 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(--mdc-filled-button-container-color, #6750a4), transparent 88%); + color: var(--mdc-filled-button-container-color, #6750a4); + 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: color-mix(in srgb, #6366f1, transparent 85%); + color: #4f46e5; + 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: rgba(99, 102, 241, 0.2); + color: #a5b4fc; + 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 0000000000000000000000000000000000000000..23e81b472c2c7491e3449f098cba489b11597cf8 GIT binary patch literal 361102 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 Date: Tue, 24 Mar 2026 16:30:55 +0000 Subject: [PATCH 2/5] cedar policy editor: add close confirmation and form improvements - Wrap dialog in form with ngSubmit for consistent submit behavior - Add confirmation on backdrop click/Escape when pending changes exist - Use CSS variables for theme colors instead of hardcoded values - Add alternative color variable to custom theme - Polish cedar policy list styling (counts, chevrons, borders) Co-Authored-By: Claude Opus 4.6 --- .../db-table-actions.component.css | 6 +- .../cedar-policy-editor-dialog.component.css | 9 ++- .../cedar-policy-editor-dialog.component.html | 79 ++++++++++--------- .../cedar-policy-editor-dialog.component.ts | 21 +++++ .../cedar-policy-list.component.css | 42 +++++----- frontend/src/custom-theme.scss | 2 + 6 files changed, 95 insertions(+), 64 deletions(-) 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..9564be4c6 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; } 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..0920e849d 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,45 @@

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.ts b/frontend/src/app/components/users/cedar-policy-editor-dialog/cedar-policy-editor-dialog.component.ts index 4dd1cb9e6..41466b416 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, 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, @@ -82,6 +84,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 { @@ -157,6 +169,15 @@ 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?'); 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 d1fc37672..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 @@ -13,14 +13,14 @@ /* ── Group ── */ .policy-group { - border: 1px solid rgba(0, 0, 0, 0.1); + 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.1); + border-color: rgba(255, 255, 255, 0.12); } } @@ -40,7 +40,7 @@ @media (prefers-color-scheme: dark) { .policy-group__header { - background: rgba(255, 255, 255, 0.04); + background: rgba(0, 0, 0, 0.2); } .policy-group__header:hover { background: rgba(255, 255, 255, 0.07); @@ -64,13 +64,13 @@ } .policy-group__icon-box--general { - background: color-mix(in srgb, #f59e0b, transparent 85%); - color: #b45309; + background: color-mix(in srgb, var(--warning-color), transparent 85%); + color: var(--warning-color); } .policy-group__icon-box--connection { - background: color-mix(in srgb, #8b5cf6, transparent 85%); - color: #6d28d9; + background: color-mix(in srgb, var(--alternative-color), transparent 85%); + color: var(--alternative-color); } .policy-group__icon-box--group { @@ -79,13 +79,13 @@ } .policy-group__icon-box--table { - background: color-mix(in srgb, #10b981, transparent 85%); - color: #059669; + background: color-mix(in srgb, var(--success-color), transparent 85%); + color: var(--success-color); } .policy-group__icon-box--dashboard { - background: color-mix(in srgb, #3b82f6, transparent 85%); - color: #1d4ed8; + background: color-mix(in srgb, var(--info-color), transparent 85%); + color: var(--info-color); } @media (prefers-color-scheme: dark) { @@ -102,13 +102,18 @@ } .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.6); + color: rgba(0, 0, 0, 0.64); margin-left: auto; + height: 20px; + width: 20px; } .policy-group__chevron { @@ -116,17 +121,18 @@ width: 20px; height: 20px; opacity: 0.4; + transform: rotate(-90deg); transition: transform 0.2s ease; } .policy-group__chevron--collapsed { - transform: rotate(-90deg); + transform: rotate(0deg); } @media (prefers-color-scheme: dark) { .policy-group__count { background: rgba(255, 255, 255, 0.08); - color: rgba(255, 255, 255, 0.6); + color: rgba(255, 255, 255, 0.64); } } @@ -217,9 +223,9 @@ /* ── Option hints ── */ -.policy-option--used { +/* .policy-option--used { background: color-mix(in srgb, #3b82f6, transparent 92%); -} +} */ .policy-option--used[data-hint]::after { content: attr(data-hint); @@ -229,11 +235,11 @@ font-style: italic; } -@media (prefers-color-scheme: dark) { +/* @media (prefers-color-scheme: dark) { .policy-option--used { background: rgba(59, 130, 246, 0.1); } -} +} */ .add-policy-button { align-self: flex-start; 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) { From 2bbd28abc4817015be6f2e3df97f9aa72249bbb5 Mon Sep 17 00:00:00 2001 From: Lyubov Voloshko Date: Wed, 25 Mar 2026 10:19:39 +0000 Subject: [PATCH 3/5] permissions: fix badge colors --- .../src/app/components/users/users.component.css | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/components/users/users.component.css b/frontend/src/app/components/users/users.component.css index 765b61f3e..a5f857f28 100644 --- a/frontend/src/app/components/users/users.component.css +++ b/frontend/src/app/components/users/users.component.css @@ -69,8 +69,8 @@ header { letter-spacing: 0.5px; padding: 2px 6px; border-radius: 10px; - background: color-mix(in srgb, var(--mdc-filled-button-container-color, #6750a4), transparent 88%); - color: var(--mdc-filled-button-container-color, #6750a4); + background: color-mix(in srgb, var(--alternative-color), transparent 88%); + color: var(--alternative-color); margin-left: 8px; } @@ -88,8 +88,8 @@ header { width: 20px; height: 20px; border-radius: 50%; - background: color-mix(in srgb, #6366f1, transparent 85%); - color: #4f46e5; + background: var(--color-accentedPalette-100); + color: var(--color-accentedPalette-500); font-size: 9px; font-weight: 600; display: flex; @@ -106,8 +106,8 @@ header { @media (prefers-color-scheme: dark) { .group-avatar { - background: rgba(99, 102, 241, 0.2); - color: #a5b4fc; + background: var(--color-accentedPalette-700); + color: var(--color-accentedPalette-100); border-color: var(--mat-expansion-container-background-color, #1e1e1e); } } From a010864d5047b2f9e8d2e47544cfb7e34bb05fb5 Mon Sep 17 00:00:00 2001 From: Lyubov Voloshko Date: Wed, 25 Mar 2026 10:26:43 +0000 Subject: [PATCH 4/5] permissions: move Add policy to the actions footer and scroll dialog to the button on Add policy click --- .../cedar-policy-editor-dialog.component.css | 4 ++++ .../cedar-policy-editor-dialog.component.html | 10 ++++++++-- .../cedar-policy-editor-dialog.component.ts | 14 +++++++++++++- .../cedar-policy-list.component.html | 4 ---- 4 files changed, 25 insertions(+), 7 deletions(-) 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 9564be4c6..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 @@ -33,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 0920e849d..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,6 +1,6 @@

Policy — {{ data.groupTitle }}

- +
Form @@ -35,7 +35,13 @@

Policy — {{ data.groupTitle }}

- + + +
-
From 9c06734518ba75b9b403450b062eb843f703351d Mon Sep 17 00:00:00 2001 From: Lyubov Voloshko Date: Wed, 25 Mar 2026 10:47:46 +0000 Subject: [PATCH 5/5] permissions: fix unit tests --- .../cedar-policy-editor-dialog.component.spec.ts | 3 +++ 1 file changed, 3 insertions(+) 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 = [