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:
+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,9 +27,13 @@ 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]
|
||||
: [...TOP_NAV, ...BOTTOM_NAV];
|
||||
: user && isAdministrator(user.role)
|
||||
? [...TOP_NAV, ...ADMIN_NAV.filter((i) => i.path !== "/investor-view")]
|
||||
: [...TOP_NAV, ...BOTTOM_NAV];
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50">
|
||||
@@ -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,16 +242,18 @@ 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>
|
||||
<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" />
|
||||
Use existing fund
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
|
||||
<input type="radio" checked={entityMode === "from_file"} onChange={() => { setEntityMode("from_file"); reset(); }} className="text-accent-500" />
|
||||
Create from file
|
||||
</label>
|
||||
</div>
|
||||
{!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" />
|
||||
Use existing fund
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
|
||||
<input type="radio" checked={entityMode === "from_file"} onChange={() => { setEntityMode("from_file"); reset(); }} className="text-accent-500" />
|
||||
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,13 +237,19 @@ function CreateUserModal({
|
||||
<PasswordInput value={password} onChange={setPassword} placeholder="minimum 4 characters" />
|
||||
</Field>
|
||||
<Field label="Role">
|
||||
<select className={inputCls} value={role} onChange={(e) => setRole(e.target.value as UserRole)}>
|
||||
{CREATABLE_ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{roleLabel(r)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{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}>
|
||||
{roleLabel(r)}
|
||||
</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,17 +411,28 @@ function EditUserModal({
|
||||
</Field>
|
||||
)}
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button onClick={onClose} className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={busy}
|
||||
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<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>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={busy}
|
||||
className="px-3 py-1.5 bg-brand-900 text-white text-sm rounded hover:bg-brand-800 disabled:opacity-50"
|
||||
>
|
||||
Save
|
||||
</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