0.2.42: external Administrator role with entity-scoped management

The external fund_administrator role (relabeled Administrator) now signs
into the full admin interface, fenced to the funds and SPVs granted to
it via EntityAccess:

- Partners, capital accounts, documents (upload and delete), entity
  edits, and eNAV imports for its own funds only; no fund creation,
  valuation sign-off, audit log, or investor view.
- Scoped user management: sees and manages only investors tied to its
  funds; creates investor accounts only; updates preserve grants on
  funds outside its scope.
- New DELETE /api/users/{id} (in-app Delete user button) with the
  cascade cleanup factored out of the CLI; Service Admin and self are
  protected, and an Administrator can only delete an investor who
  belongs solely to its funds.
- Internal fund_admin relabeled 'Staff (all funds)' and dropped from
  the create picker to end the two-similar-names confusion.
- Version badge removed from the UI (sidebar and portal header); the
  build version now logs to the browser console instead.
- deploy/.startos (signing key) added to .gitignore.
This commit is contained in:
Jonathan Kirkwood
2026-08-10 15:38:39 -05:00
parent 3c7094241c
commit ae967494bd
29 changed files with 693 additions and 332 deletions
+19 -14
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import {
api, isAdmin,
api, isAdministrator, isManager,
type Entity, type EntityType, type CapitalImportPreview, type BatchCapitalImportResult,
type BtcPricesStatus,
} from "../api";
@@ -47,7 +47,10 @@ export default function Import() {
const [step, setStep] = useState("");
const [confirmReplace, setConfirmReplace] = useState(false);
const canImport = user && isAdmin(user.role);
const canImport = user && isManager(user.role);
// An Administrator imports into their own funds only — no fund creation, and the
// portal-wide BTC price table stays internal.
const administrator = !!user && isAdministrator(user.role);
const inputCls = "w-full px-3 py-2 border border-gray-300 rounded text-sm";
useEffect(() => {
@@ -209,7 +212,7 @@ export default function Import() {
}
if (!canImport) {
return <div className="text-gray-500 text-sm">Import requires Managing Partner, Operations, or CFO role.</div>;
return <div className="text-gray-500 text-sm">Import requires an admin role.</div>;
}
return (
@@ -239,16 +242,18 @@ export default function Import() {
<div className="bg-white border border-gray-200 rounded-lg p-4 space-y-3">
<div>
<label className="block text-sm text-gray-700 mb-2">Fund</label>
<div className="flex gap-4 mb-2">
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
<input type="radio" checked={entityMode === "existing"} onChange={() => { setEntityMode("existing"); reset(); }} className="text-accent-500" />
Use existing fund
</label>
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
<input type="radio" checked={entityMode === "from_file"} onChange={() => { setEntityMode("from_file"); reset(); }} className="text-accent-500" />
Create from file
</label>
</div>
{!administrator && (
<div className="flex gap-4 mb-2">
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
<input type="radio" checked={entityMode === "existing"} onChange={() => { setEntityMode("existing"); reset(); }} className="text-accent-500" />
Use existing fund
</label>
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
<input type="radio" checked={entityMode === "from_file"} onChange={() => { setEntityMode("from_file"); reset(); }} className="text-accent-500" />
Create from file
</label>
</div>
)}
{entityMode === "existing" ? (
<select value={entityId} onChange={(e) => { setEntityId(e.target.value); reset(); }} className={inputCls}>
<option value="">Select fund</option>
@@ -384,7 +389,7 @@ export default function Import() {
{!memberPv && !result && <BatchBackfill entities={entities} />}
{!memberPv && !result && <BtcPrices />}
{!memberPv && !result && !administrator && <BtcPrices />}
{confirmReplace && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50">