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..0c79582924 100644 --- a/src/lib/stores/sdk.ts +++ b/src/lib/stores/sdk.ts @@ -88,7 +88,6 @@ const endpoint = getApiEndpoint(); const clientConsole = new Client(); const clientConsoleOperator = new Client(); const scopedConsoleClient = new Client(); - const clientProject = new Client(); const clientRealtime = new Client(); @@ -181,6 +180,25 @@ export const sdk = { return createConsoleSdk(scopedConsoleClient); }, + /** + * Users service scoped to an organization on the console project. + * + * 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) { + 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(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..eba79dd7ee 100644 --- a/src/routes/(console)/organization-[organization]/deleteMember.svelte +++ b/src/routes/(console)/organization-[organization]/deleteMember.svelte @@ -3,64 +3,151 @@ 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'; 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(); + 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; + 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 + }) + ); + + $effect(() => { + 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.forConsoleUsersInOrganization(selectedMember.teamId).delete({ + userId: selectedMember.userId + }); + } else { + await sdk.forConsole.teams.deleteMembership({ + teamId: selectedMember.teamId, + membershipId: selectedMember.$id + }); + } if (isUser) { logout(); } else { - dispatch('deleted'); + ondeleted?.(); } - 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..e403b0921d --- /dev/null +++ b/src/routes/(console)/organization-[organization]/platform-users/+page.svelte @@ -0,0 +1,128 @@ + + + + + + + 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..a26ab8ed37 --- /dev/null +++ b/src/routes/(console)/organization-[organization]/platform-users/+page.ts @@ -0,0 +1,43 @@ +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 }) => { + const parentData = await parent(); + depends(Dependencies.CONSOLE_USERS); + + 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 || !isOwner) { + 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.forConsoleUsersInOrganization(params.organization).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..2ba848a0c3 --- /dev/null +++ b/src/routes/(console)/organization-[organization]/platform-users/deleteConsoleUser.svelte @@ -0,0 +1,96 @@ + + + + + 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} +