Per organized-ideas.md §6: role assignment, account ban/unban/delete, and direct Admin/Super-Admin CRUD of public catalog entries outside the submission workflow. Backend: - list_users_for_admin(): Super-Admin-gated SECURITY DEFINER function joining profiles + auth.users (username, email, role, banned_until) — auth.users isn't exposed through PostgREST, so this is the only way to list accounts at all. - New Edge Function admin-user-action (ban/unban/delete), using @supabase/server's `auth: 'user'` mode to verify the caller's JWT, then Supabase Auth's Admin API for the actual mutation. This is deliberately an Edge Function rather than a Postgres function like everything else in this codebase: touching auth.users needs the Admin API, the stable documented interface, not a direct write to a schema Supabase manages internally. Self-action guard; verify_jwt = true at the gateway on top of the function's own JWT verification. - 5 new pgTAP tests (43/43 total) for list_users_for_admin (Super-Admin-only, even regular Admins get 42501). - CatalogRepository gains admin* methods (direct edit of a public port/cable/ device entry, plus adminUnpublish which flips is_public rather than deleting) — the update methods were already ownership-agnostic (RLS's is_admin() clause is what actually permits it), so these are thin aliases, not duplicated logic. Frontend: - authStore/AdminUserRepository: minimal role plumbing, shared UserRole type. - adminUserStore + AdminUsersModal: list/role-dropdown/ban/unban/delete, gated to Super Admin only via a new "Manage Users" TopBar button. - PortTypeManager/CableTypeManager/DevicePalette: built-in entries now show direct "Edit"/"Unpublish" for Admins (regular Admin included, per §6's capability table — not Super-Admin-exclusive) instead of "Suggest edit"; unpublish reuses the review-queue's impact-check RPC before confirming. - DeviceTemplateEditor gains an `adminMode` save path alongside its existing submissionMode/resubmitId ones. Verified: tsc -b and oxlint clean; supabase db reset + 43/43 pgTAP tests pass; confirmed both new privileged endpoints (the SQL function and the Edge Function) actually work through the real REST API via live curl calls — signup, email confirm, role promotion, ban/unban/delete round trips, self-action guard, non-super-admin rejection, and verify_jwt=true compatibility all exercised directly, not just asserted.
114 lines
4.3 KiB
TypeScript
114 lines
4.3 KiB
TypeScript
import type { CatalogEntityType } from './SubmissionRepository'
|
|
import type { CableType, DeviceTemplate, PortType } from '../domain/types'
|
|
|
|
/**
|
|
* Maps domain objects to the shape of their Supabase table row (snake_case
|
|
* columns, no id/is_public/owner_id). Shared by two call sites that both
|
|
* need a row-shaped payload: SupabaseCatalogRepository's inserts, and
|
|
* catalog submissions' `proposed_data` (see SubmissionRepository) — a
|
|
* submission's proposed_data is deliberately row-shaped so approving it is
|
|
* just writing that object onto the row at `entity_id`, not a translation
|
|
* step an Admin's approval action would otherwise have to duplicate.
|
|
*/
|
|
|
|
/** Which table backs each catalog entity type — shared by
|
|
* SupabaseAdminSubmissionRepository (approving into the right table) and
|
|
* SupabaseCatalogRepository (direct Admin unpublish). */
|
|
export const CATALOG_TABLE_BY_ENTITY_TYPE: Record<CatalogEntityType, string> = {
|
|
device_template: 'device_templates',
|
|
port_type: 'port_types',
|
|
cable_type: 'cable_types',
|
|
device_category: 'device_categories',
|
|
}
|
|
|
|
export function portTypeToRow(portType: Omit<PortType, 'id' | 'custom'>): Record<string, unknown> {
|
|
return {
|
|
name: portType.name,
|
|
category: portType.category,
|
|
family: portType.family,
|
|
compatible_family_ids: portType.compatibleFamilyIds ?? [],
|
|
max_connections: portType.maxConnections ?? null,
|
|
}
|
|
}
|
|
|
|
export function cableTypeToRow(cableType: Omit<CableType, 'id' | 'custom'>): Record<string, unknown> {
|
|
return {
|
|
name: cableType.name,
|
|
family: cableType.family,
|
|
family2: cableType.family2 ?? null,
|
|
unit: cableType.unit,
|
|
cost_per_unit: cableType.costPerUnit ?? null,
|
|
}
|
|
}
|
|
|
|
export function deviceCategoryToRow(name: string): Record<string, unknown> {
|
|
return { name }
|
|
}
|
|
|
|
export function deviceTemplateToRow(template: Omit<DeviceTemplate, 'id' | 'custom'>): Record<string, unknown> {
|
|
return {
|
|
name: template.name,
|
|
category_id: template.category,
|
|
manufacturer: template.manufacturer ?? null,
|
|
model: template.model ?? null,
|
|
cost: template.cost ?? null,
|
|
ports: template.ports.map((port, index) => ({
|
|
name: port.name,
|
|
direction: port.direction,
|
|
port_type_id: port.portTypeId,
|
|
sort_order: index,
|
|
})),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The reverse direction: turns a `catalog_submissions.proposed_data` row
|
|
* back into domain-shaped fields, so a rejected/pending submission can be
|
|
* reopened in the same form component that created it (see MySubmissionsModal)
|
|
* instead of needing a bespoke "edit a raw JSON blob" UI. Safe because
|
|
* proposed_data is always a *complete* row projection (every field of the
|
|
* type, per the `*ToRow` functions above), never a partial patch.
|
|
*/
|
|
|
|
export function rowToPortTypeFields(row: Record<string, unknown>): Omit<PortType, 'id' | 'custom'> {
|
|
const compatibleFamilyIds = row.compatible_family_ids as string[] | undefined
|
|
return {
|
|
name: row.name as string,
|
|
category: row.category as PortType['category'],
|
|
family: row.family as string,
|
|
compatibleFamilyIds: compatibleFamilyIds && compatibleFamilyIds.length > 0 ? compatibleFamilyIds : undefined,
|
|
maxConnections: (row.max_connections as number | null) ?? undefined,
|
|
}
|
|
}
|
|
|
|
export function rowToCableTypeFields(row: Record<string, unknown>): Omit<CableType, 'id' | 'custom'> {
|
|
return {
|
|
name: row.name as string,
|
|
family: row.family as string,
|
|
family2: (row.family2 as string | null) ?? undefined,
|
|
unit: row.unit as CableType['unit'],
|
|
costPerUnit: (row.cost_per_unit as number | null) ?? undefined,
|
|
}
|
|
}
|
|
|
|
export function rowToDeviceTemplateFields(row: Record<string, unknown>): Omit<DeviceTemplate, 'id' | 'custom'> {
|
|
const ports = (row.ports as Array<{ name: string; direction: DeviceTemplate['ports'][number]['direction']; port_type_id: string }>) ?? []
|
|
return {
|
|
name: row.name as string,
|
|
category: row.category_id as string,
|
|
manufacturer: (row.manufacturer as string | null) ?? undefined,
|
|
model: (row.model as string | null) ?? undefined,
|
|
cost: (row.cost as number | null) ?? undefined,
|
|
ports: ports.map((port, index) => ({
|
|
id: `draft-port-${index}`,
|
|
name: port.name,
|
|
direction: port.direction,
|
|
portTypeId: port.port_type_id,
|
|
})),
|
|
}
|
|
}
|
|
|
|
export function rowToDeviceCategoryName(row: Record<string, unknown>): string {
|
|
return row.name as string
|
|
}
|