Compare commits
4 Commits
2b72951e66
...
fix/viewer
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d137a40d3 | |||
| 3bf6b8c6c9 | |||
| 4759374883 | |||
| cb6e34d5ce |
@@ -153,6 +153,14 @@ export class OrganizationsService {
|
|||||||
existing.role = data.role;
|
existing.role = data.role;
|
||||||
return this.userOrgRepository.save(existing);
|
return this.userOrgRepository.save(existing);
|
||||||
}
|
}
|
||||||
|
// Update password for existing user being added to a new org
|
||||||
|
if (data.password) {
|
||||||
|
const passwordHash = await bcrypt.hash(data.password, 12);
|
||||||
|
await dataSource.query(
|
||||||
|
`UPDATE shared.users SET password_hash = $1 WHERE id = $2`,
|
||||||
|
[passwordHash, userId],
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Create new user
|
// Create new user
|
||||||
const passwordHash = await bcrypt.hash(data.password, 12);
|
const passwordHash = await bcrypt.hash(data.password, 12);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button } from '@mantine/core';
|
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button, ActionIcon, Tooltip } from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import {
|
import {
|
||||||
IconLogout,
|
IconLogout,
|
||||||
@@ -9,9 +9,12 @@ import {
|
|||||||
IconUserCog,
|
IconUserCog,
|
||||||
IconUsersGroup,
|
IconUsersGroup,
|
||||||
IconEyeOff,
|
IconEyeOff,
|
||||||
|
IconSun,
|
||||||
|
IconMoon,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
import { AppTour } from '../onboarding/AppTour';
|
import { AppTour } from '../onboarding/AppTour';
|
||||||
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
|
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
|
||||||
@@ -20,6 +23,7 @@ import logoSrc from '../../assets/logo.svg';
|
|||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const [opened, { toggle, close }] = useDisclosure();
|
const [opened, { toggle, close }] = useDisclosure();
|
||||||
const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore();
|
const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore();
|
||||||
|
const { colorScheme, toggleColorScheme } = usePreferencesStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isImpersonating = !!impersonationOriginal;
|
const isImpersonating = !!impersonationOriginal;
|
||||||
@@ -108,6 +112,16 @@ export function AppLayout() {
|
|||||||
{currentOrg && (
|
{currentOrg && (
|
||||||
<Text size="sm" c="dimmed">{currentOrg.name}</Text>
|
<Text size="sm" c="dimmed">{currentOrg.name}</Text>
|
||||||
)}
|
)}
|
||||||
|
<Tooltip label={colorScheme === 'dark' ? 'Light mode' : 'Dark mode'}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
onClick={toggleColorScheme}
|
||||||
|
aria-label="Toggle color scheme"
|
||||||
|
>
|
||||||
|
{colorScheme === 'dark' ? <IconSun size={18} /> : <IconMoon size={18} />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
<Menu shadow="md" width={220}>
|
<Menu shadow="md" width={220}>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<UnstyledButton>
|
<UnstyledButton>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import '@mantine/dates/styles.css';
|
|||||||
import '@mantine/notifications/styles.css';
|
import '@mantine/notifications/styles.css';
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
import { theme } from './theme/theme';
|
import { theme } from './theme/theme';
|
||||||
|
import { usePreferencesStore } from './stores/preferencesStore';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -21,9 +22,11 @@ const queryClient = new QueryClient({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
function Root() {
|
||||||
<React.StrictMode>
|
const colorScheme = usePreferencesStore((s) => s.colorScheme);
|
||||||
<MantineProvider theme={theme}>
|
|
||||||
|
return (
|
||||||
|
<MantineProvider theme={theme} forceColorScheme={colorScheme}>
|
||||||
<Notifications position="top-right" />
|
<Notifications position="top-right" />
|
||||||
<ModalsProvider>
|
<ModalsProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
@@ -33,5 +36,11 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
|||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ModalsProvider>
|
</ModalsProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<Root />
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -587,7 +587,7 @@ export function AccountsPage() {
|
|||||||
{investments.filter(i => i.is_active).length > 0 && (
|
{investments.filter(i => i.is_active).length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider label="Investment Accounts" labelPosition="center" my="xs" />
|
<Divider label="Investment Accounts" labelPosition="center" my="xs" />
|
||||||
<InvestmentMiniTable investments={investments.filter(i => i.is_active)} onEdit={handleEditInvestment} />
|
<InvestmentMiniTable investments={investments.filter(i => i.is_active)} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -605,7 +605,7 @@ export function AccountsPage() {
|
|||||||
{operatingInvestments.length > 0 && (
|
{operatingInvestments.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider label="Operating Investment Accounts" labelPosition="center" my="xs" />
|
<Divider label="Operating Investment Accounts" labelPosition="center" my="xs" />
|
||||||
<InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} />
|
<InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -623,7 +623,7 @@ export function AccountsPage() {
|
|||||||
{reserveInvestments.length > 0 && (
|
{reserveInvestments.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider label="Reserve Investment Accounts" labelPosition="center" my="xs" />
|
<Divider label="Reserve Investment Accounts" labelPosition="center" my="xs" />
|
||||||
<InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} />
|
<InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -1087,9 +1087,11 @@ function AccountTable({
|
|||||||
function InvestmentMiniTable({
|
function InvestmentMiniTable({
|
||||||
investments,
|
investments,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
isReadOnly = false,
|
||||||
}: {
|
}: {
|
||||||
investments: Investment[];
|
investments: Investment[];
|
||||||
onEdit: (inv: Investment) => void;
|
onEdit: (inv: Investment) => void;
|
||||||
|
isReadOnly?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0);
|
const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0);
|
||||||
const totalValue = investments.reduce(
|
const totalValue = investments.reduce(
|
||||||
@@ -1132,7 +1134,7 @@ function InvestmentMiniTable({
|
|||||||
<Table.Th ta="right">Maturity Value</Table.Th>
|
<Table.Th ta="right">Maturity Value</Table.Th>
|
||||||
<Table.Th>Maturity Date</Table.Th>
|
<Table.Th>Maturity Date</Table.Th>
|
||||||
<Table.Th ta="right">Days Remaining</Table.Th>
|
<Table.Th ta="right">Days Remaining</Table.Th>
|
||||||
<Table.Th></Table.Th>
|
{!isReadOnly && <Table.Th></Table.Th>}
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
@@ -1182,13 +1184,15 @@ function InvestmentMiniTable({
|
|||||||
'-'
|
'-'
|
||||||
)}
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
{!isReadOnly && (
|
||||||
<Tooltip label="Edit investment">
|
<Table.Td>
|
||||||
<ActionIcon variant="subtle" onClick={() => onEdit(inv)}>
|
<Tooltip label="Edit investment">
|
||||||
<IconEdit size={16} />
|
<ActionIcon variant="subtle" onClick={() => onEdit(inv)}>
|
||||||
</ActionIcon>
|
<IconEdit size={16} />
|
||||||
</Tooltip>
|
</ActionIcon>
|
||||||
</Table.Td>
|
</Tooltip>
|
||||||
|
</Table.Td>
|
||||||
|
)}
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
|
|||||||
@@ -72,9 +72,10 @@ interface KanbanCardProps {
|
|||||||
project: Project;
|
project: Project;
|
||||||
onEdit: (p: Project) => void;
|
onEdit: (p: Project) => void;
|
||||||
onDragStart: (e: DragEvent<HTMLDivElement>, project: Project) => void;
|
onDragStart: (e: DragEvent<HTMLDivElement>, project: Project) => void;
|
||||||
|
isReadOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
|
function KanbanCard({ project, onEdit, onDragStart, isReadOnly }: KanbanCardProps) {
|
||||||
const plannedLabel = formatPlannedDate(project.planned_date);
|
const plannedLabel = formatPlannedDate(project.planned_date);
|
||||||
// For projects in the Future bucket with a specific year, show the year
|
// For projects in the Future bucket with a specific year, show the year
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
@@ -86,21 +87,23 @@ function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
|
|||||||
padding="sm"
|
padding="sm"
|
||||||
radius="md"
|
radius="md"
|
||||||
withBorder
|
withBorder
|
||||||
draggable
|
draggable={!isReadOnly}
|
||||||
onDragStart={(e) => onDragStart(e, project)}
|
onDragStart={!isReadOnly ? (e) => onDragStart(e, project) : undefined}
|
||||||
style={{ cursor: 'grab', userSelect: 'none' }}
|
style={{ cursor: isReadOnly ? 'default' : 'grab', userSelect: 'none' }}
|
||||||
mb="xs"
|
mb="xs"
|
||||||
>
|
>
|
||||||
<Group justify="space-between" wrap="nowrap" mb={4}>
|
<Group justify="space-between" wrap="nowrap" mb={4}>
|
||||||
<Group gap={6} wrap="nowrap" style={{ overflow: 'hidden' }}>
|
<Group gap={6} wrap="nowrap" style={{ overflow: 'hidden' }}>
|
||||||
<IconGripVertical size={14} style={{ flexShrink: 0, color: 'var(--mantine-color-dimmed)' }} />
|
{!isReadOnly && <IconGripVertical size={14} style={{ flexShrink: 0, color: 'var(--mantine-color-dimmed)' }} />}
|
||||||
<Text fw={600} size="sm" truncate>
|
<Text fw={600} size="sm" truncate>
|
||||||
{project.name}
|
{project.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}>
|
{!isReadOnly && (
|
||||||
<IconEdit size={14} />
|
<ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}>
|
||||||
</ActionIcon>
|
<IconEdit size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group gap={6} mb={6}>
|
<Group gap={6} mb={6}>
|
||||||
@@ -148,11 +151,12 @@ interface KanbanColumnProps {
|
|||||||
isDragOver: boolean;
|
isDragOver: boolean;
|
||||||
onDragOverHandler: (e: DragEvent<HTMLDivElement>, year: number) => void;
|
onDragOverHandler: (e: DragEvent<HTMLDivElement>, year: number) => void;
|
||||||
onDragLeave: () => void;
|
onDragLeave: () => void;
|
||||||
|
isReadOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function KanbanColumn({
|
function KanbanColumn({
|
||||||
year, projects, onEdit, onDragStart, onDrop,
|
year, projects, onEdit, onDragStart, onDrop,
|
||||||
isDragOver, onDragOverHandler, onDragLeave,
|
isDragOver, onDragOverHandler, onDragLeave, isReadOnly,
|
||||||
}: KanbanColumnProps) {
|
}: KanbanColumnProps) {
|
||||||
const totalEst = projects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
|
const totalEst = projects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
|
||||||
const isFuture = year === FUTURE_YEAR;
|
const isFuture = year === FUTURE_YEAR;
|
||||||
@@ -178,9 +182,9 @@ function KanbanColumn({
|
|||||||
border: isDragOver ? '2px dashed var(--mantine-color-blue-4)' : undefined,
|
border: isDragOver ? '2px dashed var(--mantine-color-blue-4)' : undefined,
|
||||||
transition: 'background-color 150ms ease, border 150ms ease',
|
transition: 'background-color 150ms ease, border 150ms ease',
|
||||||
}}
|
}}
|
||||||
onDragOver={(e) => onDragOverHandler(e, year)}
|
onDragOver={!isReadOnly ? (e) => onDragOverHandler(e, year) : undefined}
|
||||||
onDragLeave={onDragLeave}
|
onDragLeave={!isReadOnly ? onDragLeave : undefined}
|
||||||
onDrop={(e) => onDrop(e, year)}
|
onDrop={!isReadOnly ? (e) => onDrop(e, year) : undefined}
|
||||||
>
|
>
|
||||||
<Group justify="space-between" mb="sm">
|
<Group justify="space-between" mb="sm">
|
||||||
<Title order={5}>{yearLabel(year)}</Title>
|
<Title order={5}>{yearLabel(year)}</Title>
|
||||||
@@ -199,7 +203,7 @@ function KanbanColumn({
|
|||||||
<Box style={{ flex: 1, minHeight: 60 }}>
|
<Box style={{ flex: 1, minHeight: 60 }}>
|
||||||
{projects.length === 0 ? (
|
{projects.length === 0 ? (
|
||||||
<Text size="xs" c="dimmed" ta="center" py="lg">
|
<Text size="xs" c="dimmed" ta="center" py="lg">
|
||||||
Drop projects here
|
{isReadOnly ? 'No projects' : 'Drop projects here'}
|
||||||
</Text>
|
</Text>
|
||||||
) : useWideLayout ? (
|
) : useWideLayout ? (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -208,12 +212,12 @@ function KanbanColumn({
|
|||||||
gap: 'var(--mantine-spacing-xs)',
|
gap: 'var(--mantine-spacing-xs)',
|
||||||
}}>
|
}}>
|
||||||
{projects.map((p) => (
|
{projects.map((p) => (
|
||||||
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} />
|
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} isReadOnly={isReadOnly} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
projects.map((p) => (
|
projects.map((p) => (
|
||||||
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} />
|
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} isReadOnly={isReadOnly} />
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -595,6 +599,7 @@ export function CapitalProjectsPage() {
|
|||||||
isDragOver={dragOverYear === year}
|
isDragOver={dragOverYear === year}
|
||||||
onDragOverHandler={handleDragOver}
|
onDragOverHandler={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
|
||||||
interface HealthScore {
|
interface HealthScore {
|
||||||
@@ -311,6 +311,7 @@ interface DashboardData {
|
|||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const currentOrg = useAuthStore((s) => s.currentOrg);
|
const currentOrg = useAuthStore((s) => s.currentOrg);
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Track whether a refresh is in progress (per score type) for async polling
|
// Track whether a refresh is in progress (per score type) for async polling
|
||||||
@@ -414,7 +415,6 @@ export function DashboardPage() {
|
|||||||
<Center h={200}><Loader /></Center>
|
<Center h={200}><Loader /></Center>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Text size="sm" fw={600} c="dimmed">AI Health Scores</Text>
|
|
||||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||||
<HealthScoreCard
|
<HealthScoreCard
|
||||||
score={healthScores?.operating || null}
|
score={healthScores?.operating || null}
|
||||||
@@ -425,7 +425,7 @@ export function DashboardPage() {
|
|||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
}
|
}
|
||||||
isRefreshing={operatingRefreshing}
|
isRefreshing={operatingRefreshing}
|
||||||
onRefresh={handleRefreshOperating}
|
onRefresh={!isReadOnly ? handleRefreshOperating : undefined}
|
||||||
lastFailed={!!healthScores?.operating_last_failed}
|
lastFailed={!!healthScores?.operating_last_failed}
|
||||||
/>
|
/>
|
||||||
<HealthScoreCard
|
<HealthScoreCard
|
||||||
@@ -437,7 +437,7 @@ export function DashboardPage() {
|
|||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
}
|
}
|
||||||
isRefreshing={reserveRefreshing}
|
isRefreshing={reserveRefreshing}
|
||||||
onRefresh={handleRefreshReserve}
|
onRefresh={!isReadOnly ? handleRefreshReserve : undefined}
|
||||||
lastFailed={!!healthScores?.reserve_last_failed}
|
lastFailed={!!healthScores?.reserve_last_failed}
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
// ── Types ──
|
// ── Types ──
|
||||||
|
|
||||||
@@ -347,6 +348,7 @@ function RecommendationsDisplay({
|
|||||||
export function InvestmentPlanningPage() {
|
export function InvestmentPlanningPage() {
|
||||||
const [ratesExpanded, setRatesExpanded] = useState(true);
|
const [ratesExpanded, setRatesExpanded] = useState(true);
|
||||||
const [isTriggering, setIsTriggering] = useState(false);
|
const [isTriggering, setIsTriggering] = useState(false);
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
// Load financial snapshot on mount
|
// Load financial snapshot on mount
|
||||||
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
|
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
|
||||||
@@ -696,15 +698,17 @@ export function InvestmentPlanningPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
<Button
|
{!isReadOnly && (
|
||||||
leftSection={<IconSparkles size={16} />}
|
<Button
|
||||||
onClick={handleTriggerAI}
|
leftSection={<IconSparkles size={16} />}
|
||||||
loading={isProcessing}
|
onClick={handleTriggerAI}
|
||||||
variant="gradient"
|
loading={isProcessing}
|
||||||
gradient={{ from: 'grape', to: 'violet' }}
|
variant="gradient"
|
||||||
>
|
gradient={{ from: 'grape', to: 'violet' }}
|
||||||
{aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'}
|
>
|
||||||
</Button>
|
{aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Processing State */}
|
{/* Processing State */}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { notifications } from '@mantine/notifications';
|
|||||||
import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
|
import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
interface Invoice {
|
interface Invoice {
|
||||||
id: string; invoice_number: string; unit_number: string; unit_id: string;
|
id: string; invoice_number: string; unit_number: string; unit_id: string;
|
||||||
@@ -64,6 +65,7 @@ export function InvoicesPage() {
|
|||||||
const [preview, setPreview] = useState<Preview | null>(null);
|
const [preview, setPreview] = useState<Preview | null>(null);
|
||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
|
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
|
||||||
queryKey: ['invoices'],
|
queryKey: ['invoices'],
|
||||||
@@ -124,10 +126,12 @@ export function InvoicesPage() {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title order={2}>Invoices</Title>
|
<Title order={2}>Invoices</Title>
|
||||||
<Group>
|
{!isReadOnly && (
|
||||||
<Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button>
|
<Group>
|
||||||
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Invoices</Button>
|
<Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button>
|
||||||
</Group>
|
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Invoices</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Group>
|
<Group>
|
||||||
<Card withBorder p="sm"><Text size="xs" c="dimmed">Total Invoices</Text><Text fw={700}>{invoices.length}</Text></Card>
|
<Card withBorder p="sm"><Text size="xs" c="dimmed">Total Invoices</Text><Text fw={700}>{invoices.length}</Text></Card>
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import {
|
|||||||
IconUser, IconPalette, IconClock, IconBell, IconEye,
|
IconUser, IconPalette, IconClock, IconBell, IconEye,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||||
|
|
||||||
export function UserPreferencesPage() {
|
export function UserPreferencesPage() {
|
||||||
const { user, currentOrg } = useAuthStore();
|
const { user, currentOrg } = useAuthStore();
|
||||||
|
const { colorScheme, toggleColorScheme } = usePreferencesStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
@@ -66,7 +68,10 @@ export function UserPreferencesPage() {
|
|||||||
<Text size="sm">Dark Mode</Text>
|
<Text size="sm">Dark Mode</Text>
|
||||||
<Text size="xs" c="dimmed">Switch to dark color theme</Text>
|
<Text size="xs" c="dimmed">Switch to dark color theme</Text>
|
||||||
</div>
|
</div>
|
||||||
<Switch disabled />
|
<Switch
|
||||||
|
checked={colorScheme === 'dark'}
|
||||||
|
onChange={toggleColorScheme}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -76,7 +81,7 @@ export function UserPreferencesPage() {
|
|||||||
<Switch disabled />
|
<Switch disabled />
|
||||||
</Group>
|
</Group>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Text size="xs" c="dimmed" ta="center">Display preferences coming in a future release</Text>
|
<Text size="xs" c="dimmed" ta="center">More display preferences coming in a future release</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
26
frontend/src/stores/preferencesStore.ts
Normal file
26
frontend/src/stores/preferencesStore.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
type ColorScheme = 'light' | 'dark';
|
||||||
|
|
||||||
|
interface PreferencesState {
|
||||||
|
colorScheme: ColorScheme;
|
||||||
|
toggleColorScheme: () => void;
|
||||||
|
setColorScheme: (scheme: ColorScheme) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePreferencesStore = create<PreferencesState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
colorScheme: 'light',
|
||||||
|
toggleColorScheme: () =>
|
||||||
|
set((state) => ({
|
||||||
|
colorScheme: state.colorScheme === 'light' ? 'dark' : 'light',
|
||||||
|
})),
|
||||||
|
setColorScheme: (scheme) => set({ colorScheme: scheme }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'ledgeriq-preferences',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
150
scripts/reset-password.sh
Executable file
150
scripts/reset-password.sh
Executable file
@@ -0,0 +1,150 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# reset-password.sh — Reset a user's password in HOA LedgerIQ
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/reset-password.sh <email> <new-password>
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# ./scripts/reset-password.sh admin@hoaledgeriq.com MyNewPassword123
|
||||||
|
# ./scripts/reset-password.sh admin@sunrisevalley.org SecurePass!
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ---- Defaults ----
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
DB_USER="${POSTGRES_USER:-hoafinance}"
|
||||||
|
DB_NAME="${POSTGRES_DB:-hoafinance}"
|
||||||
|
COMPOSE_CMD="docker compose"
|
||||||
|
|
||||||
|
# If running with the SSL override, detect it
|
||||||
|
if [ -f "$PROJECT_DIR/docker-compose.ssl.yml" ] && \
|
||||||
|
docker compose -f "$PROJECT_DIR/docker-compose.yml" \
|
||||||
|
-f "$PROJECT_DIR/docker-compose.ssl.yml" ps --quiet 2>/dev/null | head -1 | grep -q .; then
|
||||||
|
COMPOSE_CMD="docker compose -f $PROJECT_DIR/docker-compose.yml -f $PROJECT_DIR/docker-compose.ssl.yml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- Colors ----
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${CYAN}[INFO]${NC} $*"; }
|
||||||
|
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||||
|
err() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
||||||
|
die() { err "$@"; exit 1; }
|
||||||
|
|
||||||
|
# ---- Helpers ----
|
||||||
|
|
||||||
|
ensure_containers_running() {
|
||||||
|
if ! $COMPOSE_CMD ps postgres 2>/dev/null | grep -q "running\|Up"; then
|
||||||
|
die "PostgreSQL container is not running. Start it with: docker compose up -d postgres"
|
||||||
|
fi
|
||||||
|
if ! $COMPOSE_CMD ps backend 2>/dev/null | grep -q "running\|Up"; then
|
||||||
|
die "Backend container is not running. Start it with: docker compose up -d backend"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- CLI ----
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
HOA LedgerIQ Password Reset
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
$(basename "$0") <email> <new-password>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
$(basename "$0") admin@hoaledgeriq.com MyNewPassword123
|
||||||
|
$(basename "$0") admin@sunrisevalley.org SecurePass!
|
||||||
|
|
||||||
|
This script:
|
||||||
|
1. Verifies the user exists in the database
|
||||||
|
2. Generates a bcrypt hash using bcryptjs (same library the app uses)
|
||||||
|
3. Updates the password in the database
|
||||||
|
4. Verifies the new hash works
|
||||||
|
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse args
|
||||||
|
case "${1:-}" in
|
||||||
|
-h|--help|help|"") usage ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
[ $# -lt 2 ] && die "Usage: $(basename "$0") <email> <new-password>"
|
||||||
|
|
||||||
|
EMAIL="$1"
|
||||||
|
NEW_PASSWORD="$2"
|
||||||
|
|
||||||
|
# Load .env if present
|
||||||
|
if [ -f "$PROJECT_DIR/.env" ]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$PROJECT_DIR/.env"
|
||||||
|
set +a
|
||||||
|
DB_USER="${POSTGRES_USER:-hoafinance}"
|
||||||
|
DB_NAME="${POSTGRES_DB:-hoafinance}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure containers are running
|
||||||
|
info "Checking containers ..."
|
||||||
|
ensure_containers_running
|
||||||
|
|
||||||
|
# Verify user exists
|
||||||
|
info "Looking up user: ${EMAIL} ..."
|
||||||
|
USER_RECORD=$($COMPOSE_CMD exec -T postgres psql -U "$DB_USER" -d "$DB_NAME" \
|
||||||
|
-t -A -c "SELECT id, email, first_name, last_name, is_superadmin FROM shared.users WHERE email = '${EMAIL}';" 2>/dev/null)
|
||||||
|
|
||||||
|
if [ -z "$USER_RECORD" ]; then
|
||||||
|
die "No user found with email: ${EMAIL}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse user info for display
|
||||||
|
IFS='|' read -r USER_ID USER_EMAIL FIRST_NAME LAST_NAME IS_SUPER <<< "$USER_RECORD"
|
||||||
|
info "Found user: ${FIRST_NAME} ${LAST_NAME} (${USER_EMAIL})"
|
||||||
|
if [ "$IS_SUPER" = "t" ]; then
|
||||||
|
warn "This is a superadmin account"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Generate bcrypt hash using bcryptjs inside the backend container
|
||||||
|
info "Generating bcrypt hash ..."
|
||||||
|
HASH=$($COMPOSE_CMD exec -T backend node -e "
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
bcrypt.hash(process.argv[1], 12).then(h => process.stdout.write(h));
|
||||||
|
" "$NEW_PASSWORD" 2>/dev/null)
|
||||||
|
|
||||||
|
if [ -z "$HASH" ] || [ ${#HASH} -lt 50 ]; then
|
||||||
|
die "Failed to generate bcrypt hash. Is the backend container running?"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update the password using a heredoc to avoid shell escaping issues with $ in hashes
|
||||||
|
info "Updating password ..."
|
||||||
|
UPDATE_RESULT=$($COMPOSE_CMD exec -T postgres psql -U "$DB_USER" -d "$DB_NAME" -t -A <<EOSQL
|
||||||
|
UPDATE shared.users SET password_hash = '${HASH}', updated_at = NOW() WHERE email = '${EMAIL}';
|
||||||
|
EOSQL
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "$UPDATE_RESULT" != *"UPDATE 1"* ]]; then
|
||||||
|
die "Password update failed. Result: ${UPDATE_RESULT}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify the new hash works
|
||||||
|
info "Verifying new password ..."
|
||||||
|
VERIFY=$($COMPOSE_CMD exec -T backend node -e "
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
bcrypt.compare(process.argv[1], process.argv[2]).then(r => process.stdout.write(String(r)));
|
||||||
|
" "$NEW_PASSWORD" "$HASH" 2>/dev/null)
|
||||||
|
|
||||||
|
if [ "$VERIFY" != "true" ]; then
|
||||||
|
die "Verification failed — the hash does not match the password. Something went wrong."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
ok "Password reset successful!"
|
||||||
|
echo ""
|
||||||
|
info " User: ${FIRST_NAME} ${LAST_NAME} (${USER_EMAIL})"
|
||||||
|
info " Login: ${EMAIL}"
|
||||||
|
echo ""
|
||||||
Reference in New Issue
Block a user