|
| 1 | +import { Component, Inject, ChangeDetectorRef } from '@angular/core'; |
| 2 | +import { FormsModule } from '@angular/forms'; |
| 3 | +import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; |
| 4 | +import { MatFormFieldModule } from '@angular/material/form-field'; |
| 5 | +import { MatInputModule } from '@angular/material/input'; |
| 6 | +import { MatButtonModule } from '@angular/material/button'; |
| 7 | +import { MatIconModule } from '@angular/material/icon'; |
| 8 | +import { MatToolbarModule } from '@angular/material/toolbar'; |
| 9 | +import { MatTooltipModule } from '@angular/material/tooltip'; |
| 10 | +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; |
| 11 | +import { MatCheckboxModule } from '@angular/material/checkbox'; |
| 12 | +import { MatChipsModule, MatChipInputEvent } from '@angular/material/chips'; |
| 13 | +import { COMMA, ENTER, SPACE } from '@angular/cdk/keycodes'; |
| 14 | +import { BreakpointObserver } from '@angular/cdk/layout'; |
| 15 | +import { I18nService } from '../services/i18n.service'; |
| 16 | +import { DialogCancelButtonComponent } from '../components/dialog-cancel-button.component'; |
| 17 | + |
| 18 | +export interface AclUserDialogData { |
| 19 | + username: string; |
| 20 | + rules: string; |
| 21 | + isNew: boolean; |
| 22 | +} |
| 23 | + |
| 24 | +export interface AclUserDialogResult { |
| 25 | + username: string; |
| 26 | + rules: string[]; |
| 27 | +} |
| 28 | + |
| 29 | +@Component({ |
| 30 | + selector: 'p3xr-acl-user-dialog', |
| 31 | + standalone: true, |
| 32 | + imports: [ |
| 33 | + FormsModule, MatDialogModule, MatFormFieldModule, MatInputModule, |
| 34 | + MatButtonModule, MatIconModule, MatToolbarModule, MatTooltipModule, |
| 35 | + MatSlideToggleModule, MatCheckboxModule, MatChipsModule, |
| 36 | + DialogCancelButtonComponent, |
| 37 | + ], |
| 38 | + template: ` |
| 39 | + <mat-toolbar class="p3xr-dialog-toolbar p3xr-mat-layout-strong"> |
| 40 | + <span mat-dialog-title class="p3xr-dialog-title"> |
| 41 | + {{ data.isNew ? (strings().page?.acl?.createUser || 'Create User') : (strings().page?.acl?.editUser || 'Edit User') }} |
| 42 | + </span> |
| 43 | + <button mat-icon-button type="button" (click)="onCancel()"> |
| 44 | + <mat-icon>close</mat-icon> |
| 45 | + </button> |
| 46 | + </mat-toolbar> |
| 47 | +
|
| 48 | + <mat-dialog-content class="p3xr-dialog-content"> |
| 49 | + <mat-form-field class="md-block"> |
| 50 | + <mat-label>{{ strings().page?.acl?.username || 'Username' }}</mat-label> |
| 51 | + <input matInput [(ngModel)]="username" [disabled]="!data.isNew" /> |
| 52 | + </mat-form-field> |
| 53 | +
|
| 54 | + <div style="margin-bottom: 16px;"> |
| 55 | + <mat-slide-toggle [(ngModel)]="enabled"> |
| 56 | + {{ strings().page?.acl?.enabled || 'Enabled' }} |
| 57 | + </mat-slide-toggle> |
| 58 | + </div> |
| 59 | +
|
| 60 | + <div style="margin-bottom: 12px;"> |
| 61 | + <mat-checkbox [(ngModel)]="nopass"> |
| 62 | + {{ strings().page?.acl?.noPassword || 'No password (nopass)' }} |
| 63 | + </mat-checkbox> |
| 64 | + </div> |
| 65 | +
|
| 66 | + @if (!nopass) { |
| 67 | + <mat-form-field class="md-block"> |
| 68 | + <mat-label>{{ strings().page?.acl?.password || 'Password' }}</mat-label> |
| 69 | + <input matInput [(ngModel)]="password" type="password" autocomplete="new-password" /> |
| 70 | + @if (!data.isNew) { |
| 71 | + <mat-hint>{{ strings().page?.acl?.passwordHint || 'Leave empty to keep current password' }}</mat-hint> |
| 72 | + } |
| 73 | + </mat-form-field> |
| 74 | + } |
| 75 | +
|
| 76 | + <mat-form-field class="md-block"> |
| 77 | + <mat-label>{{ strings().page?.acl?.commands || 'Commands' }}</mat-label> |
| 78 | + <mat-chip-grid #cmdGrid> |
| 79 | + @for (rule of commandsList; track rule) { |
| 80 | + <mat-chip-row (removed)="removeChip(commandsList, rule)" |
| 81 | + [highlighted]="true" |
| 82 | + [class.p3xr-chip-deny]="rule.startsWith('-')"> |
| 83 | + {{ rule }} |
| 84 | + <button matChipRemove><mat-icon>cancel</mat-icon></button> |
| 85 | + </mat-chip-row> |
| 86 | + } |
| 87 | + </mat-chip-grid> |
| 88 | + <input matInput [matChipInputFor]="cmdGrid" |
| 89 | + [matChipInputSeparatorKeyCodes]="separatorKeys" |
| 90 | + (matChipInputTokenEnd)="addChip(commandsList, $event)" |
| 91 | + placeholder="+@all, -@dangerous, +get ..." /> |
| 92 | + <mat-hint>{{ strings().page?.acl?.commandsHint || 'e.g., +@all or +@read -@dangerous' }}</mat-hint> |
| 93 | + </mat-form-field> |
| 94 | +
|
| 95 | + <mat-form-field class="md-block"> |
| 96 | + <mat-label>{{ strings().page?.acl?.keys || 'Key Patterns' }}</mat-label> |
| 97 | + <mat-chip-grid #keyGrid> |
| 98 | + @for (pattern of keysList; track pattern) { |
| 99 | + <mat-chip-row (removed)="removeChip(keysList, pattern)" |
| 100 | + [highlighted]="true"> |
| 101 | + {{ pattern }} |
| 102 | + <button matChipRemove><mat-icon>cancel</mat-icon></button> |
| 103 | + </mat-chip-row> |
| 104 | + } |
| 105 | + </mat-chip-grid> |
| 106 | + <input matInput [matChipInputFor]="keyGrid" |
| 107 | + [matChipInputSeparatorKeyCodes]="separatorKeys" |
| 108 | + (matChipInputTokenEnd)="addChip(keysList, $event)" |
| 109 | + placeholder="~*, ~user:* ..." /> |
| 110 | + <mat-hint>{{ strings().page?.acl?.keysHint || 'e.g., ~* or ~user:*' }}</mat-hint> |
| 111 | + </mat-form-field> |
| 112 | +
|
| 113 | + <mat-form-field class="md-block"> |
| 114 | + <mat-label>{{ strings().page?.acl?.channels || 'Pub/Sub Channels' }}</mat-label> |
| 115 | + <mat-chip-grid #chanGrid> |
| 116 | + @for (pattern of channelsList; track pattern) { |
| 117 | + <mat-chip-row (removed)="removeChip(channelsList, pattern)" |
| 118 | + [highlighted]="true"> |
| 119 | + {{ pattern }} |
| 120 | + <button matChipRemove><mat-icon>cancel</mat-icon></button> |
| 121 | + </mat-chip-row> |
| 122 | + } |
| 123 | + </mat-chip-grid> |
| 124 | + <input matInput [matChipInputFor]="chanGrid" |
| 125 | + [matChipInputSeparatorKeyCodes]="separatorKeys" |
| 126 | + (matChipInputTokenEnd)="addChip(channelsList, $event)" |
| 127 | + placeholder="&*, ¬ifications:* ..." /> |
| 128 | + <mat-hint>{{ strings().page?.acl?.channelsHint || 'e.g., &* or ¬ifications:*' }}</mat-hint> |
| 129 | + </mat-form-field> |
| 130 | + </mat-dialog-content> |
| 131 | +
|
| 132 | + <mat-dialog-actions class="p3xr-dialog-actions"> |
| 133 | + <p3xr-dialog-cancel (cancel)="onCancel()"></p3xr-dialog-cancel> |
| 134 | + <button mat-raised-button class="btn-primary" type="button" (click)="onSave()" |
| 135 | + [disabled]="!username?.trim()" |
| 136 | + [matTooltip]="strings().intention?.save || 'Save'" [matTooltipDisabled]="isWide"> |
| 137 | + <mat-icon>done</mat-icon> |
| 138 | + @if (isWide) { <span>{{ strings().intention?.save || 'Save' }}</span> } |
| 139 | + </button> |
| 140 | + </mat-dialog-actions> |
| 141 | + `, |
| 142 | + styles: [` |
| 143 | + .md-block { width: 100%; } |
| 144 | + mat-chip-row { font-size: 13px; } |
| 145 | + .p3xr-chip-deny { --mdc-chip-elevated-container-color: var(--p3xr-btn-warn-bg, #f44336); --mdc-chip-label-text-color: #fff; } |
| 146 | + `], |
| 147 | +}) |
| 148 | +export class AclUserDialogComponent { |
| 149 | + strings; |
| 150 | + username: string; |
| 151 | + enabled = true; |
| 152 | + nopass = false; |
| 153 | + password = ''; |
| 154 | + commandsList: string[] = []; |
| 155 | + keysList: string[] = []; |
| 156 | + channelsList: string[] = []; |
| 157 | + isWide = true; |
| 158 | + readonly separatorKeys = [ENTER, COMMA, SPACE]; |
| 159 | + |
| 160 | + constructor( |
| 161 | + @Inject(MAT_DIALOG_DATA) public data: AclUserDialogData, |
| 162 | + @Inject(MatDialogRef) private dialogRef: MatDialogRef<AclUserDialogComponent>, |
| 163 | + @Inject(BreakpointObserver) private breakpointObserver: BreakpointObserver, |
| 164 | + @Inject(I18nService) private i18n: I18nService, |
| 165 | + @Inject(ChangeDetectorRef) private cdr: ChangeDetectorRef, |
| 166 | + ) { |
| 167 | + this.strings = this.i18n.strings; |
| 168 | + this.username = data.username; |
| 169 | + this.parseRules(data.rules); |
| 170 | + this.breakpointObserver.observe('(min-width: 600px)').subscribe(r => { |
| 171 | + this.isWide = r.matches; |
| 172 | + this.cdr.markForCheck(); |
| 173 | + }); |
| 174 | + } |
| 175 | + |
| 176 | + addChip(list: string[], event: MatChipInputEvent): void { |
| 177 | + const value = (event.value || '').trim(); |
| 178 | + if (value && !list.includes(value)) list.push(value); |
| 179 | + event.chipInput!.clear(); |
| 180 | + } |
| 181 | + |
| 182 | + removeChip(list: string[], value: string): void { |
| 183 | + const idx = list.indexOf(value); |
| 184 | + if (idx >= 0) list.splice(idx, 1); |
| 185 | + } |
| 186 | + |
| 187 | + private parseRules(rules: string): void { |
| 188 | + const tokens = rules.trim().split(/\s+/).filter(Boolean); |
| 189 | + for (const t of tokens) { |
| 190 | + if (t === 'on') this.enabled = true; |
| 191 | + else if (t === 'off') this.enabled = false; |
| 192 | + else if (t === 'nopass') this.nopass = true; |
| 193 | + else if (t.startsWith('>') || t.startsWith('<') || t.startsWith('#') || t === 'resetpass' || t === 'sanitize-payload' || t === 'skip-sanitize-payload') continue; |
| 194 | + else if (t.startsWith('+') || t.startsWith('-') || t === 'allcommands' || t === 'nocommands') this.commandsList.push(t); |
| 195 | + else if (t.startsWith('~') || t.startsWith('%') || t === 'allkeys' || t === 'resetkeys') this.keysList.push(t); |
| 196 | + else if (t.startsWith('&') || t === 'allchannels' || t === 'resetchannels') this.channelsList.push(t); |
| 197 | + } |
| 198 | + } |
| 199 | + |
| 200 | + onSave(): void { |
| 201 | + const u = this.username?.trim(); |
| 202 | + if (!u) return; |
| 203 | + const rules: string[] = [this.enabled ? 'on' : 'off']; |
| 204 | + // When editing, reset permissions first so removals take effect |
| 205 | + if (!this.data.isNew) { |
| 206 | + rules.push('nocommands', 'resetkeys', 'resetchannels'); |
| 207 | + if (this.nopass) rules.push('resetpass', 'nopass'); |
| 208 | + else if (this.password.trim()) rules.push('resetpass', '>' + this.password.trim()); |
| 209 | + // No password change → existing passwords preserved (no resetpass sent) |
| 210 | + } else { |
| 211 | + if (this.nopass) rules.push('nopass'); |
| 212 | + else if (this.password.trim()) rules.push('>' + this.password.trim()); |
| 213 | + } |
| 214 | + rules.push(...this.commandsList, ...this.keysList, ...this.channelsList); |
| 215 | + this.dialogRef.close({ username: u, rules } as AclUserDialogResult); |
| 216 | + } |
| 217 | + |
| 218 | + onCancel(): void { |
| 219 | + this.dialogRef.close(undefined); |
| 220 | + } |
| 221 | +} |
0 commit comments