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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user