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