diff --git a/src/lib/appwrite/impersonation.ts b/src/lib/appwrite/impersonation.ts new file mode 100644 index 000000000..923dc429a --- /dev/null +++ b/src/lib/appwrite/impersonation.ts @@ -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(); +} diff --git a/src/lib/components/impersonation/banner.svelte b/src/lib/components/impersonation/banner.svelte new file mode 100644 index 000000000..b0e606bdd --- /dev/null +++ b/src/lib/components/impersonation/banner.svelte @@ -0,0 +1,64 @@ + + +{#if isImpersonating} + + + You are operating as {effectiveLabel} on behalf of operator + {operatorLabel}. + + + + + +{/if} diff --git a/src/lib/components/impersonation/picker.svelte b/src/lib/components/impersonation/picker.svelte new file mode 100644 index 000000000..a2ec6269f --- /dev/null +++ b/src/lib/components/impersonation/picker.svelte @@ -0,0 +1,341 @@ + + + + + Matches the start of name, email, phone, or user ID. The Console runs with the selected + account's access until you end impersonation. + + + + +
+ + + + + + {#if loading} + + + + {/if} +
+ + + {#if showNoResults} + No users found + {/if} + + + {#if showRecents} + Recent + {/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} + + +
selectUser(item)}> + +
+ {displayName(item)} + {#if item.email && item.email !== displayName(item)} + {item.email} + {/if} + + +
+ {#if label} + {label} + {:else if active} + Active + {/if} +
+ {/each} +
+
+ + diff --git a/src/lib/components/navbar.svelte b/src/lib/components/navbar.svelte index ce5b64a2b..e13cb2fb8 100644 --- a/src/lib/components/navbar.svelte +++ b/src/lib/components/navbar.svelte @@ -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)); @@ -198,6 +209,22 @@ + {#if canImpersonate} + + + { + showImpersonationPicker = true; + (event.currentTarget as HTMLElement).blur(); + }} + icon> + + + Impersonate user + + {/if} 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! diff --git a/src/lib/stores/sdk.ts b/src/lib/stores/sdk.ts index d5ace1568..d60f6529f 100644 --- a/src/lib/stores/sdk.ts +++ b/src/lib/stores/sdk.ts @@ -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 = { diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 2a80bb1e0..dd032cad1 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -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) {