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:
+6
-29
@@ -1,32 +1,9 @@
|
|||||||
# Handoff pointer (updated 2026-07-18)
|
# Handoff pointer (updated 2026-07-23)
|
||||||
|
|
||||||
This file previously held a 2026-06-30 multi-agent orchestrator snapshot. That snapshot is **obsolete**.
|
Full handoff saved externally — not in repo.
|
||||||
|
|
||||||
## Read these instead
|
|
||||||
|
|
||||||
1. **`docs/FUNCTIONAL_DESIGN.md`** — what is Live / Backend-ready / UI-local / Stub / Missing
|
|
||||||
2. **`docs/TECH_DESIGN.md`** — stack, tRPC map, engines, schema debt, test counts
|
|
||||||
3. **`CONTEXT.md`** — domain glossary (not status)
|
|
||||||
4. **Obsidian** `investor-flow.md` — session orientation wiki
|
|
||||||
|
|
||||||
## Active gap tasks (created 2026-07-18)
|
|
||||||
|
|
||||||
| Priority | Task |
|
|
||||||
|----------|------|
|
|
||||||
| P0 | `fix-macro-commentary-tests` |
|
|
||||||
| P0 | `wire-sizing-risk-trpc-m20` |
|
|
||||||
| P1 | `persist-trade-plan-execution-loop` |
|
|
||||||
| P1 | `frontend-api-client-coverage` |
|
|
||||||
| P1 | `wire-emotion-logger-backend` |
|
|
||||||
| P2 | `strategy-lab-backtest-ui` |
|
|
||||||
| P2 | `screener-ui-filter-strategy` |
|
|
||||||
| P2 | `thesis-monitor-derisking-ui` |
|
|
||||||
| P2 | `options-convexity-sleeve-ui` |
|
|
||||||
| P3 | `reports-research-note-ui` |
|
|
||||||
| P3 | `schema-strategies-dedupe` |
|
|
||||||
|
|
||||||
## Quick verify
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd app/server && npm test # expect 2 known MacroRegime failures until P0 fixed
|
|
||||||
```
|
```
|
||||||
|
Read /tmp/handoff-investor-flow-20260723.md for full context
|
||||||
|
```
|
||||||
|
|
||||||
|
Short version: Phase 5 alert UI done, queue errors fixed, automaton removed. See docs/ for as-built status.
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface UserRecord {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
is_admin: number;
|
is_admin: number;
|
||||||
status: string;
|
status: string;
|
||||||
|
modules: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QueueHealthRow {
|
export interface QueueHealthRow {
|
||||||
@@ -70,15 +71,74 @@ export function requireAdminOrOwner(
|
|||||||
if (!row || !row.is_admin) throw new NotOwnerError('Actor is not admin and does not own the target.');
|
if (!row || !row.is_admin) throw new NotOwnerError('Actor is not admin and does not own the target.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** list users — id, email, complexity, created_at, is_admin, status. Never returns pw_hash. */
|
/** list users — id, email, complexity, created_at, is_admin, status, modules. Never returns pw_hash. */
|
||||||
export function listUsers(db: DatabaseSync): UserRecord[] {
|
export function listUsers(db: DatabaseSync): UserRecord[] {
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
'SELECT id, email, complexity, created_at, is_admin, status FROM users ORDER BY created_at ASC',
|
'SELECT id, email, complexity, created_at, is_admin, status, modules FROM users ORDER BY created_at ASC',
|
||||||
)
|
)
|
||||||
.all() as unknown as UserRecord[];
|
.all() as unknown as UserRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VALID_MODULES = ['research', 'execution', 'analytics', 'settings'];
|
||||||
|
|
||||||
|
/** Set the allowed modules for a user. Validates module keys against the allowlist. */
|
||||||
|
export function setUserModules(
|
||||||
|
db: DatabaseSync,
|
||||||
|
actorId: string | null,
|
||||||
|
targetUserId: string,
|
||||||
|
modules: string[],
|
||||||
|
): { ok: boolean } {
|
||||||
|
requireAdminOrOwner(db, actorId, targetUserId);
|
||||||
|
const valid = modules.filter((m) => VALID_MODULES.includes(m));
|
||||||
|
const unique = [...new Set(valid)];
|
||||||
|
if (!unique.includes('settings')) unique.push('settings');
|
||||||
|
db.prepare('UPDATE users SET modules=? WHERE id=?').run(JSON.stringify(unique), targetUserId);
|
||||||
|
recordAudit(db, actorId ?? 'cli', 'users.set-modules', targetUserId, { modules: unique });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Disable a user account (status -> 'disabled'). Blocks login + protectedProcedure calls.
|
||||||
|
* Refuses to disable the actor's own account or the last remaining admin. */
|
||||||
|
export function disableUser(db: DatabaseSync, actorId: string | null, targetUserId: string): { ok: boolean } {
|
||||||
|
if (actorId === targetUserId) throw new NotOwnerError('Cannot disable your own account.');
|
||||||
|
const row = db.prepare('SELECT is_admin FROM users WHERE id=?').get(targetUserId) as { is_admin: number } | undefined;
|
||||||
|
if (!row) throw new Error('admin: target user not found');
|
||||||
|
if (row.is_admin) {
|
||||||
|
const adminCount = (db.prepare('SELECT COUNT(*) AS c FROM users WHERE is_admin=1').get() as { c: number }).c;
|
||||||
|
if (adminCount <= 1) throw new NotOwnerError('Cannot disable the last admin account.');
|
||||||
|
}
|
||||||
|
db.prepare('UPDATE users SET status=? WHERE id=?').run('disabled', targetUserId);
|
||||||
|
db.prepare('DELETE FROM sessions WHERE user_id=?').run(targetUserId);
|
||||||
|
recordAudit(db, actorId ?? 'cli', 'users.disable', targetUserId, null);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-enable a previously disabled account (status -> 'active'). */
|
||||||
|
export function enableUser(db: DatabaseSync, actorId: string | null, targetUserId: string): { ok: boolean } {
|
||||||
|
requireAdminOrOwner(db, actorId, targetUserId);
|
||||||
|
db.prepare('UPDATE users SET status=? WHERE id=?').run('active', targetUserId);
|
||||||
|
recordAudit(db, actorId ?? 'cli', 'users.enable', targetUserId, null);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Permanently delete a user and all owned data. Cascades via FK for most tables;
|
||||||
|
* halt_state has no FK so it is cleaned up explicitly. Refuses to delete the actor's
|
||||||
|
* own account or the last remaining admin. */
|
||||||
|
export function deleteUser(db: DatabaseSync, actorId: string | null, targetUserId: string): { ok: boolean } {
|
||||||
|
if (actorId === targetUserId) throw new NotOwnerError('Cannot delete your own account.');
|
||||||
|
const row = db.prepare('SELECT is_admin FROM users WHERE id=?').get(targetUserId) as { is_admin: number } | undefined;
|
||||||
|
if (!row) throw new Error('admin: target user not found');
|
||||||
|
if (row.is_admin) {
|
||||||
|
const adminCount = (db.prepare('SELECT COUNT(*) AS c FROM users WHERE is_admin=1').get() as { c: number }).c;
|
||||||
|
if (adminCount <= 1) throw new NotOwnerError('Cannot delete the last admin account.');
|
||||||
|
}
|
||||||
|
db.prepare('DELETE FROM halt_state WHERE user_id=?').run(targetUserId);
|
||||||
|
db.prepare('DELETE FROM users WHERE id=?').run(targetUserId);
|
||||||
|
recordAudit(db, actorId ?? 'cli', 'users.delete', targetUserId, null);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
/** Force a password reset: issues a fresh hash (the operator supplies a temp password;
|
/** Force a password reset: issues a fresh hash (the operator supplies a temp password;
|
||||||
* the caller MUST communicate it out-of-band). Never logs the plaintext. */
|
* the caller MUST communicate it out-of-band). Never logs the plaintext. */
|
||||||
export function resetPassword(
|
export function resetPassword(
|
||||||
|
|||||||
@@ -145,11 +145,20 @@ export const SYMBOL_CONTEXT_OVERRIDES: Record<string, SymbolContextOverride> = {
|
|||||||
peers: ['CRWV', 'NBIS', 'APLD', 'VRT', 'SMCI', 'ANET', 'EQIX', 'DLR'],
|
peers: ['CRWV', 'NBIS', 'APLD', 'VRT', 'SMCI', 'ANET', 'EQIX', 'DLR'],
|
||||||
sectorEtf: 'XLK',
|
sectorEtf: 'XLK',
|
||||||
sectorLabel: 'Technology (AI infrastructure)',
|
sectorLabel: 'Technology (AI infrastructure)',
|
||||||
themeEtf: 'SMH',
|
themeEtf: null,
|
||||||
themeLabel: 'AI / hyperscaler infrastructure',
|
themeLabel: 'AI / hyperscaler infrastructure',
|
||||||
classificationNote:
|
classificationNote:
|
||||||
'Vendor feeds often label IREN as Financial Services / Capital Markets (crypto-era bucket). Operating comparison uses AI infrastructure / GPU-cloud peers (e.g. CRWV, NBIS), not banks or pure crypto miners.',
|
'Vendor feeds often label IREN as Financial Services / Capital Markets (crypto-era bucket). Operating comparison uses AI infrastructure / GPU-cloud peers (e.g. CRWV, NBIS), not banks or pure crypto miners.',
|
||||||
},
|
},
|
||||||
|
SLNH: {
|
||||||
|
peers: ['IREN', 'CIFR', 'WULF', 'CORZ', 'APLD'],
|
||||||
|
sectorEtf: 'XLK',
|
||||||
|
sectorLabel: 'Technology (green data centers)',
|
||||||
|
themeEtf: null,
|
||||||
|
themeLabel: null,
|
||||||
|
classificationNote:
|
||||||
|
'Vendor feeds label SLNH as Financial Services; operating profile is green-energy data centers and crypto-mining infrastructure.',
|
||||||
|
},
|
||||||
CRWV: {
|
CRWV: {
|
||||||
peers: ['NBIS', 'IREN', 'APLD', 'VRT', 'SMCI', 'ANET', 'EQIX', 'DLR'],
|
peers: ['NBIS', 'IREN', 'APLD', 'VRT', 'SMCI', 'ANET', 'EQIX', 'DLR'],
|
||||||
sectorEtf: 'XLK',
|
sectorEtf: 'XLK',
|
||||||
@@ -168,8 +177,8 @@ export const SYMBOL_CONTEXT_OVERRIDES: Record<string, SymbolContextOverride> = {
|
|||||||
},
|
},
|
||||||
CIFR: {
|
CIFR: {
|
||||||
peers: ['RIOT', 'MARA', 'CLSK', 'WULF', 'CORZ', 'HUT', 'BITF', 'IREN'],
|
peers: ['RIOT', 'MARA', 'CLSK', 'WULF', 'CORZ', 'HUT', 'BITF', 'IREN'],
|
||||||
sectorEtf: 'XLE',
|
sectorEtf: 'XLK',
|
||||||
sectorLabel: 'Energy-linked digital assets',
|
sectorLabel: 'Technology (digital assets)',
|
||||||
themeEtf: null,
|
themeEtf: null,
|
||||||
themeLabel: 'Digital-asset mining',
|
themeLabel: 'Digital-asset mining',
|
||||||
classificationNote:
|
classificationNote:
|
||||||
@@ -177,24 +186,24 @@ export const SYMBOL_CONTEXT_OVERRIDES: Record<string, SymbolContextOverride> = {
|
|||||||
},
|
},
|
||||||
RIOT: {
|
RIOT: {
|
||||||
peers: [...CRYPTO_MINING_PEERS],
|
peers: [...CRYPTO_MINING_PEERS],
|
||||||
sectorEtf: 'XLE',
|
sectorEtf: 'XLK',
|
||||||
sectorLabel: 'Energy-linked digital assets',
|
sectorLabel: 'Technology (digital assets)',
|
||||||
themeEtf: null,
|
themeEtf: null,
|
||||||
themeLabel: 'Digital-asset mining',
|
themeLabel: 'Digital-asset mining',
|
||||||
classificationNote: 'Yahoo may list under Financials; peer set is crypto miners.',
|
classificationNote: 'Yahoo may list under Financials; peer set is crypto miners.',
|
||||||
},
|
},
|
||||||
MARA: {
|
MARA: {
|
||||||
peers: [...CRYPTO_MINING_PEERS],
|
peers: [...CRYPTO_MINING_PEERS],
|
||||||
sectorEtf: 'XLE',
|
sectorEtf: 'XLK',
|
||||||
sectorLabel: 'Energy-linked digital assets',
|
sectorLabel: 'Technology (digital assets)',
|
||||||
themeEtf: null,
|
themeEtf: null,
|
||||||
themeLabel: 'Digital-asset mining',
|
themeLabel: 'Digital-asset mining',
|
||||||
classificationNote: 'Yahoo may list under Financials; peer set is crypto miners.',
|
classificationNote: 'Yahoo may list under Financials; peer set is crypto miners.',
|
||||||
},
|
},
|
||||||
CLSK: {
|
CLSK: {
|
||||||
peers: [...CRYPTO_MINING_PEERS],
|
peers: [...CRYPTO_MINING_PEERS],
|
||||||
sectorEtf: 'XLE',
|
sectorEtf: 'XLK',
|
||||||
sectorLabel: 'Energy-linked digital assets',
|
sectorLabel: 'Technology (digital assets)',
|
||||||
themeEtf: null,
|
themeEtf: null,
|
||||||
themeLabel: 'Digital-asset mining',
|
themeLabel: 'Digital-asset mining',
|
||||||
classificationNote: 'Yahoo may list under Financials; peer set is crypto miners.',
|
classificationNote: 'Yahoo may list under Financials; peer set is crypto miners.',
|
||||||
@@ -203,7 +212,7 @@ export const SYMBOL_CONTEXT_OVERRIDES: Record<string, SymbolContextOverride> = {
|
|||||||
peers: [...CRYPTO_MINING_PEERS],
|
peers: [...CRYPTO_MINING_PEERS],
|
||||||
sectorEtf: 'XLK',
|
sectorEtf: 'XLK',
|
||||||
sectorLabel: 'Technology (compute infrastructure)',
|
sectorLabel: 'Technology (compute infrastructure)',
|
||||||
themeEtf: 'SMH',
|
themeEtf: null,
|
||||||
themeLabel: 'AI / HPC infrastructure',
|
themeLabel: 'AI / HPC infrastructure',
|
||||||
classificationNote: 'Mining + HPC/AI data-center transition; not a traditional financial.',
|
classificationNote: 'Mining + HPC/AI data-center transition; not a traditional financial.',
|
||||||
},
|
},
|
||||||
@@ -211,7 +220,7 @@ export const SYMBOL_CONTEXT_OVERRIDES: Record<string, SymbolContextOverride> = {
|
|||||||
peers: [...CRYPTO_MINING_PEERS],
|
peers: [...CRYPTO_MINING_PEERS],
|
||||||
sectorEtf: 'XLK',
|
sectorEtf: 'XLK',
|
||||||
sectorLabel: 'Technology (compute infrastructure)',
|
sectorLabel: 'Technology (compute infrastructure)',
|
||||||
themeEtf: 'SMH',
|
themeEtf: null,
|
||||||
themeLabel: 'AI / HPC infrastructure',
|
themeLabel: 'AI / HPC infrastructure',
|
||||||
classificationNote: 'Mining + AI hosting; not a traditional financial.',
|
classificationNote: 'Mining + AI hosting; not a traditional financial.',
|
||||||
},
|
},
|
||||||
@@ -219,7 +228,7 @@ export const SYMBOL_CONTEXT_OVERRIDES: Record<string, SymbolContextOverride> = {
|
|||||||
peers: ['IREN', 'CIFR', 'CRWV', 'NBIS', 'EQIX', 'DLR', 'VRT', 'SMCI'],
|
peers: ['IREN', 'CIFR', 'CRWV', 'NBIS', 'EQIX', 'DLR', 'VRT', 'SMCI'],
|
||||||
sectorEtf: 'XLK',
|
sectorEtf: 'XLK',
|
||||||
sectorLabel: 'Technology (data centers)',
|
sectorLabel: 'Technology (data centers)',
|
||||||
themeEtf: 'SMH',
|
themeEtf: null,
|
||||||
themeLabel: 'AI / HPC infrastructure',
|
themeLabel: 'AI / HPC infrastructure',
|
||||||
classificationNote: 'AI data-center developer/operator peer set.',
|
classificationNote: 'AI data-center developer/operator peer set.',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ function freshDb(): DatabaseSync {
|
|||||||
// Apply the watchlists table.
|
// Apply the watchlists table.
|
||||||
db.exec(SCHEMA_SQL);
|
db.exec(SCHEMA_SQL);
|
||||||
|
|
||||||
// The repository's upsert uses ON CONFLICT(owner_id, name), so we need a
|
// The repository's upsert uses ON CONFLICT(owner_id, kind, name), so we need a
|
||||||
// unique index on that column pair (the schema only has PRIMARY KEY on `id`).
|
// unique index on that column triple (the schema only has PRIMARY KEY on `id`).
|
||||||
db.exec(
|
db.exec(
|
||||||
'CREATE UNIQUE INDEX IF NOT EXISTS uq_watchlists_owner_name ON watchlists(owner_id, name);',
|
'CREATE UNIQUE INDEX IF NOT EXISTS uq_watchlists_owner_kind_name ON watchlists(owner_id, kind, name);',
|
||||||
);
|
);
|
||||||
// Seed a users row so the FK constraint on watchlists.owner_id doesn't fire.
|
// Seed a users row so the FK constraint on watchlists.owner_id doesn't fire.
|
||||||
db.prepare(
|
db.prepare(
|
||||||
@@ -147,13 +147,13 @@ test('removeSymbol returns false when symbol is not in watchlist', () => {
|
|||||||
test('removeSymbol cleans up the watchlist when last symbol is removed', () => {
|
test('removeSymbol cleans up the watchlist when last symbol is removed', () => {
|
||||||
const db = freshDb();
|
const db = freshDb();
|
||||||
|
|
||||||
addSymbol(db, 'user_1', 'AAPL');
|
addSymbol(db, 'user_1', 'AAPL', undefined, 'my-list');
|
||||||
const removed = removeSymbol(db, 'user_1', 'AAPL');
|
const removed = removeSymbol(db, 'user_1', 'AAPL', 'my-list');
|
||||||
|
|
||||||
assert.equal(removed, true);
|
assert.equal(removed, true);
|
||||||
|
|
||||||
// The watchlist row should be deleted (cleaned up).
|
// The watchlist row should be deleted (cleaned up) for non-default lists.
|
||||||
const rows = db.prepare('SELECT * FROM watchlists WHERE owner_id = ?').all('user_1') as Array<{ name: string }>;
|
const rows = db.prepare("SELECT * FROM watchlists WHERE owner_id = ? AND name = 'my-list'").all('user_1') as Array<{ name: string }>;
|
||||||
assert.equal(rows.length, 0);
|
assert.equal(rows.length, 0);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
|
|||||||
@@ -64,6 +64,13 @@ function runMigrations(db: DatabaseSync): void {
|
|||||||
`ALTER TABLE users ADD COLUMN backup_codes_hashed TEXT`,
|
`ALTER TABLE users ADD COLUMN backup_codes_hashed TEXT`,
|
||||||
`ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0`,
|
`ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0`,
|
||||||
`ALTER TABLE rotation_state ADD COLUMN signal_since TEXT`,
|
`ALTER TABLE rotation_state ADD COLUMN signal_since TEXT`,
|
||||||
|
`ALTER TABLE users ADD COLUMN modules TEXT NOT NULL DEFAULT '["research","settings"]'`,
|
||||||
|
`UPDATE users SET modules='["research","execution","analytics","settings"]' WHERE modules='["research","settings"]'`,
|
||||||
|
`ALTER TABLE watchlists ADD COLUMN kind TEXT NOT NULL DEFAULT 'user'`,
|
||||||
|
`ALTER TABLE watchlists ADD COLUMN class_key TEXT`,
|
||||||
|
`ALTER TABLE watchlists ADD COLUMN class_label TEXT`,
|
||||||
|
`DROP INDEX IF EXISTS idx_watchlists_owner_name`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_watchlists_owner_kind_name ON watchlists(owner_id, kind, name)`,
|
||||||
`CREATE TABLE IF NOT EXISTS rotation_state (
|
`CREATE TABLE IF NOT EXISTS rotation_state (
|
||||||
id TEXT PRIMARY KEY DEFAULT 'singleton',
|
id TEXT PRIMARY KEY DEFAULT 'singleton',
|
||||||
signal TEXT NOT NULL DEFAULT 'none',
|
signal TEXT NOT NULL DEFAULT 'none',
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
convexity_posture TEXT NOT NULL DEFAULT 'off', -- off|covered_income|cash_secured_entry|insurance_sleeve|leaps_conviction
|
convexity_posture TEXT NOT NULL DEFAULT 'off', -- off|covered_income|cash_secured_entry|insurance_sleeve|leaps_conviction
|
||||||
backup_codes_hashed TEXT, -- JSON array of scrypt-hashed backup codes (slice 2)
|
backup_codes_hashed TEXT, -- JSON array of scrypt-hashed backup codes (slice 2)
|
||||||
status TEXT NOT NULL DEFAULT 'active', -- active|pending_approval|rejected (user provisioning)
|
status TEXT NOT NULL DEFAULT 'active', -- active|pending_approval|rejected (user provisioning)
|
||||||
|
modules TEXT NOT NULL DEFAULT '["research","settings"]', -- JSON array of allowed module keys
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -253,16 +254,17 @@ CREATE TABLE IF NOT EXISTS symbol_demand (
|
|||||||
|
|
||||||
-- ===== Tier C — Per-user (ownerId NOT NULL) =====
|
-- ===== Tier C — Per-user (ownerId NOT NULL) =====
|
||||||
CREATE TABLE IF NOT EXISTS watchlists (
|
CREATE TABLE IF NOT EXISTS watchlists (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
symbols TEXT NOT NULL, -- JSON array
|
symbols TEXT NOT NULL, -- JSON array
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'user', -- 'user' | 'sector' | 'thematic' | 'style' | 'region'
|
||||||
|
class_key TEXT, -- ETF symbol for derived lists (XLK, SMH...); NULL for user
|
||||||
|
class_label TEXT -- human label synced with market outlook; NULL for user
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_watchlists_owner_name ON watchlists(owner_id, name);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS portfolio_holdings (
|
CREATE TABLE IF NOT EXISTS portfolio_holdings (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { DatabaseSync } from 'node:sqlite';
|
import type { DatabaseSync } from 'node:sqlite';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { resolveBusinessContext } from '../analysis/tickerContext.ts';
|
||||||
|
|
||||||
export interface WatchlistEntry {
|
export interface WatchlistEntry {
|
||||||
symbol: string;
|
symbol: string;
|
||||||
@@ -13,6 +14,9 @@ export interface WatchlistMeta {
|
|||||||
symbol_count: number;
|
symbol_count: number;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
kind?: string;
|
||||||
|
class_key?: string | null;
|
||||||
|
class_label?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WatchlistRow {
|
interface WatchlistRow {
|
||||||
@@ -22,34 +26,70 @@ interface WatchlistRow {
|
|||||||
symbols: string;
|
symbols: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
|
kind: string;
|
||||||
|
class_key: string | null;
|
||||||
|
class_label: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mapping from ETF symbol → market-outlook display label (kept in sync with marketRotationRs.ts).
|
||||||
|
const ETF_TO_LABEL: Record<string, string> = {
|
||||||
|
XLK: 'Technology',
|
||||||
|
XLF: 'Financials',
|
||||||
|
XLE: 'Energy',
|
||||||
|
XLI: 'Industrials',
|
||||||
|
XLV: 'Healthcare',
|
||||||
|
XLY: 'Consumer Discretionary',
|
||||||
|
XLP: 'Consumer Staples',
|
||||||
|
XLU: 'Utilities',
|
||||||
|
XLRE: 'Real Estate',
|
||||||
|
XLC: 'Communication Services',
|
||||||
|
XLB: 'Materials',
|
||||||
|
SMH: 'Semiconductors',
|
||||||
|
XBI: 'Biotech',
|
||||||
|
IWM: 'Style: Small',
|
||||||
|
EFA: 'Region: Intl',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALLOWED_SECTOR_ETFS = new Set(Object.keys(ETF_TO_LABEL).filter((k) => k.startsWith('XL')));
|
||||||
|
const ALLOWED_THEME_ETFS = new Set(['SMH', 'XBI']);
|
||||||
|
const ALLOWED_STYLE_ETFS = new Set(['IWM']);
|
||||||
|
const ALLOWED_REGION_ETFS = new Set(['EFA']);
|
||||||
|
|
||||||
|
const US_EXCHANGES = new Set(['NASDAQ', 'NYSE', 'NYSEAMERICAN', 'NYSEARCA', 'BATS', 'NYSEMKT', 'OTC', 'PNK']);
|
||||||
|
|
||||||
function stmts(db: DatabaseSync) {
|
function stmts(db: DatabaseSync) {
|
||||||
return {
|
return {
|
||||||
upsert: db.prepare(
|
upsert: db.prepare(
|
||||||
`INSERT INTO watchlists (id, owner_id, name, symbols, created_at, sort_order)
|
`INSERT INTO watchlists (id, owner_id, name, symbols, created_at, sort_order, kind, class_key, class_label)
|
||||||
VALUES (?, ?, ?, ?, ?, COALESCE(?, 0))
|
VALUES (?, ?, ?, ?, ?, COALESCE(?, 0), ?, ?, ?)
|
||||||
ON CONFLICT(owner_id, name) DO UPDATE SET
|
ON CONFLICT(owner_id, kind, name) DO UPDATE SET
|
||||||
symbols = excluded.symbols,
|
symbols = excluded.symbols,
|
||||||
sort_order = excluded.sort_order`,
|
sort_order = excluded.sort_order,
|
||||||
|
class_key = excluded.class_key,
|
||||||
|
class_label = excluded.class_label`,
|
||||||
),
|
),
|
||||||
selectByOwnerAndName: db.prepare(
|
selectByOwnerKindName: db.prepare(
|
||||||
`SELECT id, owner_id, name, symbols, created_at, sort_order
|
`SELECT id, owner_id, name, symbols, created_at, sort_order, kind, class_key, class_label
|
||||||
FROM watchlists WHERE owner_id = ? AND name = ?`,
|
FROM watchlists WHERE owner_id = ? AND kind = ? AND name = ?`,
|
||||||
),
|
),
|
||||||
selectByOwner: db.prepare(
|
selectByOwner: db.prepare(
|
||||||
`SELECT id, owner_id, name, symbols, created_at, sort_order
|
`SELECT id, owner_id, name, symbols, created_at, sort_order, kind, class_key, class_label
|
||||||
FROM watchlists WHERE owner_id = ?
|
FROM watchlists WHERE owner_id = ?
|
||||||
|
ORDER BY kind DESC, sort_order ASC, created_at ASC`,
|
||||||
|
),
|
||||||
|
selectByOwnerKind: db.prepare(
|
||||||
|
`SELECT id, owner_id, name, symbols, created_at, sort_order, kind, class_key, class_label
|
||||||
|
FROM watchlists WHERE owner_id = ? AND kind = ?
|
||||||
ORDER BY sort_order ASC, created_at ASC`,
|
ORDER BY sort_order ASC, created_at ASC`,
|
||||||
),
|
),
|
||||||
deleteByOwnerAndName: db.prepare(
|
deleteByOwnerKindName: db.prepare(
|
||||||
`DELETE FROM watchlists WHERE owner_id = ? AND name = ?`,
|
`DELETE FROM watchlists WHERE owner_id = ? AND kind = ? AND name = ?`,
|
||||||
),
|
),
|
||||||
updateSymbols: db.prepare(
|
updateSymbols: db.prepare(
|
||||||
`UPDATE watchlists SET symbols = ? WHERE id = ? AND owner_id = ?`,
|
`UPDATE watchlists SET symbols = ? WHERE id = ? AND owner_id = ?`,
|
||||||
),
|
),
|
||||||
selectById: db.prepare(
|
selectById: db.prepare(
|
||||||
`SELECT id, owner_id, name, symbols, created_at, sort_order
|
`SELECT id, owner_id, name, symbols, created_at, sort_order, kind, class_key, class_label
|
||||||
FROM watchlists WHERE id = ? AND owner_id = ?`,
|
FROM watchlists WHERE id = ? AND owner_id = ?`,
|
||||||
),
|
),
|
||||||
deleteById: db.prepare(
|
deleteById: db.prepare(
|
||||||
@@ -59,9 +99,18 @@ function stmts(db: DatabaseSync) {
|
|||||||
`UPDATE watchlists SET sort_order = ? WHERE id = ? AND owner_id = ?`,
|
`UPDATE watchlists SET sort_order = ? WHERE id = ? AND owner_id = ?`,
|
||||||
),
|
),
|
||||||
selectAllByOwner: db.prepare(
|
selectAllByOwner: db.prepare(
|
||||||
`SELECT id, owner_id, name, symbols, created_at, sort_order
|
`SELECT id, owner_id, name, symbols, created_at, sort_order, kind, class_key, class_label
|
||||||
FROM watchlists WHERE owner_id = ?
|
FROM watchlists WHERE owner_id = ?
|
||||||
ORDER BY sort_order ASC, created_at ASC`,
|
ORDER BY kind DESC, sort_order ASC, created_at ASC`,
|
||||||
|
),
|
||||||
|
deleteSystemByClassKey: db.prepare(
|
||||||
|
`DELETE FROM watchlists WHERE owner_id = ? AND kind != 'user' AND class_key = ?`,
|
||||||
|
),
|
||||||
|
upsertSystem: db.prepare(
|
||||||
|
`INSERT INTO watchlists (id, owner_id, name, symbols, created_at, sort_order, kind, class_key, class_label)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)
|
||||||
|
ON CONFLICT(owner_id, kind, name) DO UPDATE SET
|
||||||
|
symbols = excluded.symbols, class_key = excluded.class_key, class_label = excluded.class_label`,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -76,7 +125,7 @@ export function addSymbol(
|
|||||||
const s = stmts(db);
|
const s = stmts(db);
|
||||||
const upper = symbol.toUpperCase();
|
const upper = symbol.toUpperCase();
|
||||||
|
|
||||||
const existing = readWatchlistRaw(db, userId, watchlistName);
|
const existing = readWatchlistRaw(db, userId, watchlistName, 'user');
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const alreadyExists = existing.symbols.some((sym) => {
|
const alreadyExists = existing.symbols.some((sym) => {
|
||||||
@@ -92,14 +141,14 @@ export function addSymbol(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
s.upsert.run(existing.id, userId, watchlistName, JSON.stringify(existing.symbols), now, 0);
|
s.upsert.run(existing.id, userId, watchlistName, JSON.stringify(existing.symbols), now, 0, 'user', null, null);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const serialized = notes ? [{ symbol: upper, notes }] : [upper];
|
const serialized = notes ? [{ symbol: upper, notes }] : [upper];
|
||||||
const id = generateId();
|
const id = generateId();
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
s.upsert.run(id, userId, watchlistName, JSON.stringify(serialized), now, 0);
|
s.upsert.run(id, userId, watchlistName, JSON.stringify(serialized), now, 0, 'user', null, null);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +161,7 @@ export function removeSymbol(
|
|||||||
const s = stmts(db);
|
const s = stmts(db);
|
||||||
const upper = symbol.toUpperCase();
|
const upper = symbol.toUpperCase();
|
||||||
|
|
||||||
const existing = readWatchlistRaw(db, userId, watchlistName);
|
const existing = readWatchlistRaw(db, userId, watchlistName, 'user');
|
||||||
if (!existing) return false;
|
if (!existing) return false;
|
||||||
|
|
||||||
const before = existing.symbols.length;
|
const before = existing.symbols.length;
|
||||||
@@ -126,7 +175,11 @@ export function removeSymbol(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (remaining.length === 0) {
|
if (remaining.length === 0) {
|
||||||
s.deleteByOwnerAndName.run(userId, watchlistName);
|
if (watchlistName === 'default') {
|
||||||
|
s.updateSymbols.run('[]', existing.id, userId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
s.deleteByOwnerKindName.run(userId, 'user', watchlistName);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,12 +187,52 @@ export function removeSymbol(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function moveSymbol(
|
||||||
|
db: DatabaseSync,
|
||||||
|
userId: string,
|
||||||
|
symbol: string,
|
||||||
|
fromWatchlist: string,
|
||||||
|
toWatchlist: string,
|
||||||
|
): boolean {
|
||||||
|
if (fromWatchlist === toWatchlist) return false;
|
||||||
|
const sym = symbol.toUpperCase();
|
||||||
|
const src = readWatchlistRaw(db, userId, fromWatchlist, 'user');
|
||||||
|
if (!src) return false;
|
||||||
|
const dst = readWatchlistRaw(db, userId, toWatchlist, 'user');
|
||||||
|
if (!dst) return false;
|
||||||
|
|
||||||
|
const srcRemaining = src.symbols.filter((s) => {
|
||||||
|
const sStr = typeof s === 'string' ? s : s.symbol;
|
||||||
|
return sStr !== sym;
|
||||||
|
});
|
||||||
|
|
||||||
|
const alreadyInTarget = dst.symbols.some((s) => {
|
||||||
|
const sStr = typeof s === 'string' ? s : s.symbol;
|
||||||
|
return sStr === sym;
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
if (srcRemaining.length === 0 && fromWatchlist !== 'default') {
|
||||||
|
stmts(db).deleteByOwnerKindName.run(userId, 'user', fromWatchlist);
|
||||||
|
} else {
|
||||||
|
stmts(db).updateSymbols.run(JSON.stringify(srcRemaining), src.id, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alreadyInTarget) return true;
|
||||||
|
|
||||||
|
const mergedSymbols = [...dst.symbols, sym];
|
||||||
|
stmts(db).updateSymbols.run(JSON.stringify(mergedSymbols), dst.id, userId);
|
||||||
|
materializeClassificationWatchlists(db, userId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export function listSymbols(
|
export function listSymbols(
|
||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
userId: string,
|
userId: string,
|
||||||
): WatchlistEntry[] {
|
): WatchlistEntry[] {
|
||||||
const s = stmts(db);
|
const s = stmts(db);
|
||||||
const rows = s.selectByOwner.all(userId) as unknown as WatchlistRow[];
|
const rows = s.selectByOwnerKind.all(userId, 'user') as unknown as WatchlistRow[];
|
||||||
|
|
||||||
const entries: WatchlistEntry[] = [];
|
const entries: WatchlistEntry[] = [];
|
||||||
|
|
||||||
@@ -163,8 +256,9 @@ export function listSymbols(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function listWatchlists(db: DatabaseSync, userId: string): WatchlistMeta[] {
|
export function listWatchlists(db: DatabaseSync, userId: string): WatchlistMeta[] {
|
||||||
|
materializeClassificationWatchlists(db, userId);
|
||||||
const s = stmts(db);
|
const s = stmts(db);
|
||||||
const rows = s.selectByOwner.all(userId) as unknown as WatchlistRow[];
|
const rows = s.selectAllByOwner.all(userId) as unknown as WatchlistRow[];
|
||||||
return rows.map((row) => {
|
return rows.map((row) => {
|
||||||
const parsed = safeParseSymbols(row.symbols);
|
const parsed = safeParseSymbols(row.symbols);
|
||||||
return {
|
return {
|
||||||
@@ -173,6 +267,9 @@ export function listWatchlists(db: DatabaseSync, userId: string): WatchlistMeta[
|
|||||||
symbol_count: parsed.length,
|
symbol_count: parsed.length,
|
||||||
sort_order: row.sort_order,
|
sort_order: row.sort_order,
|
||||||
created_at: row.created_at,
|
created_at: row.created_at,
|
||||||
|
kind: row.kind,
|
||||||
|
class_key: row.class_key,
|
||||||
|
class_label: row.class_label,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -187,15 +284,16 @@ export function createWatchlist(
|
|||||||
const id = generateId();
|
const id = generateId();
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const serialized = JSON.stringify(symbols ?? []);
|
const serialized = JSON.stringify(symbols ?? []);
|
||||||
s.upsert.run(id, userId, name, serialized, now, 0);
|
s.upsert.run(id, userId, name, serialized, now, 0, 'user', null, null);
|
||||||
return { id, name, symbol_count: (symbols ?? []).length, sort_order: 0, created_at: now };
|
return { id, name, symbol_count: (symbols ?? []).length, sort_order: 0, created_at: now, kind: 'user' };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteWatchlist(db: DatabaseSync, userId: string, name: string): boolean {
|
export function deleteWatchlist(db: DatabaseSync, userId: string, name: string): boolean {
|
||||||
|
if (name === 'default') return false;
|
||||||
const s = stmts(db);
|
const s = stmts(db);
|
||||||
const existing = readWatchlistRaw(db, userId, name);
|
const existing = readWatchlistRaw(db, userId, name, 'user');
|
||||||
if (!existing) return false;
|
if (!existing) return false;
|
||||||
s.deleteByOwnerAndName.run(userId, name);
|
s.deleteByOwnerKindName.run(userId, 'user', name);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,14 +303,15 @@ export function renameWatchlist(
|
|||||||
oldName: string,
|
oldName: string,
|
||||||
newName: string,
|
newName: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
|
if (oldName === 'default') return false;
|
||||||
const s = stmts(db);
|
const s = stmts(db);
|
||||||
const existing = readWatchlistRaw(db, userId, oldName);
|
const existing = readWatchlistRaw(db, userId, oldName, 'user');
|
||||||
if (!existing) return false;
|
if (!existing) return false;
|
||||||
const conflict = readWatchlistRaw(db, userId, newName);
|
const conflict = readWatchlistRaw(db, userId, newName, 'user');
|
||||||
if (conflict) return false;
|
if (conflict) return false;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
s.upsert.run(existing.id, userId, newName, JSON.stringify(existing.symbols), now, existing.sort_order);
|
s.upsert.run(existing.id, userId, newName, JSON.stringify(existing.symbols), now, existing.sort_order, 'user', null, null);
|
||||||
s.deleteByOwnerAndName.run(userId, oldName);
|
s.deleteByOwnerKindName.run(userId, 'user', oldName);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,7 +331,7 @@ export function getSymbolsInWatchlist(
|
|||||||
userId: string,
|
userId: string,
|
||||||
watchlistName: string = 'default',
|
watchlistName: string = 'default',
|
||||||
): string[] {
|
): string[] {
|
||||||
const existing = readWatchlistRaw(db, userId, watchlistName);
|
const existing = readWatchlistRaw(db, userId, watchlistName, 'user');
|
||||||
if (!existing) return [];
|
if (!existing) return [];
|
||||||
return existing.symbols.map((sym) => {
|
return existing.symbols.map((sym) => {
|
||||||
if (typeof sym === 'string') return sym;
|
if (typeof sym === 'string') return sym;
|
||||||
@@ -258,9 +357,19 @@ export function listSymbolsByWatchlist(
|
|||||||
userId: string,
|
userId: string,
|
||||||
watchlistName: string,
|
watchlistName: string,
|
||||||
): WatchlistEntry[] {
|
): WatchlistEntry[] {
|
||||||
const raw = readWatchlistRaw(db, userId, watchlistName);
|
// Try user lists first, then fall back to system lists (for viewing sector lists).
|
||||||
if (!raw) return [];
|
let raw = readWatchlistRaw(db, userId, watchlistName, 'user');
|
||||||
|
if (!raw) {
|
||||||
|
const systemRow = db
|
||||||
|
.prepare(`SELECT id, symbols, kind, class_key, class_label FROM watchlists WHERE owner_id = ? AND kind != 'user' AND name = ?`)
|
||||||
|
.get(userId, watchlistName) as { id: string; symbols: string; kind: string; class_key: string | null; class_label: string | null } | undefined;
|
||||||
|
if (!systemRow) return [];
|
||||||
|
const parsed = safeParseSymbols(systemRow.symbols);
|
||||||
|
return parsed.map((item) => {
|
||||||
|
if (typeof item === 'string') return { symbol: item, added_at: '' };
|
||||||
|
return { symbol: (item.symbol ?? '').toUpperCase(), notes: (item as Record<string, unknown>).notes as string | null ?? null, added_at: '' };
|
||||||
|
});
|
||||||
|
}
|
||||||
const entries: WatchlistEntry[] = [];
|
const entries: WatchlistEntry[] = [];
|
||||||
for (const item of raw.symbols) {
|
for (const item of raw.symbols) {
|
||||||
if (typeof item === 'string') {
|
if (typeof item === 'string') {
|
||||||
@@ -281,10 +390,13 @@ function readWatchlistRaw(
|
|||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
userId: string,
|
userId: string,
|
||||||
name: string,
|
name: string,
|
||||||
|
kind?: string,
|
||||||
): { id: string; symbols: Array<string | { symbol: string; notes?: string }>; sort_order: number } | null {
|
): { id: string; symbols: Array<string | { symbol: string; notes?: string }>; sort_order: number } | null {
|
||||||
const rows = stmts(db).selectByOwnerAndName.all(userId, name) as unknown as WatchlistRow[];
|
const s = stmts(db);
|
||||||
|
const rows = kind
|
||||||
|
? s.selectByOwnerKindName.all(userId, kind, name) as unknown as WatchlistRow[]
|
||||||
|
: s.selectByOwner.all(userId).filter((r) => r.name === name) as unknown as WatchlistRow[];
|
||||||
if (rows.length === 0) return null;
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
const rawSymbols = safeParseSymbols(String(row.symbols));
|
const rawSymbols = safeParseSymbols(String(row.symbols));
|
||||||
return { id: row.id, symbols: rawSymbols as Array<string | { symbol: string; notes?: string }>, sort_order: row.sort_order };
|
return { id: row.id, symbols: rawSymbols as Array<string | { symbol: string; notes?: string }>, sort_order: row.sort_order };
|
||||||
@@ -293,3 +405,86 @@ function readWatchlistRaw(
|
|||||||
function generateId(): string {
|
function generateId(): string {
|
||||||
return randomUUID();
|
return randomUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Materialize system watchlists (sector, thematic, style, region) from user's symbol metadata. Idempotent; auto-prunes empty lists. */
|
||||||
|
export function materializeClassificationWatchlists(db: DatabaseSync, userId: string): void {
|
||||||
|
const s = stmts(db);
|
||||||
|
|
||||||
|
// 1. Gather all unique symbols across user's lists (kind='user').
|
||||||
|
const userRows = s.selectByOwnerKind.all(userId, 'user') as unknown as WatchlistRow[];
|
||||||
|
const symbolSet = new Set<string>();
|
||||||
|
for (const row of userRows) {
|
||||||
|
for (const item of safeParseSymbols(row.symbols)) {
|
||||||
|
const sym = typeof item === 'string' ? item.toUpperCase() : (item.symbol ?? '').toUpperCase();
|
||||||
|
if (sym) symbolSet.add(sym);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (symbolSet.size === 0) {
|
||||||
|
// Prune all system lists for this user.
|
||||||
|
db.prepare(`DELETE FROM watchlists WHERE owner_id = ? AND kind != 'user'`).run(userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch symbol metadata from symbols table (market_cap/description not in schema).
|
||||||
|
const symbolMeta = new Map<string, { sector: string | null; industry: string | null; exchange: string | null }>();
|
||||||
|
for (const sym of symbolSet) {
|
||||||
|
const row = db.prepare('SELECT sector, industry, exchange FROM symbols WHERE symbol = ?').get(sym) as
|
||||||
|
| { sector: string | null; industry: string | null; exchange: string | null }
|
||||||
|
| undefined;
|
||||||
|
if (row) symbolMeta.set(sym, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Resolve classifications per symbol.
|
||||||
|
const groups = new Map<string, Set<string>>(); // class_key -> symbol set
|
||||||
|
for (const sym of symbolSet) {
|
||||||
|
const meta = symbolMeta.get(sym);
|
||||||
|
if (!meta) continue;
|
||||||
|
|
||||||
|
const ctx = resolveBusinessContext({
|
||||||
|
symbol: sym,
|
||||||
|
sector: meta.sector ?? null,
|
||||||
|
industry: meta.industry ?? null,
|
||||||
|
description: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sector
|
||||||
|
if (ctx.sectorEtf && ALLOWED_SECTOR_ETFS.has(ctx.sectorEtf)) {
|
||||||
|
groups.set(ctx.sectorEtf, (groups.get(ctx.sectorEtf) ?? new Set()).add(sym));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thematic (SMH=Semiconductors, XBI=Biotech) — only if industry actually matches the theme.
|
||||||
|
if (ctx.themeEtf && ALLOWED_THEME_ETFS.has(ctx.themeEtf)) {
|
||||||
|
const industry = (meta.industry ?? '').toLowerCase();
|
||||||
|
if ((ctx.themeEtf === 'SMH' && /semiconductor|chip|gpu|foundry|fabless|wafer|memory|processors?/.test(industry)) ||
|
||||||
|
(ctx.themeEtf === 'XBI' && /biotech|biotechnology|genomic|pharmaceut|drug|therapeutic/.test(industry))) {
|
||||||
|
groups.set(ctx.themeEtf, (groups.get(ctx.themeEtf) ?? new Set()).add(sym));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Style: market_cap column does not exist in schema — skip Small Cap for now.
|
||||||
|
// Region: non-US exchange -> Intl (EFA)
|
||||||
|
if (meta.exchange && !US_EXCHANGES.has(meta.exchange.toUpperCase())) {
|
||||||
|
groups.set('EFA', (groups.get('EFA') ?? new Set()).add(sym));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Upsert system lists; delete orphaned ones.
|
||||||
|
const presentKeys = new Set<string>();
|
||||||
|
for (const [classKey, syms] of groups) {
|
||||||
|
presentKeys.add(classKey);
|
||||||
|
const label = ETF_TO_LABEL[classKey] ?? classKey;
|
||||||
|
const kind = classKey === 'SMH' || classKey === 'XBI' ? 'thematic' : classKey === 'IWM' ? 'style' : classKey === 'EFA' ? 'region' : 'sector';
|
||||||
|
const name = label;
|
||||||
|
const symbolsArr = Array.from(syms).map((s) => s);
|
||||||
|
const serialized = JSON.stringify(symbolsArr);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const id = generateId();
|
||||||
|
s.upsertSystem.run(id, userId, name, serialized, now, kind, classKey, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Prune system lists for class keys no longer present.
|
||||||
|
for (const classKey of ALLOWED_SECTOR_ETFS) { if (!presentKeys.has(classKey)) s.deleteSystemByClassKey.run(userId, classKey); }
|
||||||
|
for (const classKey of ALLOWED_THEME_ETFS) { if (!presentKeys.has(classKey)) s.deleteSystemByClassKey.run(userId, classKey); }
|
||||||
|
for (const classKey of ALLOWED_STYLE_ETFS) { if (!presentKeys.has(classKey)) s.deleteSystemByClassKey.run(userId, classKey); }
|
||||||
|
for (const classKey of ALLOWED_REGION_ETFS) { if (!presentKeys.has(classKey)) s.deleteSystemByClassKey.run(userId, classKey); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type { ThesisEvent } from '../thesis/ThesisMonitor.ts';
|
|||||||
import type { OptionsUnlockState } from '../options/ConvexityGate.ts';
|
import type { OptionsUnlockState } from '../options/ConvexityGate.ts';
|
||||||
import type { XCookieHealth } from '../adapters/XCookieAdapter.ts';
|
import type { XCookieHealth } from '../adapters/XCookieAdapter.ts';
|
||||||
import { emaFromCandles, rsi as rsiFn, relativeVolume, macd as macdFn } from '../analysis/indicators.ts';
|
import { emaFromCandles, rsi as rsiFn, relativeVolume, macd as macdFn } from '../analysis/indicators.ts';
|
||||||
import { listUsers, resetPassword, gdprExport, queueHealth, resetQueueBackoff, NotOwnerError, listUserSessions, listAuditLog, queueSecFetch } from '../admin/admin.ts';
|
import { listUsers, resetPassword, gdprExport, queueHealth, resetQueueBackoff, NotOwnerError, listUserSessions, listAuditLog, queueSecFetch, setUserModules, disableUser, enableUser, deleteUser } from '../admin/admin.ts';
|
||||||
import { restartServers, type RestartTarget } from '../lib/restart.ts';
|
import { restartServers, type RestartTarget } from '../lib/restart.ts';
|
||||||
import type { LintResult } from '../services/secDataFetcher.ts';
|
import type { LintResult } from '../services/secDataFetcher.ts';
|
||||||
import { EdgarAdapter } from '../adapters/EdgarAdapter.ts';
|
import { EdgarAdapter } from '../adapters/EdgarAdapter.ts';
|
||||||
@@ -101,9 +101,15 @@ const authRouter = router({
|
|||||||
}),
|
}),
|
||||||
me: publicProcedure.query(({ ctx }) => {
|
me: publicProcedure.query(({ ctx }) => {
|
||||||
if (!ctx.userId) return null;
|
if (!ctx.userId) return null;
|
||||||
const u = ctx.db.prepare('SELECT id,email,complexity,risk_tolerance,convexity_posture FROM users WHERE id=?').get(ctx.userId) as { id: string; email: string; complexity: string; risk_tolerance: string; convexity_posture: string } | undefined;
|
const u = ctx.db.prepare('SELECT id,email,complexity,risk_tolerance,convexity_posture,modules FROM users WHERE id=?').get(ctx.userId) as { id: string; email: string; complexity: string; risk_tolerance: string; convexity_posture: string; modules: string } | undefined;
|
||||||
const wl = ctx.db.prepare('SELECT 1 FROM watchlists WHERE owner_id=? LIMIT 1').get(ctx.userId);
|
const wl = ctx.db.prepare('SELECT 1 FROM watchlists WHERE owner_id=? LIMIT 1').get(ctx.userId);
|
||||||
return u ? { userId: u.id, email: u.email, complexity: u.complexity, riskTolerance: u.risk_tolerance, convexityPosture: u.convexity_posture, onboarded: !!wl } : null;
|
let modules: string[] = ['research', 'settings'];
|
||||||
|
try { modules = JSON.parse(u?.modules ?? '["research","settings"]'); } catch { /* keep default */ }
|
||||||
|
if (u?.id) {
|
||||||
|
const adminRow = ctx.db.prepare('SELECT is_admin FROM users WHERE id=?').get(u.id) as { is_admin: number } | undefined;
|
||||||
|
if (adminRow?.is_admin && !modules.includes('admin')) modules.push('admin');
|
||||||
|
}
|
||||||
|
return u ? { userId: u.id, email: u.email, complexity: u.complexity, riskTolerance: u.risk_tolerance, convexityPosture: u.convexity_posture, onboarded: !!wl, modules } : null;
|
||||||
}),
|
}),
|
||||||
enable2fa: protectedProcedure.input(z.object({})).mutation(async ({ ctx }) => {
|
enable2fa: protectedProcedure.input(z.object({})).mutation(async ({ ctx }) => {
|
||||||
const userId = ctx.userId as string;
|
const userId = ctx.userId as string;
|
||||||
@@ -190,6 +196,10 @@ const onboardingRouter = router({
|
|||||||
await ctx.cache.subscribe(sym, kind);
|
await ctx.cache.subscribe(sym, kind);
|
||||||
queueSecFetch(ctx.db, sym);
|
queueSecFetch(ctx.db, sym);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const { materializeClassificationWatchlists } = await import('../db/watchlistRepository.ts');
|
||||||
|
materializeClassificationWatchlists(ctx.db, userId);
|
||||||
|
} catch { /* ignore — sector data not available yet, will materialize on first listWatchlists */ }
|
||||||
if (input.portfolio) {
|
if (input.portfolio) {
|
||||||
const ins = ctx.db.prepare('INSERT INTO portfolio_holdings (id, owner_id, symbol, qty, avg_cost, acquired_at, status) VALUES (?,?,?,?,?,?,?)');
|
const ins = ctx.db.prepare('INSERT INTO portfolio_holdings (id, owner_id, symbol, qty, avg_cost, acquired_at, status) VALUES (?,?,?,?,?,?,?)');
|
||||||
for (const h of input.portfolio) ins.run(randomUUID(), userId, h.symbol.toUpperCase(), h.qty, h.avgCost, h.acquiredAt, 'open');
|
for (const h of input.portfolio) ins.run(randomUUID(), userId, h.symbol.toUpperCase(), h.qty, h.avgCost, h.acquiredAt, 'open');
|
||||||
@@ -1079,6 +1089,33 @@ function parseCandlesFromChart(raw: Record<string, unknown>): PriceCandle[] {
|
|||||||
const adminRouter = router({
|
const adminRouter = router({
|
||||||
usersList: adminProcedure.query(({ ctx }) => listUsers(ctx.db)),
|
usersList: adminProcedure.query(({ ctx }) => listUsers(ctx.db)),
|
||||||
|
|
||||||
|
setUserModules: adminProcedure
|
||||||
|
.input(z.object({ userId: z.string().uuid(), modules: z.array(z.string()) }))
|
||||||
|
.mutation(({ ctx, input }) => {
|
||||||
|
return setUserModules(ctx.db, ctx.userId, input.userId, input.modules);
|
||||||
|
}),
|
||||||
|
|
||||||
|
disableUser: adminProcedure
|
||||||
|
.input(z.object({ userId: z.string().uuid() }))
|
||||||
|
.mutation(({ ctx, input }) => {
|
||||||
|
try { return disableUser(ctx.db, ctx.userId, input.userId); }
|
||||||
|
catch (e) { throw new TRPCError({ code: 'FORBIDDEN', message: e instanceof Error ? e.message : 'Failed to disable user.' }); }
|
||||||
|
}),
|
||||||
|
|
||||||
|
enableUser: adminProcedure
|
||||||
|
.input(z.object({ userId: z.string().uuid() }))
|
||||||
|
.mutation(({ ctx, input }) => {
|
||||||
|
try { return enableUser(ctx.db, ctx.userId, input.userId); }
|
||||||
|
catch (e) { throw new TRPCError({ code: 'FORBIDDEN', message: e instanceof Error ? e.message : 'Failed to enable user.' }); }
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteUser: adminProcedure
|
||||||
|
.input(z.object({ userId: z.string().uuid() }))
|
||||||
|
.mutation(({ ctx, input }) => {
|
||||||
|
try { return deleteUser(ctx.db, ctx.userId, input.userId); }
|
||||||
|
catch (e) { throw new TRPCError({ code: 'FORBIDDEN', message: e instanceof Error ? e.message : 'Failed to delete user.' }); }
|
||||||
|
}),
|
||||||
|
|
||||||
resetPassword: adminProcedure
|
resetPassword: adminProcedure
|
||||||
.input(z.object({ email: z.string().email(), tempPassword: z.string().min(8) }))
|
.input(z.object({ email: z.string().email(), tempPassword: z.string().min(8) }))
|
||||||
.mutation(({ ctx, input }) => {
|
.mutation(({ ctx, input }) => {
|
||||||
@@ -2076,14 +2113,13 @@ const watchlistRouter = router({
|
|||||||
}))
|
}))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const userId = ctx.userId ?? 'anonymous';
|
const userId = ctx.userId ?? 'anonymous';
|
||||||
const { addSymbol } = await import('../db/watchlistRepository.ts');
|
const { addSymbol, materializeClassificationWatchlists } = await import('../db/watchlistRepository.ts');
|
||||||
const added = addSymbol(ctx.db, userId, input.symbol, input.notes, input.watchlistName);
|
const added = addSymbol(ctx.db, userId, input.symbol, input.notes, input.watchlistName);
|
||||||
|
|
||||||
if (added) {
|
if (added) {
|
||||||
|
materializeClassificationWatchlists(ctx.db, userId);
|
||||||
await ctx.cache.subscribe(input.symbol, 'equity');
|
await ctx.cache.subscribe(input.symbol, 'equity');
|
||||||
queueSecFetch(ctx.db, input.symbol);
|
queueSecFetch(ctx.db, input.symbol);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { added };
|
return { added };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -2095,10 +2131,25 @@ const watchlistRouter = router({
|
|||||||
}))
|
}))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const userId = ctx.userId ?? 'anonymous';
|
const userId = ctx.userId ?? 'anonymous';
|
||||||
const { removeSymbol } = await import('../db/watchlistRepository.ts');
|
const { removeSymbol, materializeClassificationWatchlists } = await import('../db/watchlistRepository.ts');
|
||||||
const removed = removeSymbol(ctx.db, userId, input.symbol, input.watchlistName);
|
const removed = removeSymbol(ctx.db, userId, input.symbol, input.watchlistName);
|
||||||
|
if (removed) materializeClassificationWatchlists(ctx.db, userId);
|
||||||
return { removed };
|
return { removed };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/** Move a symbol from one watchlist to another (both default to 'default'). */
|
||||||
|
moveSymbol: publicProcedure
|
||||||
|
.input(z.object({
|
||||||
|
symbol: z.string().toUpperCase(),
|
||||||
|
fromWatchlist: z.string().optional(),
|
||||||
|
toWatchlist: z.string().optional(),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const userId = ctx.userId ?? 'anonymous';
|
||||||
|
const { moveSymbol } = await import('../db/watchlistRepository.ts');
|
||||||
|
const moved = moveSymbol(ctx.db, userId, input.symbol, input.fromWatchlist ?? 'default', input.toWatchlist ?? 'default');
|
||||||
|
return { moved };
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Portfolio Router (Slice 10) ──────────────────────────────────────────────
|
// ─── Portfolio Router (Slice 10) ──────────────────────────────────────────────
|
||||||
|
|||||||
+264
-208
@@ -1,5 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { AdminLayout } from "@/components/AdminLayout";
|
import { AdminLayout } from "@/components/AdminLayout";
|
||||||
import { api } from "@/lib/trpc";
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
@@ -10,13 +10,7 @@ interface UserRow {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
is_admin: number;
|
is_admin: number;
|
||||||
status: string;
|
status: string;
|
||||||
}
|
modules: string;
|
||||||
|
|
||||||
interface SessionRow {
|
|
||||||
id: string;
|
|
||||||
user_id: string;
|
|
||||||
expires_at: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Spinner() {
|
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 }) {
|
function Modal({ open, onClose, children }: { open: boolean; onClose: () => void; children: React.ReactNode }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -91,7 +77,7 @@ function ResetPasswordModal({ open, onClose, email, onReset }: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={open} onClose={() => { setConfirmText(""); setResult(null); onClose(); }}>
|
<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>
|
<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>
|
<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 ALL_MODULES = ["research", "execution", "analytics", "settings"] as const;
|
||||||
const [sessions, setSessions] = useState<SessionRow[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
function ModulesModal({ open, onClose, user, onUpdated }: { open: boolean; onClose: () => void; user: UserRow; onUpdated: () => void }) {
|
||||||
let cancelled = false;
|
const [saving, setSaving] = useState(false);
|
||||||
api.admin.userSessions(userId)
|
const [error, setError] = useState<string | null>(null);
|
||||||
.then((data) => { if (!cancelled) setSessions(data); })
|
const [selected, setSelected] = useState<Set<string>>(() => {
|
||||||
.catch(() => {})
|
try { return new Set(JSON.parse(user.modules)); } catch { return new Set(["research", "settings"]); }
|
||||||
.finally(() => { if (!cancelled) setLoading(false); });
|
});
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [userId]);
|
|
||||||
|
|
||||||
function formatTime(ts: string): string {
|
async function handleSave() {
|
||||||
|
setSaving(true); setError(null);
|
||||||
try {
|
try {
|
||||||
const d = new Date(ts);
|
await api.admin.setUserModules(user.id, Array.from(selected));
|
||||||
return d.toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
onUpdated();
|
||||||
} catch {
|
onClose();
|
||||||
return ts.slice(0, 16);
|
} 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 (
|
return (
|
||||||
<div className="bg-surface border-x border-b border-line-strong px-4 py-3">
|
<Modal open={open} onClose={onClose}>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="p-5 text-left">
|
||||||
<span className="text-xs font-medium text-fg-faint uppercase tracking-wide">
|
<h2 className="text-base font-semibold text-fg mb-1">Module Access</h2>
|
||||||
Active Sessions ({sessions.length})
|
<p className="text-xs text-fg-faint mb-4">User: <span className="font-mono text-fg">{user.email}</span></p>
|
||||||
</span>
|
|
||||||
<button onClick={onClose} className="text-[10px] text-fg-faint hover:text-fg transition">Hide</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{sessions.length === 0 ? (
|
{error && (
|
||||||
<p className="text-xs text-fg-faint italic py-1">No active sessions</p>
|
<div className="rounded border border-red-500/30 bg-red-950/20 p-3 mb-4 text-sm text-danger">{error}</div>
|
||||||
) : (
|
)}
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead>
|
<div className="space-y-3">
|
||||||
<tr className="border-b border-line-strong text-left text-fg-faint uppercase tracking-wide text-[10px]">
|
<label className="block text-xs font-medium text-fg">Select allowed modules:</label>
|
||||||
<th className="pb-2 pr-3 font-medium">Session ID</th>
|
<div className="space-y-2">
|
||||||
<th className="pb-2 pr-3 font-medium">Expires At</th>
|
{ALL_MODULES.map((mod) => (
|
||||||
<th className="pb-2 font-medium">Created At</th>
|
<label key={mod} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
</tr>
|
<input
|
||||||
</thead>
|
type="checkbox"
|
||||||
<tbody className="divide-y divide-[#1a1b28]">
|
checked={selected.has(mod)}
|
||||||
{sessions.map((s) => (
|
onChange={(e) => {
|
||||||
<tr key={s.id}>
|
const next = new Set(selected);
|
||||||
<td className="py-2 pr-3 font-mono text-accent" title={s.id}>{truncateId(s.id)}</td>
|
if (e.target.checked) next.add(mod);
|
||||||
<td className="py-2 pr-3 text-fg whitespace-nowrap">{formatTime(s.expires_at)}</td>
|
else next.delete(mod);
|
||||||
<td className="py-2 text-fg-faint whitespace-nowrap font-mono">{formatTime(s.created_at)}</td>
|
setSelected(next);
|
||||||
</tr>
|
}}
|
||||||
|
className="accent-accent"
|
||||||
|
/>
|
||||||
|
<span className="text-fg capitalize">{mod}</span>
|
||||||
|
</label>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
|
||||||
)}
|
<div className="flex gap-2 pt-2">
|
||||||
</div>
|
<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 }) {
|
function ConfirmModal({ open, onClose, title, message, confirmLabel, danger, onConfirm, loading }: {
|
||||||
const [showSessions, setShowSessions] = useState(false);
|
open: boolean; onClose: () => void; title: string; message: string;
|
||||||
const [complexityLabel] = useState(() => {
|
confirmLabel: string; danger?: boolean; onConfirm: () => Promise<void>; loading: boolean;
|
||||||
switch (user.complexity) {
|
}) {
|
||||||
case "beginner": return <span className="text-[#10b981]">Beginner</span>;
|
return (
|
||||||
case "intermediate": return <span className="text-[#f59e0b]">Intermediate</span>;
|
<Modal open={open} onClose={onClose}>
|
||||||
case "advanced": return <span className="text-accent">Advanced</span>;
|
<div className="p-5 text-left">
|
||||||
default: return <span className="text-fg-faint">{user.complexity}</span>;
|
<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 {
|
<div className="space-y-3">
|
||||||
try {
|
<div className="flex gap-2 pt-2">
|
||||||
const d = new Date(ts);
|
<button
|
||||||
return d.toLocaleString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
onClick={onClose}
|
||||||
} catch {
|
disabled={loading}
|
||||||
return ts.slice(0, 10);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<tr className="group border-b border-line-strong hover:bg-surface-raised/50 transition">
|
<div ref={dropdownRef} className="relative inline-block text-left">
|
||||||
<td className="py-3 pr-4 font-mono text-sm text-fg">{user.email}</td>
|
<button
|
||||||
<td className="py-3 pr-4 text-xs">{complexityLabel}</td>
|
onClick={() => setOpen(!open)}
|
||||||
<td className="py-3 pr-4">
|
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"
|
||||||
{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>
|
···
|
||||||
) : (
|
</button>
|
||||||
<span className="text-xs text-fg-faint">User</span>
|
{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">
|
||||||
</td>
|
{!isPending && (
|
||||||
<td className="py-3 pr-4">
|
<>
|
||||||
{user.status === 'pending_approval' ? (
|
<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>
|
||||||
<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>
|
<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>
|
||||||
) : user.status === 'rejected' ? (
|
<hr className="my-1 border-line" />
|
||||||
<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>
|
{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>
|
||||||
<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>
|
) : (
|
||||||
)}
|
<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>
|
||||||
</td>
|
)}
|
||||||
<td className="py-3 pr-4 text-xs text-fg-faint whitespace-nowrap font-mono">{formatTime(user.created_at)}</td>
|
<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>
|
||||||
<td className="py-3 pr-2">
|
</>
|
||||||
<button
|
)}
|
||||||
onClick={() => setShowSessions(!showSessions)}
|
{isPending && (
|
||||||
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]"}`}
|
<span className="block px-4 py-2 text-xs text-fg-faint italic">Pending approval</span>
|
||||||
>
|
)}
|
||||||
Sessions
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
|
<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() {
|
export default function AdminUsersPage() {
|
||||||
const [users, setUsers] = useState<UserRow[]>([]);
|
const [users, setUsers] = useState<UserRow[]>([]);
|
||||||
const [pending, setPending] = useState<UserRow[]>([]);
|
const [pending, setPending] = useState<UserRow[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 [approveBusy, setApproveBusy] = useState<string | null>(null);
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
setLoading(true); setError(null);
|
setLoading(true); setError(null);
|
||||||
Promise.all([
|
Promise.all([
|
||||||
api.admin.usersList().catch((e: Error) => { setError(e.message); return []; }),
|
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));
|
]).then(([u, p]) => { setUsers(u); setPending(p); }).finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -301,15 +380,15 @@ export default function AdminUsersPage() {
|
|||||||
async function handleApprove(userId: string) {
|
async function handleApprove(userId: string) {
|
||||||
setApproveBusy(userId);
|
setApproveBusy(userId);
|
||||||
try { await api.admin.approveUser(userId); load(); }
|
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); }
|
finally { setApproveBusy(null); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleReject(userId: string) {
|
async function handleReject(userId: string) {
|
||||||
if (!confirm('Reject this user?')) return;
|
if (!confirm("Reject this user?")) return;
|
||||||
setApproveBusy(userId);
|
setApproveBusy(userId);
|
||||||
try { await api.admin.rejectUser(userId); load(); }
|
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); }
|
finally { setApproveBusy(null); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,36 +401,6 @@ export default function AdminUsersPage() {
|
|||||||
return () => { cancelled = true; };
|
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 />;
|
if (loading) return <Spinner />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -359,11 +408,10 @@ export default function AdminUsersPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-fg">User Management</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
{error && <ErrorBanner message={error} />}
|
{error && <ErrorBanner message={error} />}
|
||||||
{successMsg && <SuccessBanner message={successMsg} />}
|
|
||||||
|
|
||||||
{/* Pending Approvals */}
|
{/* Pending Approvals */}
|
||||||
{pending.length > 0 && (
|
{pending.length > 0 && (
|
||||||
@@ -386,25 +434,17 @@ export default function AdminUsersPage() {
|
|||||||
<tr key={u.id} className="hover:bg-surface-raised/50 transition">
|
<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 font-mono text-sm text-fg">{u.email}</td>
|
||||||
<td className="py-3 px-4 text-xs text-fg-faint">
|
<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>
|
||||||
<td className="py-3 px-4 text-xs text-fg-faint whitespace-nowrap font-mono">
|
<td className="py-3 px-4 text-xs text-fg-faint whitespace-nowrap font-mono">
|
||||||
{new Date(u.created_at).toLocaleDateString()}
|
{new Date(u.created_at).toLocaleDateString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4 text-right">
|
<td className="py-3 px-4 text-right">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<button
|
<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">
|
||||||
onClick={() => handleApprove(u.id)}
|
{approveBusy === u.id ? "..." : "Approve"}
|
||||||
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>
|
||||||
<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">
|
||||||
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
|
Reject
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -416,7 +456,8 @@ export default function AdminUsersPage() {
|
|||||||
</section>
|
</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">
|
<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>
|
<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>
|
<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>
|
<p className="text-sm text-fg-faint italic">No users found</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<table className="w-full text-sm">
|
||||||
<table className="w-full text-sm">
|
<thead>
|
||||||
<thead>
|
<tr className="border-b border-line-strong bg-surface/50 text-left text-fg-faint uppercase tracking-wide text-[10px]">
|
||||||
<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">Email</th>
|
<th className="py-3 px-4 font-medium">Role</th>
|
||||||
<th className="py-3 px-4 font-medium">Complexity</th>
|
<th className="py-3 px-4 font-medium">Status</th>
|
||||||
<th className="py-3 px-4 font-medium">Role</th>
|
<th className="py-3 px-4 font-medium whitespace-nowrap">Created At</th>
|
||||||
<th className="py-3 px-4 font-medium">Status</th>
|
<th className="py-3 px-4 font-medium text-right w-24">Actions</th>
|
||||||
<th className="py-3 px-4 font-medium">Created At</th>
|
</tr>
|
||||||
<th className="py-3 px-2 font-medium text-right w-28">Sessions</th>
|
</thead>
|
||||||
<th className="py-3 pl-2 font-medium text-right w-36">Actions</th>
|
<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>
|
</tr>
|
||||||
</thead>
|
))}
|
||||||
<tbody className="divide-y divide-[#1a1b28]">
|
</tbody>
|
||||||
{users.map((user) => (
|
</table>
|
||||||
<UserTableRow key={user.id} user={user} onReset={openResetModal} />
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<ResetPasswordModal
|
|
||||||
open={resetModal.open}
|
|
||||||
onClose={closeResetModal}
|
|
||||||
email={resetModal.email}
|
|
||||||
onReset={handlePasswordReset}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { LayoutShell } from "@/components/LayoutShell";
|
||||||
|
import { FeatureGate } from "@/components/FeatureGate";
|
||||||
import { api, type AlertEventRow, type AlertSubscriptionRow } from "@/lib/trpc";
|
import { api, type AlertEventRow, type AlertSubscriptionRow } from "@/lib/trpc";
|
||||||
|
|
||||||
const SEVERITY_COLORS: Record<string, string> = {
|
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",
|
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 [tab, setTab] = useState<"events" | "subscriptions">("events");
|
||||||
const [events, setEvents] = useState<AlertEventRow[]>([]);
|
const [events, setEvents] = useState<AlertEventRow[]>([]);
|
||||||
const [subscriptions, setSubscriptions] = useState<AlertSubscriptionRow[]>([]);
|
const [subscriptions, setSubscriptions] = useState<AlertSubscriptionRow[]>([]);
|
||||||
@@ -64,8 +66,8 @@ export default function AlertsPage() {
|
|||||||
const unacknowledged = events.filter((e) => !e.acknowledged);
|
const unacknowledged = events.filter((e) => !e.acknowledged);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-surface text-fg p-6">
|
<LayoutShell>
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-bold mb-4">Alerts</h1>
|
<h1 className="text-2xl font-bold mb-4">Alerts</h1>
|
||||||
|
|
||||||
<div className="flex gap-1 mb-6 border-b border-border">
|
<div className="flex gap-1 mb-6 border-b border-border">
|
||||||
@@ -167,6 +169,14 @@ export default function AlertsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</LayoutShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AlertsPage() {
|
||||||
|
return (
|
||||||
|
<FeatureGate module="analytics">
|
||||||
|
<AlertsPageInner />
|
||||||
|
</FeatureGate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { useState, useRef } from 'react';
|
|||||||
import { useGoalsStore } from '@/stores/goals-store';
|
import { useGoalsStore } from '@/stores/goals-store';
|
||||||
import type { MonthlyGoal, WeeklyGoal } from '@/types';
|
import type { MonthlyGoal, WeeklyGoal } from '@/types';
|
||||||
import { LayoutShell } from '@/components/LayoutShell';
|
import { LayoutShell } from '@/components/LayoutShell';
|
||||||
|
import { FeatureGate } from '@/components/FeatureGate';
|
||||||
|
|
||||||
export default function DailyFocusPage() {
|
function DailyFocusPageInner() {
|
||||||
const store = useGoalsStore();
|
const store = useGoalsStore();
|
||||||
|
|
||||||
const [showAddMonthly, setShowAddMonthly] = useState(false);
|
const [showAddMonthly, setShowAddMonthly] = useState(false);
|
||||||
@@ -241,3 +242,11 @@ function MonthlyGoalItem({ goal, onToggle, onDelete }: { goal: { id: string; des
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function DailyFocusPage() {
|
||||||
|
return (
|
||||||
|
<FeatureGate module="execution">
|
||||||
|
<DailyFocusPageInner />
|
||||||
|
</FeatureGate>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useExecutionStore } from '@/stores/execution-store';
|
|||||||
import { EmotionLogger } from '@/components/EmotionLogger';
|
import { EmotionLogger } from '@/components/EmotionLogger';
|
||||||
import type { ExecutionPlaybook, EmotionLogState, MistakeStage, ScaleEntry } from '@/types';
|
import type { ExecutionPlaybook, EmotionLogState, MistakeStage, ScaleEntry } from '@/types';
|
||||||
import { LayoutShell } from '@/components/LayoutShell';
|
import { LayoutShell } from '@/components/LayoutShell';
|
||||||
|
import { FeatureGate } from '@/components/FeatureGate';
|
||||||
|
|
||||||
const PLAYBOOK_LABELS: Record<ExecutionPlaybook, string> = {
|
const PLAYBOOK_LABELS: Record<ExecutionPlaybook, string> = {
|
||||||
mean_reversion: 'Mean Reversion (MR)',
|
mean_reversion: 'Mean Reversion (MR)',
|
||||||
@@ -14,7 +15,7 @@ const PLAYBOOK_LABELS: Record<ExecutionPlaybook, string> = {
|
|||||||
|
|
||||||
const EXECUTION_PATTERNS = Object.keys(PLAYBOOK_LABELS) as ExecutionPlaybook[];
|
const EXECUTION_PATTERNS = Object.keys(PLAYBOOK_LABELS) as ExecutionPlaybook[];
|
||||||
|
|
||||||
export default function ExecutionPage() {
|
function ExecutionPageInner() {
|
||||||
const store = useExecutionStore();
|
const store = useExecutionStore();
|
||||||
const plan = store.executionPlaybook;
|
const plan = store.executionPlaybook;
|
||||||
|
|
||||||
@@ -377,3 +378,11 @@ function KPICard({ label, value, color }: { label: string; value: string; color:
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function ExecutionPage() {
|
||||||
|
return (
|
||||||
|
<FeatureGate module="execution">
|
||||||
|
<ExecutionPageInner />
|
||||||
|
</FeatureGate>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { LayoutShell } from "@/components/LayoutShell";
|
import { LayoutShell } from "@/components/LayoutShell";
|
||||||
|
import { FeatureGate } from "@/components/FeatureGate";
|
||||||
import { FilingsPanel } from "@/components/FilingsPanel";
|
import { FilingsPanel } from "@/components/FilingsPanel";
|
||||||
|
|
||||||
export default function FilingsPage() {
|
function FilingsPageInner() {
|
||||||
return (
|
return (
|
||||||
<LayoutShell>
|
<LayoutShell>
|
||||||
<FilingsPanel />
|
<FilingsPanel />
|
||||||
</LayoutShell>
|
</LayoutShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function FilingsPage() {
|
||||||
|
return (
|
||||||
|
<FeatureGate module="analytics">
|
||||||
|
<FilingsPageInner />
|
||||||
|
</FeatureGate>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { LayoutShell } from "@/components/LayoutShell";
|
import { LayoutShell } from "@/components/LayoutShell";
|
||||||
|
import { FeatureGate } from "@/components/FeatureGate";
|
||||||
import { OptionsDDPanel } from "@/components/OptionsDDPanel";
|
import { OptionsDDPanel } from "@/components/OptionsDDPanel";
|
||||||
|
|
||||||
export default function OptionsDDPage() {
|
function OptionsDDPageInner() {
|
||||||
return (
|
return (
|
||||||
<LayoutShell>
|
<LayoutShell>
|
||||||
<OptionsDDPanel />
|
<OptionsDDPanel />
|
||||||
</LayoutShell>
|
</LayoutShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function OptionsDDPage() {
|
||||||
|
return (
|
||||||
|
<FeatureGate module="analytics">
|
||||||
|
<OptionsDDPageInner />
|
||||||
|
</FeatureGate>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { LayoutShell } from "@/components/LayoutShell";
|
import { LayoutShell } from "@/components/LayoutShell";
|
||||||
|
import { FeatureGate } from "@/components/FeatureGate";
|
||||||
import { RiskPosturePanel } from "@/components/RiskPosturePanel";
|
import { RiskPosturePanel } from "@/components/RiskPosturePanel";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* M20 Risk Posture — single surface for portfolio risk picture + sizing math.
|
* M20 Risk Posture — single surface for portfolio risk picture + sizing math.
|
||||||
*/
|
*/
|
||||||
export default function RiskPage() {
|
function RiskPageInner() {
|
||||||
return (
|
return (
|
||||||
<LayoutShell>
|
<LayoutShell>
|
||||||
<RiskPosturePanel />
|
<RiskPosturePanel />
|
||||||
</LayoutShell>
|
</LayoutShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function RiskPage() {
|
||||||
|
return (
|
||||||
|
<FeatureGate module="analytics">
|
||||||
|
<RiskPageInner />
|
||||||
|
</FeatureGate>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { LayoutShell } from "@/components/LayoutShell";
|
import { LayoutShell } from "@/components/LayoutShell";
|
||||||
|
import { FeatureGate } from "@/components/FeatureGate";
|
||||||
import { TradeClosureView } from "@/components/trade-closure/TradeClosureView";
|
import { TradeClosureView } from "@/components/trade-closure/TradeClosureView";
|
||||||
|
|
||||||
export default function TradeClosurePage() {
|
function TradeClosurePageInner() {
|
||||||
return (
|
return (
|
||||||
<LayoutShell>
|
<LayoutShell>
|
||||||
<TradeClosureView />
|
<TradeClosureView />
|
||||||
</LayoutShell>
|
</LayoutShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function TradeClosurePage() {
|
||||||
|
return (
|
||||||
|
<FeatureGate module="execution">
|
||||||
|
<TradeClosurePageInner />
|
||||||
|
</FeatureGate>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export const dynamic = 'force-dynamic';
|
|||||||
import { useTradePlanStore } from '@/stores/trade-plan-store';
|
import { useTradePlanStore } from '@/stores/trade-plan-store';
|
||||||
import type { ConfluenceType, SetupTier, TradePlan } from '@/types';
|
import type { ConfluenceType, SetupTier, TradePlan } from '@/types';
|
||||||
import { LayoutShell } from '@/components/LayoutShell';
|
import { LayoutShell } from '@/components/LayoutShell';
|
||||||
|
import { FeatureGate } from '@/components/FeatureGate';
|
||||||
|
|
||||||
// --- Plan List Sidebar ---
|
// --- Plan List Sidebar ---
|
||||||
function PlanListSidebar({ plans }: { plans: TradePlan[] }) {
|
function PlanListSidebar({ plans }: { plans: TradePlan[] }) {
|
||||||
@@ -147,7 +148,7 @@ function AddTargetForm({ onAdd, entryPrice }: { onAdd: (price: number, reason: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Main Component ---
|
// --- Main Component ---
|
||||||
export default function TradePlanPage() {
|
function TradePlanPageInner() {
|
||||||
const store = useTradePlanStore();
|
const store = useTradePlanStore();
|
||||||
const plan = store.currentPlanId
|
const plan = store.currentPlanId
|
||||||
? store.activePlans.find(p => p.id === 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 ---
|
// --- Helpers ---
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { api } from "@/lib/trpc";
|
import { api } from "@/lib/trpc";
|
||||||
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||||
|
import { useFeatureAccess, type ModuleKey } from "@/lib/useFeatureAccess";
|
||||||
|
|
||||||
interface NavCommand {
|
interface NavCommand {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -31,25 +32,26 @@ interface NavCommand {
|
|||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
goTo?: string;
|
goTo?: string;
|
||||||
group: "Page";
|
group: "Page";
|
||||||
|
module?: ModuleKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NAV_COMMANDS: NavCommand[] = [
|
const NAV_COMMANDS: NavCommand[] = [
|
||||||
{ label: "Overview", href: "/", icon: Home, goTo: "o", group: "Page" },
|
{ label: "Overview", href: "/", icon: Home, goTo: "o", group: "Page", module: "research" },
|
||||||
{ label: "Charts", href: "/chart-lab", icon: LineChart, goTo: "c", group: "Page" },
|
{ label: "Charts", href: "/chart-lab", icon: LineChart, goTo: "c", group: "Page", module: "research" },
|
||||||
{ label: "Institutional", href: "/institutional", icon: Building2, goTo: "i", group: "Page" },
|
{ label: "Institutional", href: "/institutional", icon: Building2, goTo: "i", group: "Page", module: "research" },
|
||||||
{ label: "Market Outlook", href: "/market-outlook", icon: TrendingUp, goTo: "m", group: "Page" },
|
{ 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" },
|
{ label: "Trade Plan", href: "/trade-plan", icon: ClipboardList, goTo: "t", group: "Page", module: "execution" },
|
||||||
{ label: "Execution", href: "/execution", icon: PlayCircle, goTo: "e", group: "Page" },
|
{ label: "Execution", href: "/execution", icon: PlayCircle, goTo: "e", group: "Page", module: "execution" },
|
||||||
{ label: "Trade Closure", href: "/trade-closure", icon: XCircle, goTo: "x", group: "Page" },
|
{ 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" },
|
{ 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" },
|
{ label: "Risk Posture", href: "/risk", icon: Shield, goTo: "r", group: "Page", module: "analytics" },
|
||||||
{ label: "Filings", href: "/filings", icon: FileText, goTo: "f", group: "Page" },
|
{ label: "Filings", href: "/filings", icon: FileText, goTo: "f", group: "Page", module: "analytics" },
|
||||||
{ label: "Options DD", href: "/options-dd", icon: BarChart3, goTo: "p", group: "Page" },
|
{ label: "Options DD", href: "/options-dd", icon: BarChart3, goTo: "p", group: "Page", module: "analytics" },
|
||||||
{ label: "Settings", href: "/settings", icon: Settings, goTo: "s", group: "Page" },
|
{ label: "Settings", href: "/settings", icon: Settings, goTo: "s", group: "Page", module: "settings" },
|
||||||
{ label: "Admin", href: "/admin", icon: Shield, goTo: "a", group: "Page" },
|
{ label: "Admin", href: "/admin", icon: Shield, goTo: "a", group: "Page", module: "admin" },
|
||||||
{ label: "Admin · Users", href: "/admin/users", icon: Users, group: "Page" },
|
{ label: "Admin · Users", href: "/admin/users", icon: Users, group: "Page", module: "admin" },
|
||||||
{ label: "Admin · Queue", href: "/admin/queue", icon: Activity, group: "Page" },
|
{ label: "Admin · Queue", href: "/admin/queue", icon: Activity, group: "Page", module: "admin" },
|
||||||
{ label: "Admin · Audit Logs", href: "/admin/audit-logs", icon: ScrollText, group: "Page" },
|
{ label: "Admin · Audit Logs", href: "/admin/audit-logs", icon: ScrollText, group: "Page", module: "admin" },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface Command {
|
interface Command {
|
||||||
@@ -84,6 +86,7 @@ function fuzzyScore(query: string, text: string): number {
|
|||||||
|
|
||||||
export function CommandPalette() {
|
export function CommandPalette() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { modules } = useFeatureAccess();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
@@ -124,16 +127,26 @@ export function CommandPalette() {
|
|||||||
const typing = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || (el as HTMLElement)?.isContentEditable;
|
const typing = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || (el as HTMLElement)?.isContentEditable;
|
||||||
if (open || typing || e.metaKey || e.ctrlKey || e.altKey) return;
|
if (open || typing || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
if (gPressedAt.current && Date.now() - gPressedAt.current < 1200) {
|
if (gPressedAt.current && Date.now() - gPressedAt.current < 1200) {
|
||||||
const map: Record<string, string> = {
|
const goToMap: Array<{ key: string; href: string; module?: ModuleKey }> = [
|
||||||
o: "/", c: "/chart-lab", i: "/institutional", m: "/market-outlook",
|
{ key: "o", href: "/", module: "research" },
|
||||||
t: "/trade-plan", e: "/execution", x: "/trade-closure", d: "/daily-focus",
|
{ key: "c", href: "/chart-lab", module: "research" },
|
||||||
r: "/risk", f: "/filings", p: "/options-dd", s: "/settings", a: "/admin",
|
{ key: "i", href: "/institutional", module: "research" },
|
||||||
};
|
{ key: "m", href: "/market-outlook", module: "research" },
|
||||||
const href = map[e.key.toLowerCase()];
|
{ key: "t", href: "/trade-plan", module: "execution" },
|
||||||
if (href) {
|
{ 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();
|
e.preventDefault();
|
||||||
gPressedAt.current = 0;
|
gPressedAt.current = 0;
|
||||||
router.push(href);
|
router.push(entry.href);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -143,17 +156,19 @@ export function CommandPalette() {
|
|||||||
};
|
};
|
||||||
window.addEventListener("keydown", onGoTo);
|
window.addEventListener("keydown", onGoTo);
|
||||||
return () => window.removeEventListener("keydown", onGoTo);
|
return () => window.removeEventListener("keydown", onGoTo);
|
||||||
}, [open, router]);
|
}, [open, router, modules]);
|
||||||
|
|
||||||
const commands = useMemo<Command[]>(() => {
|
const commands = useMemo<Command[]>(() => {
|
||||||
const pageCmds: Command[] = NAV_COMMANDS.map((c) => ({
|
const pageCmds: Command[] = NAV_COMMANDS
|
||||||
id: `page:${c.href}`,
|
.filter((c) => !c.module || modules.includes(c.module))
|
||||||
label: c.label,
|
.map((c) => ({
|
||||||
hint: c.goTo ? `g ${c.goTo}` : undefined,
|
id: `page:${c.href}`,
|
||||||
group: c.group,
|
label: c.label,
|
||||||
icon: c.icon,
|
hint: c.goTo ? `g ${c.goTo}` : undefined,
|
||||||
run: () => router.push(c.href),
|
group: c.group,
|
||||||
}));
|
icon: c.icon,
|
||||||
|
run: () => router.push(c.href),
|
||||||
|
}));
|
||||||
const symbolCmds: Command[] = symbols.map((sym) => ({
|
const symbolCmds: Command[] = symbols.map((sym) => ({
|
||||||
id: `sym:${sym}`,
|
id: `sym:${sym}`,
|
||||||
label: `Open ${sym}`,
|
label: `Open ${sym}`,
|
||||||
@@ -166,7 +181,7 @@ export function CommandPalette() {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return [...pageCmds, ...symbolCmds];
|
return [...pageCmds, ...symbolCmds];
|
||||||
}, [symbols, router]);
|
}, [symbols, router, modules]);
|
||||||
|
|
||||||
const results = useMemo(() => {
|
const results = useMemo(() => {
|
||||||
if (!query.trim()) return commands;
|
if (!query.trim()) return commands;
|
||||||
|
|||||||
@@ -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't have access to this module.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useFeatureAccess, type ModuleKey } from "@/lib/useFeatureAccess";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mobile tab navigation bar.
|
* Mobile tab navigation bar.
|
||||||
@@ -12,24 +13,27 @@ interface TabItem {
|
|||||||
label: string;
|
label: string;
|
||||||
href: string;
|
href: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
|
module: ModuleKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TABS: TabItem[] = [
|
const TABS: TabItem[] = [
|
||||||
{ label: "Overview", href: "/", icon: "📊" },
|
{ label: "Overview", href: "/", icon: "📊", module: "research" },
|
||||||
{ label: "Filings", href: "/filings", icon: "📄" },
|
{ label: "Filings", href: "/filings", icon: "📄", module: "analytics" },
|
||||||
{ label: "Options", href: "/options-dd", icon: "🔮" },
|
{ label: "Options", href: "/options-dd", icon: "🔮", module: "analytics" },
|
||||||
{ label: "Alerts", href: "/alerts", icon: "🔔" },
|
{ label: "Alerts", href: "/alerts", icon: "🔔", module: "analytics" },
|
||||||
{ label: "Execution", href: "/execution", icon: "⚡" },
|
{ label: "Execution", href: "/execution", icon: "⚡", module: "execution" },
|
||||||
{ label: "Trade Plan", href: "/trade-plan", icon: "📋" },
|
{ label: "Trade Plan", href: "/trade-plan", icon: "📋", module: "execution" },
|
||||||
{ label: "Settings", href: "/settings", icon: "⚙️" },
|
{ label: "Settings", href: "/settings", icon: "⚙️", module: "settings" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function MobileTabNav() {
|
export function MobileTabNav() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { modules } = useFeatureAccess();
|
||||||
|
const visibleTabs = TABS.filter((t) => modules.includes(t.module));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex md:hidden border-t border-line-strong bg-surface-raised">
|
<nav className="flex md:hidden border-t border-line-strong bg-surface-raised">
|
||||||
{TABS.map((tab) => {
|
{visibleTabs.map((tab) => {
|
||||||
const isActive = pathname === tab.href;
|
const isActive = pathname === tab.href;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,10 +10,10 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Search,
|
Search,
|
||||||
Bell,
|
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "@/lib/trpc";
|
import { api } from "@/lib/trpc";
|
||||||
|
import { useFeatureAccess, type ModuleKey } from "@/lib/useFeatureAccess";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Left sidebar navigation with collapsible sections.
|
* Left sidebar navigation with collapsible sections.
|
||||||
@@ -29,6 +29,7 @@ interface NavItem {
|
|||||||
interface NavSection {
|
interface NavSection {
|
||||||
title: string;
|
title: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
|
module: ModuleKey;
|
||||||
items: NavItem[];
|
items: NavItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ const SECTIONS: NavSection[] = [
|
|||||||
{
|
{
|
||||||
title: "Research",
|
title: "Research",
|
||||||
icon: BarChart3,
|
icon: BarChart3,
|
||||||
|
module: "research",
|
||||||
items: [
|
items: [
|
||||||
{ label: "Overview", href: "/" },
|
{ label: "Overview", href: "/" },
|
||||||
{ label: "Market Outlook", href: "/market-outlook" },
|
{ label: "Market Outlook", href: "/market-outlook" },
|
||||||
@@ -46,6 +48,7 @@ const SECTIONS: NavSection[] = [
|
|||||||
{
|
{
|
||||||
title: "Execution",
|
title: "Execution",
|
||||||
icon: Zap,
|
icon: Zap,
|
||||||
|
module: "execution",
|
||||||
items: [
|
items: [
|
||||||
{ label: "Trade Plan", href: "/trade-plan" },
|
{ label: "Trade Plan", href: "/trade-plan" },
|
||||||
{ label: "Execution", href: "/execution" },
|
{ label: "Execution", href: "/execution" },
|
||||||
@@ -56,6 +59,7 @@ const SECTIONS: NavSection[] = [
|
|||||||
{
|
{
|
||||||
title: "Analytics",
|
title: "Analytics",
|
||||||
icon: Microscope,
|
icon: Microscope,
|
||||||
|
module: "analytics",
|
||||||
items: [
|
items: [
|
||||||
{ label: "Risk Posture", href: "/risk" },
|
{ label: "Risk Posture", href: "/risk" },
|
||||||
{ label: "Filings", href: "/filings" },
|
{ label: "Filings", href: "/filings" },
|
||||||
@@ -66,6 +70,7 @@ const SECTIONS: NavSection[] = [
|
|||||||
{
|
{
|
||||||
title: "Admin",
|
title: "Admin",
|
||||||
icon: Shield,
|
icon: Shield,
|
||||||
|
module: "admin",
|
||||||
items: [
|
items: [
|
||||||
{ label: "Overview", href: "/admin" },
|
{ label: "Overview", href: "/admin" },
|
||||||
{ label: "Users", href: "/admin/users" },
|
{ label: "Users", href: "/admin/users" },
|
||||||
@@ -78,13 +83,14 @@ const SECTIONS: NavSection[] = [
|
|||||||
{
|
{
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
|
module: "settings",
|
||||||
items: [{ label: "Profile / Auth", href: "/settings" }],
|
items: [{ label: "Profile / Auth", href: "/settings" }],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const STORAGE_KEY = "iflow-nav-collapsed";
|
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") {
|
if (typeof window !== "undefined") {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
@@ -93,7 +99,7 @@ function getInitialCollapsed(pathname: string): Record<string, boolean> {
|
|||||||
}
|
}
|
||||||
const initial: Record<string, boolean> = {};
|
const initial: Record<string, boolean> = {};
|
||||||
const norm = (pathname || "/").replace(/\/+$/, "") || "/";
|
const norm = (pathname || "/").replace(/\/+$/, "") || "/";
|
||||||
for (const section of SECTIONS) {
|
for (const section of visibleSections) {
|
||||||
const isActive = section.items.some((item) => item.href === norm);
|
const isActive = section.items.some((item) => item.href === norm);
|
||||||
initial[section.title] = !isActive;
|
initial[section.title] = !isActive;
|
||||||
}
|
}
|
||||||
@@ -103,18 +109,25 @@ function getInitialCollapsed(pathname: string): Record<string, boolean> {
|
|||||||
export function SidebarNav() {
|
export function SidebarNav() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const normalizedPath = (pathname || "/").replace(/\/+$/, "") || "/";
|
const normalizedPath = (pathname || "/").replace(/\/+$/, "") || "/";
|
||||||
|
const { modules, loading } = useFeatureAccess();
|
||||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||||
const [initialized, setInitialized] = useState(false);
|
const [initialized, setInitialized] = useState(false);
|
||||||
const [unackedCount, setUnackedCount] = useState(0);
|
const [unackedCount, setUnackedCount] = useState(0);
|
||||||
|
|
||||||
|
const visibleSections = SECTIONS.filter((s) => modules.includes(s.module));
|
||||||
|
|
||||||
useEffect(() => {
|
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(() => {
|
useEffect(() => {
|
||||||
setCollapsed(getInitialCollapsed(pathname));
|
setCollapsed(getInitialCollapsed(pathname, visibleSections));
|
||||||
setInitialized(true);
|
setInitialized(true);
|
||||||
}, []);
|
}, [loading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialized) {
|
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">
|
<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">
|
<nav className="flex h-full flex-col p-3">
|
||||||
<div className="space-y-0.5 flex-1">
|
<div className="space-y-0.5 flex-1">
|
||||||
{SECTIONS.map((section) => {
|
{visibleSections.map((section) => {
|
||||||
const Icon = section.icon;
|
const Icon = section.icon;
|
||||||
return (
|
return (
|
||||||
<div key={section.title} className="mb-1">
|
<div key={section.title} className="mb-1">
|
||||||
@@ -182,7 +195,7 @@ export function SidebarNav() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search affordance — opens the command palette */}
|
{/* Search affordance -- opens the command palette */}
|
||||||
<button
|
<button
|
||||||
onClick={openPalette}
|
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"
|
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"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useActiveSymbol } from "@/stores/active-symbol-store";
|
|||||||
import { api, type Snapshot } from "@/lib/trpc";
|
import { api, type Snapshot } from "@/lib/trpc";
|
||||||
import { UI_STRINGS } from "@/lib/strings";
|
import { UI_STRINGS } from "@/lib/strings";
|
||||||
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
|
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
|
||||||
|
import { NotificationBell } from "@/components/NotificationBell";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persistent top bar showing the active symbol, price, change, and search.
|
* Persistent top bar showing the active symbol, price, change, and search.
|
||||||
@@ -115,6 +116,7 @@ export function SymbolHeader() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Theme switcher */}
|
{/* Theme switcher */}
|
||||||
|
<NotificationBell />
|
||||||
<ThemeSwitcher />
|
<ThemeSwitcher />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
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 { api, type WatchlistEntry, type Quote, type PortfolioHolding } from "@/lib/trpc";
|
||||||
import { chart as CHART } from "@/lib/chart-theme";
|
import { chart as CHART } from "@/lib/chart-theme";
|
||||||
import { UI_STRINGS } from "@/lib/strings";
|
import { UI_STRINGS } from "@/lib/strings";
|
||||||
@@ -15,6 +16,9 @@ interface WatchlistMeta {
|
|||||||
symbol_count: number;
|
symbol_count: number;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
kind?: string;
|
||||||
|
class_key?: string | null;
|
||||||
|
class_label?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WatchlistSidebarProps {
|
interface WatchlistSidebarProps {
|
||||||
@@ -30,11 +34,19 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [holdings, setHoldings] = useState<PortfolioHolding[]>([]);
|
const [holdings, setHoldings] = useState<PortfolioHolding[]>([]);
|
||||||
const [watchlists, setWatchlists] = useState<WatchlistMeta[]>([]);
|
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 [showListPicker, setShowListPicker] = useState(false);
|
||||||
const [newListName, setNewListName] = useState("");
|
const [newListName, setNewListName] = useState("");
|
||||||
|
const [menuOpenFor, setMenuOpenFor] = useState<string | null>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const pickerRef = 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
|
// Portfolio summary
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.portfolio.holdings().then(setHoldings).catch(() => {});
|
api.portfolio.holdings().then(setHoldings).catch(() => {});
|
||||||
@@ -46,8 +58,10 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
|||||||
try {
|
try {
|
||||||
const wls = await api.watchlists.listWatchlists();
|
const wls = await api.watchlists.listWatchlists();
|
||||||
setWatchlists(wls);
|
setWatchlists(wls);
|
||||||
if (wls.length > 0 && !wls.find((w) => w.name === activeWatchlist)) {
|
const allNames = wls.map((w) => w.name);
|
||||||
setActiveWatchlist(wls[0].name);
|
if (activeWatchlist && !allNames.includes(activeWatchlist)) {
|
||||||
|
const fallback = wls.find((w) => w.kind === 'user')?.name ?? 'default';
|
||||||
|
setActiveWatchlist(fallback);
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
};
|
};
|
||||||
@@ -87,6 +101,7 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
|||||||
|
|
||||||
const add = async () => {
|
const add = async () => {
|
||||||
if (!draft.trim()) return;
|
if (!draft.trim()) return;
|
||||||
|
if (isSystemList(activeWatchlist)) return;
|
||||||
const sym = draft.trim().toUpperCase();
|
const sym = draft.trim().toUpperCase();
|
||||||
await api.watchlists.addSymbol(sym, activeWatchlist === "default" ? undefined : activeWatchlist);
|
await api.watchlists.addSymbol(sym, activeWatchlist === "default" ? undefined : activeWatchlist);
|
||||||
setDraft("");
|
setDraft("");
|
||||||
@@ -95,11 +110,20 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const remove = async (symbol: string) => {
|
const remove = async (symbol: string) => {
|
||||||
|
if (isSystemList(activeWatchlist)) return;
|
||||||
await api.watchlists.removeSymbol(symbol, activeWatchlist === "default" ? undefined : activeWatchlist);
|
await api.watchlists.removeSymbol(symbol, activeWatchlist === "default" ? undefined : activeWatchlist);
|
||||||
load();
|
load();
|
||||||
loadWatchlists();
|
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 () => {
|
const createWatchlist = async () => {
|
||||||
if (!newListName.trim()) return;
|
if (!newListName.trim()) return;
|
||||||
await api.watchlists.create(newListName.trim());
|
await api.watchlists.create(newListName.trim());
|
||||||
@@ -110,6 +134,7 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deleteWatchlist = async (name: string) => {
|
const deleteWatchlist = async (name: string) => {
|
||||||
|
if (name === "default" || isSystemList(name)) return;
|
||||||
await api.watchlists.delete(name);
|
await api.watchlists.delete(name);
|
||||||
loadWatchlists();
|
loadWatchlists();
|
||||||
if (activeWatchlist === name) {
|
if (activeWatchlist === name) {
|
||||||
@@ -130,6 +155,18 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
|||||||
}
|
}
|
||||||
}, [showListPicker]);
|
}, [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
|
const containerClass = compact
|
||||||
? "w-full border-b border-line bg-surface-raised flex-shrink-0"
|
? "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";
|
: "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>
|
</button>
|
||||||
|
|
||||||
{showListPicker && (
|
{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="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">
|
||||||
<div className="max-h-48 overflow-y-auto">
|
{systemLists.length > 0 && (
|
||||||
{watchlists.map((wl) => (
|
<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); }}>
|
<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={`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-fg-faint">{wl.symbol_count}</span>
|
||||||
@@ -208,8 +258,8 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add symbol form */}
|
{/* Add symbol form — hidden for read-only system/sector lists */}
|
||||||
{!compact && (
|
{!compact && !isSystemList(activeWatchlist) && (
|
||||||
<form onSubmit={(e) => { e.preventDefault(); add(); }} className="flex gap-1">
|
<form onSubmit={(e) => { e.preventDefault(); add(); }} className="flex gap-1">
|
||||||
<label className="sr-only" htmlFor="watchlist-add-symbol">Add watchlist symbol</label>
|
<label className="sr-only" htmlFor="watchlist-add-symbol">Add watchlist symbol</label>
|
||||||
<input
|
<input
|
||||||
@@ -286,14 +336,32 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
|||||||
) : (
|
) : (
|
||||||
<span className="w-14" />
|
<span className="w-14" />
|
||||||
)}
|
)}
|
||||||
{!compact && (
|
{!compact && !isSystemList(activeWatchlist) && (
|
||||||
<button
|
<div className="relative" ref={menuOpenFor === e.symbol ? menuRef : undefined}>
|
||||||
onClick={(ev) => { ev.stopPropagation(); remove(e.symbol); }}
|
<button
|
||||||
aria-label={`Remove ${e.symbol} from watchlist`}
|
onClick={(ev) => { ev.stopPropagation(); setMenuOpenFor(menuOpenFor === e.symbol ? null : e.symbol); }}
|
||||||
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"
|
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>
|
⋯
|
||||||
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+10
-3
@@ -367,6 +367,7 @@ export interface AuthUser {
|
|||||||
riskTolerance?: string;
|
riskTolerance?: string;
|
||||||
convexityPosture?: string;
|
convexityPosture?: string;
|
||||||
onboarded?: boolean;
|
onboarded?: boolean;
|
||||||
|
modules?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8-second timeout for all tRPC calls to prevent hung requests.
|
// 8-second timeout for all tRPC calls to prevent hung requests.
|
||||||
@@ -540,11 +541,13 @@ export const api = {
|
|||||||
watchlists: {
|
watchlists: {
|
||||||
list: () => trpcQuery<WatchlistEntry[]>("watchlists.list"),
|
list: () => trpcQuery<WatchlistEntry[]>("watchlists.list"),
|
||||||
listByWatchlist: (name: string) => trpcQuery<WatchlistEntry[]>("watchlists.listByWatchlist", { name }),
|
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"),
|
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 }>("watchlists.create", { name }),
|
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 }),
|
delete: (name: string) => trpcMutate<{ deleted: boolean }>("watchlists.delete", { name }),
|
||||||
rename: (oldName: string, newName: string) => trpcMutate<{ renamed: boolean }>("watchlists.rename", { oldName, newName }),
|
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 }),
|
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) =>
|
addSymbol: (symbol: string, watchlistName?: string, notes?: string) =>
|
||||||
trpcMutate<{ added: boolean }>("watchlists.addSymbol", { symbol, ...(watchlistName ? { watchlistName } : {}), ...(notes ? { notes } : {}) }),
|
trpcMutate<{ added: boolean }>("watchlists.addSymbol", { symbol, ...(watchlistName ? { watchlistName } : {}), ...(notes ? { notes } : {}) }),
|
||||||
removeSymbol: (symbol: string, watchlistName?: string) =>
|
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 }),
|
accountsForSymbol: (symbol: string) => trpcQuery<Array<{id:string; handle:string; label:string}>>("x.accountsForSymbol", { symbol }),
|
||||||
},
|
},
|
||||||
admin: {
|
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 }),
|
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 }),
|
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"),
|
queueHealth: () => trpcQuery<Array<{ source: string; status: string; count: number; last_attempt: string | null; retry_count: number; backoff_until: string | null }>>("admin.queueHealth"),
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user