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