Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 18 additions & 18 deletions BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@ cleared: set when the owner clears a done task from the widget's done list (d
- result: 2026-10-08 · feature/desktop-app-tests-ux · הקישורים והניווט ל-/clients הופנו לדשבורד (שמציג את הלקוחות)

## Associate form labels with their inputs (htmlFor/id)
- status: proposed
- status: done
- added: 2026-10-08
- priority: 3
- complexity: medium
- tokens: 80000
- details: Forms in client/src (Login.jsx, Register.jsx, ClientModal.jsx, ProjectModal.jsx, TaskModal.jsx and other modals) render <label className="form-label"> as a sibling of the control with no htmlFor/id, so screen readers and Playwright getByLabel can't resolve them (e2e/helpers.js has a field() workaround). Add useId-based id/htmlFor pairs. Done when getByLabel('שם לקוח *') etc. resolve in the e2e specs, client unit tests and npm run test:e2e pass.
- result:
- result: 2026-10-08 · feature/backlog-fixes · כל התוויות בטפסים ובמודאלים מקושרות לשדות (useId), ה-e2e משתמש ב-getByLabel

## Make the closed mobile sidebar inert
- status: done
Expand All @@ -42,73 +42,73 @@ cleared: set when the owner clears a done task from the widget's done list (d
- result: 2026-10-08 · feature/desktop-app-tests-ux · הסיידבר הסגור במובייל מקבל inert, כך שאינו נגיש ב-Tab ובקורא מסך

## Fix forced password reset bypass on login
- status: proposed
- status: done
- added: 2026-10-08
- priority: 1
- complexity: medium
- tokens: 200000
- details: client/src/App.jsx GuestRoute + client/src/store/useStore.js:100 completeAuth: when the server answers requiresPasswordReset, completeAuth marks the user authenticated and GuestRoute redirects to / before Login.jsx opens its reset prompt, so the admin-forced reset never happens. Also the new-password prompt renders type=text (client/src/components/Modal/CustomModal.jsx:91 ignores the 'password' type passed from Login.jsx:93). Done when the it.fails tests in client/src/App.test.jsx pass as normal tests, the prompt masks the password, and client unit tests + npm run test:e2e pass.
- result:
- result: 2026-10-08 · feature/backlog-fixes · איפוס כפוי נאכף גם בשרת (token ייעודי ל-15 דקות, אין session לפני האיפוס), שדה הסיסמה מוסתר, הגבלת ניסיונות

## Make addon enablement per workspace, not per user
- status: proposed
- status: done
- added: 2026-10-08
- priority: 2
- complexity: medium
- tokens: 200000
- details: server/database.js:409 user_addons has UNIQUE(user_id, addon_id), so a member of two workspaces gets a 500 when enabling the same addon in the second one. Rebuild the table with UNIQUE(workspace_id, addon_id) in a migration that keeps existing rows (back up first). Done when the it.fails test 'a member of two workspaces can toggle the same addon in both' in server/tests/catalog-sources-addons.test.js passes as a normal test and npm --prefix server test is green.
- result:
- result: 2026-10-08 · feature/backlog-fixes · מפתח ייחודי (workspace_id, addon_id) עם migration שנבדק על עותק; גם integrations

## Stop account deletion and workspace deletion from orphaning or wiping shared data
- status: proposed
- status: done
- added: 2026-10-08
- priority: 2
- complexity: medium
- tokens: 200000
- details: server/database.js: clients.user_id is ON DELETE CASCADE, so a member deleting their account wipes clients (and their projects/entries) they created in someone else's workspace; deleting a workspace (server/routes/workspaces.js:154) leaves its clients/projects/entries orphaned because workspace_id has no FK. Decide ownership by workspace: reassign or keep shared records, and delete workspace-scoped rows explicitly when a workspace is deleted. Done when the two it.fails tests in server/tests/auth-profile-admin.test.js:113 and server/tests/workspaces-isolation.test.js:380 pass as normal tests.
- result:
- result: 2026-10-08 · feature/backlog-fixes · מחיקת workspace מוחקת את כל הנתונים שלו; מחיקת חשבון מעבירה רשומות משותפות לבעלים

## Fix monthly recurrence on the 29th-31st
- status: proposed
- status: done
- added: 2026-10-08
- priority: 3
- complexity: low
- tokens: 100000
- details: server/routes/reminders.js:217 and server/routes/planned-slots.js:38 use setMonth for monthly repeats, which overflows into the next month when the start day is 29-31 (Jan 31 -> Mar 3). Clamp to the last day of the target month. Also planned-slots.js:115 should 400 when the series end is before the start. Done when the three it.fails tests in server/tests/reminders-alerts-slots.test.js pass as normal tests.
- result:
- result: 2026-10-08 · feature/backlog-fixes · קיטום לסוף חודש עם שמירת היום המקורי (recurrence_day), ו-400 לסדרה שמסתיימת לפני תחילתה

## Small API validation gaps found by the test suite
- status: proposed
- status: done
- added: 2026-10-08
- priority: 3
- complexity: low
- tokens: 100000
- details: Fix the remaining it.fails cases: credentials PUT wipes omitted fields (server/routes/credentials.js:190), quick status change away from paid keeps paid_date (server/routes/payments.js:494), expenses PUT accepts amount <= 0 (server/routes/expenses.js:265), catalog price 0 stored as NULL (server/routes/catalog.js:76,112). Done when those it.fails tests in server/tests pass as normal tests and npm --prefix server test is green.
- result:
- result: 2026-10-08 · feature/backlog-fixes · credentials/payments/expenses/catalog: שדות שלא נשלחו נשמרים, paid_date מתנקה, סכום לא חיובי נדחה, מחיר 0 נשמר

## Restrict who can manage global client sources and addon keys
- status: proposed
- status: done
- added: 2026-10-08
- priority: 2
- complexity: medium
- tokens: 200000
- details: server/routes/client_sources.js:99,120 lets any user create a global source (visible to all workspaces) or claim a global one for their workspace; server/routes/addons.js lets any workspace member, any role, change addon API keys. Limit global sources to system admins and addon secrets to workspace owner/admin. Done when new tests in server/tests/catalog-sources-addons.test.js cover both rules and the suite is green.
- result:
- result: 2026-10-08 · feature/backlog-fixes · מקורות גלובליים רק לאדמין מערכת; תוספים ומפתחות API רק לבעלים/מנהל

## Agree on day boundaries between StatsBar and the stats API
- status: proposed
- status: done
- added: 2026-10-08
- priority: 3
- complexity: medium
- tokens: 200000
- details: client/src/components/StatsBar.jsx sends date ranges as UTC midnight while server/routes/stats.js:91-94 rounds to UTC days, so in Israel the dashboard range is shifted by 2-3 hours. Pick one rule (local day boundaries sent as ISO with offset is the natural one), apply it on both sides, and add tests on both sides. Done when a range for 'today' in Asia/Jerusalem counts an entry at 01:00 local time.
- result:
- result: 2026-10-08 · feature/backlog-fixes · טווחים וחודשים נשלחים כרגעים מקומיים והשרת משתמש בהם כמו שהם

## Split the client bundle per route
- status: proposed
- status: done
- added: 2026-10-08
- priority: 3
- complexity: medium
- tokens: 200000
- details: client is a single 1MB JS chunk. Convert the page imports in client/src/App.jsx to React.lazy with Suspense around the Layout Outlet. Caution: page CSS files define classes used by other pages, so keep CSS in one file (vite build.cssCodeSplit: false) and keep the import order, then compare screenshots of every route before/after. Done when the main chunk is under 500kB and npm run test:e2e passes.
- result:
- result: 2026-10-08 · feature/backlog-fixes · React.lazy לכל העמודים, CSS אחד באותו סדר; החבילה הראשית ירדה מ-1,026kB ל-412kB
77 changes: 53 additions & 24 deletions client/src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,67 @@
import { useEffect, useState } from 'react';
import { lazy, useEffect, useState } from 'react';
import { Routes, Route, Navigate, Link } from 'react-router-dom';
import { authAPI } from './services/api';
import useStore from './store/useStore';
import { ModalProvider } from './components/Modal';
import { TimerSyncProvider } from './components/TimerSyncProvider';
import ErrorBoundary from './components/ErrorBoundary';
import RouteSuspense from './components/RouteSuspense';

// Layout
import Layout from './components/Layout';
import AuthLayout from './components/AuthLayout';

// Pages
// Pages on the first screen a user sees stay in the main bundle
import Login from './pages/Login';
import Register from './pages/Register';
import Dashboard from './pages/Dashboard';
import ClientDetail from './pages/ClientDetail';
import Projects from './pages/Projects';
import ProjectDetail from './pages/ProjectDetail';
import TaskDetail from './pages/TaskDetail';
import Tasks from './pages/Tasks';
import Profile from './pages/Profile';
import SharedClient from './pages/SharedClient';
import SharedProject from './pages/SharedProject';
import SharedAccess from './pages/SharedAccess';
import SharedWithMe from './pages/SharedWithMe';
import Reminders from './pages/Reminders';
import AdminPanel from './pages/AdminPanel';
import SettingsPage from './pages/SettingsPage';
import CredentialsPage from './pages/CredentialsPage';
import LeadsManagement from './pages/LeadsManagement';
import LeadDetail from './pages/LeadDetail';
import TimeEntries from './pages/TimeEntries';
import Payments from './pages/Payments';
import Schedule from './pages/Schedule';
import WorkspaceSettings from './pages/WorkspaceSettings';
import JoinWorkspace from './pages/JoinWorkspace';
import CatalogPage from './pages/CatalogPage';

// The CSS of the lazy pages below, in its original cascade order (see routeStyles.js)
import './routeStyles';

// After a new build is deployed, an open tab still asks for the previous build's chunk files, which
// are gone. Reload once to pick up the new build instead of showing the error screen.
const RELOADED_FOR_CHUNK = 'clockwize:reloaded-for-chunk';
const lazyPage = (load) => lazy(() => load().then(
(module) => {
try { sessionStorage.removeItem(RELOADED_FOR_CHUNK); } catch { /* storage unavailable */ }
return module;
},
(error) => {
let reloaded = true;
try {
reloaded = sessionStorage.getItem(RELOADED_FOR_CHUNK) === '1';
if (!reloaded) sessionStorage.setItem(RELOADED_FOR_CHUNK, '1');
} catch { /* storage unavailable - don't risk a reload loop */ }
if (reloaded) throw error;
window.location.reload();
return new Promise(() => {});
}
));

// Every other page is split into its own chunk, loaded on first visit
const ClientDetail = lazyPage(() => import('./pages/ClientDetail'));
const Projects = lazyPage(() => import('./pages/Projects'));
const ProjectDetail = lazyPage(() => import('./pages/ProjectDetail'));
const TaskDetail = lazyPage(() => import('./pages/TaskDetail'));
const Tasks = lazyPage(() => import('./pages/Tasks'));
const Profile = lazyPage(() => import('./pages/Profile'));
const SharedClient = lazyPage(() => import('./pages/SharedClient'));
const SharedProject = lazyPage(() => import('./pages/SharedProject'));
const SharedAccess = lazyPage(() => import('./pages/SharedAccess'));
const SharedWithMe = lazyPage(() => import('./pages/SharedWithMe'));
const Reminders = lazyPage(() => import('./pages/Reminders'));
const AdminPanel = lazyPage(() => import('./pages/AdminPanel'));
const SettingsPage = lazyPage(() => import('./pages/SettingsPage'));
const CredentialsPage = lazyPage(() => import('./pages/CredentialsPage'));
const LeadsManagement = lazyPage(() => import('./pages/LeadsManagement'));
const LeadDetail = lazyPage(() => import('./pages/LeadDetail'));
const TimeEntries = lazyPage(() => import('./pages/TimeEntries'));
const Payments = lazyPage(() => import('./pages/Payments'));
const Schedule = lazyPage(() => import('./pages/Schedule'));
const WorkspaceSettings = lazyPage(() => import('./pages/WorkspaceSettings'));
const JoinWorkspace = lazyPage(() => import('./pages/JoinWorkspace'));
const CatalogPage = lazyPage(() => import('./pages/CatalogPage'));

// Protected Route
const ProtectedRoute = ({ children }) => {
Expand Down Expand Up @@ -138,6 +164,8 @@ function App() {
<ErrorBoundary>
<ModalProvider>
<TimerSyncProvider>
{/* Pages inside Layout have their own boundary around the <Outlet>; this one covers the rest */}
<RouteSuspense fullPage>
<Routes>
{/* New unified share access (with password/email support) */}
<Route path="/s/:token" element={<SharedAccess />} />
Expand Down Expand Up @@ -206,6 +234,7 @@ function App() {
{/* Catch all */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</RouteSuspense>
</TimerSyncProvider>
</ModalProvider>
</ErrorBoundary>
Expand Down
50 changes: 43 additions & 7 deletions client/src/App.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, useLocation } from 'react-router-dom';

const login = vi.hoisted(() => vi.fn());
const { login, resetPassword } = vi.hoisted(() => ({ login: vi.fn(), resetPassword: vi.fn() }));

vi.mock('./services/api', async (importOriginal) => {
const actual = await importOriginal();
return { ...actual, authAPI: { ...actual.authAPI, login } };
return { ...actual, authAPI: { ...actual.authAPI, login, resetPassword } };
});
vi.mock('@simplewebauthn/browser', () => ({
startAuthentication: vi.fn(),
Expand Down Expand Up @@ -43,11 +43,13 @@ const loginThroughApp = async () => {
await user.type(await screen.findByPlaceholderText('admin או your@email.com'), 'dana@example.com');
await user.type(screen.getByPlaceholderText('••••••••'), 'old-password');
await user.click(screen.getByRole('button', { name: 'התחבר' }));
return user;
};

beforeEach(() => {
useStore.setState(initialState, true);
login.mockReset();
resetPassword.mockReset();
// Everything other than login goes through the real api module to a stubbed backend
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true,
Expand All @@ -69,14 +71,48 @@ describe('App routing around login', () => {
expect(screen.queryByPlaceholderText('••••••••')).not.toBeInTheDocument();
});

// completeAuth() sets isAuthenticated before Login can open the reset prompt, so GuestRoute
// (App.jsx) redirects to "/" and unmounts Login: a user the admin flagged for a forced
// password reset goes straight to the dashboard and is never asked to change it
it.fails('a user flagged for a forced password reset is asked for a new password first', async () => {
login.mockResolvedValue(loginResponse({ requiresPasswordReset: true }));
// The login response of a flagged user is not a session yet: GuestRoute must keep Login (and its
// reset prompt) on screen instead of redirecting to the dashboard
it('a user flagged for a forced password reset is asked for a new password first', async () => {
login.mockResolvedValue({ requiresPasswordReset: true, resetToken: 'reset-jwt' });

await loginThroughApp();

expect(await screen.findByRole('heading', { name: 'נדרש איפוס סיסמה' })).toBeInTheDocument();
expect(screen.getByLabelText('הזן סיסמה חדשה (לפחות 4 תווים):')).toHaveAttribute('type', 'password');
expect(screen.getByTestId('path')).toHaveTextContent(/^\/login$/);
expect(useStore.getState().isAuthenticated).toBe(false);
expect(localStorage.getItem('token')).toBeNull();
});

it('cancelling the forced reset keeps the user logged out on /login', async () => {
login.mockResolvedValue({ requiresPasswordReset: true, resetToken: 'reset-jwt' });

const user = await loginThroughApp();
await screen.findByRole('heading', { name: 'נדרש איפוס סיסמה' });
await user.click(screen.getByRole('button', { name: 'ביטול' }));

expect(await screen.findByText('חובה לשנות סיסמה כדי להמשיך')).toBeInTheDocument();
expect(screen.getByTestId('path')).toHaveTextContent(/^\/login$/);
expect(useStore.getState().isAuthenticated).toBe(false);
expect(localStorage.getItem('token')).toBeNull();
});

it('enters the app only after the new password was saved', async () => {
login.mockResolvedValue({ requiresPasswordReset: true, resetToken: 'reset-jwt' });
resetPassword.mockResolvedValue({ ...loginResponse(), token: 'jwt-after-reset' });

const user = await loginThroughApp();
await user.type(await screen.findByLabelText('הזן סיסמה חדשה (לפחות 4 תווים):'), 'new-password');
await user.click(screen.getByRole('button', { name: 'אישור' }));

expect(resetPassword).toHaveBeenCalledWith({ resetToken: 'reset-jwt', newPassword: 'new-password' });
expect(await screen.findByText('סיסמה שונתה בהצלחה!')).toBeInTheDocument();
expect(useStore.getState().isAuthenticated).toBe(false);
await user.click(screen.getByRole('button', { name: 'אישור' }));

await waitFor(() => expect(screen.getByTestId('path')).toHaveTextContent(/^\/$/));
expect(useStore.getState().isAuthenticated).toBe(true);
expect(localStorage.getItem('token')).toBe('jwt-after-reset');
});
});
9 changes: 6 additions & 3 deletions client/src/apps/morning/Settings.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useId } from 'react';
import { Check, AlertCircle, Loader, Search, Settings, LogOut } from 'lucide-react';
import api from '../../services/api';
import '../../pages/Settings.css';
Expand Down Expand Up @@ -97,6 +97,7 @@ function SearchableSelect({ options, value, onChange, placeholder }) {
}

const MorningSettings = ({ integration, onUpdate }) => {
const uid = useId();
const [connecting, setConnecting] = useState(false);
const [apiKey, setApiKey] = useState('');
const [apiSecret, setApiSecret] = useState('');
Expand Down Expand Up @@ -540,8 +541,9 @@ const MorningSettings = ({ integration, onUpdate }) => {
<div className="integration-body">
<form onSubmit={handleConnect} className="connect-form">
<div className="form-group">
<label className="form-label">API Key (ID)</label>
<label className="form-label" htmlFor={`${uid}-api-key`}>API Key (ID)</label>
<input
id={`${uid}-api-key`}
type="text"
className="form-input"
value={apiKey}
Expand All @@ -551,8 +553,9 @@ const MorningSettings = ({ integration, onUpdate }) => {
/>
</div>
<div className="form-group">
<label className="form-label">API Secret</label>
<label className="form-label" htmlFor={`${uid}-api-secret`}>API Secret</label>
<input
id={`${uid}-api-secret`}
type="password"
className="form-input"
value={apiSecret}
Expand Down
Loading
Loading