From 5dbb705f3e4178a8c7616f091d25fcc4e451748b Mon Sep 17 00:00:00 2001 From: Marton Schneider Date: Sat, 11 Jul 2026 20:08:38 +0200 Subject: [PATCH 1/3] feat: permanent console user deletion for self-hosted Fixes appwrite/appwrite#11393 Self-hosted organization owners can permanently delete platform (console) accounts, not only remove org memberships. Adds a Platform users page and an optional permanent-delete path when removing members. --- src/lib/actions/analytics.ts | 1 + src/lib/constants.ts | 1 + src/lib/helpers/consoleUsers.test.ts | 86 ++++++++++++ src/lib/helpers/consoleUsers.ts | 53 ++++++++ src/lib/stores/sdk.ts | 18 +++ .../deleteMember.svelte | 114 +++++++++++++--- .../organization-[organization]/header.svelte | 10 +- .../platform-users/+page.svelte | 125 ++++++++++++++++++ .../platform-users/+page.ts | 40 ++++++ .../platform-users/deleteConsoleUser.svelte | 86 ++++++++++++ 10 files changed, 514 insertions(+), 20 deletions(-) create mode 100644 src/lib/helpers/consoleUsers.test.ts create mode 100644 src/lib/helpers/consoleUsers.ts create mode 100644 src/routes/(console)/organization-[organization]/platform-users/+page.svelte create mode 100644 src/routes/(console)/organization-[organization]/platform-users/+page.ts create mode 100644 src/routes/(console)/organization-[organization]/platform-users/deleteConsoleUser.svelte diff --git a/src/lib/actions/analytics.ts b/src/lib/actions/analytics.ts index e7e014de9e..d76b8f6854 100644 --- a/src/lib/actions/analytics.ts +++ b/src/lib/actions/analytics.ts @@ -262,6 +262,7 @@ export enum Submit { ProjectResume = 'submit_project_resume', MemberCreate = 'submit_member_create', MemberDelete = 'submit_member_delete', + ConsoleUserDelete = 'submit_console_user_delete', MembershipUpdate = 'submit_membership_update', MembershipUpdateStatus = 'submit_membership_update_status', MessagingTargetUpdate = 'submit_messaging_target_update', diff --git a/src/lib/constants.ts b/src/lib/constants.ts index d917925ae6..5935d61298 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -31,6 +31,7 @@ export enum Dependencies { PAYMENT_METHODS = 'dependency:paymentMethods', ORGANIZATION = 'dependency:organization', MEMBERS = 'dependency:members', + CONSOLE_USERS = 'dependency:console_users', PROJECT = 'dependency:project', PROJECT_VARIABLES = 'dependency:project_variables', PROJECT_INSTALLATIONS = 'dependency:project_installations', diff --git a/src/lib/helpers/consoleUsers.test.ts b/src/lib/helpers/consoleUsers.test.ts new file mode 100644 index 0000000000..439146212b --- /dev/null +++ b/src/lib/helpers/consoleUsers.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { + canPermanentlyDeleteConsoleUser, + resolveConsoleUserDeletionMode +} from './consoleUsers'; + +describe('canPermanentlyDeleteConsoleUser', () => { + it('allows permanent delete on self-hosted for another user', () => { + expect( + canPermanentlyDeleteConsoleUser({ + isSelfHosted: true, + actorUserId: 'root', + targetUserId: 'shadow' + }) + ).toBe(true); + }); + + it('blocks permanent delete on cloud', () => { + expect( + canPermanentlyDeleteConsoleUser({ + isSelfHosted: false, + actorUserId: 'root', + targetUserId: 'shadow' + }) + ).toBe(false); + }); + + it('blocks deleting yourself', () => { + expect( + canPermanentlyDeleteConsoleUser({ + isSelfHosted: true, + actorUserId: 'root', + targetUserId: 'root' + }) + ).toBe(false); + }); + + it('blocks when target user id is missing', () => { + expect( + canPermanentlyDeleteConsoleUser({ + isSelfHosted: true, + actorUserId: 'root', + targetUserId: null + }) + ).toBe(false); + }); + + it('blocks when actor user id is missing (fail closed)', () => { + expect( + canPermanentlyDeleteConsoleUser({ + isSelfHosted: true, + actorUserId: undefined, + targetUserId: 'shadow' + }) + ).toBe(false); + }); +}); + +describe('resolveConsoleUserDeletionMode', () => { + it('returns permanent only when requested and allowed', () => { + expect( + resolveConsoleUserDeletionMode({ + permanentlyDeleteRequested: true, + canPermanentlyDelete: true + }) + ).toBe('permanent'); + }); + + it('falls back to membership_only when not allowed', () => { + expect( + resolveConsoleUserDeletionMode({ + permanentlyDeleteRequested: true, + canPermanentlyDelete: false + }) + ).toBe('membership_only'); + }); + + it('falls back to membership_only when not requested', () => { + expect( + resolveConsoleUserDeletionMode({ + permanentlyDeleteRequested: false, + canPermanentlyDelete: true + }) + ).toBe('membership_only'); + }); +}); diff --git a/src/lib/helpers/consoleUsers.ts b/src/lib/helpers/consoleUsers.ts new file mode 100644 index 0000000000..648f2074ec --- /dev/null +++ b/src/lib/helpers/consoleUsers.ts @@ -0,0 +1,53 @@ +/** + * Helpers for self-hosted console (platform) user management. + * + * Console accounts live in the special `console` project. Organization owners + * can manage them via the Users API when the request includes an organization + * context (X-Appwrite-Organization). Permanent deletion is intentionally only + * surfaced in the self-hosted console UI. + */ + +export type ConsoleUserDeletionMode = 'membership_only' | 'permanent'; + +/** + * Decide whether permanent console-account deletion is allowed for the current + * actor and target membership. + * + * Fail closed: missing actor id, missing target id, or non-self-hosted → false. + */ +export function canPermanentlyDeleteConsoleUser(params: { + isSelfHosted: boolean; + actorUserId?: string | null; + targetUserId?: string | null; +}): boolean { + const { isSelfHosted, actorUserId, targetUserId } = params; + + if (!isSelfHosted) { + return false; + } + + if (!targetUserId || !actorUserId) { + return false; + } + + // Never allow deleting your own account from the org/member management UI. + if (actorUserId === targetUserId) { + return false; + } + + return true; +} + +/** + * Resolve the effective deletion mode from UI state. + */ +export function resolveConsoleUserDeletionMode(params: { + permanentlyDeleteRequested: boolean; + canPermanentlyDelete: boolean; +}): ConsoleUserDeletionMode { + if (params.permanentlyDeleteRequested && params.canPermanentlyDelete) { + return 'permanent'; + } + + return 'membership_only'; +} diff --git a/src/lib/stores/sdk.ts b/src/lib/stores/sdk.ts index cdea8ebd19..1b077c5cb5 100644 --- a/src/lib/stores/sdk.ts +++ b/src/lib/stores/sdk.ts @@ -181,6 +181,24 @@ export const sdk = { return createConsoleSdk(scopedConsoleClient); }, + /** + * Console client scoped to an organization. + * + * The backend grants owner/admin scopes (including users.read / users.write) + * on the console project only when an organization context is present. + * Use this for platform-level user management on self-hosted instances. + */ + forConsoleInOrganization(organizationId: string) { + const organizationClient = new Client(); + organizationClient.setEndpoint(clientConsole.config.endpoint); + organizationClient.setProject('console'); + Object.assign(organizationClient.headers, clientConsole.getHeaders(), { + 'X-Appwrite-Organization': organizationId + }); + + return createConsoleSdk(organizationClient); + }, + forProject(region: string, projectId: string) { const endpoint = getApiEndpoint(region); if (endpoint !== clientProject.config.endpoint) { diff --git a/src/routes/(console)/organization-[organization]/deleteMember.svelte b/src/routes/(console)/organization-[organization]/deleteMember.svelte index 5b01f8429a..f8c3901923 100644 --- a/src/routes/(console)/organization-[organization]/deleteMember.svelte +++ b/src/routes/(console)/organization-[organization]/deleteMember.svelte @@ -8,10 +8,16 @@ import { Submit, trackEvent, trackError } from '$lib/actions/analytics'; import { Dependencies } from '$lib/constants'; import { checkForUsageLimit } from '$lib/stores/billing'; - import { isCloud } from '$lib/system'; + import { isCloud, isSelfHosted } from '$lib/system'; import { organization } from '$lib/stores/organization'; import { logout } from '$lib/helpers/logout'; import Confirm from '$lib/components/confirm.svelte'; + import { InputCheckbox } from '$lib/elements/forms'; + import { Layout, Typography } from '@appwrite.io/pink-svelte'; + import { + canPermanentlyDeleteConsoleUser, + resolveConsoleUserDeletionMode + } from '$lib/helpers/consoleUsers'; const dispatch = createEventDispatcher(); @@ -19,13 +25,39 @@ export let selectedMember: Models.Membership; let error: string; + let permanentlyDelete = false; + + $: isUser = selectedMember?.userId === $user?.$id; + $: canPermanentlyDelete = canPermanentlyDeleteConsoleUser({ + isSelfHosted, + actorUserId: $user?.$id, + targetUserId: selectedMember?.userId + }); + $: deletionMode = resolveConsoleUserDeletionMode({ + permanentlyDeleteRequested: permanentlyDelete, + canPermanentlyDelete + }); + $: if (showDelete) { + permanentlyDelete = false; + error = null; + } const deleteMembership = async () => { try { - await sdk.forConsole.teams.deleteMembership({ - teamId: selectedMember.teamId, - membershipId: selectedMember.$id - }); + if (deletionMode === 'permanent') { + // Permanent system-wide account deletion (self-hosted only). + // Single Users API call — the deletes worker cleans memberships. + // Do NOT delete membership first: a partial failure would leave a + // shadow account and make retries fail on the already-removed membership. + await sdk.forConsoleInOrganization(selectedMember.teamId).users.delete({ + userId: selectedMember.userId + }); + } else { + await sdk.forConsole.teams.deleteMembership({ + teamId: selectedMember.teamId, + membershipId: selectedMember.$id + }); + } if (isUser) { logout(); @@ -33,34 +65,78 @@ dispatch('deleted'); } - await Promise.all([invalidate(Dependencies.ACCOUNT), invalidate(Dependencies.MEMBERS)]); + await Promise.all([ + invalidate(Dependencies.ACCOUNT), + invalidate(Dependencies.MEMBERS), + invalidate(Dependencies.CONSOLE_USERS) + ]); if (isCloud && $organization) { await checkForUsageLimit($organization); } showDelete = false; - addNotification({ - type: 'success', - message: `${selectedMember.userName || 'User'} was deleted from ${selectedMember.teamName}` - }); - trackEvent(Submit.MemberDelete); + + if (deletionMode === 'permanent') { + addNotification({ + type: 'success', + message: `${selectedMember.userName || selectedMember.userEmail || 'User'} was permanently deleted from the system` + }); + trackEvent(Submit.ConsoleUserDelete); + } else { + addNotification({ + type: 'success', + message: `${selectedMember.userName || 'User'} was deleted from ${selectedMember.teamName}` + }); + trackEvent(Submit.MemberDelete); + } } catch (e) { error = e.message; - trackError(e, Submit.MemberDelete); + trackError( + e, + deletionMode === 'permanent' ? Submit.ConsoleUserDelete : Submit.MemberDelete + ); } }; - - $: isUser = selectedMember?.userId === $user?.$id; - {isUser - ? `Are you sure you want to leave '${selectedMember?.teamName}'?` - : `Are you sure you want to delete ${selectedMember?.userName} from '${selectedMember?.teamName}'?`} + + {#if isUser} + + Are you sure you want to leave '{selectedMember?.teamName}'? + + {:else} + + Are you sure you want to remove + {selectedMember?.userName || selectedMember?.userEmail || 'this user'} + from '{selectedMember?.teamName}'? + + {#if canPermanentlyDelete} + + {#if permanentlyDelete} + + This removes the account system-wide — not just from this organization. They + will no longer be able to sign in or create organizations. This action cannot + be undone. + + {/if} + {/if} + {/if} + diff --git a/src/routes/(console)/organization-[organization]/header.svelte b/src/routes/(console)/organization-[organization]/header.svelte index 3aedaf7dbf..88a691cd58 100644 --- a/src/routes/(console)/organization-[organization]/header.svelte +++ b/src/routes/(console)/organization-[organization]/header.svelte @@ -20,7 +20,7 @@ isBilling, isOwner } from '$lib/stores/roles'; - import { GRACE_PERIOD_OVERRIDE, isCloud } from '$lib/system'; + import { GRACE_PERIOD_OVERRIDE, isCloud, isSelfHosted } from '$lib/system'; import { IconPlus, IconPlusSm } from '@appwrite.io/pink-icons-svelte'; import { Badge, Icon, Layout, Tooltip, Typography } from '@appwrite.io/pink-svelte'; import { BillingPlanGroup, type Models } from '@appwrite.io/console'; @@ -67,6 +67,14 @@ hasChildren: true, disabled: !$canSeeTeams }, + { + href: `${path}/platform-users`, + title: 'Platform users', + event: 'platform_users', + hasChildren: true, + // Self-hosted only: list/delete every console account on the instance. + disabled: !(isSelfHosted && $isOwner) + }, { href: `${path}/usage`, event: 'usage', diff --git a/src/routes/(console)/organization-[organization]/platform-users/+page.svelte b/src/routes/(console)/organization-[organization]/platform-users/+page.svelte new file mode 100644 index 0000000000..912bc7f81e --- /dev/null +++ b/src/routes/(console)/organization-[organization]/platform-users/+page.svelte @@ -0,0 +1,125 @@ + + + + + + + Platform users + + Manage all console accounts on this self-hosted instance. Permanently deleting a + user removes their account system-wide — not only from a single organization. + + + + + + {#if data.users.total} + + + Name + Email + Status + Created + + + {#each data.users.users as consoleUser (consoleUser.$id)} + + + + + + + {consoleUser.name || 'n/a'} + + {#if consoleUser.$id === $user?.$id} + + {/if} + + + + + {consoleUser.email || '—'} + + + + + + + + + {#if $isOwner && consoleUser.$id !== $user?.$id} + + + + +
+ + { + selectedUser = consoleUser; + showDelete = true; + }}> + Delete permanently + + +
+
+ {/if} +
+
+ {/each} +
+ + + {:else if data.search} + + No platform users found matching "{data.search}". + + {:else} + No platform users found. + {/if} +
+
+ + diff --git a/src/routes/(console)/organization-[organization]/platform-users/+page.ts b/src/routes/(console)/organization-[organization]/platform-users/+page.ts new file mode 100644 index 0000000000..be97ac4a04 --- /dev/null +++ b/src/routes/(console)/organization-[organization]/platform-users/+page.ts @@ -0,0 +1,40 @@ +import { Dependencies, PAGE_LIMIT } from '$lib/constants'; +import { getLimit, getPage, getSearch, pageToOffset } from '$lib/helpers/load'; +import { sdk } from '$lib/stores/sdk'; +import { isSelfHosted } from '$lib/system'; +import { Query } from '@appwrite.io/console'; +import { error, redirect } from '@sveltejs/kit'; +import { base } from '$app/paths'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ url, params, route, depends, parent }) => { + await parent(); + depends(Dependencies.CONSOLE_USERS); + + // Platform-wide console user management is self-hosted only. + // Cloud multi-tenancy must not expose cross-organization user listings. + if (!isSelfHosted) { + redirect(303, `${base}/organization-${params.organization}`); + } + + const page = getPage(url); + const search = getSearch(url); + const limit = getLimit(url, route, PAGE_LIMIT); + const offset = pageToOffset(page, limit); + + try { + const users = await sdk.forConsoleInOrganization(params.organization).users.list({ + queries: [Query.limit(limit), Query.offset(offset), Query.orderDesc('$createdAt')], + search: search || undefined + }); + + return { + offset, + limit, + search, + users + }; + } catch (e) { + throw error(e?.code || 500, e?.message || 'Failed to load console users'); + } +}; diff --git a/src/routes/(console)/organization-[organization]/platform-users/deleteConsoleUser.svelte b/src/routes/(console)/organization-[organization]/platform-users/deleteConsoleUser.svelte new file mode 100644 index 0000000000..bef02317df --- /dev/null +++ b/src/routes/(console)/organization-[organization]/platform-users/deleteConsoleUser.svelte @@ -0,0 +1,86 @@ + + + + + Are you sure you want to permanently delete + {selectedUser?.name || selectedUser?.email || 'this user'} + from the entire Appwrite system? + + + They will lose access to all organizations and will no longer be able to sign in. Memberships + are cleaned up automatically. Organizations they solely owned may be left without an owner + until cleaned up. This action cannot be undone. + + {#if isSelf} + + You cannot delete your own account here. Use Account → Delete account instead. + + {/if} + From b2e61ddd7d7df7ce966aec086f748107d018c660 Mon Sep 17 00:00:00 2001 From: Marton Schneider Date: Sat, 11 Jul 2026 20:17:13 +0200 Subject: [PATCH 2/3] fix: address greptile review on platform user delete - Gate platform-users load on owner role (clean redirect) - Migrate new/touched components to Svelte 5 runes - Narrow org-scoped Users helper with reused Client --- src/lib/stores/sdk.ts | 30 +++++++---- .../deleteMember.svelte | 53 +++++++++++-------- .../platform-users/+page.svelte | 11 ++-- .../platform-users/+page.ts | 11 ++-- .../platform-users/deleteConsoleUser.svelte | 34 +++++++----- 5 files changed, 89 insertions(+), 50 deletions(-) diff --git a/src/lib/stores/sdk.ts b/src/lib/stores/sdk.ts index 1b077c5cb5..400897b88b 100644 --- a/src/lib/stores/sdk.ts +++ b/src/lib/stores/sdk.ts @@ -88,12 +88,15 @@ const endpoint = getApiEndpoint(); const clientConsole = new Client(); const clientConsoleOperator = new Client(); const scopedConsoleClient = new Client(); +/** Reused client for org-scoped console Users API (platform user management). */ +const organizationScopedConsoleClient = new Client(); const clientProject = new Client(); const clientRealtime = new Client(); if (!building) { scopedConsoleClient.setProject('console'); + organizationScopedConsoleClient.setProject('console'); clientConsole.setEndpoint(endpoint).setProject('console'); clientConsoleOperator.setEndpoint(endpoint).setProject('console'); @@ -103,6 +106,7 @@ if (!building) { registerImpersonationClients([ clientConsole, scopedConsoleClient, + organizationScopedConsoleClient, clientProject, clientRealtime ]); @@ -182,21 +186,29 @@ export const sdk = { }, /** - * Console client scoped to an organization. + * Users service scoped to an organization on the console project. * * The backend grants owner/admin scopes (including users.read / users.write) - * on the console project only when an organization context is present. - * Use this for platform-level user management on self-hosted instances. + * only when an organization context is present. Returns a narrow Users + * instance (not a full SDK) and reuses one Client, updating the org header + * when the organization changes — same pattern as `organization()`. */ - forConsoleInOrganization(organizationId: string) { - const organizationClient = new Client(); - organizationClient.setEndpoint(clientConsole.config.endpoint); - organizationClient.setProject('console'); - Object.assign(organizationClient.headers, clientConsole.getHeaders(), { + forConsoleUsersInOrganization(organizationId: string) { + if ( + !organizationScopedConsoleClient.config.endpoint || + organizationScopedConsoleClient.config.endpoint !== clientConsole.config.endpoint + ) { + organizationScopedConsoleClient.setEndpoint(clientConsole.config.endpoint); + } + if (organizationScopedConsoleClient.config.project !== 'console') { + organizationScopedConsoleClient.setProject('console'); + } + + Object.assign(organizationScopedConsoleClient.headers, clientConsole.getHeaders(), { 'X-Appwrite-Organization': organizationId }); - return createConsoleSdk(organizationClient); + return new Users(organizationScopedConsoleClient); }, forProject(region: string, projectId: string) { diff --git a/src/routes/(console)/organization-[organization]/deleteMember.svelte b/src/routes/(console)/organization-[organization]/deleteMember.svelte index f8c3901923..eba79dd7ee 100644 --- a/src/routes/(console)/organization-[organization]/deleteMember.svelte +++ b/src/routes/(console)/organization-[organization]/deleteMember.svelte @@ -3,7 +3,6 @@ import { addNotification } from '$lib/stores/notifications'; import { sdk } from '$lib/stores/sdk'; import type { Models } from '@appwrite.io/console'; - import { createEventDispatcher } from 'svelte'; import { user } from '$lib/stores/user'; import { Submit, trackEvent, trackError } from '$lib/actions/analytics'; import { Dependencies } from '$lib/constants'; @@ -19,28 +18,40 @@ resolveConsoleUserDeletionMode } from '$lib/helpers/consoleUsers'; - const dispatch = createEventDispatcher(); + let { + showDelete = $bindable(false), + selectedMember = undefined, + ondeleted + }: { + showDelete?: boolean; + selectedMember?: Models.Membership; + ondeleted?: () => void; + } = $props(); - export let showDelete = false; - export let selectedMember: Models.Membership; + let error = $state(null); + let permanentlyDelete = $state(false); - let error: string; - let permanentlyDelete = false; + const isUser = $derived(selectedMember?.userId === $user?.$id); + const canPermanentlyDelete = $derived( + canPermanentlyDeleteConsoleUser({ + isSelfHosted, + actorUserId: $user?.$id, + targetUserId: selectedMember?.userId + }) + ); + const deletionMode = $derived( + resolveConsoleUserDeletionMode({ + permanentlyDeleteRequested: permanentlyDelete, + canPermanentlyDelete + }) + ); - $: isUser = selectedMember?.userId === $user?.$id; - $: canPermanentlyDelete = canPermanentlyDeleteConsoleUser({ - isSelfHosted, - actorUserId: $user?.$id, - targetUserId: selectedMember?.userId - }); - $: deletionMode = resolveConsoleUserDeletionMode({ - permanentlyDeleteRequested: permanentlyDelete, - canPermanentlyDelete + $effect(() => { + if (showDelete) { + permanentlyDelete = false; + error = null; + } }); - $: if (showDelete) { - permanentlyDelete = false; - error = null; - } const deleteMembership = async () => { try { @@ -49,7 +60,7 @@ // Single Users API call — the deletes worker cleans memberships. // Do NOT delete membership first: a partial failure would leave a // shadow account and make retries fail on the already-removed membership. - await sdk.forConsoleInOrganization(selectedMember.teamId).users.delete({ + await sdk.forConsoleUsersInOrganization(selectedMember.teamId).delete({ userId: selectedMember.userId }); } else { @@ -62,7 +73,7 @@ if (isUser) { logout(); } else { - dispatch('deleted'); + ondeleted?.(); } await Promise.all([ diff --git a/src/routes/(console)/organization-[organization]/platform-users/+page.svelte b/src/routes/(console)/organization-[organization]/platform-users/+page.svelte index 912bc7f81e..e403b0921d 100644 --- a/src/routes/(console)/organization-[organization]/platform-users/+page.svelte +++ b/src/routes/(console)/organization-[organization]/platform-users/+page.svelte @@ -17,11 +17,12 @@ Popover, ActionMenu } from '@appwrite.io/pink-svelte'; + import type { PageProps } from './$types'; - export let data; + let { data }: PageProps = $props(); - let selectedUser: Models.User> | null = null; - let showDelete = false; + let selectedUser = $state> | null>(null); + let showDelete = $state(false); @@ -58,7 +59,9 @@ - + {consoleUser.name || 'n/a'} diff --git a/src/routes/(console)/organization-[organization]/platform-users/+page.ts b/src/routes/(console)/organization-[organization]/platform-users/+page.ts index be97ac4a04..a26ab8ed37 100644 --- a/src/routes/(console)/organization-[organization]/platform-users/+page.ts +++ b/src/routes/(console)/organization-[organization]/platform-users/+page.ts @@ -8,12 +8,15 @@ import { base } from '$app/paths'; import type { PageLoad } from './$types'; export const load: PageLoad = async ({ url, params, route, depends, parent }) => { - await parent(); + const parentData = await parent(); depends(Dependencies.CONSOLE_USERS); - // Platform-wide console user management is self-hosted only. + const roles: string[] = parentData.roles ?? []; + const isOwner = roles.includes('owner'); + + // Platform-wide console user management is self-hosted + owner only. // Cloud multi-tenancy must not expose cross-organization user listings. - if (!isSelfHosted) { + if (!isSelfHosted || !isOwner) { redirect(303, `${base}/organization-${params.organization}`); } @@ -23,7 +26,7 @@ export const load: PageLoad = async ({ url, params, route, depends, parent }) => const offset = pageToOffset(page, limit); try { - const users = await sdk.forConsoleInOrganization(params.organization).users.list({ + const users = await sdk.forConsoleUsersInOrganization(params.organization).list({ queries: [Query.limit(limit), Query.offset(offset), Query.orderDesc('$createdAt')], search: search || undefined }); diff --git a/src/routes/(console)/organization-[organization]/platform-users/deleteConsoleUser.svelte b/src/routes/(console)/organization-[organization]/platform-users/deleteConsoleUser.svelte index bef02317df..2ba848a0c3 100644 --- a/src/routes/(console)/organization-[organization]/platform-users/deleteConsoleUser.svelte +++ b/src/routes/(console)/organization-[organization]/platform-users/deleteConsoleUser.svelte @@ -12,20 +12,30 @@ import { isSelfHosted } from '$lib/system'; import { canPermanentlyDeleteConsoleUser } from '$lib/helpers/consoleUsers'; - export let showDelete = false; - export let selectedUser: Models.User> | null = null; + let { + showDelete = $bindable(false), + selectedUser = null + }: { + showDelete?: boolean; + selectedUser?: Models.User> | null; + } = $props(); - let error: string; + let error = $state(null); - $: isSelf = selectedUser?.$id === $user?.$id; - $: allowed = canPermanentlyDeleteConsoleUser({ - isSelfHosted, - actorUserId: $user?.$id, - targetUserId: selectedUser?.$id + const isSelf = $derived(selectedUser?.$id === $user?.$id); + const allowed = $derived( + canPermanentlyDeleteConsoleUser({ + isSelfHosted, + actorUserId: $user?.$id, + targetUserId: selectedUser?.$id + }) + ); + + $effect(() => { + if (showDelete) { + error = null; + } }); - $: if (showDelete) { - error = null; - } const deleteUser = async () => { if (!selectedUser || !allowed) { @@ -36,7 +46,7 @@ } try { - await sdk.forConsoleInOrganization(page.params.organization).users.delete({ + await sdk.forConsoleUsersInOrganization(page.params.organization).delete({ userId: selectedUser.$id }); From 285bc058e5953a493f0977aa642b12dab1def441 Mon Sep 17 00:00:00 2001 From: Marton Schneider Date: Sat, 11 Jul 2026 20:31:06 +0200 Subject: [PATCH 3/3] fix(sdk): use fresh client for org-scoped Users API Avoid shared-client header staleness with impersonation by building a new Client per call (matching organization()), while still returning only a narrow Users instance. --- src/lib/stores/sdk.ts | 30 +++++++++--------------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/src/lib/stores/sdk.ts b/src/lib/stores/sdk.ts index 400897b88b..0c79582924 100644 --- a/src/lib/stores/sdk.ts +++ b/src/lib/stores/sdk.ts @@ -88,15 +88,11 @@ const endpoint = getApiEndpoint(); const clientConsole = new Client(); const clientConsoleOperator = new Client(); const scopedConsoleClient = new Client(); -/** Reused client for org-scoped console Users API (platform user management). */ -const organizationScopedConsoleClient = new Client(); - const clientProject = new Client(); const clientRealtime = new Client(); if (!building) { scopedConsoleClient.setProject('console'); - organizationScopedConsoleClient.setProject('console'); clientConsole.setEndpoint(endpoint).setProject('console'); clientConsoleOperator.setEndpoint(endpoint).setProject('console'); @@ -106,7 +102,6 @@ if (!building) { registerImpersonationClients([ clientConsole, scopedConsoleClient, - organizationScopedConsoleClient, clientProject, clientRealtime ]); @@ -188,27 +183,20 @@ export const sdk = { /** * Users service scoped to an organization on the console project. * - * The backend grants owner/admin scopes (including users.read / users.write) - * only when an organization context is present. Returns a narrow Users - * instance (not a full SDK) and reuses one Client, updating the org header - * when the organization changes — same pattern as `organization()`. + * Builds a fresh Client per call (same pattern as `organization()`) so auth + * headers always mirror the current console session / impersonation state, + * then stamps `X-Appwrite-Organization` for users.read/write scopes. + * Returns a narrow Users instance only — not a full console SDK. */ forConsoleUsersInOrganization(organizationId: string) { - if ( - !organizationScopedConsoleClient.config.endpoint || - organizationScopedConsoleClient.config.endpoint !== clientConsole.config.endpoint - ) { - organizationScopedConsoleClient.setEndpoint(clientConsole.config.endpoint); - } - if (organizationScopedConsoleClient.config.project !== 'console') { - organizationScopedConsoleClient.setProject('console'); - } - - Object.assign(organizationScopedConsoleClient.headers, clientConsole.getHeaders(), { + const organizationClient = new Client(); + organizationClient.setEndpoint(clientConsole.config.endpoint || getApiEndpoint()); + organizationClient.setProject('console'); + Object.assign(organizationClient.headers, clientConsole.getHeaders(), { 'X-Appwrite-Organization': organizationId }); - return new Users(organizationScopedConsoleClient); + return new Users(organizationClient); }, forProject(region: string, projectId: string) {