diff --git a/deploy/startos/actions/index.ts b/deploy/startos/actions/index.ts index f06c5d9..9057760 100644 --- a/deploy/startos/actions/index.ts +++ b/deploy/startos/actions/index.ts @@ -1,4 +1,129 @@ import { sdk } from '../sdk' -// No custom actions for v1. Config is handled at install time. -export const actions = sdk.Actions.of() +const { InputSpec, Value, Action } = sdk + +// ============================================ +// Action: Create User +// ============================================ +const createUserInputSpec = InputSpec.of({ + name: Value.text({ + name: 'Full Name', + description: 'Display name for the user', + default: '', + required: true, + placeholder: 'Jane Smith', + }), + email: Value.text({ + name: 'Email', + description: 'Login email address', + default: '', + required: true, + placeholder: 'jane@ten31.com', + patterns: [ + { + regex: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$', + description: 'Must be a valid email address', + }, + ], + }), + password: Value.text({ + name: 'Password', + description: 'Initial password (user should change after first login)', + default: '', + required: true, + placeholder: 'minimum 4 characters', + }), + role: Value.select({ + name: 'Role', + description: + 'approver: full access including sign-off. cfo: read + edit + submit. fund_admin: read + edit + submit. viewer: read only.', + default: 'viewer', + values: { + approver: 'Approver', + cfo: 'CFO', + fund_admin: 'Fund Admin', + viewer: 'Viewer', + }, + }), +}) + +const createUserAction = Action.withInput( + 'create-user', + { + name: 'Create User', + description: 'Add a new user account with a role', + warning: null, + allowedStatuses: 'only-running', + group: null, + visibility: 'enabled', + }, + createUserInputSpec, + async () => ({ + name: '', + email: '', + password: '', + role: 'viewer' as const, + }), + async ({ input, effects }) => { + try { + const sub = await sdk.SubContainer.of( + effects, + { imageId: 'main' }, + sdk.Mounts.of().mountVolume({ + volumeId: 'main', + subpath: null, + mountpoint: '/data', + readonly: false, + }), + 'create-user-task', + ) + + const result = await sub.exec( + [ + 'python3', + '-m', + 'ten31portal.cli', + 'create-user', + '--name', + input.name, + '--email', + input.email, + '--role', + input.role, + '--password', + input.password, + ], + { + env: { TEN31_DB_PATH: '/data/portal.db' }, + }, + 30000, + ) + + if (result.exitCode !== 0) { + const stderr = result.stderr?.toString() || 'Unknown error' + return { + version: '1' as const, + title: 'Error', + message: `Failed to create user: ${stderr}`, + result: null, + } + } + + return { + version: '1' as const, + title: 'User Created', + message: `Created user ${input.name} (${input.email}) with role: ${input.role}`, + result: null, + } + } catch (e: any) { + return { + version: '1' as const, + title: 'Error', + message: `Failed to create user: ${e.message || e}`, + result: null, + } + } + }, +) + +export const actions = sdk.Actions.of().addAction(createUserAction) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bce6884..76c660b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,8 @@ import EntitiesList from "./pages/EntitiesList"; import EntityOverview from "./pages/EntityOverview"; import Investments from "./pages/Investments"; import ValuationWorkflow from "./pages/ValuationWorkflow"; +import Import from "./pages/Import"; +import AuditLog from "./pages/AuditLog"; function ProtectedRoutes() { const { user, loading } = useAuth(); @@ -29,6 +31,8 @@ function ProtectedRoutes() { } /> } /> } /> + } /> + } /> } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index ab1e396..cf1861f 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -3,8 +3,8 @@ import { useAuth } from "../context/AuthContext"; const NAV_ITEMS = [ { label: "Entities", path: "/" }, - { label: "Import", path: "/import", stub: true }, - { label: "Audit Log", path: "/audit", stub: true }, + { label: "Import", path: "/import" }, + { label: "Audit Log", path: "/audit" }, ]; export default function Layout({ children }: { children: React.ReactNode }) { @@ -25,22 +25,16 @@ export default function Layout({ children }: { children: React.ReactNode }) { : location.pathname.startsWith(item.path); return (
  • - {item.stub ? ( - - {item.label} - - ) : ( - - {item.label} - - )} + + {item.label} +
  • ); })} diff --git a/frontend/src/pages/AuditLog.tsx b/frontend/src/pages/AuditLog.tsx new file mode 100644 index 0000000..86333b8 --- /dev/null +++ b/frontend/src/pages/AuditLog.tsx @@ -0,0 +1,140 @@ +import { useEffect, useState } from "react"; +import { api, type AuditEntry } from "../api"; +import { useAuth } from "../context/AuthContext"; +import { formatDate } from "../format"; + +export default function AuditLog() { + const { user } = useAuth(); + const [entries, setEntries] = useState([]); + const [page, setPage] = useState(1); + const [filterType, setFilterType] = useState(""); + const [loading, setLoading] = useState(true); + + const canView = user && ["approver", "cfo"].includes(user.role); + + useEffect(() => { + if (!canView) return; + loadData(); + }, [page, filterType]); + + async function loadData() { + setLoading(true); + try { + const params: any = { page }; + if (filterType) params.object_type = filterType; + const data = await api.listAudit(params); + setEntries(data); + } finally { + setLoading(false); + } + } + + if (!canView) { + return ( +
    + Audit log requires approver or CFO role. +
    + ); + } + + const ACTION_COLORS: Record = { + create: "bg-green-100 text-green-700", + update: "bg-blue-100 text-blue-700", + delete: "bg-red-100 text-red-600", + submit: "bg-yellow-100 text-yellow-700", + approve: "bg-green-100 text-green-700", + return: "bg-red-100 text-red-600", + update_valuations: "bg-blue-100 text-blue-700", + import_entities: "bg-purple-100 text-purple-700", + import_schedule: "bg-purple-100 text-purple-700", + }; + + return ( +
    +

    Audit Log

    + + {/* Filter */} +
    + + +
    + + {loading ? ( +
    Loading...
    + ) : ( + <> +
    + + + + + + + + + + + + + {entries.map((e) => ( + + + + + + + + + ))} + {entries.length === 0 && ( + + + + )} + +
    TimeActionTypeIDUserDetail
    + {formatDate(e.created_at)} + + + {e.action} + + {e.object_type}{e.object_id ?? "—"}#{e.actor_user_id ?? "system"} + {e.detail ? JSON.stringify(e.detail) : "—"} +
    + No audit entries. +
    +
    + + {/* Pagination */} +
    + + Page {page} + +
    + + )} +
    + ); +} diff --git a/frontend/src/pages/Import.tsx b/frontend/src/pages/Import.tsx new file mode 100644 index 0000000..054d38a --- /dev/null +++ b/frontend/src/pages/Import.tsx @@ -0,0 +1,283 @@ +import { useEffect, useRef, useState } from "react"; +import { api, type Entity } from "../api"; +import { useAuth } from "../context/AuthContext"; + +export default function Import() { + const { user } = useAuth(); + const [entities, setEntities] = useState([]); + const [entityId, setEntityId] = useState(""); + const [asOf, setAsOf] = useState(""); + const [mode, setMode] = useState<"entities" | "schedule">("schedule"); + const [commit, setCommit] = useState(false); + const [file, setFile] = useState(null); + const [result, setResult] = useState(null); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const fileRef = useRef(null); + + const canImport = user && ["approver", "cfo"].includes(user.role); + + useEffect(() => { + api.listEntities().then(setEntities); + }, []); + + async function handleImport() { + if (!file) return; + setError(""); + setResult(null); + setLoading(true); + + try { + const form = new FormData(); + form.append("file", file); + + const params = new URLSearchParams(); + params.set("commit", String(commit)); + + let url: string; + if (mode === "entities") { + url = `/api/import/entities?${params}`; + } else { + if (!entityId || !asOf) { + setError("Select an entity and quarter-end date."); + setLoading(false); + return; + } + params.set("entity_id", entityId); + params.set("as_of", asOf); + url = `/api/import/schedule?${params}`; + } + + const res = await fetch(url, { method: "POST", body: form }); + const data = await res.json(); + if (!res.ok) { + setError(data.detail || "Import failed"); + } else { + setResult(data); + } + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + } + + if (!canImport) { + return ( +
    + Import requires approver or CFO role. +
    + ); + } + + return ( +
    +

    Import

    + + {/* Mode selector */} +
    + + +
    + +
    + {mode === "schedule" && ( + <> +
    + + +
    +
    + + setAsOf(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded text-sm" + /> +
    + + )} + +
    + + setFile(e.target.files?.[0] || null)} + className="w-full text-sm text-gray-600" + /> +
    + +
    + setCommit(e.target.checked)} + className="rounded border-gray-300" + /> + +
    + + {error && ( +
    + {error} +
    + )} + + +
    + + {/* Results */} + {result && ( +
    +

    + {result.committed ? "Import Complete" : "Dry Run Preview"} +

    + + {result.source_entity_name && ( +

    + Source: {result.source_entity_name} +

    + )} + + {result.summary && ( +
    + Created: {result.summary.created} | Updated: {result.summary.updated} | Errors: {result.summary.error_rows} +
    + )} + + {result.holdings_count != null && ( +
    + Holdings: {result.holdings_count} | Positions: {result.positions_count} + {result.seed_round_id && ` | Seed round #${result.seed_round_id}`} +
    + )} + + {result.holdings && result.holdings.length > 0 && ( +
    +

    Holdings

    +
      + {result.holdings.map((h: any, i: number) => ( +
    • {h.company_name}
    • + ))} +
    +
    + )} + + {result.positions && result.positions.length > 0 && ( +
    +

    + Positions ({result.positions.length}) +

    +
    + + + + + + + + + + + + {result.positions.map((p: any, i: number) => ( + + + + + + + + ))} + +
    CompanySecurityDateCostValue
    {p.company_name}{p.security_name}{p.investment_date || "—"} + {p.cost_cents != null ? `$${(p.cost_cents / 100).toLocaleString()}` : "—"} + + {p.value_cents != null ? `$${(p.value_cents / 100).toLocaleString()}` : "—"} +
    +
    +
    + )} + + {result.errors && result.errors.length > 0 && ( +
    +

    + Errors ({result.errors.length}) +

    +
      + {result.errors.map((e: any, i: number) => ( +
    • + Row {e.row}: {e.errors?.join(", ")} +
    • + ))} +
    +
    + )} + + {result.preview && result.preview.length > 0 && ( +
    +

    + Entities ({result.preview.length}) +

    +
      + {result.preview.map((p: any, i: number) => ( +
    • + + {p.action} + {" "} + {p.name} +
    • + ))} +
    +
    + )} +
    + )} +
    + ); +}