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:
@@ -107,9 +107,10 @@ require_entity_writer = require_role(
|
|||||||
|
|
||||||
|
|
||||||
def require_internal_or_administrator(user: User = Depends(get_current_user)) -> User:
|
def require_internal_or_administrator(user: User = Depends(get_current_user)) -> User:
|
||||||
"""Read gate for admin screens: any internal role, or the external Administrator.
|
"""Read gate for admin screens: any internal role, or an external Administrator
|
||||||
|
(managing or view-only).
|
||||||
|
|
||||||
Investors are blocked; Administrator calls must still be scope-checked per entity.
|
Investors are blocked; external calls must still be scope-checked per entity.
|
||||||
"""
|
"""
|
||||||
if user.role == UserRole.investor:
|
if user.role == UserRole.investor:
|
||||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
@@ -117,9 +118,9 @@ def require_internal_or_administrator(user: User = Depends(get_current_user)) ->
|
|||||||
|
|
||||||
|
|
||||||
def check_administrator_scope(user: User, entity_id: int, session: Session) -> None:
|
def check_administrator_scope(user: User, entity_id: int, session: Session) -> None:
|
||||||
"""403 when an external Administrator touches an entity outside their grants.
|
"""403 when an external account touches an entity outside their grants.
|
||||||
|
|
||||||
Internal roles pass through untouched — their reach is decided by the route's gate.
|
Internal roles pass through untouched — their reach is decided by the route's gate.
|
||||||
"""
|
"""
|
||||||
if user.role == UserRole.fund_administrator and not can_access_entity(user, entity_id, session):
|
if user.role in EXTERNAL_ROLES and not can_access_entity(user, entity_id, session):
|
||||||
raise HTTPException(status_code=403, detail="No access to this entity")
|
raise HTTPException(status_code=403, detail="No access to this entity")
|
||||||
|
|||||||
@@ -20,11 +20,12 @@ class UserRole(str, enum.Enum):
|
|||||||
viewer = "viewer"
|
viewer = "viewer"
|
||||||
# External accounts (entity-scoped via EntityAccess)
|
# External accounts (entity-scoped via EntityAccess)
|
||||||
investor = "investor"
|
investor = "investor"
|
||||||
fund_administrator = "fund_administrator"
|
fund_administrator = "fund_administrator" # "Administrator" — manages its funds
|
||||||
|
administrator_viewer = "administrator_viewer" # "Administrator (view only)" — reads its funds
|
||||||
|
|
||||||
|
|
||||||
# External roles see only the entities granted to them.
|
# External roles see only the entities granted to them.
|
||||||
EXTERNAL_ROLES = (UserRole.investor, UserRole.fund_administrator)
|
EXTERNAL_ROLES = (UserRole.investor, UserRole.fund_administrator, UserRole.administrator_viewer)
|
||||||
|
|
||||||
|
|
||||||
class DocumentCategory(str, enum.Enum):
|
class DocumentCategory(str, enum.Enum):
|
||||||
|
|||||||
@@ -76,8 +76,9 @@ def list_statements(
|
|||||||
query = select(CapitalAccountStatement)
|
query = select(CapitalAccountStatement)
|
||||||
allowed = accessible_entity_ids(user, session)
|
allowed = accessible_entity_ids(user, session)
|
||||||
|
|
||||||
if user.role == UserRole.fund_administrator:
|
if user.role in (UserRole.fund_administrator, UserRole.administrator_viewer):
|
||||||
# An Administrator sees every investor's statements, but only inside their funds.
|
# An Administrator (managing or view-only) sees every investor's statements,
|
||||||
|
# but only inside their funds.
|
||||||
if not allowed:
|
if not allowed:
|
||||||
return []
|
return []
|
||||||
query = query.where(col(CapitalAccountStatement.entity_id).in_(allowed))
|
query = query.where(col(CapitalAccountStatement.entity_id).in_(allowed))
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from sqlmodel import Session, select, col
|
|||||||
from ten31portal.audit import record_audit
|
from ten31portal.audit import record_audit
|
||||||
from ten31portal.auth import (
|
from ten31portal.auth import (
|
||||||
accessible_entity_ids, can_access_entity, check_administrator_scope, get_current_user,
|
accessible_entity_ids, can_access_entity, check_administrator_scope, get_current_user,
|
||||||
hash_password, household_user_ids, require_admin, require_internal_admin,
|
hash_password, household_user_ids, require_admin, require_internal_admin, require_role,
|
||||||
)
|
)
|
||||||
from ten31portal.database import get_session
|
from ten31portal.database import get_session
|
||||||
from ten31portal.models import (
|
from ten31portal.models import (
|
||||||
@@ -329,7 +329,13 @@ def _set_entity_access(user_id: int, entity_ids: list[int], session: Session) ->
|
|||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def list_users(
|
def list_users(
|
||||||
admin: User = Depends(require_admin),
|
# Read-widened: a view-only Administrator may list the investors of its funds (it needs
|
||||||
|
# their names on the capital-accounts and documents screens); every mutating endpoint
|
||||||
|
# below keeps the stricter require_admin gate.
|
||||||
|
admin: User = Depends(require_role(
|
||||||
|
UserRole.approver, UserRole.cfo, UserRole.operations,
|
||||||
|
UserRole.fund_administrator, UserRole.administrator_viewer,
|
||||||
|
)),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> list[UserResponse]:
|
) -> list[UserResponse]:
|
||||||
rows = session.exec(select(User).order_by(User.name)).all() # type: ignore[arg-type]
|
rows = session.exec(select(User).order_by(User.name)).all() # type: ignore[arg-type]
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Administrator (view only) role (0.2.45): reads everything on its granted entities,
|
||||||
|
changes nothing anywhere."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from ten31portal.models import (
|
||||||
|
CapitalAccountStatement, Entity, EntityAccess, EntityType, UserRole,
|
||||||
|
)
|
||||||
|
from tests.conftest import make_user
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client, username, password="password123"):
|
||||||
|
client.post("/api/auth/logout")
|
||||||
|
resp = client.post("/api/auth/login", json={"login": username, "password": password})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def _setup(session):
|
||||||
|
fund_a = Entity(name="Fund A", type=EntityType.fund)
|
||||||
|
fund_b = Entity(name="Fund B", type=EntityType.fund)
|
||||||
|
session.add(fund_a)
|
||||||
|
session.add(fund_b)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
viewer = make_user(session, username="viewonly", role=UserRole.administrator_viewer,
|
||||||
|
name="Read Only Admin")
|
||||||
|
session.add(EntityAccess(user_id=viewer.id, entity_id=fund_a.id))
|
||||||
|
|
||||||
|
lp = make_user(session, username="lp-a", role=UserRole.investor, name="LP Alpha")
|
||||||
|
session.add(EntityAccess(user_id=lp.id, entity_id=fund_a.id))
|
||||||
|
session.add(CapitalAccountStatement(
|
||||||
|
entity_id=fund_a.id, investor_user_id=lp.id, as_of_date=date(2026, 3, 31),
|
||||||
|
commitment_cents=100_00, beginning_balance_cents=0, contributions_cents=100_00,
|
||||||
|
distributions_cents=0, ending_balance_cents=110_00,
|
||||||
|
))
|
||||||
|
session.commit()
|
||||||
|
return fund_a, fund_b, viewer, lp
|
||||||
|
|
||||||
|
|
||||||
|
def test_viewer_reads_only_their_fund(client, session, approver):
|
||||||
|
fund_a, fund_b, viewer, lp = _setup(session)
|
||||||
|
_login(client, "viewonly")
|
||||||
|
|
||||||
|
entities = client.get("/api/entities").json()
|
||||||
|
assert [e["name"] for e in entities] == ["Fund A"]
|
||||||
|
assert client.get(f"/api/entities/{fund_a.id}/partners").status_code == 200
|
||||||
|
assert client.get(f"/api/entities/{fund_b.id}/partners").status_code == 403
|
||||||
|
assert client.get(f"/api/entities/{fund_a.id}/rounds").status_code == 200
|
||||||
|
assert client.get(f"/api/entities/{fund_a.id}/holdings").status_code == 200
|
||||||
|
|
||||||
|
# Sees every investor's statements within the fund, plus their names.
|
||||||
|
rows = client.get("/api/capital-accounts").json()
|
||||||
|
assert {r["entity_id"] for r in rows} == {fund_a.id}
|
||||||
|
users = client.get("/api/users").json()
|
||||||
|
assert {u["username"] for u in users} == {"lp-a"}
|
||||||
|
|
||||||
|
docs = client.get("/api/documents").json()
|
||||||
|
assert isinstance(docs, list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_viewer_cannot_change_anything(client, session, approver):
|
||||||
|
fund_a, fund_b, viewer, lp = _setup(session)
|
||||||
|
_login(client, "viewonly")
|
||||||
|
|
||||||
|
# Entity records
|
||||||
|
assert client.patch(f"/api/entities/{fund_a.id}", json={"name": "X"}).status_code == 403
|
||||||
|
assert client.put(
|
||||||
|
f"/api/entities/{fund_a.id}/partners/{lp.id}/exited", json={"exited_on": "2026-06-30"}
|
||||||
|
).status_code == 403
|
||||||
|
assert client.delete(f"/api/entities/{fund_a.id}/partners").status_code == 403
|
||||||
|
|
||||||
|
# Documents
|
||||||
|
resp = client.post(
|
||||||
|
"/api/documents",
|
||||||
|
data={"entity_id": str(fund_a.id), "category": "statement"},
|
||||||
|
files={"file": ("x.pdf", io.BytesIO(b"pdf"), "application/pdf")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
# Capital accounts
|
||||||
|
assert client.post("/api/capital-accounts", json={
|
||||||
|
"entity_id": fund_a.id, "investor_user_id": lp.id, "as_of_date": "2026-06-30",
|
||||||
|
"commitment_dollars": 1, "beginning_balance_dollars": 0,
|
||||||
|
"contributions_dollars": 1, "distributions_dollars": 0, "ending_balance_dollars": 1,
|
||||||
|
}).status_code == 403
|
||||||
|
|
||||||
|
# Imports
|
||||||
|
assert client.post(
|
||||||
|
f"/api/import/schedule?entity_id={fund_a.id}",
|
||||||
|
files={"file": ("x.xlsx", io.BytesIO(b"junk"), "application/octet-stream")},
|
||||||
|
).status_code == 403
|
||||||
|
assert client.post(
|
||||||
|
"/api/import/capital-accounts/batch",
|
||||||
|
data={"entity_id": str(fund_a.id)},
|
||||||
|
files=[("files", ("x.xlsx", io.BytesIO(b"junk"), "application/octet-stream"))],
|
||||||
|
).status_code == 403
|
||||||
|
|
||||||
|
# User management
|
||||||
|
assert client.post("/api/users", json={
|
||||||
|
"name": "N", "username": "n", "password": "secretpw",
|
||||||
|
"role": "investor", "entity_ids": [fund_a.id],
|
||||||
|
}).status_code == 403
|
||||||
|
assert client.patch(f"/api/users/{lp.id}", json={"is_active": False}).status_code == 403
|
||||||
|
assert client.delete(f"/api/users/{lp.id}").status_code == 403
|
||||||
|
assert client.get("/api/users/access-matrix").status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_internal_admin_toggles_administrator_level(auth_client, session):
|
||||||
|
admin_acct = make_user(session, username="mgr", role=UserRole.fund_administrator,
|
||||||
|
name="Managing Admin")
|
||||||
|
|
||||||
|
resp = auth_client.patch(f"/api/users/{admin_acct.id}", json={"role": "administrator_viewer"})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["role"] == "administrator_viewer"
|
||||||
|
|
||||||
|
resp = auth_client.patch(f"/api/users/{admin_acct.id}", json={"role": "fund_administrator"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["role"] == "fund_administrator"
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ten31portal-startos",
|
"name": "ten31portal-startos",
|
||||||
"version": "0.2.44",
|
"version": "0.2.45",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
|
"build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
export { v_0_2_44 as current } from './v_0_2_44'
|
export { v_0_2_45 as current } from './v_0_2_45'
|
||||||
import { v_0_1_0 } from './v_0_1_0'
|
import { v_0_1_0 } from './v_0_1_0'
|
||||||
import { v_0_2_40 } from './v_0_2_40'
|
import { v_0_2_40 } from './v_0_2_40'
|
||||||
import { v_0_2_41 } from './v_0_2_41'
|
import { v_0_2_41 } from './v_0_2_41'
|
||||||
import { v_0_2_42 } from './v_0_2_42'
|
import { v_0_2_42 } from './v_0_2_42'
|
||||||
import { v_0_2_43 } from './v_0_2_43'
|
import { v_0_2_43 } from './v_0_2_43'
|
||||||
|
import { v_0_2_44 } from './v_0_2_44'
|
||||||
import { v_0_2_0 } from './v_0_2_0'
|
import { v_0_2_0 } from './v_0_2_0'
|
||||||
import { v_0_2_1 } from './v_0_2_1'
|
import { v_0_2_1 } from './v_0_2_1'
|
||||||
import { v_0_2_3 } from './v_0_2_3'
|
import { v_0_2_3 } from './v_0_2_3'
|
||||||
@@ -43,4 +44,4 @@ import { v_0_2_36 } from './v_0_2_36'
|
|||||||
import { v_0_2_37 } from './v_0_2_37'
|
import { v_0_2_37 } from './v_0_2_37'
|
||||||
import { v_0_2_38 } from './v_0_2_38'
|
import { v_0_2_38 } from './v_0_2_38'
|
||||||
import { v_0_2_39 } from './v_0_2_39'
|
import { v_0_2_39 } from './v_0_2_39'
|
||||||
export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20, v_0_2_21, v_0_2_22, v_0_2_23, v_0_2_24, v_0_2_25, v_0_2_26, v_0_2_27, v_0_2_28, v_0_2_29, v_0_2_30, v_0_2_31, v_0_2_32, v_0_2_33, v_0_2_34, v_0_2_35, v_0_2_36, v_0_2_37, v_0_2_38, v_0_2_39, v_0_2_40, v_0_2_41, v_0_2_42, v_0_2_43]
|
export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20, v_0_2_21, v_0_2_22, v_0_2_23, v_0_2_24, v_0_2_25, v_0_2_26, v_0_2_27, v_0_2_28, v_0_2_29, v_0_2_30, v_0_2_31, v_0_2_32, v_0_2_33, v_0_2_34, v_0_2_35, v_0_2_36, v_0_2_37, v_0_2_38, v_0_2_39, v_0_2_40, v_0_2_41, v_0_2_42, v_0_2_43, v_0_2_44]
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_45 = VersionInfo.of({
|
||||||
|
version: '0.2.45:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'New "Administrator (view only)" role: sees everything an Administrator sees for ' +
|
||||||
|
'its granted funds and SPVs (overview, partners, capital accounts, documents, ' +
|
||||||
|
'valuation history) but can change nothing — no imports, uploads, deletions, or ' +
|
||||||
|
'account management. Create accounts with it directly, or switch an existing ' +
|
||||||
|
'Administrator between full management and view only from Users, then Manage, ' +
|
||||||
|
'then Access level.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
// - content-hashed /assets/* are cache-first (immutable, safe forever)
|
// - content-hashed /assets/* are cache-first (immutable, safe forever)
|
||||||
// - /api/* is never cached
|
// - /api/* is never cached
|
||||||
// Bump CACHE on each release so old entries are purged.
|
// 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())
|
self.addEventListener('install', () => self.skipWaiting())
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||||
import { AuthProvider, useAuth } from "./context/AuthContext";
|
import { AuthProvider, useAuth } from "./context/AuthContext";
|
||||||
import { isAdministrator, isInternal } from "./api";
|
import { isExternalAdmin, isInternal } from "./api";
|
||||||
import { ForcePasswordChange, WelcomeFlow } from "./components/FirstLogin";
|
import { ForcePasswordChange, WelcomeFlow } from "./components/FirstLogin";
|
||||||
import Layout from "./components/Layout";
|
import Layout from "./components/Layout";
|
||||||
import Login from "./pages/Login";
|
import Login from "./pages/Login";
|
||||||
@@ -27,6 +27,8 @@ function AdminApp() {
|
|||||||
// the backend refuses them anyway.
|
// the backend refuses them anyway.
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const internal = user != null && isInternal(user.role);
|
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 (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -37,11 +39,11 @@ function AdminApp() {
|
|||||||
<Route path="/entities/:id/investments" element={<Investments />} />
|
<Route path="/entities/:id/investments" element={<Investments />} />
|
||||||
<Route path="/entities/:id/assets" element={<EntityAssets />} />
|
<Route path="/entities/:id/assets" element={<EntityAssets />} />
|
||||||
{internal && <Route path="/entities/:id/valuation" element={<ValuationWorkflow />} />}
|
{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 />} />}
|
{internal && <Route path="/audit" element={<AuditLog />} />}
|
||||||
<Route path="/users" element={<Users />} />
|
{manager && <Route path="/users" element={<Users />} />}
|
||||||
{internal && <Route path="/investor-view" element={<InvestorView />} />}
|
{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="/documents" element={<Documents />} />
|
||||||
<Route path="/capital-accounts" element={<CapitalAccounts />} />
|
<Route path="/capital-accounts" element={<CapitalAccounts />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<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.
|
// Still on the shared default password → nothing else until they set their own.
|
||||||
if (user.must_change_password) return <ForcePasswordChange />;
|
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() {
|
export default function App() {
|
||||||
|
|||||||
+6
-2
@@ -11,7 +11,8 @@ export type UserRole =
|
|||||||
| "fund_admin"
|
| "fund_admin"
|
||||||
| "viewer"
|
| "viewer"
|
||||||
| "investor"
|
| "investor"
|
||||||
| "fund_administrator";
|
| "fund_administrator"
|
||||||
|
| "administrator_viewer";
|
||||||
export type EntityType = "fund" | "spv" | "gp" | "mgmt_co" | "carry";
|
export type EntityType = "fund" | "spv" | "gp" | "mgmt_co" | "carry";
|
||||||
export type EntityStatus = "active" | "closed";
|
export type EntityStatus = "active" | "closed";
|
||||||
export type RoundStatus = "draft" | "submitted" | "approved" | "returned";
|
export type RoundStatus = "draft" | "submitted" | "approved" | "returned";
|
||||||
@@ -23,7 +24,7 @@ export type DocumentCategory =
|
|||||||
| "other";
|
| "other";
|
||||||
|
|
||||||
export const INTERNAL_ROLES: UserRole[] = ["approver", "operations", "cfo", "fund_admin", "viewer"];
|
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).
|
// Roles that can administer users, documents, and capital accounts (everything except sign-off).
|
||||||
export const ADMIN_ROLES: UserRole[] = ["approver", "operations", "cfo"];
|
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";
|
export const isApprover = (role: UserRole) => role === "approver";
|
||||||
// The external Administrator: full management, but only inside their granted entities.
|
// The external Administrator: full management, but only inside their granted entities.
|
||||||
export const isAdministrator = (role: UserRole) => role === "fund_administrator";
|
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).
|
// Can run the admin screens (users, documents, capital accounts, imports).
|
||||||
export const isManager = (role: UserRole) => isAdmin(role) || isAdministrator(role);
|
export const isManager = (role: UserRole) => isAdmin(role) || isAdministrator(role);
|
||||||
// Can edit entity records (partners, exits, fund details) — internal writers + Administrator.
|
// Can edit entity records (partners, exits, fund details) — internal writers + Administrator.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { isAdmin, isAdministrator } from "../api";
|
import { isAdmin, isAdministrator, isExternalAdmin } from "../api";
|
||||||
import { roleLabel } from "../format";
|
import { roleLabel } from "../format";
|
||||||
import ChangePasswordModal from "./ChangePasswordModal";
|
import ChangePasswordModal from "./ChangePasswordModal";
|
||||||
import TwoFactorModal from "./TwoFactorModal";
|
import TwoFactorModal from "./TwoFactorModal";
|
||||||
@@ -29,10 +29,16 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
const [managing2fa, setManaging2fa] = useState(false);
|
const [managing2fa, setManaging2fa] = useState(false);
|
||||||
// An external Administrator gets the admin screens for their funds, but not the
|
// 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).
|
// 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)
|
const navItems = user && isAdmin(user.role)
|
||||||
? [...TOP_NAV, ...ADMIN_NAV, ...BOTTOM_NAV]
|
? [...TOP_NAV, ...ADMIN_NAV, ...BOTTOM_NAV]
|
||||||
: user && isAdministrator(user.role)
|
: user && isAdministrator(user.role)
|
||||||
? [...TOP_NAV, ...ADMIN_NAV.filter((i) => i.path !== "/investor-view")]
|
? [...TOP_NAV, ...ADMIN_NAV.filter((i) => i.path !== "/investor-view")]
|
||||||
|
: 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];
|
: [...TOP_NAV, ...BOTTOM_NAV];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export function roleLabel(role: string): string {
|
|||||||
viewer: "Viewer (internal)",
|
viewer: "Viewer (internal)",
|
||||||
investor: "Investor",
|
investor: "Investor",
|
||||||
fund_administrator: "Administrator",
|
fund_administrator: "Administrator",
|
||||||
|
administrator_viewer: "Administrator (view only)",
|
||||||
};
|
};
|
||||||
return map[role] ?? role;
|
return map[role] ?? role;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
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";
|
import { formatDate, formatMoneyExact } from "../format";
|
||||||
|
|
||||||
export default function CapitalAccounts() {
|
export default function CapitalAccounts() {
|
||||||
|
const { user: me } = useAuth();
|
||||||
|
const canWrite = me != null && isManager(me.role);
|
||||||
const [entities, setEntities] = useState<Entity[]>([]);
|
const [entities, setEntities] = useState<Entity[]>([]);
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [rows, setRows] = useState<CapitalAccount[]>([]);
|
const [rows, setRows] = useState<CapitalAccount[]>([]);
|
||||||
@@ -45,12 +48,14 @@ export default function CapitalAccounts() {
|
|||||||
</p>
|
</p>
|
||||||
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
|
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
|
||||||
|
|
||||||
|
{canWrite && (
|
||||||
<EntryForm
|
<EntryForm
|
||||||
entities={entities}
|
entities={entities}
|
||||||
investors={investors}
|
investors={investors}
|
||||||
onSaved={load}
|
onSaved={load}
|
||||||
setError={setError}
|
setError={setError}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<h3 className="text-sm font-medium text-gray-700 mt-8 mb-2">Statements</h3>
|
<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">
|
<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">
|
<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)}
|
Exited {formatDate(r.exited_on)}
|
||||||
</span>
|
</span>
|
||||||
|
{canWrite && (
|
||||||
<button
|
<button
|
||||||
onClick={() => saveExit(r, null)}
|
onClick={() => saveExit(r, null)}
|
||||||
className="ml-2 text-xs text-accent-600 hover:text-accent-700"
|
className="ml-2 text-xs text-accent-600 hover:text-accent-700"
|
||||||
>
|
>
|
||||||
Undo
|
Undo
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
) : !canWrite ? (
|
||||||
|
<span className="text-xs text-gray-400">Member</span>
|
||||||
) : exitingKey === `${r.entity_id}:${r.investor_user_id}` ? (
|
) : exitingKey === `${r.entity_id}:${r.investor_user_id}` ? (
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<input
|
<input
|
||||||
@@ -124,6 +133,7 @@ export default function CapitalAccounts() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-right">
|
<td className="px-4 py-2 text-right">
|
||||||
|
{canWrite && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm("Delete this statement?"))
|
if (confirm("Delete this statement?"))
|
||||||
@@ -133,6 +143,7 @@ export default function CapitalAccounts() {
|
|||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
|
isManager,
|
||||||
type DocumentCategory,
|
type DocumentCategory,
|
||||||
type Entity,
|
type Entity,
|
||||||
type PortalDocument,
|
type PortalDocument,
|
||||||
type User,
|
type User,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { categoryLabel, formatBytes, formatDate } from "../format";
|
import { categoryLabel, formatBytes, formatDate } from "../format";
|
||||||
|
|
||||||
const CATEGORIES: DocumentCategory[] = ["statement", "k1", "capital_account", "tax", "other"];
|
const CATEGORIES: DocumentCategory[] = ["statement", "k1", "capital_account", "tax", "other"];
|
||||||
|
|
||||||
export default function Documents() {
|
export default function Documents() {
|
||||||
|
const { user: me } = useAuth();
|
||||||
|
const canWrite = me != null && isManager(me.role);
|
||||||
const [entities, setEntities] = useState<Entity[]>([]);
|
const [entities, setEntities] = useState<Entity[]>([]);
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [docs, setDocs] = useState<PortalDocument[]>([]);
|
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>
|
<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>}
|
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
|
||||||
|
|
||||||
|
{canWrite && (
|
||||||
<UploadForm
|
<UploadForm
|
||||||
entities={entities}
|
entities={entities}
|
||||||
onUploaded={loadDocs}
|
onUploaded={loadDocs}
|
||||||
setError={setError}
|
setError={setError}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mt-8 mb-2">
|
<div className="flex items-center gap-2 mt-8 mb-2">
|
||||||
<h3 className="text-sm font-medium text-gray-700">All documents</h3>
|
<h3 className="text-sm font-medium text-gray-700">All documents</h3>
|
||||||
@@ -91,6 +97,7 @@ export default function Documents() {
|
|||||||
>
|
>
|
||||||
Download
|
Download
|
||||||
</a>
|
</a>
|
||||||
|
{canWrite && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm(`Delete "${d.title}"?`))
|
if (confirm(`Delete "${d.title}"?`))
|
||||||
@@ -100,6 +107,7 @@ export default function Documents() {
|
|||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
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 { categoryLabel, formatDate } from "../format";
|
||||||
import EntityHeader from "../components/EntityHeader";
|
import EntityHeader from "../components/EntityHeader";
|
||||||
|
|
||||||
export default function EntityDocuments() {
|
export default function EntityDocuments() {
|
||||||
|
const { user: me } = useAuth();
|
||||||
|
const canWrite = me != null && isManager(me.role);
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const [entity, setEntity] = useState<Entity | null>(null);
|
const [entity, setEntity] = useState<Entity | null>(null);
|
||||||
const [docs, setDocs] = useState<PortalDocument[]>([]);
|
const [docs, setDocs] = useState<PortalDocument[]>([]);
|
||||||
@@ -64,6 +67,7 @@ export default function EntityDocuments() {
|
|||||||
<a href={api.downloadUrl(d.id)} className="text-accent-600 hover:text-accent-700 mr-3">
|
<a href={api.downloadUrl(d.id)} className="text-accent-600 hover:text-accent-700 mr-3">
|
||||||
Download
|
Download
|
||||||
</a>
|
</a>
|
||||||
|
{canWrite && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm(`Delete "${d.title}"?`))
|
if (confirm(`Delete "${d.title}"?`))
|
||||||
@@ -73,6 +77,7 @@ export default function EntityDocuments() {
|
|||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import PasswordInput from "../components/PasswordInput";
|
|||||||
const CREATABLE_ROLES: UserRole[] = [
|
const CREATABLE_ROLES: UserRole[] = [
|
||||||
"investor",
|
"investor",
|
||||||
"fund_administrator",
|
"fund_administrator",
|
||||||
|
"administrator_viewer",
|
||||||
"operations",
|
"operations",
|
||||||
"approver",
|
"approver",
|
||||||
];
|
];
|
||||||
@@ -298,6 +299,10 @@ function EditUserModal({
|
|||||||
const { user: me } = useAuth();
|
const { user: me } = useAuth();
|
||||||
const external = !isInternal(user.role);
|
const external = !isInternal(user.role);
|
||||||
const canDelete = !user.is_service_admin && user.id !== me?.id;
|
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 () => {
|
const remove = async () => {
|
||||||
if (
|
if (
|
||||||
@@ -335,6 +340,7 @@ function EditUserModal({
|
|||||||
...(username.trim() && username.trim() !== user.username
|
...(username.trim() && username.trim() !== user.username
|
||||||
? { username: username.trim() }
|
? { username: username.trim() }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(canSetLevel && role !== user.role ? { role } : {}),
|
||||||
...(external ? { entity_ids: [...selected] } : {}),
|
...(external ? { entity_ids: [...selected] } : {}),
|
||||||
});
|
});
|
||||||
if (isInvestor && !isPrimary && linkTo !== (user.primary_account_id ?? "")) {
|
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)} />
|
<input type="checkbox" checked={isActive} onChange={(e) => setIsActive(e.target.checked)} />
|
||||||
Account active
|
Account active
|
||||||
</label>
|
</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 && (
|
{external && (
|
||||||
<Field label="Entity access">
|
<Field label="Entity access">
|
||||||
<EntityChecklist entities={entities} selected={selected} onChange={setSelected} />
|
<EntityChecklist entities={entities} selected={selected} onChange={setSelected} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Bumped each release so the running build is visible in the UI.
|
// 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,
|
// If the number shown in the app doesn't match the installed s9pk version,
|
||||||
// the new frontend isn't actually being served.
|
// the new frontend isn't actually being served.
|
||||||
export const APP_VERSION = "0.2.44";
|
export const APP_VERSION = "0.2.45";
|
||||||
|
|||||||
Reference in New Issue
Block a user