import { Component, Inject, ChangeDetectorRef } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatChipsModule, MatChipInputEvent } from '@angular/material/chips'; import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { COMMA, ENTER, SPACE } from '@angular/cdk/keycodes'; import { BreakpointObserver } from '@angular/cdk/layout'; import { I18nService } from '../services/i18n.service'; import { CommonService } from '../services/common.service'; import { DialogCancelButtonComponent } from '../components/dialog-cancel-button.component'; export interface AclUserDialogData { username: string; rules: string; isNew: boolean; } export interface AclUserDialogResult { username: string; rules: string[]; } @Component({ selector: 'p3xr-acl-user-dialog', standalone: true, imports: [ FormsModule, MatDialogModule, MatFormFieldModule, MatInputModule, MatButtonModule, MatIconModule, MatToolbarModule, MatTooltipModule, MatSlideToggleModule, MatCheckboxModule, MatChipsModule, MatAutocompleteModule, DialogCancelButtonComponent, ], template: ` {{ data.isNew ? (strings().page?.acl?.createUser) : (strings().page?.acl?.editUser) }} {{ strings().page?.acl?.username }} @if (username === 'default') {
info {{ strings().page?.acl?.defaultUserWarning }}
}
{{ strings().page?.acl?.enabled }}
{{ strings().page?.acl?.noPassword }}
@if (!nopass) { {{ strings().page?.acl?.password }} @if (!data.isNew) { {{ strings().page?.acl?.passwordHint }} } } {{ strings().page?.acl?.commands }} @for (rule of commandsList; track rule) { {{ rule }} } @for (group of filteredCmdGroups; track group.name) { @for (opt of group.options; track opt) { {{ opt }} } } {{ strings().page?.acl?.commandsHint }} {{ strings().page?.acl?.keys }} @for (pattern of keysList; track pattern) { {{ pattern }} } @for (opt of filteredKeyOptions; track opt) { {{ opt }} } {{ strings().page?.acl?.keysHint }} {{ strings().page?.acl?.channels }} @for (pattern of channelsList; track pattern) { {{ pattern }} } @for (opt of filteredChanOptions; track opt) { {{ opt }} } {{ strings().page?.acl?.channelsHint }}
`, styles: [` .md-block { width: 100%; } .p3xr-acl-default-warning { display: flex; align-items: flex-start; gap: 8px; padding: 12px; margin-bottom: 16px; border-radius: 4px; background-color: var(--p3xr-btn-warn-bg); color: var(--p3xr-btn-warn-color); font-size: 13px; line-height: 1.4; } .p3xr-acl-default-warning mat-icon { flex-shrink: 0; } mat-chip-row { font-size: 13px; --mdc-chip-elevated-container-color: var(--p3xr-btn-primary-bg) !important; --mdc-chip-label-text-color: var(--p3xr-btn-primary-color) !important; --mat-chip-elevated-selected-container-color: var(--p3xr-btn-primary-bg) !important; --mat-chip-selected-label-text-color: var(--p3xr-btn-primary-color) !important; --mat-chip-selected-trailing-icon-color: var(--p3xr-btn-primary-color) !important; } .p3xr-chip-deny { --mdc-chip-elevated-container-color: var(--p3xr-btn-warn-bg) !important; --mdc-chip-label-text-color: var(--p3xr-btn-warn-color) !important; --mat-chip-elevated-selected-container-color: var(--p3xr-btn-warn-bg) !important; --mat-chip-selected-label-text-color: var(--p3xr-btn-warn-color) !important; --mat-chip-selected-trailing-icon-color: var(--p3xr-btn-warn-color) !important; } `], }) export class AclUserDialogComponent { strings; username: string; enabled = true; nopass = false; password = ''; commandsList: string[] = []; keysList: string[] = []; channelsList: string[] = []; isWide = true; readonly separatorKeys = [ENTER, COMMA, SPACE]; private readonly cmdGroupDefs = [ { key: 'groupCommon', options: ['+@all', '-@all', '+@read', '-@read', '+@write', '-@write', '+@admin', '-@admin', '+@dangerous', '-@dangerous'] }, { key: 'groupDataTypes', options: ['+@string', '+@hash', '+@list', '+@set', '+@sortedset', '+@stream', '+@geo', '+@bitmap', '+@hyperloglog'] }, { key: 'groupOperations', options: ['+@keyspace', '+@pubsub', '+@connection', '+@transaction', '+@scripting', '+@fast', '+@slow', '+@blocking'] }, ]; private readonly allKeyOptions = ['~*', '%R~*', '%W~*', 'resetkeys']; private readonly allChanOptions = ['&*', 'resetchannels']; filteredCmdGroups: { name: string; options: string[] }[] = []; filteredKeyOptions: string[] = []; filteredChanOptions: string[] = []; constructor( @Inject(MAT_DIALOG_DATA) public data: AclUserDialogData, @Inject(MatDialogRef) private dialogRef: MatDialogRef, @Inject(BreakpointObserver) private breakpointObserver: BreakpointObserver, @Inject(I18nService) private i18n: I18nService, @Inject(CommonService) private common: CommonService, @Inject(ChangeDetectorRef) private cdr: ChangeDetectorRef, ) { this.strings = this.i18n.strings; this.username = data.username; this.parseRules(data.rules); this.breakpointObserver.observe('(min-width: 600px)').subscribe(r => { this.isWide = r.matches; this.cdr.markForCheck(); }); } filterOptions(type: 'cmd' | 'key' | 'chan', event: Event | null): void { const filter = event ? (event.target as HTMLInputElement).value.toLowerCase() : ''; if (type === 'cmd') { const acl = this.strings()?.page?.acl || {} as any; this.filteredCmdGroups = this.cmdGroupDefs .map(g => ({ name: acl[g.key] || g.key, options: g.options.filter(o => !this.commandsList.includes(o) && o.toLowerCase().includes(filter)) })) .filter(g => g.options.length > 0); } else if (type === 'key') { this.filteredKeyOptions = this.allKeyOptions.filter(o => !this.keysList.includes(o) && o.toLowerCase().includes(filter)); } else { this.filteredChanOptions = this.allChanOptions.filter(o => !this.channelsList.includes(o) && o.toLowerCase().includes(filter)); } } selectAutocomplete(list: string[], event: MatAutocompleteSelectedEvent, input: HTMLInputElement): void { const value = event.option.viewValue; if (value && !list.includes(value)) list.push(value); input.value = ''; this.filterOptions(list === this.commandsList ? 'cmd' : list === this.keysList ? 'key' : 'chan', null); } addChip(list: string[], event: MatChipInputEvent): void { const value = (event.value || '').trim(); if (value && !list.includes(value)) list.push(value); event.chipInput!.clear(); } removeChip(list: string[], value: string): void { const idx = list.indexOf(value); if (idx >= 0) list.splice(idx, 1); } private parseRules(rules: string): void { const tokens = rules.trim().split(/\s+/).filter(Boolean); for (const t of tokens) { if (t === 'on') this.enabled = true; else if (t === 'off') this.enabled = false; else if (t === 'nopass') this.nopass = true; else if (t.startsWith('>') || t.startsWith('<') || t.startsWith('#') || t === 'resetpass' || t === 'sanitize-payload' || t === 'skip-sanitize-payload') continue; else if (t.startsWith('+') || t.startsWith('-') || t === 'allcommands' || t === 'nocommands') this.commandsList.push(t); else if (t.startsWith('~') || t.startsWith('%') || t === 'allkeys' || t === 'resetkeys') this.keysList.push(t); else if (t.startsWith('&') || t === 'allchannels' || t === 'resetchannels') this.channelsList.push(t); } } async onSave(): Promise { const u = this.username?.trim(); if (!u) return; try { await this.common.confirm({ message: this.strings().intention?.areYouSure }); } catch { return; } const rules: string[] = [this.enabled ? 'on' : 'off']; // When editing, reset permissions first so removals take effect if (!this.data.isNew) { rules.push('nocommands', 'resetkeys', 'resetchannels'); if (this.nopass) rules.push('resetpass', 'nopass'); else if (this.password.trim()) rules.push('resetpass', '>' + this.password.trim()); // No password change → existing passwords preserved (no resetpass sent) } else { if (this.nopass) rules.push('nopass'); else if (this.password.trim()) rules.push('>' + this.password.trim()); } rules.push(...this.commandsList, ...this.keysList, ...this.channelsList); this.dialogRef.close({ username: u, rules } as AclUserDialogResult); } onCancel(): void { this.dialogRef.close(undefined); } }