Skip to content

Commit b61111d

Browse files
authored
feat(impersonation): remember recently impersonated profiles (#622)
* feat(impersonation): remember recently impersonated profiles Persist past impersonations in localStorage and surface them as suggestions when the admin clicks the input. Restores the persona context that was last used with each profile on selection. SSR-safe: localStorage access is gated by isPlatformBrowser and falls back to an empty list during server rendering. Signed-off-by: Asitha de Silva <asithade@gmail.com> * fix(review): address PR #622 review feedback Address review comments from copilot[bot], coderabbitai: - impersonation-dialog.component.ts: read AutoCompleteSelectEvent.value as the full RecentImpersonation object (PrimeNG emits the option object on select even when optionValue is configured), so personaContext is actually restored (per copilot[bot]) - impersonation-dialog.component.ts: clear the restored personaContext via a takeUntilDestroyed valueChanges subscription as soon as the typed targetUser diverges from the last selected recent (per coderabbitai) - impersonation.service.ts: tighten isValidRecentImpersonation to validate username and the optional name/picture fields used by the dialog UI, so a malformed localStorage entry can't reach .toLowerCase() at filter time (per copilot[bot], coderabbitai) - impersonation-dialog.component.html: drop optionValue and the empty template, and add showEmptyMessage=false so free-text input is accepted and no panel is shown when nothing matches (direct reviewer testing) - autocomplete.component: add showEmptyMessage passthrough input Resolves 4 review threads. Signed-off-by: Asitha de Silva <asithade@gmail.com> --------- Signed-off-by: Asitha de Silva <asithade@gmail.com>
1 parent 5ad7a2f commit b61111d

8 files changed

Lines changed: 192 additions & 13 deletions

File tree

apps/lfx-one/src/app/shared/components/autocomplete/autocomplete.component.html

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
(onClear)="onClear.emit()"
1818
(onBlur)="onBlur.emit()"
1919
[attr.data-testid]="dataTestId()"
20+
[inputId]="inputId()"
2021
[autoOptionFocus]="autoOptionFocus()"
2122
[completeOnFocus]="completeOnFocus()"
2223
[panelStyleClass]="panelStyleClass()"
@@ -26,7 +27,9 @@
2627
[dataKey]="dataKey()"
2728
[appendTo]="appendTo()"
2829
[showClear]="showClear()"
29-
[forceSelection]="forceSelection()">
30+
[forceSelection]="forceSelection()"
31+
[showEmptyMessage]="showEmptyMessage()"
32+
[size]="size()">
3033
<!-- Empty message template -->
3134
@if (emptyTemplate) {
3235
<ng-template #empty>

apps/lfx-one/src/app/shared/components/autocomplete/autocomplete.component.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export class AutocompleteComponent {
2828
public delay = input<number>(300);
2929
public minLength = input<number>(1);
3030
public dataTestId = input<string>();
31+
public inputId = input<string>();
3132
public optionLabel = input<string>();
3233
public optionValue = input<string>();
3334
public autoOptionFocus = input<boolean>(false);
@@ -39,6 +40,8 @@ export class AutocompleteComponent {
3940
public dataKey = input<string>();
4041
public showClear = input<boolean>(false);
4142
public forceSelection = input<boolean>(false);
43+
public showEmptyMessage = input<boolean>(true);
44+
public size = input<'small' | 'large'>('small');
4245

4346
public readonly completeMethod = output<AutoCompleteCompleteEvent>();
4447
public readonly onSelect = output<AutoCompleteSelectEvent>();

apps/lfx-one/src/app/shared/components/impersonation-dialog/impersonation-dialog.component.html

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,49 @@
1313
<p class="text-sm text-gray-600">Enter the email or username of the user you want to view the app as.</p>
1414
<div class="flex flex-col gap-1" (keydown.enter)="submit()">
1515
<label for="targetUser" class="text-sm font-medium text-gray-700">Email or Username</label>
16-
<lfx-input-text
17-
size="small"
18-
[form]="targetUserForm"
19-
control="targetUser"
20-
placeholder="e.g. jdoe or jdoe@example.com"
21-
id="targetUser"
22-
dataTest="impersonation-target-input" />
16+
@if (recentImpersonations().length > 0) {
17+
<lfx-autocomplete
18+
[form]="targetUserForm"
19+
control="targetUser"
20+
[suggestions]="suggestions()"
21+
placeholder="e.g. jdoe or jdoe@example.com"
22+
optionLabel="email"
23+
[minLength]="0"
24+
[showEmptyMessage]="false"
25+
[dropdown]="true"
26+
dropdownMode="blank"
27+
size="small"
28+
appendTo="body"
29+
styleClass="w-full"
30+
inputId="targetUser"
31+
dataTestId="impersonation-target-input"
32+
(completeMethod)="onSearchComplete($event)"
33+
(onSelect)="onSuggestionSelected($event)">
34+
<ng-template #item let-entry>
35+
<div class="flex w-full items-center gap-2">
36+
@if (entry.picture) {
37+
<img [src]="entry.picture" [alt]="entry.name || entry.email" class="h-7 w-7 rounded-full object-cover" />
38+
} @else {
39+
<div class="flex h-7 w-7 items-center justify-center rounded-full bg-gray-100 text-gray-500">
40+
<i class="fa-light fa-user text-xs"></i>
41+
</div>
42+
}
43+
<div class="flex min-w-0 flex-col">
44+
<span class="truncate text-sm">{{ entry.name || entry.username || entry.email }}</span>
45+
<span class="truncate text-xs text-gray-500">{{ entry.email }}</span>
46+
</div>
47+
</div>
48+
</ng-template>
49+
</lfx-autocomplete>
50+
} @else {
51+
<lfx-input-text
52+
size="small"
53+
[form]="targetUserForm"
54+
control="targetUser"
55+
placeholder="e.g. jdoe or jdoe@example.com"
56+
id="targetUser"
57+
dataTest="impersonation-target-input" />
58+
}
2359
</div>
2460
<div class="flex flex-col gap-1">
2561
<label for="personaContext" class="text-sm font-medium text-gray-700">Context (optional)</label>

apps/lfx-one/src/app/shared/components/impersonation-dialog/impersonation-dialog.component.ts

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,21 @@
22
// SPDX-License-Identifier: MIT
33

44
import { Component, inject, signal } from '@angular/core';
5+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
56
import { FormControl, FormGroup, Validators } from '@angular/forms';
7+
import { AutocompleteComponent } from '@components/autocomplete/autocomplete.component';
68
import { ButtonComponent } from '@components/button/button.component';
79
import { InputTextComponent } from '@components/input-text/input-text.component';
810
import { SelectComponent } from '@components/select/select.component';
9-
import { PersonaType } from '@lfx-one/shared/interfaces';
11+
import { PersonaType, RecentImpersonation } from '@lfx-one/shared/interfaces';
1012
import { ImpersonationService } from '@services/impersonation.service';
13+
import { AutoCompleteCompleteEvent, AutoCompleteSelectEvent } from 'primeng/autocomplete';
1114
import { DynamicDialogRef } from 'primeng/dynamicdialog';
1215
import { take } from 'rxjs';
1316

1417
@Component({
1518
selector: 'lfx-impersonation-dialog',
16-
imports: [InputTextComponent, SelectComponent, ButtonComponent],
19+
imports: [AutocompleteComponent, InputTextComponent, SelectComponent, ButtonComponent],
1720
templateUrl: './impersonation-dialog.component.html',
1821
})
1922
export class ImpersonationDialogComponent {
@@ -35,6 +38,22 @@ export class ImpersonationDialogComponent {
3538

3639
protected loading = signal(false);
3740
protected error = signal('');
41+
protected recentImpersonations = signal<RecentImpersonation[]>(this.impersonationService.getRecentImpersonations());
42+
protected suggestions = signal<RecentImpersonation[]>(this.recentImpersonations());
43+
private selectedRecentTargetUser = signal<string | null>(null);
44+
45+
public constructor() {
46+
// Drop the auto-restored persona context as soon as the user steers the target away from
47+
// the recent profile they picked — otherwise submit() would impersonate the new target
48+
// under the previous profile's lens.
49+
this.targetUserForm.controls.targetUser.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => {
50+
const lastSelected = this.selectedRecentTargetUser();
51+
if (lastSelected && value !== lastSelected) {
52+
this.selectedRecentTargetUser.set(null);
53+
this.targetUserForm.controls.personaContext.setValue(null);
54+
}
55+
});
56+
}
3857

3958
public submit(): void {
4059
const target = this.targetUserForm.controls.targetUser.value.trim();
@@ -51,7 +70,15 @@ export class ImpersonationDialogComponent {
5170
.startImpersonation(target, personaContext)
5271
.pipe(take(1))
5372
.subscribe({
54-
next: () => {
73+
next: (response) => {
74+
this.impersonationService.addRecentImpersonation({
75+
targetUser: target,
76+
email: response.targetUser.email,
77+
username: response.targetUser.username,
78+
name: response.targetUser.name,
79+
picture: response.targetUser.picture,
80+
personaContext,
81+
});
5582
this.dialogRef.close(true);
5683
window.location.reload();
5784
},
@@ -67,4 +94,35 @@ export class ImpersonationDialogComponent {
6794
public cancel(): void {
6895
this.dialogRef.close(false);
6996
}
97+
98+
protected onSearchComplete(event: AutoCompleteCompleteEvent): void {
99+
const query = (event.query ?? '').trim().toLowerCase();
100+
const entries = this.recentImpersonations();
101+
102+
if (!query) {
103+
this.suggestions.set([...entries]);
104+
return;
105+
}
106+
107+
this.suggestions.set(
108+
entries.filter(
109+
(r) =>
110+
r.email.toLowerCase().includes(query) ||
111+
r.username?.toLowerCase().includes(query) ||
112+
(r.name?.toLowerCase().includes(query) ?? false) ||
113+
r.targetUser.toLowerCase().includes(query)
114+
)
115+
);
116+
}
117+
118+
protected onSuggestionSelected(event: AutoCompleteSelectEvent): void {
119+
// p-autocomplete writes the full option object into the form on select; replace it with
120+
// the targetUser string so submit() and free-text typing both see a plain string.
121+
const entry = event.value as RecentImpersonation | null;
122+
if (!entry || typeof entry !== 'object') return;
123+
124+
this.selectedRecentTargetUser.set(entry.targetUser);
125+
this.targetUserForm.controls.targetUser.setValue(entry.targetUser);
126+
this.targetUserForm.controls.personaContext.setValue(entry.personaContext ?? null);
127+
}
70128
}

apps/lfx-one/src/app/shared/services/impersonation.service.ts

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,25 @@
11
// Copyright The Linux Foundation and each contributor to LFX.
22
// SPDX-License-Identifier: MIT
33

4+
import { isPlatformBrowser } from '@angular/common';
45
import { HttpClient } from '@angular/common/http';
5-
import { inject, Injectable } from '@angular/core';
6-
import { ImpersonationStartRequest, ImpersonationStartResponse, ImpersonationStatusResponse, PersonaType } from '@lfx-one/shared/interfaces';
6+
import { inject, Injectable, PLATFORM_ID } from '@angular/core';
7+
import { MAX_RECENT_IMPERSONATIONS, RECENT_IMPERSONATIONS_STORAGE_KEY } from '@lfx-one/shared/constants';
8+
import {
9+
ImpersonationStartRequest,
10+
ImpersonationStartResponse,
11+
ImpersonationStatusResponse,
12+
PersonaType,
13+
RecentImpersonation,
14+
} from '@lfx-one/shared/interfaces';
715
import { catchError, Observable, of } from 'rxjs';
816

917
@Injectable({
1018
providedIn: 'root',
1119
})
1220
export class ImpersonationService {
1321
private readonly http = inject(HttpClient);
22+
private readonly platformId = inject(PLATFORM_ID);
1423

1524
public startImpersonation(targetUser: string, personaContext?: PersonaType | null): Observable<ImpersonationStartResponse> {
1625
const body: ImpersonationStartRequest = personaContext ? { targetUser, personaContext } : { targetUser };
@@ -26,4 +35,55 @@ export class ImpersonationService {
2635
.get<ImpersonationStatusResponse>('/api/impersonate/status')
2736
.pipe(catchError(() => of({ impersonating: false } as ImpersonationStatusResponse)));
2837
}
38+
39+
public getRecentImpersonations(): RecentImpersonation[] {
40+
if (!isPlatformBrowser(this.platformId)) {
41+
return [];
42+
}
43+
44+
try {
45+
const raw = window.localStorage.getItem(RECENT_IMPERSONATIONS_STORAGE_KEY);
46+
if (!raw) {
47+
return [];
48+
}
49+
50+
const parsed = JSON.parse(raw);
51+
if (!Array.isArray(parsed)) {
52+
return [];
53+
}
54+
55+
return parsed.filter((entry): entry is RecentImpersonation => this.isValidRecentImpersonation(entry)).sort((a, b) => b.lastUsedAt - a.lastUsedAt);
56+
} catch {
57+
return [];
58+
}
59+
}
60+
61+
public addRecentImpersonation(entry: Omit<RecentImpersonation, 'lastUsedAt'>): void {
62+
if (!isPlatformBrowser(this.platformId)) {
63+
return;
64+
}
65+
66+
try {
67+
const existing = this.getRecentImpersonations().filter((e) => e.email !== entry.email);
68+
const updated: RecentImpersonation[] = [{ ...entry, lastUsedAt: Date.now() }, ...existing].slice(0, MAX_RECENT_IMPERSONATIONS);
69+
window.localStorage.setItem(RECENT_IMPERSONATIONS_STORAGE_KEY, JSON.stringify(updated));
70+
} catch {
71+
// Ignore quota / disabled-storage errors — recent impersonations are a best-effort convenience.
72+
}
73+
}
74+
75+
private isValidRecentImpersonation(entry: unknown): entry is RecentImpersonation {
76+
if (!entry || typeof entry !== 'object') {
77+
return false;
78+
}
79+
const candidate = entry as Partial<RecentImpersonation>;
80+
return (
81+
typeof candidate.targetUser === 'string' &&
82+
typeof candidate.email === 'string' &&
83+
typeof candidate.username === 'string' &&
84+
typeof candidate.lastUsedAt === 'number' &&
85+
(candidate.name === undefined || typeof candidate.name === 'string') &&
86+
(candidate.picture === undefined || typeof candidate.picture === 'string')
87+
);
88+
}
2989
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// Copyright The Linux Foundation and each contributor to LFX.
2+
// SPDX-License-Identifier: MIT
3+
4+
export const RECENT_IMPERSONATIONS_STORAGE_KEY = 'lfx:recent-impersonations';
5+
export const MAX_RECENT_IMPERSONATIONS = 5;

packages/shared/src/constants/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export * from './plausible.constants';
2727
export * from './chart.constants';
2828
export * from './chart-options.constants';
2929
export * from './cookie.constants';
30+
export * from './impersonation.constants';
3031
export * from './mailing-list.constants';
3132
export * from './primeng-theme.constants';
3233
export * from './poll.constants';

packages/shared/src/interfaces/impersonation.interface.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,16 @@ export interface ImpersonationStartResponse {
6161
impersonating: boolean;
6262
targetUser: ImpersonationUser;
6363
}
64+
65+
/**
66+
* A previously impersonated profile, persisted client-side for quick re-selection.
67+
*/
68+
export interface RecentImpersonation {
69+
targetUser: string;
70+
email: string;
71+
username: string;
72+
name?: string;
73+
picture?: string;
74+
personaContext?: PersonaType | null;
75+
lastUsedAt: number;
76+
}

0 commit comments

Comments
 (0)