From 57a18ee6d9d0e06431bbfc3a1df6dfdc2f262d6f Mon Sep 17 00:00:00 2001 From: Patryk Lewczuk Date: Sun, 13 Sep 2026 22:46:37 +0200 Subject: [PATCH] feat(portal_users): bulk-delete customer portal users from the admin list The Customer Users screen (backend -> Customer Portal -> Customer Users) could only delete one account at a time through the row menu, which does not scale when cleaning up participants after an event. Add an app module that injects a bulk action into the core table via UMES instead of ejecting customer_accounts. Injecting a bulk action is what makes DataTable turn on row selection, so the checkboxes and the "Delete selected" button appear together. - portal_users.injection.users-bulk-delete targets the spot data-table:customer_accounts.admin.users:bulk-actions - gated on the existing core feature customer_accounts.manage, the same one the DELETE endpoint enforces, so no new ACL feature to grant - deletes through the core endpoint per user, carrying the optimistic-lock header built from each row's updatedAt, so stale deletes are still refused and session revocation plus the deleted event still run - the core page keeps its rows in client state and exposes no refresh callback to widgets, so the action reloads through the URL and hands the result to FlashMessages via the query string - partial failures report how many were deleted and why the rest were not Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VLqyzgTthFCZsqQWE9gh4a --- src/modules.ts | 1 + src/modules/portal_users/i18n/en.json | 9 ++ src/modules/portal_users/i18n/pl.json | 9 ++ src/modules/portal_users/index.ts | 8 + .../portal_users/widgets/injection-table.ts | 12 ++ .../injection/users-bulk-delete/widget.ts | 140 ++++++++++++++++++ 6 files changed, 179 insertions(+) create mode 100644 src/modules/portal_users/i18n/en.json create mode 100644 src/modules/portal_users/i18n/pl.json create mode 100644 src/modules/portal_users/index.ts create mode 100644 src/modules/portal_users/widgets/injection-table.ts create mode 100644 src/modules/portal_users/widgets/injection/users-bulk-delete/widget.ts diff --git a/src/modules.ts b/src/modules.ts index 616891a..b3cdbb7 100644 --- a/src/modules.ts +++ b/src/modules.ts @@ -55,6 +55,7 @@ export const enabledModules: ModuleEntry[] = [ { id: 'sponsors', from: '@app' }, { id: 'incidents', from: '@app' }, { id: 'bounties', from: '@app' }, + { id: 'portal_users', from: '@app' }, { id: 'portal', from: '@open-mercato/core' }, ] diff --git a/src/modules/portal_users/i18n/en.json b/src/modules/portal_users/i18n/en.json new file mode 100644 index 0000000..7f46c75 --- /dev/null +++ b/src/modules/portal_users/i18n/en.json @@ -0,0 +1,9 @@ +{ + "portal_users.bulk.delete.label": "Delete selected", + "portal_users.bulk.delete.noSelection": "No users selected.", + "portal_users.bulk.delete.confirmTitle": "Delete {count} users?", + "portal_users.bulk.delete.confirmDescription": "Their portal access is revoked immediately. This cannot be undone from here.", + "portal_users.bulk.delete.success": "{count} users deleted", + "portal_users.bulk.delete.partial": "Deleted {count} of {total} users. Failed: {errors}", + "portal_users.bulk.delete.failed": "Could not delete the selected users. {errors}" +} diff --git a/src/modules/portal_users/i18n/pl.json b/src/modules/portal_users/i18n/pl.json new file mode 100644 index 0000000..bc97001 --- /dev/null +++ b/src/modules/portal_users/i18n/pl.json @@ -0,0 +1,9 @@ +{ + "portal_users.bulk.delete.label": "Usuń zaznaczonych", + "portal_users.bulk.delete.noSelection": "Nie zaznaczono żadnych użytkowników.", + "portal_users.bulk.delete.confirmTitle": "Usunąć {count} użytkowników?", + "portal_users.bulk.delete.confirmDescription": "Dostęp do portalu zostanie natychmiast odebrany. Tej operacji nie cofniesz z tego ekranu.", + "portal_users.bulk.delete.success": "Usunięto {count} użytkowników", + "portal_users.bulk.delete.partial": "Usunięto {count} z {total} użytkowników. Nie udało się: {errors}", + "portal_users.bulk.delete.failed": "Nie udało się usunąć zaznaczonych użytkowników. {errors}" +} diff --git a/src/modules/portal_users/index.ts b/src/modules/portal_users/index.ts new file mode 100644 index 0000000..7f73e6c --- /dev/null +++ b/src/modules/portal_users/index.ts @@ -0,0 +1,8 @@ +import type { ModuleInfo } from '@open-mercato/shared/modules/registry' + +export const metadata: ModuleInfo = { + name: 'portal_users', + title: 'Portal Users', + version: '0.1.0', + description: 'Admin tooling for customer portal users, injected into the core customer_accounts screens.', +} diff --git a/src/modules/portal_users/widgets/injection-table.ts b/src/modules/portal_users/widgets/injection-table.ts new file mode 100644 index 0000000..f8fc77d --- /dev/null +++ b/src/modules/portal_users/widgets/injection-table.ts @@ -0,0 +1,12 @@ +import type { ModuleInjectionTable } from '@open-mercato/shared/modules/widgets/injection' + +// `customer_accounts.admin.users` is the tableId the core users page passes to +// its DataTable; the table enables row selection as soon as a bulk action is +// injected here. +export const injectionTable: ModuleInjectionTable = { + 'data-table:customer_accounts.admin.users:bulk-actions': [ + { widgetId: 'portal_users.injection.users-bulk-delete', priority: 30 }, + ], +} + +export default injectionTable diff --git a/src/modules/portal_users/widgets/injection/users-bulk-delete/widget.ts b/src/modules/portal_users/widgets/injection/users-bulk-delete/widget.ts new file mode 100644 index 0000000..62da982 --- /dev/null +++ b/src/modules/portal_users/widgets/injection/users-bulk-delete/widget.ts @@ -0,0 +1,140 @@ +import type { InjectionBulkActionWidget } from '@open-mercato/shared/modules/widgets/injection' +import { apiCall, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall' +import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock' + +/** Shape of the rows rendered by the core customer_accounts users table. */ +type CustomerUserRow = { + id?: unknown + email?: unknown + displayName?: unknown + updatedAt?: unknown +} + +type ConfirmOptions = { + title?: string + description?: string + confirmText?: string + variant?: 'default' | 'destructive' +} + +type BulkActionContext = { + confirm?: (options?: ConfirmOptions) => Promise + refresh?: () => void + translate?: (key: string, fallback?: string, params?: Record) => string +} + +function identity(key: string, fallback?: string): string { + return fallback ?? key +} + +function readRow(row: unknown): { id: string; label: string; updatedAt: string | null } | null { + const record = row as CustomerUserRow | null + const id = typeof record?.id === 'string' ? record.id : null + if (!id) return null + const displayName = typeof record?.displayName === 'string' ? record.displayName : '' + const email = typeof record?.email === 'string' ? record.email : '' + const updatedAt = typeof record?.updatedAt === 'string' ? record.updatedAt : null + return { id, label: displayName || email || id, updatedAt } +} + +async function deleteUser(id: string, updatedAt: string | null): Promise { + const call = await withScopedApiRequestHeaders( + buildOptimisticLockHeader(updatedAt), + () => apiCall<{ ok?: boolean; error?: string }>( + `/api/customer_accounts/admin/users/${encodeURIComponent(id)}`, + { method: 'DELETE' }, + ), + ) + if (call.ok) return null + const error = call.result?.error + return typeof error === 'string' && error.trim().length > 0 ? error : `HTTP ${call.status}` +} + +/** + * The core users page reloads its rows from client state, so it exposes no + * refresh callback to injected widgets. Reload through the URL instead and let + * FlashMessages pick the result up from the query string after the navigation. + */ +function reloadWithFlash(message: string, kind: 'success' | 'error'): void { + if (typeof window === 'undefined') return + const url = new URL(window.location.href) + url.searchParams.set('flash', message) + url.searchParams.set('type', kind) + window.location.href = url.toString() +} + +const widget: InjectionBulkActionWidget = { + metadata: { + id: 'portal_users.injection.users-bulk-delete', + title: 'Delete selected portal users', + description: 'Bulk-deletes the selected customer portal users.', + features: ['customer_accounts.manage'], + priority: 30, + }, + bulkActions: [ + { + id: 'portal_users.bulk.delete-users', + label: 'portal_users.bulk.delete.label', + icon: 'Trash2', + onExecute: async (selectedRows, context) => { + const ctx = (context ?? {}) as BulkActionContext + const t = ctx.translate ?? identity + const rows = selectedRows.map(readRow).filter((row): row is NonNullable> => row !== null) + + if (rows.length === 0) { + return { ok: false, message: t('portal_users.bulk.delete.noSelection', 'No users selected.') } + } + + const confirmed = await ctx.confirm?.({ + title: t('portal_users.bulk.delete.confirmTitle', 'Delete {count} users?', { count: rows.length }), + description: t( + 'portal_users.bulk.delete.confirmDescription', + 'Their portal access is revoked immediately. This cannot be undone from here.', + ), + variant: 'destructive', + }) + if (confirmed !== true) return { ok: false } + + const failures: string[] = [] + for (const row of rows) { + // Sequential on purpose: the endpoint revokes sessions and emits an + // event per user, and an admin list is small enough that a burst of + // parallel deletes buys nothing. + const error = await deleteUser(row.id, row.updatedAt) + if (error) failures.push(`${row.label}: ${error}`) + } + + const deleted = rows.length - failures.length + if (failures.length === 0) { + reloadWithFlash( + t('portal_users.bulk.delete.success', '{count} users deleted', { count: deleted }), + 'success', + ) + // Silences the table's own flash — the reload above carries the message. + return { ok: false } + } + + if (deleted > 0) { + reloadWithFlash( + t('portal_users.bulk.delete.partial', 'Deleted {count} of {total} users. Failed: {errors}', { + count: deleted, + total: rows.length, + errors: failures.join('; '), + }), + 'error', + ) + return { ok: false } + } + + return { + ok: false, + message: t('portal_users.bulk.delete.failed', 'Could not delete the selected users. {errors}', { + errors: failures.join('; '), + }), + } + }, + }, + ], +} + +export default widget