feat: user impersonation

This commit is contained in:
harsh mahajan
2026-04-23 16:14:43 +05:30
parent cf68f82f8f
commit ff596962d9
8 changed files with 583 additions and 2 deletions
+128
View File
@@ -0,0 +1,128 @@
import { writable } from 'svelte/store';
import { building } from '$app/environment';
const KEY_TARGET_USER_ID = 'console.impersonation.targetUserId';
const KEY_OPERATOR = 'console.impersonation.operator';
const KEY_TARGET = 'console.impersonation.target';
export type OperatorSnapshot = {
$id: string;
name: string;
email: string;
};
export type TargetSnapshot = {
$id: string;
name: string;
email: string;
};
/**
* Incrementing revision triggers reactive re-fetches after impersonation changes.
* Consumers can depend() on this or subscribe to it.
*/
export const impersonationRevision = writable(0);
function bumpRevision() {
impersonationRevision.update((n) => n + 1);
}
/**
* All SDK clients that need impersonation headers applied/cleared.
* Populated lazily by sdk.ts via registerImpersonationClients().
*/
let _clients: import('@appwrite.io/console').Client[] = [];
export function registerImpersonationClients(
clients: import('@appwrite.io/console').Client[]
): void {
_clients = clients;
}
export function applyImpersonation(targetUserId: string): void {
for (const client of _clients) {
client.setImpersonateUserId(targetUserId);
}
}
export function clearImpersonation(): void {
for (const client of _clients) {
// Use the typed setters to empty the values so the SDK removes the headers cleanly.
client.setImpersonateUserId('');
client.setImpersonateUserEmail('');
client.setImpersonateUserPhone('');
}
}
export function persistImpersonation(
target: TargetSnapshot,
operator: OperatorSnapshot
): void {
sessionStorage.setItem(KEY_TARGET_USER_ID, target.$id);
sessionStorage.setItem(KEY_TARGET, JSON.stringify(target));
sessionStorage.setItem(KEY_OPERATOR, JSON.stringify(operator));
}
export function clearPersistedImpersonation(): void {
sessionStorage.removeItem(KEY_TARGET_USER_ID);
sessionStorage.removeItem(KEY_TARGET);
sessionStorage.removeItem(KEY_OPERATOR);
}
export function readTargetSnapshot(): TargetSnapshot | null {
if (building) return null;
const raw = sessionStorage.getItem(KEY_TARGET);
if (!raw) return null;
try {
return JSON.parse(raw) as TargetSnapshot;
} catch {
return null;
}
}
export function readImpersonationTargetUserId(): string | null {
if (building) return null;
return sessionStorage.getItem(KEY_TARGET_USER_ID);
}
export function readOperatorSnapshot(): OperatorSnapshot | null {
if (building) return null;
const raw = sessionStorage.getItem(KEY_OPERATOR);
if (!raw) return null;
try {
return JSON.parse(raw) as OperatorSnapshot;
} catch {
return null;
}
}
/**
* Call once on app startup (e.g. root layout onMount).
* Re-applies impersonation headers from sessionStorage to all registered clients.
*/
export function restoreImpersonation(): void {
const targetUserId = readImpersonationTargetUserId();
if (targetUserId) {
applyImpersonation(targetUserId);
bumpRevision();
}
}
/**
* Start impersonating a user. Applies headers, persists to sessionStorage,
* and bumps the revision store so callers can invalidate/refetch.
*/
export function startImpersonation(target: TargetSnapshot, operator: OperatorSnapshot): void {
applyImpersonation(target.$id);
persistImpersonation(target, operator);
bumpRevision();
}
/**
* Stop impersonating. Clears headers, removes sessionStorage, bumps revision.
*/
export function stopImpersonation(): void {
clearImpersonation();
clearPersistedImpersonation();
bumpRevision();
}
@@ -0,0 +1,64 @@
<script lang="ts">
import { goto, invalidate } from '$app/navigation';
import { base } from '$app/paths';
import { Dependencies } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { HeaderAlert } from '$lib/layout';
import {
readImpersonationTargetUserId,
readTargetSnapshot,
readOperatorSnapshot,
stopImpersonation,
impersonationRevision
} from '$lib/appwrite/impersonation';
import { user } from '$lib/stores/user';
// Re-read sessionStorage whenever the revision bumps (start/stop) or $user changes
// (account invalidated after impersonation). Without this the plain function calls
// only execute once on mount and show stale data from previous sessions.
let targetSnapshot = readTargetSnapshot();
let operatorSnapshot = readOperatorSnapshot();
let targetId = readImpersonationTargetUserId();
$: {
void $impersonationRevision;
void $user;
targetSnapshot = readTargetSnapshot();
operatorSnapshot = readOperatorSnapshot();
targetId = readImpersonationTargetUserId();
}
$: isImpersonating = !!$user?.impersonatorUserId || !!targetId;
// Effective user: always from the stored target snapshot (written at pick-time from
// the search result, so it's always the correct selected user identity).
$: effectiveLabel = targetSnapshot
? (targetSnapshot.name || targetSnapshot.email || targetSnapshot.$id)
: $user?.impersonatorUserId
? ($user?.name || $user?.email || $user?.$id)
: targetId ?? 'unknown';
// Operator: always from the stored operator snapshot (written at pick-time from $user,
// which is the real operator session before the header is applied).
$: operatorLabel = operatorSnapshot
? (operatorSnapshot.name || operatorSnapshot.email || operatorSnapshot.$id)
: ($user?.impersonatorUserId ?? 'operator');
async function exit() {
stopImpersonation();
await invalidate(Dependencies.ACCOUNT);
await invalidate(Dependencies.ORGANIZATIONS);
await goto(base);
}
</script>
{#if isImpersonating}
<HeaderAlert type="warning" title="Impersonation active">
<svelte:fragment>
You are operating as <b>{effectiveLabel}</b> on behalf of operator
<b>{operatorLabel}</b>.
</svelte:fragment>
<svelte:fragment slot="buttons">
<Button secondary on:click={exit}>Exit impersonation</Button>
</svelte:fragment>
</HeaderAlert>
{/if}
@@ -0,0 +1,341 @@
<script lang="ts">
import { goto, invalidate } from '$app/navigation';
import { base } from '$app/paths';
import { Query, type Models } from '@appwrite.io/console';
import { Layout, Modal, Typography, Icon, Spinner, Tag } from '@appwrite.io/pink-svelte';
import { IconDuplicate, IconSearch } from '@appwrite.io/pink-icons-svelte';
import { Dependencies } from '$lib/constants';
import { sdk } from '$lib/stores/sdk';
import { user } from '$lib/stores/user';
import { AvatarInitials, Copy } from '$lib/components';
import {
readOperatorSnapshot,
startImpersonation,
readImpersonationTargetUserId
} from '$lib/appwrite/impersonation';
import type { OperatorSnapshot, TargetSnapshot } from '$lib/appwrite/impersonation';
export let show = false;
const RECENTS_KEY_PREFIX = 'console.impersonation.recents.';
const MAX_RECENTS = 5;
type RecentTarget = { $id: string; name: string; email: string };
function recentsKey(): string | null {
const op = readOperatorSnapshot();
const id = op?.$id ?? $user?.$id;
return id ? `${RECENTS_KEY_PREFIX}${id}` : null;
}
function readRecents(): RecentTarget[] {
const key = recentsKey();
if (!key) return [];
try {
return JSON.parse(localStorage.getItem(key) ?? '[]');
} catch {
return [];
}
}
function saveRecent(target: { $id: string; name: string; email: string }) {
const key = recentsKey();
if (!key) return;
const existing = readRecents().filter((r) => r.$id !== target.$id);
const updated: RecentTarget[] = [
{ $id: target.$id, name: target.name, email: target.email },
...existing
].slice(0, MAX_RECENTS);
localStorage.setItem(key, JSON.stringify(updated));
}
let search = '';
let results: Models.User<Record<string, string>>[] = [];
let loading = false;
let debounceTimer: ReturnType<typeof setTimeout>;
let recents: RecentTarget[] = [];
$: if (show) {
recents = readRecents();
}
function debounce(fn: () => void, delay = 300) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(fn, delay);
}
async function fetchUsers(prefix: string) {
if (!prefix.trim()) {
results = [];
return;
}
loading = true;
try {
const response = await sdk.forConsole.users.list({
queries: [
Query.or([
Query.startsWith('name', prefix),
Query.startsWith('email', prefix),
Query.startsWith('phone', prefix),
Query.startsWith('$id', prefix)
]),
Query.orderDesc('$createdAt'),
Query.limit(25),
Query.offset(0)
]
});
results = response.users;
} catch {
results = [];
} finally {
loading = false;
}
}
$: debounce(() => fetchUsers(search));
$: activeAccountId = $user?.$id;
$: operatorId = readOperatorSnapshot()?.$id ?? null;
$: impersonatingId = readImpersonationTargetUserId();
function isDisabled(id: string): boolean {
return id === activeAccountId || (!!operatorId && id === operatorId);
}
function disabledLabel(id: string): string {
if (id === activeAccountId) return 'Current';
if (operatorId && id === operatorId) return 'Operator';
return '';
}
async function selectUser(target: { $id: string; name: string; email: string }) {
if (isDisabled(target.$id)) return;
const existingOperator = readOperatorSnapshot();
const operator: OperatorSnapshot = existingOperator ?? {
$id: $user.$id,
name: $user.name,
email: $user.email
};
const targetSnapshot: TargetSnapshot = {
$id: target.$id,
name: target.name,
email: target.email
};
saveRecent(target);
startImpersonation(targetSnapshot, operator);
search = '';
results = [];
show = false;
await invalidate(Dependencies.ACCOUNT);
await invalidate(Dependencies.ORGANIZATIONS);
await goto(base);
}
function displayName(u: { name: string; email: string; $id: string }): string {
return u.name || u.email || u.$id;
}
$: listToShow = search.trim() ? results : [];
$: showRecents = !search.trim() && recents.length > 0;
$: showNoResults = !!search.trim() && !loading && results.length === 0;
</script>
<Modal size="s" title="Impersonate user" bind:open={show} hideFooter>
<svelte:fragment slot="description">
Matches the start of name, email, phone, or user ID. The Console runs with the selected
account's access until you end impersonation.
</svelte:fragment>
<Layout.Stack gap="m">
<!-- Search input -->
<div class="search-wrap">
<span class="search-icon-wrap">
<Icon icon={IconSearch} size="s" />
</span>
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus
class="search-field"
type="search"
placeholder="Name, email, phone, or user ID..."
bind:value={search} />
{#if loading}
<span class="search-end">
<Spinner size="s" />
</span>
{/if}
</div>
<!-- No results -->
{#if showNoResults}
<Typography.Text>No users found</Typography.Text>
{/if}
<!-- Search results or recents -->
{#if showRecents}
<Typography.Text variant="m-500">Recent</Typography.Text>
{/if}
{#each (listToShow.length ? listToShow : showRecents ? recents : []) as item (item.$id)}
{@const disabled = isDisabled(item.$id)}
{@const label = disabledLabel(item.$id)}
{@const active = item.$id === impersonatingId}
<!-- svelte-ignore a11y_interactive_supports_focus -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
role="button"
class="user-item"
class:is-disabled={disabled}
on:click={() => selectUser(item)}>
<AvatarInitials name={displayName(item)} size="m" />
<div class="user-details">
<Typography.Text variant="m-500">{displayName(item)}</Typography.Text>
{#if item.email && item.email !== displayName(item)}
<Typography.Text>{item.email}</Typography.Text>
{/if}
<!-- ID copy row — clicks stop propagation so they don't trigger selectUser -->
<div class="id-row" role="presentation" on:click|stopPropagation>
<Copy value={item.$id} event="user_impersonate_id">
<Tag size="xs" variant="code">
<Icon size="s" icon={IconDuplicate} slot="start" />
{item.$id}
</Tag>
</Copy>
</div>
</div>
{#if label}
<span class="badge">{label}</span>
{:else if active}
<span class="badge badge-active">Active</span>
{/if}
</div>
{/each}
</Layout.Stack>
</Modal>
<style>
/* Search bar */
.search-wrap {
display: flex;
align-items: center;
gap: 0.5rem;
border: 1px solid hsl(var(--color-neutral-20));
border-radius: var(--border-radius-s, 6px);
padding-inline: 0.75rem;
background: transparent;
}
:global(.theme-dark) .search-wrap {
border-color: hsl(var(--color-neutral-70));
}
.search-icon-wrap {
display: flex;
flex-shrink: 0;
color: hsl(var(--color-neutral-50));
}
.search-field {
flex: 1;
border: none;
outline: none;
background: transparent;
padding-block: 0.625rem;
font-size: var(--font-size-1, 0.875rem);
color: inherit;
min-width: 0;
}
.search-field::placeholder {
color: hsl(var(--color-neutral-50));
}
/* Hide native clear button — we rely on the user clearing manually */
.search-field::-webkit-search-cancel-button {
-webkit-appearance: none;
}
.search-end {
display: flex;
flex-shrink: 0;
}
/* User item */
.user-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem;
border-radius: var(--border-radius-m, 8px);
border: 1px solid hsl(var(--color-neutral-10));
cursor: pointer;
transition: background 0.1s ease;
}
:global(.theme-dark) .user-item {
border-color: hsl(var(--color-neutral-80));
}
.user-item:not(.is-disabled):hover {
background: hsl(var(--color-neutral-5));
}
:global(.theme-dark) .user-item:not(.is-disabled):hover {
background: hsl(var(--color-neutral-85));
}
.user-item.is-disabled {
opacity: 0.45;
cursor: default;
pointer-events: none;
}
/* Re-enable pointer events on the ID copy row even when item is disabled
so the user can still copy the ID if needed */
.user-item.is-disabled .id-row {
pointer-events: auto;
}
.user-details {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.id-row {
margin-top: 0.25rem;
}
/* Badges */
.badge {
flex-shrink: 0;
align-self: center;
font-size: var(--font-size-0, 0.75rem);
padding: 0.125rem 0.5rem;
border-radius: 999px;
background: hsl(var(--color-neutral-10));
color: hsl(var(--color-neutral-60));
white-space: nowrap;
}
:global(.theme-dark) .badge {
background: hsl(var(--color-neutral-80));
color: hsl(var(--color-neutral-40));
}
.badge-active {
background: hsl(var(--color-success-10));
color: hsl(var(--color-success-60));
}
:global(.theme-dark) .badge-active {
background: hsl(var(--color-success-80));
color: hsl(var(--color-success-30));
}
</style>
+27
View File
@@ -50,6 +50,9 @@
import { page } from '$app/state';
import type { Models } from '@appwrite.io/console';
import { isProjectBlocked as getIsProjectBlocked } from '$lib/helpers/project';
import ImpersonationPicker from '$lib/components/impersonation/picker.svelte';
import { readImpersonationTargetUserId } from '$lib/appwrite/impersonation';
import { IconEye } from '@appwrite.io/pink-icons-svelte';
let showSupport = false;
@@ -111,6 +114,14 @@
$: currentOrg = organizations.find((org) => org.isSelected);
$: isProjectBlocked = getIsProjectBlocked(currentProject);
// Show impersonation picker when the operator has the capability or impersonation is active.
$: canImpersonate =
$user?.impersonator === true ||
!!$user?.impersonatorUserId ||
!!readImpersonationTargetUserId();
let showImpersonationPicker = false;
beforeNavigate(() => (showAccountMenu = false));
</script>
@@ -198,6 +209,22 @@
</svelte:fragment>
</DropList>
</Layout.Stack>
{#if canImpersonate}
<ImpersonationPicker bind:show={showImpersonationPicker} />
<Tooltip disabled={showImpersonationPicker}>
<Button.Button
variant="text"
aria-label="Impersonate user"
on:click={(event) => {
showImpersonationPicker = true;
(event.currentTarget as HTMLElement).blur();
}}
icon>
<Icon icon={IconEye} />
</Button.Button>
<span slot="tooltip">Impersonate user</span>
</Tooltip>
{/if}
<Layout.Stack direction="row">
<Tooltip>
<Button.Button
+6
View File
@@ -4,8 +4,14 @@ import { Dependencies } from '$lib/constants';
import { Submit, trackEvent } from '$lib/actions/analytics';
import { base } from '$app/paths';
import { uploader } from '$lib/stores/uploader';
import { stopImpersonation, readImpersonationTargetUserId } from '$lib/appwrite/impersonation';
export async function logout(redirect: boolean = true) {
// Clear impersonation before deleting the real session so the server
// sees the operator's session, not an impersonated one.
if (readImpersonationTargetUserId()) {
stopImpersonation();
}
await sdk.forConsole.account.deleteSession({ sessionId: 'current' });
await invalidate(Dependencies.ACCOUNT);
uploader.reset();
+2 -2
View File
@@ -29,8 +29,8 @@
function setNavigationHeight() {
const alertHeight = container ? container.getBoundingClientRect().height : 0;
const header: HTMLHeadingElement = document.querySelector('main > header');
const sidebar: HTMLElement = document.querySelector('main > div > nav');
const contentSection: HTMLElement = document.querySelector('main > div > section');
const sidebar: HTMLElement = document.querySelector('main nav');
const contentSection: HTMLElement = document.querySelector('main .main-content');
if (alertHeight) {
// for sidebar and sub-navigation!
+3
View File
@@ -1,4 +1,5 @@
import { isMultiRegionSupported, VARS } from '$lib/system';
import { registerImpersonationClients } from '$lib/appwrite/impersonation';
import {
Account,
Assistant,
@@ -78,6 +79,8 @@ if (!building) {
clientProject.setEndpoint(endpoint).setMode('admin');
clientRealtime.setEndpoint(endpoint).setProject('console');
registerImpersonationClients([clientConsole, scopedConsoleClient, clientProject, clientRealtime]);
}
const sdkForProject = {
+12
View File
@@ -13,6 +13,9 @@
import { sdk } from '$lib/stores/sdk';
import { user } from '$lib/stores/user';
import { loading } from '$routes/store';
import { restoreImpersonation } from '$lib/appwrite/impersonation';
import { headerAlert } from '$lib/stores/headerAlert';
import ImpersonationBanner from '$lib/components/impersonation/banner.svelte';
import { Root } from '@appwrite.io/pink-svelte';
import { ThemeDark, ThemeLight, ThemeDarkCloud, ThemeLightCloud } from '../themes';
import { isSmallViewport, updateViewport } from '$lib/stores/viewport';
@@ -29,6 +32,15 @@
}
onMount(async () => {
restoreImpersonation();
headerAlert.add({
id: 'impersonation',
component: ImpersonationBanner,
show: true,
importance: 100 // highest priority — always visible when active
});
updateViewport();
// handle sources
if (isCloud) {