diff --git a/api/main.py b/api/main.py index ee00026..0578adb 100644 --- a/api/main.py +++ b/api/main.py @@ -55,6 +55,7 @@ "purr_publication_requests", "data_uploads", "ingestion_logs", + "equipment_access", ) STARTUP_MIGRATION_LOCK_ID = 6420260506 @@ -154,6 +155,7 @@ def _run_startup_migrations() -> None: ensure_run_version_tracking_columns_pg, ensure_user_role_audit_table_pg, ensure_sample_and_publication_tables_pg, + ensure_equipment_access_table_pg, ) lock_conn = get_pg_superuser_connection() @@ -176,6 +178,7 @@ def _run_startup_migrations() -> None: ensure_run_version_tracking_columns_pg() ensure_user_role_audit_table_pg() ensure_sample_and_publication_tables_pg() + ensure_equipment_access_table_pg() finally: try: with lock_conn.cursor() as cur: diff --git a/api/metadata_pg.py b/api/metadata_pg.py index 4a92763..97920d7 100644 --- a/api/metadata_pg.py +++ b/api/metadata_pg.py @@ -547,6 +547,188 @@ def ensure_sample_and_publication_tables_pg() -> None: conn.close() +def ensure_equipment_access_table_pg() -> None: + """Create the equipment_access table for trusted maintainers.""" + conn = get_pg_superuser_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + CREATE TABLE IF NOT EXISTS equipment_access ( + equipment_id VARCHAR(255) NOT NULL REFERENCES equipment_metadata(domain_id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role VARCHAR(50) NOT NULL DEFAULT 'editor', + granted_by VARCHAR(255) DEFAULT '', + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (equipment_id, user_id) + ) + """ + ) + cur.execute( + """ + CREATE INDEX IF NOT EXISTS idx_equipment_access_user + ON equipment_access(user_id) + """ + ) + cur.execute( + "GRANT SELECT, INSERT, UPDATE, DELETE ON equipment_access TO api_client" + ) + conn.commit() + finally: + conn.close() + + +def grant_equipment_access_pg( + equipment_id: str, + user_id: str, + granted_by: str, +) -> dict[str, Any]: + """Grant a user trusted-maintainer access to a piece of equipment.""" + conn = get_pg_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + # Verify equipment exists + cur.execute( + "SELECT domain_id, owner_id FROM equipment_metadata WHERE domain_id = %s", + (equipment_id,), + ) + eq = cur.fetchone() + if not eq: + return {"status": "not_found", "detail": "Equipment not found"} + + # Verify target user exists + cur.execute( + "SELECT id, name, email, organization FROM users WHERE id = %s", + (user_id,), + ) + target_user = cur.fetchone() + if not target_user: + return {"status": "user_not_found", "detail": "User not found"} + + # Don't add the owner as a maintainer + if eq["owner_id"] == user_id: + return {"status": "is_owner", "detail": "User is already the equipment owner"} + + cur.execute( + """ + INSERT INTO equipment_access (equipment_id, user_id, role, granted_by) + VALUES (%s, %s, 'editor', %s) + ON CONFLICT (equipment_id, user_id) DO NOTHING + RETURNING equipment_id, user_id, role, granted_by, created_at + """, + (equipment_id, user_id, granted_by), + ) + row = cur.fetchone() + conn.commit() + + if not row: + return { + "status": "already_exists", + "detail": "User already has access", + "user_id": user_id, + "name": target_user["name"], + "email": target_user["email"], + } + + return { + "status": "granted", + "equipment_id": equipment_id, + "user_id": user_id, + "name": target_user["name"], + "email": target_user["email"], + "organization": target_user["organization"], + "role": row["role"], + "granted_by": row["granted_by"], + "created_at": _iso(row["created_at"]), + } + finally: + conn.close() + + +def revoke_equipment_access_pg( + equipment_id: str, + user_id: str, +) -> dict[str, Any]: + """Remove a user's trusted-maintainer access from a piece of equipment.""" + conn = get_pg_connection() + try: + with conn.cursor() as cur: + cur.execute( + "DELETE FROM equipment_access WHERE equipment_id = %s AND user_id = %s", + (equipment_id, user_id), + ) + deleted = cur.rowcount + conn.commit() + if not deleted: + return {"status": "not_found", "detail": "Access record not found"} + return {"status": "revoked", "equipment_id": equipment_id, "user_id": user_id} + finally: + conn.close() + + +def list_equipment_access_pg(equipment_id: str) -> list[dict[str, Any]]: + """List all trusted maintainers for a piece of equipment.""" + conn = get_pg_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT + ea.user_id, + ea.role, + ea.granted_by, + ea.created_at, + u.name, + u.email, + u.organization + FROM equipment_access ea + JOIN users u ON u.id = ea.user_id + WHERE ea.equipment_id = %s + ORDER BY ea.created_at + """, + (equipment_id,), + ) + rows = cur.fetchall() + conn.commit() + return [ + { + "user_id": row["user_id"], + "name": row["name"], + "email": row["email"], + "organization": row["organization"], + "role": row["role"], + "granted_by": row["granted_by"], + "created_at": _iso(row["created_at"]), + } + for row in rows + ] + finally: + conn.close() + + +def search_users_pg(query: str, limit: int = 10) -> list[dict[str, Any]]: + """Search active users by name or email for the maintainer picker.""" + conn = get_pg_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT id, name, email, organization + FROM users + WHERE status = 'active' + AND (name ILIKE %s OR email ILIKE %s) + ORDER BY name + LIMIT %s + """, + (f"%{query}%", f"%{query}%", limit), + ) + rows = cur.fetchall() + conn.commit() + return [dict(row) for row in rows] + finally: + conn.close() + + def update_experiment_proposal_generation_status_pg( *, experiment_id: str, @@ -806,6 +988,10 @@ def equipment_visible_to_user( if owner_id == user.id: return True + # Trusted maintainer check + maintainer_ids = record.get("_maintainer_ids", []) + if user.id in maintainer_ids: + return True if status != "approved": return False if owner_org and user.organization and owner_org != user.organization: @@ -866,6 +1052,7 @@ def _serialize_equipment( "primary_target": _coerce_json(record.get("primary_target_json"), {}), "secondary_target": _coerce_json(record.get("secondary_target_json"), {}), "config_json": config_json, + "maintainers": record.get("_maintainers", []), } @@ -927,12 +1114,21 @@ def list_equipment_pg(user: PlatformUser) -> list[dict[str, Any]]: ) rows = cur.fetchall() + # Fetch maintainer mappings for visibility checks + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute("SELECT equipment_id, user_id FROM equipment_access") + access_rows = cur.fetchall() + maintainer_map: dict[str, list[str]] = {} + for arow in access_rows: + maintainer_map.setdefault(arow["equipment_id"], []).append(arow["user_id"]) + metrics = _fetch_equipment_run_metrics(conn) conn.commit() visible: list[dict[str, Any]] = [] for row in rows: record = dict(row) + record["_maintainer_ids"] = maintainer_map.get(record["domain_id"], []) if equipment_visible_to_user(record, user): visible.append( _serialize_equipment(record, metrics.get(record["domain_id"])) @@ -985,9 +1181,46 @@ def get_equipment_pg(domain_id: str) -> dict[str, Any] | None: conn.commit() return None + # Fetch maintainer IDs for visibility check + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + "SELECT user_id FROM equipment_access WHERE equipment_id = %s", + (domain_id,), + ) + maintainer_rows = cur.fetchall() + record = dict(row) + record["_maintainer_ids"] = [r["user_id"] for r in maintainer_rows] + + # Fetch full maintainer details for serialized output + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT ea.user_id, ea.role, ea.granted_by, ea.created_at, + u.name, u.email, u.organization + FROM equipment_access ea + JOIN users u ON u.id = ea.user_id + WHERE ea.equipment_id = %s + ORDER BY ea.created_at + """, + (domain_id,), + ) + maintainer_detail_rows = cur.fetchall() + record["_maintainers"] = [ + { + "user_id": r["user_id"], + "name": r["name"], + "email": r["email"], + "organization": r["organization"], + "role": r["role"], + "granted_by": r["granted_by"], + "created_at": _iso(r["created_at"]), + } + for r in maintainer_detail_rows + ] + metrics = _fetch_equipment_run_metrics(conn) conn.commit() - return _serialize_equipment(dict(row), metrics.get(domain_id)) + return _serialize_equipment(record, metrics.get(domain_id)) finally: conn.close() @@ -1278,7 +1511,13 @@ def update_equipment_pg( return {"status": "not_found", "domain_id": domain_id} if user.role != "admin" and existing["owner_id"] != user.id: - raise PermissionError("Only the equipment owner or an admin can edit this equipment") + # Check if user is a trusted maintainer + cur.execute( + "SELECT 1 FROM equipment_access WHERE equipment_id = %s AND user_id = %s", + (domain_id, user.id), + ) + if not cur.fetchone(): + raise PermissionError("Only the equipment owner, a trusted maintainer, or an admin can edit this equipment") owner_id = existing.get("owner_id") or user.id owner_org = existing.get("owner_org") or user.organization diff --git a/api/routers/equipment.py b/api/routers/equipment.py index 440c2c6..17d922e 100644 --- a/api/routers/equipment.py +++ b/api/routers/equipment.py @@ -29,11 +29,15 @@ get_equipment_pg, get_experiment_type_versions_pg, generate_initial_experiment_proposals_pg, + grant_equipment_access_pg, + list_equipment_access_pg, list_experiment_definitions_pg, list_experiment_types_pg, list_public_experiment_types_pg, list_equipment_pg, register_equipment_pg, + revoke_equipment_access_pg, + search_users_pg, update_experiment_proposal_generation_status_pg, update_experiment_type_pg, update_equipment_pg, @@ -200,6 +204,25 @@ class ExperimentPayload(BaseModel): owner_org: str = "" +class AddMaintainerPayload(BaseModel): + user_id: str + + +def _can_manage_maintainers( + equipment: dict, + user: PlatformUser, + conn=None, +) -> bool: + """Check if the user can manage the maintainer list.""" + if user.role == "admin": + return True + if user.id == equipment.get("owner_id"): + return True + # Check if user is already a trusted maintainer + maintainers = equipment.get("maintainers", []) + return any(m.get("user_id") == user.id for m in maintainers) + + def _has_planned_parameter_values(params: List[PlannedParamPayload]) -> bool: return any( param.name.strip() @@ -666,6 +689,88 @@ def update_recipe_proposal_status( return result +# ── Trusted Maintainers ───────────────────────────────────────────────────── + +@router.get("/{domain_id}/maintainers") +def list_maintainers( + domain_id: str, + user: PlatformUser = Depends(get_platform_user), +): + """List trusted maintainers for a piece of equipment.""" + item = get_equipment_pg(domain_id) + if not item: + raise HTTPException(status_code=404, detail="Equipment not found") + if not equipment_visible_to_user(item, user): + raise HTTPException(status_code=403, detail="Not authorized to view this equipment") + return list_equipment_access_pg(domain_id) + + +@router.post("/{domain_id}/maintainers") +def add_maintainer( + domain_id: str, + payload: AddMaintainerPayload, + user: PlatformUser = Depends(get_platform_user), +): + """Add a trusted maintainer to a piece of equipment.""" + item = get_equipment_pg(domain_id) + if not item: + raise HTTPException(status_code=404, detail="Equipment not found") + if not _can_manage_maintainers(item, user): + raise HTTPException(status_code=403, detail="Only the owner, existing maintainers, or admins can manage access") + + result = grant_equipment_access_pg( + equipment_id=domain_id, + user_id=payload.user_id, + granted_by=user.id, + ) + if result["status"] == "not_found": + raise HTTPException(status_code=404, detail=result["detail"]) + if result["status"] == "user_not_found": + raise HTTPException(status_code=404, detail=result["detail"]) + if result["status"] == "is_owner": + raise HTTPException(status_code=400, detail=result["detail"]) + return result + + +@router.delete("/{domain_id}/maintainers/{target_user_id}") +def remove_maintainer( + domain_id: str, + target_user_id: str, + user: PlatformUser = Depends(get_platform_user), +): + """Remove a trusted maintainer from a piece of equipment.""" + item = get_equipment_pg(domain_id) + if not item: + raise HTTPException(status_code=404, detail="Equipment not found") + if not _can_manage_maintainers(item, user): + raise HTTPException(status_code=403, detail="Only the owner, existing maintainers, or admins can manage access") + + result = revoke_equipment_access_pg( + equipment_id=domain_id, + user_id=target_user_id, + ) + if result["status"] == "not_found": + raise HTTPException(status_code=404, detail=result["detail"]) + return result + + +@router.get("/{domain_id}/users/search") +def search_users_for_equipment( + domain_id: str, + q: str = "", + user: PlatformUser = Depends(get_platform_user), +): + """Search registered users by name or email for the maintainer picker.""" + item = get_equipment_pg(domain_id) + if not item: + raise HTTPException(status_code=404, detail="Equipment not found") + if not _can_manage_maintainers(item, user): + raise HTTPException(status_code=403, detail="Only the owner, existing maintainers, or admins can search users") + if not q.strip(): + return [] + return search_users_pg(q.strip(), limit=10) + + @router.delete("/{domain_id}") def delete_equipment( domain_id: str, diff --git a/web/app/equipment/[id]/page.tsx b/web/app/equipment/[id]/page.tsx index 13fb5ef..7ff1357 100644 --- a/web/app/equipment/[id]/page.tsx +++ b/web/app/equipment/[id]/page.tsx @@ -9,8 +9,13 @@ import { getEquipmentDetail, getV2Projects, getProposalsData, + addEquipmentMaintainer, + removeEquipmentMaintainer, + searchUsersForEquipment, type EquipmentInfo, + type EquipmentMaintainer, type ProposalBatch, + type UserSearchResult, type V2Project, } from "@/lib/api-client"; import Link from "next/link"; @@ -29,6 +34,11 @@ import { Trash2, AlertTriangle, SlidersHorizontal, + Users, + X, + Search, + UserPlus, + Loader2, } from "lucide-react"; const STATUS_STYLES: Record = { @@ -56,6 +66,14 @@ export default function EquipmentDetailPage({ const [deleting, setDeleting] = useState(false); const [deleteError, setDeleteError] = useState(null); + // Trusted Maintainers state + const [maintainers, setMaintainers] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [searching, setSearching] = useState(false); + const [addingMaintainer, setAddingMaintainer] = useState(false); + const [maintainerError, setMaintainerError] = useState(null); + useEffect(() => { const fetchData = async () => { const [eqData, projData, runsData] = await Promise.all([ @@ -64,7 +82,10 @@ export default function EquipmentDetailPage({ getProposalsData() ]); - if (eqData) setEquipment(eqData); + if (eqData) { + setEquipment(eqData); + setMaintainers(eqData.maintainers ?? []); + } if (projData) setProjects(projData.filter((p) => p.equipment_id === id)); if (runsData && runsData.batches) { setRuns(runsData.batches.slice(0, 3)); @@ -75,6 +96,31 @@ export default function EquipmentDetailPage({ fetchData(); }, [id]); + // Debounced user search for maintainer picker + useEffect(() => { + if (!searchQuery.trim() || !equipment) { + setSearchResults([]); + return; + } + const timer = setTimeout(async () => { + setSearching(true); + const equipmentDomainId = equipment.domain_id || equipment.id || id; + const results = await searchUsersForEquipment(equipmentDomainId, searchQuery.trim()); + if (results) { + // Filter out the owner and existing maintainers + const existingIds = new Set([ + equipment.owner_id, + ...maintainers.map(m => m.user_id), + ]); + setSearchResults(results.filter(u => !existingIds.has(u.id))); + } else { + setSearchResults([]); + } + setSearching(false); + }, 300); + return () => clearTimeout(timer); + }, [searchQuery, equipment, maintainers, id]); + if (loading) { return (
@@ -117,12 +163,53 @@ export default function EquipmentDetailPage({ // represents a canonical production resource; the backend also refuses // those requests defensively. const isDeletable = equipment.source === "postgres"; + const isMaintainer = maintainers.some(m => m.user_id === user.id); const canEdit = isDeletable && - (user.role === "admin" || (user.id && user.id === equipment.owner_id)); + (user.role === "admin" || (user.id && user.id === equipment.owner_id) || isMaintainer); const canDelete = isDeletable && (user.role === "admin" || (user.id && user.id === equipment.owner_id)); + const canManageMaintainers = + isDeletable && + (user.role === "admin" || (user.id && user.id === equipment.owner_id) || isMaintainer); + + const handleAddMaintainer = async (userId: string) => { + setAddingMaintainer(true); + setMaintainerError(null); + const result = await addEquipmentMaintainer(equipmentDomainId, userId); + setAddingMaintainer(false); + if (!result.ok) { + setMaintainerError(result.error ?? "Failed to add maintainer"); + return; + } + if (result.data) { + setMaintainers(prev => [ + ...prev, + { + user_id: result.data!.user_id, + name: result.data!.name, + email: result.data!.email, + organization: result.data!.organization ?? null, + role: result.data!.role ?? "editor", + granted_by: result.data!.granted_by ?? "", + created_at: result.data!.created_at ?? null, + }, + ]); + } + setSearchQuery(""); + setSearchResults([]); + }; + + const handleRemoveMaintainer = async (userId: string) => { + setMaintainerError(null); + const result = await removeEquipmentMaintainer(equipmentDomainId, userId); + if (!result.ok) { + setMaintainerError(result.error ?? "Failed to remove maintainer"); + return; + } + setMaintainers(prev => prev.filter(m => m.user_id !== userId)); + }; const handleConfirmDelete = async () => { if (!canDelete) return; @@ -281,6 +368,114 @@ export default function EquipmentDetailPage({ {operational.service_notes}

)} + + {/* Trusted Maintainers */} +
+

+ + Trusted Maintainers + + ({maintainers.length}) + +

+ + {maintainerError && ( +
+ {maintainerError} +
+ )} + + {/* Maintainer list */} + {maintainers.length > 0 ? ( +
+ {maintainers.map((m) => ( +
+
+
+ {(m.name || m.email || "?").charAt(0).toUpperCase()} +
+
+

+ {m.name || m.email} +

+

+ {m.email} +

+
+
+ {canManageMaintainers && ( + + )} +
+ ))} +
+ ) : ( +

+ No trusted maintainers yet. +

+ )} + + {/* Add maintainer form */} + {canManageMaintainers && ( +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search by email to add..." + className="w-full pl-8 pr-3 py-2 text-sm rounded-lg border border-[hsl(var(--border))] bg-transparent placeholder:text-[hsl(var(--muted-foreground))] focus:outline-none focus:ring-1 focus:ring-[hsl(var(--primary))]" + disabled={addingMaintainer} + /> + {searching && ( + + )} +
+ + {/* Search results dropdown */} + {searchResults.length > 0 && ( +
+ {searchResults.map((u) => ( + + ))} +
+ )} + + {searchQuery.trim() && !searching && searchResults.length === 0 && ( +
+

+ No matching users found. +

+
+ )} +
+ )} +
diff --git a/web/lib/api-client.ts b/web/lib/api-client.ts index 6df1078..5c4ab99 100644 --- a/web/lib/api-client.ts +++ b/web/lib/api-client.ts @@ -619,6 +619,16 @@ export interface EquipmentTarget { transform_multiplier?: number | null; } +export interface EquipmentMaintainer { + user_id: string; + name: string | null; + email: string | null; + organization: string | null; + role: string; + granted_by: string; + created_at: string | null; +} + export interface EquipmentInfo { domain_id?: string; id?: string; @@ -665,6 +675,7 @@ export interface EquipmentInfo { }; [key: string]: unknown; }; + maintainers?: EquipmentMaintainer[]; } export interface EquipmentSimple { @@ -778,6 +789,41 @@ export function deleteEquipment(domainId: string) { ); } +// ── Equipment Trusted Maintainers ─────────────────────────────────────────── + +export interface UserSearchResult { + id: string; + name: string; + email: string; + organization: string; +} + +export function getEquipmentMaintainers(domainId: string) { + return apiFetch( + `/equipment/${encodeURIComponent(domainId)}/maintainers`, + ); +} + +export function addEquipmentMaintainer(domainId: string, userId: string) { + return apiJsonResult<{ user_id: string }, EquipmentMaintainer & { status: string }>( + `/equipment/${encodeURIComponent(domainId)}/maintainers`, + "POST", + { user_id: userId }, + ); +} + +export function removeEquipmentMaintainer(domainId: string, userId: string) { + return apiDelete<{ status: string; equipment_id: string; user_id: string }>( + `/equipment/${encodeURIComponent(domainId)}/maintainers/${encodeURIComponent(userId)}`, + ); +} + +export function searchUsersForEquipment(domainId: string, query: string) { + return apiFetch( + `/equipment/${encodeURIComponent(domainId)}/users/search?q=${encodeURIComponent(query)}`, + ); +} + export interface ExperimentTypeParameterInput { name: string; type?: string;