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:
@@ -33,3 +33,6 @@ deploy/javascript/
|
||||
# Confidential fund-admin spreadsheets (never commit)
|
||||
*.xlsx
|
||||
*.xls
|
||||
|
||||
# start-cli signing key + local config (never commit)
|
||||
deploy/.startos/
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 }) => {},
|
||||
},
|
||||
})
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { api, canEditRound, type AssetBalances, type Entity, type EntityType, type Holding, type User, type ValuationRound } from "../api";
|
||||
import { api, canManageEntity, type AssetBalances, type Entity, type EntityType, type Holding, type User, type ValuationRound } from "../api";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { formatMoney, formatDate, formatQuarter } from "../format";
|
||||
import EntityHeader from "../components/EntityHeader";
|
||||
@@ -25,7 +25,7 @@ export default function EntityOverview() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const isWriter = !!user && canEditRound(user.role);
|
||||
const isWriter = !!user && canManageEntity(user.role);
|
||||
|
||||
// For a GP/mgmt entity linked to an investor account, total its balance across funds.
|
||||
const linkedTotal = useMemo(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { api, canEditRound, type Entity, type Partner } from "../api";
|
||||
import { api, canManageEntity, type Entity, type Partner } from "../api";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { formatDate, formatMoneyExact } from "../format";
|
||||
import EntityHeader from "../components/EntityHeader";
|
||||
@@ -18,7 +18,7 @@ export default function EntityPartners() {
|
||||
const [exitingId, setExitingId] = useState<number | null>(null);
|
||||
const [exitDate, setExitDate] = useState(() => new Date().toISOString().slice(0, 10));
|
||||
|
||||
const isWriter = !!user && canEditRound(user.role);
|
||||
const isWriter = !!user && canManageEntity(user.role);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
api, isAdmin,
|
||||
api, isAdministrator, isManager,
|
||||
type Entity, type EntityType, type CapitalImportPreview, type BatchCapitalImportResult,
|
||||
type BtcPricesStatus,
|
||||
} from "../api";
|
||||
@@ -47,7 +47,10 @@ export default function Import() {
|
||||
const [step, setStep] = useState("");
|
||||
const [confirmReplace, setConfirmReplace] = useState(false);
|
||||
|
||||
const canImport = user && isAdmin(user.role);
|
||||
const canImport = user && isManager(user.role);
|
||||
// An Administrator imports into their own funds only — no fund creation, and the
|
||||
// portal-wide BTC price table stays internal.
|
||||
const administrator = !!user && isAdministrator(user.role);
|
||||
const inputCls = "w-full px-3 py-2 border border-gray-300 rounded text-sm";
|
||||
|
||||
useEffect(() => {
|
||||
@@ -209,7 +212,7 @@ export default function Import() {
|
||||
}
|
||||
|
||||
if (!canImport) {
|
||||
return <div className="text-gray-500 text-sm">Import requires Managing Partner, Operations, or CFO role.</div>;
|
||||
return <div className="text-gray-500 text-sm">Import requires an admin role.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -239,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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -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,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";
|
||||
|
||||
Reference in New Issue
Block a user