diff --git a/README.md b/README.md index a6885d95..e7d4f89b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

- Worklenz Logo + Worklenz Logo
Worklenz @@ -315,6 +315,7 @@ docker-compose up -d docker-compose down ``` + ## MinIO Integration The project uses MinIO as an S3-compatible object storage service, which provides an open-source alternative to AWS S3 for development and production. @@ -403,6 +404,10 @@ This script generates properly configured environment files for both development - Frontend: http://localhost:5000 - Backend API: http://localhost:3000 (or https://localhost:3000 with SSL) +4. Video Guide + + For a visual walkthrough of the local Docker deployment process, check out our [step-by-step video guide](https://www.youtube.com/watch?v=AfwAKxJbqLg). + ### Remote Server Deployment When deploying to a remote server: @@ -428,6 +433,10 @@ When deploying to a remote server: - Frontend: http://your-server-hostname:5000 - Backend API: http://your-server-hostname:3000 +4. Video Guide + + For a complete walkthrough of deploying Worklenz to a remote server, check out our [deployment video guide](https://www.youtube.com/watch?v=CAZGu2iOXQs&t=10s). + ### Environment Configuration The Docker setup uses environment variables to configure the services: diff --git a/update-docker-env.sh b/update-docker-env.sh index 77ab1beb..7852e86f 100755 --- a/update-docker-env.sh +++ b/update-docker-env.sh @@ -73,8 +73,8 @@ cat > worklenz-backend/.env << EOL NODE_ENV=production PORT=3000 SESSION_NAME=worklenz.sid -SESSION_SECRET=change_me_in_production -COOKIE_SECRET=change_me_in_production +SESSION_SECRET=$(openssl rand -base64 48) +COOKIE_SECRET=$(openssl rand -base64 48) # CORS SOCKET_IO_CORS=${FRONTEND_URL} @@ -123,7 +123,7 @@ SLACK_WEBHOOK= COMMIT_BUILD_IMMEDIATELY=true # JWT Secret -JWT_SECRET=change_me_in_production +JWT_SECRET=$(openssl rand -base64 48) EOL echo "Environment configuration updated for ${HOSTNAME} with" $([ "$USE_SSL" = "true" ] && echo "HTTPS/WSS" || echo "HTTP/WS") @@ -138,4 +138,4 @@ echo "Frontend URL: ${FRONTEND_URL}" echo "API URL: ${HTTP_PREFIX}${HOSTNAME}:3000" echo "Socket URL: ${WS_PREFIX}${HOSTNAME}:3000" echo "MinIO Dashboard URL: ${MINIO_DASHBOARD_URL}" -echo "CORS is configured to allow requests from: ${FRONTEND_URL}" \ No newline at end of file +echo "CORS is configured to allow requests from: ${FRONTEND_URL}" diff --git a/worklenz-backend/src/controllers/auth-controller.ts b/worklenz-backend/src/controllers/auth-controller.ts index 8364d59c..4fea4f59 100644 --- a/worklenz-backend/src/controllers/auth-controller.ts +++ b/worklenz-backend/src/controllers/auth-controller.ts @@ -35,8 +35,18 @@ export default class AuthController extends WorklenzControllerBase { const auth_error = errors.length > 0 ? errors[0] : null; const message = messages.length > 0 ? messages[0] : null; - const midTitle = req.query.strategy === "login" ? "Login Failed!" : "Signup Failed!"; - const title = req.query.strategy ? midTitle : null; + // Determine title based on authentication status and strategy + let title = null; + if (req.query.strategy) { + if (auth_error) { + // Show failure title only when there's an actual error + title = req.query.strategy === "login" ? "Login Failed!" : "Signup Failed!"; + } else if (req.isAuthenticated() && message) { + // Show success title when authenticated and there's a success message + title = req.query.strategy === "login" ? "Login Successful!" : "Signup Successful!"; + } + // If no error and not authenticated, don't show any title (this might be a redirect without completion) + } if (req.user) req.user.build_v = FileConstants.getRelease(); diff --git a/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts b/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts index 7d29fae8..d71c4a36 100644 --- a/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts +++ b/worklenz-backend/src/passport/passport-strategies/passport-local-login.ts @@ -3,13 +3,16 @@ import { Strategy as LocalStrategy } from "passport-local"; import { log_error } from "../../shared/utils"; import db from "../../config/db"; import { Request } from "express"; +import { ERROR_KEY, SUCCESS_KEY } from "./passport-constants"; async function handleLogin(req: Request, email: string, password: string, done: any) { - console.log("Login attempt for:", email); + // Clear any existing flash messages + (req.session as any).flash = {}; if (!email || !password) { - console.log("Missing credentials"); - return done(null, false, { message: "Please enter both email and password" }); + const errorMsg = "Please enter both email and password"; + req.flash(ERROR_KEY, errorMsg); + return done(null, false); } try { @@ -19,23 +22,27 @@ async function handleLogin(req: Request, email: string, password: string, done: AND google_id IS NULL AND is_deleted IS FALSE;`; const result = await db.query(q, [email]); - console.log("User query result count:", result.rowCount); const [data] = result.rows; if (!data?.password) { - console.log("No account found"); - return done(null, false, { message: "No account found with this email" }); + const errorMsg = "No account found with this email"; + req.flash(ERROR_KEY, errorMsg); + return done(null, false); } const passwordMatch = bcrypt.compareSync(password, data.password); - console.log("Password match:", passwordMatch); if (passwordMatch && email === data.email) { delete data.password; - return done(null, data, {message: "User successfully logged in"}); + const successMsg = "User successfully logged in"; + req.flash(SUCCESS_KEY, successMsg); + return done(null, data); } - return done(null, false, { message: "Incorrect email or password" }); + + const errorMsg = "Incorrect email or password"; + req.flash(ERROR_KEY, errorMsg); + return done(null, false); } catch (error) { console.error("Login error:", error); log_error(error, req.body); diff --git a/worklenz-frontend/Dockerfile b/worklenz-frontend/Dockerfile index a32f879e..46a87fa7 100644 --- a/worklenz-frontend/Dockerfile +++ b/worklenz-frontend/Dockerfile @@ -12,7 +12,7 @@ COPY . . RUN echo "window.VITE_API_URL='${VITE_API_URL:-http://backend:3000}';" > ./public/env-config.js && \ echo "window.VITE_SOCKET_URL='${VITE_SOCKET_URL:-ws://backend:3000}';" >> ./public/env-config.js -RUN npm run build +RUN NODE_OPTIONS="--max-old-space-size=4096" npm run build FROM node:22-alpine AS production diff --git a/worklenz-frontend/public/locales/en/project-drawer.json b/worklenz-frontend/public/locales/en/project-drawer.json index c9d89238..be553a01 100644 --- a/worklenz-frontend/public/locales/en/project-drawer.json +++ b/worklenz-frontend/public/locales/en/project-drawer.json @@ -47,5 +47,6 @@ "weightedProgress": "Weighted Progress", "weightedProgressTooltip": "Calculate progress based on subtask weights", "timeProgress": "Time-based Progress", - "timeProgressTooltip": "Calculate progress based on estimated time" + "timeProgressTooltip": "Calculate progress based on estimated time", + "enterProjectKey": "Enter project key" } diff --git a/worklenz-frontend/public/locales/es/project-drawer.json b/worklenz-frontend/public/locales/es/project-drawer.json index abe5a856..447ad4f1 100644 --- a/worklenz-frontend/public/locales/es/project-drawer.json +++ b/worklenz-frontend/public/locales/es/project-drawer.json @@ -47,5 +47,6 @@ "weightedProgress": "Progreso Ponderado", "weightedProgressTooltip": "Calcular el progreso basado en los pesos de las subtareas", "timeProgress": "Progreso Basado en Tiempo", - "timeProgressTooltip": "Calcular el progreso basado en el tiempo estimado" + "timeProgressTooltip": "Calcular el progreso basado en el tiempo estimado", + "enterProjectKey": "Ingresa la clave del proyecto" } diff --git a/worklenz-frontend/public/locales/pt/project-drawer.json b/worklenz-frontend/public/locales/pt/project-drawer.json index b7ff40be..92e11964 100644 --- a/worklenz-frontend/public/locales/pt/project-drawer.json +++ b/worklenz-frontend/public/locales/pt/project-drawer.json @@ -47,5 +47,6 @@ "weightedProgress": "Progresso Ponderado", "weightedProgressTooltip": "Calcular o progresso com base nos pesos das subtarefas", "timeProgress": "Progresso Baseado em Tempo", - "timeProgressTooltip": "Calcular o progresso com base no tempo estimado" + "timeProgressTooltip": "Calcular o progresso com base no tempo estimado", + "enterProjectKey": "Insira a chave do projeto" } diff --git a/worklenz-frontend/src/api/tasks/task-time-logs.api.service.ts b/worklenz-frontend/src/api/tasks/task-time-logs.api.service.ts index f4565837..37673590 100644 --- a/worklenz-frontend/src/api/tasks/task-time-logs.api.service.ts +++ b/worklenz-frontend/src/api/tasks/task-time-logs.api.service.ts @@ -5,6 +5,16 @@ import { ITaskLogViewModel } from "@/types/tasks/task-log-view.types"; const rootUrl = `${API_BASE_URL}/task-time-log`; +export interface IRunningTimer { + task_id: string; + start_time: string; + task_name: string; + project_id: string; + project_name: string; + parent_task_id?: string; + parent_task_name?: string; +} + export const taskTimeLogsApiService = { getByTask: async (id: string) : Promise> => { const response = await apiClient.get(`${rootUrl}/task/${id}`); @@ -26,6 +36,11 @@ export const taskTimeLogsApiService = { return response.data; }, + getRunningTimers: async (): Promise> => { + const response = await apiClient.get(`${rootUrl}/running-timers`); + return response.data; + }, + exportToExcel(taskId: string) { window.location.href = `${import.meta.env.VITE_API_URL}${API_BASE_URL}/task-time-log/export/${taskId}`; }, diff --git a/worklenz-frontend/src/components/home-tasks/statusDropdown/home-tasks-status-dropdown.tsx b/worklenz-frontend/src/components/home-tasks/statusDropdown/home-tasks-status-dropdown.tsx index bc75016d..f3595e99 100644 --- a/worklenz-frontend/src/components/home-tasks/statusDropdown/home-tasks-status-dropdown.tsx +++ b/worklenz-frontend/src/components/home-tasks/statusDropdown/home-tasks-status-dropdown.tsx @@ -21,10 +21,10 @@ const HomeTasksStatusDropdown = ({ task, teamId }: HomeTasksStatusDropdownProps) const { socket, connected } = useSocket(); const { homeTasksConfig } = useAppSelector(state => state.homePageReducer); const { - refetch - } = useGetMyTasksQuery(homeTasksConfig, { - skip: true // Skip automatic queries entirely - }); + refetch + } = useGetMyTasksQuery(homeTasksConfig, { + skip: false, // Ensure this query runs + }); const [selectedStatus, setSelectedStatus] = useState(undefined); diff --git a/worklenz-frontend/src/components/home-tasks/taskDatePicker/home-tasks-date-picker.tsx b/worklenz-frontend/src/components/home-tasks/taskDatePicker/home-tasks-date-picker.tsx index 604cbce5..857458ff 100644 --- a/worklenz-frontend/src/components/home-tasks/taskDatePicker/home-tasks-date-picker.tsx +++ b/worklenz-frontend/src/components/home-tasks/taskDatePicker/home-tasks-date-picker.tsx @@ -23,14 +23,14 @@ const HomeTasksDatePicker = ({ record }: HomeTasksDatePickerProps) => { const { t } = useTranslation('home'); const { homeTasksConfig } = useAppSelector(state => state.homePageReducer); const { refetch } = useGetMyTasksQuery(homeTasksConfig, { - skip: true // Skip automatic queries entirely + skip: false }); - + // Use useMemo to avoid re-renders when record.end_date is the same - const initialDate = useMemo(() => + const initialDate = useMemo(() => record.end_date ? dayjs(record.end_date) : null - , [record.end_date]); - + , [record.end_date]); + const [selectedDate, setSelectedDate] = useState(initialDate); // Update selected date when record changes diff --git a/worklenz-frontend/src/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar.tsx b/worklenz-frontend/src/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar.tsx index ce38df32..5ac8b726 100644 --- a/worklenz-frontend/src/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar.tsx +++ b/worklenz-frontend/src/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar.tsx @@ -437,7 +437,7 @@ const TaskListBulkActionsBar = () => { placement="top" arrow trigger={['click']} - destroyPopupOnHide + destroyOnHidden onOpenChange={value => { if (!value) { setSelectedLabels([]); diff --git a/worklenz-frontend/src/features/navbar/navbar.tsx b/worklenz-frontend/src/features/navbar/navbar.tsx index 41d7c0e7..430318d3 100644 --- a/worklenz-frontend/src/features/navbar/navbar.tsx +++ b/worklenz-frontend/src/features/navbar/navbar.tsx @@ -5,7 +5,6 @@ import { Col, ConfigProvider, Flex, Menu, MenuProps, Alert } from 'antd'; import { createPortal } from 'react-dom'; import InviteTeamMembers from '../../components/common/invite-team-members/invite-team-members'; -import HelpButton from './help/HelpButton'; import InviteButton from './invite/InviteButton'; import MobileMenuButton from './mobileMenu/MobileMenuButton'; import NavbarLogo from './navbar-logo'; @@ -22,6 +21,7 @@ import { useAuthService } from '@/hooks/useAuth'; import { authApiService } from '@/api/auth/auth.api.service'; import { ISUBSCRIPTION_TYPE } from '@/shared/constants'; import logger from '@/utils/errorLogger'; +import TimerButton from './timers/timer-button'; const Navbar = () => { const [current, setCurrent] = useState('home'); @@ -90,6 +90,7 @@ const Navbar = () => { }, [location]); return ( + { - + diff --git a/worklenz-frontend/src/features/navbar/timers/timer-button.tsx b/worklenz-frontend/src/features/navbar/timers/timer-button.tsx new file mode 100644 index 00000000..b9e050f0 --- /dev/null +++ b/worklenz-frontend/src/features/navbar/timers/timer-button.tsx @@ -0,0 +1,275 @@ +import { ClockCircleOutlined, StopOutlined } from '@ant-design/icons'; +import { Badge, Button, Dropdown, List, Tooltip, Typography, Space, Divider, theme } from 'antd'; +import React, { useEffect, useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { taskTimeLogsApiService, IRunningTimer } from '@/api/tasks/task-time-logs.api.service'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; +import { updateTaskTimeTracking } from '@/features/tasks/tasks.slice'; +import moment from 'moment'; + +const { Text } = Typography; +const { useToken } = theme; + +const TimerButton = () => { + const [runningTimers, setRunningTimers] = useState([]); + const [loading, setLoading] = useState(false); + const [currentTimes, setCurrentTimes] = useState>({}); + const [dropdownOpen, setDropdownOpen] = useState(false); + const { t } = useTranslation('navbar'); + const { token } = useToken(); + const dispatch = useAppDispatch(); + const { socket } = useSocket(); + + const fetchRunningTimers = useCallback(async () => { + try { + setLoading(true); + const response = await taskTimeLogsApiService.getRunningTimers(); + if (response.done) { + setRunningTimers(response.body || []); + } + } catch (error) { + console.error('Error fetching running timers:', error); + } finally { + setLoading(false); + } + }, []); + + const updateCurrentTimes = () => { + const newTimes: Record = {}; + runningTimers.forEach(timer => { + const startTime = moment(timer.start_time); + const now = moment(); + const duration = moment.duration(now.diff(startTime)); + const hours = Math.floor(duration.asHours()); + const minutes = duration.minutes(); + const seconds = duration.seconds(); + newTimes[timer.task_id] = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + }); + setCurrentTimes(newTimes); + }; + + useEffect(() => { + fetchRunningTimers(); + + // Set up polling to refresh timers every 30 seconds + const pollInterval = setInterval(() => { + fetchRunningTimers(); + }, 30000); + + return () => clearInterval(pollInterval); + }, [fetchRunningTimers]); + + useEffect(() => { + if (runningTimers.length > 0) { + updateCurrentTimes(); + const interval = setInterval(updateCurrentTimes, 1000); + return () => clearInterval(interval); + } + }, [runningTimers]); + + // Listen for timer start/stop events and project updates to refresh the count + useEffect(() => { + if (!socket) return; + + const handleTimerStart = (data: string) => { + try { + const { id } = typeof data === 'string' ? JSON.parse(data) : data; + if (id) { + // Refresh the running timers list when a new timer is started + fetchRunningTimers(); + } + } catch (error) { + console.error('Error parsing timer start event:', error); + } + }; + + const handleTimerStop = (data: string) => { + try { + const { id } = typeof data === 'string' ? JSON.parse(data) : data; + if (id) { + // Refresh the running timers list when a timer is stopped + fetchRunningTimers(); + } + } catch (error) { + console.error('Error parsing timer stop event:', error); + } + }; + + const handleProjectUpdates = () => { + // Refresh timers when project updates are available + fetchRunningTimers(); + }; + + socket.on(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart); + socket.on(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop); + socket.on(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates); + + return () => { + socket.off(SocketEvents.TASK_TIMER_START.toString(), handleTimerStart); + socket.off(SocketEvents.TASK_TIMER_STOP.toString(), handleTimerStop); + socket.off(SocketEvents.PROJECT_UPDATES_AVAILABLE.toString(), handleProjectUpdates); + }; + }, [socket, fetchRunningTimers]); + + const hasRunningTimers = () => { + return runningTimers.length > 0; + }; + + const timerCount = () => { + return runningTimers.length; + }; + + const handleStopTimer = (taskId: string) => { + if (!socket) return; + + socket.emit(SocketEvents.TASK_TIMER_STOP.toString(), JSON.stringify({ task_id: taskId })); + dispatch(updateTaskTimeTracking({ taskId, timeTracking: null })); + }; + + const dropdownContent = ( +
+ {runningTimers.length === 0 ? ( +
+ No running timers +
+ ) : ( + ( + +
+ + + {timer.task_name} + +
+ {timer.project_name} +
+ {timer.parent_task_name && ( + + Parent: {timer.parent_task_name} + + )} +
+
+
+ + Started: {moment(timer.start_time).format('HH:mm')} + + + {currentTimes[timer.task_id] || '00:00:00'} + +
+
+ +
+
+
+
+ )} + /> + )} + {runningTimers.length > 0 && ( + <> + +
+ + {runningTimers.length} timer{runningTimers.length !== 1 ? 's' : ''} running + +
+ + )} +
+ ); + + return ( + dropdownContent} + trigger={['click']} + placement="bottomRight" + open={dropdownOpen} + onOpenChange={(open) => { + setDropdownOpen(open); + if (open) { + fetchRunningTimers(); + } + }} + > + +