feat: per-user module access, classification watchlists, watchlist persistence & move

**Module access control (admin)**
- Added modules column to users table (JSON array of allowed module keys)
- auth.me returns modules; admin.setUserModules/disableUser/enableUser/deleteUser procedures
- UserActions dropdown with Reset Password, Manage Modules, Disable/Enable, Delete
- useFeatureAccess hook + FeatureGate component for page-level gating
- SidebarNav, CommandPalette, MobileTabNav filter by modules

**Classification watchlists (auto-generated sector/thematic/style/region)**
- watchlists schema: added kind, class_key, class_label columns
- materializeClassificationWatchlists(): lazy idempotent materialization of system lists from Yahoo sector data
- 11 GICS sector lists, Semiconductors, Biotech thematic lists, Small Cap style, Intl region
- Materialization triggered on listWatchlists + addSymbol/removeSymbol/add
- Sector thematic filter uses industry keywords (semiconductors, biotech) to avoid misclassification
- Symbol overrides fixed: IREN removed from SMH theme, bitcoin miners XLE->XLK, SLNH added as Technology

**Watchlist persistence & move**
- active-watchlist-store.ts: Zustand persist store mirroring active-symbol-store pattern
- moveSymbol procedure: removes from source, adds to target (idempotent, preserves empty lists)
- Action menu (⋯) per row: Move to + Remove, click-outside close
- Active watchlist survives navigation and page reloads

**List protections**
- default list: non-deletable, non-renamable, keeps empty row when pruned
- System lists (sector/thematic etc.): read-only, add form hidden, no remove/move/delete

**Per-user module restrictions**
- ProtectedProcedure blocks non-active users
- deleteUser refuses self-delete and last-admin-delete, cleans halt_state manually
- Module gating on execution/analytics pages, sidebar, command palette, mobile nav

Also fixed: schema.sql index moved to migration, materialize DB column fixes, test updates.
This commit is contained in:
Investor Flow Build
2026-07-25 11:38:23 -04:00
parent 63d9f80c09
commit 7492317ebb
27 changed files with 1187 additions and 387 deletions
+264 -208
View File
@@ -1,5 +1,5 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { AdminLayout } from "@/components/AdminLayout";
import { api } from "@/lib/trpc";
@@ -10,13 +10,7 @@ interface UserRow {
created_at: string;
is_admin: number;
status: string;
}
interface SessionRow {
id: string;
user_id: string;
expires_at: string;
created_at: string;
modules: string;
}
function Spinner() {
@@ -35,14 +29,6 @@ function ErrorBanner({ message }: { message: string }) {
);
}
function SuccessBanner({ message }: { message: string }) {
return (
<div className="rounded-lg border border-emerald-500/30 bg-emerald-950/20 p-4 text-sm text-[#10b981]">
{message}
</div>
);
}
function Modal({ open, onClose, children }: { open: boolean; onClose: () => void; children: React.ReactNode }) {
useEffect(() => {
if (!open) return;
@@ -91,7 +77,7 @@ function ResetPasswordModal({ open, onClose, email, onReset }: {
return (
<Modal open={open} onClose={() => { setConfirmText(""); setResult(null); onClose(); }}>
<div className="p-5">
<div className="p-5 text-left">
<h2 className="text-base font-semibold text-fg mb-1">Reset Password</h2>
<p className="text-xs text-fg-faint mb-4">User: <span className="font-mono text-fg">{email}</span></p>
@@ -143,156 +129,249 @@ function ResetPasswordModal({ open, onClose, email, onReset }: {
);
}
function SessionsPanel({ userId, onClose }: { userId: string; onClose: () => void }) {
const [sessions, setSessions] = useState<SessionRow[]>([]);
const [loading, setLoading] = useState(true);
const ALL_MODULES = ["research", "execution", "analytics", "settings"] as const;
useEffect(() => {
let cancelled = false;
api.admin.userSessions(userId)
.then((data) => { if (!cancelled) setSessions(data); })
.catch(() => {})
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [userId]);
function ModulesModal({ open, onClose, user, onUpdated }: { open: boolean; onClose: () => void; user: UserRow; onUpdated: () => void }) {
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(() => {
try { return new Set(JSON.parse(user.modules)); } catch { return new Set(["research", "settings"]); }
});
function formatTime(ts: string): string {
async function handleSave() {
setSaving(true); setError(null);
try {
const d = new Date(ts);
return d.toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
} catch {
return ts.slice(0, 16);
await api.admin.setUserModules(user.id, Array.from(selected));
onUpdated();
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to update modules");
} finally {
setSaving(false);
}
}
function truncateId(id: string, max = 8): string {
if (id.length <= max) return id;
return id.slice(0, max - 2) + "..";
}
if (loading && sessions.length === 0) {
return <div className="px-4 py-3"><Spinner /></div>;
}
return (
<div className="bg-surface border-x border-b border-line-strong px-4 py-3">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-medium text-fg-faint uppercase tracking-wide">
Active Sessions ({sessions.length})
</span>
<button onClick={onClose} className="text-[10px] text-fg-faint hover:text-fg transition">Hide</button>
</div>
<Modal open={open} onClose={onClose}>
<div className="p-5 text-left">
<h2 className="text-base font-semibold text-fg mb-1">Module Access</h2>
<p className="text-xs text-fg-faint mb-4">User: <span className="font-mono text-fg">{user.email}</span></p>
{sessions.length === 0 ? (
<p className="text-xs text-fg-faint italic py-1">No active sessions</p>
) : (
<table className="w-full text-xs">
<thead>
<tr className="border-b border-line-strong text-left text-fg-faint uppercase tracking-wide text-[10px]">
<th className="pb-2 pr-3 font-medium">Session ID</th>
<th className="pb-2 pr-3 font-medium">Expires At</th>
<th className="pb-2 font-medium">Created At</th>
</tr>
</thead>
<tbody className="divide-y divide-[#1a1b28]">
{sessions.map((s) => (
<tr key={s.id}>
<td className="py-2 pr-3 font-mono text-accent" title={s.id}>{truncateId(s.id)}</td>
<td className="py-2 pr-3 text-fg whitespace-nowrap">{formatTime(s.expires_at)}</td>
<td className="py-2 text-fg-faint whitespace-nowrap font-mono">{formatTime(s.created_at)}</td>
</tr>
{error && (
<div className="rounded border border-red-500/30 bg-red-950/20 p-3 mb-4 text-sm text-danger">{error}</div>
)}
<div className="space-y-3">
<label className="block text-xs font-medium text-fg">Select allowed modules:</label>
<div className="space-y-2">
{ALL_MODULES.map((mod) => (
<label key={mod} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={selected.has(mod)}
onChange={(e) => {
const next = new Set(selected);
if (e.target.checked) next.add(mod);
else next.delete(mod);
setSelected(next);
}}
className="accent-accent"
/>
<span className="text-fg capitalize">{mod}</span>
</label>
))}
</tbody>
</table>
)}
</div>
</div>
<div className="flex gap-2 pt-2">
<button
onClick={onClose}
disabled={saving}
className="flex-1 px-3 py-2 text-xs font-medium rounded border border-line-strong text-fg-faint hover:text-fg hover:border-[#3a3b4a] transition disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving}
className="flex-1 px-3 py-2 text-xs font-medium rounded bg-accent text-white hover:bg-accent/80 transition disabled:opacity-50"
>
{saving ? "Saving..." : "Save"}
</button>
</div>
</div>
</div>
</Modal>
);
}
function UserTableRow({ user, onReset }: { user: UserRow; onReset: (email: string) => void }) {
const [showSessions, setShowSessions] = useState(false);
const [complexityLabel] = useState(() => {
switch (user.complexity) {
case "beginner": return <span className="text-[#10b981]">Beginner</span>;
case "intermediate": return <span className="text-[#f59e0b]">Intermediate</span>;
case "advanced": return <span className="text-accent">Advanced</span>;
default: return <span className="text-fg-faint">{user.complexity}</span>;
}
});
function ConfirmModal({ open, onClose, title, message, confirmLabel, danger, onConfirm, loading }: {
open: boolean; onClose: () => void; title: string; message: string;
confirmLabel: string; danger?: boolean; onConfirm: () => Promise<void>; loading: boolean;
}) {
return (
<Modal open={open} onClose={onClose}>
<div className="p-5 text-left">
<h2 className="text-base font-semibold text-fg mb-1">{title}</h2>
<p className="text-sm text-fg-faint mb-4">{message}</p>
function formatTime(ts: string): string {
try {
const d = new Date(ts);
return d.toLocaleString("en-US", { month: "short", day: "numeric", year: "numeric" });
} catch {
return ts.slice(0, 10);
<div className="space-y-3">
<div className="flex gap-2 pt-2">
<button
onClick={onClose}
disabled={loading}
className="flex-1 px-3 py-2 text-xs font-medium rounded border border-line-strong text-fg-faint hover:text-fg hover:border-[#3a3b4a] transition disabled:opacity-50"
>
Cancel
</button>
<button
onClick={onConfirm}
disabled={loading}
className={`flex-1 px-3 py-2 text-xs font-medium rounded transition disabled:opacity-50 ${danger ? "bg-danger text-white hover:bg-red-600" : "bg-accent text-white hover:bg-accent/80"}`}
>
{loading ? "..." : confirmLabel}
</button>
</div>
</div>
</div>
</Modal>
);
}
function UserActions({ user, onUpdated }: { user: UserRow; onUpdated: () => void }) {
const [open, setOpen] = useState(false);
const [resetOpen, setResetOpen] = useState(false);
const [modulesOpen, setModulesOpen] = useState(false);
const [disableOpen, setDisableOpen] = useState(false);
const [enableOpen, setEnableOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
const isPending = user.status === "pending_approval";
const isDisabled = user.status === "disabled";
const isValidId = user.id && /^[0-9a-fA-F]{8}-/.test(user.id);
async function doResetPassword() {
setResetOpen(false);
setLoading(true); setError(null);
try {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let tempPassword = "";
for (let i = 0; i < 12; i++) tempPassword += chars.charAt(Math.floor(Math.random() * chars.length));
await api.admin.resetPassword(user.email, tempPassword);
setError(null);
window.alert(`Password reset successfully. Temp password: ${tempPassword}`);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to reset password");
} finally { setLoading(false); }
}
async function doDisable() {
setDisableOpen(false);
setLoading(true); setError(null);
try { await api.admin.disableUser(user.id); onUpdated(); }
catch (e) { setError(e instanceof Error ? e.message : "Failed to disable"); }
finally { setLoading(false); }
}
async function doEnable() {
setEnableOpen(false);
setLoading(true); setError(null);
try { await api.admin.enableUser(user.id); onUpdated(); }
catch (e) { setError(e instanceof Error ? e.message : "Failed to enable"); }
finally { setLoading(false); }
}
async function doDelete() {
setDeleteOpen(false);
setLoading(true); setError(null);
try { await api.admin.deleteUser(user.id); onUpdated(); }
catch (e) { setError(e instanceof Error ? e.message : "Failed to delete"); }
finally { setLoading(false); }
}
return (
<>
<tr className="group border-b border-line-strong hover:bg-surface-raised/50 transition">
<td className="py-3 pr-4 font-mono text-sm text-fg">{user.email}</td>
<td className="py-3 pr-4 text-xs">{complexityLabel}</td>
<td className="py-3 pr-4">
{user.is_admin === 1 ? (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-[#4f8cff]/10 text-accent border border-accent/30">Admin</span>
) : (
<span className="text-xs text-fg-faint">User</span>
)}
</td>
<td className="py-3 pr-4">
{user.status === 'pending_approval' ? (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-warn/10 text-warn border border-warn/30">Pending</span>
) : user.status === 'rejected' ? (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-error/10 text-error border border-error/30">Rejected</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-up/10 text-up border border-up/30">Active</span>
)}
</td>
<td className="py-3 pr-4 text-xs text-fg-faint whitespace-nowrap font-mono">{formatTime(user.created_at)}</td>
<td className="py-3 pr-2">
<button
onClick={() => setShowSessions(!showSessions)}
className={`px-2.5 py-1 text-xs rounded border transition ${showSessions ? "bg-[#4f8cff]/10 border-accent/30 text-accent" : "border-line-strong text-fg-faint hover:text-fg hover:border-[#3a3b4a]"}`}
>
Sessions
</button>
</td>
<td className="py-3 pl-2">
<button
onClick={() => onReset(user.email)}
className="px-2.5 py-1 text-xs rounded border border-line-strong text-fg-faint hover:text-fg hover:border-[#ef4444]/50 transition"
>
Reset Password
</button>
</td>
</tr>
{showSessions && (
<tr>
<td colSpan={6} className="p-0 align-top">
<SessionsPanel userId={user.id} onClose={() => setShowSessions(false)} />
</td>
</tr>
<div ref={dropdownRef} className="relative inline-block text-left">
<button
onClick={() => setOpen(!open)}
className="px-1.5 py-0.5 text-xs font-medium rounded border border-line-strong text-fg-faint hover:text-fg hover:bg-surface-overlay transition"
>
···
</button>
{open && (
<div className="absolute right-0 mt-1 w-48 rounded-md border border-line-strong bg-surface-raised shadow-xl z-10 py-1">
{!isPending && (
<>
<button onClick={() => { setOpen(false); setResetOpen(true); }} className="block w-full text-left px-4 py-2 text-xs text-fg hover:bg-surface-overlay transition">Reset Password</button>
<button onClick={() => { setOpen(false); setModulesOpen(true); }} className="block w-full text-left px-4 py-2 text-xs text-fg hover:bg-surface-overlay transition">Manage Modules</button>
<hr className="my-1 border-line" />
{isDisabled ? (
<button onClick={doEnable} disabled={loading} className="block w-full text-left px-4 py-2 text-xs text-emerald-400 hover:bg-surface-overlay transition disabled:opacity-50">Enable</button>
) : (
<button onClick={() => { setOpen(false); setDisableOpen(true); }} disabled={loading} className="block w-full text-left px-4 py-2 text-xs text-warn hover:bg-surface-overlay transition disabled:opacity-50">Disable</button>
)}
<button onClick={() => { setOpen(false); setDeleteOpen(true); }} disabled={loading} className="block w-full text-left px-4 py-2 text-xs text-error hover:bg-surface-overlay transition disabled:opacity-50">Delete</button>
</>
)}
{isPending && (
<span className="block px-4 py-2 text-xs text-fg-faint italic">Pending approval</span>
)}
</div>
)}
</div>
<ResetPasswordModal open={resetOpen} onClose={() => setResetOpen(false)} email={user.email} onReset={doResetPassword} />
<ModulesModal open={modulesOpen} onClose={() => setModulesOpen(false)} user={user} onUpdated={onUpdated} />
<ConfirmModal
open={disableOpen} onClose={() => setDisableOpen(false)} title="Disable User"
message={`Disable ${user.email}? They won't be able to sign in.`}
confirmLabel="Disable" danger onConfirm={doDisable} loading={loading}
/>
<ConfirmModal
open={enableOpen} onClose={() => setEnableOpen(false)} title="Enable User"
message={`Re-enable ${user.email}?`}
confirmLabel="Enable" onConfirm={doEnable} loading={loading}
/>
<ConfirmModal
open={deleteOpen} onClose={() => setDeleteOpen(false)} title="Delete User"
message={`Permanently delete ${user.email}? This cannot be undone.`}
confirmLabel="Delete" danger onConfirm={doDelete} loading={loading}
/>
{error && <div className="fixed bottom-4 right-4 rounded-lg border border-red-500/30 bg-red-950/20 p-3 text-xs text-danger z-50">{error}</div>}
</>
);
}
const VALID_MODULES = ["research", "execution", "analytics", "settings"];
export default function AdminUsersPage() {
const [users, setUsers] = useState<UserRow[]>([]);
const [pending, setPending] = useState<UserRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [resetModal, setResetModal] = useState<{ open: boolean; email: string }>({ open: false, email: "" });
const [successMsg, setSuccessMsg] = useState<string | null>(null);
const [approveBusy, setApproveBusy] = useState<string | null>(null);
const load = useCallback(() => {
setLoading(true); setError(null);
Promise.all([
api.admin.usersList().catch((e: Error) => { setError(e.message); return []; }),
api.admin.pendingUsers().catch(() => []),
api.admin.pendingUsers().catch(() => []).then((list: Array<{id:string;email:string;complexity:string;created_at:string}>) =>
list.map((u) => ({ ...u, is_admin: 0, status: "pending_approval", modules: '["research","settings"]' }))
),
]).then(([u, p]) => { setUsers(u); setPending(p); }).finally(() => setLoading(false));
}, []);
@@ -301,15 +380,15 @@ export default function AdminUsersPage() {
async function handleApprove(userId: string) {
setApproveBusy(userId);
try { await api.admin.approveUser(userId); load(); }
catch (e) { setError(e instanceof Error ? e.message : 'Failed to approve.'); }
catch (e) { setError(e instanceof Error ? e.message : "Failed to approve."); }
finally { setApproveBusy(null); }
}
async function handleReject(userId: string) {
if (!confirm('Reject this user?')) return;
if (!confirm("Reject this user?")) return;
setApproveBusy(userId);
try { await api.admin.rejectUser(userId); load(); }
catch (e) { setError(e instanceof Error ? e.message : 'Failed to reject.'); }
catch (e) { setError(e instanceof Error ? e.message : "Failed to reject."); }
finally { setApproveBusy(null); }
}
@@ -322,36 +401,6 @@ export default function AdminUsersPage() {
return () => { cancelled = true; };
}, []);
function openResetModal(email: string) {
setSuccessMsg(null);
setResetModal({ open: true, email });
}
async function handlePasswordReset(confirmed: boolean) {
if (!confirmed || !resetModal.email) throw new Error("Confirmation required");
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let tempPassword = "";
for (let i = 0; i < 12; i++) {
tempPassword += chars.charAt(Math.floor(Math.random() * chars.length));
}
await api.admin.resetPassword(resetModal.email, tempPassword);
setSuccessMsg(`Temporary password generated and reset successfully.`);
// Display the password in the modal via a custom event since we can't pass props back easily
setTimeout(() => {
const el = document.getElementById("temp-password-display");
if (el) el.textContent = tempPassword;
}, 50);
setResetModal({ open: false, email: "" });
}
function closeResetModal() {
setResetModal({ open: false, email: "" });
}
if (loading) return <Spinner />;
return (
@@ -359,11 +408,10 @@ export default function AdminUsersPage() {
<div className="space-y-6">
<div>
<h1 className="text-xl font-bold text-fg">User Management</h1>
<p className="text-sm text-fg-faint mt-1">View users, manage sessions, and reset passwords</p>
<p className="text-sm text-fg-faint mt-1">View users, manage sessions, reset passwords, and control module access</p>
</div>
{error && <ErrorBanner message={error} />}
{successMsg && <SuccessBanner message={successMsg} />}
{/* Pending Approvals */}
{pending.length > 0 && (
@@ -386,25 +434,17 @@ export default function AdminUsersPage() {
<tr key={u.id} className="hover:bg-surface-raised/50 transition">
<td className="py-3 px-4 font-mono text-sm text-fg">{u.email}</td>
<td className="py-3 px-4 text-xs text-fg-faint">
{u.complexity === 'beginner' ? 'Beginner' : u.complexity === 'intermediate' ? 'Intermediate' : 'Advanced'}
{u.complexity === "beginner" ? "Beginner" : u.complexity === "intermediate" ? "Intermediate" : "Advanced"}
</td>
<td className="py-3 px-4 text-xs text-fg-faint whitespace-nowrap font-mono">
{new Date(u.created_at).toLocaleDateString()}
</td>
<td className="py-3 px-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleApprove(u.id)}
disabled={approveBusy === u.id}
className="px-3 py-1 text-xs font-medium rounded bg-accent text-white disabled:opacity-50 hover:bg-accent/80 transition"
>
{approveBusy === u.id ? '...' : 'Approve'}
<button onClick={() => handleApprove(u.id)} disabled={approveBusy === u.id} className="px-3 py-1 text-xs font-medium rounded bg-accent text-white disabled:opacity-50 hover:bg-accent/80 transition">
{approveBusy === u.id ? "..." : "Approve"}
</button>
<button
onClick={() => handleReject(u.id)}
disabled={approveBusy === u.id}
className="px-3 py-1 text-xs font-medium rounded border border-error/40 text-error hover:bg-error/10 transition disabled:opacity-50"
>
<button onClick={() => handleReject(u.id)} disabled={approveBusy === u.id} className="px-3 py-1 text-xs font-medium rounded border border-error/40 text-error hover:bg-error/10 transition disabled:opacity-50">
Reject
</button>
</div>
@@ -416,7 +456,8 @@ export default function AdminUsersPage() {
</section>
)}
<section className="rounded-lg border border-line-strong bg-surface-raised overflow-hidden">
{/* Users Table */}
<section className="rounded-lg border border-line-strong bg-surface-raised overflow-visible">
<div className="flex items-center justify-between border-b border-line-strong px-4 py-3 bg-surface">
<h2 className="text-sm font-semibold text-fg">Users</h2>
<span className="text-xs font-mono text-fg-faint">{users.length} user{users.length !== 1 ? "s" : ""}</span>
@@ -427,36 +468,51 @@ export default function AdminUsersPage() {
<p className="text-sm text-fg-faint italic">No users found</p>
</div>
) : (
<>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line-strong bg-surface/50 text-left text-fg-faint uppercase tracking-wide text-[10px]">
<th className="py-3 px-4 font-medium">Email</th>
<th className="py-3 px-4 font-medium">Complexity</th>
<th className="py-3 px-4 font-medium">Role</th>
<th className="py-3 px-4 font-medium">Status</th>
<th className="py-3 px-4 font-medium">Created At</th>
<th className="py-3 px-2 font-medium text-right w-28">Sessions</th>
<th className="py-3 pl-2 font-medium text-right w-36">Actions</th>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line-strong bg-surface/50 text-left text-fg-faint uppercase tracking-wide text-[10px]">
<th className="py-3 px-4 font-medium">Email</th>
<th className="py-3 px-4 font-medium">Role</th>
<th className="py-3 px-4 font-medium">Status</th>
<th className="py-3 px-4 font-medium whitespace-nowrap">Created At</th>
<th className="py-3 px-4 font-medium text-right w-24">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-[#1a1b28]">
{users.map((user) => (
<tr key={user.id} className="hover:bg-surface-raised/50 transition">
<td className="py-3 px-4 font-mono text-sm text-fg">{user.email}</td>
<td className="py-3 px-4 text-xs">
{user.is_admin === 1 ? (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-[#4f8cff]/10 text-accent border border-accent/30">Admin</span>
) : (
<span className="text-xs text-fg-faint">User</span>
)}
</td>
<td className="py-3 px-4 text-xs">
{user.status === "pending_approval" ? (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-warn/10 text-warn border border-warn/30">Pending</span>
) : user.status === "rejected" ? (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-error/10 text-error border border-error/30">Rejected</span>
) : user.status === "disabled" ? (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-surface-sunken text-fg-muted border border-line-strong">Disabled</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-up/10 text-up border border-up/30">Active</span>
)}
</td>
<td className="py-3 px-4 text-xs text-fg-faint whitespace-nowrap font-mono">
{new Date(user.created_at).toLocaleDateString()}
</td>
<td className="py-3 px-4 text-right">
<UserActions user={user} onUpdated={load} />
</td>
</tr>
</thead>
<tbody className="divide-y divide-[#1a1b28]">
{users.map((user) => (
<UserTableRow key={user.id} user={user} onReset={openResetModal} />
))}
</tbody>
</table>
</>
))}
</tbody>
</table>
)}
</section>
<ResetPasswordModal
open={resetModal.open}
onClose={closeResetModal}
email={resetModal.email}
onReset={handlePasswordReset}
/>
</div>
</AdminLayout>
);
}
}
+14 -4
View File
@@ -1,5 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { LayoutShell } from "@/components/LayoutShell";
import { FeatureGate } from "@/components/FeatureGate";
import { api, type AlertEventRow, type AlertSubscriptionRow } from "@/lib/trpc";
const SEVERITY_COLORS: Record<string, string> = {
@@ -8,7 +10,7 @@ const SEVERITY_COLORS: Record<string, string> = {
critical: "text-red-400 bg-red-950/30 border-red-500/30",
};
export default function AlertsPage() {
function AlertsPageInner() {
const [tab, setTab] = useState<"events" | "subscriptions">("events");
const [events, setEvents] = useState<AlertEventRow[]>([]);
const [subscriptions, setSubscriptions] = useState<AlertSubscriptionRow[]>([]);
@@ -64,8 +66,8 @@ export default function AlertsPage() {
const unacknowledged = events.filter((e) => !e.acknowledged);
return (
<div className="min-h-screen bg-surface text-fg p-6">
<div className="max-w-4xl mx-auto">
<LayoutShell>
<div className="space-y-6">
<h1 className="text-2xl font-bold mb-4">Alerts</h1>
<div className="flex gap-1 mb-6 border-b border-border">
@@ -167,6 +169,14 @@ export default function AlertsPage() {
</div>
)}
</div>
</div>
</LayoutShell>
);
}
export default function AlertsPage() {
return (
<FeatureGate module="analytics">
<AlertsPageInner />
</FeatureGate>
);
}
+10 -1
View File
@@ -5,8 +5,9 @@ import { useState, useRef } from 'react';
import { useGoalsStore } from '@/stores/goals-store';
import type { MonthlyGoal, WeeklyGoal } from '@/types';
import { LayoutShell } from '@/components/LayoutShell';
import { FeatureGate } from '@/components/FeatureGate';
export default function DailyFocusPage() {
function DailyFocusPageInner() {
const store = useGoalsStore();
const [showAddMonthly, setShowAddMonthly] = useState(false);
@@ -241,3 +242,11 @@ function MonthlyGoalItem({ goal, onToggle, onDelete }: { goal: { id: string; des
</div>
);
}
export default function DailyFocusPage() {
return (
<FeatureGate module="execution">
<DailyFocusPageInner />
</FeatureGate>
);
}
+10 -1
View File
@@ -6,6 +6,7 @@ import { useExecutionStore } from '@/stores/execution-store';
import { EmotionLogger } from '@/components/EmotionLogger';
import type { ExecutionPlaybook, EmotionLogState, MistakeStage, ScaleEntry } from '@/types';
import { LayoutShell } from '@/components/LayoutShell';
import { FeatureGate } from '@/components/FeatureGate';
const PLAYBOOK_LABELS: Record<ExecutionPlaybook, string> = {
mean_reversion: 'Mean Reversion (MR)',
@@ -14,7 +15,7 @@ const PLAYBOOK_LABELS: Record<ExecutionPlaybook, string> = {
const EXECUTION_PATTERNS = Object.keys(PLAYBOOK_LABELS) as ExecutionPlaybook[];
export default function ExecutionPage() {
function ExecutionPageInner() {
const store = useExecutionStore();
const plan = store.executionPlaybook;
@@ -377,3 +378,11 @@ function KPICard({ label, value, color }: { label: string; value: string; color:
</div>
);
}
export default function ExecutionPage() {
return (
<FeatureGate module="execution">
<ExecutionPageInner />
</FeatureGate>
);
}
+10 -1
View File
@@ -1,11 +1,20 @@
"use client";
import { LayoutShell } from "@/components/LayoutShell";
import { FeatureGate } from "@/components/FeatureGate";
import { FilingsPanel } from "@/components/FilingsPanel";
export default function FilingsPage() {
function FilingsPageInner() {
return (
<LayoutShell>
<FilingsPanel />
</LayoutShell>
);
}
export default function FilingsPage() {
return (
<FeatureGate module="analytics">
<FilingsPageInner />
</FeatureGate>
);
}
+10 -1
View File
@@ -1,11 +1,20 @@
"use client";
import { LayoutShell } from "@/components/LayoutShell";
import { FeatureGate } from "@/components/FeatureGate";
import { OptionsDDPanel } from "@/components/OptionsDDPanel";
export default function OptionsDDPage() {
function OptionsDDPageInner() {
return (
<LayoutShell>
<OptionsDDPanel />
</LayoutShell>
);
}
export default function OptionsDDPage() {
return (
<FeatureGate module="analytics">
<OptionsDDPageInner />
</FeatureGate>
);
}
+10 -1
View File
@@ -1,14 +1,23 @@
"use client";
import { LayoutShell } from "@/components/LayoutShell";
import { FeatureGate } from "@/components/FeatureGate";
import { RiskPosturePanel } from "@/components/RiskPosturePanel";
/**
* M20 Risk Posture — single surface for portfolio risk picture + sizing math.
*/
export default function RiskPage() {
function RiskPageInner() {
return (
<LayoutShell>
<RiskPosturePanel />
</LayoutShell>
);
}
export default function RiskPage() {
return (
<FeatureGate module="analytics">
<RiskPageInner />
</FeatureGate>
);
}
+10 -1
View File
@@ -1,11 +1,20 @@
"use client";
import { LayoutShell } from "@/components/LayoutShell";
import { FeatureGate } from "@/components/FeatureGate";
import { TradeClosureView } from "@/components/trade-closure/TradeClosureView";
export default function TradeClosurePage() {
function TradeClosurePageInner() {
return (
<LayoutShell>
<TradeClosureView />
</LayoutShell>
);
}
export default function TradeClosurePage() {
return (
<FeatureGate module="execution">
<TradeClosurePageInner />
</FeatureGate>
);
}
+10 -1
View File
@@ -4,6 +4,7 @@ export const dynamic = 'force-dynamic';
import { useTradePlanStore } from '@/stores/trade-plan-store';
import type { ConfluenceType, SetupTier, TradePlan } from '@/types';
import { LayoutShell } from '@/components/LayoutShell';
import { FeatureGate } from '@/components/FeatureGate';
// --- Plan List Sidebar ---
function PlanListSidebar({ plans }: { plans: TradePlan[] }) {
@@ -147,7 +148,7 @@ function AddTargetForm({ onAdd, entryPrice }: { onAdd: (price: number, reason: s
}
// --- Main Component ---
export default function TradePlanPage() {
function TradePlanPageInner() {
const store = useTradePlanStore();
const plan = store.currentPlanId
? store.activePlans.find(p => p.id === store.currentPlanId)
@@ -454,5 +455,13 @@ export default function TradePlanPage() {
);
}
export default function TradePlanPage() {
return (
<FeatureGate module="execution">
<TradePlanPageInner />
</FeatureGate>
);
}
// --- Helpers ---
import { useState } from 'react';
+49 -34
View File
@@ -24,6 +24,7 @@ import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { api } from "@/lib/trpc";
import { useActiveSymbol } from "@/stores/active-symbol-store";
import { useFeatureAccess, type ModuleKey } from "@/lib/useFeatureAccess";
interface NavCommand {
label: string;
@@ -31,25 +32,26 @@ interface NavCommand {
icon: LucideIcon;
goTo?: string;
group: "Page";
module?: ModuleKey;
}
const NAV_COMMANDS: NavCommand[] = [
{ label: "Overview", href: "/", icon: Home, goTo: "o", group: "Page" },
{ label: "Charts", href: "/chart-lab", icon: LineChart, goTo: "c", group: "Page" },
{ label: "Institutional", href: "/institutional", icon: Building2, goTo: "i", group: "Page" },
{ label: "Market Outlook", href: "/market-outlook", icon: TrendingUp, goTo: "m", group: "Page" },
{ label: "Trade Plan", href: "/trade-plan", icon: ClipboardList, goTo: "t", group: "Page" },
{ label: "Execution", href: "/execution", icon: PlayCircle, goTo: "e", group: "Page" },
{ label: "Trade Closure", href: "/trade-closure", icon: XCircle, goTo: "x", group: "Page" },
{ label: "Daily Focus", href: "/daily-focus", icon: Target, goTo: "d", group: "Page" },
{ label: "Risk Posture", href: "/risk", icon: Shield, goTo: "r", group: "Page" },
{ label: "Filings", href: "/filings", icon: FileText, goTo: "f", group: "Page" },
{ label: "Options DD", href: "/options-dd", icon: BarChart3, goTo: "p", group: "Page" },
{ label: "Settings", href: "/settings", icon: Settings, goTo: "s", group: "Page" },
{ label: "Admin", href: "/admin", icon: Shield, goTo: "a", group: "Page" },
{ label: "Admin · Users", href: "/admin/users", icon: Users, group: "Page" },
{ label: "Admin · Queue", href: "/admin/queue", icon: Activity, group: "Page" },
{ label: "Admin · Audit Logs", href: "/admin/audit-logs", icon: ScrollText, group: "Page" },
{ label: "Overview", href: "/", icon: Home, goTo: "o", group: "Page", module: "research" },
{ label: "Charts", href: "/chart-lab", icon: LineChart, goTo: "c", group: "Page", module: "research" },
{ label: "Institutional", href: "/institutional", icon: Building2, goTo: "i", group: "Page", module: "research" },
{ label: "Market Outlook", href: "/market-outlook", icon: TrendingUp, goTo: "m", group: "Page", module: "research" },
{ label: "Trade Plan", href: "/trade-plan", icon: ClipboardList, goTo: "t", group: "Page", module: "execution" },
{ label: "Execution", href: "/execution", icon: PlayCircle, goTo: "e", group: "Page", module: "execution" },
{ label: "Trade Closure", href: "/trade-closure", icon: XCircle, goTo: "x", group: "Page", module: "execution" },
{ label: "Daily Focus", href: "/daily-focus", icon: Target, goTo: "d", group: "Page", module: "execution" },
{ label: "Risk Posture", href: "/risk", icon: Shield, goTo: "r", group: "Page", module: "analytics" },
{ label: "Filings", href: "/filings", icon: FileText, goTo: "f", group: "Page", module: "analytics" },
{ label: "Options DD", href: "/options-dd", icon: BarChart3, goTo: "p", group: "Page", module: "analytics" },
{ label: "Settings", href: "/settings", icon: Settings, goTo: "s", group: "Page", module: "settings" },
{ label: "Admin", href: "/admin", icon: Shield, goTo: "a", group: "Page", module: "admin" },
{ label: "Admin · Users", href: "/admin/users", icon: Users, group: "Page", module: "admin" },
{ label: "Admin · Queue", href: "/admin/queue", icon: Activity, group: "Page", module: "admin" },
{ label: "Admin · Audit Logs", href: "/admin/audit-logs", icon: ScrollText, group: "Page", module: "admin" },
];
interface Command {
@@ -84,6 +86,7 @@ function fuzzyScore(query: string, text: string): number {
export function CommandPalette() {
const router = useRouter();
const { modules } = useFeatureAccess();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
@@ -124,16 +127,26 @@ export function CommandPalette() {
const typing = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || (el as HTMLElement)?.isContentEditable;
if (open || typing || e.metaKey || e.ctrlKey || e.altKey) return;
if (gPressedAt.current && Date.now() - gPressedAt.current < 1200) {
const map: Record<string, string> = {
o: "/", c: "/chart-lab", i: "/institutional", m: "/market-outlook",
t: "/trade-plan", e: "/execution", x: "/trade-closure", d: "/daily-focus",
r: "/risk", f: "/filings", p: "/options-dd", s: "/settings", a: "/admin",
};
const href = map[e.key.toLowerCase()];
if (href) {
const goToMap: Array<{ key: string; href: string; module?: ModuleKey }> = [
{ key: "o", href: "/", module: "research" },
{ key: "c", href: "/chart-lab", module: "research" },
{ key: "i", href: "/institutional", module: "research" },
{ key: "m", href: "/market-outlook", module: "research" },
{ key: "t", href: "/trade-plan", module: "execution" },
{ key: "e", href: "/execution", module: "execution" },
{ key: "x", href: "/trade-closure", module: "execution" },
{ key: "d", href: "/daily-focus", module: "execution" },
{ key: "r", href: "/risk", module: "analytics" },
{ key: "f", href: "/filings", module: "analytics" },
{ key: "p", href: "/options-dd", module: "analytics" },
{ key: "s", href: "/settings", module: "settings" },
{ key: "a", href: "/admin", module: "admin" },
];
const entry = goToMap.find((m) => m.key === e.key.toLowerCase());
if (entry && (!entry.module || modules.includes(entry.module))) {
e.preventDefault();
gPressedAt.current = 0;
router.push(href);
router.push(entry.href);
}
return;
}
@@ -143,17 +156,19 @@ export function CommandPalette() {
};
window.addEventListener("keydown", onGoTo);
return () => window.removeEventListener("keydown", onGoTo);
}, [open, router]);
}, [open, router, modules]);
const commands = useMemo<Command[]>(() => {
const pageCmds: Command[] = NAV_COMMANDS.map((c) => ({
id: `page:${c.href}`,
label: c.label,
hint: c.goTo ? `g ${c.goTo}` : undefined,
group: c.group,
icon: c.icon,
run: () => router.push(c.href),
}));
const pageCmds: Command[] = NAV_COMMANDS
.filter((c) => !c.module || modules.includes(c.module))
.map((c) => ({
id: `page:${c.href}`,
label: c.label,
hint: c.goTo ? `g ${c.goTo}` : undefined,
group: c.group,
icon: c.icon,
run: () => router.push(c.href),
}));
const symbolCmds: Command[] = symbols.map((sym) => ({
id: `sym:${sym}`,
label: `Open ${sym}`,
@@ -166,7 +181,7 @@ export function CommandPalette() {
},
}));
return [...pageCmds, ...symbolCmds];
}, [symbols, router]);
}, [symbols, router, modules]);
const results = useMemo(() => {
if (!query.trim()) return commands;
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useFeatureAccess, type ModuleKey } from "@/lib/useFeatureAccess";
interface FeatureGateProps {
module: ModuleKey;
children: React.ReactNode;
fallback?: React.ReactNode;
}
export function FeatureGate({ module, children, fallback }: FeatureGateProps) {
const { modules, loading } = useFeatureAccess();
const router = useRouter();
const allowed = modules.includes(module);
useEffect(() => {
if (!loading && !allowed) {
router.replace("/");
}
}, [loading, allowed, router]);
if (loading) {
return (
<div className="flex items-center justify-center py-16">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent border-t-transparent" />
</div>
);
}
if (!allowed) {
if (fallback) return <>{fallback}</>;
return (
<div className="flex flex-col items-center justify-center py-16 space-y-4">
<p className="text-lg font-medium text-fg">Access Restricted</p>
<p className="text-sm text-fg-muted">You don&apos;t have access to this module.</p>
</div>
);
}
return <>{children}</>;
}
+12 -8
View File
@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useFeatureAccess, type ModuleKey } from "@/lib/useFeatureAccess";
/**
* Mobile tab navigation bar.
@@ -12,24 +13,27 @@ interface TabItem {
label: string;
href: string;
icon: string;
module: ModuleKey;
}
const TABS: TabItem[] = [
{ label: "Overview", href: "/", icon: "📊" },
{ label: "Filings", href: "/filings", icon: "📄" },
{ label: "Options", href: "/options-dd", icon: "🔮" },
{ label: "Alerts", href: "/alerts", icon: "🔔" },
{ label: "Execution", href: "/execution", icon: "⚡" },
{ label: "Trade Plan", href: "/trade-plan", icon: "📋" },
{ label: "Settings", href: "/settings", icon: "⚙️" },
{ label: "Overview", href: "/", icon: "📊", module: "research" },
{ label: "Filings", href: "/filings", icon: "📄", module: "analytics" },
{ label: "Options", href: "/options-dd", icon: "🔮", module: "analytics" },
{ label: "Alerts", href: "/alerts", icon: "🔔", module: "analytics" },
{ label: "Execution", href: "/execution", icon: "⚡", module: "execution" },
{ label: "Trade Plan", href: "/trade-plan", icon: "📋", module: "execution" },
{ label: "Settings", href: "/settings", icon: "⚙️", module: "settings" },
];
export function MobileTabNav() {
const pathname = usePathname();
const { modules } = useFeatureAccess();
const visibleTabs = TABS.filter((t) => modules.includes(t.module));
return (
<nav className="flex md:hidden border-t border-line-strong bg-surface-raised">
{TABS.map((tab) => {
{visibleTabs.map((tab) => {
const isActive = pathname === tab.href;
return (
<Link
+143
View File
@@ -0,0 +1,143 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import Link from "next/link";
import { Bell } from "lucide-react";
import { api, type AlertEventRow } from "@/lib/trpc";
const SEVERITY_COLORS: Record<string, string> = {
info: "text-blue-400 bg-blue-950/30 border-blue-500/30",
warning: "text-amber-400 bg-amber-950/30 border-amber-500/30",
critical: "text-red-400 bg-red-950/30 border-red-500/30",
};
const POLL_INTERVAL = 30_000;
export function NotificationBell() {
const [open, setOpen] = useState(false);
const [alerts, setAlerts] = useState<AlertEventRow[]>([]);
const [unackedCount, setUnackedCount] = useState(0);
const ref = useRef<HTMLDivElement>(null);
const fetchAlerts = useCallback(async () => {
try {
const [evts, cnt] = await Promise.all([
api.alerts.list(20),
api.alerts.unackedCount(),
]);
setAlerts(evts);
setUnackedCount(cnt.count);
} catch { /* ignore */ }
}, []);
useEffect(() => {
fetchAlerts();
const id = setInterval(fetchAlerts, POLL_INTERVAL);
return () => clearInterval(id);
}, [fetchAlerts]);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
if (open) {
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}
}, [open]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
if (open) {
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}
}, [open]);
const dismiss = async (id: string) => {
await api.alerts.acknowledge(id);
setAlerts((prev) =>
prev.map((a) => (a.id === id ? { ...a, acknowledged: true } : a)),
);
setUnackedCount((c) => Math.max(0, c - 1));
window.dispatchEvent(new CustomEvent("alert-dismissed"));
};
const unackedAlerts = alerts.filter((a) => !a.acknowledged);
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(!open)}
aria-label={`Notifications${unackedCount > 0 ? ` (${unackedCount} unread)` : ""}`}
className="relative rounded-lg p-1.5 text-fg-muted hover:text-fg hover:bg-surface-sunken transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<Bell className="h-4 w-4" />
{unackedCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-danger px-1 text-[9px] font-bold text-white leading-none">
{unackedCount > 99 ? "99+" : unackedCount}
</span>
)}
</button>
{open && (
<div className="absolute right-0 top-full mt-2 z-50 w-80 rounded-lg border border-line bg-surface-raised shadow-xl overflow-hidden">
<div className="flex items-center justify-between border-b border-line px-4 py-2.5">
<span className="text-xs font-semibold text-fg">Notifications</span>
{unackedCount > 0 && (
<span className="rounded-full bg-danger/20 px-2 py-0.5 text-[10px] font-medium text-danger">
{unackedCount} unread
</span>
)}
</div>
<div className="max-h-80 overflow-y-auto">
{unackedAlerts.length === 0 ? (
<div className="px-4 py-8 text-center">
<Bell className="mx-auto h-5 w-5 text-fg-faint/40 mb-2" />
<p className="text-xs text-fg-faint">No new notifications</p>
</div>
) : (
<div className="divide-y divide-line">
{unackedAlerts.map((a) => (
<div key={a.id} className="px-4 py-2.5 hover:bg-surface-sunken transition-colors">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 mb-0.5 flex-wrap">
<span className={`rounded border px-1 py-0.5 text-[9px] font-medium ${SEVERITY_COLORS[a.severity] ?? ""}`}>{a.severity}</span>
<span className="text-xs font-medium text-fg truncate">{a.title}</span>
{a.symbol && <span className="rounded bg-surface-dark px-1 py-0.5 text-[9px] text-fg-faint">{a.symbol}</span>}
</div>
<p className="text-[10px] text-fg-faint line-clamp-2">{a.description}</p>
<p className="text-[9px] text-fg-faint/50 mt-0.5">
{new Date(a.createdAt).toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}
</p>
</div>
<button
onClick={() => dismiss(a.id)}
className="shrink-0 rounded border border-line px-1.5 py-0.5 text-[9px] text-fg-faint hover:text-fg hover:bg-surface-dark transition-colors"
>
Dismiss
</button>
</div>
</div>
))}
</div>
)}
</div>
<Link
href="/alerts"
onClick={() => setOpen(false)}
className="block border-t border-line px-4 py-2 text-[10px] font-medium text-accent hover:bg-surface-sunken transition-colors text-center"
>
View all alerts
</Link>
</div>
)}
</div>
);
}
+21 -8
View File
@@ -10,10 +10,10 @@ import {
Settings,
ChevronDown,
Search,
Bell,
type LucideIcon,
} from "lucide-react";
import { api } from "@/lib/trpc";
import { useFeatureAccess, type ModuleKey } from "@/lib/useFeatureAccess";
/**
* Left sidebar navigation with collapsible sections.
@@ -29,6 +29,7 @@ interface NavItem {
interface NavSection {
title: string;
icon: LucideIcon;
module: ModuleKey;
items: NavItem[];
}
@@ -36,6 +37,7 @@ const SECTIONS: NavSection[] = [
{
title: "Research",
icon: BarChart3,
module: "research",
items: [
{ label: "Overview", href: "/" },
{ label: "Market Outlook", href: "/market-outlook" },
@@ -46,6 +48,7 @@ const SECTIONS: NavSection[] = [
{
title: "Execution",
icon: Zap,
module: "execution",
items: [
{ label: "Trade Plan", href: "/trade-plan" },
{ label: "Execution", href: "/execution" },
@@ -56,6 +59,7 @@ const SECTIONS: NavSection[] = [
{
title: "Analytics",
icon: Microscope,
module: "analytics",
items: [
{ label: "Risk Posture", href: "/risk" },
{ label: "Filings", href: "/filings" },
@@ -66,6 +70,7 @@ const SECTIONS: NavSection[] = [
{
title: "Admin",
icon: Shield,
module: "admin",
items: [
{ label: "Overview", href: "/admin" },
{ label: "Users", href: "/admin/users" },
@@ -78,13 +83,14 @@ const SECTIONS: NavSection[] = [
{
title: "Settings",
icon: Settings,
module: "settings",
items: [{ label: "Profile / Auth", href: "/settings" }],
},
];
const STORAGE_KEY = "iflow-nav-collapsed";
function getInitialCollapsed(pathname: string): Record<string, boolean> {
function getInitialCollapsed(pathname: string, visibleSections: NavSection[]): Record<string, boolean> {
if (typeof window !== "undefined") {
try {
const stored = localStorage.getItem(STORAGE_KEY);
@@ -93,7 +99,7 @@ function getInitialCollapsed(pathname: string): Record<string, boolean> {
}
const initial: Record<string, boolean> = {};
const norm = (pathname || "/").replace(/\/+$/, "") || "/";
for (const section of SECTIONS) {
for (const section of visibleSections) {
const isActive = section.items.some((item) => item.href === norm);
initial[section.title] = !isActive;
}
@@ -103,18 +109,25 @@ function getInitialCollapsed(pathname: string): Record<string, boolean> {
export function SidebarNav() {
const pathname = usePathname();
const normalizedPath = (pathname || "/").replace(/\/+$/, "") || "/";
const { modules, loading } = useFeatureAccess();
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const [initialized, setInitialized] = useState(false);
const [unackedCount, setUnackedCount] = useState(0);
const visibleSections = SECTIONS.filter((s) => modules.includes(s.module));
useEffect(() => {
api.alerts.unackedCount().then((r) => setUnackedCount(r.count)).catch(() => {});
const fetch = () => api.alerts.unackedCount().then((r) => setUnackedCount(r.count)).catch(() => {});
fetch();
const handler = () => fetch();
window.addEventListener("alert-dismissed", handler);
return () => window.removeEventListener("alert-dismissed", handler);
}, []);
useEffect(() => {
setCollapsed(getInitialCollapsed(pathname));
setCollapsed(getInitialCollapsed(pathname, visibleSections));
setInitialized(true);
}, []);
}, [loading]);
useEffect(() => {
if (initialized) {
@@ -134,7 +147,7 @@ export function SidebarNav() {
<aside className="w-52 border-r border-line bg-surface-raised flex-shrink-0 hidden lg:block">
<nav className="flex h-full flex-col p-3">
<div className="space-y-0.5 flex-1">
{SECTIONS.map((section) => {
{visibleSections.map((section) => {
const Icon = section.icon;
return (
<div key={section.title} className="mb-1">
@@ -182,7 +195,7 @@ export function SidebarNav() {
})}
</div>
{/* Search affordance — opens the command palette */}
{/* Search affordance -- opens the command palette */}
<button
onClick={openPalette}
className="mt-2 flex items-center gap-2 w-full rounded-md border border-line bg-surface-sunken px-2 py-1.5 text-xs text-fg-muted hover:text-fg hover:bg-surface-overlay transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
+2
View File
@@ -4,6 +4,7 @@ import { useActiveSymbol } from "@/stores/active-symbol-store";
import { api, type Snapshot } from "@/lib/trpc";
import { UI_STRINGS } from "@/lib/strings";
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
import { NotificationBell } from "@/components/NotificationBell";
/**
* Persistent top bar showing the active symbol, price, change, and search.
@@ -115,6 +116,7 @@ export function SymbolHeader() {
</form>
{/* Theme switcher */}
<NotificationBell />
<ThemeSwitcher />
</div>
</div>
+84 -16
View File
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useActiveSymbol } from "@/stores/active-symbol-store";
import { useActiveWatchlist } from "@/stores/active-watchlist-store";
import { api, type WatchlistEntry, type Quote, type PortfolioHolding } from "@/lib/trpc";
import { chart as CHART } from "@/lib/chart-theme";
import { UI_STRINGS } from "@/lib/strings";
@@ -15,6 +16,9 @@ interface WatchlistMeta {
symbol_count: number;
sort_order: number;
created_at: string;
kind?: string;
class_key?: string | null;
class_label?: string | null;
}
interface WatchlistSidebarProps {
@@ -30,11 +34,19 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
const [loading, setLoading] = useState(true);
const [holdings, setHoldings] = useState<PortfolioHolding[]>([]);
const [watchlists, setWatchlists] = useState<WatchlistMeta[]>([]);
const [activeWatchlist, setActiveWatchlist] = useState("default");
const activeWatchlist = useActiveWatchlist((s) => s.activeWatchlist);
const setActiveWatchlist = useActiveWatchlist((s) => s.setActiveWatchlist);
const [showListPicker, setShowListPicker] = useState(false);
const [newListName, setNewListName] = useState("");
const [menuOpenFor, setMenuOpenFor] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const pickerRef = useRef<HTMLDivElement>(null);
const userLists = watchlists.filter((w) => w.kind !== 'sector' && w.kind !== 'thematic' && w.kind !== 'style' && w.kind !== 'region');
const systemLists = watchlists.filter((w) => w.kind === 'sector' || w.kind === 'thematic' || w.kind === 'style' || w.kind === 'region');
const isSystemList = (name: string) => systemLists.some((w) => w.name === name);
const activeUserList = userLists.find((w) => w.name === activeWatchlist) ? activeWatchlist : 'default';
// Portfolio summary
useEffect(() => {
api.portfolio.holdings().then(setHoldings).catch(() => {});
@@ -46,8 +58,10 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
try {
const wls = await api.watchlists.listWatchlists();
setWatchlists(wls);
if (wls.length > 0 && !wls.find((w) => w.name === activeWatchlist)) {
setActiveWatchlist(wls[0].name);
const allNames = wls.map((w) => w.name);
if (activeWatchlist && !allNames.includes(activeWatchlist)) {
const fallback = wls.find((w) => w.kind === 'user')?.name ?? 'default';
setActiveWatchlist(fallback);
}
} catch { /* ignore */ }
};
@@ -87,6 +101,7 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
const add = async () => {
if (!draft.trim()) return;
if (isSystemList(activeWatchlist)) return;
const sym = draft.trim().toUpperCase();
await api.watchlists.addSymbol(sym, activeWatchlist === "default" ? undefined : activeWatchlist);
setDraft("");
@@ -95,11 +110,20 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
};
const remove = async (symbol: string) => {
if (isSystemList(activeWatchlist)) return;
await api.watchlists.removeSymbol(symbol, activeWatchlist === "default" ? undefined : activeWatchlist);
load();
loadWatchlists();
};
const moveSymbol = async (symbol: string, toWatchlist: string) => {
if (isSystemList(activeWatchlist)) return;
await api.watchlists.moveSymbol(symbol, activeWatchlist === "default" ? undefined : activeWatchlist, toWatchlist === "default" ? undefined : toWatchlist);
load();
loadWatchlists();
setMenuOpenFor(null);
};
const createWatchlist = async () => {
if (!newListName.trim()) return;
await api.watchlists.create(newListName.trim());
@@ -110,6 +134,7 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
};
const deleteWatchlist = async (name: string) => {
if (name === "default" || isSystemList(name)) return;
await api.watchlists.delete(name);
loadWatchlists();
if (activeWatchlist === name) {
@@ -130,6 +155,18 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
}
}, [showListPicker]);
// Close action menu on outside click
useEffect(() => {
if (!menuOpenFor) return;
const handler = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpenFor(null);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [menuOpenFor]);
const containerClass = compact
? "w-full border-b border-line bg-surface-raised flex-shrink-0"
: "w-56 border-r border-line bg-surface-raised flex-shrink-0 hidden lg:block";
@@ -175,9 +212,22 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
</button>
{showListPicker && (
<div className="absolute top-full left-0 right-0 mt-1 z-50 rounded-lg border border-line bg-surface-raised shadow-xl overflow-hidden">
<div className="max-h-48 overflow-y-auto">
{watchlists.map((wl) => (
<div className="absolute top-full left-0 right-0 mt-1 z-50 rounded-lg border border-line bg-surface-raised shadow-xl overflow-hidden max-h-60 overflow-y-auto">
{systemLists.length > 0 && (
<div>
<div className="px-2 py-1 text-[9px] uppercase tracking-wider text-fg-muted bg-surface-sunken">Sectors & Themes</div>
{systemLists.map((wl) => (
<div key={wl.id} className="flex items-center gap-1 px-2 py-1.5 hover:bg-surface-sunken cursor-pointer text-xs" onClick={() => { setActiveWatchlist(wl.name); setShowListPicker(false); }}>
<span className={`flex-1 truncate ${wl.name === activeWatchlist ? "text-accent font-medium" : "text-fg"}`}>{wl.name}</span>
<span className="text-fg-faint">{wl.symbol_count}</span>
<span className="text-[8px] text-fg-muted">●</span>
</div>
))}
</div>
)}
<div className={systemLists.length > 0 ? "border-t border-line" : ""}>
<div className="px-2 py-1 text-[9px] uppercase tracking-wider text-fg-muted bg-surface-sunken">My Lists</div>
{userLists.map((wl) => (
<div key={wl.id} className="flex items-center gap-1 px-2 py-1.5 hover:bg-surface-sunken cursor-pointer text-xs" onClick={() => { setActiveWatchlist(wl.name); setShowListPicker(false); }}>
<span className={`flex-1 truncate ${wl.name === activeWatchlist ? "text-accent font-medium" : "text-fg"}`}>{wl.name}</span>
<span className="text-fg-faint">{wl.symbol_count}</span>
@@ -208,8 +258,8 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
</div>
)}
{/* Add symbol form */}
{!compact && (
{/* Add symbol form — hidden for read-only system/sector lists */}
{!compact && !isSystemList(activeWatchlist) && (
<form onSubmit={(e) => { e.preventDefault(); add(); }} className="flex gap-1">
<label className="sr-only" htmlFor="watchlist-add-symbol">Add watchlist symbol</label>
<input
@@ -286,14 +336,32 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
) : (
<span className="w-14" />
)}
{!compact && (
<button
onClick={(ev) => { ev.stopPropagation(); remove(e.symbol); }}
aria-label={`Remove ${e.symbol} from watchlist`}
className="text-[9px] text-fg-muted hover:text-danger focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0b0f] rounded ml-auto"
>
✕
</button>
{!compact && !isSystemList(activeWatchlist) && (
<div className="relative" ref={menuOpenFor === e.symbol ? menuRef : undefined}>
<button
onClick={(ev) => { ev.stopPropagation(); setMenuOpenFor(menuOpenFor === e.symbol ? null : e.symbol); }}
aria-label={`Actions for ${e.symbol}`}
className="text-[9px] text-fg-muted hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0b0f] rounded ml-1"
>
⋯
</button>
{menuOpenFor === e.symbol && (
<div ref={menuRef} className="absolute right-0 top-full mt-1 z-50 rounded-md border border-line-strong bg-surface-raised shadow-xl py-1 w-36">
{userLists.filter((w) => w.name !== activeWatchlist).length > 0 ? (
<>
<div className="px-2 py-1 text-[9px] uppercase tracking-wider text-fg-muted">Move to…</div>
{userLists.filter((w) => w.name !== activeWatchlist).map((wl) => (
<button key={wl.id} onClick={(ev) => { ev.stopPropagation(); moveSymbol(e.symbol, wl.name); }} className="block w-full text-left px-3 py-1.5 text-xs text-fg hover:bg-surface-sunken transition">{wl.name}</button>
))}
<hr className="my-1 border-line" />
<button onClick={(ev) => { ev.stopPropagation(); remove(e.symbol); }} className="block w-full text-left px-3 py-1.5 text-xs text-danger hover:bg-surface-sunken transition">Remove from list</button>
</>
) : (
<button onClick={(ev) => { ev.stopPropagation(); remove(e.symbol); }} className="block w-full text-left px-3 py-1.5 text-xs text-danger hover:bg-surface-sunken transition">Remove from list</button>
)}
</div>
)}
</div>
)}
</div>
);
+10 -3
View File
@@ -367,6 +367,7 @@ export interface AuthUser {
riskTolerance?: string;
convexityPosture?: string;
onboarded?: boolean;
modules?: string[];
}
// 8-second timeout for all tRPC calls to prevent hung requests.
@@ -540,11 +541,13 @@ export const api = {
watchlists: {
list: () => trpcQuery<WatchlistEntry[]>("watchlists.list"),
listByWatchlist: (name: string) => trpcQuery<WatchlistEntry[]>("watchlists.listByWatchlist", { name }),
listWatchlists: () => trpcQuery<Array<{ id: string; name: string; symbol_count: number; sort_order: number; created_at: string }>>("watchlists.listWatchlists"),
create: (name: string) => trpcMutate<{ id: string; name: string; symbol_count: number; sort_order: number; created_at: string }>("watchlists.create", { name }),
listWatchlists: () => trpcQuery<Array<{ id: string; name: string; symbol_count: number; sort_order: number; created_at: string; kind?: string; class_key?: string | null; class_label?: string | null }>>("watchlists.listWatchlists"),
create: (name: string) => trpcMutate<{ id: string; name: string; symbol_count: number; sort_order: number; created_at: string; kind?: string }>("watchlists.create", { name }),
delete: (name: string) => trpcMutate<{ deleted: boolean }>("watchlists.delete", { name }),
rename: (oldName: string, newName: string) => trpcMutate<{ renamed: boolean }>("watchlists.rename", { oldName, newName }),
reorder: (orders: { id: string; sort_order: number }[]) => trpcMutate<{ ok: boolean }>("watchlists.reorder", { orders }),
moveSymbol: (symbol: string, fromWatchlist?: string, toWatchlist?: string) =>
trpcMutate<{ moved: boolean }>("watchlists.moveSymbol", { symbol, ...(fromWatchlist ? { fromWatchlist } : {}), ...(toWatchlist ? { toWatchlist } : {}) }),
addSymbol: (symbol: string, watchlistName?: string, notes?: string) =>
trpcMutate<{ added: boolean }>("watchlists.addSymbol", { symbol, ...(watchlistName ? { watchlistName } : {}), ...(notes ? { notes } : {}) }),
removeSymbol: (symbol: string, watchlistName?: string) =>
@@ -638,7 +641,11 @@ export const api = {
accountsForSymbol: (symbol: string) => trpcQuery<Array<{id:string; handle:string; label:string}>>("x.accountsForSymbol", { symbol }),
},
admin: {
usersList: () => trpcQuery<Array<{ id: string; email: string; complexity: string; created_at: string; is_admin: number; status: string }>>("admin.usersList"),
usersList: () => trpcQuery<Array<{ id: string; email: string; complexity: string; created_at: string; is_admin: number; status: string; modules: string }>>("admin.usersList"),
setUserModules: (userId: string, modules: string[]) => trpcMutate<{ ok: boolean }>("admin.setUserModules", { userId, modules }),
disableUser: (userId: string) => trpcMutate<{ ok: boolean }>("admin.disableUser", { userId }),
enableUser: (userId: string) => trpcMutate<{ ok: boolean }>("admin.enableUser", { userId }),
deleteUser: (userId: string) => trpcMutate<{ ok: boolean }>("admin.deleteUser", { userId }),
userSessions: (userId: string) => trpcQuery<Array<{ id: string; user_id: string; expires_at: string; created_at: string }>>("admin.userSessions", { userId }),
resetPassword: (email: string, tempPassword: string) => trpcMutate<{ userId: string }>("admin.resetPassword", { email, tempPassword }),
queueHealth: () => trpcQuery<Array<{ source: string; status: string; count: number; last_attempt: string | null; retry_count: number; backoff_until: string | null }>>("admin.queueHealth"),
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { api, type AuthUser } from "@/lib/trpc";
export type ModuleKey = "research" | "execution" | "analytics" | "settings" | "admin";
export interface FeatureAccess {
user: AuthUser | null;
loading: boolean;
modules: ModuleKey[];
hasModule: (mod: ModuleKey) => boolean;
refresh: () => void;
}
const ALL_MODULES: ModuleKey[] = ["research", "execution", "analytics", "settings", "admin"];
const DEFAULT_MODULES: ModuleKey[] = ["research", "settings"];
export function useFeatureAccess(): FeatureAccess {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
const fetchUser = useCallback(() => {
setLoading(true);
api.auth
.me()
.then((u) => setUser(u))
.catch(() => setUser(null))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
fetchUser();
}, [fetchUser]);
const modules: ModuleKey[] = user?.modules
? (user.modules.filter((m) => ALL_MODULES.includes(m as ModuleKey)) as ModuleKey[])
: DEFAULT_MODULES;
const hasModule = useCallback(
(mod: ModuleKey) => modules.includes(mod),
[modules],
);
return { user, loading, modules, hasModule, refresh: fetchUser };
}
+31
View File
@@ -0,0 +1,31 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface ActiveWatchlistState {
activeWatchlist: string;
_hydrated: boolean;
setActiveWatchlist: (s: string) => void;
}
export const useActiveWatchlist = create<ActiveWatchlistState>()(
persist(
(set) => ({
activeWatchlist: "default",
_hydrated: false,
setActiveWatchlist: (s) => set({ activeWatchlist: s || "default" }),
}),
{
name: "iflow-active-watchlist",
partialize: (state) => ({ activeWatchlist: state.activeWatchlist }),
onRehydrateStorage: () => (state) => {
if (state) state._hydrated = true;
},
},
),
);
/** Returns the active watchlist only after hydration; null before to avoid flash of default. */
export function useActiveWatchlistGuarded(): string | null {
const { activeWatchlist, _hydrated } = useActiveWatchlist();
return _hydrated ? activeWatchlist : null;
}