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.
289 lines
12 KiB
TypeScript
289 lines
12 KiB
TypeScript
import { v4 as uuid } from 'uuid'
|
|
import type { Catalog, CableType, DeviceCategoryDef, DeviceTemplate, Port, PortType } from '../domain/types'
|
|
import { CATALOG_TABLE_BY_ENTITY_TYPE, cableTypeToRow, deviceTemplateToRow, portTypeToRow } from './catalogRowMapping'
|
|
import type { CatalogRepository } from './CatalogRepository'
|
|
import type { CatalogEntityType } from './SubmissionRepository'
|
|
import { supabase } from './supabaseClient'
|
|
|
|
interface DeviceCategoryRow {
|
|
id: string
|
|
name: string
|
|
is_public: boolean
|
|
}
|
|
|
|
interface PortTypeRow {
|
|
id: string
|
|
name: string
|
|
category: PortType['category']
|
|
family: string
|
|
compatible_family_ids: string[]
|
|
max_connections: number | null
|
|
is_public: boolean
|
|
}
|
|
|
|
interface CableTypeRow {
|
|
id: string
|
|
name: string
|
|
family: string
|
|
family2: string | null
|
|
unit: CableType['unit']
|
|
cost_per_unit: number | null
|
|
is_public: boolean
|
|
}
|
|
|
|
interface DeviceTemplateRow {
|
|
id: string
|
|
name: string
|
|
category_id: string
|
|
manufacturer: string | null
|
|
model: string | null
|
|
cost: number | null
|
|
is_public: boolean
|
|
}
|
|
|
|
interface DeviceTemplatePortRow {
|
|
id: string
|
|
device_template_id: string
|
|
name: string
|
|
direction: Port['direction']
|
|
port_type_id: string
|
|
sort_order: number
|
|
}
|
|
|
|
function toDeviceCategory(row: DeviceCategoryRow): DeviceCategoryDef {
|
|
return { id: row.id, name: row.name, custom: !row.is_public }
|
|
}
|
|
|
|
function toPortType(row: PortTypeRow): PortType {
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
category: row.category,
|
|
family: row.family,
|
|
compatibleFamilyIds: row.compatible_family_ids.length > 0 ? row.compatible_family_ids : undefined,
|
|
maxConnections: row.max_connections ?? undefined,
|
|
custom: !row.is_public,
|
|
}
|
|
}
|
|
|
|
function toCableType(row: CableTypeRow): CableType {
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
family: row.family,
|
|
family2: row.family2 ?? undefined,
|
|
unit: row.unit,
|
|
costPerUnit: row.cost_per_unit ?? undefined,
|
|
custom: !row.is_public,
|
|
}
|
|
}
|
|
|
|
function toDeviceTemplate(row: DeviceTemplateRow, portRows: DeviceTemplatePortRow[]): DeviceTemplate {
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
category: row.category_id,
|
|
manufacturer: row.manufacturer ?? undefined,
|
|
model: row.model ?? undefined,
|
|
cost: row.cost ?? undefined,
|
|
ports: portRows
|
|
.filter((p) => p.device_template_id === row.id)
|
|
.sort((a, b) => a.sort_order - b.sort_order)
|
|
.map((p) => ({ id: p.id, name: p.name, direction: p.direction, portTypeId: p.port_type_id })),
|
|
custom: !row.is_public,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Backs the app with the shared Supabase catalog tables instead of the
|
|
* per-diagram embedded custom-type arrays the app used before this pass.
|
|
* RLS scopes every select to what the current user can see (all public
|
|
* entries, plus their own private ones) — see the `*_select` policies in
|
|
* the init schema migration. Every write here creates or edits a private
|
|
* (is_public = false) row owned by the current user; there is no public
|
|
* write path yet (that's the submission workflow, organized-ideas.md §3).
|
|
*/
|
|
export class SupabaseCatalogRepository implements CatalogRepository {
|
|
async load(): Promise<Catalog> {
|
|
const [categories, portTypes, cableTypes, templates, templatePorts] = await Promise.all([
|
|
supabase.from('device_categories').select('id, name, is_public').order('name'),
|
|
supabase
|
|
.from('port_types')
|
|
.select('id, name, category, family, compatible_family_ids, max_connections, is_public')
|
|
.order('name'),
|
|
supabase
|
|
.from('cable_types')
|
|
.select('id, name, family, family2, unit, cost_per_unit, is_public')
|
|
.order('name'),
|
|
supabase.from('device_templates').select('id, name, category_id, manufacturer, model, cost, is_public').order('name'),
|
|
supabase.from('device_template_ports').select('id, device_template_id, name, direction, port_type_id, sort_order'),
|
|
])
|
|
|
|
for (const [label, result] of [
|
|
['device categories', categories],
|
|
['port types', portTypes],
|
|
['cable types', cableTypes],
|
|
['device templates', templates],
|
|
['device template ports', templatePorts],
|
|
] as const) {
|
|
if (result.error) console.error(`Failed to load ${label} from Supabase`, result.error)
|
|
}
|
|
|
|
const portRows = (templatePorts.data ?? []) as DeviceTemplatePortRow[]
|
|
return {
|
|
deviceCategories: ((categories.data ?? []) as DeviceCategoryRow[]).map(toDeviceCategory),
|
|
portTypes: ((portTypes.data ?? []) as PortTypeRow[]).map(toPortType),
|
|
cableTypes: ((cableTypes.data ?? []) as CableTypeRow[]).map(toCableType),
|
|
deviceTemplates: ((templates.data ?? []) as DeviceTemplateRow[]).map((row) => toDeviceTemplate(row, portRows)),
|
|
}
|
|
}
|
|
|
|
private async currentUserId(): Promise<string | null> {
|
|
const {
|
|
data: { user },
|
|
} = await supabase.auth.getUser()
|
|
return user?.id ?? null
|
|
}
|
|
|
|
async addDeviceCategory(name: string): Promise<DeviceCategoryDef> {
|
|
const ownerId = await this.currentUserId()
|
|
const id = uuid()
|
|
const { error } = await supabase
|
|
.from('device_categories')
|
|
.insert({ id, name, is_public: false, owner_id: ownerId })
|
|
if (error) console.error('Failed to add device category to Supabase', error)
|
|
return { id, name, custom: true }
|
|
}
|
|
|
|
async addPortType(portType: Omit<PortType, 'id' | 'custom'>): Promise<PortType> {
|
|
const ownerId = await this.currentUserId()
|
|
const id = uuid()
|
|
const { error } = await supabase
|
|
.from('port_types')
|
|
.insert({ id, ...portTypeToRow(portType), is_public: false, owner_id: ownerId })
|
|
if (error) console.error('Failed to add port type to Supabase', error)
|
|
return { ...portType, id, custom: true }
|
|
}
|
|
|
|
async updatePortType(id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>): Promise<void> {
|
|
const payload: Record<string, unknown> = {}
|
|
if (patch.name !== undefined) payload.name = patch.name
|
|
if (patch.category !== undefined) payload.category = patch.category
|
|
if (patch.family !== undefined) payload.family = patch.family
|
|
if (patch.compatibleFamilyIds !== undefined) payload.compatible_family_ids = patch.compatibleFamilyIds
|
|
if (patch.maxConnections !== undefined) payload.max_connections = patch.maxConnections
|
|
const { error } = await supabase.from('port_types').update(payload).eq('id', id)
|
|
if (error) console.error('Failed to update port type in Supabase', error)
|
|
}
|
|
|
|
async addCableType(cableType: Omit<CableType, 'id' | 'custom'>): Promise<CableType> {
|
|
const ownerId = await this.currentUserId()
|
|
const id = uuid()
|
|
const { error } = await supabase
|
|
.from('cable_types')
|
|
.insert({ id, ...cableTypeToRow(cableType), is_public: false, owner_id: ownerId })
|
|
if (error) console.error('Failed to add cable type to Supabase', error)
|
|
return { ...cableType, id, custom: true }
|
|
}
|
|
|
|
async updateCableType(id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>): Promise<void> {
|
|
const payload: Record<string, unknown> = {}
|
|
if (patch.name !== undefined) payload.name = patch.name
|
|
if (patch.family !== undefined) payload.family = patch.family
|
|
if (patch.family2 !== undefined) payload.family2 = patch.family2
|
|
if (patch.unit !== undefined) payload.unit = patch.unit
|
|
if (patch.costPerUnit !== undefined) payload.cost_per_unit = patch.costPerUnit
|
|
const { error } = await supabase.from('cable_types').update(payload).eq('id', id)
|
|
if (error) console.error('Failed to update cable type in Supabase', error)
|
|
}
|
|
|
|
async addDeviceTemplate(template: Omit<DeviceTemplate, 'id' | 'custom'>): Promise<DeviceTemplate> {
|
|
const ownerId = await this.currentUserId()
|
|
const id = uuid()
|
|
const { ports: _ports, ...templateRow } = deviceTemplateToRow(template)
|
|
const { error } = await supabase.from('device_templates').insert({ id, ...templateRow, is_public: false, owner_id: ownerId })
|
|
if (error) {
|
|
console.error('Failed to add device template to Supabase', error)
|
|
return { ...template, id, custom: true }
|
|
}
|
|
const ports = template.ports.map((port, index) => ({ ...port, id: uuid(), sortOrder: index }))
|
|
if (ports.length > 0) {
|
|
const { error: portsError } = await supabase.from('device_template_ports').insert(
|
|
ports.map((port) => ({
|
|
id: port.id,
|
|
device_template_id: id,
|
|
name: port.name,
|
|
direction: port.direction,
|
|
port_type_id: port.portTypeId,
|
|
sort_order: port.sortOrder,
|
|
})),
|
|
)
|
|
if (portsError) console.error('Failed to add device template ports to Supabase', portsError)
|
|
}
|
|
return { ...template, id, ports, custom: true }
|
|
}
|
|
|
|
async updateDeviceTemplate(id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>): Promise<void> {
|
|
const payload: Record<string, unknown> = {}
|
|
if (patch.name !== undefined) payload.name = patch.name
|
|
if (patch.category !== undefined) payload.category_id = patch.category
|
|
if (patch.manufacturer !== undefined) payload.manufacturer = patch.manufacturer
|
|
if (patch.model !== undefined) payload.model = patch.model
|
|
if (patch.cost !== undefined) payload.cost = patch.cost
|
|
if (Object.keys(payload).length > 0) {
|
|
const { error } = await supabase.from('device_templates').update(payload).eq('id', id)
|
|
if (error) console.error('Failed to update device template in Supabase', error)
|
|
}
|
|
|
|
if (patch.ports !== undefined) {
|
|
// Simplest correct approach: replace the whole port set rather than
|
|
// diffing. Templates are edited as a whole in the UI (DeviceTemplateEditor),
|
|
// so there's no risk of clobbering a concurrent partial edit.
|
|
const { error: deleteError } = await supabase.from('device_template_ports').delete().eq('device_template_id', id)
|
|
if (deleteError) console.error('Failed to clear device template ports in Supabase', deleteError)
|
|
if (patch.ports.length > 0) {
|
|
const { error: insertError } = await supabase.from('device_template_ports').insert(
|
|
patch.ports.map((port, index) => ({
|
|
id: uuid(),
|
|
device_template_id: id,
|
|
name: port.name,
|
|
direction: port.direction,
|
|
port_type_id: port.portTypeId,
|
|
sort_order: index,
|
|
})),
|
|
)
|
|
if (insertError) console.error('Failed to replace device template ports in Supabase', insertError)
|
|
}
|
|
}
|
|
}
|
|
|
|
async removeDeviceTemplate(id: string): Promise<void> {
|
|
const { error } = await supabase.from('device_templates').delete().eq('id', id)
|
|
if (error) console.error('Failed to remove device template from Supabase', error)
|
|
}
|
|
|
|
// The update* methods above never check ownership themselves — RLS does,
|
|
// server-side, based on the caller's identity — so an Admin/Super-Admin
|
|
// direct edit of a public entry is *exactly* the same write, just
|
|
// permitted by a different branch of the same policy (`is_admin()`
|
|
// instead of "owns it and it's still private"). These are aliases, not
|
|
// separate logic, so the two paths can't drift apart.
|
|
adminUpdatePortType(id: string, patch: Partial<Omit<PortType, 'id' | 'custom'>>): Promise<void> {
|
|
return this.updatePortType(id, patch)
|
|
}
|
|
|
|
adminUpdateCableType(id: string, patch: Partial<Omit<CableType, 'id' | 'custom'>>): Promise<void> {
|
|
return this.updateCableType(id, patch)
|
|
}
|
|
|
|
adminUpdateDeviceTemplate(id: string, patch: Partial<Omit<DeviceTemplate, 'id' | 'custom'>>): Promise<void> {
|
|
return this.updateDeviceTemplate(id, patch)
|
|
}
|
|
|
|
async adminUnpublish(entityType: CatalogEntityType, id: string): Promise<void> {
|
|
const table = CATALOG_TABLE_BY_ENTITY_TYPE[entityType]
|
|
const { error } = await supabase.from(table).update({ is_public: false }).eq('id', id)
|
|
if (error) console.error('Failed to unpublish catalog entry in Supabase', error)
|
|
}
|
|
}
|