From 858bbe10da60536741cc71abe2a2693b280f577d Mon Sep 17 00:00:00 2001 From: Jonathan Kirkwood Date: Tue, 11 Aug 2026 15:37:44 -0500 Subject: [PATCH] 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. --- backend/ten31portal/auth.py | 9 +- backend/ten31portal/models.py | 5 +- .../routers/capital_account_router.py | 5 +- backend/ten31portal/routers/user_router.py | 10 +- backend/tests/test_administrator_viewer.py | 120 ++++++++++++++++++ deploy/package.json | 2 +- deploy/startos/install/versions/index.ts | 5 +- deploy/startos/install/versions/v_0_2_45.ts | 18 +++ frontend/public/sw.js | 2 +- frontend/src/App.tsx | 12 +- frontend/src/api.ts | 8 +- frontend/src/components/Layout.tsx | 10 +- frontend/src/format.ts | 1 + frontend/src/pages/CapitalAccounts.tsx | 55 ++++---- frontend/src/pages/Documents.tsx | 36 ++++-- frontend/src/pages/EntityDocuments.tsx | 25 ++-- frontend/src/pages/Users.tsx | 18 +++ frontend/src/version.ts | 2 +- 18 files changed, 273 insertions(+), 70 deletions(-) create mode 100644 backend/tests/test_administrator_viewer.py create mode 100644 deploy/startos/install/versions/v_0_2_45.ts diff --git a/backend/ten31portal/auth.py b/backend/ten31portal/auth.py index 0fcfb89..9d68532 100644 --- a/backend/ten31portal/auth.py +++ b/backend/ten31portal/auth.py @@ -107,9 +107,10 @@ require_entity_writer = require_role( 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: 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: - """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. """ - 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") diff --git a/backend/ten31portal/models.py b/backend/ten31portal/models.py index 265d670..aa86995 100644 --- a/backend/ten31portal/models.py +++ b/backend/ten31portal/models.py @@ -20,11 +20,12 @@ class UserRole(str, enum.Enum): viewer = "viewer" # External accounts (entity-scoped via EntityAccess) 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 = (UserRole.investor, UserRole.fund_administrator) +EXTERNAL_ROLES = (UserRole.investor, UserRole.fund_administrator, UserRole.administrator_viewer) class DocumentCategory(str, enum.Enum): diff --git a/backend/ten31portal/routers/capital_account_router.py b/backend/ten31portal/routers/capital_account_router.py index 4a79cd4..e3a1cf9 100644 --- a/backend/ten31portal/routers/capital_account_router.py +++ b/backend/ten31portal/routers/capital_account_router.py @@ -76,8 +76,9 @@ def list_statements( query = select(CapitalAccountStatement) allowed = accessible_entity_ids(user, session) - if user.role == UserRole.fund_administrator: - # An Administrator sees every investor's statements, but only inside their funds. + if user.role in (UserRole.fund_administrator, UserRole.administrator_viewer): + # An Administrator (managing or view-only) sees every investor's statements, + # but only inside their funds. if not allowed: return [] query = query.where(col(CapitalAccountStatement.entity_id).in_(allowed)) diff --git a/backend/ten31portal/routers/user_router.py b/backend/ten31portal/routers/user_router.py index b453f85..1802219 100644 --- a/backend/ten31portal/routers/user_router.py +++ b/backend/ten31portal/routers/user_router.py @@ -6,7 +6,7 @@ from sqlmodel import Session, select, col from ten31portal.audit import record_audit from ten31portal.auth import ( 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.models import ( @@ -329,7 +329,13 @@ def _set_entity_access(user_id: int, entity_ids: list[int], session: Session) -> @router.get("") 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), ) -> list[UserResponse]: rows = session.exec(select(User).order_by(User.name)).all() # type: ignore[arg-type] diff --git a/backend/tests/test_administrator_viewer.py b/backend/tests/test_administrator_viewer.py new file mode 100644 index 0000000..9a6c684 --- /dev/null +++ b/backend/tests/test_administrator_viewer.py @@ -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" diff --git a/deploy/package.json b/deploy/package.json index 48b946a..753bfe6 100644 --- a/deploy/package.json +++ b/deploy/package.json @@ -1,6 +1,6 @@ { "name": "ten31portal-startos", - "version": "0.2.44", + "version": "0.2.45", "private": true, "scripts": { "build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript", diff --git a/deploy/startos/install/versions/index.ts b/deploy/startos/install/versions/index.ts index f9dac2c..ac4a2ad 100644 --- a/deploy/startos/install/versions/index.ts +++ b/deploy/startos/install/versions/index.ts @@ -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_2_40 } from './v_0_2_40' import { v_0_2_41 } from './v_0_2_41' import { v_0_2_42 } from './v_0_2_42' 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_1 } from './v_0_2_1' 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_38 } from './v_0_2_38' 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] diff --git a/deploy/startos/install/versions/v_0_2_45.ts b/deploy/startos/install/versions/v_0_2_45.ts new file mode 100644 index 0000000..74e29fd --- /dev/null +++ b/deploy/startos/install/versions/v_0_2_45.ts @@ -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 }) => {}, + }, +}) diff --git a/frontend/public/sw.js b/frontend/public/sw.js index cfa1edf..8abff2b 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -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()) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cbd4e36..d9d197a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( @@ -37,11 +39,11 @@ function AdminApp() { } /> } /> {internal && } />} - } /> + {manager && } />} {internal && } />} - } /> + {manager && } />} {internal && } />} - } /> + {manager && } />} } /> } /> } /> @@ -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 ; - return isInternal(user.role) || isAdministrator(user.role) ? : ; + return isInternal(user.role) || isExternalAdmin(user.role) ? : ; } export default function App() { diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 33fe187..bd05f12 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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. diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index f0f2c39..421fb4f 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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 (
diff --git a/frontend/src/format.ts b/frontend/src/format.ts index 98daf55..8bac79e 100644 --- a/frontend/src/format.ts +++ b/frontend/src/format.ts @@ -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; } diff --git a/frontend/src/pages/CapitalAccounts.tsx b/frontend/src/pages/CapitalAccounts.tsx index 6da7a67..b59de11 100644 --- a/frontend/src/pages/CapitalAccounts.tsx +++ b/frontend/src/pages/CapitalAccounts.tsx @@ -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([]); const [users, setUsers] = useState([]); const [rows, setRows] = useState([]); @@ -45,12 +48,14 @@ export default function CapitalAccounts() {

{error &&

{error}

} - + {canWrite && ( + + )}

Statements

@@ -86,13 +91,17 @@ export default function CapitalAccounts() { Exited {formatDate(r.exited_on)} - + {canWrite && ( + + )} + ) : !canWrite ? ( + Member ) : exitingKey === `${r.entity_id}:${r.investor_user_id}` ? ( - + {canWrite && ( + + )} ))} diff --git a/frontend/src/pages/Documents.tsx b/frontend/src/pages/Documents.tsx index 55f384f..04f952f 100644 --- a/frontend/src/pages/Documents.tsx +++ b/frontend/src/pages/Documents.tsx @@ -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([]); const [users, setUsers] = useState([]); const [docs, setDocs] = useState([]); @@ -38,11 +42,13 @@ export default function Documents() {

Documents

{error &&

{error}

} - + {canWrite && ( + + )}

All documents

@@ -91,15 +97,17 @@ export default function Documents() { > Download - + {canWrite && ( + + )} ))} diff --git a/frontend/src/pages/EntityDocuments.tsx b/frontend/src/pages/EntityDocuments.tsx index a50f048..cf70d2d 100644 --- a/frontend/src/pages/EntityDocuments.tsx +++ b/frontend/src/pages/EntityDocuments.tsx @@ -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(null); const [docs, setDocs] = useState([]); @@ -64,15 +67,17 @@ export default function EntityDocuments() { Download - + {canWrite && ( + + )} ))} diff --git a/frontend/src/pages/Users.tsx b/frontend/src/pages/Users.tsx index 41c7837..4f15347 100644 --- a/frontend/src/pages/Users.tsx +++ b/frontend/src/pages/Users.tsx @@ -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(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({ setIsActive(e.target.checked)} /> Account active + {canSetLevel && ( + + + + )} {external && ( diff --git a/frontend/src/version.ts b/frontend/src/version.ts index b83aa75..4798da4 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -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";