Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/lib/actions/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
86 changes: 86 additions & 0 deletions src/lib/helpers/consoleUsers.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
53 changes: 53 additions & 0 deletions src/lib/helpers/consoleUsers.ts
Original file line number Diff line number Diff line change
@@ -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';
}
20 changes: 19 additions & 1 deletion src/lib/stores/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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) {
Expand Down
137 changes: 112 additions & 25 deletions src/routes/(console)/organization-[organization]/deleteMember.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(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;
</script>

<Confirm
onSubmit={deleteMembership}
submissionLoader
title={isUser ? 'Leave organization' : 'Delete member'}
title={isUser
? 'Leave organization'
: deletionMode === 'permanent'
? 'Delete account permanently'
: 'Delete member'}
bind:open={showDelete}
action={isUser ? 'Leave' : 'Delete'}
action={isUser ? 'Leave' : deletionMode === 'permanent' ? 'Delete permanently' : 'Delete'}
confirmDeletion={deletionMode === 'permanent'}
confirmDeletionLabel="I understand this permanently deletes the account from the entire system"
bind:error>
{isUser
? `Are you sure you want to leave '${selectedMember?.teamName}'?`
: `Are you sure you want to delete ${selectedMember?.userName} from '${selectedMember?.teamName}'?`}
<Layout.Stack gap="m">
{#if isUser}
<Typography.Text>
Are you sure you want to leave '{selectedMember?.teamName}'?
</Typography.Text>
{:else}
<Typography.Text>
Are you sure you want to remove
<b>{selectedMember?.userName || selectedMember?.userEmail || 'this user'}</b>
from '{selectedMember?.teamName}'?
</Typography.Text>
{#if canPermanentlyDelete}
<InputCheckbox
size="s"
id="permanently_delete_console_user"
bind:checked={permanentlyDelete}
label="Also permanently delete their account from the entire system" />
{#if permanentlyDelete}
<Typography.Text>
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.
</Typography.Text>
{/if}
{/if}
{/if}
</Layout.Stack>
</Confirm>
10 changes: 9 additions & 1 deletion src/routes/(console)/organization-[organization]/header.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down
Loading