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:
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { api, canEditRound, type AssetBalances, type Entity, type EntityType, type Holding, type User, type ValuationRound } from "../api";
|
||||
import { api, canManageEntity, type AssetBalances, type Entity, type EntityType, type Holding, type User, type ValuationRound } from "../api";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { formatMoney, formatDate, formatQuarter } from "../format";
|
||||
import EntityHeader from "../components/EntityHeader";
|
||||
@@ -25,7 +25,7 @@ export default function EntityOverview() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const isWriter = !!user && canEditRound(user.role);
|
||||
const isWriter = !!user && canManageEntity(user.role);
|
||||
|
||||
// For a GP/mgmt entity linked to an investor account, total its balance across funds.
|
||||
const linkedTotal = useMemo(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { api, canEditRound, type Entity, type Partner } from "../api";
|
||||
import { api, canManageEntity, type Entity, type Partner } from "../api";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { formatDate, formatMoneyExact } from "../format";
|
||||
import EntityHeader from "../components/EntityHeader";
|
||||
@@ -18,7 +18,7 @@ export default function EntityPartners() {
|
||||
const [exitingId, setExitingId] = useState<number | null>(null);
|
||||
const [exitDate, setExitDate] = useState(() => new Date().toISOString().slice(0, 10));
|
||||
|
||||
const isWriter = !!user && canEditRound(user.role);
|
||||
const isWriter = !!user && canManageEntity(user.role);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -2,19 +2,20 @@ import { useEffect, useState } from "react";
|
||||
import {
|
||||
api,
|
||||
EXTERNAL_ROLES,
|
||||
isAdministrator,
|
||||
isInternal,
|
||||
type Entity,
|
||||
type User,
|
||||
type UserDetail,
|
||||
type UserRole,
|
||||
} from "../api";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { roleLabel } from "../format";
|
||||
import PasswordInput from "../components/PasswordInput";
|
||||
|
||||
const CREATABLE_ROLES: UserRole[] = [
|
||||
"investor",
|
||||
"fund_administrator",
|
||||
"fund_admin",
|
||||
"operations",
|
||||
"approver",
|
||||
];
|
||||
@@ -181,6 +182,9 @@ function CreateUserModal({
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const { user: me } = useAuth();
|
||||
// An external Administrator only ever creates investor accounts for their funds.
|
||||
const investorOnly = me != null && isAdministrator(me.role);
|
||||
const [name, setName] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -233,13 +237,19 @@ function CreateUserModal({
|
||||
<PasswordInput value={password} onChange={setPassword} placeholder="minimum 4 characters" />
|
||||
</Field>
|
||||
<Field label="Role">
|
||||
<select className={inputCls} value={role} onChange={(e) => setRole(e.target.value as UserRole)}>
|
||||
{CREATABLE_ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{roleLabel(r)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{investorOnly ? (
|
||||
<p className="px-3 py-2 border border-gray-200 rounded text-sm text-gray-600 bg-gray-50">
|
||||
Investor
|
||||
</p>
|
||||
) : (
|
||||
<select className={inputCls} value={role} onChange={(e) => setRole(e.target.value as UserRole)}>
|
||||
{CREATABLE_ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{roleLabel(r)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
{external && (
|
||||
<Field label="Entity access">
|
||||
@@ -285,7 +295,28 @@ function EditUserModal({
|
||||
const [linkTo, setLinkTo] = useState<number | "">(user.primary_account_id ?? "");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const { user: me } = useAuth();
|
||||
const external = !isInternal(user.role);
|
||||
const canDelete = !user.is_service_admin && user.id !== me?.id;
|
||||
|
||||
const remove = async () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Delete ${user.name} (${user.username})? Their fund access, capital statements, ` +
|
||||
"and private documents are removed too. This cannot be undone.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.deleteUser(user.id);
|
||||
onSaved();
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Failed to delete");
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const isInvestor = user.role === "investor";
|
||||
const isPrimary = user.linked_accounts.length > 0;
|
||||
const linked = linkTo !== "";
|
||||
@@ -380,17 +411,28 @@ function EditUserModal({
|
||||
</Field>
|
||||
)}
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button onClick={onClose} className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={busy}
|
||||
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={remove}
|
||||
disabled={busy}
|
||||
className="px-3 py-1.5 text-sm text-red-600 hover:text-red-700 disabled:opacity-50"
|
||||
>
|
||||
Delete user
|
||||
</button>
|
||||
)}
|
||||
<div className="ml-auto flex gap-2">
|
||||
<button onClick={onClose} className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={busy}
|
||||
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
Reference in New Issue
Block a user