diff --git a/backend/alembic/versions/d0e1f2a3b4c5_docs_seen_at.py b/backend/alembic/versions/d0e1f2a3b4c5_docs_seen_at.py new file mode 100644 index 0000000..ed57688 --- /dev/null +++ b/backend/alembic/versions/d0e1f2a3b4c5_docs_seen_at.py @@ -0,0 +1,27 @@ +"""add users.docs_seen_at (portal "New" document badge watermark) + +Revision ID: d0e1f2a3b4c5 +Revises: c9d0e1f2a3b4 +Create Date: 2026-07-03 09:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'd0e1f2a3b4c5' +down_revision: Union[str, None] = 'c9d0e1f2a3b4' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('docs_seen_at', sa.DateTime(), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('docs_seen_at') diff --git a/backend/ten31portal/cli.py b/backend/ten31portal/cli.py index 98087f6..9bb8187 100644 --- a/backend/ten31portal/cli.py +++ b/backend/ten31portal/cli.py @@ -87,6 +87,42 @@ def reset_password(args: argparse.Namespace) -> None: print(f"Password reset for {user.name} ({user.username}).") +def enable_investor_logins(args: argparse.Namespace) -> None: + """Give every no-login investor account the shared default password and enable sign-in. + + Targets investor-role accounts with login_enabled=False that log in on their own + (linked secondary names are skipped — they sign in under their primary). Accounts that + already have a working login are never touched. + """ + run_migrations() + + from ten31portal.models import UserRole + + with Session(engine) as session: + users = session.exec( + select(User).where( + User.role == UserRole.investor, + User.login_enabled == False, # noqa: E712 — SQL expression + User.primary_account_id == None, # noqa: E711 + ).order_by(User.name) # type: ignore[arg-type] + ).all() + if not users: + print("Nothing to do — every investor account already has a login.") + return + for u in users: + u.password_hash = hash_password(config.DEFAULT_INVESTOR_PASSWORD) + u.login_enabled = True + session.add(u) + session.commit() + for u in users: + print(f"Enabled login for {u.name} ({u.username})") + print( + f"\n{len(users)} investor account(s) set to the default password " + f"'{config.DEFAULT_INVESTOR_PASSWORD}'. Each investor should change it in the " + "portal (Change password)." + ) + + def show_admin_password(args: argparse.Namespace) -> None: """Print the randomly-generated initial admin password recorded on first boot.""" path = config.ADMIN_PASSWORD_FILE @@ -234,6 +270,29 @@ def reset_holdings(args: argparse.Namespace) -> None: f"Re-import the fund's NAV to repopulate it.") +def reset_partners(args: argparse.Namespace) -> None: + """Remove all partners (capital-account statements + access grants) from one fund.""" + run_migrations() + from ten31portal.models import Entity + from ten31portal.routers.capital_import_router import reset_entity_partners + + with Session(engine) as session: + entity = None + if args.entity_id: + entity = session.get(Entity, args.entity_id) + elif args.name: + entity = session.exec(select(Entity).where(Entity.name == args.name)).first() + if entity is None: + print(f"Error: no fund found for '{args.name or args.entity_id}'. " + f"Check the exact name with list-funds.", file=sys.stderr) + sys.exit(1) + res = reset_entity_partners(entity.id, session) + session.commit() + print(f"Cleared partners from {entity.name}: removed {res['statements']} capital " + f"statement(s) and {res['access_grants']} access grant(s). " + f"Investor accounts were kept. Re-import the correct roster to repopulate.") + + def list_funds(args: argparse.Namespace) -> None: """Print every entity's id and name (so the exact name is known for reset-holdings).""" run_migrations() @@ -268,6 +327,11 @@ def main() -> None: sub.add_parser("list-users", help="List all user accounts") + sub.add_parser( + "enable-investor-logins", + help="Set every no-login investor account to the default password and enable sign-in", + ) + sub.add_parser("show-admin-password", help="Show the initial admin password from first boot") delete = sub.add_parser("delete-user", help="Delete a user (not the Service Admin)") @@ -284,6 +348,10 @@ def main() -> None: reset.add_argument("--name", required=False, default=None, help="Exact fund name") reset.add_argument("--entity-id", type=int, required=False, default=None) + rparts = sub.add_parser("reset-partners", help="Remove all partners (capital accounts + access) from a fund") + rparts.add_argument("--name", required=False, default=None, help="Exact fund name") + rparts.add_argument("--entity-id", type=int, required=False, default=None) + args = parser.parse_args() if args.command == "create-user": create_user(args) @@ -291,6 +359,8 @@ def main() -> None: reset_password(args) elif args.command == "list-users": list_users(args) + elif args.command == "enable-investor-logins": + enable_investor_logins(args) elif args.command == "show-admin-password": show_admin_password(args) elif args.command == "delete-user": @@ -301,6 +371,8 @@ def main() -> None: list_funds(args) elif args.command == "reset-holdings": reset_holdings(args) + elif args.command == "reset-partners": + reset_partners(args) else: parser.print_help() diff --git a/backend/ten31portal/config.py b/backend/ten31portal/config.py index 9643d4e..837edcb 100644 --- a/backend/ten31portal/config.py +++ b/backend/ten31portal/config.py @@ -11,6 +11,11 @@ DOCS_DIR: str = os.getenv("TEN31_DOCS_DIR", "/data/ten31portal/documents") # also bounds spreadsheet imports (which must be read fully into memory to parse). MAX_UPLOAD_SIZE: int = int(os.getenv("TEN31_MAX_UPLOAD_SIZE", str(50 * 1024 * 1024))) +# Investor accounts created by the eNAV import (and existing no-login accounts converted via +# the enable-investor-logins CLI/action) start with this password so the admin can hand out +# credentials easily; each investor changes it via the portal's own Change password. +DEFAULT_INVESTOR_PASSWORD: str = os.getenv("TEN31_DEFAULT_INVESTOR_PASSWORD", "Ten31Portal") + # Where start.sh records the randomly-generated initial admin password on first boot, so the # operator can retrieve it once (via the "Show Initial Admin Password" service action) and then # change it. Lives next to the DB on the 0600 data volume; removed once the password is reset. diff --git a/backend/ten31portal/models.py b/backend/ten31portal/models.py index 7c57bbe..de014d0 100644 --- a/backend/ten31portal/models.py +++ b/backend/ten31portal/models.py @@ -39,6 +39,7 @@ class EntityType(str, enum.Enum): spv = "spv" gp = "gp" mgmt_co = "mgmt_co" + carry = "carry" class EntityStatus(str, enum.Enum): @@ -75,6 +76,9 @@ class User(SQLModel, table=True): primary_account_id: int | None = Field(default=None, foreign_key="users.id", index=True) # Fund-administrator investor ID (from the eNAV ALLOC SI tab) for idempotent re-import. external_investor_id: str | None = Field(default=None, sa_column=Column(String, nullable=True)) + # When this investor last loaded their documents list — docs newer than this get a "New" + # badge in the portal. Null until their first visit (nothing badged for brand-new logins). + docs_seen_at: datetime | None = Field(default=None) created_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/backend/ten31portal/routers/auth_router.py b/backend/ten31portal/routers/auth_router.py index 084b9dd..2854de1 100644 --- a/backend/ten31portal/routers/auth_router.py +++ b/backend/ten31portal/routers/auth_router.py @@ -87,8 +87,8 @@ def change_password( """Let the signed-in user set their own password (after confirming the current one).""" if not verify_password(body.current_password, user.password_hash): raise HTTPException(status_code=400, detail="Current password is incorrect.") - if len(body.new_password) < 4: - raise HTTPException(status_code=400, detail="New password must be at least 4 characters.") + if len(body.new_password) < 8: + raise HTTPException(status_code=400, detail="New password must be at least 8 characters.") user.password_hash = hash_password(body.new_password) session.add(user) session.commit() diff --git a/backend/ten31portal/routers/capital_import_router.py b/backend/ten31portal/routers/capital_import_router.py index 5bb37fd..1b5f6ea 100644 --- a/backend/ten31portal/routers/capital_import_router.py +++ b/backend/ten31portal/routers/capital_import_router.py @@ -6,20 +6,19 @@ Fallback: a subaccounts sheet (investor names across columns, per-vehicle value Encrypted workbooks are decrypted with the open password. Nothing is written on preview. Commit matches existing members (by fund-admin investor ID, else name), creates new ones -(without a login unless a password is given), grants entity access, and loads each member's -capital-account statement (commitment, contributions, distributions, current value). +(on the shared default password unless one is given), grants entity access, and loads each +member's capital-account statement (commitment, contributions, distributions, current value). """ import io import re -import secrets from datetime import date, datetime import openpyxl from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile from sqlmodel import Session, select -from ten31portal import storage +from ten31portal import config, storage from ten31portal.audit import record_audit from ten31portal.auth import hash_password, require_internal_admin from ten31portal.database import get_session @@ -38,6 +37,39 @@ MAX_ROWS = 400 MAX_COLS = 90 +def reset_entity_partners(entity_id: int, session: Session) -> dict[str, int]: + """Remove every partner from one fund: delete its capital-account statements and the + investors' access grants to it. The investor *accounts* are kept — they usually also + belong to other funds — only their membership of THIS entity is cleared. Use to undo a + wrong members/ALLOC-SI import (e.g. Fund II's roster loaded into Fund III). Holdings/NAV + are untouched (see reset_entity_holdings for those). Caller commits. + """ + statements = 0 + for s in session.exec( + select(CapitalAccountStatement).where( + CapitalAccountStatement.entity_id == entity_id + ) + ).all(): + session.delete(s) + statements += 1 + + # Only drop investor memberships; a fund_administrator's access is not a "partner". + investor_ids = { + u.id for u in session.exec( + select(User).where(User.role == UserRole.investor) + ).all() + } + access = 0 + for a in session.exec( + select(EntityAccess).where(EntityAccess.entity_id == entity_id) + ).all(): + if a.user_id in investor_ids: + session.delete(a) + access += 1 + + return {"statements": statements, "access_grants": access} + + def _slug_username(name: str) -> str: base = re.sub(r"[^a-z0-9]+", "", name.lower()) return base or "investor" @@ -282,14 +314,16 @@ def commit_import( raise HTTPException(status_code=409, detail=f"Username '{inv.username}' already taken.") if inv.email and session.exec(select(User).where(User.email == inv.email)).first(): raise HTTPException(status_code=409, detail=f"Email '{inv.email}' already in use.") - pw = inv.password or secrets.token_urlsafe(32) + # New members start on the shared default password (login enabled) so the admin + # can send credentials right away; each investor rotates it in the portal. + pw = inv.password or config.DEFAULT_INVESTOR_PASSWORD user = User( name=inv.name, username=inv.username, email=inv.email or None, password_hash=hash_password(pw), role=UserRole.investor, - login_enabled=bool(inv.password), + login_enabled=True, external_investor_id=inv.external_id, ) session.add(user) diff --git a/backend/ten31portal/routers/document_router.py b/backend/ten31portal/routers/document_router.py index 13881eb..9241556 100644 --- a/backend/ten31portal/routers/document_router.py +++ b/backend/ten31portal/routers/document_router.py @@ -1,5 +1,7 @@ """Document upload, listing, download, and deletion with per-account access control.""" +from datetime import datetime, timedelta + from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile from fastapi.responses import FileResponse from sqlmodel import Session, select @@ -58,7 +60,25 @@ def list_documents( query = query.where(Document.investor_user_id == investor_user_id) rows = session.exec(query.order_by(Document.created_at.desc())).all() # type: ignore[union-attr] visible = [d for d in rows if _can_view(user, d, session)] - return [DocumentResponse.model_validate(d, from_attributes=True) for d in visible] + + # Badge docs that arrived since the investor's previous visit. Badges show on the first + # page-load after new documents arrive; the watermark advances at most once per 30 minutes + # so rapid refetches don't rewrite the row. First ever visit (no watermark) badges nothing — + # everything would be "new". + seen_before = user.docs_seen_at if user.role == UserRole.investor else None + if user.role == UserRole.investor: + now = datetime.utcnow() + if user.docs_seen_at is None or (now - user.docs_seen_at) > timedelta(minutes=30): + user.docs_seen_at = now + session.add(user) + session.commit() + + return [ + DocumentResponse.model_validate(d, from_attributes=True).model_copy( + update={"is_new": seen_before is not None and d.created_at > seen_before} + ) + for d in visible + ] @router.post("", status_code=201) diff --git a/backend/ten31portal/routers/entity_router.py b/backend/ten31portal/routers/entity_router.py index b7976af..d632dea 100644 --- a/backend/ten31portal/routers/entity_router.py +++ b/backend/ten31portal/routers/entity_router.py @@ -148,6 +148,26 @@ def list_partners( return result +@router.delete("/{entity_id}/partners") +def clear_partners( + entity_id: int, + user: User = Depends(require_writer), + session: Session = Depends(get_session), +) -> dict[str, int]: + """Remove all partners from this fund — deletes its capital-account statements and the + investors' access grants, but keeps the investor accounts (they belong to other funds). + For undoing a wrong members import. Holdings/NAV are not affected.""" + # Local import avoids a module-load cycle (capital_import_router imports import_router). + from ten31portal.routers.capital_import_router import reset_entity_partners + + if session.get(Entity, entity_id) is None: + raise HTTPException(status_code=404, detail="Entity not found") + res = reset_entity_partners(entity_id, session) + record_audit(session, user.id, "clear_partners", "entity", entity_id, res) + session.commit() + return res + + @router.get("") def list_entities( user: User = Depends(get_current_user), diff --git a/backend/ten31portal/schemas.py b/backend/ten31portal/schemas.py index 86f5195..9bb79eb 100644 --- a/backend/ten31portal/schemas.py +++ b/backend/ten31portal/schemas.py @@ -212,6 +212,8 @@ class DocumentResponse(BaseModel): size_bytes: int uploaded_by: int | None created_at: datetime + # True for an investor when the doc arrived since their previous portal visit. + is_new: bool = False # --- Capital account --- @@ -310,7 +312,7 @@ class ImportCommitInvestor(BaseModel): name: str | None = None # for action=create username: str | None = None # for action=create email: str | None = None - password: str | None = None # for action=create; omit to create without a login + password: str | None = None # for action=create; omit for the shared default password external_id: str | None = None # fund-admin INVESTOR ID, stored for re-import matching diff --git a/backend/tests/test_clear_partners.py b/backend/tests/test_clear_partners.py new file mode 100644 index 0000000..5e7205b --- /dev/null +++ b/backend/tests/test_clear_partners.py @@ -0,0 +1,60 @@ +"""Clearing a fund's partners removes only that fund's statements + access grants, +never the investor accounts (which may belong to other funds).""" + +from datetime import date + +from ten31portal.models import ( + CapitalAccountStatement, Entity, EntityAccess, EntityType, User, UserRole, +) + + +def _make_fund(session, name): + e = Entity(name=name, type=EntityType.fund) + session.add(e) + session.commit() + session.refresh(e) + return e + + +def _add_partner(session, entity_id, user_id, ending): + session.add(EntityAccess(user_id=user_id, entity_id=entity_id)) + session.add(CapitalAccountStatement( + entity_id=entity_id, investor_user_id=user_id, as_of_date=date(2025, 12, 31), + commitment_cents=0, beginning_balance_cents=0, contributions_cents=0, + distributions_cents=0, ending_balance_cents=ending, + )) + session.commit() + + +def test_clear_partners_scoped_to_one_fund(auth_client, session): + from tests.conftest import make_user + + fund2 = _make_fund(session, "Low Time Preference Fund II, LLC") + fund3 = _make_fund(session, "Low Time Preference Fund III, LP") + lp = make_user(session, username="lp1", role=UserRole.investor, name="LP One") + # Same investor is a partner in BOTH funds (the real-world case that caused the mixup). + _add_partner(session, fund2.id, lp.id, 1_000_00) + _add_partner(session, fund3.id, lp.id, 2_000_00) + + resp = auth_client.delete(f"/api/entities/{fund3.id}/partners") + assert resp.status_code == 200, resp.text + assert resp.json() == {"statements": 1, "access_grants": 1} + + # Fund III is wiped of partners... + assert session.exec( + CapitalAccountStatement.__table__.select().where( + CapitalAccountStatement.entity_id == fund3.id + ) + ).first() is None + assert session.exec( + EntityAccess.__table__.select().where(EntityAccess.entity_id == fund3.id) + ).first() is None + assert auth_client.get(f"/api/entities/{fund3.id}/partners").json() == [] + + # ...but Fund II keeps its partner, and the investor account still exists. + assert len(auth_client.get(f"/api/entities/{fund2.id}/partners").json()) == 1 + assert session.get(User, lp.id) is not None + + +def test_clear_partners_missing_entity_404(auth_client): + assert auth_client.delete("/api/entities/99999/partners").status_code == 404 diff --git a/backend/tests/test_lp_polish.py b/backend/tests/test_lp_polish.py new file mode 100644 index 0000000..b9d3c51 --- /dev/null +++ b/backend/tests/test_lp_polish.py @@ -0,0 +1,134 @@ +"""0.2.32 LP-facing behavior: 8-char password floor, default investor password, +enable-investor-logins conversion, and the documents "New" badge watermark.""" + +from datetime import datetime, timedelta + +from sqlmodel import select + +from ten31portal import config +from ten31portal.auth import hash_password, verify_password +from ten31portal.models import ( + Document, Entity, EntityAccess, EntityType, User, UserRole, +) +from tests.conftest import make_user + + +def _login(client, username, password): + return client.post("/api/auth/login", json={"login": username, "password": password}) + + +def test_change_password_requires_eight_chars(auth_client): + resp = auth_client.post( + "/api/auth/change-password", + json={"current_password": "password123", "new_password": "short7c"}, + ) + assert resp.status_code == 400 + assert "8 characters" in resp.json()["detail"] + + resp = auth_client.post( + "/api/auth/change-password", + json={"current_password": "password123", "new_password": "longenough8"}, + ) + assert resp.status_code == 200 + + +def test_import_created_member_gets_default_password(auth_client, session): + entity = Entity(name="Test Fund", type=EntityType.fund) + session.add(entity) + session.commit() + + resp = auth_client.post( + "/api/import/capital-accounts/commit", + json={ + "entity_id": entity.id, + "as_of_date": "2026-03-31", + "investors": [{ + "action": "create", + "name": "New LP", + "username": "newlp", + "value_dollars": 100_000, + }], + }, + ) + assert resp.status_code == 200, resp.text + + user = session.exec(select(User).where(User.username == "newlp")).one() + assert user.login_enabled is True + assert verify_password(config.DEFAULT_INVESTOR_PASSWORD, user.password_hash) + + # And the account can actually sign in with it. + resp = _login(auth_client, "newlp", config.DEFAULT_INVESTOR_PASSWORD) + assert resp.status_code == 200, resp.text + + +def test_enable_investor_logins_converts_only_no_login_accounts(session): + from ten31portal.cli import enable_investor_logins # imported here: cli pulls in argparse setup + + no_login = make_user(session, username="dormant", role=UserRole.investor, + login_enabled=False, name="Dormant LP") + has_login = make_user(session, username="active-lp", role=UserRole.investor, + password="theirownpw", name="Active LP") + linked = make_user(session, username="linked", role=UserRole.investor, + login_enabled=False, primary_account_id=has_login.id, name="Linked Name") + old_active_hash = has_login.password_hash + + # Run the conversion against the test engine (CLI normally uses the real one). + import ten31portal.cli as cli_mod + orig_engine, orig_migrate = cli_mod.engine, cli_mod.run_migrations + cli_mod.engine = session.get_bind() + cli_mod.run_migrations = lambda: None + try: + enable_investor_logins(None) + finally: + cli_mod.engine, cli_mod.run_migrations = orig_engine, orig_migrate + + session.refresh(no_login) + session.refresh(has_login) + session.refresh(linked) + assert no_login.login_enabled is True + assert verify_password(config.DEFAULT_INVESTOR_PASSWORD, no_login.password_hash) + # Working logins and linked secondary names are untouched. + assert has_login.password_hash == old_active_hash + assert linked.login_enabled is False + + +def test_documents_new_badge(client, session): + entity = Entity(name="Badge Fund", type=EntityType.fund) + session.add(entity) + session.commit() + lp = make_user(session, username="lp", role=UserRole.investor, password="password123") + session.add(EntityAccess(user_id=lp.id, entity_id=entity.id)) + + old_doc = Document( + entity_id=entity.id, category="statement", title="Old statement", + original_filename="old.pdf", content_type="application/pdf", size_bytes=1, + storage_path="x-old", created_at=datetime.utcnow() - timedelta(days=30), + ) + session.add(old_doc) + session.commit() + + assert _login(client, "lp", "password123").status_code == 200 + + # First ever visit: nothing badged (no watermark yet), watermark gets set. + docs = client.get("/api/documents").json() + assert [d["is_new"] for d in docs] == [False] + + # A doc uploaded after that visit is badged next time; pretend the visit was yesterday. + session.refresh(lp) + lp.docs_seen_at = datetime.utcnow() - timedelta(days=1) + session.add(lp) + new_doc = Document( + entity_id=entity.id, category="k1", title="Fresh K-1", + original_filename="k1.pdf", content_type="application/pdf", size_bytes=1, + storage_path="x-new", + ) + session.add(new_doc) + session.commit() + + docs = client.get("/api/documents").json() + flags = {d["title"]: d["is_new"] for d in docs} + assert flags == {"Fresh K-1": True, "Old statement": False} + + # Within the same visit (watermark just advanced) the badge computation stays stable. + docs = client.get("/api/documents").json() + assert all(d["is_new"] is False for d in docs) # watermark now newer than both docs diff --git a/deploy/package.json b/deploy/package.json index e30174e..e295c52 100644 --- a/deploy/package.json +++ b/deploy/package.json @@ -1,6 +1,6 @@ { "name": "ten31portal-startos", - "version": "0.2.26", + "version": "0.2.32", "private": true, "scripts": { "build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript", diff --git a/deploy/startos/actions/index.ts b/deploy/startos/actions/index.ts index b2cbb58..bedc525 100644 --- a/deploy/startos/actions/index.ts +++ b/deploy/startos/actions/index.ts @@ -414,12 +414,97 @@ const resetHoldingsAction = Action.withInput( }, ) +// ============================================ +// Action: Reset Fund Partners +// ============================================ +const resetPartnersInputSpec = InputSpec.of({ + name: Value.text({ + name: 'Fund Name', + description: 'Exact name of the fund whose partners to clear (see List Funds)', + default: '', + required: true, + placeholder: 'Low Time Preference Fund III, LP', + }), +}) + +const resetPartnersAction = Action.withInput( + 'reset-partners', + { + name: 'Reset Fund Partners', + description: + "Remove every partner from a fund — deletes its investor capital-account statements and their access grants to it. Use to undo a wrong members import (e.g. another fund's roster loaded into this one). Investor accounts themselves are kept, and holdings/NAV are not affected (use Reset Fund Holdings for those).", + warning: + "This permanently deletes this fund's capital-account statements and removes investors' access to it. Investor accounts are kept. Re-import the correct roster afterward to repopulate.", + allowedStatuses: 'only-running', + group: null, + visibility: 'enabled', + }, + resetPartnersInputSpec, + async () => ({ name: '' }), + async ({ input, effects }) => { + try { + const result = await runCli(effects, ['reset-partners', '--name', input.name], 'reset-partners-task') + if (result.exitCode !== 0) { + return errorResult(result.stderr?.toString() || 'Failed to clear partners') + } + return { + version: '1' as const, + title: 'Partners Cleared', + message: result.stdout?.toString() || `Cleared partners from ${input.name}.`, + result: null, + } + } catch (e: any) { + return errorResult(`Failed to clear partners: ${e.message || e}`) + } + }, +) + +// ============================================ +// Action: Enable Investor Logins +// ============================================ +const enableInvestorLoginsAction = Action.withoutInput( + 'enable-investor-logins', + { + name: 'Enable Investor Logins', + description: + 'Give every investor account that has no login yet the default password (Ten31Portal) and enable sign-in. Accounts that can already sign in are not touched; investors change their own password in the portal.', + warning: 'Every converted account gets the same well-known default password until the investor changes it.', + allowedStatuses: 'only-running', + group: null, + visibility: 'enabled', + }, + async ({ effects }) => { + try { + const result = await runCli(effects, ['enable-investor-logins'], 'enable-investor-logins-task') + if (result.exitCode !== 0) { + return errorResult(result.stderr?.toString() || 'Failed to enable investor logins') + } + return { + version: '1' as const, + title: 'Investor Logins Enabled', + message: 'Send each investor their username; they sign in with the default password and change it.', + result: { + type: 'single' as const, + value: result.stdout?.toString() || 'Nothing to do.', + copyable: true, + qr: false, + masked: false, + }, + } + } catch (e: any) { + return errorResult(`Failed to enable investor logins: ${e.message || e}`) + } + }, +) + export const actions = sdk.Actions.of() .addAction(createUserAction) .addAction(resetPasswordAction) .addAction(showAdminPasswordAction) .addAction(listUsersAction) + .addAction(enableInvestorLoginsAction) .addAction(deleteUserAction) .addAction(dedupeAction) .addAction(listFundsAction) .addAction(resetHoldingsAction) + .addAction(resetPartnersAction) diff --git a/deploy/startos/install/versions/index.ts b/deploy/startos/install/versions/index.ts index ea7f38f..0ad6a8b 100644 --- a/deploy/startos/install/versions/index.ts +++ b/deploy/startos/install/versions/index.ts @@ -1,4 +1,4 @@ -export { v_0_2_26 as current } from './v_0_2_26' +export { v_0_2_32 as current } from './v_0_2_32' import { v_0_1_0 } from './v_0_1_0' import { v_0_2_0 } from './v_0_2_0' import { v_0_2_1 } from './v_0_2_1' @@ -25,4 +25,10 @@ import { v_0_2_22 } from './v_0_2_22' import { v_0_2_23 } from './v_0_2_23' import { v_0_2_24 } from './v_0_2_24' import { v_0_2_25 } from './v_0_2_25' -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] +import { v_0_2_26 } from './v_0_2_26' +import { v_0_2_27 } from './v_0_2_27' +import { v_0_2_28 } from './v_0_2_28' +import { v_0_2_29 } from './v_0_2_29' +import { v_0_2_30 } from './v_0_2_30' +import { v_0_2_31 } from './v_0_2_31' +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] diff --git a/deploy/startos/install/versions/v_0_2_27.ts b/deploy/startos/install/versions/v_0_2_27.ts new file mode 100644 index 0000000..a36b44c --- /dev/null +++ b/deploy/startos/install/versions/v_0_2_27.ts @@ -0,0 +1,13 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +export const v_0_2_27 = VersionInfo.of({ + version: '0.2.27:0', + releaseNotes: { + en_US: + 'Entity overview now shows a full Valuation history — every quarter on record with its NAV, status, and signed date — instead of just the latest quarter. Investor portal now shows, per fund/SPV, gain/loss (amount and % vs paid-in) on the capital account and the % of commitment distributed.', + }, + migrations: { + up: async ({ effects }) => {}, + down: async ({ effects }) => {}, + }, +}) diff --git a/deploy/startos/install/versions/v_0_2_28.ts b/deploy/startos/install/versions/v_0_2_28.ts new file mode 100644 index 0000000..b370450 --- /dev/null +++ b/deploy/startos/install/versions/v_0_2_28.ts @@ -0,0 +1,13 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +export const v_0_2_28 = VersionInfo.of({ + version: '0.2.28:0', + releaseNotes: { + en_US: + "Investor gain/loss now measures total value (current NAV + distributions received) against paid-in capital, so an LP who has taken distributions no longer shows a false loss.", + }, + migrations: { + up: async ({ effects }) => {}, + down: async ({ effects }) => {}, + }, +}) diff --git a/deploy/startos/install/versions/v_0_2_29.ts b/deploy/startos/install/versions/v_0_2_29.ts new file mode 100644 index 0000000..be93273 --- /dev/null +++ b/deploy/startos/install/versions/v_0_2_29.ts @@ -0,0 +1,13 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +export const v_0_2_29 = VersionInfo.of({ + version: '0.2.29:0', + releaseNotes: { + en_US: + "New 'Reset Fund Partners' service action (and a 'Clear all partners' button on a fund's Partners tab) removes all of a fund's capital-account statements and investor access grants — for undoing a wrong members import, e.g. one fund's roster loaded into another. Investor accounts and holdings/NAV are left intact. Reset Fund Holdings still only clears holdings; this covers the partner side.", + }, + migrations: { + up: async ({ effects }) => {}, + down: async ({ effects }) => {}, + }, +}) diff --git a/deploy/startos/install/versions/v_0_2_30.ts b/deploy/startos/install/versions/v_0_2_30.ts new file mode 100644 index 0000000..1b4aceb --- /dev/null +++ b/deploy/startos/install/versions/v_0_2_30.ts @@ -0,0 +1,13 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +export const v_0_2_30 = VersionInfo.of({ + version: '0.2.30:0', + releaseNotes: { + en_US: + "Investor portal: the capital block now reads 'Current Capital Balance' and shows only the percentage gain (green) or loss (red) beneath it — the redundant dollar 'gain/loss vs paid-in' line was removed.", + }, + migrations: { + up: async ({ effects }) => {}, + down: async ({ effects }) => {}, + }, +}) diff --git a/deploy/startos/install/versions/v_0_2_31.ts b/deploy/startos/install/versions/v_0_2_31.ts new file mode 100644 index 0000000..31b4886 --- /dev/null +++ b/deploy/startos/install/versions/v_0_2_31.ts @@ -0,0 +1,13 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +export const v_0_2_31 = VersionInfo.of({ + version: '0.2.31:0', + releaseNotes: { + en_US: + "Entities view: the 'GP Entities and Management Companies' section is now 'Management Entities' and gains a new 'Carry Vehicle' entity type (with both Partners and Assets tabs) for carry vehicles like Ten31 EP LLC. Investor capital chart: the Distributions line only appears once distributions have actually been made.", + }, + migrations: { + up: async ({ effects }) => {}, + down: async ({ effects }) => {}, + }, +}) diff --git a/deploy/startos/install/versions/v_0_2_32.ts b/deploy/startos/install/versions/v_0_2_32.ts new file mode 100644 index 0000000..dca3467 --- /dev/null +++ b/deploy/startos/install/versions/v_0_2_32.ts @@ -0,0 +1,13 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +export const v_0_2_32 = VersionInfo.of({ + version: '0.2.32:0', + releaseNotes: { + en_US: + "Investor portal polish: a portfolio summary card totals commitment, paid-in, distributions and current balance across funds; gain/loss is labeled 'net of paid-in'; headline figures show whole dollars; documents group by year with a 'New' badge since the investor's last visit. Ten31 brand palette (navy/mint from the logo) replaces the orange accents. New members from the eNAV import now start with the default password 'Ten31Portal' (login enabled), and a new 'Enable Investor Logins' action converts existing no-login accounts. Password changes now require at least 8 characters. Login page shows a Portal@ten31.xyz contact line.", + }, + migrations: { + up: async ({ effects }) => {}, + down: async ({ effects }) => {}, + }, +}) diff --git a/frontend/index.html b/frontend/index.html index afaf458..f4a4404 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -6,7 +6,7 @@ - + diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest index c489c3d..ce5b12c 100644 --- a/frontend/public/manifest.webmanifest +++ b/frontend/public/manifest.webmanifest @@ -6,8 +6,8 @@ "scope": "/", "display": "standalone", "orientation": "portrait-primary", - "background_color": "#16243A", - "theme_color": "#16243A", + "background_color": "#0C1F33", + "theme_color": "#0C1F33", "icons": [ { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 81e9bc0..9310366 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -3,7 +3,7 @@ // - content-hashed /assets/* are cache-first (immutable, safe forever) // - /api/* is never cached // Bump CACHE on each release so old entries are purged. -const CACHE = 'ten31-portal-0.2.26' +const CACHE = 'ten31-portal-0.2.32' self.addEventListener('install', () => self.skipWaiting()) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 39a8034..f9bb810 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -62,7 +62,7 @@ function OfflineNotice({ onRetry }: { onRetry: () => void }) {
diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 5d2913e..6c48849 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -12,7 +12,7 @@ export type UserRole = | "viewer" | "investor" | "fund_administrator"; -export type EntityType = "fund" | "spv" | "gp" | "mgmt_co"; +export type EntityType = "fund" | "spv" | "gp" | "mgmt_co" | "carry"; export type EntityStatus = "active" | "closed"; export type RoundStatus = "draft" | "submitted" | "approved" | "returned"; export type DocumentCategory = @@ -70,6 +70,7 @@ export interface PortalDocument { size_bytes: number; uploaded_by: number | null; created_at: string; + is_new?: boolean; } export interface Partner { @@ -307,6 +308,11 @@ export const api = { }[]>("/api/entities/rollup"), getEntity: (id: number) => requestYour password has been updated.
Uploading to {entityName} ·{" "} - + {targetLabel}
@@ -252,7 +252,7 @@ function UploadForm({This entity isn't linked to an investor account yet, so there are no balances to show.
Link it on the{" "} - + Overview tab → Edit entity {" "} (choose its investor account). Its capital-account balance in each fund will then @@ -110,7 +110,7 @@ export default function EntityAssets() { {rows.map((r) => (
+ {rounds.length} quarter{rounds.length === 1 ? "" : "s"} on record +
+| Quarter | +NAV | +Status | +Signed | +
|---|---|---|---|
| {formatQuarter(r.quarter_end)} | +{formatMoney(nav)} | ++ + {r.status} + + | ++ {r.status === "approved" && r.approved_at ? formatDate(r.approved_at) : "—"} + | +
{error}
}{error}
} + {notice &&{notice}
}{partners.length} member{partners.length === 1 ? "" : "s"} with access to this fund.
-- Total committed: {formatMoneyExact(totalCommitted)} - · - Total capital: {formatMoneyExact(totalCapital)} -
++ Total committed: {formatMoneyExact(totalCommitted)} + · + Total capital: {formatMoneyExact(totalCapital)} +
+ {isWriter && partners.length > 0 && ( ++ Trouble signing in?{" "} + + Contact Portal@ten31.xyz + +
); diff --git a/frontend/src/pages/Users.tsx b/frontend/src/pages/Users.tsx index 13a4309..7e471d3 100644 --- a/frontend/src/pages/Users.tsx +++ b/frontend/src/pages/Users.tsx @@ -40,7 +40,7 @@ export default function Users() {Commitment
+{formatMoneyWhole(totals.commitment)}
+Paid-in
+{formatMoneyWhole(totals.paidIn)}
+Distributions
+{formatMoneyWhole(totals.distributions)}
+Current Capital Balance
+{formatMoneyWhole(totals.balance)}
+ {gainLossPct != null && ( += 0 ? "text-accent-300" : "text-red-300"}`}> + {pct(gainLossPct)} net of paid-in +
+ )} +No documents available.
- ) : ( -No documents available.
+ ) : ( +{g.year}
+ )} +{label}
}As of {formatDate(latest.as_of_date)}
Current capital value
+Current Capital Balance
- {formatMoneyExact(latest.ending_balance_cents)} + {formatMoneyWhole(latest.ending_balance_cents)}
+ {gainLossPct != null && ( += 0 ? "text-accent-600" : "text-red-600"}`}> + {pct(gainLossPct)} net of paid-in +
+ )} {history.length > 1 && ({label}
-{value}
+{value}
+ {sub &&{sub}
}