mirror of
https://github.com/appwrite/console.git
synced 2026-06-06 19:27:48 +00:00
feat: user impersonation
This commit is contained in:
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user