0.2.45: Administrator (view only) role

New external role administrator_viewer: signs into the admin interface
and reads everything for its granted funds and SPVs (overview, partners,
capital accounts with every investor's statements, documents, valuation
history) but every write is refused: no imports, uploads, deletions,
entity edits, exit marking, or account management. No migration needed;
roles are stored as strings.

Internal admins can flip an Administrator between full management and
view only via a new Access level dropdown in Users > Manage. The
user-list endpoint is read-widened for the viewer role so investor names
resolve on its screens; all mutating endpoints keep the stricter gate.
This commit is contained in:
Jonathan Kirkwood
2026-08-11 15:37:44 -05:00
parent 1dabbc3073
commit 858bbe10da
18 changed files with 273 additions and 70 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
// - content-hashed /assets/* are cache-first (immutable, safe forever)
// - /api/* is never cached
// Bump CACHE on each release so old entries are purged.
const CACHE = 'ten31-portal-0.2.44'
const CACHE = 'ten31-portal-0.2.45'
self.addEventListener('install', () => self.skipWaiting())
+7 -5
View File
@@ -1,6 +1,6 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { AuthProvider, useAuth } from "./context/AuthContext";
import { isAdministrator, isInternal } from "./api";
import { isExternalAdmin, isInternal } from "./api";
import { ForcePasswordChange, WelcomeFlow } from "./components/FirstLogin";
import Layout from "./components/Layout";
import Login from "./pages/Login";
@@ -27,6 +27,8 @@ function AdminApp() {
// the backend refuses them anyway.
const { user } = useAuth();
const internal = user != null && isInternal(user.role);
// A view-only Administrator reads its funds; the management screens stay off.
const manager = internal || user?.role === "fund_administrator";
return (
<Layout>
<Routes>
@@ -37,11 +39,11 @@ function AdminApp() {
<Route path="/entities/:id/investments" element={<Investments />} />
<Route path="/entities/:id/assets" element={<EntityAssets />} />
{internal && <Route path="/entities/:id/valuation" element={<ValuationWorkflow />} />}
<Route path="/import" element={<Import />} />
{manager && <Route path="/import" element={<Import />} />}
{internal && <Route path="/audit" element={<AuditLog />} />}
<Route path="/users" element={<Users />} />
{manager && <Route path="/users" element={<Users />} />}
{internal && <Route path="/investor-view" element={<InvestorView />} />}
<Route path="/access" element={<AccessGrid />} />
{manager && <Route path="/access" element={<AccessGrid />} />}
<Route path="/documents" element={<Documents />} />
<Route path="/capital-accounts" element={<CapitalAccounts />} />
<Route path="*" element={<Navigate to="/" replace />} />
@@ -96,7 +98,7 @@ function ProtectedRoutes() {
// Still on the shared default password → nothing else until they set their own.
if (user.must_change_password) return <ForcePasswordChange />;
return isInternal(user.role) || isAdministrator(user.role) ? <AdminApp /> : <ExternalApp />;
return isInternal(user.role) || isExternalAdmin(user.role) ? <AdminApp /> : <ExternalApp />;
}
export default function App() {
+6 -2
View File
@@ -11,7 +11,8 @@ export type UserRole =
| "fund_admin"
| "viewer"
| "investor"
| "fund_administrator";
| "fund_administrator"
| "administrator_viewer";
export type EntityType = "fund" | "spv" | "gp" | "mgmt_co" | "carry";
export type EntityStatus = "active" | "closed";
export type RoundStatus = "draft" | "submitted" | "approved" | "returned";
@@ -23,7 +24,7 @@ export type DocumentCategory =
| "other";
export const INTERNAL_ROLES: UserRole[] = ["approver", "operations", "cfo", "fund_admin", "viewer"];
export const EXTERNAL_ROLES: UserRole[] = ["investor", "fund_administrator"];
export const EXTERNAL_ROLES: UserRole[] = ["investor", "fund_administrator", "administrator_viewer"];
// Roles that can administer users, documents, and capital accounts (everything except sign-off).
export const ADMIN_ROLES: UserRole[] = ["approver", "operations", "cfo"];
@@ -36,6 +37,9 @@ export const canEditRound = (role: UserRole) => WRITER_ROLES.includes(role);
export const isApprover = (role: UserRole) => role === "approver";
// The external Administrator: full management, but only inside their granted entities.
export const isAdministrator = (role: UserRole) => role === "fund_administrator";
// Any external administrator account (managing or view-only) — routed to the admin app.
export const isExternalAdmin = (role: UserRole) =>
role === "fund_administrator" || role === "administrator_viewer";
// Can run the admin screens (users, documents, capital accounts, imports).
export const isManager = (role: UserRole) => isAdmin(role) || isAdministrator(role);
// Can edit entity records (partners, exits, fund details) — internal writers + Administrator.
+8 -2
View File
@@ -1,7 +1,7 @@
import { useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { isAdmin, isAdministrator } from "../api";
import { isAdmin, isAdministrator, isExternalAdmin } from "../api";
import { roleLabel } from "../format";
import ChangePasswordModal from "./ChangePasswordModal";
import TwoFactorModal from "./TwoFactorModal";
@@ -29,11 +29,17 @@ export default function Layout({ children }: { children: React.ReactNode }) {
const [managing2fa, setManaging2fa] = useState(false);
// An external Administrator gets the admin screens for their funds, but not the
// internal-only views (Investor View mirrors any LP; Audit Log spans the whole portal).
// The view-only variant reads its funds: no Import, no Users, no Access Grid.
const navItems = user && isAdmin(user.role)
? [...TOP_NAV, ...ADMIN_NAV, ...BOTTOM_NAV]
: user && isAdministrator(user.role)
? [...TOP_NAV, ...ADMIN_NAV.filter((i) => i.path !== "/investor-view")]
: [...TOP_NAV, ...BOTTOM_NAV];
: user && isExternalAdmin(user.role)
? [
...TOP_NAV.filter((i) => i.path !== "/import"),
...ADMIN_NAV.filter((i) => ["/documents", "/capital-accounts"].includes(i.path)),
]
: [...TOP_NAV, ...BOTTOM_NAV];
return (
<div className="flex h-screen bg-gray-50">
+1
View File
@@ -64,6 +64,7 @@ export function roleLabel(role: string): string {
viewer: "Viewer (internal)",
investor: "Investor",
fund_administrator: "Administrator",
administrator_viewer: "Administrator (view only)",
};
return map[role] ?? role;
}
+33 -22
View File
@@ -1,8 +1,11 @@
import { useEffect, useMemo, useState } from "react";
import { api, type CapitalAccount, type Entity, type User } from "../api";
import { api, isManager, type CapitalAccount, type Entity, type User } from "../api";
import { useAuth } from "../context/AuthContext";
import { formatDate, formatMoneyExact } from "../format";
export default function CapitalAccounts() {
const { user: me } = useAuth();
const canWrite = me != null && isManager(me.role);
const [entities, setEntities] = useState<Entity[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [rows, setRows] = useState<CapitalAccount[]>([]);
@@ -45,12 +48,14 @@ export default function CapitalAccounts() {
</p>
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
<EntryForm
entities={entities}
investors={investors}
onSaved={load}
setError={setError}
/>
{canWrite && (
<EntryForm
entities={entities}
investors={investors}
onSaved={load}
setError={setError}
/>
)}
<h3 className="text-sm font-medium text-gray-700 mt-8 mb-2">Statements</h3>
<div className="bg-white border border-gray-200 rounded-lg overflow-x-auto">
@@ -86,13 +91,17 @@ export default function CapitalAccounts() {
<span className="text-xs font-medium uppercase text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">
Exited {formatDate(r.exited_on)}
</span>
<button
onClick={() => saveExit(r, null)}
className="ml-2 text-xs text-accent-600 hover:text-accent-700"
>
Undo
</button>
{canWrite && (
<button
onClick={() => saveExit(r, null)}
className="ml-2 text-xs text-accent-600 hover:text-accent-700"
>
Undo
</button>
)}
</span>
) : !canWrite ? (
<span className="text-xs text-gray-400">Member</span>
) : exitingKey === `${r.entity_id}:${r.investor_user_id}` ? (
<span className="flex items-center gap-1.5">
<input
@@ -124,15 +133,17 @@ export default function CapitalAccounts() {
)}
</td>
<td className="px-4 py-2 text-right">
<button
onClick={() => {
if (confirm("Delete this statement?"))
api.deleteCapitalAccount(r.id).then(load).catch((e) => setError(e.message));
}}
className="text-gray-400 hover:text-red-600"
>
Delete
</button>
{canWrite && (
<button
onClick={() => {
if (confirm("Delete this statement?"))
api.deleteCapitalAccount(r.id).then(load).catch((e) => setError(e.message));
}}
className="text-gray-400 hover:text-red-600"
>
Delete
</button>
)}
</td>
</tr>
))}
+22 -14
View File
@@ -1,16 +1,20 @@
import { useEffect, useMemo, useState } from "react";
import {
api,
isManager,
type DocumentCategory,
type Entity,
type PortalDocument,
type User,
} from "../api";
import { useAuth } from "../context/AuthContext";
import { categoryLabel, formatBytes, formatDate } from "../format";
const CATEGORIES: DocumentCategory[] = ["statement", "k1", "capital_account", "tax", "other"];
export default function Documents() {
const { user: me } = useAuth();
const canWrite = me != null && isManager(me.role);
const [entities, setEntities] = useState<Entity[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [docs, setDocs] = useState<PortalDocument[]>([]);
@@ -38,11 +42,13 @@ export default function Documents() {
<h2 className="text-xl font-semibold text-gray-900 mb-4">Documents</h2>
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
<UploadForm
entities={entities}
onUploaded={loadDocs}
setError={setError}
/>
{canWrite && (
<UploadForm
entities={entities}
onUploaded={loadDocs}
setError={setError}
/>
)}
<div className="flex items-center gap-2 mt-8 mb-2">
<h3 className="text-sm font-medium text-gray-700">All documents</h3>
@@ -91,15 +97,17 @@ export default function Documents() {
>
Download
</a>
<button
onClick={() => {
if (confirm(`Delete "${d.title}"?`))
api.deleteDocument(d.id).then(loadDocs).catch((e) => setError(e.message));
}}
className="text-gray-400 hover:text-red-600"
>
Delete
</button>
{canWrite && (
<button
onClick={() => {
if (confirm(`Delete "${d.title}"?`))
api.deleteDocument(d.id).then(loadDocs).catch((e) => setError(e.message));
}}
className="text-gray-400 hover:text-red-600"
>
Delete
</button>
)}
</td>
</tr>
))}
+15 -10
View File
@@ -1,10 +1,13 @@
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { api, type Entity, type PortalDocument, type User } from "../api";
import { api, isManager, type Entity, type PortalDocument, type User } from "../api";
import { useAuth } from "../context/AuthContext";
import { categoryLabel, formatDate } from "../format";
import EntityHeader from "../components/EntityHeader";
export default function EntityDocuments() {
const { user: me } = useAuth();
const canWrite = me != null && isManager(me.role);
const { id } = useParams<{ id: string }>();
const [entity, setEntity] = useState<Entity | null>(null);
const [docs, setDocs] = useState<PortalDocument[]>([]);
@@ -64,15 +67,17 @@ export default function EntityDocuments() {
<a href={api.downloadUrl(d.id)} className="text-accent-600 hover:text-accent-700 mr-3">
Download
</a>
<button
onClick={() => {
if (confirm(`Delete "${d.title}"?`))
api.deleteDocument(d.id).then(() => loadDocs(eid)).catch((e) => setError(e.message));
}}
className="text-gray-400 hover:text-red-600"
>
Delete
</button>
{canWrite && (
<button
onClick={() => {
if (confirm(`Delete "${d.title}"?`))
api.deleteDocument(d.id).then(() => loadDocs(eid)).catch((e) => setError(e.message));
}}
className="text-gray-400 hover:text-red-600"
>
Delete
</button>
)}
</td>
</tr>
))}
+18
View File
@@ -16,6 +16,7 @@ import PasswordInput from "../components/PasswordInput";
const CREATABLE_ROLES: UserRole[] = [
"investor",
"fund_administrator",
"administrator_viewer",
"operations",
"approver",
];
@@ -298,6 +299,10 @@ function EditUserModal({
const { user: me } = useAuth();
const external = !isInternal(user.role);
const canDelete = !user.is_service_admin && user.id !== me?.id;
// Internal admins can flip an Administrator between full management and view only.
const externalAdmin = user.role === "fund_administrator" || user.role === "administrator_viewer";
const canSetLevel = externalAdmin && me != null && isInternal(me.role);
const [role, setRole] = useState<UserRole>(user.role);
const remove = async () => {
if (
@@ -335,6 +340,7 @@ function EditUserModal({
...(username.trim() && username.trim() !== user.username
? { username: username.trim() }
: {}),
...(canSetLevel && role !== user.role ? { role } : {}),
...(external ? { entity_ids: [...selected] } : {}),
});
if (isInvestor && !isPrimary && linkTo !== (user.primary_account_id ?? "")) {
@@ -366,6 +372,18 @@ function EditUserModal({
<input type="checkbox" checked={isActive} onChange={(e) => setIsActive(e.target.checked)} />
Account active
</label>
{canSetLevel && (
<Field label="Access level">
<select
className={inputCls}
value={role}
onChange={(e) => setRole(e.target.value as UserRole)}
>
<option value="fund_administrator">Administrator (manages their funds)</option>
<option value="administrator_viewer">Administrator (view only)</option>
</select>
</Field>
)}
{external && (
<Field label="Entity access">
<EntityChecklist entities={entities} selected={selected} onChange={setSelected} />
+1 -1
View File
@@ -1,4 +1,4 @@
// Bumped each release so the running build is visible in the UI.
// If the number shown in the app doesn't match the installed s9pk version,
// the new frontend isn't actually being served.
export const APP_VERSION = "0.2.44";
export const APP_VERSION = "0.2.45";