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
+3
View File
@@ -33,3 +33,6 @@ deploy/javascript/
# Confidential fund-admin spreadsheets (never commit)
*.xlsx
*.xls
# start-cli signing key + local config (never commit)
deploy/.startos/
+31
View File
@@ -92,3 +92,34 @@ require_approver = require_role(UserRole.approver) # final sign-off — Managin
require_audit_reader = require_role(UserRole.approver, UserRole.cfo, UserRole.operations)
# Account administration (create/manage users, documents, capital accounts)
require_internal_admin = require_role(UserRole.approver, UserRole.cfo, UserRole.operations)
# Internal admins plus the external Administrator (fund_administrator). Every endpoint
# using this gate must also call check_administrator_scope for the entity it touches —
# the role alone says nothing about WHICH entities an Administrator may manage.
require_admin = require_role(
UserRole.approver, UserRole.cfo, UserRole.operations, UserRole.fund_administrator
)
# Entity-record writers: internal writers plus the external Administrator (scope-checked).
require_entity_writer = require_role(
UserRole.fund_admin, UserRole.cfo, UserRole.approver, UserRole.operations,
UserRole.fund_administrator,
)
def require_internal_or_administrator(user: User = Depends(get_current_user)) -> User:
"""Read gate for admin screens: any internal role, or the external Administrator.
Investors are blocked; Administrator calls must still be scope-checked per entity.
"""
if user.role == UserRole.investor:
raise HTTPException(status_code=403, detail="Insufficient permissions")
return user
def check_administrator_scope(user: User, entity_id: int, session: Session) -> None:
"""403 when an external Administrator 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):
raise HTTPException(status_code=403, detail="No access to this entity")
+6 -50
View File
@@ -10,10 +10,7 @@ from ten31portal import config
from ten31portal.auth import hash_password
from ten31portal.database import engine
from ten31portal.db_init import run_migrations
from ten31portal.models import (
AuditLog, CapitalAccountStatement, Document, EntityAccess, User, UserRole,
ValuationRound,
)
from ten31portal.models import User, UserRole
def _find_user(session: Session, username: str | None, email: str | None) -> User | None:
@@ -183,7 +180,7 @@ def delete_user(args: argparse.Namespace) -> None:
"""Delete a user account and its dependent rows. The Service Admin is protected."""
run_migrations()
from ten31portal import storage
from ten31portal.routers.user_router import delete_user_cascade
with Session(engine) as session:
user = _find_user(session, args.username, args.email)
@@ -195,52 +192,11 @@ def delete_user(args: argparse.Namespace) -> None:
print(f"Error: '{user.username}' is the Service Admin and cannot be deleted.", file=sys.stderr)
sys.exit(1)
uid = user.id
# Entity-access grants and capital-account statements are this user's own data.
for acc in session.exec(select(EntityAccess).where(EntityAccess.user_id == uid)).all():
session.delete(acc)
for stmt in session.exec(
select(CapitalAccountStatement).where(CapitalAccountStatement.investor_user_id == uid)
).all():
session.delete(stmt)
# Documents addressed privately to this investor are removed (file + row); documents
# they uploaded stay, with the uploader cleared.
for doc in session.exec(select(Document).where(Document.investor_user_id == uid)).all():
try:
storage.delete_file(doc.storage_path)
except OSError as exc:
# Don't abort the whole deletion, but surface it — a swallowed disk/permission
# error would silently orphan the file on the data volume.
print(f"Warning: could not delete file {doc.storage_path}: {exc}", file=sys.stderr)
session.delete(doc)
for doc in session.exec(select(Document).where(Document.uploaded_by == uid)).all():
doc.uploaded_by = None
session.add(doc)
# Preserve history/rounds by clearing the references to this user.
for rnd in session.exec(
select(ValuationRound).where(
(ValuationRound.submitted_by == uid) | (ValuationRound.approved_by == uid)
)
).all():
if rnd.submitted_by == uid:
rnd.submitted_by = None
if rnd.approved_by == uid:
rnd.approved_by = None
session.add(rnd)
for log in session.exec(select(AuditLog).where(AuditLog.actor_user_id == uid)).all():
log.actor_user_id = None
session.add(log)
# Detach any linked sub-accounts so they log in on their own again.
for sub in session.exec(select(User).where(User.primary_account_id == uid)).all():
sub.primary_account_id = None
session.add(sub)
name, username = user.name, user.username
session.delete(user)
# Don't abort on a file that won't delete, but surface it — a swallowed disk or
# permission error would silently orphan the file on the data volume.
for warning in delete_user_cascade(session, user):
print(f"Warning: {warning}", file=sys.stderr)
session.commit()
print(f"Deleted user {name} ({username}).")
@@ -7,8 +7,8 @@ from sqlmodel import Session, select, col
from ten31portal.audit import record_audit
from ten31portal.auth import (
accessible_entity_ids, get_current_user, household_user_ids,
require_internal_admin,
accessible_entity_ids, check_administrator_scope, get_current_user,
household_user_ids, require_admin,
)
from ten31portal.database import get_session
from ten31portal.models import (
@@ -76,8 +76,15 @@ def list_statements(
query = select(CapitalAccountStatement)
allowed = accessible_entity_ids(user, session)
if allowed is not None:
# External accounts see statements for every legal name linked to their login.
if user.role == UserRole.fund_administrator:
# An Administrator sees every investor's statements, but only inside their funds.
if not allowed:
return []
query = query.where(col(CapitalAccountStatement.entity_id).in_(allowed))
if investor_user_id is not None:
query = query.where(CapitalAccountStatement.investor_user_id == investor_user_id)
elif allowed is not None:
# Investors see statements for every legal name linked to their login.
query = query.where(
col(CapitalAccountStatement.investor_user_id).in_(household_user_ids(user, session))
)
@@ -118,9 +125,10 @@ def list_statements(
@router.post("", status_code=201)
def create_statement(
body: CapitalAccountCreate,
admin: User = Depends(require_internal_admin),
admin: User = Depends(require_admin),
session: Session = Depends(get_session),
) -> CapitalAccountResponse:
check_administrator_scope(admin, body.entity_id, session)
if session.get(Entity, body.entity_id) is None:
raise HTTPException(status_code=404, detail="Entity not found")
investor = session.get(User, body.investor_user_id)
@@ -167,12 +175,13 @@ def create_statement(
@router.delete("/{statement_id}")
def delete_statement(
statement_id: int,
admin: User = Depends(require_internal_admin),
admin: User = Depends(require_admin),
session: Session = Depends(get_session),
) -> dict[str, str]:
stmt = session.get(CapitalAccountStatement, statement_id)
if stmt is None:
raise HTTPException(status_code=404, detail="Statement not found")
check_administrator_scope(admin, stmt.entity_id, session)
record_audit(session, admin.id, "delete", "capital_account", statement_id, None)
session.delete(stmt)
session.commit()
@@ -20,7 +20,7 @@ from sqlmodel import Session, select
from ten31portal import config, storage
from ten31portal.audit import record_audit
from ten31portal.auth import hash_password, require_internal_admin
from ten31portal.auth import check_administrator_scope, hash_password, require_admin
from ten31portal.database import get_session
from ten31portal.models import (
CapitalAccountStatement, Entity, EntityAccess, User, UserRole,
@@ -222,9 +222,11 @@ def preview_import(
entity_id: int | None = Form(None),
row_index: int | None = Form(None),
password: str | None = Form(None),
admin: User = Depends(require_internal_admin),
admin: User = Depends(require_admin),
session: Session = Depends(get_session),
) -> CapitalImportPreview:
if entity_id is not None:
check_administrator_scope(admin, entity_id, session)
if entity_id is not None and session.get(Entity, entity_id) is None:
raise HTTPException(status_code=404, detail="Entity not found")
@@ -289,9 +291,10 @@ def preview_import(
@router.post("/commit")
def commit_import(
body: CapitalImportCommit,
admin: User = Depends(require_internal_admin),
admin: User = Depends(require_admin),
session: Session = Depends(get_session),
) -> dict:
check_administrator_scope(admin, body.entity_id, session)
entity = session.get(Entity, body.entity_id)
if entity is None:
raise HTTPException(status_code=404, detail="Entity not found")
@@ -395,7 +398,7 @@ def batch_import(
files: list[UploadFile] = File(...),
entity_id: int = Form(...),
password: str | None = Form(None),
admin: User = Depends(require_internal_admin),
admin: User = Depends(require_admin),
session: Session = Depends(get_session),
) -> BatchCapitalImportResult:
"""Backfill several quarters of capital history for one fund from a batch of eNAV files.
@@ -407,6 +410,7 @@ def batch_import(
(no account creation). One file failing (bad password, no ALLOC SI, unreadable date) is
reported per-file and does not abort the rest.
"""
check_administrator_scope(admin, entity_id, session)
entity = session.get(Entity, entity_id)
if entity is None:
raise HTTPException(status_code=404, detail="Entity not found")
@@ -8,8 +8,7 @@ from sqlmodel import Session, select
from ten31portal.audit import record_audit
from ten31portal.auth import (
accessible_entity_ids, can_access_entity, get_current_user,
household_user_ids, require_internal_admin,
accessible_entity_ids, can_access_entity, get_current_user, household_user_ids,
)
from ten31portal.database import get_session
from ten31portal.models import (
@@ -161,12 +160,15 @@ def download_document(
@router.delete("/{document_id}")
def delete_document(
document_id: int,
admin: User = Depends(require_internal_admin),
admin: User = Depends(get_current_user),
session: Session = Depends(get_session),
) -> dict[str, str]:
doc = session.get(Document, document_id)
if doc is None:
raise HTTPException(status_code=404, detail="Document not found")
# Same reach as upload: internal admins anywhere, an Administrator on their entities.
if not _can_upload(admin, doc.entity_id, session):
raise HTTPException(status_code=403, detail="Insufficient permissions")
storage.delete_file(doc.storage_path)
record_audit(session, admin.id, "delete", "document", document_id, {
"filename": doc.original_filename,
+17 -7
View File
@@ -7,8 +7,8 @@ from sqlmodel import Session, col, select
from ten31portal.audit import record_audit
from ten31portal.auth import (
accessible_entity_ids, get_current_user, household_user_ids, require_internal,
require_writer,
accessible_entity_ids, check_administrator_scope, get_current_user, household_user_ids,
require_entity_writer, require_internal, require_internal_or_administrator, require_writer,
)
from ten31portal.database import get_session
from ten31portal.models import (
@@ -115,10 +115,11 @@ def entity_rollup(
@router.get("/{entity_id}/partners")
def list_partners(
entity_id: int,
user: User = Depends(require_internal),
user: User = Depends(require_internal_or_administrator),
session: Session = Depends(get_session),
) -> list[PartnerResponse]:
"""Members (investors) granted access to this entity, with their latest capital value."""
check_administrator_scope(user, entity_id, session)
if session.get(Entity, entity_id) is None:
raise HTTPException(status_code=404, detail="Entity not found")
@@ -163,7 +164,7 @@ def set_partner_exited(
entity_id: int,
user_id: int,
body: PartnerExitUpdate,
admin: User = Depends(require_writer),
admin: User = Depends(require_entity_writer),
session: Session = Depends(get_session),
) -> PartnerResponse:
"""Mark a member as exited from this fund (stake sold/transferred), or clear it.
@@ -171,6 +172,7 @@ def set_partner_exited(
Their statements and documents stay; the portal shows an Exited badge instead of a
phantom -100% and drops the position from portfolio and fund committed totals.
"""
check_administrator_scope(admin, entity_id, session)
if session.get(Entity, entity_id) is None:
raise HTTPException(status_code=404, detail="Entity not found")
member = session.get(User, user_id)
@@ -208,7 +210,7 @@ def set_partner_exited(
@router.delete("/{entity_id}/partners")
def clear_partners(
entity_id: int,
user: User = Depends(require_writer),
user: User = Depends(require_entity_writer),
session: Session = Depends(get_session),
) -> dict[str, int]:
"""Remove all partners from this fund — deletes its capital-account statements and the
@@ -217,6 +219,7 @@ def clear_partners(
# Local import avoids a module-load cycle (capital_import_router imports import_router).
from ten31portal.routers.capital_import_router import reset_entity_partners
check_administrator_scope(user, entity_id, session)
if session.get(Entity, entity_id) is None:
raise HTTPException(status_code=404, detail="Entity not found")
res = reset_entity_partners(entity_id, session)
@@ -281,9 +284,10 @@ def create_entity(
def update_entity(
entity_id: int,
body: EntityUpdate,
user: User = Depends(require_writer),
user: User = Depends(require_entity_writer),
session: Session = Depends(get_session),
) -> EntityResponse:
check_administrator_scope(user, entity_id, session)
entity = session.get(Entity, entity_id)
if entity is None:
raise HTTPException(status_code=404, detail="Entity not found")
@@ -305,7 +309,7 @@ def update_entity(
@router.get("/{entity_id}/asset-balances")
def asset_balances(
entity_id: int,
user: User = Depends(require_internal),
user: User = Depends(require_internal_or_administrator),
session: Session = Depends(get_session),
) -> AssetBalancesResponse:
"""A GP/mgmt entity's assets = the linked account's capital balances across the funds.
@@ -313,6 +317,7 @@ def asset_balances(
Household-aware: if the linked account has other legal names linked to it (as the eNAV
often splits one LLC across names), their balances are included too.
"""
check_administrator_scope(user, entity_id, session)
entity = session.get(Entity, entity_id)
if entity is None:
raise HTTPException(status_code=404, detail="Entity not found")
@@ -328,6 +333,11 @@ def asset_balances(
.where(col(CapitalAccountStatement.investor_user_id).in_(household))
.order_by(col(CapitalAccountStatement.as_of_date).desc())
).all()
# An Administrator only sees the slice of the linked account's balances that sits
# in funds granted to them — the linked account may also invest elsewhere.
allowed = accessible_entity_ids(user, session)
if allowed is not None:
rows = [r for r in rows if r.entity_id in allowed]
names = dict(session.exec(
select(User.id, User.name).where(col(User.id).in_({r.investor_user_id for r in rows}))
).all()) if rows else {}
@@ -4,7 +4,9 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from ten31portal.audit import record_audit
from ten31portal.auth import require_internal, require_writer
from ten31portal.auth import (
check_administrator_scope, require_internal_or_administrator, require_writer,
)
from ten31portal.database import get_session
from ten31portal.models import Entity, Holding, Position, User
from ten31portal.schemas import HoldingCreate, HoldingResponse, HoldingUpdate
@@ -15,9 +17,10 @@ router = APIRouter(tags=["holdings"])
@router.get("/api/entities/{entity_id}/holdings")
def list_holdings(
entity_id: int,
user: User = Depends(require_internal),
user: User = Depends(require_internal_or_administrator),
session: Session = Depends(get_session),
) -> list[HoldingResponse]:
check_administrator_scope(user, entity_id, session)
entity = session.get(Entity, entity_id)
if entity is None:
raise HTTPException(status_code=404, detail="Entity not found")
+10 -2
View File
@@ -18,7 +18,7 @@ from sqlmodel import Session, select, col
from ten31portal import storage
from ten31portal.audit import record_audit
from ten31portal.auth import require_role
from ten31portal.auth import check_administrator_scope, require_admin, require_role
from ten31portal.database import get_session
from ten31portal.models import (
BtcPrice, Entity, EntityType, Holding, Position,
@@ -341,9 +341,17 @@ def import_schedule(
commit: bool = Query(default=True),
replace_existing: bool = Query(default=False),
password: str | None = Form(default=None),
user: User = Depends(require_role(UserRole.approver, UserRole.cfo, UserRole.operations)),
user: User = Depends(require_admin),
session: Session = Depends(get_session),
) -> dict[str, Any]:
# An Administrator imports only into a fund granted to them — creating a new entity
# (or name-matching into an arbitrary one) is internal-only.
if user.role == UserRole.fund_administrator:
if entity_id is None:
raise HTTPException(
status_code=403, detail="Choose one of your funds to import into."
)
check_administrator_scope(user, entity_id, session)
try:
file_bytes = storage.read_capped(file)
except storage.UploadTooLarge as exc:
@@ -6,7 +6,9 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from ten31portal.audit import record_audit
from ten31portal.auth import require_internal, require_writer
from ten31portal.auth import (
check_administrator_scope, require_internal_or_administrator, require_writer,
)
from ten31portal.database import get_session
from ten31portal.models import Holding, Position, Valuation, ValuationRound, RoundStatus, User
from ten31portal.schemas import PositionCreate, PositionResponse, PositionUpdate
@@ -22,12 +24,13 @@ def _dollars_to_cents(dollars: float) -> int:
@router.get("/api/holdings/{holding_id}/positions")
def list_positions(
holding_id: int,
user: User = Depends(require_internal),
user: User = Depends(require_internal_or_administrator),
session: Session = Depends(get_session),
) -> list[PositionResponse]:
holding = session.get(Holding, holding_id)
if holding is None:
raise HTTPException(status_code=404, detail="Holding not found")
check_administrator_scope(user, holding.entity_id, session)
rows = session.exec(select(Position).where(Position.holding_id == holding_id)).all()
return [PositionResponse.model_validate(r, from_attributes=True) for r in rows]
+8 -3
View File
@@ -6,7 +6,10 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select, col
from ten31portal.audit import record_audit
from ten31portal.auth import require_internal, require_writer, require_approver
from ten31portal.auth import (
check_administrator_scope, require_internal_or_administrator, require_writer,
require_approver,
)
from ten31portal.database import get_session
from ten31portal.models import (
Entity, Holding, Position, Valuation, ValuationRound,
@@ -30,9 +33,10 @@ def _round_response(round: ValuationRound, session: Session) -> RoundResponse:
@router.get("/api/entities/{entity_id}/rounds")
def list_rounds(
entity_id: int,
user: User = Depends(require_internal),
user: User = Depends(require_internal_or_administrator),
session: Session = Depends(get_session),
) -> list[RoundResponse]:
check_administrator_scope(user, entity_id, session)
entity = session.get(Entity, entity_id)
if entity is None:
raise HTTPException(status_code=404, detail="Entity not found")
@@ -47,12 +51,13 @@ def list_rounds(
@router.get("/api/rounds/{round_id}")
def get_round(
round_id: int,
user: User = Depends(require_internal),
user: User = Depends(require_internal_or_administrator),
session: Session = Depends(get_session),
) -> RoundResponse:
round = session.get(ValuationRound, round_id)
if round is None:
raise HTTPException(status_code=404, detail="Round not found")
check_administrator_scope(user, round.entity_id, session)
return _round_response(round, session)
+198 -15
View File
@@ -5,12 +5,13 @@ from sqlmodel import Session, select, col
from ten31portal.audit import record_audit
from ten31portal.auth import (
accessible_entity_ids, can_access_entity, get_current_user, hash_password,
household_user_ids, require_internal_admin,
accessible_entity_ids, can_access_entity, check_administrator_scope, get_current_user,
hash_password, household_user_ids, require_admin, require_internal_admin,
)
from ten31portal.database import get_session
from ten31portal.models import (
CapitalAccountStatement, Document, Entity, EntityAccess, EXTERNAL_ROLES, User, UserRole,
AuditLog, CapitalAccountStatement, Document, Entity, EntityAccess, EXTERNAL_ROLES,
User, UserRole, ValuationRound,
)
from ten31portal.schemas import (
AccessGrant, AccessMatrixResponse, AccountLink, CapitalAccountResponse,
@@ -21,17 +22,111 @@ from ten31portal.schemas import (
router = APIRouter(prefix="/api/users", tags=["users"])
# --- Administrator (external, entity-scoped) visibility helpers ---
def _visible_investor_ids(scope: set[int], session: Session) -> set[int]:
"""Investors an Administrator may see and manage: anyone with an access grant or a
capital-account statement in one of the Administrator's entities."""
if not scope:
return set()
ids = set(session.exec(
select(EntityAccess.user_id).where(col(EntityAccess.entity_id).in_(scope))
).all())
ids |= set(session.exec(
select(CapitalAccountStatement.investor_user_id).where(
col(CapitalAccountStatement.entity_id).in_(scope)
)
).all())
return ids
def _check_target_in_scope(admin: User, target: User, session: Session) -> None:
"""403 unless the caller is internal, or the target is an investor tied to one of the
Administrator's entities. Keeps an external Administrator away from staff accounts and
from other funds' investors entirely."""
scope = accessible_entity_ids(admin, session)
if scope is None:
return
if target.role != UserRole.investor or target.id not in _visible_investor_ids(scope, session):
raise HTTPException(status_code=403, detail="No access to this account")
def delete_user_cascade(session: Session, user: User) -> list[str]:
"""Delete a user and its dependent rows; returns warnings for files that would not delete.
Shared by the API endpoint and the CLI. The caller is responsible for the guards
(Service Admin, self-deletion, scope) and for committing the session.
"""
from ten31portal import storage
warnings: list[str] = []
uid = user.id
# Entity-access grants and capital-account statements are this user's own data.
for acc in session.exec(select(EntityAccess).where(EntityAccess.user_id == uid)).all():
session.delete(acc)
for stmt in session.exec(
select(CapitalAccountStatement).where(CapitalAccountStatement.investor_user_id == uid)
).all():
session.delete(stmt)
# Documents addressed privately to this investor are removed (file + row); documents
# they uploaded stay, with the uploader cleared.
for doc in session.exec(select(Document).where(Document.investor_user_id == uid)).all():
try:
storage.delete_file(doc.storage_path)
except OSError as exc:
warnings.append(f"could not delete file {doc.storage_path}: {exc}")
session.delete(doc)
for doc in session.exec(select(Document).where(Document.uploaded_by == uid)).all():
doc.uploaded_by = None
session.add(doc)
# Preserve history/rounds by clearing the references to this user.
for rnd in session.exec(
select(ValuationRound).where(
(ValuationRound.submitted_by == uid) | (ValuationRound.approved_by == uid)
)
).all():
if rnd.submitted_by == uid:
rnd.submitted_by = None
if rnd.approved_by == uid:
rnd.approved_by = None
session.add(rnd)
for log in session.exec(select(AuditLog).where(AuditLog.actor_user_id == uid)).all():
log.actor_user_id = None
session.add(log)
# Detach any linked sub-accounts so they log in on their own again.
for sub in session.exec(select(User).where(User.primary_account_id == uid)).all():
sub.primary_account_id = None
session.add(sub)
session.delete(user)
return warnings
@router.get("/access-matrix")
def access_matrix(
admin: User = Depends(require_internal_admin),
admin: User = Depends(require_admin),
session: Session = Depends(get_session),
) -> AccessMatrixResponse:
"""External accounts, all entities, and the grants linking them."""
"""External accounts, all entities, and the grants linking them.
For an external Administrator the matrix is their slice of the world: only their
granted entities, and only the investors tied to those entities.
"""
users = session.exec(
select(User).where(col(User.role).in_(EXTERNAL_ROLES)).order_by(User.name) # type: ignore[arg-type]
).all()
entities = session.exec(select(Entity).order_by(Entity.name)).all() # type: ignore[arg-type]
grants = session.exec(select(EntityAccess)).all()
scope = accessible_entity_ids(admin, session)
if scope is not None:
visible = _visible_investor_ids(scope, session)
users = [u for u in users if u.id in visible]
entities = [e for e in entities if e.id in scope]
grants = [g for g in grants if g.entity_id in scope and g.user_id in visible]
return AccessMatrixResponse(
users=[UserResponse.model_validate(u, from_attributes=True) for u in users],
entities=[EntityResponse.model_validate(e, from_attributes=True) for e in entities],
@@ -43,12 +138,15 @@ def access_matrix(
def grant_access(
user_id: int,
entity_id: int,
admin: User = Depends(require_internal_admin),
admin: User = Depends(require_admin),
session: Session = Depends(get_session),
) -> dict[str, str]:
check_administrator_scope(admin, entity_id, session)
user = session.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
if admin.role == UserRole.fund_administrator and user.role != UserRole.investor:
raise HTTPException(status_code=403, detail="Administrators manage investor accounts only")
if session.get(Entity, entity_id) is None:
raise HTTPException(status_code=404, detail="Entity not found")
existing = session.exec(
@@ -67,9 +165,16 @@ def grant_access(
def revoke_access(
user_id: int,
entity_id: int,
admin: User = Depends(require_internal_admin),
admin: User = Depends(require_admin),
session: Session = Depends(get_session),
) -> dict[str, str]:
check_administrator_scope(admin, entity_id, session)
if admin.role == UserRole.fund_administrator:
target = session.get(User, user_id)
if target is not None and target.role != UserRole.investor:
raise HTTPException(
status_code=403, detail="Administrators manage investor accounts only"
)
existing = session.exec(
select(EntityAccess).where(
EntityAccess.user_id == user_id, EntityAccess.entity_id == entity_id
@@ -224,31 +329,50 @@ def _set_entity_access(user_id: int, entity_ids: list[int], session: Session) ->
@router.get("")
def list_users(
admin: User = Depends(require_internal_admin),
admin: User = Depends(require_admin),
session: Session = Depends(get_session),
) -> list[UserResponse]:
rows = session.exec(select(User).order_by(User.name)).all() # type: ignore[arg-type]
scope = accessible_entity_ids(admin, session)
if scope is not None:
visible = _visible_investor_ids(scope, session)
rows = [r for r in rows if r.role == UserRole.investor and r.id in visible]
return [UserResponse.model_validate(r, from_attributes=True) for r in rows]
@router.get("/{user_id}")
def get_user(
user_id: int,
admin: User = Depends(require_internal_admin),
admin: User = Depends(require_admin),
session: Session = Depends(get_session),
) -> UserDetailResponse:
user = session.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
_check_target_in_scope(admin, user, session)
return _user_detail(user, session)
@router.post("", status_code=201)
def create_user(
body: UserCreate,
admin: User = Depends(require_internal_admin),
admin: User = Depends(require_admin),
session: Session = Depends(get_session),
) -> UserDetailResponse:
entity_ids = body.entity_ids
scope = accessible_entity_ids(admin, session)
if scope is not None:
# An Administrator creates investor accounts only, and only on their own funds —
# anything else would be privilege escalation or an account they can't see again.
if body.role != UserRole.investor:
raise HTTPException(
status_code=403, detail="Administrators can only create investor accounts"
)
entity_ids = [e for e in entity_ids if e in scope]
if not entity_ids:
raise HTTPException(
status_code=400, detail="Select at least one of your funds for this investor"
)
if session.exec(select(User).where(User.username == body.username)).first():
raise HTTPException(status_code=409, detail="Username already taken")
if body.email and session.exec(select(User).where(User.email == body.email)).first():
@@ -263,10 +387,10 @@ def create_user(
)
session.add(user)
session.flush()
_set_entity_access(user.id, body.entity_ids, session)
_set_entity_access(user.id, entity_ids, session)
record_audit(session, admin.id, "create", "user", user.id,
{"username": body.username, "role": body.role.value,
"entity_ids": body.entity_ids})
"entity_ids": entity_ids})
session.commit()
session.refresh(user)
return _user_detail(user, session)
@@ -276,16 +400,28 @@ def create_user(
def update_user(
user_id: int,
body: UserUpdate,
admin: User = Depends(require_internal_admin),
admin: User = Depends(require_admin),
session: Session = Depends(get_session),
) -> UserDetailResponse:
user = session.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
_check_target_in_scope(admin, user, session)
changes = body.model_dump(exclude_unset=True)
entity_ids = changes.pop("entity_ids", None)
scope = accessible_entity_ids(admin, session)
if scope is not None:
if "role" in changes and changes["role"] != user.role:
raise HTTPException(status_code=403, detail="Administrators cannot change roles")
changes.pop("role", None)
if entity_ids is not None:
# Only this Administrator's funds are theirs to grant or revoke; the investor's
# access to any other fund is preserved untouched.
kept_elsewhere = set(_entity_ids_for(user_id, session)) - scope
entity_ids = list((set(entity_ids) & scope) | kept_elsewhere)
if "username" in changes:
new_username = (changes["username"] or "").strip()
if not new_username:
@@ -319,7 +455,7 @@ def update_user(
def link_account(
user_id: int,
body: AccountLink,
admin: User = Depends(require_internal_admin),
admin: User = Depends(require_admin),
session: Session = Depends(get_session),
) -> UserDetailResponse:
"""Link an investor account to a primary login (or detach it when null).
@@ -330,6 +466,7 @@ def link_account(
user = session.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
_check_target_in_scope(admin, user, session)
if user.role != UserRole.investor:
raise HTTPException(status_code=400, detail="Only investor accounts can be linked.")
@@ -340,6 +477,7 @@ def link_account(
primary = session.get(User, primary_id)
if primary is None or primary.role != UserRole.investor:
raise HTTPException(status_code=400, detail="Primary must be an investor account.")
_check_target_in_scope(admin, primary, session)
if primary.primary_account_id is not None:
raise HTTPException(
status_code=400,
@@ -369,12 +507,13 @@ def link_account(
def reset_password(
user_id: int,
body: PasswordReset,
admin: User = Depends(require_internal_admin),
admin: User = Depends(require_admin),
session: Session = Depends(get_session),
) -> dict[str, str]:
user = session.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
_check_target_in_scope(admin, user, session)
user.password_hash = hash_password(body.password)
# Admin handed them a real password — no forced change on next login.
user.must_change_password = False
@@ -383,3 +522,47 @@ def reset_password(
record_audit(session, admin.id, "reset_password", "user", user_id, None)
session.commit()
return {"status": "ok"}
@router.delete("/{user_id}")
def delete_user(
user_id: int,
admin: User = Depends(require_admin),
session: Session = Depends(get_session),
) -> dict[str, str]:
"""Delete an account and its dependent data (grants, statements, private documents).
The Service Admin can never be deleted, nor can you delete yourself. An external
Administrator may only delete an investor who belongs solely to their own funds —
an investor who also sits in another fund is another manager's problem too.
"""
user = session.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
if user.is_service_admin:
raise HTTPException(status_code=400, detail="The Service Admin cannot be deleted")
if user.id == admin.id:
raise HTTPException(status_code=400, detail="You cannot delete your own account")
_check_target_in_scope(admin, user, session)
scope = accessible_entity_ids(admin, session)
if scope is not None:
footprint = set(_entity_ids_for(user_id, session))
footprint |= set(session.exec(
select(CapitalAccountStatement.entity_id).where(
CapitalAccountStatement.investor_user_id == user_id
)
).all())
if not footprint <= scope:
raise HTTPException(
status_code=403,
detail="This investor also belongs to funds outside your access; "
"remove them from your funds instead of deleting the account.",
)
name, username = user.name, user.username
delete_user_cascade(session, user)
record_audit(session, admin.id, "delete", "user", user_id,
{"username": username, "name": name})
session.commit()
return {"status": "deleted"}
+241
View File
@@ -0,0 +1,241 @@
"""External Administrator role (0.2.42): full management, fenced to granted entities.
An Administrator (UserRole.fund_administrator) runs the same admin screens as internal
staff — users, partners, documents, capital accounts, imports — but only for the funds
granted to them via EntityAccess. These tests pin the fence.
"""
import io
from datetime import date
from sqlmodel import select
from ten31portal.models import (
CapitalAccountStatement, Document, Entity, EntityAccess, EntityType, User, 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):
"""Two funds; the Administrator manages fund A only. One LP in each fund."""
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()
admin = make_user(session, username="fundadmin", role=UserRole.fund_administrator,
name="Outside Administrator")
session.add(EntityAccess(user_id=admin.id, entity_id=fund_a.id))
lp_a = make_user(session, username="lp-a", role=UserRole.investor, name="LP Alpha")
session.add(EntityAccess(user_id=lp_a.id, entity_id=fund_a.id))
lp_b = make_user(session, username="lp-b", role=UserRole.investor, name="LP Beta")
session.add(EntityAccess(user_id=lp_b.id, entity_id=fund_b.id))
session.commit()
return fund_a, fund_b, admin, lp_a, lp_b
def _stmt(entity_id, investor_id, as_of=date(2026, 3, 31), balance=1_000_000_00):
return CapitalAccountStatement(
entity_id=entity_id, investor_user_id=investor_id, as_of_date=as_of,
commitment_cents=balance, beginning_balance_cents=0,
contributions_cents=balance, distributions_cents=0,
ending_balance_cents=balance,
)
def test_administrator_sees_only_their_funds_users(client, session, approver):
fund_a, fund_b, admin, lp_a, lp_b = _setup(session)
_login(client, "fundadmin")
users = client.get("/api/users").json()
assert {u["username"] for u in users} == {"lp-a"}
# Their fund's investor is reachable; the other fund's — and staff — are not.
assert client.get(f"/api/users/{lp_a.id}").status_code == 200
assert client.get(f"/api/users/{lp_b.id}").status_code == 403
assert client.get(f"/api/users/{approver.id}").status_code == 403
def test_administrator_creates_investors_only_on_their_funds(client, session, approver):
fund_a, fund_b, admin, lp_a, lp_b = _setup(session)
_login(client, "fundadmin")
# Investor on fund A: allowed; out-of-scope fund ids are silently dropped.
resp = client.post("/api/users", json={
"name": "New LP", "username": "new-lp", "password": "secretpw",
"role": "investor", "entity_ids": [fund_a.id, fund_b.id],
})
assert resp.status_code == 201, resp.text
assert resp.json()["entity_ids"] == [fund_a.id]
# Staff roles are privilege escalation.
resp = client.post("/api/users", json={
"name": "Sneaky", "username": "sneaky", "password": "secretpw",
"role": "operations", "entity_ids": [],
})
assert resp.status_code == 403
# An investor with no in-scope fund would be invisible to its creator.
resp = client.post("/api/users", json={
"name": "Orphan", "username": "orphan", "password": "secretpw",
"role": "investor", "entity_ids": [fund_b.id],
})
assert resp.status_code == 400
def test_administrator_partner_management_is_scoped(client, session, approver):
fund_a, fund_b, admin, lp_a, lp_b = _setup(session)
_login(client, "fundadmin")
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
resp = client.put(
f"/api/entities/{fund_a.id}/partners/{lp_a.id}/exited",
json={"exited_on": "2026-06-30"},
)
assert resp.status_code == 200
resp = client.put(
f"/api/entities/{fund_b.id}/partners/{lp_b.id}/exited",
json={"exited_on": "2026-06-30"},
)
assert resp.status_code == 403
def test_administrator_documents_upload_and_delete(client, session, approver, monkeypatch, tmp_path):
monkeypatch.setattr("ten31portal.storage.DOCS_DIR", str(tmp_path))
fund_a, fund_b, admin, lp_a, lp_b = _setup(session)
doc_b = Document(
entity_id=fund_b.id, category="statement", title="B statement",
original_filename="b.pdf", content_type="application/pdf", size_bytes=1,
storage_path="x-b",
)
session.add(doc_b)
session.commit()
_login(client, "fundadmin")
resp = client.post(
"/api/documents",
data={"entity_id": str(fund_a.id), "category": "statement", "title": "Q1 statement"},
files={"file": ("q1.pdf", io.BytesIO(b"pdf"), "application/pdf")},
)
assert resp.status_code == 201, resp.text
doc_a_id = resp.json()["id"]
assert client.delete(f"/api/documents/{doc_a_id}").json() == {"status": "deleted"}
assert client.delete(f"/api/documents/{doc_b.id}").status_code == 403
def test_administrator_capital_accounts_are_scoped(client, session, approver):
fund_a, fund_b, admin, lp_a, lp_b = _setup(session)
session.add(_stmt(fund_a.id, lp_a.id))
stmt_b = _stmt(fund_b.id, lp_b.id)
session.add(stmt_b)
session.commit()
_login(client, "fundadmin")
rows = client.get("/api/capital-accounts").json()
assert {r["entity_id"] for r in rows} == {fund_a.id}
resp = client.post("/api/capital-accounts", json={
"entity_id": fund_a.id, "investor_user_id": lp_a.id, "as_of_date": "2026-06-30",
"commitment_dollars": 100, "beginning_balance_dollars": 0,
"contributions_dollars": 100, "distributions_dollars": 0,
"ending_balance_dollars": 110,
})
assert resp.status_code == 201, resp.text
resp = client.post("/api/capital-accounts", json={
"entity_id": fund_b.id, "investor_user_id": lp_b.id, "as_of_date": "2026-06-30",
"commitment_dollars": 100, "beginning_balance_dollars": 0,
"contributions_dollars": 100, "distributions_dollars": 0,
"ending_balance_dollars": 110,
})
assert resp.status_code == 403
assert client.delete(f"/api/capital-accounts/{stmt_b.id}").status_code == 403
def test_administrator_deletes_only_investors_solely_in_their_funds(client, session, approver):
fund_a, fund_b, admin, lp_a, lp_b = _setup(session)
# lp_a also joins fund B — deleting them would reach beyond the Administrator's fence.
session.add(EntityAccess(user_id=lp_a.id, entity_id=fund_b.id))
solo = make_user(session, username="solo", role=UserRole.investor, name="Solo LP")
session.add(EntityAccess(user_id=solo.id, entity_id=fund_a.id))
session.commit()
solo_id = solo.id
_login(client, "fundadmin")
assert client.delete(f"/api/users/{lp_a.id}").status_code == 403
assert client.delete(f"/api/users/{approver.id}").status_code == 403
assert client.delete(f"/api/users/{solo_id}").json() == {"status": "deleted"}
session.expire_all() # the API deleted through its own session; drop our cached copy
assert session.exec(select(User).where(User.id == solo_id)).first() is None
assert session.exec(
select(EntityAccess).where(EntityAccess.user_id == solo_id)
).first() is None
def test_internal_admin_delete_guards(auth_client, session, approver):
service = make_user(session, username="svc", role=UserRole.operations,
is_service_admin=True)
victim = make_user(session, username="victim", role=UserRole.investor)
assert auth_client.delete(f"/api/users/{service.id}").status_code == 400
assert auth_client.delete(f"/api/users/{approver.id}").status_code == 400 # self
assert auth_client.delete(f"/api/users/{victim.id}").json() == {"status": "deleted"}
def test_administrator_update_preserves_other_funds_grants(client, session, approver):
fund_a, fund_b, admin, lp_a, lp_b = _setup(session)
session.add(EntityAccess(user_id=lp_a.id, entity_id=fund_b.id))
session.commit()
_login(client, "fundadmin")
# Submitting only in-scope grants must not strip the investor's fund B access.
resp = client.patch(f"/api/users/{lp_a.id}", json={"entity_ids": [fund_a.id]})
assert resp.status_code == 200
assert set(resp.json()["entity_ids"]) == {fund_a.id, fund_b.id}
# Revoking their own fund keeps fund B untouched.
resp = client.patch(f"/api/users/{lp_a.id}", json={"entity_ids": []})
assert resp.status_code == 200
assert resp.json()["entity_ids"] == [fund_b.id]
def test_administrator_blocked_from_internal_surfaces(client, session, approver):
fund_a, fund_b, admin, lp_a, lp_b = _setup(session)
_login(client, "fundadmin")
assert client.get("/api/audit").status_code == 403
assert client.post("/api/entities", json={"name": "New Fund", "type": "fund"}).status_code == 403
# eNAV import into a fund outside their grants (or with no fund chosen) is refused.
resp = client.post(
"/api/import/schedule",
files={"file": ("x.xlsx", io.BytesIO(b"junk"), "application/octet-stream")},
)
assert resp.status_code == 403
resp = client.post(
f"/api/import/schedule?entity_id={fund_b.id}",
files={"file": ("x.xlsx", io.BytesIO(b"junk"), "application/octet-stream")},
)
assert resp.status_code == 403
def test_administrator_reads_rounds_and_holdings_in_scope(client, session, approver):
fund_a, fund_b, admin, lp_a, lp_b = _setup(session)
_login(client, "fundadmin")
assert client.get(f"/api/entities/{fund_a.id}/rounds").status_code == 200
assert client.get(f"/api/entities/{fund_b.id}/rounds").status_code == 403
assert client.get(f"/api/entities/{fund_a.id}/holdings").status_code == 200
assert client.get(f"/api/entities/{fund_b.id}/holdings").status_code == 403
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ten31portal-startos",
"version": "0.2.40",
"version": "0.2.42",
"private": true,
"scripts": {
"build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
+3 -2
View File
@@ -1,6 +1,7 @@
export { v_0_2_41 as current } from './v_0_2_41'
export { v_0_2_42 as current } from './v_0_2_42'
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_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'
@@ -40,4 +41,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]
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]
@@ -0,0 +1,17 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v_0_2_42 = VersionInfo.of({
version: '0.2.42:0',
releaseNotes: {
en_US:
'Administrator accounts: the external Administrator role now signs into the ' +
'full admin interface, scoped to the funds and SPVs granted to it — fund ' +
'overview, partners, capital accounts, documents (upload and delete), eNAV ' +
'imports, and investor account management (create, edit, reset, delete) for ' +
'its own funds only. Also carries the 0.2.41 static-path security fix.',
},
migrations: {
up: async ({ effects }) => {},
down: async ({ effects }) => {},
},
})
+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.40'
const CACHE = 'ten31-portal-0.2.42'
self.addEventListener('install', () => self.skipWaiting())
+12 -8
View File
@@ -1,6 +1,6 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { AuthProvider, useAuth } from "./context/AuthContext";
import { isInternal } from "./api";
import { isAdministrator, isInternal } from "./api";
import { ForcePasswordChange, WelcomeFlow } from "./components/FirstLogin";
import Layout from "./components/Layout";
import Login from "./pages/Login";
@@ -20,9 +20,13 @@ import AccessGrid from "./pages/AccessGrid";
import InvestorView from "./pages/InvestorView";
import PortalLayout from "./portal/PortalLayout";
import InvestorHome from "./portal/InvestorHome";
import FundAdminHome from "./portal/FundAdminHome";
function InternalApp() {
function AdminApp() {
// Shared by internal staff and external Administrators. The internal-only screens
// (valuation sign-off, audit log, investor view) are simply absent for Administrators —
// the backend refuses them anyway.
const { user } = useAuth();
const internal = user != null && isInternal(user.role);
return (
<Layout>
<Routes>
@@ -32,11 +36,11 @@ function InternalApp() {
<Route path="/entities/:id/documents" element={<EntityDocuments />} />
<Route path="/entities/:id/investments" element={<Investments />} />
<Route path="/entities/:id/assets" element={<EntityAssets />} />
<Route path="/entities/:id/valuation" element={<ValuationWorkflow />} />
{internal && <Route path="/entities/:id/valuation" element={<ValuationWorkflow />} />}
<Route path="/import" element={<Import />} />
<Route path="/audit" element={<AuditLog />} />
{internal && <Route path="/audit" element={<AuditLog />} />}
<Route path="/users" element={<Users />} />
<Route path="/investor-view" element={<InvestorView />} />
{internal && <Route path="/investor-view" element={<InvestorView />} />}
<Route path="/access" element={<AccessGrid />} />
<Route path="/documents" element={<Documents />} />
<Route path="/capital-accounts" element={<CapitalAccounts />} />
@@ -50,7 +54,7 @@ function ExternalApp() {
const { user } = useAuth();
return (
<PortalLayout>
{user?.role === "fund_administrator" ? <FundAdminHome /> : <InvestorHome />}
<InvestorHome />
{user && !user.onboarded_at && <WelcomeFlow />}
</PortalLayout>
);
@@ -92,7 +96,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) ? <InternalApp /> : <ExternalApp />;
return isInternal(user.role) || isAdministrator(user.role) ? <AdminApp /> : <ExternalApp />;
}
export default function App() {
+9
View File
@@ -34,6 +34,13 @@ export const isInternal = (role: UserRole) => INTERNAL_ROLES.includes(role);
export const isAdmin = (role: UserRole) => ADMIN_ROLES.includes(role);
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";
// 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.
export const canManageEntity = (role: UserRole) =>
WRITER_ROLES.includes(role) || isAdministrator(role);
export interface User {
id: number;
@@ -576,6 +583,8 @@ export const api = {
method: "PUT",
body: JSON.stringify({ primary_account_id }),
}),
deleteUser: (id: number) =>
request<{ status: string }>(`/api/users/${id}`, { method: "DELETE" }),
// Documents
listDocuments: (params?: { entity_id?: number; investor_user_id?: number }) => {
+5 -3
View File
@@ -1,9 +1,8 @@
import { useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { isAdmin } from "../api";
import { isAdmin, isAdministrator } from "../api";
import { roleLabel } from "../format";
import { APP_VERSION } from "../version";
import ChangePasswordModal from "./ChangePasswordModal";
import TwoFactorModal from "./TwoFactorModal";
@@ -28,8 +27,12 @@ export default function Layout({ children }: { children: React.ReactNode }) {
const [navOpen, setNavOpen] = useState(false);
const [changingPw, setChangingPw] = useState(false);
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).
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];
return (
@@ -91,7 +94,6 @@ export default function Layout({ children }: { children: React.ReactNode }) {
>
Two-factor auth
</button>
<div className="text-xs text-gray-400">v{APP_VERSION}</div>
</div>
</nav>
+2 -2
View File
@@ -60,10 +60,10 @@ export function roleLabel(role: string): string {
approver: "Managing Partner",
operations: "Operations",
cfo: "CFO",
fund_admin: "Fund Admin",
fund_admin: "Staff (all funds)",
viewer: "Viewer (internal)",
investor: "Investor",
fund_administrator: "Fund administrator",
fund_administrator: "Administrator",
};
return map[role] ?? role;
}
+5
View File
@@ -2,6 +2,11 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { APP_VERSION } from "./version";
// Not shown in the UI; open the browser console to confirm which build is being served
// (the stale-deploy diagnostic the old sidebar badge used to provide).
console.info(`Ten31 Portal v${APP_VERSION}`);
createRoot(document.getElementById("root")!).render(
<StrictMode>
+2 -2
View File
@@ -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(() => {
+2 -2
View File
@@ -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;
+9 -4
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,6 +242,7 @@ 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>
{!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" />
@@ -249,6 +253,7 @@ export default function Import() {
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">
+44 -2
View File
@@ -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,6 +237,11 @@ function CreateUserModal({
<PasswordInput value={password} onChange={setPassword} placeholder="minimum 4 characters" />
</Field>
<Field label="Role">
{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}>
@@ -240,6 +249,7 @@ function CreateUserModal({
</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,7 +411,17 @@ function EditUserModal({
</Field>
)}
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex justify-end gap-2 pt-2">
<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>
@@ -393,6 +434,7 @@ function EditUserModal({
</button>
</div>
</div>
</div>
</Modal>
);
}
-180
View File
@@ -1,180 +0,0 @@
import { useEffect, useState } from "react";
import {
api,
type DocumentCategory,
type Entity,
type PortalDocument,
type User,
} from "../api";
import { categoryLabel, formatBytes, formatDate } from "../format";
const CATEGORIES: DocumentCategory[] = ["statement", "k1", "capital_account", "tax", "other"];
export default function FundAdminHome() {
const [entities, setEntities] = useState<Entity[]>([]);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
api
.listEntities()
.then(setEntities)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, []);
if (loading) return <p className="text-gray-500 text-sm">Loading</p>;
if (error) return <p className="text-red-600 text-sm">{error}</p>;
if (entities.length === 0)
return <p className="text-gray-500 text-sm">You don't have access to any entities yet.</p>;
return (
<div className="space-y-8">
{entities.map((e) => (
<EntityDocs key={e.id} entity={e} />
))}
</div>
);
}
function EntityDocs({ entity }: { entity: Entity }) {
const [docs, setDocs] = useState<PortalDocument[]>([]);
const [investors, setInvestors] = useState<User[]>([]);
const [error, setError] = useState("");
const load = () => {
api.listDocuments({ entity_id: entity.id }).then(setDocs).catch((e) => setError(e.message));
};
useEffect(() => {
load();
api.investorsForEntity(entity.id).then(setInvestors).catch(() => {});
}, [entity.id]);
const investorName = (id: number | null) =>
id == null ? "Shared (all investors)" : investors.find((i) => i.id === id)?.name ?? `Investor ${id}`;
return (
<section className="bg-white border border-gray-200 rounded-lg p-5">
<div className="flex items-baseline justify-between">
<h2 className="text-lg font-semibold text-gray-900">{entity.name}</h2>
<span className="text-xs text-gray-400 uppercase">{entity.type}</span>
</div>
{error && <p className="text-sm text-red-600 mt-2">{error}</p>}
<UploadForm entity={entity} investors={investors} onUploaded={load} setError={setError} />
<h3 className="text-xs font-medium text-gray-500 uppercase mt-5 mb-2">Documents</h3>
{docs.length === 0 ? (
<p className="text-sm text-gray-400">No documents yet.</p>
) : (
<ul className="divide-y divide-gray-100 border border-gray-100 rounded">
{docs.map((d) => (
<li key={d.id} className="flex items-center px-3 py-2 text-sm">
<span className="text-gray-900">{d.title}</span>
<span className="ml-2 text-xs text-gray-400">{categoryLabel(d.category)}</span>
<span className="ml-2 text-xs text-gray-400">· {investorName(d.investor_user_id)}</span>
<span className="ml-auto text-xs text-gray-400 mr-3">{formatDate(d.created_at)}</span>
<a href={api.downloadUrl(d.id)} className="text-accent-600 hover:text-accent-700">
Download
</a>
</li>
))}
</ul>
)}
</section>
);
}
function UploadForm({
entity,
investors,
onUploaded,
setError,
}: {
entity: Entity;
investors: User[];
onUploaded: () => void;
setError: (s: string) => void;
}) {
const [category, setCategory] = useState<DocumentCategory>("statement");
const [investorId, setInvestorId] = useState<number | "">("");
const [title, setTitle] = useState("");
const [file, setFile] = useState<File | null>(null);
const [busy, setBusy] = useState(false);
const submit = async () => {
if (!file) return;
setError("");
setBusy(true);
try {
const form = new FormData();
form.set("entity_id", String(entity.id));
form.set("category", category);
if (title) form.set("title", title);
if (investorId !== "") form.set("investor_user_id", String(investorId));
form.set("file", file);
await api.uploadDocument(form);
setTitle("");
setFile(null);
setInvestorId("");
onUploaded();
} catch (e: any) {
setError(e.message || "Upload failed");
} finally {
setBusy(false);
}
};
return (
<div className="mt-3 bg-gray-50 border border-gray-200 rounded p-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-500 mb-1">Category</label>
<select className={inputCls} value={category} onChange={(e) => setCategory(e.target.value as DocumentCategory)}>
{CATEGORIES.map((c) => (
<option key={c} value={c}>
{categoryLabel(c)}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Visibility</label>
<select
className={inputCls}
value={investorId}
onChange={(e) => setInvestorId(e.target.value === "" ? "" : Number(e.target.value))}
>
<option value="">Shared all investors</option>
{investors.map((u) => (
<option key={u.id} value={u.id}>
Private {u.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Title (optional)</label>
<input className={inputCls} value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">File</label>
<input type="file" onChange={(e) => setFile(e.target.files?.[0] ?? null)} className="text-sm" />
{file && <span className="text-xs text-gray-400 ml-2">{formatBytes(file.size)}</span>}
</div>
</div>
<div className="flex justify-end mt-2">
<button
onClick={submit}
disabled={busy || !file}
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
>
{busy ? "Uploading…" : "Upload"}
</button>
</div>
</div>
);
}
const inputCls =
"w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent";
-2
View File
@@ -1,7 +1,6 @@
import { useState } from "react";
import { useAuth } from "../context/AuthContext";
import { roleLabel } from "../format";
import { APP_VERSION } from "../version";
import ChangePasswordModal from "../components/ChangePasswordModal";
import TwoFactorModal from "../components/TwoFactorModal";
@@ -31,7 +30,6 @@ export default function PortalLayout({ children }: { children: React.ReactNode }
<button onClick={logout} className="text-sm text-gray-500 hover:text-gray-800">
Sign out
</button>
<span className="hidden sm:inline text-xs text-gray-300">v{APP_VERSION}</span>
</div>
</header>
<main className="max-w-4xl mx-auto p-4 sm:p-6">{children}</main>
+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.40";
export const APP_VERSION = "0.2.42";