Compare commits
21 Commits
release-v2
...
chore/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5e56e48f8 | ||
|
|
7635676289 | ||
|
|
2bd6c19c13 | ||
|
|
374595261f | ||
|
|
b6c056dd1a | ||
|
|
81e1872c1f | ||
|
|
5cce3bc613 | ||
|
|
c53ab511bf | ||
|
|
7b9a16fd72 | ||
|
|
b915de2b93 | ||
|
|
29b8c1b2af | ||
|
|
c2b231d5cc | ||
|
|
53a28cf489 | ||
|
|
e8ccc2a533 | ||
|
|
f24c0d8955 | ||
|
|
61461bb776 | ||
|
|
2a7019c64c | ||
|
|
5b1cbb0c46 | ||
|
|
7c04598264 | ||
|
|
5222d75064 | ||
|
|
2587b8afd9 |
220
docs/SPAM_PROTECTION_GUIDE.md
Normal file
220
docs/SPAM_PROTECTION_GUIDE.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# Worklenz Spam Protection System Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This guide documents the spam protection system implemented in Worklenz to prevent abuse of user invitations and registrations.
|
||||
|
||||
## System Components
|
||||
|
||||
### 1. Spam Detection (`/worklenz-backend/src/utils/spam-detector.ts`)
|
||||
|
||||
The core spam detection engine that analyzes text for suspicious patterns:
|
||||
|
||||
- **Flag-First Policy**: Suspicious content is flagged for review, not blocked
|
||||
- **Selective Blocking**: Only extremely obvious spam (score > 80) gets blocked
|
||||
- **URL Detection**: Identifies links, shortened URLs, and suspicious domains
|
||||
- **Spam Phrases**: Detects common spam tactics (urgent, click here, win prizes)
|
||||
- **Cryptocurrency Spam**: Identifies blockchain/crypto compensation scams
|
||||
- **Formatting Issues**: Excessive capitals, special characters, emojis
|
||||
- **Fake Name Detection**: Generic names (test, demo, fake, spam)
|
||||
- **Whitelist Support**: Legitimate business names bypass all checks
|
||||
- **Context-Aware**: Smart detection reduces false positives
|
||||
|
||||
### 2. Rate Limiting (`/worklenz-backend/src/middleware/rate-limiter.ts`)
|
||||
|
||||
Prevents volume-based attacks:
|
||||
|
||||
- **Invite Limits**: 5 invitations per 15 minutes per user
|
||||
- **Organization Creation**: 3 attempts per hour
|
||||
- **In-Memory Store**: Fast rate limit checking without database queries
|
||||
|
||||
### 3. Frontend Validation
|
||||
|
||||
Real-time feedback as users type:
|
||||
|
||||
- `/worklenz-frontend/src/components/account-setup/organization-step.tsx`
|
||||
- `/worklenz-frontend/src/components/admin-center/overview/organization-name/organization-name.tsx`
|
||||
- `/worklenz-frontend/src/components/settings/edit-team-name-modal.tsx`
|
||||
|
||||
### 4. Backend Enforcement
|
||||
|
||||
Blocks spam at API level:
|
||||
|
||||
- **Team Members Controller**: Validates organization/owner names before invites
|
||||
- **Signup Process**: Blocks spam during registration
|
||||
- **Logging**: All blocked attempts sent to Slack via winston logger
|
||||
|
||||
### 5. Database Schema
|
||||
|
||||
```sql
|
||||
-- Teams table: Simple status field
|
||||
ALTER TABLE teams ADD COLUMN status VARCHAR(20) DEFAULT 'active';
|
||||
|
||||
-- Moderation history tracking
|
||||
CREATE TABLE team_moderation (
|
||||
id UUID PRIMARY KEY,
|
||||
team_id UUID REFERENCES teams(id),
|
||||
status VARCHAR(20), -- 'flagged', 'suspended', 'restored'
|
||||
reason TEXT,
|
||||
moderator_id UUID,
|
||||
created_at TIMESTAMP,
|
||||
expires_at TIMESTAMP -- For temporary suspensions
|
||||
);
|
||||
|
||||
-- Spam detection logs
|
||||
CREATE TABLE spam_logs (
|
||||
id UUID PRIMARY KEY,
|
||||
team_id UUID,
|
||||
content_type VARCHAR(50),
|
||||
original_content TEXT,
|
||||
spam_score INTEGER,
|
||||
spam_reasons JSONB,
|
||||
action_taken VARCHAR(50)
|
||||
);
|
||||
```
|
||||
|
||||
## Admin Tools
|
||||
|
||||
### API Endpoints
|
||||
|
||||
```
|
||||
GET /api/moderation/flagged-organizations - View flagged teams
|
||||
POST /api/moderation/flag-organization - Manually flag a team
|
||||
POST /api/moderation/suspend-organization - Suspend a team
|
||||
POST /api/moderation/unsuspend-organization - Restore a team
|
||||
GET /api/moderation/scan-spam - Scan for spam in existing data
|
||||
GET /api/moderation/stats - View moderation statistics
|
||||
POST /api/moderation/bulk-scan - Bulk scan and auto-flag
|
||||
```
|
||||
|
||||
## Slack Notifications
|
||||
|
||||
The system sends structured alerts to Slack for:
|
||||
|
||||
- 🚨 **Spam Detected** (score > 30)
|
||||
- 🔥 **High Risk Content** (known spam domains)
|
||||
- 🛑 **Blocked Attempts** (invitations/signups)
|
||||
- ⚠️ **Rate Limit Exceeded**
|
||||
|
||||
Example Slack notification:
|
||||
```json
|
||||
{
|
||||
"alert_type": "high_risk_content",
|
||||
"team_name": "CLICK LINK: gclnk.com/spam",
|
||||
"user_email": "spammer@example.com",
|
||||
"spam_score": 95,
|
||||
"reasons": ["Contains suspicious URLs", "Contains monetary references"],
|
||||
"timestamp": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Testing the System
|
||||
|
||||
### Test Spam Patterns
|
||||
|
||||
These will be **FLAGGED** for review (flag-first approach):
|
||||
|
||||
1. **Suspicious Words**: "Free Software Solutions" (flagged but allowed)
|
||||
2. **URLs**: "Visit our site: bit.ly/win-prize" (flagged but allowed)
|
||||
3. **Cryptocurrency**: "🔔 $50,000 BLOCKCHAIN COMPENSATION" (flagged but allowed)
|
||||
4. **Urgency**: "URGENT! Click here NOW!!!" (flagged but allowed)
|
||||
5. **Generic Names**: "Test Company", "Demo Organization" (flagged but allowed)
|
||||
6. **Excessive Numbers**: "Company12345" (flagged but allowed)
|
||||
7. **Single Emoji**: "Great Company 💰" (flagged but allowed)
|
||||
|
||||
### BLOCKED Patterns (zero-tolerance - score > 80):
|
||||
|
||||
1. **Known Spam Domains**: "CLICK LINK: gclnk.com/spam"
|
||||
2. **Extreme Scam Patterns**: "🔔CHECK $213,953 BLOCKCHAIN COMPENSATION URGENT🔔"
|
||||
3. **Obvious Spam URLs**: Content with bit.ly/scam patterns
|
||||
|
||||
### Whitelisted (Will NOT be flagged):
|
||||
|
||||
1. **Legitimate Business**: "Microsoft Corporation", "Free Software Company"
|
||||
2. **Standard Suffixes**: "ABC Solutions Inc", "XYZ Consulting LLC"
|
||||
3. **Tech Companies**: "DataTech Services", "The Design Studio"
|
||||
4. **Context-Aware**: "Free Range Marketing", "Check Point Systems"
|
||||
5. **Legitimate "Test"**: "TestDrive Automotive" (not generic)
|
||||
|
||||
### Expected Behavior
|
||||
|
||||
1. **Suspicious Signup**: Flagged in logs, user allowed to proceed
|
||||
2. **Obvious Spam Signup**: Blocked with user-friendly message
|
||||
3. **Suspicious Invitations**: Flagged in logs, invitation sent
|
||||
4. **Obvious Spam Invitations**: Blocked with support contact suggestion
|
||||
5. **Frontend**: Shows warning message for suspicious content
|
||||
6. **Logger**: Sends Slack notification for all suspicious activity
|
||||
7. **Database**: Records all activity in spam_logs table
|
||||
|
||||
## Database Migration
|
||||
|
||||
Run these SQL scripts in order:
|
||||
|
||||
1. `spam_protection_tables.sql` - Creates new schema
|
||||
2. `fix_spam_protection_constraints.sql` - Fixes notification_settings constraints
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
No additional environment variables required. The system uses existing:
|
||||
- `COOKIE_SECRET` - For session management
|
||||
- Database connection settings
|
||||
|
||||
### Adjusting Thresholds
|
||||
|
||||
In `spam-detector.ts`:
|
||||
```typescript
|
||||
const isSpam = score >= 50; // Adjust threshold here
|
||||
```
|
||||
|
||||
In `rate-limiter.ts`:
|
||||
```typescript
|
||||
inviteRateLimit(5, 15 * 60 * 1000) // 5 requests per 15 minutes
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Check Spam Statistics
|
||||
```sql
|
||||
SELECT * FROM moderation_dashboard;
|
||||
SELECT COUNT(*) FROM spam_logs WHERE created_at > NOW() - INTERVAL '24 hours';
|
||||
```
|
||||
|
||||
### View Rate Limit Events
|
||||
```sql
|
||||
SELECT * FROM rate_limit_log WHERE blocked = true ORDER BY created_at DESC;
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: Legitimate users blocked
|
||||
|
||||
1. Check spam_logs for their content
|
||||
2. Adjust spam patterns or scoring threshold
|
||||
3. Whitelist specific domains if needed
|
||||
|
||||
### Issue: Notification settings error during signup
|
||||
|
||||
Run the fix script: `fix_spam_protection_constraints.sql`
|
||||
|
||||
### Issue: Slack notifications not received
|
||||
|
||||
1. Check winston logger configuration
|
||||
2. Verify log levels in `logger.ts`
|
||||
3. Ensure Slack webhook is configured
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Machine Learning**: Train on spam_logs data
|
||||
2. **IP Blocking**: Geographic or reputation-based blocking
|
||||
3. **CAPTCHA Integration**: For suspicious signups
|
||||
4. **Email Verification**: Stronger email validation
|
||||
5. **Allowlist Management**: Pre-approved domains
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Logs contain sensitive data - ensure proper access controls
|
||||
- Rate limit data stored in memory - consider Redis for scaling
|
||||
- Spam patterns should be regularly updated
|
||||
- Monitor for false positives and adjust accordingly
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Fix for notification_settings constraint issue during signup
|
||||
-- This makes the team_id nullable temporarily during user creation
|
||||
|
||||
-- First, drop the existing NOT NULL constraint
|
||||
ALTER TABLE notification_settings
|
||||
ALTER COLUMN team_id DROP NOT NULL;
|
||||
|
||||
-- Add a constraint that ensures team_id is not null when there's no ongoing signup
|
||||
ALTER TABLE notification_settings
|
||||
ADD CONSTRAINT notification_settings_team_id_check
|
||||
CHECK (team_id IS NOT NULL OR user_id IS NOT NULL);
|
||||
|
||||
-- Update the notification_settings trigger to handle null team_id gracefully
|
||||
CREATE OR REPLACE FUNCTION notification_settings_insert_trigger_fn() RETURNS TRIGGER AS
|
||||
$$
|
||||
BEGIN
|
||||
-- Only insert if team_id is not null
|
||||
IF NEW.team_id IS NOT NULL AND
|
||||
(NOT EXISTS(SELECT 1 FROM notification_settings WHERE team_id = NEW.team_id AND user_id = NEW.user_id)) AND
|
||||
(NEW.active = TRUE)
|
||||
THEN
|
||||
INSERT INTO notification_settings (popup_notifications_enabled, show_unread_items_count, user_id,
|
||||
email_notifications_enabled, team_id, daily_digest_enabled)
|
||||
VALUES (TRUE, TRUE, NEW.user_id, TRUE, NEW.team_id, FALSE);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Also update the teams table to ensure the status column doesn't interfere with signup
|
||||
ALTER TABLE teams
|
||||
DROP CONSTRAINT IF EXISTS teams_status_check;
|
||||
|
||||
ALTER TABLE teams
|
||||
ADD CONSTRAINT teams_status_check
|
||||
CHECK (status IS NULL OR status IN ('active', 'flagged', 'suspended'));
|
||||
|
||||
-- Set default value for status
|
||||
ALTER TABLE teams
|
||||
ALTER COLUMN status SET DEFAULT 'active';
|
||||
|
||||
-- Update existing null values
|
||||
UPDATE teams SET status = 'active' WHERE status IS NULL;
|
||||
220
worklenz-backend/database/sql/spam_protection_tables.sql
Normal file
220
worklenz-backend/database/sql/spam_protection_tables.sql
Normal file
@@ -0,0 +1,220 @@
|
||||
-- Add minimal status column to teams table for performance
|
||||
ALTER TABLE teams
|
||||
ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'flagged', 'suspended'));
|
||||
|
||||
-- Create separate moderation table for detailed tracking
|
||||
CREATE TABLE IF NOT EXISTS team_moderation (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
status VARCHAR(20) NOT NULL CHECK (status IN ('flagged', 'suspended', 'restored')),
|
||||
reason TEXT,
|
||||
moderator_id UUID REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
expires_at TIMESTAMP, -- For temporary suspensions
|
||||
metadata JSONB -- For additional context
|
||||
);
|
||||
|
||||
-- Create indexes for efficient querying
|
||||
CREATE INDEX IF NOT EXISTS idx_teams_status ON teams(status, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_moderation_team_id ON team_moderation(team_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_moderation_status ON team_moderation(status, created_at);
|
||||
|
||||
-- Create spam_logs table to track spam detection events
|
||||
CREATE TABLE IF NOT EXISTS spam_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
content_type VARCHAR(50) NOT NULL, -- 'organization_name', 'owner_name', 'invitation'
|
||||
original_content TEXT NOT NULL,
|
||||
sanitized_content TEXT,
|
||||
spam_score INTEGER NOT NULL DEFAULT 0,
|
||||
spam_reasons JSONB,
|
||||
is_high_risk BOOLEAN DEFAULT FALSE,
|
||||
action_taken VARCHAR(50), -- 'blocked', 'flagged', 'allowed'
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
ip_address INET
|
||||
);
|
||||
|
||||
-- Create index for spam logs
|
||||
CREATE INDEX IF NOT EXISTS idx_spam_logs_team_id ON spam_logs(team_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_spam_logs_created_at ON spam_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_spam_logs_content_type ON spam_logs(content_type);
|
||||
|
||||
-- Create rate_limit_log table to track rate limiting events
|
||||
CREATE TABLE IF NOT EXISTS rate_limit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
ip_address INET NOT NULL,
|
||||
action_type VARCHAR(50) NOT NULL, -- 'invite_attempt', 'org_creation'
|
||||
blocked BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create index for rate limit logs
|
||||
CREATE INDEX IF NOT EXISTS idx_rate_limit_log_user_id ON rate_limit_log(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_rate_limit_log_created_at ON rate_limit_log(created_at);
|
||||
|
||||
-- Add admin flag to users table if it doesn't exist
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS is_admin BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- Function to log spam detection
|
||||
CREATE OR REPLACE FUNCTION log_spam_detection(
|
||||
p_team_id UUID,
|
||||
p_user_id UUID,
|
||||
p_content_type VARCHAR(50),
|
||||
p_original_content TEXT,
|
||||
p_sanitized_content TEXT,
|
||||
p_spam_score INTEGER,
|
||||
p_spam_reasons JSONB,
|
||||
p_is_high_risk BOOLEAN,
|
||||
p_action_taken VARCHAR(50),
|
||||
p_ip_address INET
|
||||
) RETURNS VOID AS $$
|
||||
BEGIN
|
||||
INSERT INTO spam_logs (
|
||||
team_id, user_id, content_type, original_content, sanitized_content,
|
||||
spam_score, spam_reasons, is_high_risk, action_taken, ip_address
|
||||
) VALUES (
|
||||
p_team_id, p_user_id, p_content_type, p_original_content, p_sanitized_content,
|
||||
p_spam_score, p_spam_reasons, p_is_high_risk, p_action_taken, p_ip_address
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Function to log rate limiting events
|
||||
CREATE OR REPLACE FUNCTION log_rate_limit_event(
|
||||
p_user_id UUID,
|
||||
p_ip_address INET,
|
||||
p_action_type VARCHAR(50),
|
||||
p_blocked BOOLEAN
|
||||
) RETURNS VOID AS $$
|
||||
BEGIN
|
||||
INSERT INTO rate_limit_log (user_id, ip_address, action_type, blocked)
|
||||
VALUES (p_user_id, p_ip_address, p_action_type, p_blocked);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Function to get spam statistics for a team
|
||||
CREATE OR REPLACE FUNCTION get_team_spam_stats(p_team_id UUID)
|
||||
RETURNS TABLE (
|
||||
total_detections BIGINT,
|
||||
high_risk_detections BIGINT,
|
||||
blocked_actions BIGINT,
|
||||
latest_detection TIMESTAMP
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
COUNT(*) as total_detections,
|
||||
COUNT(*) FILTER (WHERE is_high_risk = TRUE) as high_risk_detections,
|
||||
COUNT(*) FILTER (WHERE action_taken = 'blocked') as blocked_actions,
|
||||
MAX(created_at) as latest_detection
|
||||
FROM spam_logs
|
||||
WHERE team_id = p_team_id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- View for easy moderation dashboard
|
||||
CREATE OR REPLACE VIEW moderation_dashboard AS
|
||||
SELECT
|
||||
t.id as team_id,
|
||||
t.name as organization_name,
|
||||
u.name as owner_name,
|
||||
u.email as owner_email,
|
||||
t.created_at as team_created_at,
|
||||
t.status as current_status,
|
||||
tm.status as last_moderation_action,
|
||||
tm.reason as last_moderation_reason,
|
||||
tm.created_at as last_moderation_date,
|
||||
tm.expires_at as suspension_expires_at,
|
||||
moderator.name as moderator_name,
|
||||
(SELECT COUNT(*) FROM team_members WHERE team_id = t.id) as member_count,
|
||||
(SELECT COUNT(*) FROM spam_logs WHERE team_id = t.id) as spam_detection_count,
|
||||
(SELECT COUNT(*) FROM spam_logs WHERE team_id = t.id AND is_high_risk = TRUE) as high_risk_count
|
||||
FROM teams t
|
||||
INNER JOIN users u ON t.user_id = u.id
|
||||
LEFT JOIN team_moderation tm ON t.id = tm.team_id
|
||||
AND tm.created_at = (SELECT MAX(created_at) FROM team_moderation WHERE team_id = t.id)
|
||||
LEFT JOIN users moderator ON tm.moderator_id = moderator.id
|
||||
WHERE t.status != 'active' OR EXISTS(
|
||||
SELECT 1 FROM spam_logs WHERE team_id = t.id AND created_at > NOW() - INTERVAL '7 days'
|
||||
);
|
||||
|
||||
-- Function to update team status and create moderation records
|
||||
CREATE OR REPLACE FUNCTION update_team_status(
|
||||
p_team_id UUID,
|
||||
p_new_status VARCHAR(20),
|
||||
p_reason TEXT,
|
||||
p_moderator_id UUID DEFAULT NULL,
|
||||
p_expires_at TIMESTAMP DEFAULT NULL
|
||||
) RETURNS VOID AS $$
|
||||
BEGIN
|
||||
-- Update team status
|
||||
UPDATE teams SET status = p_new_status WHERE id = p_team_id;
|
||||
|
||||
-- Insert moderation record
|
||||
INSERT INTO team_moderation (team_id, status, reason, moderator_id, expires_at)
|
||||
VALUES (p_team_id, p_new_status, p_reason, p_moderator_id, p_expires_at);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Trigger to automatically flag teams with high spam scores
|
||||
CREATE OR REPLACE FUNCTION auto_flag_spam_teams()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Auto-flag teams if they have high spam scores or multiple violations
|
||||
IF NEW.spam_score > 80 OR NEW.is_high_risk = TRUE THEN
|
||||
PERFORM update_team_status(
|
||||
NEW.team_id,
|
||||
'flagged',
|
||||
'Auto-flagged: High spam score or high-risk content detected',
|
||||
NULL
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Function to check and restore expired suspensions
|
||||
CREATE OR REPLACE FUNCTION restore_expired_suspensions() RETURNS VOID AS $$
|
||||
BEGIN
|
||||
-- Find teams with expired suspensions
|
||||
UPDATE teams
|
||||
SET status = 'active'
|
||||
WHERE id IN (
|
||||
SELECT DISTINCT tm.team_id
|
||||
FROM team_moderation tm
|
||||
WHERE tm.status = 'suspended'
|
||||
AND tm.expires_at IS NOT NULL
|
||||
AND tm.expires_at < NOW()
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM team_moderation tm2
|
||||
WHERE tm2.team_id = tm.team_id
|
||||
AND tm2.created_at > tm.created_at
|
||||
)
|
||||
);
|
||||
|
||||
-- Log restoration records
|
||||
INSERT INTO team_moderation (team_id, status, reason, moderator_id)
|
||||
SELECT DISTINCT tm.team_id, 'restored', 'Auto-restored: suspension expired', NULL
|
||||
FROM team_moderation tm
|
||||
WHERE tm.status = 'suspended'
|
||||
AND tm.expires_at IS NOT NULL
|
||||
AND tm.expires_at < NOW()
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM team_moderation tm2
|
||||
WHERE tm2.team_id = tm.team_id
|
||||
AND tm2.created_at > tm.created_at
|
||||
AND tm2.status = 'restored'
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create trigger for auto-flagging
|
||||
DROP TRIGGER IF EXISTS trigger_auto_flag_spam ON spam_logs;
|
||||
CREATE TRIGGER trigger_auto_flag_spam
|
||||
AFTER INSERT ON spam_logs
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION auto_flag_spam_teams();
|
||||
253
worklenz-backend/src/controllers/moderation-controller.ts
Normal file
253
worklenz-backend/src/controllers/moderation-controller.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||
import { ServerResponse } from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import db from "../config/db";
|
||||
import { SpamDetector } from "../utils/spam-detector";
|
||||
import { RateLimiter } from "../middleware/rate-limiter";
|
||||
|
||||
export default class ModerationController extends WorklenzControllerBase {
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getFlaggedOrganizations(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
if (!req.user?.is_admin) {
|
||||
return res.status(403).send(new ServerResponse(false, null, "Admin access required"));
|
||||
}
|
||||
|
||||
const q = `
|
||||
SELECT * FROM moderation_dashboard
|
||||
ORDER BY last_moderation_date DESC
|
||||
LIMIT 100;
|
||||
`;
|
||||
|
||||
const result = await db.query(q);
|
||||
|
||||
// Add spam analysis to each result
|
||||
const flaggedTeams = result.rows.map(team => {
|
||||
const orgSpamCheck = SpamDetector.detectSpam(team.organization_name);
|
||||
const ownerSpamCheck = SpamDetector.detectSpam(team.owner_name);
|
||||
|
||||
return {
|
||||
...team,
|
||||
org_spam_score: orgSpamCheck.score,
|
||||
org_spam_reasons: orgSpamCheck.reasons,
|
||||
owner_spam_score: ownerSpamCheck.score,
|
||||
owner_spam_reasons: ownerSpamCheck.reasons,
|
||||
is_high_risk: SpamDetector.isHighRiskContent(team.organization_name) ||
|
||||
SpamDetector.isHighRiskContent(team.owner_name)
|
||||
};
|
||||
});
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, flaggedTeams));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async flagOrganization(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
if (!req.user?.is_admin) {
|
||||
return res.status(403).send(new ServerResponse(false, null, "Admin access required"));
|
||||
}
|
||||
|
||||
const { teamId, reason } = req.body;
|
||||
if (!teamId) {
|
||||
return res.status(400).send(new ServerResponse(false, null, "Team ID is required"));
|
||||
}
|
||||
|
||||
const q = `SELECT update_team_status($1, 'flagged', $2, $3) as result`;
|
||||
const result = await db.query(q, [teamId, reason || 'Spam/Abuse', req.user.id]);
|
||||
|
||||
const teamQuery = `SELECT id, name FROM teams WHERE id = $1`;
|
||||
const teamResult = await db.query(teamQuery, [teamId]);
|
||||
|
||||
if (teamResult.rows.length === 0) {
|
||||
return res.status(404).send(new ServerResponse(false, null, "Organization not found"));
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, teamResult.rows[0], "Organization flagged successfully"));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async suspendOrganization(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
if (!req.user?.is_admin) {
|
||||
return res.status(403).send(new ServerResponse(false, null, "Admin access required"));
|
||||
}
|
||||
|
||||
const { teamId, reason, expiresAt } = req.body;
|
||||
if (!teamId) {
|
||||
return res.status(400).send(new ServerResponse(false, null, "Team ID is required"));
|
||||
}
|
||||
|
||||
const q = `SELECT update_team_status($1, 'suspended', $2, $3, $4) as result`;
|
||||
const result = await db.query(q, [teamId, reason || 'Terms of Service Violation', req.user.id, expiresAt || null]);
|
||||
|
||||
const teamQuery = `SELECT id, name FROM teams WHERE id = $1`;
|
||||
const teamResult = await db.query(teamQuery, [teamId]);
|
||||
|
||||
if (teamResult.rows.length === 0) {
|
||||
return res.status(404).send(new ServerResponse(false, null, "Organization not found"));
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, teamResult.rows[0], "Organization suspended successfully"));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async unsuspendOrganization(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
if (!req.user?.is_admin) {
|
||||
return res.status(403).send(new ServerResponse(false, null, "Admin access required"));
|
||||
}
|
||||
|
||||
const { teamId } = req.body;
|
||||
if (!teamId) {
|
||||
return res.status(400).send(new ServerResponse(false, null, "Team ID is required"));
|
||||
}
|
||||
|
||||
const q = `SELECT update_team_status($1, 'active', 'Manually restored by admin', $2) as result`;
|
||||
const result = await db.query(q, [teamId, req.user.id]);
|
||||
|
||||
const teamQuery = `SELECT id, name FROM teams WHERE id = $1`;
|
||||
const teamResult = await db.query(teamQuery, [teamId]);
|
||||
|
||||
if (teamResult.rows.length === 0) {
|
||||
return res.status(404).send(new ServerResponse(false, null, "Organization not found"));
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, teamResult.rows[0], "Organization restored successfully"));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async scanForSpam(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
if (!req.user?.is_admin) {
|
||||
return res.status(403).send(new ServerResponse(false, null, "Admin access required"));
|
||||
}
|
||||
|
||||
const q = `
|
||||
SELECT t.id, t.name as organization_name, u.name as owner_name, u.email as owner_email,
|
||||
t.created_at
|
||||
FROM teams t
|
||||
INNER JOIN users u ON t.user_id = u.id
|
||||
WHERE t.status = 'active'
|
||||
AND t.created_at > NOW() - INTERVAL '7 days'
|
||||
ORDER BY t.created_at DESC;
|
||||
`;
|
||||
|
||||
const result = await db.query(q);
|
||||
const suspiciousTeams = [];
|
||||
|
||||
for (const team of result.rows) {
|
||||
const orgSpamCheck = SpamDetector.detectSpam(team.organization_name);
|
||||
const ownerSpamCheck = SpamDetector.detectSpam(team.owner_name);
|
||||
|
||||
if (orgSpamCheck.isSpam || ownerSpamCheck.isSpam ||
|
||||
SpamDetector.isHighRiskContent(team.organization_name) ||
|
||||
SpamDetector.isHighRiskContent(team.owner_name)) {
|
||||
|
||||
suspiciousTeams.push({
|
||||
...team,
|
||||
org_spam_score: orgSpamCheck.score,
|
||||
org_spam_reasons: orgSpamCheck.reasons,
|
||||
owner_spam_score: ownerSpamCheck.score,
|
||||
owner_spam_reasons: ownerSpamCheck.reasons,
|
||||
is_high_risk: SpamDetector.isHighRiskContent(team.organization_name) ||
|
||||
SpamDetector.isHighRiskContent(team.owner_name)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, {
|
||||
total_scanned: result.rows.length,
|
||||
suspicious_count: suspiciousTeams.length,
|
||||
suspicious_teams: suspiciousTeams
|
||||
}));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getModerationStats(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
if (!req.user?.is_admin) {
|
||||
return res.status(403).send(new ServerResponse(false, null, "Admin access required"));
|
||||
}
|
||||
|
||||
const statsQuery = `
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM teams WHERE status = 'flagged') as flagged_count,
|
||||
(SELECT COUNT(*) FROM teams WHERE status = 'suspended') as suspended_count,
|
||||
(SELECT COUNT(*) FROM teams WHERE created_at > NOW() - INTERVAL '24 hours') as new_teams_24h,
|
||||
(SELECT COUNT(*) FROM teams WHERE created_at > NOW() - INTERVAL '7 days') as new_teams_7d
|
||||
`;
|
||||
|
||||
const result = await db.query(statsQuery);
|
||||
const stats = result.rows[0];
|
||||
|
||||
// Get rate limiting stats for recent activity
|
||||
const recentInviteActivity = RateLimiter.getStats(req.user?.id || '');
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, {
|
||||
...stats,
|
||||
rate_limit_stats: recentInviteActivity
|
||||
}));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async bulkScanAndFlag(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
if (!req.user?.is_admin) {
|
||||
return res.status(403).send(new ServerResponse(false, null, "Admin access required"));
|
||||
}
|
||||
|
||||
const { autoFlag = false } = req.body;
|
||||
|
||||
const q = `
|
||||
SELECT t.id, t.name as organization_name, u.name as owner_name
|
||||
FROM teams t
|
||||
INNER JOIN users u ON t.user_id = u.id
|
||||
WHERE t.status = 'active'
|
||||
AND t.created_at > NOW() - INTERVAL '30 days'
|
||||
LIMIT 1000;
|
||||
`;
|
||||
|
||||
const result = await db.query(q);
|
||||
const flaggedTeams = [];
|
||||
|
||||
for (const team of result.rows) {
|
||||
const orgSpamCheck = SpamDetector.detectSpam(team.organization_name);
|
||||
const ownerSpamCheck = SpamDetector.detectSpam(team.owner_name);
|
||||
const isHighRisk = SpamDetector.isHighRiskContent(team.organization_name) ||
|
||||
SpamDetector.isHighRiskContent(team.owner_name);
|
||||
|
||||
if ((orgSpamCheck.score > 70 || ownerSpamCheck.score > 70 || isHighRisk) && autoFlag) {
|
||||
// Auto-flag high-confidence spam
|
||||
const reasons = [
|
||||
...orgSpamCheck.reasons,
|
||||
...ownerSpamCheck.reasons,
|
||||
...(isHighRisk ? ['High-risk content detected'] : [])
|
||||
];
|
||||
|
||||
const flagQuery = `SELECT update_team_status($1, 'flagged', $2, $3) as result`;
|
||||
await db.query(flagQuery, [
|
||||
team.id,
|
||||
`Auto-flagged: ${reasons.join(', ')}`,
|
||||
req.user.id
|
||||
]);
|
||||
|
||||
flaggedTeams.push({
|
||||
...team,
|
||||
action: 'flagged',
|
||||
reasons: reasons
|
||||
});
|
||||
} else if (orgSpamCheck.isSpam || ownerSpamCheck.isSpam || isHighRisk) {
|
||||
flaggedTeams.push({
|
||||
...team,
|
||||
action: 'review_needed',
|
||||
org_spam_score: orgSpamCheck.score,
|
||||
owner_spam_score: ownerSpamCheck.score,
|
||||
reasons: [...orgSpamCheck.reasons, ...ownerSpamCheck.reasons, ...(isHighRisk ? ['High-risk content'] : [])]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, {
|
||||
total_scanned: result.rows.length,
|
||||
auto_flagged: flaggedTeams.filter(t => t.action === 'flagged').length,
|
||||
needs_review: flaggedTeams.filter(t => t.action === 'review_needed').length,
|
||||
teams: flaggedTeams
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {getColor} from "../shared/utils";
|
||||
import TeamMembersController from "./team-members-controller";
|
||||
import {checkTeamSubscriptionStatus} from "../shared/paddle-utils";
|
||||
import {updateUsers} from "../shared/paddle-requests";
|
||||
import {statusExclude} from "../shared/constants";
|
||||
import {statusExclude, TRIAL_MEMBER_LIMIT} from "../shared/constants";
|
||||
import {NotificationsService} from "../services/notifications/notifications.service";
|
||||
|
||||
export default class ProjectMembersController extends WorklenzControllerBase {
|
||||
@@ -118,6 +118,17 @@ export default class ProjectMembersController extends WorklenzControllerBase {
|
||||
return res.status(200).send(new ServerResponse(false, null, "Maximum number of life time users reached."));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks trial user team member limit
|
||||
*/
|
||||
if (subscriptionData.subscription_status === "trialing") {
|
||||
const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
|
||||
|
||||
if (currentTrialMembers + 1 > TRIAL_MEMBER_LIMIT) {
|
||||
return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
|
||||
}
|
||||
}
|
||||
|
||||
// if (subscriptionData.status === "trialing") break;
|
||||
if (!userExists && !subscriptionData.is_credit && !subscriptionData.is_custom && subscriptionData.subscription_status !== "trialing") {
|
||||
// if (subscriptionData.subscription_status === "active") {
|
||||
|
||||
@@ -17,7 +17,7 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
|
||||
JOIN timezones tz ON u.timezone_id = tz.id
|
||||
WHERE u.id = $1`;
|
||||
const result = await db.query(q, [userId]);
|
||||
return result.rows[0]?.timezone || 'UTC';
|
||||
return result.rows[0]?.timezone || "UTC";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,26 +25,48 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
|
||||
* @param key - Date range key (e.g., YESTERDAY, LAST_WEEK)
|
||||
* @param dateRange - Array of date strings
|
||||
* @param userTimezone - User's timezone (e.g., 'America/New_York')
|
||||
* @param tableAlias - Table alias to use (e.g., 'twl', 'task_work_log')
|
||||
* @returns SQL clause for date filtering
|
||||
*/
|
||||
protected static getDateRangeClauseWithTimezone(key: string, dateRange: string[], userTimezone: string, tableAlias: string = 'task_work_log') {
|
||||
protected static getDateRangeClauseWithTimezone(key: string, dateRange: string[], userTimezone: string) {
|
||||
// For custom date ranges
|
||||
if (dateRange.length === 2) {
|
||||
// Convert dates to user's timezone start/end of day
|
||||
const start = moment.tz(dateRange[0], userTimezone).startOf('day');
|
||||
const end = moment.tz(dateRange[1], userTimezone).endOf('day');
|
||||
try {
|
||||
// Handle different date formats that might come from frontend
|
||||
let startDate, endDate;
|
||||
|
||||
// Convert to UTC for database comparison
|
||||
const startUtc = start.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
const endUtc = end.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
// Try to parse the date - it might be a full JS Date string or ISO string
|
||||
if (dateRange[0].includes("GMT") || dateRange[0].includes("(")) {
|
||||
// Parse JavaScript Date toString() format
|
||||
startDate = moment(new Date(dateRange[0]));
|
||||
endDate = moment(new Date(dateRange[1]));
|
||||
} else {
|
||||
// Parse ISO format or other formats
|
||||
startDate = moment(dateRange[0]);
|
||||
endDate = moment(dateRange[1]);
|
||||
}
|
||||
|
||||
if (start.isSame(end, 'day')) {
|
||||
// Single day selection
|
||||
return `AND ${tableAlias}.created_at >= '${startUtc}'::TIMESTAMP AND ${tableAlias}.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||
// Convert to user's timezone and get start/end of day
|
||||
const start = startDate.tz(userTimezone).startOf("day");
|
||||
const end = endDate.tz(userTimezone).endOf("day");
|
||||
|
||||
// Convert to UTC for database comparison
|
||||
const startUtc = start.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
const endUtc = end.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
|
||||
if (start.isSame(end, "day")) {
|
||||
// Single day selection
|
||||
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||
}
|
||||
|
||||
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||
} catch (error) {
|
||||
console.error("Error parsing date range:", error, { dateRange, userTimezone });
|
||||
// Fallback to current date if parsing fails
|
||||
const now = moment.tz(userTimezone);
|
||||
const startUtc = now.clone().startOf("day").utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
const endUtc = now.clone().endOf("day").utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||
}
|
||||
|
||||
return `AND ${tableAlias}.created_at >= '${startUtc}'::TIMESTAMP AND ${tableAlias}.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||
}
|
||||
|
||||
// For predefined ranges, calculate based on user's timezone
|
||||
@@ -53,20 +75,20 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
|
||||
|
||||
switch (key) {
|
||||
case DATE_RANGES.YESTERDAY:
|
||||
startDate = now.clone().subtract(1, 'day').startOf('day');
|
||||
endDate = now.clone().subtract(1, 'day').endOf('day');
|
||||
startDate = now.clone().subtract(1, "day").startOf("day");
|
||||
endDate = now.clone().subtract(1, "day").endOf("day");
|
||||
break;
|
||||
case DATE_RANGES.LAST_WEEK:
|
||||
startDate = now.clone().subtract(1, 'week').startOf('week');
|
||||
endDate = now.clone().subtract(1, 'week').endOf('week');
|
||||
startDate = now.clone().subtract(1, "week").startOf("week");
|
||||
endDate = now.clone().subtract(1, "week").endOf("week");
|
||||
break;
|
||||
case DATE_RANGES.LAST_MONTH:
|
||||
startDate = now.clone().subtract(1, 'month').startOf('month');
|
||||
endDate = now.clone().subtract(1, 'month').endOf('month');
|
||||
startDate = now.clone().subtract(1, "month").startOf("month");
|
||||
endDate = now.clone().subtract(1, "month").endOf("month");
|
||||
break;
|
||||
case DATE_RANGES.LAST_QUARTER:
|
||||
startDate = now.clone().subtract(3, 'months').startOf('day');
|
||||
endDate = now.clone().endOf('day');
|
||||
startDate = now.clone().subtract(3, "months").startOf("day");
|
||||
endDate = now.clone().endOf("day");
|
||||
break;
|
||||
default:
|
||||
return "";
|
||||
@@ -75,7 +97,7 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
|
||||
if (startDate && endDate) {
|
||||
const startUtc = startDate.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
const endUtc = endDate.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
return `AND ${tableAlias}.created_at >= '${startUtc}'::TIMESTAMP AND ${tableAlias}.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||
}
|
||||
|
||||
return "";
|
||||
@@ -88,7 +110,7 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
|
||||
* @param format - Moment format string
|
||||
* @returns Formatted date string
|
||||
*/
|
||||
protected static formatDateInTimezone(date: string | Date, userTimezone: string, format: string = "YYYY-MM-DD HH:mm:ss") {
|
||||
protected static formatDateInTimezone(date: string | Date, userTimezone: string, format = "YYYY-MM-DD HH:mm:ss") {
|
||||
return moment.tz(date, userTimezone).format(format);
|
||||
}
|
||||
|
||||
@@ -105,12 +127,12 @@ export default abstract class ReportingControllerBaseWithTimezone extends Workle
|
||||
let workingDays = 0;
|
||||
|
||||
const current = start.clone();
|
||||
while (current.isSameOrBefore(end, 'day')) {
|
||||
while (current.isSameOrBefore(end, "day")) {
|
||||
// Monday = 1, Friday = 5
|
||||
if (current.isoWeekday() >= 1 && current.isoWeekday() <= 5) {
|
||||
workingDays++;
|
||||
}
|
||||
current.add(1, 'day');
|
||||
current.add(1, "day");
|
||||
}
|
||||
|
||||
return workingDays;
|
||||
|
||||
@@ -324,8 +324,8 @@ export default class ReportingMembersController extends ReportingControllerBaseW
|
||||
(SELECT color_code FROM project_phases WHERE id = (SELECT phase_id FROM task_phase WHERE task_id = t.id)) AS phase_color,
|
||||
|
||||
(total_minutes * 60) AS total_minutes,
|
||||
(SELECT SUM(time_spent) FROM task_work_log twl WHERE twl.task_id = t.id AND twl.user_id = (SELECT user_id FROM team_members WHERE id = $1)) AS time_logged,
|
||||
((SELECT SUM(time_spent) FROM task_work_log twl WHERE twl.task_id = t.id AND twl.user_id = (SELECT user_id FROM team_members WHERE id = $1)) - (total_minutes * 60)) AS overlogged_time`;
|
||||
(SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id AND ta.team_member_id = $1) AS time_logged,
|
||||
((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id AND ta.team_member_id = $1) - (total_minutes * 60)) AS overlogged_time`;
|
||||
}
|
||||
|
||||
protected static getActivityLogsOverdue(key: string, dateRange: string[]) {
|
||||
@@ -548,7 +548,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW
|
||||
|
||||
// Get user timezone for proper date filtering
|
||||
const userTimezone = await this.getUserTimezone(req.user?.id as string);
|
||||
const durationClause = this.getDateRangeClauseWithTimezone(duration as string || DATE_RANGES.LAST_WEEK, dateRange, userTimezone, 'twl');
|
||||
const durationClause = this.getDateRangeClauseWithTimezone(duration as string || DATE_RANGES.LAST_WEEK, dateRange, userTimezone);
|
||||
const minMaxDateClause = this.getMinMaxDates(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "task_work_log");
|
||||
const memberName = (req.query.member_name as string)?.trim() || null;
|
||||
|
||||
@@ -1101,7 +1101,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW
|
||||
|
||||
// Get user timezone for proper date filtering
|
||||
const userTimezone = await this.getUserTimezone(req.user?.id as string);
|
||||
const durationClause = this.getDateRangeClauseWithTimezone(duration || DATE_RANGES.LAST_WEEK, date_range, userTimezone, 'twl');
|
||||
const durationClause = this.getDateRangeClauseWithTimezone(duration || DATE_RANGES.LAST_WEEK, date_range, userTimezone);
|
||||
const minMaxDateClause = this.getMinMaxDates(duration || DATE_RANGES.LAST_WEEK, date_range, "task_work_log");
|
||||
|
||||
const billableQuery = this.buildBillableQuery(billable);
|
||||
|
||||
@@ -13,10 +13,14 @@ import { SocketEvents } from "../socket.io/events";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import { formatDuration, getColor } from "../shared/utils";
|
||||
import { statusExclude, TEAM_MEMBER_TREE_MAP_COLOR_ALPHA } from "../shared/constants";
|
||||
import { statusExclude, TEAM_MEMBER_TREE_MAP_COLOR_ALPHA, TRIAL_MEMBER_LIMIT } from "../shared/constants";
|
||||
import { checkTeamSubscriptionStatus } from "../shared/paddle-utils";
|
||||
import { updateUsers } from "../shared/paddle-requests";
|
||||
import { NotificationsService } from "../services/notifications/notifications.service";
|
||||
import { SpamDetector } from "../utils/spam-detector";
|
||||
import loggerModule from "../utils/logger";
|
||||
|
||||
const { logger } = loggerModule;
|
||||
|
||||
export default class TeamMembersController extends WorklenzControllerBase {
|
||||
|
||||
@@ -72,7 +76,8 @@ export default class TeamMembersController extends WorklenzControllerBase {
|
||||
|
||||
@HandleExceptions({
|
||||
raisedExceptions: {
|
||||
"ERROR_EMAIL_INVITATION_EXISTS": `Team member with email "{0}" already exists.`
|
||||
"ERROR_EMAIL_INVITATION_EXISTS": `Team member with email "{0}" already exists.`,
|
||||
"ERROR_SPAM_DETECTED": `Invitation blocked: {0}`
|
||||
}
|
||||
})
|
||||
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
@@ -82,6 +87,54 @@ export default class TeamMembersController extends WorklenzControllerBase {
|
||||
return res.status(200).send(new ServerResponse(false, "Required fields are missing."));
|
||||
}
|
||||
|
||||
// Validate organization name for spam - Flag suspicious, block only obvious spam
|
||||
const orgSpamCheck = SpamDetector.detectSpam(req.user?.team_name || '');
|
||||
const ownerSpamCheck = SpamDetector.detectSpam(req.user?.name || '');
|
||||
|
||||
// Only block extremely suspicious content for invitations (higher threshold)
|
||||
const isObviousSpam = orgSpamCheck.score > 70 || ownerSpamCheck.score > 70 ||
|
||||
SpamDetector.isHighRiskContent(req.user?.team_name || '') ||
|
||||
SpamDetector.isHighRiskContent(req.user?.name || '');
|
||||
|
||||
if (isObviousSpam) {
|
||||
logger.error('🛑 INVITATION BLOCKED - OBVIOUS SPAM', {
|
||||
user_id: req.user?.id,
|
||||
user_email: req.user?.email,
|
||||
team_id: req.user?.team_id,
|
||||
team_name: req.user?.team_name,
|
||||
owner_name: req.user?.name,
|
||||
org_spam_score: orgSpamCheck.score,
|
||||
owner_spam_score: ownerSpamCheck.score,
|
||||
org_reasons: orgSpamCheck.reasons,
|
||||
owner_reasons: ownerSpamCheck.reasons,
|
||||
ip_address: req.ip,
|
||||
timestamp: new Date().toISOString(),
|
||||
alert_type: 'obvious_spam_invitation_blocked'
|
||||
});
|
||||
return res.status(200).send(new ServerResponse(false, null, `Invitations temporarily disabled. Please contact support for assistance.`));
|
||||
}
|
||||
|
||||
// Log suspicious but allow invitations
|
||||
if (orgSpamCheck.score > 0 || ownerSpamCheck.score > 0) {
|
||||
logger.warn('⚠️ SUSPICIOUS INVITATION ATTEMPT', {
|
||||
user_id: req.user?.id,
|
||||
user_email: req.user?.email,
|
||||
team_id: req.user?.team_id,
|
||||
team_name: req.user?.team_name,
|
||||
owner_name: req.user?.name,
|
||||
org_spam_score: orgSpamCheck.score,
|
||||
owner_spam_score: ownerSpamCheck.score,
|
||||
org_reasons: orgSpamCheck.reasons,
|
||||
owner_reasons: ownerSpamCheck.reasons,
|
||||
ip_address: req.ip,
|
||||
timestamp: new Date().toISOString(),
|
||||
alert_type: 'suspicious_invitation_flagged'
|
||||
});
|
||||
// Continue with invitation but flag for review
|
||||
}
|
||||
|
||||
// High-risk content already checked above in isObviousSpam condition
|
||||
|
||||
/**
|
||||
* Checks the subscription status of the team.
|
||||
* @type {Object} subscriptionData - Object containing subscription information
|
||||
@@ -141,6 +194,17 @@ export default class TeamMembersController extends WorklenzControllerBase {
|
||||
return res.status(200).send(new ServerResponse(false, null, "Cannot exceed the maximum number of life time users."));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks trial user team member limit
|
||||
*/
|
||||
if (subscriptionData.subscription_status === "trialing") {
|
||||
const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
|
||||
|
||||
if (currentTrialMembers + incrementBy > TRIAL_MEMBER_LIMIT) {
|
||||
return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks subscription details and updates the user count if applicable.
|
||||
* Sends a response if there is an issue with the subscription.
|
||||
@@ -1081,6 +1145,18 @@ export default class TeamMembersController extends WorklenzControllerBase {
|
||||
return res.status(200).send(new ServerResponse(false, "Please check your subscription status."));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks trial user team member limit
|
||||
*/
|
||||
if (subscriptionData.subscription_status === "trialing") {
|
||||
const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
|
||||
const emailsToAdd = req.body.emails?.length || 1;
|
||||
|
||||
if (currentTrialMembers + emailsToAdd > TRIAL_MEMBER_LIMIT) {
|
||||
return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
|
||||
}
|
||||
}
|
||||
|
||||
// if (subscriptionData.status === "trialing") break;
|
||||
if (!subscriptionData.is_credit && !subscriptionData.is_custom) {
|
||||
if (subscriptionData.subscription_status === "active") {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import moment from "moment";
|
||||
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
|
||||
import { ServerResponse } from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import { formatDuration, formatLogText, getColor } from "../shared/utils";
|
||||
|
||||
interface IUserRecentTask {
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
project_id: string;
|
||||
project_name: string;
|
||||
last_activity_at: string;
|
||||
activity_count: number;
|
||||
project_color?: string;
|
||||
task_status?: string;
|
||||
status_color?: string;
|
||||
}
|
||||
|
||||
interface IUserTimeLoggedTask {
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
project_id: string;
|
||||
project_name: string;
|
||||
total_time_logged: number;
|
||||
total_time_logged_string: string;
|
||||
last_logged_at: string;
|
||||
logged_by_timer: boolean;
|
||||
project_color?: string;
|
||||
task_status?: string;
|
||||
status_color?: string;
|
||||
log_entries_count?: number;
|
||||
estimated_time?: number;
|
||||
}
|
||||
|
||||
export default class UserActivityLogsController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async getRecentTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
if (!req.user) {
|
||||
return res.status(401).send(new ServerResponse(false, null, "Unauthorized"));
|
||||
}
|
||||
|
||||
const { id: userId, team_id: teamId } = req.user;
|
||||
const { offset = 0, limit = 10 } = req.query;
|
||||
|
||||
// Optimized query with better performance and team filtering
|
||||
const q = `
|
||||
SELECT DISTINCT tal.task_id, t.name AS task_name, tal.project_id, p.name AS project_name,
|
||||
MAX(tal.created_at) AS last_activity_at,
|
||||
COUNT(DISTINCT tal.id) AS activity_count,
|
||||
p.color_code AS project_color,
|
||||
(SELECT name FROM task_statuses WHERE id = t.status_id) AS task_status,
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color
|
||||
FROM task_activity_logs tal
|
||||
INNER JOIN tasks t ON tal.task_id = t.id AND t.archived = FALSE
|
||||
INNER JOIN projects p ON tal.project_id = p.id AND p.team_id = $1
|
||||
WHERE tal.user_id = $2
|
||||
AND tal.created_at >= NOW() - INTERVAL '30 days'
|
||||
GROUP BY tal.task_id, t.name, tal.project_id, p.name, p.color_code, t.status_id
|
||||
ORDER BY MAX(tal.created_at) DESC
|
||||
LIMIT $3 OFFSET $4;
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [teamId, userId, limit, offset]);
|
||||
const tasks: IUserRecentTask[] = result.rows;
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, tasks));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getTimeLoggedTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
if (!req.user) {
|
||||
return res.status(401).send(new ServerResponse(false, null, "Unauthorized"));
|
||||
}
|
||||
|
||||
const { id: userId, team_id: teamId } = req.user;
|
||||
const { offset = 0, limit = 10 } = req.query;
|
||||
|
||||
// Optimized query with better performance, team filtering, and useful additional data
|
||||
const q = `
|
||||
SELECT twl.task_id, t.name AS task_name, t.project_id, p.name AS project_name,
|
||||
SUM(twl.time_spent) AS total_time_logged,
|
||||
MAX(twl.created_at) AS last_logged_at,
|
||||
MAX(twl.logged_by_timer::int)::boolean AS logged_by_timer,
|
||||
p.color_code AS project_color,
|
||||
(SELECT name FROM task_statuses WHERE id = t.status_id) AS task_status,
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color,
|
||||
COUNT(DISTINCT twl.id) AS log_entries_count,
|
||||
(t.total_minutes * 60) AS estimated_time
|
||||
FROM task_work_log twl
|
||||
INNER JOIN tasks t ON twl.task_id = t.id AND t.archived = FALSE
|
||||
INNER JOIN projects p ON t.project_id = p.id AND p.team_id = $1
|
||||
WHERE twl.user_id = $2
|
||||
AND twl.created_at >= NOW() - INTERVAL '90 days'
|
||||
GROUP BY twl.task_id, t.name, t.project_id, p.name, p.color_code, t.status_id, t.total_minutes
|
||||
HAVING SUM(twl.time_spent) > 0
|
||||
ORDER BY MAX(twl.created_at) DESC
|
||||
LIMIT $3 OFFSET $4;
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [teamId, userId, limit, offset]);
|
||||
const tasks: IUserTimeLoggedTask[] = result.rows.map(task => ({
|
||||
...task,
|
||||
total_time_logged_string: formatDuration(moment.duration(task.total_time_logged, "seconds")),
|
||||
}));
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, tasks));
|
||||
}
|
||||
}
|
||||
141
worklenz-backend/src/middleware/rate-limiter.ts
Normal file
141
worklenz-backend/src/middleware/rate-limiter.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { NextFunction } from "express";
|
||||
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||
import { ServerResponse } from "../models/server-response";
|
||||
import loggerModule from "../utils/logger";
|
||||
|
||||
const { logger } = loggerModule;
|
||||
|
||||
interface RateLimitStore {
|
||||
[key: string]: {
|
||||
count: number;
|
||||
resetTime: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class RateLimiter {
|
||||
private static store: RateLimitStore = {};
|
||||
private static cleanupInterval: NodeJS.Timeout;
|
||||
|
||||
static {
|
||||
// Clean up expired entries every 5 minutes
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
Object.keys(this.store).forEach(key => {
|
||||
if (this.store[key].resetTime < now) {
|
||||
delete this.store[key];
|
||||
}
|
||||
});
|
||||
}, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
public static inviteRateLimit(
|
||||
maxRequests = 5,
|
||||
windowMs: number = 15 * 60 * 1000 // 15 minutes
|
||||
) {
|
||||
return (req: IWorkLenzRequest, res: IWorkLenzResponse, next: NextFunction) => {
|
||||
const identifier = req.user?.id || req.ip;
|
||||
const key = `invite_${identifier}`;
|
||||
const now = Date.now();
|
||||
|
||||
if (!this.store[key] || this.store[key].resetTime < now) {
|
||||
this.store[key] = {
|
||||
count: 1,
|
||||
resetTime: now + windowMs
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
if (this.store[key].count >= maxRequests) {
|
||||
const remainingTime = Math.ceil((this.store[key].resetTime - now) / 1000);
|
||||
|
||||
// Log rate limit exceeded for Slack notifications
|
||||
logger.warn("⚠️ RATE LIMIT EXCEEDED - INVITE ATTEMPTS", {
|
||||
user_id: req.user?.id,
|
||||
user_email: req.user?.email,
|
||||
ip_address: req.ip,
|
||||
attempts: this.store[key].count,
|
||||
max_attempts: maxRequests,
|
||||
remaining_time: remainingTime,
|
||||
timestamp: new Date().toISOString(),
|
||||
alert_type: "rate_limit_exceeded"
|
||||
});
|
||||
|
||||
return res.status(429).send(
|
||||
new ServerResponse(
|
||||
false,
|
||||
null,
|
||||
`Too many invitation attempts. Please try again in ${remainingTime} seconds.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
this.store[key].count++;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
public static organizationCreationRateLimit(
|
||||
maxRequests = 3,
|
||||
windowMs: number = 60 * 60 * 1000 // 1 hour
|
||||
) {
|
||||
return (req: IWorkLenzRequest, res: IWorkLenzResponse, next: NextFunction) => {
|
||||
const identifier = req.user?.id || req.ip;
|
||||
const key = `org_creation_${identifier}`;
|
||||
const now = Date.now();
|
||||
|
||||
if (!this.store[key] || this.store[key].resetTime < now) {
|
||||
this.store[key] = {
|
||||
count: 1,
|
||||
resetTime: now + windowMs
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
if (this.store[key].count >= maxRequests) {
|
||||
const remainingTime = Math.ceil((this.store[key].resetTime - now) / (1000 * 60));
|
||||
|
||||
// Log organization creation rate limit exceeded
|
||||
logger.warn("⚠️ RATE LIMIT EXCEEDED - ORG CREATION", {
|
||||
user_id: req.user?.id,
|
||||
user_email: req.user?.email,
|
||||
ip_address: req.ip,
|
||||
attempts: this.store[key].count,
|
||||
max_attempts: maxRequests,
|
||||
remaining_time_minutes: remainingTime,
|
||||
timestamp: new Date().toISOString(),
|
||||
alert_type: "org_creation_rate_limit"
|
||||
});
|
||||
|
||||
return res.status(429).send(
|
||||
new ServerResponse(
|
||||
false,
|
||||
null,
|
||||
`Too many organization creation attempts. Please try again in ${remainingTime} minutes.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
this.store[key].count++;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
public static getStats(identifier: string): { invites: number; orgCreations: number } {
|
||||
const inviteKey = `invite_${identifier}`;
|
||||
const orgKey = `org_creation_${identifier}`;
|
||||
|
||||
return {
|
||||
invites: this.store[inviteKey]?.count || 0,
|
||||
orgCreations: this.store[orgKey]?.count || 0
|
||||
};
|
||||
}
|
||||
|
||||
public static clearStats(identifier: string): void {
|
||||
const inviteKey = `invite_${identifier}`;
|
||||
const orgKey = `org_creation_${identifier}`;
|
||||
|
||||
delete this.store[inviteKey];
|
||||
delete this.store[orgKey];
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,10 @@ import {log_error} from "../../shared/utils";
|
||||
import db from "../../config/db";
|
||||
import {Request} from "express";
|
||||
import {ERROR_KEY, SUCCESS_KEY} from "./passport-constants";
|
||||
import { SpamDetector } from "../../utils/spam-detector";
|
||||
import loggerModule from "../../utils/logger";
|
||||
|
||||
const { logger } = loggerModule;
|
||||
|
||||
async function isGoogleAccountFound(email: string) {
|
||||
const q = `
|
||||
@@ -49,12 +53,111 @@ async function handleSignUp(req: Request, email: string, password: string, done:
|
||||
|
||||
if (!team_name) return done(null, null, req.flash(ERROR_KEY, "Team name is required"));
|
||||
|
||||
// Check for spam in team name - Flag suspicious but allow signup
|
||||
const teamNameSpamCheck = SpamDetector.detectSpam(team_name);
|
||||
if (teamNameSpamCheck.score > 0 || teamNameSpamCheck.reasons.length > 0) {
|
||||
logger.warn('⚠️ SUSPICIOUS SIGNUP - TEAM NAME', {
|
||||
email,
|
||||
team_name,
|
||||
user_name: name,
|
||||
spam_score: teamNameSpamCheck.score,
|
||||
reasons: teamNameSpamCheck.reasons,
|
||||
ip_address: req.ip,
|
||||
timestamp: new Date().toISOString(),
|
||||
alert_type: 'suspicious_signup_flagged'
|
||||
});
|
||||
// Continue with signup but flag for review
|
||||
}
|
||||
|
||||
// Check for spam in user name - Flag suspicious but allow signup
|
||||
const userNameSpamCheck = SpamDetector.detectSpam(name);
|
||||
if (userNameSpamCheck.score > 0 || userNameSpamCheck.reasons.length > 0) {
|
||||
logger.warn('⚠️ SUSPICIOUS SIGNUP - USER NAME', {
|
||||
email,
|
||||
team_name,
|
||||
user_name: name,
|
||||
spam_score: userNameSpamCheck.score,
|
||||
reasons: userNameSpamCheck.reasons,
|
||||
ip_address: req.ip,
|
||||
timestamp: new Date().toISOString(),
|
||||
alert_type: 'suspicious_signup_flagged'
|
||||
});
|
||||
// Continue with signup but flag for review
|
||||
}
|
||||
|
||||
// Only block EXTREMELY high-risk content (known spam domains, obvious scams)
|
||||
if (SpamDetector.isHighRiskContent(team_name) || SpamDetector.isHighRiskContent(name)) {
|
||||
// Check if it's REALLY obvious spam (very high scores)
|
||||
const isObviousSpam = teamNameSpamCheck.score > 80 || userNameSpamCheck.score > 80 ||
|
||||
/gclnk\.com|bit\.ly\/scam|win.*\$\d+.*crypto/i.test(team_name + ' ' + name);
|
||||
|
||||
if (isObviousSpam) {
|
||||
logger.error('🛑 SIGNUP BLOCKED - OBVIOUS SPAM', {
|
||||
email,
|
||||
team_name,
|
||||
user_name: name,
|
||||
team_spam_score: teamNameSpamCheck.score,
|
||||
user_spam_score: userNameSpamCheck.score,
|
||||
ip_address: req.ip,
|
||||
timestamp: new Date().toISOString(),
|
||||
alert_type: 'obvious_spam_blocked'
|
||||
});
|
||||
return done(null, null, req.flash(ERROR_KEY, "Registration temporarily unavailable. Please contact support if you need immediate access."));
|
||||
} else {
|
||||
// High-risk but not obviously spam - flag and allow
|
||||
logger.error('🔥 HIGH RISK SIGNUP - FLAGGED', {
|
||||
email,
|
||||
team_name,
|
||||
user_name: name,
|
||||
team_spam_score: teamNameSpamCheck.score,
|
||||
user_spam_score: userNameSpamCheck.score,
|
||||
ip_address: req.ip,
|
||||
timestamp: new Date().toISOString(),
|
||||
alert_type: 'high_risk_signup_flagged'
|
||||
});
|
||||
// Continue with signup but flag for immediate review
|
||||
}
|
||||
}
|
||||
|
||||
const googleAccountFound = await isGoogleAccountFound(email);
|
||||
if (googleAccountFound)
|
||||
return done(null, null, req.flash(ERROR_KEY, `${req.body.email} is already linked with a Google account.`));
|
||||
|
||||
try {
|
||||
const user = await registerUser(password, team_id, name, team_name, email, timezone, team_member_id);
|
||||
|
||||
// If signup was suspicious, flag the team for review after creation
|
||||
const totalSuspicionScore = (teamNameSpamCheck.score || 0) + (userNameSpamCheck.score || 0);
|
||||
if (totalSuspicionScore > 0) {
|
||||
// Flag team for admin review (but don't block user)
|
||||
const flagQuery = `
|
||||
INSERT INTO spam_logs (team_id, user_id, content_type, original_content, spam_score, spam_reasons, action_taken, ip_address)
|
||||
VALUES (
|
||||
(SELECT team_id FROM users WHERE id = $1),
|
||||
$1,
|
||||
'signup_review',
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
'flagged_for_review',
|
||||
$5
|
||||
)
|
||||
`;
|
||||
|
||||
try {
|
||||
await db.query(flagQuery, [
|
||||
user.id,
|
||||
`Team: ${team_name} | User: ${name}`,
|
||||
totalSuspicionScore,
|
||||
JSON.stringify([...teamNameSpamCheck.reasons, ...userNameSpamCheck.reasons]),
|
||||
req.ip
|
||||
]);
|
||||
} catch (flagError) {
|
||||
// Don't fail signup if flagging fails
|
||||
logger.warn('Failed to flag suspicious signup for review', { error: flagError, user_id: user.id });
|
||||
}
|
||||
}
|
||||
|
||||
sendWelcomeEmail(email, name);
|
||||
return done(null, user, req.flash(SUCCESS_KEY, "Registration successful. Please check your email for verification."));
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -57,7 +57,10 @@ import billingApiRouter from "./billing-api-router";
|
||||
import taskDependenciesApiRouter from "./task-dependencies-api-router";
|
||||
|
||||
import taskRecurringApiRouter from "./task-recurring-api-router";
|
||||
|
||||
|
||||
import customColumnsApiRouter from "./custom-columns-api-router";
|
||||
import userActivityLogsApiRouter from "./user-activity-logs-api-router";
|
||||
import moderationApiRouter from "./moderation-api-router";
|
||||
|
||||
const api = express.Router();
|
||||
|
||||
@@ -119,4 +122,6 @@ api.use("/task-recurring", taskRecurringApiRouter);
|
||||
|
||||
api.use("/custom-columns", customColumnsApiRouter);
|
||||
|
||||
api.use("/logs", userActivityLogsApiRouter);
|
||||
api.use("/moderation", moderationApiRouter);
|
||||
export default api;
|
||||
|
||||
16
worklenz-backend/src/routes/apis/moderation-api-router.ts
Normal file
16
worklenz-backend/src/routes/apis/moderation-api-router.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import express from "express";
|
||||
import ModerationController from "../../controllers/moderation-controller";
|
||||
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||
|
||||
const moderationApiRouter = express.Router();
|
||||
|
||||
// Admin-only routes for spam/abuse moderation
|
||||
moderationApiRouter.get("/flagged-organizations", safeControllerFunction(ModerationController.getFlaggedOrganizations));
|
||||
moderationApiRouter.post("/flag-organization", safeControllerFunction(ModerationController.flagOrganization));
|
||||
moderationApiRouter.post("/suspend-organization", safeControllerFunction(ModerationController.suspendOrganization));
|
||||
moderationApiRouter.post("/unsuspend-organization", safeControllerFunction(ModerationController.unsuspendOrganization));
|
||||
moderationApiRouter.get("/scan-spam", safeControllerFunction(ModerationController.scanForSpam));
|
||||
moderationApiRouter.get("/stats", safeControllerFunction(ModerationController.getModerationStats));
|
||||
moderationApiRouter.post("/bulk-scan", safeControllerFunction(ModerationController.bulkScanAndFlag));
|
||||
|
||||
export default moderationApiRouter;
|
||||
@@ -6,6 +6,7 @@ import idParamValidator from "../../middlewares/validators/id-param-validator";
|
||||
import teamMembersBodyValidator from "../../middlewares/validators/team-members-body-validator";
|
||||
import teamOwnerOrAdminValidator from "../../middlewares/validators/team-owner-or-admin-validator";
|
||||
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||
import { RateLimiter } from "../../middleware/rate-limiter";
|
||||
|
||||
const teamMembersApiRouter = express.Router();
|
||||
|
||||
@@ -13,7 +14,7 @@ const teamMembersApiRouter = express.Router();
|
||||
teamMembersApiRouter.get("/export-all", safeControllerFunction(TeamMembersController.exportAllMembers));
|
||||
teamMembersApiRouter.get("/export/:id", idParamValidator, safeControllerFunction(TeamMembersController.exportByMember));
|
||||
|
||||
teamMembersApiRouter.post("/", teamOwnerOrAdminValidator, teamMembersBodyValidator, safeControllerFunction(TeamMembersController.create));
|
||||
teamMembersApiRouter.post("/", teamOwnerOrAdminValidator, RateLimiter.inviteRateLimit(5, 15 * 60 * 1000), teamMembersBodyValidator, safeControllerFunction(TeamMembersController.create));
|
||||
teamMembersApiRouter.get("/", safeControllerFunction(TeamMembersController.get));
|
||||
teamMembersApiRouter.get("/list", safeControllerFunction(TeamMembersController.getTeamMemberList));
|
||||
teamMembersApiRouter.get("/tree-map", safeControllerFunction(TeamMembersController.getTeamMembersTreeMap));
|
||||
@@ -30,6 +31,6 @@ teamMembersApiRouter.put("/:id", teamOwnerOrAdminValidator, idParamValidator, sa
|
||||
teamMembersApiRouter.delete("/:id", teamOwnerOrAdminValidator, idParamValidator, safeControllerFunction(TeamMembersController.deleteById));
|
||||
teamMembersApiRouter.get("/deactivate/:id", teamOwnerOrAdminValidator, idParamValidator, safeControllerFunction(TeamMembersController.toggleMemberActiveStatus));
|
||||
|
||||
teamMembersApiRouter.put("/add-member/:id", teamOwnerOrAdminValidator, teamMembersBodyValidator, safeControllerFunction(TeamMembersController.addTeamMember));
|
||||
teamMembersApiRouter.put("/add-member/:id", teamOwnerOrAdminValidator, RateLimiter.inviteRateLimit(3, 10 * 60 * 1000), teamMembersBodyValidator, safeControllerFunction(TeamMembersController.addTeamMember));
|
||||
|
||||
export default teamMembersApiRouter;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import express from 'express';
|
||||
|
||||
import UserActivityLogsController from '../../controllers/user-activity-logs-controller';
|
||||
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||
|
||||
const userActivityLogsApiRouter = express.Router();
|
||||
|
||||
userActivityLogsApiRouter.get('/user-recent-tasks', safeControllerFunction(UserActivityLogsController.getRecentTasks));
|
||||
userActivityLogsApiRouter.get('/user-time-logged-tasks', safeControllerFunction(UserActivityLogsController.getTimeLoggedTasks));
|
||||
|
||||
export default userActivityLogsApiRouter;
|
||||
@@ -160,6 +160,9 @@ export const PASSWORD_POLICY = "Minimum of 8 characters, with upper and lowercas
|
||||
// paddle status to exclude
|
||||
export const statusExclude = ["past_due", "paused", "deleted"];
|
||||
|
||||
// Trial user team member limit
|
||||
export const TRIAL_MEMBER_LIMIT = 10;
|
||||
|
||||
export const HTML_TAG_REGEXP = /<\/?[^>]+>/gi;
|
||||
|
||||
export const UNMAPPED = "Unmapped";
|
||||
|
||||
@@ -75,7 +75,7 @@ export async function on_quick_assign_or_remove(_io: Server, socket: Socket, dat
|
||||
assign_type: type
|
||||
});
|
||||
|
||||
if (assignment && userId !== assignment.user_id) {
|
||||
if (userId !== assignment.user_id) {
|
||||
NotificationsService.createTaskUpdate(
|
||||
type,
|
||||
userId as string,
|
||||
@@ -109,11 +109,6 @@ export async function assignMemberIfNot(taskId: string, userId: string, teamId:
|
||||
const result = await db.query(q, [taskId, userId, teamId]);
|
||||
const [data] = result.rows;
|
||||
|
||||
if (!data) {
|
||||
log_error(new Error(`No team member found for userId: ${userId}, teamId: ${teamId}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const body = {
|
||||
team_member_id: data.team_member_id,
|
||||
project_id: data.project_id,
|
||||
|
||||
244
worklenz-backend/src/utils/spam-detector.ts
Normal file
244
worklenz-backend/src/utils/spam-detector.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import loggerModule from "./logger";
|
||||
|
||||
const { logger } = loggerModule;
|
||||
|
||||
export interface SpamDetectionResult {
|
||||
isSpam: boolean;
|
||||
score: number;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export class SpamDetector {
|
||||
// Whitelist for legitimate organizations that might trigger false positives
|
||||
private static readonly WHITELIST_PATTERNS = [
|
||||
/^(microsoft|google|apple|amazon|facebook|meta|twitter|linkedin|github|stackoverflow)$/i,
|
||||
/^.*(inc|llc|ltd|corp|corporation|company|co|group|enterprises|solutions|services|consulting|tech|technologies|agency|studio|lab|labs|systems|software|development|designs?)$/i,
|
||||
// Allow "free" when it's clearly about software/business
|
||||
/free.*(software|source|lance|consulting|solutions|services|tech|development|range|market|trade)/i,
|
||||
/(open|free).*(software|source)/i,
|
||||
// Common legitimate business patterns
|
||||
/^[a-z]+\s+(software|solutions|services|consulting|tech|technologies|systems|development|designs?|agency|studio|labs?|group|company)$/i,
|
||||
/^(the\s+)?[a-z]+\s+(company|group|studio|agency|lab|labs)$/i
|
||||
];
|
||||
|
||||
private static readonly SPAM_PATTERNS = [
|
||||
// URLs and links
|
||||
/https?:\/\//i,
|
||||
/www\./i,
|
||||
/\b\w+\.(com|net|org|io|co|me|ly|tk|ml|ga|cf|cc|to|us|biz|info|xyz)\b/i,
|
||||
|
||||
// Common spam phrases
|
||||
/click\s*(here|link|now)/i,
|
||||
/urgent|emergency|immediate|limited.time/i,
|
||||
/win|won|winner|prize|reward|congratulations/i,
|
||||
/free|bonus|gift|offer|special.offer/i,
|
||||
/check\s*(out|this|pay)|verify|claim/i,
|
||||
/blockchain|crypto|bitcoin|compensation|investment/i,
|
||||
/cash|money|dollars?|\$\d+|earn.*money/i,
|
||||
|
||||
// Excessive special characters
|
||||
/[!]{2,}/,
|
||||
/[🔔⬅👆💰$💎🎁🎉⚡]{1,}/,
|
||||
/\b[A-Z]{4,}\b/,
|
||||
|
||||
// Suspicious formatting
|
||||
/\s{3,}/,
|
||||
/[.]{3,}/,
|
||||
|
||||
// Additional suspicious patterns
|
||||
/act.now|don.t.miss|guaranteed|limited.spots/i,
|
||||
/download|install|app|software/i,
|
||||
/survey|questionnaire|feedback/i,
|
||||
/\d+%.*off|save.*\$|discount/i
|
||||
];
|
||||
|
||||
private static readonly SUSPICIOUS_WORDS = [
|
||||
"urgent", "emergency", "click", "link", "win", "winner", "prize",
|
||||
"free", "bonus", "cash", "money", "blockchain", "crypto", "compensation",
|
||||
"check", "pay", "reward", "offer", "gift", "congratulations", "claim",
|
||||
"verify", "earn", "investment", "guaranteed", "limited", "exclusive",
|
||||
"download", "install", "survey", "feedback", "discount", "save"
|
||||
];
|
||||
|
||||
public static detectSpam(text: string): SpamDetectionResult {
|
||||
if (!text || typeof text !== "string") {
|
||||
return { isSpam: false, score: 0, reasons: [] };
|
||||
}
|
||||
|
||||
const normalizedText = text.toLowerCase().trim();
|
||||
const reasons: string[] = [];
|
||||
let score = 0;
|
||||
|
||||
// Check for obviously fake organization names FIRST (before whitelist)
|
||||
if (/^(test|example|demo|fake|spam|abuse|temp)\s*(company|org|corp|inc|llc)?$/i.test(text.trim()) ||
|
||||
/(test|demo|fake|spam|abuse|temp)\s*(123|abc|xyz|\d+)/i.test(text)) {
|
||||
score += 30;
|
||||
reasons.push("Contains generic/test name patterns");
|
||||
}
|
||||
|
||||
// Check whitelist - bypass remaining checks for whitelisted organizations
|
||||
if (score === 0) { // Only check whitelist if no generic patterns found
|
||||
for (const pattern of this.WHITELIST_PATTERNS) {
|
||||
if (pattern.test(normalizedText)) {
|
||||
return { isSpam: false, score: 0, reasons: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for URL patterns
|
||||
for (const pattern of this.SPAM_PATTERNS) {
|
||||
if (pattern.test(text)) {
|
||||
score += 25; // Lowered from 30 to catch more suspicious content
|
||||
if (pattern.toString().includes("https?") || pattern.toString().includes("www")) {
|
||||
reasons.push("Contains suspicious URLs or links");
|
||||
} else if (pattern.toString().includes("urgent|emergency")) {
|
||||
reasons.push("Contains urgent/emergency language");
|
||||
} else if (pattern.toString().includes("win|won|winner")) {
|
||||
reasons.push("Contains prize/winning language");
|
||||
} else if (pattern.toString().includes("cash|money")) {
|
||||
reasons.push("Contains monetary references");
|
||||
} else if (pattern.toString().includes("blockchain|crypto")) {
|
||||
reasons.push("Contains cryptocurrency references");
|
||||
} else if (pattern.toString().includes("[!]{3,}")) {
|
||||
reasons.push("Excessive use of exclamation marks");
|
||||
} else if (pattern.toString().includes("[🔔⬅👆💰$]")) {
|
||||
reasons.push("Contains suspicious emojis or symbols");
|
||||
} else if (pattern.toString().includes("[A-Z]{5,}")) {
|
||||
reasons.push("Contains excessive capital letters");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for excessive suspicious words - Now with context awareness
|
||||
const suspiciousWords = this.SUSPICIOUS_WORDS.filter(word => {
|
||||
if (!normalizedText.includes(word)) return false;
|
||||
|
||||
// Context-aware filtering for common false positives
|
||||
if (word === 'free') {
|
||||
// Allow "free" in legitimate software/business contexts
|
||||
return !/free.*(software|source|lance|consulting|solutions|services|tech|development|range|market|trade)/i.test(text);
|
||||
}
|
||||
|
||||
if (word === 'check') {
|
||||
// Allow "check" in legitimate business contexts
|
||||
return !/check.*(list|mark|point|out|up|in|book|ing|ed)/i.test(text);
|
||||
}
|
||||
|
||||
if (word === 'save') {
|
||||
// Allow "save" in legitimate business contexts
|
||||
return !/save.*(data|file|document|time|energy|environment|earth)/i.test(text);
|
||||
}
|
||||
|
||||
return true; // Other words are still suspicious
|
||||
});
|
||||
|
||||
if (suspiciousWords.length >= 1) {
|
||||
score += suspiciousWords.length * 20;
|
||||
reasons.push(`Contains ${suspiciousWords.length} suspicious word${suspiciousWords.length > 1 ? 's' : ''}: ${suspiciousWords.join(', ')}`);
|
||||
}
|
||||
|
||||
// Check text length - very short or very long names are suspicious
|
||||
if (text.length < 2) {
|
||||
score += 20;
|
||||
reasons.push("Text too short");
|
||||
} else if (text.length > 100) {
|
||||
score += 25;
|
||||
reasons.push("Text unusually long");
|
||||
}
|
||||
|
||||
// Check for repeated characters
|
||||
if (/(.)\1{4,}/.test(text)) {
|
||||
score += 20;
|
||||
reasons.push("Contains repeated characters");
|
||||
}
|
||||
|
||||
// Check for mixed scripts (potential homograph attack)
|
||||
const hasLatin = /[a-zA-Z]/.test(text);
|
||||
const hasCyrillic = /[\u0400-\u04FF]/.test(text);
|
||||
const hasGreek = /[\u0370-\u03FF]/.test(text);
|
||||
|
||||
if ((hasLatin && hasCyrillic) || (hasLatin && hasGreek)) {
|
||||
score += 40;
|
||||
reasons.push("Contains mixed character scripts");
|
||||
}
|
||||
|
||||
// Generic name check already done above - skip duplicate check
|
||||
|
||||
// Check for excessive numbers in organization names (often spam)
|
||||
if (/\d{4,}/.test(text)) {
|
||||
score += 25;
|
||||
reasons.push("Contains excessive numbers");
|
||||
}
|
||||
|
||||
const isSpam = score >= 50;
|
||||
|
||||
// Log suspicious activity for Slack notifications
|
||||
if (isSpam || score > 30) {
|
||||
logger.warn("🚨 SPAM DETECTED", {
|
||||
text: text.substring(0, 100),
|
||||
score,
|
||||
reasons: [...new Set(reasons)],
|
||||
isSpam,
|
||||
timestamp: new Date().toISOString(),
|
||||
alert_type: "spam_detection"
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isSpam,
|
||||
score,
|
||||
reasons: [...new Set(reasons)] // Remove duplicates
|
||||
};
|
||||
}
|
||||
|
||||
public static isHighRiskContent(text: string): boolean {
|
||||
const patterns = [
|
||||
/gclnk\.com/i,
|
||||
/bit\.ly\/scam/i, // More specific bit.ly patterns
|
||||
/tinyurl\.com\/scam/i,
|
||||
/\$\d{3,}.*crypto/i, // Money + crypto combination
|
||||
/blockchain.*compensation.*urgent/i,
|
||||
/win.*\$\d+.*urgent/i, // Win money urgent pattern
|
||||
/click.*here.*\$\d+/i // Click here money pattern
|
||||
];
|
||||
|
||||
const isHighRisk = patterns.some(pattern => pattern.test(text));
|
||||
|
||||
// Log high-risk content immediately
|
||||
if (isHighRisk) {
|
||||
logger.error("🔥 HIGH RISK CONTENT DETECTED", {
|
||||
text: text.substring(0, 100),
|
||||
matched_patterns: patterns.filter(pattern => pattern.test(text)).map(p => p.toString()),
|
||||
timestamp: new Date().toISOString(),
|
||||
alert_type: "high_risk_content"
|
||||
});
|
||||
}
|
||||
|
||||
return isHighRisk;
|
||||
}
|
||||
|
||||
public static shouldBlockContent(text: string): boolean {
|
||||
const result = this.detectSpam(text);
|
||||
// Only block if extremely high score or high-risk patterns
|
||||
return result.score > 80 || this.isHighRiskContent(text);
|
||||
}
|
||||
|
||||
public static shouldFlagContent(text: string): boolean {
|
||||
const result = this.detectSpam(text);
|
||||
// Flag anything suspicious (score > 0) but not necessarily blocked
|
||||
return result.score > 0 || result.reasons.length > 0;
|
||||
}
|
||||
|
||||
public static sanitizeText(text: string): string {
|
||||
if (!text || typeof text !== "string") return "";
|
||||
|
||||
return text
|
||||
.trim()
|
||||
.replace(/https?:\/\/[^\s]+/gi, "[URL_REMOVED]")
|
||||
.replace(/www\.[^\s]+/gi, "[URL_REMOVED]")
|
||||
.replace(/[🔔⬅👆💰$]{2,}/g, "")
|
||||
.replace(/[!]{3,}/g, "!")
|
||||
.replace(/\s{3,}/g, " ")
|
||||
.substring(0, 100);
|
||||
}
|
||||
}
|
||||
@@ -16,3 +16,7 @@ VITE_WORKLENZ_SESSION_ID=worklenz-session-id
|
||||
|
||||
# Google Login
|
||||
VITE_ENABLE_GOOGLE_LOGIN=false
|
||||
|
||||
# Survey Modal Configuration
|
||||
# Set to true to enable the survey modal, false to disable it
|
||||
VITE_ENABLE_SURVEY_MODAL=false
|
||||
22
worklenz-frontend/.env.example
Normal file
22
worklenz-frontend/.env.example
Normal file
@@ -0,0 +1,22 @@
|
||||
VITE_API_URL=http://localhost:3000
|
||||
VITE_SOCKET_URL=ws://localhost:3000
|
||||
|
||||
VITE_APP_TITLE=Worklenz
|
||||
VITE_APP_ENV=development
|
||||
|
||||
# Mixpanel
|
||||
VITE_MIXPANEL_TOKEN=mixpanel-token
|
||||
|
||||
# Recaptcha
|
||||
VITE_ENABLE_RECAPTCHA=false
|
||||
VITE_RECAPTCHA_SITE_KEY=recaptcha-site-key
|
||||
|
||||
# Session ID
|
||||
VITE_WORKLENZ_SESSION_ID=worklenz-session-id
|
||||
|
||||
# Google Login
|
||||
VITE_ENABLE_GOOGLE_LOGIN=false
|
||||
|
||||
# Survey Modal Configuration
|
||||
# Set to true to enable the survey modal, false to disable it
|
||||
VITE_ENABLE_SURVEY_MODAL=false
|
||||
1
worklenz-frontend/.gitignore
vendored
1
worklenz-frontend/.gitignore
vendored
@@ -11,6 +11,7 @@
|
||||
# production
|
||||
/build
|
||||
/public/tinymce
|
||||
/docs
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
558
worklenz-frontend/package-lock.json
generated
558
worklenz-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,11 @@
|
||||
"build": "vite build",
|
||||
"dev-build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"format": "prettier --write ."
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:ui": "vitest --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/colors": "^7.1.0",
|
||||
@@ -77,7 +81,10 @@
|
||||
"@types/react-dom": "19.0.0",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"jsdom": "^26.1.0",
|
||||
"postcss": "^8.5.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.13",
|
||||
"rollup": "^4.40.2",
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"signup-failed": "Regjistrimi dështoi. Ju lutemi sigurohuni që të gjitha fushat e nevojshme janë plotësuar dhe provoni përsëri.",
|
||||
"reconnecting": "Jeni shkëputur nga serveri.",
|
||||
"connection-lost": "Lidhja me serverin dështoi. Ju lutemi kontrolloni lidhjen tuaj me internet.",
|
||||
"connection-restored": "U lidhët me serverin me sukses"
|
||||
"connection-restored": "U lidhët me serverin me sukses",
|
||||
"cancel": "Anulo"
|
||||
}
|
||||
|
||||
@@ -41,6 +41,22 @@
|
||||
"list": "Listë",
|
||||
"calendar": "Kalendar",
|
||||
"tasks": "Detyrat",
|
||||
"refresh": "Rifresko"
|
||||
"refresh": "Rifresko",
|
||||
"recentActivity": "Aktiviteti i Fundit",
|
||||
"recentTasks": "Detyrat e Fundit",
|
||||
"recentTasksSegment": "Detyrat e Fundit",
|
||||
"timeLogged": "Koha e Regjistruar",
|
||||
"timeLoggedSegment": "Koha e Regjistruar",
|
||||
"noRecentTasks": "Asnjë detyrë e fundit",
|
||||
"noTimeLoggedTasks": "Asnjë detyrë me kohë të regjistruar",
|
||||
"activityTag": "Aktiviteti",
|
||||
"timeLogTag": "Regjistrim Kohe",
|
||||
"timerTag": "Kohëmatës",
|
||||
"activitySingular": "aktivitet",
|
||||
"activityPlural": "aktivitete",
|
||||
"recentTaskAriaLabel": "Detyrë e fundit:",
|
||||
"timeLoggedTaskAriaLabel": "Detyrë me kohë të regjistruar:",
|
||||
"errorLoadingRecentTasks": "Gabim në ngarkimin e detyrave të fundit",
|
||||
"errorLoadingTimeLoggedTasks": "Gabim në ngarkimin e detyrave me kohë të regjistruar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,9 @@
|
||||
|
||||
"contextMenu": {
|
||||
"assignToMe": "Cakto mua",
|
||||
"copyLink": "Kopjo lidhjen e detyrës",
|
||||
"linkCopied": "Lidhja u kopjua në clipboard",
|
||||
"linkCopyFailed": "Dështoi kopjimi i lidhjes",
|
||||
"moveTo": "Zhvendos në",
|
||||
"unarchive": "Ç'arkivizo",
|
||||
"archive": "Arkivizo",
|
||||
@@ -133,5 +136,11 @@
|
||||
"dependencies": "Detyra ka varësi",
|
||||
"recurring": "Detyrë përsëritëse"
|
||||
}
|
||||
},
|
||||
|
||||
"timer": {
|
||||
"conflictTitle": "Kronómetr Tashë Në Ecuri",
|
||||
"conflictMessage": "Ju keni një kronómetr në ecuri për \"{{taskName}}\" në projektin \"{{projectName}}\". Dëshironi ta ndaloni atë kronómetr dhe të filloni një të ri për këtë detyrë?",
|
||||
"stopAndStart": "Ndalo & Fillo Kronómetr të Ri"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"signup-failed": "Registrierung fehlgeschlagen. Bitte füllen Sie alle erforderlichen Felder aus und versuchen Sie es erneut.",
|
||||
"reconnecting": "Vom Server getrennt.",
|
||||
"connection-lost": "Verbindung zum Server fehlgeschlagen. Bitte überprüfen Sie Ihre Internetverbindung.",
|
||||
"connection-restored": "Erfolgreich mit dem Server verbunden"
|
||||
"connection-restored": "Erfolgreich mit dem Server verbunden",
|
||||
"cancel": "Abbrechen"
|
||||
}
|
||||
|
||||
@@ -41,6 +41,22 @@
|
||||
"list": "Liste",
|
||||
"calendar": "Kalender",
|
||||
"tasks": "Aufgaben",
|
||||
"refresh": "Aktualisieren"
|
||||
"refresh": "Aktualisieren",
|
||||
"recentActivity": "Aktuelle Aktivitäten",
|
||||
"recentTasks": "Aktuelle Aufgaben",
|
||||
"recentTasksSegment": "Aktuelle Aufgaben",
|
||||
"timeLogged": "Erfasste Zeit",
|
||||
"timeLoggedSegment": "Erfasste Zeit",
|
||||
"noRecentTasks": "Keine aktuellen Aufgaben",
|
||||
"noTimeLoggedTasks": "Keine Aufgaben mit erfasster Zeit",
|
||||
"activityTag": "Aktivität",
|
||||
"timeLogTag": "Zeiterfassung",
|
||||
"timerTag": "Timer",
|
||||
"activitySingular": "Aktivität",
|
||||
"activityPlural": "Aktivitäten",
|
||||
"recentTaskAriaLabel": "Aktuelle Aufgabe:",
|
||||
"timeLoggedTaskAriaLabel": "Aufgabe mit erfasster Zeit:",
|
||||
"errorLoadingRecentTasks": "Fehler beim Laden aktueller Aufgaben",
|
||||
"errorLoadingTimeLoggedTasks": "Fehler beim Laden der Zeiterfassung"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,9 @@
|
||||
|
||||
"contextMenu": {
|
||||
"assignToMe": "Mir zuweisen",
|
||||
"copyLink": "Link zur Aufgabe kopieren",
|
||||
"linkCopied": "Link in die Zwischenablage kopiert",
|
||||
"linkCopyFailed": "Fehler beim Kopieren des Links",
|
||||
"moveTo": "Verschieben nach",
|
||||
"unarchive": "Dearchivieren",
|
||||
"archive": "Archivieren",
|
||||
@@ -133,5 +136,11 @@
|
||||
"dependencies": "Aufgabe hat Abhängigkeiten",
|
||||
"recurring": "Wiederkehrende Aufgabe"
|
||||
}
|
||||
},
|
||||
|
||||
"timer": {
|
||||
"conflictTitle": "Timer läuft bereits",
|
||||
"conflictMessage": "Sie haben einen Timer für \"{{taskName}}\" im Projekt \"{{projectName}}\" laufen. Möchten Sie diesen Timer stoppen und einen neuen für diese Aufgabe starten?",
|
||||
"stopAndStart": "Stoppen & Neuen Timer starten"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"signup-failed": "Signup failed. Please ensure all required fields are filled and try again.",
|
||||
"reconnecting": "Disconnected from server.",
|
||||
"connection-lost": "Failed to connect to server. Please check your internet connection.",
|
||||
"connection-restored": "Connected to server successfully"
|
||||
"connection-restored": "Connected to server successfully",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
|
||||
@@ -41,6 +41,22 @@
|
||||
"list": "List",
|
||||
"calendar": "Calendar",
|
||||
"tasks": "Tasks",
|
||||
"refresh": "Refresh"
|
||||
"refresh": "Refresh",
|
||||
"recentActivity": "Recent Activity",
|
||||
"recentTasks": "Recent Tasks",
|
||||
"recentTasksSegment": "Recent Tasks",
|
||||
"timeLogged": "Time Logged",
|
||||
"timeLoggedSegment": "Time Logged",
|
||||
"noRecentTasks": "No recent tasks",
|
||||
"noTimeLoggedTasks": "No time logged tasks",
|
||||
"activityTag": "Activity",
|
||||
"timeLogTag": "Time Log",
|
||||
"timerTag": "Timer",
|
||||
"activitySingular": "activity",
|
||||
"activityPlural": "activities",
|
||||
"recentTaskAriaLabel": "Recent task:",
|
||||
"timeLoggedTaskAriaLabel": "Time logged task:",
|
||||
"errorLoadingRecentTasks": "Error loading recent tasks",
|
||||
"errorLoadingTimeLoggedTasks": "Error loading time logged tasks"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,9 @@
|
||||
|
||||
"contextMenu": {
|
||||
"assignToMe": "Assign to me",
|
||||
"copyLink": "Copy link to task",
|
||||
"linkCopied": "Link copied to clipboard",
|
||||
"linkCopyFailed": "Failed to copy link",
|
||||
"moveTo": "Move to",
|
||||
"unarchive": "Unarchive",
|
||||
"archive": "Archive",
|
||||
@@ -133,5 +136,11 @@
|
||||
"dependencies": "Task has dependencies",
|
||||
"recurring": "Recurring task"
|
||||
}
|
||||
},
|
||||
|
||||
"timer": {
|
||||
"conflictTitle": "Timer Already Running",
|
||||
"conflictMessage": "You have a timer running for \"{{taskName}}\" in project \"{{projectName}}\". Would you like to stop that timer and start a new one for this task?",
|
||||
"stopAndStart": "Stop & Start New Timer"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"signup-failed": "Error al registrarse. Por favor asegúrate de llenar todos los campos requeridos e intenta nuevamente.",
|
||||
"reconnecting": "Reconectando al servidor...",
|
||||
"connection-lost": "Conexión perdida. Intentando reconectarse...",
|
||||
"connection-restored": "Conexión restaurada. Reconectando al servidor..."
|
||||
"connection-restored": "Conexión restaurada. Reconectando al servidor...",
|
||||
"cancel": "Cancelar"
|
||||
}
|
||||
|
||||
@@ -40,6 +40,22 @@
|
||||
"list": "Lista",
|
||||
"calendar": "Calendario",
|
||||
"tasks": "Tareas",
|
||||
"refresh": "Actualizar"
|
||||
"refresh": "Actualizar",
|
||||
"recentActivity": "Actividad Reciente",
|
||||
"recentTasks": "Tareas Recientes",
|
||||
"recentTasksSegment": "Tareas Recientes",
|
||||
"timeLogged": "Tiempo Registrado",
|
||||
"timeLoggedSegment": "Tiempo Registrado",
|
||||
"noRecentTasks": "No hay tareas recientes",
|
||||
"noTimeLoggedTasks": "No hay tareas con tiempo registrado",
|
||||
"activityTag": "Actividad",
|
||||
"timeLogTag": "Registro de Tiempo",
|
||||
"timerTag": "Temporizador",
|
||||
"activitySingular": "actividad",
|
||||
"activityPlural": "actividades",
|
||||
"recentTaskAriaLabel": "Tarea reciente:",
|
||||
"timeLoggedTaskAriaLabel": "Tarea con tiempo registrado:",
|
||||
"errorLoadingRecentTasks": "Error al cargar tareas recientes",
|
||||
"errorLoadingTimeLoggedTasks": "Error al cargar tareas con tiempo registrado"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,9 @@
|
||||
|
||||
"contextMenu": {
|
||||
"assignToMe": "Asignar a mí",
|
||||
"copyLink": "Copiar enlace a la tarea",
|
||||
"linkCopied": "Enlace copiado al portapapeles",
|
||||
"linkCopyFailed": "Error al copiar el enlace",
|
||||
"moveTo": "Mover a",
|
||||
"unarchive": "Desarchivar",
|
||||
"archive": "Archivar",
|
||||
@@ -133,5 +136,11 @@
|
||||
"dependencies": "La tarea tiene dependencias",
|
||||
"recurring": "Tarea recurrente"
|
||||
}
|
||||
},
|
||||
|
||||
"timer": {
|
||||
"conflictTitle": "Temporizador Ya En Ejecución",
|
||||
"conflictMessage": "Tiene un temporizador ejecutándose para \"{{taskName}}\" en el proyecto \"{{projectName}}\". ¿Le gustaría detener ese temporizador e iniciar uno nuevo para esta tarea?",
|
||||
"stopAndStart": "Detener e Iniciar Nuevo Temporizador"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"signup-failed": "Falha no cadastro. Por favor, certifique-se de que todos os campos obrigatórios estão preenchidos e tente novamente.",
|
||||
"reconnecting": "Reconectando ao servidor...",
|
||||
"connection-lost": "Conexão perdida. Tentando reconectar...",
|
||||
"connection-restored": "Conexão restaurada. Reconectando ao servidor..."
|
||||
"connection-restored": "Conexão restaurada. Reconectando ao servidor...",
|
||||
"cancel": "Cancelar"
|
||||
}
|
||||
|
||||
@@ -40,6 +40,22 @@
|
||||
"list": "Lista",
|
||||
"calendar": "Calendário",
|
||||
"tasks": "Tarefas",
|
||||
"refresh": "Atualizar"
|
||||
"refresh": "Atualizar",
|
||||
"recentActivity": "Atividade Recente",
|
||||
"recentTasks": "Tarefas Recentes",
|
||||
"recentTasksSegment": "Tarefas Recentes",
|
||||
"timeLogged": "Tempo Registrado",
|
||||
"timeLoggedSegment": "Tempo Registrado",
|
||||
"noRecentTasks": "Nenhuma tarefa recente",
|
||||
"noTimeLoggedTasks": "Nenhuma tarefa com tempo registrado",
|
||||
"activityTag": "Atividade",
|
||||
"timeLogTag": "Registro de Tempo",
|
||||
"timerTag": "Cronômetro",
|
||||
"activitySingular": "atividade",
|
||||
"activityPlural": "atividades",
|
||||
"recentTaskAriaLabel": "Tarefa recente:",
|
||||
"timeLoggedTaskAriaLabel": "Tarefa com tempo registrado:",
|
||||
"errorLoadingRecentTasks": "Erro ao carregar tarefas recentes",
|
||||
"errorLoadingTimeLoggedTasks": "Erro ao carregar tarefas com tempo registrado"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,9 @@
|
||||
|
||||
"contextMenu": {
|
||||
"assignToMe": "Atribuir a mim",
|
||||
"copyLink": "Copiar link da tarefa",
|
||||
"linkCopied": "Link copiado para a área de transferência",
|
||||
"linkCopyFailed": "Falha ao copiar o link",
|
||||
"moveTo": "Mover para",
|
||||
"unarchive": "Desarquivar",
|
||||
"archive": "Arquivar",
|
||||
@@ -133,5 +136,11 @@
|
||||
"dependencies": "A tarefa tem dependências",
|
||||
"recurring": "Tarefa recorrente"
|
||||
}
|
||||
},
|
||||
|
||||
"timer": {
|
||||
"conflictTitle": "Temporizador Já Em Execução",
|
||||
"conflictMessage": "Você tem um temporizador executando para \"{{taskName}}\" no projeto \"{{projectName}}\". Gostaria de parar esse temporizador e iniciar um novo para esta tarefa?",
|
||||
"stopAndStart": "Parar e Iniciar Novo Temporizador"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"signup-failed": "注册失败。请确保填写所有必填字段并重试。",
|
||||
"reconnecting": "与服务器断开连接。",
|
||||
"connection-lost": "无法连接到服务器。请检查您的互联网连接。",
|
||||
"connection-restored": "成功连接到服务器"
|
||||
"connection-restored": "成功连接到服务器",
|
||||
"cancel": "取消"
|
||||
}
|
||||
@@ -41,6 +41,22 @@
|
||||
"list": "列表",
|
||||
"calendar": "日历",
|
||||
"tasks": "任务",
|
||||
"refresh": "刷新"
|
||||
"refresh": "刷新",
|
||||
"recentActivity": "最近活动",
|
||||
"recentTasks": "最近任务",
|
||||
"recentTasksSegment": "最近任务",
|
||||
"timeLogged": "时间记录",
|
||||
"timeLoggedSegment": "时间记录",
|
||||
"noRecentTasks": "没有最近任务",
|
||||
"noTimeLoggedTasks": "没有时间记录任务",
|
||||
"activityTag": "活动",
|
||||
"timeLogTag": "时间记录",
|
||||
"timerTag": "计时器",
|
||||
"activitySingular": "活动",
|
||||
"activityPlural": "活动",
|
||||
"recentTaskAriaLabel": "最近任务:",
|
||||
"timeLoggedTaskAriaLabel": "时间记录任务:",
|
||||
"errorLoadingRecentTasks": "加载最近任务时出错",
|
||||
"errorLoadingTimeLoggedTasks": "加载时间记录任务时出错"
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,9 @@
|
||||
"pendingInvitation": "待处理邀请",
|
||||
"contextMenu": {
|
||||
"assignToMe": "分配给我",
|
||||
"copyLink": "复制任务链接",
|
||||
"linkCopied": "链接已复制到剪贴板",
|
||||
"linkCopyFailed": "复制链接失败",
|
||||
"moveTo": "移动到",
|
||||
"unarchive": "取消归档",
|
||||
"archive": "归档",
|
||||
@@ -126,5 +129,11 @@
|
||||
"dependencies": "任务有依赖项",
|
||||
"recurring": "重复任务"
|
||||
}
|
||||
},
|
||||
|
||||
"timer": {
|
||||
"conflictTitle": "计时器已在运行",
|
||||
"conflictMessage": "您在项目\"{{projectName}}\"中的\"{{taskName}}\"任务正在运行计时器。您是否要停止该计时器并为此任务启动新的计时器?",
|
||||
"stopAndStart": "停止并启动新计时器"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
|
||||
import { API_BASE_URL } from '@/shared/constants';
|
||||
import { getCsrfToken } from '../api-client';
|
||||
import { IUserRecentTask, IUserTimeLoggedTask } from '@/types/home/user-activity.types';
|
||||
import config from '@/config/env';
|
||||
|
||||
const rootUrl = '/logs';
|
||||
|
||||
export const userActivityApiService = createApi({
|
||||
reducerPath: 'userActivityApi',
|
||||
baseQuery: fetchBaseQuery({
|
||||
baseUrl: `${config.apiUrl}${API_BASE_URL}`,
|
||||
prepareHeaders: (headers) => {
|
||||
headers.set('X-CSRF-Token', getCsrfToken() || '');
|
||||
headers.set('Content-Type', 'application/json');
|
||||
return headers;
|
||||
},
|
||||
credentials: 'include',
|
||||
}),
|
||||
tagTypes: ['UserRecentTasks', 'UserTimeLoggedTasks'],
|
||||
endpoints: (builder) => ({
|
||||
getUserRecentTasks: builder.query<IUserRecentTask[], { limit?: number; offset?: number }>({
|
||||
query: ({ limit = 10, offset = 0 }) => ({
|
||||
url: `${rootUrl}/user-recent-tasks`,
|
||||
params: { limit, offset },
|
||||
method: 'GET',
|
||||
}),
|
||||
providesTags: ['UserRecentTasks'],
|
||||
}),
|
||||
getUserTimeLoggedTasks: builder.query<IUserTimeLoggedTask[], { limit?: number; offset?: number }>({
|
||||
query: ({ limit = 10, offset = 0 }) => ({
|
||||
url: `${rootUrl}/user-time-logged-tasks`,
|
||||
params: { limit, offset },
|
||||
method: 'GET',
|
||||
}),
|
||||
providesTags: ['UserTimeLoggedTasks'],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetUserRecentTasksQuery,
|
||||
useGetUserTimeLoggedTasksQuery,
|
||||
} = userActivityApiService;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import userReducer from '@features/user/userSlice';
|
||||
|
||||
// Home Page
|
||||
import homePageReducer from '@features/home-page/home-page.slice';
|
||||
import userActivityReducer from '@features/home-page/user-activity.slice';
|
||||
|
||||
// Account Setup
|
||||
import accountSetupReducer from '@features/account-setup/account-setup.slice';
|
||||
@@ -82,6 +83,7 @@ import groupingReducer from '@/features/task-management/grouping.slice';
|
||||
import selectionReducer from '@/features/task-management/selection.slice';
|
||||
import homePageApiService from '@/api/home-page/home-page.api.service';
|
||||
import { projectsApi } from '@/api/projects/projects.v1.api.service';
|
||||
import { userActivityApiService } from '@/api/home-page/user-activity.api.service';
|
||||
|
||||
import projectViewReducer from '@features/project/project-view-slice';
|
||||
import taskManagementFieldsReducer from '@features/task-management/taskListFields.slice';
|
||||
@@ -90,7 +92,7 @@ export const store = configureStore({
|
||||
middleware: getDefaultMiddleware =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: false,
|
||||
}).concat(homePageApiService.middleware, projectsApi.middleware),
|
||||
}).concat(homePageApiService.middleware, projectsApi.middleware, userActivityApiService.middleware),
|
||||
reducer: {
|
||||
// Auth & User
|
||||
auth: authReducer,
|
||||
@@ -103,6 +105,9 @@ export const store = configureStore({
|
||||
homePageReducer: homePageReducer,
|
||||
[homePageApiService.reducerPath]: homePageApiService.reducer,
|
||||
[projectsApi.reducerPath]: projectsApi.reducer,
|
||||
userActivityReducer: userActivityReducer,
|
||||
[userActivityApiService.reducerPath]: userActivityApiService.reducer,
|
||||
|
||||
// Core UI
|
||||
themeReducer: themeReducer,
|
||||
localesReducer: localesReducer,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Form, Input, InputRef, Typography, Card, Tooltip } from '@/shared/antd-imports';
|
||||
import { Form, Input, InputRef, Typography, Card, Tooltip, Alert } from '@/shared/antd-imports';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { setOrganizationName } from '@/features/account-setup/account-setup.slice';
|
||||
import { RootState } from '@/app/store';
|
||||
import { sanitizeInput } from '@/utils/sanitizeInput';
|
||||
import { SpamDetector } from '@/utils/spamDetector';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
@@ -29,6 +30,7 @@ export const OrganizationStep: React.FC<Props> = ({
|
||||
const dispatch = useDispatch();
|
||||
const { organizationName } = useSelector((state: RootState) => state.accountSetupReducer);
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const [spamWarning, setSpamWarning] = useState<string>('');
|
||||
|
||||
// Autofill organization name if not already set
|
||||
useEffect(() => {
|
||||
@@ -44,7 +46,19 @@ export const OrganizationStep: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
const handleOrgNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const sanitizedValue = sanitizeInput(e.target.value);
|
||||
const rawValue = e.target.value;
|
||||
const sanitizedValue = sanitizeInput(rawValue);
|
||||
|
||||
// Check for spam patterns
|
||||
const spamCheck = SpamDetector.detectSpam(rawValue);
|
||||
if (spamCheck.isSpam) {
|
||||
setSpamWarning(`Warning: ${spamCheck.reasons.join(', ')}`);
|
||||
} else if (SpamDetector.isHighRiskContent(rawValue)) {
|
||||
setSpamWarning('Warning: Content appears to contain suspicious links or patterns');
|
||||
} else {
|
||||
setSpamWarning('');
|
||||
}
|
||||
|
||||
dispatch(setOrganizationName(sanitizedValue));
|
||||
};
|
||||
|
||||
@@ -60,12 +74,25 @@ export const OrganizationStep: React.FC<Props> = ({
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* Spam Warning */}
|
||||
{spamWarning && (
|
||||
<div className="mb-4">
|
||||
<Alert
|
||||
message={spamWarning}
|
||||
type="warning"
|
||||
showIcon
|
||||
closable
|
||||
onClose={() => setSpamWarning('')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Form Card */}
|
||||
<div className="mb-6">
|
||||
<Card
|
||||
className="border-2 hover:shadow-md transition-all duration-200"
|
||||
style={{
|
||||
borderColor: token?.colorPrimary,
|
||||
borderColor: spamWarning ? token?.colorWarning : token?.colorPrimary,
|
||||
backgroundColor: token?.colorBgContainer
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { EnterOutlined, EditOutlined } from '@/shared/antd-imports';
|
||||
import { Card, Button, Tooltip, Typography } from '@/shared/antd-imports';
|
||||
import { Card, Button, Tooltip, Typography, Alert } from '@/shared/antd-imports';
|
||||
import TextArea from 'antd/es/input/TextArea';
|
||||
import { TFunction } from 'i18next';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { SpamDetector } from '@/utils/spamDetector';
|
||||
|
||||
interface OrganizationNameProps {
|
||||
themeMode: string;
|
||||
@@ -16,6 +17,7 @@ interface OrganizationNameProps {
|
||||
const OrganizationName = ({ themeMode, name, t, refetch }: OrganizationNameProps) => {
|
||||
const [isEditable, setIsEditable] = useState(false);
|
||||
const [newName, setNewName] = useState(name);
|
||||
const [spamWarning, setSpamWarning] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
setNewName(name);
|
||||
@@ -34,7 +36,18 @@ const OrganizationName = ({ themeMode, name, t, refetch }: OrganizationNameProps
|
||||
};
|
||||
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setNewName(e.target.value);
|
||||
const value = e.target.value;
|
||||
setNewName(value);
|
||||
|
||||
// Check for spam patterns
|
||||
const spamCheck = SpamDetector.detectSpam(value);
|
||||
if (spamCheck.isSpam) {
|
||||
setSpamWarning(`Warning: ${spamCheck.reasons.join(', ')}`);
|
||||
} else if (SpamDetector.isHighRiskContent(value)) {
|
||||
setSpamWarning('Warning: Content appears to contain suspicious links or patterns');
|
||||
} else {
|
||||
setSpamWarning('');
|
||||
}
|
||||
};
|
||||
|
||||
const updateOrganizationName = async () => {
|
||||
@@ -62,6 +75,16 @@ const OrganizationName = ({ themeMode, name, t, refetch }: OrganizationNameProps
|
||||
<Typography.Title level={5} style={{ margin: 0, marginBottom: '0.5rem' }}>
|
||||
{t('name')}
|
||||
</Typography.Title>
|
||||
{spamWarning && (
|
||||
<Alert
|
||||
message={spamWarning}
|
||||
type="warning"
|
||||
showIcon
|
||||
closable
|
||||
onClose={() => setSpamWarning('')}
|
||||
style={{ marginBottom: '8px' }}
|
||||
/>
|
||||
)}
|
||||
<div style={{ paddingTop: '8px' }}>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
{isEditable ? (
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Divider, Form, Input, message, Modal, Typography } from '@/shared/antd-imports';
|
||||
import { Divider, Form, Input, message, Modal, Typography, Alert } from '@/shared/antd-imports';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { editTeamName, fetchTeams } from '@/features/teams/teamSlice';
|
||||
import { ITeamGetResponse } from '@/types/teams/team.type';
|
||||
import { SpamDetector } from '@/utils/spamDetector';
|
||||
|
||||
interface EditTeamNameModalProps {
|
||||
team: ITeamGetResponse | null;
|
||||
@@ -16,6 +17,7 @@ const EditTeamNameModal = ({ team, isModalOpen, onCancel }: EditTeamNameModalPro
|
||||
const dispatch = useAppDispatch();
|
||||
const [form] = Form.useForm();
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [spamWarning, setSpamWarning] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (team) {
|
||||
@@ -67,6 +69,16 @@ const EditTeamNameModal = ({ team, isModalOpen, onCancel }: EditTeamNameModalPro
|
||||
destroyOnClose={true}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleFormSubmit}>
|
||||
{spamWarning && (
|
||||
<Alert
|
||||
message={spamWarning}
|
||||
type="warning"
|
||||
showIcon
|
||||
closable
|
||||
onClose={() => setSpamWarning('')}
|
||||
style={{ marginBottom: '16px' }}
|
||||
/>
|
||||
)}
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('name')}
|
||||
@@ -77,7 +89,20 @@ const EditTeamNameModal = ({ team, isModalOpen, onCancel }: EditTeamNameModalPro
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder={t('namePlaceholder')} />
|
||||
<Input
|
||||
placeholder={t('namePlaceholder')}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const spamCheck = SpamDetector.detectSpam(value);
|
||||
if (spamCheck.isSpam) {
|
||||
setSpamWarning(`Warning: ${spamCheck.reasons.join(', ')}`);
|
||||
} else if (SpamDetector.isHighRiskContent(value)) {
|
||||
setSpamWarning('Warning: Content appears to contain suspicious links or patterns');
|
||||
} else {
|
||||
setSpamWarning('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -30,6 +30,11 @@ export const SurveyPromptModal: React.FC<SurveyPromptModalProps> = ({ forceShow
|
||||
const isDarkMode = themeMode === 'dark';
|
||||
|
||||
useEffect(() => {
|
||||
// Check if survey modal is disabled via environment variable
|
||||
if (import.meta.env.VITE_ENABLE_SURVEY_MODAL !== 'true' && !forceShow) {
|
||||
return; // Don't show modal if disabled in environment
|
||||
}
|
||||
|
||||
// Check if survey was skipped recently (within 7 days)
|
||||
const skippedAt = localStorage.getItem('survey_skipped_at');
|
||||
if (!forceShow && skippedAt) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import TimeLogList from './time-log-list';
|
||||
import { taskTimeLogsApiService } from '@/api/tasks/task-time-logs.api.service';
|
||||
import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types';
|
||||
import TaskTimer from '@/components/taskListCommon/task-timer/task-timer';
|
||||
import { useTaskTimer } from '@/hooks/useTaskTimer';
|
||||
import { useTaskTimerWithConflictCheck } from '@/hooks/useTaskTimerWithConflictCheck';
|
||||
import logger from '@/utils/errorLogger';
|
||||
|
||||
interface TaskDrawerTimeLogProps {
|
||||
@@ -31,7 +31,7 @@ const TaskDrawerTimeLog = ({ t, refreshTrigger = 0 }: TaskDrawerTimeLogProps) =>
|
||||
state => state.taskDrawerReducer
|
||||
);
|
||||
|
||||
const { started, timeString, handleStartTimer, handleStopTimer } = useTaskTimer(
|
||||
const { started, timeString, handleStartTimer, handleStopTimer } = useTaskTimerWithConflictCheck(
|
||||
selectedTaskId || '',
|
||||
taskFormViewModel?.task?.timer_start_time || null
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useMemo, useCallback, useState } from 'react';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
// @ts-ignore: Heroicons module types
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
@@ -382,24 +381,12 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({
|
||||
t,
|
||||
]);
|
||||
|
||||
// Make the group header droppable
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id: group.id,
|
||||
data: {
|
||||
type: 'group',
|
||||
group,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center">
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`inline-flex w-max items-center px-1 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-t border-b border-gray-200 dark:border-gray-700 rounded-t-md pr-2 ${
|
||||
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : ''
|
||||
}`}
|
||||
className="inline-flex w-max items-center px-1 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-t border-b border-gray-200 dark:border-gray-700 rounded-t-md pr-2"
|
||||
style={{
|
||||
backgroundColor: isOver ? `${headerBackgroundColor}dd` : headerBackgroundColor,
|
||||
backgroundColor: headerBackgroundColor,
|
||||
color: headerTextColor,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
|
||||
@@ -9,8 +9,9 @@ import {
|
||||
KeyboardSensor,
|
||||
TouchSensor,
|
||||
closestCenter,
|
||||
useDroppable,
|
||||
Modifier,
|
||||
} from '@dnd-kit/core';
|
||||
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
@@ -67,106 +68,132 @@ import TaskListSkeleton from './components/TaskListSkeleton';
|
||||
import ConvertToSubtaskDrawer from '@/components/task-list-common/convert-to-subtask-drawer/convert-to-subtask-drawer';
|
||||
import EmptyListPlaceholder from '@/components/EmptyListPlaceholder';
|
||||
|
||||
// Empty Group Drop Zone Component
|
||||
const EmptyGroupDropZone: React.FC<{
|
||||
groupId: string;
|
||||
visibleColumns: any[];
|
||||
t: (key: string) => string;
|
||||
}> = ({ groupId, visibleColumns, t }) => {
|
||||
const { setNodeRef, isOver, active } = useDroppable({
|
||||
id: `empty-group-${groupId}`,
|
||||
data: {
|
||||
type: 'group',
|
||||
groupId: groupId,
|
||||
isEmpty: true,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`relative w-full transition-colors duration-200 ${
|
||||
isOver && active ? 'bg-blue-50 dark:bg-blue-900/20' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center min-w-max px-1 border-t border-b border-gray-200 dark:border-gray-700" style={{ height: '40px' }}>
|
||||
{visibleColumns.map((column, index) => {
|
||||
const emptyColumnStyle = {
|
||||
width: column.width,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
// Show text in the title column
|
||||
if (column.id === 'title') {
|
||||
return (
|
||||
<div
|
||||
key={`empty-${column.id}`}
|
||||
className="flex items-center pl-1"
|
||||
style={emptyColumnStyle}
|
||||
>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
No tasks in this group
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`empty-${column.id}`}
|
||||
className="border-r border-gray-200 dark:border-gray-700"
|
||||
style={emptyColumnStyle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{isOver && active && (
|
||||
<div className="absolute inset-0 border-2 border-dashed border-blue-400 dark:border-blue-500 rounded-md pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Placeholder Drop Indicator Component
|
||||
const PlaceholderDropIndicator: React.FC<{
|
||||
isVisible: boolean;
|
||||
visibleColumns: any[];
|
||||
}> = ({ isVisible, visibleColumns }) => {
|
||||
// Drop Spacer Component - creates space between tasks when dragging
|
||||
const DropSpacer: React.FC<{ isVisible: boolean; visibleColumns: any[]; isDarkMode?: boolean }> = ({
|
||||
isVisible,
|
||||
visibleColumns,
|
||||
isDarkMode = false
|
||||
}) => {
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center min-w-max px-1 border-2 border-dashed border-blue-400 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/20 rounded-md mx-1 my-1 transition-all duration-200 ease-in-out"
|
||||
style={{ minWidth: 'max-content', height: '40px' }}
|
||||
className="flex items-center min-w-max px-1 border-2 border-dashed border-blue-400 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/20 transition-all duration-200 ease-in-out"
|
||||
style={{
|
||||
height: isVisible ? '40px' : '0px',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
marginTop: isVisible ? '2px' : '0px',
|
||||
marginBottom: isVisible ? '2px' : '0px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{visibleColumns.map((column, index) => {
|
||||
// Calculate left position for sticky columns
|
||||
let leftPosition = 0;
|
||||
if (column.isSticky) {
|
||||
for (let i = 0; i < index; i++) {
|
||||
const prevColumn = visibleColumns[i];
|
||||
if (prevColumn.isSticky) {
|
||||
leftPosition += parseInt(prevColumn.width.replace('px', ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const columnStyle = {
|
||||
width: column.width,
|
||||
flexShrink: 0,
|
||||
...(column.isSticky && {
|
||||
position: 'sticky' as const,
|
||||
left: leftPosition,
|
||||
zIndex: 10,
|
||||
backgroundColor: 'inherit', // Inherit from parent spacer
|
||||
}),
|
||||
};
|
||||
|
||||
if (column.id === 'title') {
|
||||
return (
|
||||
<div
|
||||
key={`spacer-${column.id}`}
|
||||
className="flex items-center pl-1 border-r border-blue-300 dark:border-blue-600"
|
||||
style={columnStyle}
|
||||
>
|
||||
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium">
|
||||
Drop here
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`placeholder-${column.id}`}
|
||||
className="flex items-center justify-center h-full"
|
||||
key={`spacer-${column.id}`}
|
||||
className="border-r border-blue-300 dark:border-blue-600"
|
||||
style={columnStyle}
|
||||
>
|
||||
{/* Show "Drop task here" message in the title column */}
|
||||
{column.id === 'title' && (
|
||||
<div className="text-xs text-blue-600 dark:text-blue-400 font-medium opacity-75">
|
||||
Drop task here
|
||||
</div>
|
||||
)}
|
||||
{/* Show subtle placeholder content in other columns */}
|
||||
{column.id !== 'title' && column.id !== 'dragHandle' && (
|
||||
<div className="w-full h-4 mx-1 bg-white dark:bg-gray-700 rounded opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Empty Group Message Component
|
||||
const EmptyGroupMessage: React.FC<{ visibleColumns: any[]; isDarkMode?: boolean }> = ({
|
||||
visibleColumns,
|
||||
isDarkMode = false
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center min-w-max px-1 border-b border-gray-200 dark:border-gray-700" style={{ height: '40px' }}>
|
||||
{visibleColumns.map((column, index) => {
|
||||
// Calculate left position for sticky columns
|
||||
let leftPosition = 0;
|
||||
if (column.isSticky) {
|
||||
for (let i = 0; i < index; i++) {
|
||||
const prevColumn = visibleColumns[i];
|
||||
if (prevColumn.isSticky) {
|
||||
leftPosition += parseInt(prevColumn.width.replace('px', ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const emptyColumnStyle = {
|
||||
width: column.width,
|
||||
flexShrink: 0,
|
||||
...(column.isSticky && {
|
||||
position: 'sticky' as const,
|
||||
left: leftPosition,
|
||||
zIndex: 10,
|
||||
backgroundColor: 'inherit', // Inherit from parent container
|
||||
}),
|
||||
};
|
||||
|
||||
// Show text in the title column
|
||||
if (column.id === 'title') {
|
||||
return (
|
||||
<div
|
||||
key={`empty-${column.id}`}
|
||||
className="flex items-center pl-1 border-r border-gray-200 dark:border-gray-700"
|
||||
style={emptyColumnStyle}
|
||||
>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
No tasks in this group
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`empty-${column.id}`}
|
||||
className="border-r border-gray-200 dark:border-gray-700"
|
||||
style={emptyColumnStyle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// Hooks and utilities
|
||||
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
@@ -229,7 +256,7 @@ const TaskListV2Section: React.FC = () => {
|
||||
);
|
||||
|
||||
// Custom hooks
|
||||
const { activeId, overId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop(
|
||||
const { activeId, overId, dropPosition, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop(
|
||||
allTasks,
|
||||
groups
|
||||
);
|
||||
@@ -564,16 +591,12 @@ const TaskListV2Section: React.FC = () => {
|
||||
projectId={urlProjectId || ''}
|
||||
/>
|
||||
{isGroupEmpty && !isGroupCollapsed && (
|
||||
<EmptyGroupDropZone
|
||||
groupId={group.id}
|
||||
visibleColumns={visibleColumns}
|
||||
t={t}
|
||||
/>
|
||||
<EmptyGroupMessage visibleColumns={visibleColumns} isDarkMode={isDarkMode} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[virtuosoGroups, collapsedGroups, handleGroupCollapse, visibleColumns, t]
|
||||
[virtuosoGroups, collapsedGroups, handleGroupCollapse, visibleColumns, t, isDarkMode]
|
||||
);
|
||||
|
||||
const renderTask = useCallback(
|
||||
@@ -614,19 +637,40 @@ const TaskListV2Section: React.FC = () => {
|
||||
const renderColumnHeaders = useCallback(
|
||||
() => (
|
||||
<div
|
||||
className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700"
|
||||
style={{ width: '100%', minWidth: 'max-content' }}
|
||||
className="border-b border-gray-200 dark:border-gray-700"
|
||||
style={{
|
||||
width: '100%',
|
||||
minWidth: 'max-content',
|
||||
backgroundColor: isDarkMode ? '#141414' : '#f9fafb'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center px-1 py-3 w-full"
|
||||
style={{ minWidth: 'max-content', height: '44px' }}
|
||||
>
|
||||
{visibleColumns.map((column, index) => {
|
||||
// Calculate left position for sticky columns
|
||||
let leftPosition = 0;
|
||||
if (column.isSticky) {
|
||||
for (let i = 0; i < index; i++) {
|
||||
const prevColumn = visibleColumns[i];
|
||||
if (prevColumn.isSticky) {
|
||||
leftPosition += parseInt(prevColumn.width.replace('px', ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const columnStyle: ColumnStyle = {
|
||||
width: column.width,
|
||||
flexShrink: 0,
|
||||
...((column as any).minWidth && { minWidth: (column as any).minWidth }),
|
||||
...((column as any).maxWidth && { maxWidth: (column as any).maxWidth }),
|
||||
...(column.isSticky && {
|
||||
position: 'sticky' as const,
|
||||
left: leftPosition,
|
||||
zIndex: 15,
|
||||
backgroundColor: isDarkMode ? '#141414' : '#f9fafb', // custom dark header : bg-gray-50
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -791,13 +835,25 @@ const TaskListV2Section: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<>
|
||||
{/* CSS for sticky column hover effects */}
|
||||
<style>
|
||||
{`
|
||||
.hover\\:bg-gray-50:hover .sticky-column-hover,
|
||||
.dark .hover\\:bg-gray-800:hover .sticky-column-hover {
|
||||
background-color: var(--hover-bg) !important;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
>
|
||||
<div className="flex flex-col bg-white dark:bg-gray-900 h-full overflow-hidden">
|
||||
{/* Table Container */}
|
||||
<div
|
||||
@@ -851,40 +907,20 @@ const TaskListV2Section: React.FC = () => {
|
||||
// Check if this is the first actual task in the group (not AddTaskRow)
|
||||
const isFirstTaskInGroup = taskIndex === 0 && !('isAddTaskRow' in task);
|
||||
|
||||
// Check if we should show drop indicators
|
||||
const isTaskBeingDraggedOver = overId === task.id;
|
||||
const isGroupBeingDraggedOver = overId === group.id;
|
||||
const isFirstTaskInGroupBeingDraggedOver = isFirstTaskInGroup && isTaskBeingDraggedOver;
|
||||
// Check if we should show drop spacer
|
||||
const isOverThisTask = activeId && overId === task.id && !('isAddTaskRow' in task);
|
||||
const showDropSpacerBefore = isOverThisTask && dropPosition === 'before';
|
||||
const showDropSpacerAfter = isOverThisTask && dropPosition === 'after';
|
||||
|
||||
return (
|
||||
<div key={task.id || `add-task-${group.id}-${taskIndex}`}>
|
||||
{/* Placeholder drop indicator before first task in group */}
|
||||
{isFirstTaskInGroupBeingDraggedOver && (
|
||||
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
|
||||
)}
|
||||
|
||||
{/* Placeholder drop indicator between tasks */}
|
||||
{isTaskBeingDraggedOver && !isFirstTaskInGroup && (
|
||||
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
|
||||
)}
|
||||
|
||||
{showDropSpacerBefore && <DropSpacer isVisible={true} visibleColumns={visibleColumns} isDarkMode={isDarkMode} />}
|
||||
{renderTask(globalTaskIndex, isFirstTaskInGroup)}
|
||||
|
||||
{/* Placeholder drop indicator at end of group when dragging over group */}
|
||||
{isGroupBeingDraggedOver && taskIndex === group.tasks.length - 1 && (
|
||||
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
|
||||
)}
|
||||
{showDropSpacerAfter && <DropSpacer isVisible={true} visibleColumns={visibleColumns} isDarkMode={isDarkMode} />}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
// Handle empty groups with placeholder drop indicator
|
||||
overId === group.id && (
|
||||
<div style={{ minWidth: 'max-content' }}>
|
||||
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
|
||||
</div>
|
||||
)
|
||||
)
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -894,15 +930,15 @@ const TaskListV2Section: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Drag Overlay */}
|
||||
<DragOverlay dropAnimation={{ duration: 200, easing: 'ease-in-out' }}>
|
||||
<DragOverlay dropAnimation={{ duration: 200, easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)' }}>
|
||||
{activeId ? (
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 shadow-lg rounded-md border border-blue-400 dark:border-blue-500 opacity-90"
|
||||
className="bg-white dark:bg-gray-800 shadow-2xl rounded-lg border-2 border-blue-500 dark:border-blue-400 opacity-95"
|
||||
style={{ width: visibleColumns.find(col => col.id === 'title')?.width || '300px' }}
|
||||
>
|
||||
<div className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<HolderOutlined className="text-gray-400 dark:text-gray-500 text-xs" />
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<HolderOutlined className="text-blue-500 dark:text-blue-400 text-sm" />
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate flex-1">
|
||||
{allTasks.find(task => task.id === activeId)?.name ||
|
||||
allTasks.find(task => task.id === activeId)?.title ||
|
||||
@@ -954,6 +990,7 @@ const TaskListV2Section: React.FC = () => {
|
||||
{createPortal(<ConvertToSubtaskDrawer />, document.body, 'convert-to-subtask-drawer')}
|
||||
</div>
|
||||
</DndContext>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ const TaskRow: React.FC<TaskRowProps> = memo(({
|
||||
});
|
||||
|
||||
// Drag and drop functionality - only enable for parent tasks
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging, isOver } = useSortable({
|
||||
id: task.id,
|
||||
data: {
|
||||
type: 'task',
|
||||
@@ -116,24 +116,41 @@ const TaskRow: React.FC<TaskRowProps> = memo(({
|
||||
const style = useMemo(() => ({
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0 : 1, // Completely hide the original task while dragging
|
||||
opacity: isDragging ? 0.3 : 1, // Make original task slightly transparent while dragging
|
||||
}), [transform, transition, isDragging]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{ ...style, height: '40px' }}
|
||||
className={`flex items-center min-w-max px-1 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 ${
|
||||
className={`flex items-center min-w-max px-1 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${
|
||||
isFirstInGroup ? 'border-t border-gray-200 dark:border-gray-700' : ''
|
||||
} ${
|
||||
isDragging ? 'shadow-lg border border-blue-300' : ''
|
||||
isDragging ? 'opacity-50' : ''
|
||||
} ${
|
||||
isOver && !isDragging ? 'bg-blue-50 dark:bg-blue-900/20' : ''
|
||||
}`}
|
||||
>
|
||||
{visibleColumns.map((column, index) => (
|
||||
<React.Fragment key={column.id}>
|
||||
{renderColumn(column.id, column.width, column.isSticky, index)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{visibleColumns.map((column, index) => {
|
||||
// Calculate background state for sticky columns - custom dark mode colors
|
||||
const rowBackgrounds = {
|
||||
normal: isDarkMode ? '#1e1e1e' : '#ffffff', // custom dark : bg-white
|
||||
hover: isDarkMode ? '#1f2937' : '#f9fafb', // slightly lighter dark : bg-gray-50
|
||||
dragOver: isDarkMode ? '#1e3a8a33' : '#dbeafe', // bg-blue-900/20 : bg-blue-50
|
||||
};
|
||||
|
||||
let currentBg = rowBackgrounds.normal;
|
||||
if (isOver && !isDragging) {
|
||||
currentBg = rowBackgrounds.dragOver;
|
||||
}
|
||||
// Note: hover state is handled by CSS, so we'll use a CSS custom property
|
||||
|
||||
return (
|
||||
<React.Fragment key={column.id}>
|
||||
{renderColumn(column.id, column.width, column.isSticky, index, currentBg, rowBackgrounds)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import TaskTimer from '@/components/taskListCommon/task-timer/task-timer';
|
||||
import { useTaskTimer } from '@/hooks/useTaskTimer';
|
||||
import { useTaskTimerWithConflictCheck } from '@/hooks/useTaskTimerWithConflictCheck';
|
||||
|
||||
interface TaskTimeTrackingProps {
|
||||
taskId: string;
|
||||
@@ -8,7 +8,7 @@ interface TaskTimeTrackingProps {
|
||||
}
|
||||
|
||||
const TaskTimeTracking: React.FC<TaskTimeTrackingProps> = React.memo(({ taskId, isDarkMode }) => {
|
||||
const { started, timeString, handleStartTimer, handleStopTimer } = useTaskTimer(
|
||||
const { started, timeString, handleStartTimer, handleStopTimer } = useTaskTimerWithConflictCheck(
|
||||
taskId,
|
||||
null // The hook will get the timer start time from Redux
|
||||
);
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
RetweetOutlined,
|
||||
UserAddOutlined,
|
||||
LoadingOutlined,
|
||||
CopyOutlined,
|
||||
message,
|
||||
} from '@/shared/antd-imports';
|
||||
|
||||
interface TaskContextMenuProps {
|
||||
@@ -325,6 +327,21 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
|
||||
}
|
||||
}, [task?.id, projectId, dispatch, onClose]);
|
||||
|
||||
const handleCopyLink = useCallback(async () => {
|
||||
if (!projectId || !task.id) return;
|
||||
|
||||
try {
|
||||
const taskLink = `${window.location.origin}/worklenz/projects/${projectId}?tab=tasks-list&pinned_tab=tasks-list&task=${task.id}`;
|
||||
await navigator.clipboard.writeText(taskLink);
|
||||
message.success(t('contextMenu.linkCopied'));
|
||||
} catch (error) {
|
||||
logger.error('Error copying link:', error);
|
||||
message.error(t('contextMenu.linkCopyFailed'));
|
||||
} finally {
|
||||
onClose();
|
||||
}
|
||||
}, [projectId, task.id, onClose, t]);
|
||||
|
||||
const menuItems = useMemo(() => {
|
||||
const items = [
|
||||
{
|
||||
@@ -344,6 +361,18 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'copyLink',
|
||||
label: (
|
||||
<button
|
||||
onClick={handleCopyLink}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
|
||||
>
|
||||
<CopyOutlined className="text-gray-500 dark:text-gray-400" />
|
||||
<span>{t('contextMenu.copyLink')}</span>
|
||||
</button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Add Move To submenu if there are options
|
||||
@@ -500,6 +529,7 @@ const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
|
||||
handleArchive,
|
||||
handleDelete,
|
||||
handleConvertToTask,
|
||||
handleCopyLink,
|
||||
getMoveToOptions,
|
||||
dispatch,
|
||||
t,
|
||||
|
||||
@@ -19,10 +19,11 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [overId, setOverId] = useState<string | null>(null);
|
||||
const [dropPosition, setDropPosition] = useState<'before' | 'after' | null>(null);
|
||||
|
||||
// Helper function to emit socket event for persistence
|
||||
// Helper function to emit socket event for persistence (within-group only)
|
||||
const emitTaskSortChange = useCallback(
|
||||
(taskId: string, sourceGroup: TaskGroup, targetGroup: TaskGroup, insertIndex: number) => {
|
||||
(taskId: string, group: TaskGroup, insertIndex: number) => {
|
||||
if (!socket || !connected || !projectId) {
|
||||
logger.warning('Socket not connected or missing project ID');
|
||||
return;
|
||||
@@ -40,54 +41,30 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
||||
// Use new bulk update approach - recalculate ALL task orders to prevent duplicates
|
||||
const taskUpdates: any[] = [];
|
||||
|
||||
// Create a copy of all groups and perform the move operation
|
||||
const updatedGroups = groups.map(group => ({
|
||||
...group,
|
||||
taskIds: [...group.taskIds]
|
||||
// Create a copy of all groups
|
||||
const updatedGroups = groups.map(g => ({
|
||||
...g,
|
||||
taskIds: [...g.taskIds]
|
||||
}));
|
||||
|
||||
// Find the source and target groups in our copy
|
||||
const sourceGroupCopy = updatedGroups.find(g => g.id === sourceGroup.id)!;
|
||||
const targetGroupCopy = updatedGroups.find(g => g.id === targetGroup.id)!;
|
||||
// Find the group in our copy
|
||||
const groupCopy = updatedGroups.find(g => g.id === group.id)!;
|
||||
|
||||
if (sourceGroup.id === targetGroup.id) {
|
||||
// Same group - reorder within the group
|
||||
const sourceIndex = sourceGroupCopy.taskIds.indexOf(taskId);
|
||||
// Remove task from old position
|
||||
sourceGroupCopy.taskIds.splice(sourceIndex, 1);
|
||||
// Insert at new position
|
||||
sourceGroupCopy.taskIds.splice(insertIndex, 0, taskId);
|
||||
} else {
|
||||
// Different groups - move task between groups
|
||||
// Remove from source group
|
||||
const sourceIndex = sourceGroupCopy.taskIds.indexOf(taskId);
|
||||
sourceGroupCopy.taskIds.splice(sourceIndex, 1);
|
||||
|
||||
// Add to target group
|
||||
targetGroupCopy.taskIds.splice(insertIndex, 0, taskId);
|
||||
}
|
||||
// Reorder within the group
|
||||
const sourceIndex = groupCopy.taskIds.indexOf(taskId);
|
||||
// Remove task from old position
|
||||
groupCopy.taskIds.splice(sourceIndex, 1);
|
||||
// Insert at new position
|
||||
groupCopy.taskIds.splice(insertIndex, 0, taskId);
|
||||
|
||||
// Now assign sequential sort orders to ALL tasks across ALL groups
|
||||
let currentSortOrder = 0;
|
||||
updatedGroups.forEach(group => {
|
||||
group.taskIds.forEach(id => {
|
||||
const update: any = {
|
||||
updatedGroups.forEach(grp => {
|
||||
grp.taskIds.forEach(id => {
|
||||
taskUpdates.push({
|
||||
task_id: id,
|
||||
sort_order: currentSortOrder
|
||||
};
|
||||
|
||||
// Add group-specific fields for the moved task if it changed groups
|
||||
if (id === taskId && sourceGroup.id !== targetGroup.id) {
|
||||
if (currentGrouping === 'status') {
|
||||
update.status_id = targetGroup.id;
|
||||
} else if (currentGrouping === 'priority') {
|
||||
update.priority_id = targetGroup.id;
|
||||
} else if (currentGrouping === 'phase') {
|
||||
update.phase_id = targetGroup.id;
|
||||
}
|
||||
}
|
||||
|
||||
taskUpdates.push(update);
|
||||
});
|
||||
currentSortOrder++;
|
||||
});
|
||||
});
|
||||
@@ -96,8 +73,8 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
||||
project_id: projectId,
|
||||
group_by: currentGrouping || 'status',
|
||||
task_updates: taskUpdates,
|
||||
from_group: sourceGroup.id,
|
||||
to_group: targetGroup.id,
|
||||
from_group: group.id,
|
||||
to_group: group.id,
|
||||
task: {
|
||||
id: task.id,
|
||||
project_id: projectId,
|
||||
@@ -108,32 +85,6 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
||||
};
|
||||
|
||||
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), socketData);
|
||||
|
||||
// Also emit the specific grouping field change event for the moved task
|
||||
if (sourceGroup.id !== targetGroup.id) {
|
||||
if (currentGrouping === 'phase') {
|
||||
// Emit phase change event
|
||||
socket.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), {
|
||||
task_id: taskId,
|
||||
phase_id: targetGroup.id,
|
||||
parent_task: task.parent_task_id || null,
|
||||
});
|
||||
} else if (currentGrouping === 'priority') {
|
||||
// Emit priority change event
|
||||
socket.emit(SocketEvents.TASK_PRIORITY_CHANGE.toString(), JSON.stringify({
|
||||
task_id: taskId,
|
||||
priority_id: targetGroup.id,
|
||||
team_id: teamId,
|
||||
}));
|
||||
} else if (currentGrouping === 'status') {
|
||||
// Emit status change event
|
||||
socket.emit(SocketEvents.TASK_STATUS_CHANGE.toString(), JSON.stringify({
|
||||
task_id: taskId,
|
||||
status_id: targetGroup.id,
|
||||
team_id: teamId,
|
||||
}));
|
||||
}
|
||||
}
|
||||
},
|
||||
[socket, connected, projectId, allTasks, groups, currentGrouping, currentSession]
|
||||
);
|
||||
@@ -148,32 +99,38 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
||||
|
||||
if (!over) {
|
||||
setOverId(null);
|
||||
setDropPosition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeId = active.id;
|
||||
const overId = over.id;
|
||||
const activeTask = allTasks.find(task => task.id === active.id);
|
||||
const overTask = allTasks.find(task => task.id === over.id);
|
||||
|
||||
// Set the overId for drop indicators
|
||||
setOverId(overId as string);
|
||||
if (activeTask && overTask) {
|
||||
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
|
||||
const overGroup = groups.find(group => group.taskIds.includes(overTask.id));
|
||||
|
||||
// Find the active task and the item being dragged over
|
||||
const activeTask = allTasks.find(task => task.id === activeId);
|
||||
if (!activeTask) return;
|
||||
// Only set overId if both tasks are in the same group
|
||||
if (activeGroup && overGroup && activeGroup.id === overGroup.id) {
|
||||
setOverId(over.id as string);
|
||||
|
||||
// Check if we're dragging over a task or a group
|
||||
const overTask = allTasks.find(task => task.id === overId);
|
||||
const overGroup = groups.find(group => group.id === overId);
|
||||
// Calculate drop position based on task indices
|
||||
const activeIndex = activeGroup.taskIds.indexOf(activeTask.id);
|
||||
const overIndex = activeGroup.taskIds.indexOf(overTask.id);
|
||||
|
||||
// Find the groups
|
||||
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
|
||||
let targetGroup = overGroup;
|
||||
|
||||
if (overTask) {
|
||||
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
|
||||
if (activeIndex < overIndex) {
|
||||
setDropPosition('after');
|
||||
} else {
|
||||
setDropPosition('before');
|
||||
}
|
||||
} else {
|
||||
setOverId(null);
|
||||
setDropPosition(null);
|
||||
}
|
||||
} else {
|
||||
setOverId(null);
|
||||
setDropPosition(null);
|
||||
}
|
||||
|
||||
if (!activeGroup || !targetGroup) return;
|
||||
},
|
||||
[allTasks, groups]
|
||||
);
|
||||
@@ -183,6 +140,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
||||
const { active, over } = event;
|
||||
setActiveId(null);
|
||||
setOverId(null);
|
||||
setDropPosition(null);
|
||||
|
||||
if (!over || active.id === over.id) {
|
||||
return;
|
||||
@@ -198,86 +156,50 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the groups
|
||||
// Find the active task's group
|
||||
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
|
||||
if (!activeGroup) {
|
||||
logger.error('Could not find active group for task:', activeId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we're dropping on a task, group, or empty group
|
||||
// Only allow dropping on tasks in the same group
|
||||
const overTask = allTasks.find(task => task.id === overId);
|
||||
const overGroup = groups.find(group => group.id === overId);
|
||||
|
||||
// Check if dropping on empty group drop zone
|
||||
const isEmptyGroupDrop = typeof overId === 'string' && overId.startsWith('empty-group-');
|
||||
const emptyGroupId = isEmptyGroupDrop ? overId.replace('empty-group-', '') : null;
|
||||
const emptyGroup = emptyGroupId ? groups.find(group => group.id === emptyGroupId) : null;
|
||||
|
||||
let targetGroup = overGroup || emptyGroup;
|
||||
let insertIndex = 0;
|
||||
|
||||
if (overTask) {
|
||||
// Dropping on a task
|
||||
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
|
||||
if (targetGroup) {
|
||||
insertIndex = targetGroup.taskIds.indexOf(overTask.id);
|
||||
}
|
||||
} else if (overGroup) {
|
||||
// Dropping on a group (at the end)
|
||||
targetGroup = overGroup;
|
||||
insertIndex = targetGroup.taskIds.length;
|
||||
} else if (emptyGroup) {
|
||||
// Dropping on an empty group
|
||||
targetGroup = emptyGroup;
|
||||
insertIndex = 0; // First position in empty group
|
||||
}
|
||||
|
||||
if (!targetGroup) {
|
||||
logger.error('Could not find target group');
|
||||
if (!overTask) {
|
||||
return;
|
||||
}
|
||||
|
||||
const overGroup = groups.find(group => group.taskIds.includes(overTask.id));
|
||||
if (!overGroup || overGroup.id !== activeGroup.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isCrossGroup = activeGroup.id !== targetGroup.id;
|
||||
const activeIndex = activeGroup.taskIds.indexOf(activeTask.id);
|
||||
const overIndex = activeGroup.taskIds.indexOf(overTask.id);
|
||||
|
||||
if (isCrossGroup) {
|
||||
// Moving task between groups
|
||||
console.log('Moving task between groups:', {
|
||||
task: activeTask.name || activeTask.title,
|
||||
from: activeGroup.title,
|
||||
to: targetGroup.title,
|
||||
newPosition: insertIndex,
|
||||
});
|
||||
|
||||
// reorderTasksInGroup handles both same-group and cross-group moves
|
||||
// No need for separate moveTaskBetweenGroups call
|
||||
if (activeIndex !== overIndex) {
|
||||
// Reorder task within same group
|
||||
dispatch(
|
||||
reorderTasksInGroup({
|
||||
sourceTaskId: activeId as string,
|
||||
destinationTaskId: over.id as string,
|
||||
destinationTaskId: overId as string,
|
||||
sourceGroupId: activeGroup.id,
|
||||
destinationGroupId: targetGroup.id,
|
||||
destinationGroupId: activeGroup.id,
|
||||
})
|
||||
);
|
||||
|
||||
// Emit socket event for persistence
|
||||
emitTaskSortChange(activeId as string, activeGroup, targetGroup, insertIndex);
|
||||
} else {
|
||||
if (activeIndex !== insertIndex) {
|
||||
// Reorder task within same group at drop position
|
||||
dispatch(
|
||||
reorderTasksInGroup({
|
||||
sourceTaskId: activeId as string,
|
||||
destinationTaskId: over.id as string,
|
||||
sourceGroupId: activeGroup.id,
|
||||
destinationGroupId: activeGroup.id,
|
||||
})
|
||||
);
|
||||
|
||||
// Emit socket event for persistence
|
||||
emitTaskSortChange(activeId as string, activeGroup, targetGroup, insertIndex);
|
||||
// Calculate the final index after reordering for socket emission
|
||||
let finalIndex = overIndex;
|
||||
if (activeIndex < overIndex) {
|
||||
// When dragging down, the task ends up just after the destination
|
||||
finalIndex = overIndex;
|
||||
} else {
|
||||
// When dragging up, the task ends up at the destination position
|
||||
finalIndex = overIndex;
|
||||
}
|
||||
|
||||
// Emit socket event for persistence
|
||||
emitTaskSortChange(activeId as string, activeGroup, finalIndex);
|
||||
}
|
||||
},
|
||||
[allTasks, groups, dispatch, emitTaskSortChange]
|
||||
@@ -286,6 +208,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
||||
return {
|
||||
activeId,
|
||||
overId,
|
||||
dropPosition,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDragEnd,
|
||||
|
||||
@@ -90,17 +90,39 @@ export const useTaskRowColumns = ({
|
||||
depth = 0,
|
||||
}: UseTaskRowColumnsProps) => {
|
||||
|
||||
const renderColumn = useCallback((columnId: string, width: string, isSticky?: boolean, index?: number) => {
|
||||
switch (columnId) {
|
||||
case 'dragHandle':
|
||||
return (
|
||||
<DragHandleColumn
|
||||
width={width}
|
||||
isSubtask={isSubtask}
|
||||
attributes={attributes}
|
||||
listeners={listeners}
|
||||
/>
|
||||
);
|
||||
const renderColumn = useCallback((columnId: string, width: string, isSticky?: boolean, index?: number, currentBg?: string, rowBackgrounds?: any) => {
|
||||
// Calculate left position for sticky columns
|
||||
let leftPosition = 0;
|
||||
if (isSticky && typeof index === 'number') {
|
||||
for (let i = 0; i < index; i++) {
|
||||
const prevColumn = visibleColumns[i];
|
||||
if (prevColumn.isSticky) {
|
||||
leftPosition += parseInt(prevColumn.width.replace('px', ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create wrapper style for sticky positioning
|
||||
const wrapperStyle = isSticky ? {
|
||||
position: 'sticky' as const,
|
||||
left: leftPosition,
|
||||
zIndex: 5, // Lower than header but above regular content
|
||||
backgroundColor: currentBg || (isDarkMode ? '#1e1e1e' : '#ffffff'), // Use dynamic background or fallback
|
||||
overflow: 'hidden', // Prevent content from spilling over
|
||||
width: width, // Ensure the wrapper respects column width
|
||||
} : {};
|
||||
|
||||
const renderColumnContent = () => {
|
||||
switch (columnId) {
|
||||
case 'dragHandle':
|
||||
return (
|
||||
<DragHandleColumn
|
||||
width={width}
|
||||
isSubtask={isSubtask}
|
||||
attributes={attributes}
|
||||
listeners={listeners}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'checkbox':
|
||||
return (
|
||||
@@ -294,7 +316,27 @@ export const useTaskRowColumns = ({
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Wrap content with sticky positioning if needed
|
||||
const content = renderColumnContent();
|
||||
if (isSticky) {
|
||||
const hoverBg = rowBackgrounds?.hover || (isDarkMode ? '#2a2a2a' : '#f9fafb');
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...wrapperStyle,
|
||||
'--hover-bg': hoverBg,
|
||||
} as React.CSSProperties}
|
||||
className="border-r border-gray-200 dark:border-gray-700 overflow-hidden sticky-column-hover hover:bg-[var(--hover-bg)]"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}, [
|
||||
task,
|
||||
projectId,
|
||||
@@ -319,6 +361,7 @@ export const useTaskRowColumns = ({
|
||||
handleTaskNameEdit,
|
||||
attributes,
|
||||
listeners,
|
||||
depth,
|
||||
]);
|
||||
|
||||
return { renderColumn };
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Task } from '@/types/task-management.types';
|
||||
import {
|
||||
updateTask,
|
||||
selectCurrentGroupingV3,
|
||||
selectGroups,
|
||||
moveTaskBetweenGroups,
|
||||
} from '@/features/task-management/task-management.slice';
|
||||
|
||||
interface TaskStatusDropdownProps {
|
||||
@@ -30,6 +32,7 @@ const TaskStatusDropdown: React.FC<TaskStatusDropdownProps> = ({
|
||||
|
||||
const statusList = useAppSelector(state => state.taskStatusReducer.status);
|
||||
const currentGroupingV3 = useAppSelector(selectCurrentGroupingV3);
|
||||
const groups = useAppSelector(selectGroups);
|
||||
|
||||
// Find current status details
|
||||
const currentStatus = useMemo(() => {
|
||||
@@ -44,21 +47,53 @@ const TaskStatusDropdown: React.FC<TaskStatusDropdownProps> = ({
|
||||
(statusId: string, statusName: string) => {
|
||||
if (!task.id || !statusId || !connected) return;
|
||||
|
||||
console.log('🎯 Status change initiated:', { taskId: task.id, statusId, statusName });
|
||||
// Optimistic update: immediately update the task status in Redux for instant feedback
|
||||
const updatedTask = {
|
||||
...task,
|
||||
status: statusId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
dispatch(updateTask(updatedTask));
|
||||
|
||||
// Handle group movement if grouping by status
|
||||
if (currentGroupingV3 === 'status' && groups && groups.length > 0) {
|
||||
// Find current group containing the task
|
||||
const currentGroup = groups.find(group => group.taskIds.includes(task.id));
|
||||
|
||||
// Find target group based on the new status ID
|
||||
let targetGroup = groups.find(group => group.id === statusId);
|
||||
|
||||
// If not found by status ID, try matching with group value
|
||||
if (!targetGroup) {
|
||||
targetGroup = groups.find(group => group.groupValue === statusId);
|
||||
}
|
||||
|
||||
if (currentGroup && targetGroup && currentGroup.id !== targetGroup.id) {
|
||||
// Move task between groups immediately for instant feedback
|
||||
dispatch(
|
||||
moveTaskBetweenGroups({
|
||||
taskId: task.id,
|
||||
sourceGroupId: currentGroup.id,
|
||||
targetGroupId: targetGroup.id,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit socket event for server-side update and real-time sync
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_STATUS_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
task_id: task.id,
|
||||
status_id: statusId,
|
||||
parent_task: null, // Assuming top-level tasks for now
|
||||
team_id: projectId, // Using projectId as teamId
|
||||
parent_task: task.parent_task_id || null,
|
||||
team_id: projectId,
|
||||
})
|
||||
);
|
||||
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
|
||||
setIsOpen(false);
|
||||
},
|
||||
[task.id, connected, socket, projectId]
|
||||
[task, connected, socket, projectId, dispatch, currentGroupingV3, groups]
|
||||
);
|
||||
|
||||
// Calculate dropdown position and handle outside clicks
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { ActivityFeedType } from '@/types/home/user-activity.types';
|
||||
|
||||
interface ActivityItem {
|
||||
id: string;
|
||||
type: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface UserActivityState {
|
||||
activeTab: ActivityFeedType;
|
||||
activities: ActivityItem[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: UserActivityState = {
|
||||
activeTab: ActivityFeedType.TIME_LOGGED_TASKS,
|
||||
activities: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const userActivitySlice = createSlice({
|
||||
name: 'userActivity',
|
||||
initialState,
|
||||
reducers: {
|
||||
setActiveTab(state, action: PayloadAction<ActivityFeedType>) {
|
||||
state.activeTab = action.payload;
|
||||
},
|
||||
fetchActivitiesStart(state) {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
},
|
||||
fetchActivitiesSuccess(state, action: PayloadAction<ActivityItem[]>) {
|
||||
state.activities = action.payload;
|
||||
state.loading = false;
|
||||
state.error = null;
|
||||
},
|
||||
fetchActivitiesFailure(state, action: PayloadAction<string>) {
|
||||
state.loading = false;
|
||||
state.error = action.payload;
|
||||
},
|
||||
clearActivities(state) {
|
||||
state.activities = [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setActiveTab,
|
||||
fetchActivitiesStart,
|
||||
fetchActivitiesSuccess,
|
||||
fetchActivitiesFailure,
|
||||
clearActivities,
|
||||
} = userActivitySlice.actions;
|
||||
|
||||
export default userActivitySlice.reducer;
|
||||
@@ -153,8 +153,8 @@ const Navbar = () => {
|
||||
<Flex align="center">
|
||||
<SwitchTeamButton />
|
||||
<NotificationButton />
|
||||
{/* <TimerButton /> */}
|
||||
<HelpButton />
|
||||
<TimerButton />
|
||||
{/* <HelpButton /> */}
|
||||
<ProfileButton isOwnerOrAdmin={isOwnerOrAdmin} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
@@ -7,7 +7,7 @@ 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';
|
||||
import { format, differenceInSeconds, isValid, parseISO } from 'date-fns';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { useToken } = theme;
|
||||
@@ -60,17 +60,17 @@ const TimerButton = () => {
|
||||
try {
|
||||
if (!timer || !timer.task_id || !timer.start_time) return;
|
||||
|
||||
const startTime = moment(timer.start_time);
|
||||
if (!startTime.isValid()) {
|
||||
const startTime = parseISO(timer.start_time);
|
||||
if (!isValid(startTime)) {
|
||||
logError(`Invalid start time for timer ${timer.task_id}: ${timer.start_time}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = moment();
|
||||
const duration = moment.duration(now.diff(startTime));
|
||||
const hours = Math.floor(duration.asHours());
|
||||
const minutes = duration.minutes();
|
||||
const seconds = duration.seconds();
|
||||
const now = new Date();
|
||||
const totalSeconds = differenceInSeconds(now, startTime);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
newTimes[timer.task_id] =
|
||||
`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
} catch (error) {
|
||||
@@ -86,12 +86,7 @@ const TimerButton = () => {
|
||||
useEffect(() => {
|
||||
fetchRunningTimers();
|
||||
|
||||
// Set up polling to refresh timers every 30 seconds
|
||||
const pollInterval = setInterval(() => {
|
||||
fetchRunningTimers();
|
||||
}, 30000);
|
||||
|
||||
return () => clearInterval(pollInterval);
|
||||
// Removed periodic polling - rely on socket events for real-time updates
|
||||
}, [fetchRunningTimers]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -273,7 +268,7 @@ const TimerButton = () => {
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
Started:{' '}
|
||||
{timer.start_time
|
||||
? moment(timer.start_time).format('HH:mm')
|
||||
? format(parseISO(timer.start_time), 'HH:mm')
|
||||
: '--:--'}
|
||||
</Text>
|
||||
<Text
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Drawer, Typography, Flex, Button, Dropdown } from '@/shared/antd-imports';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useAppSelector } from '../../../../hooks/useAppSelector';
|
||||
import { useAppDispatch } from '../../../../hooks/useAppDispatch';
|
||||
import { setSelectedProject, toggleProjectReportsDrawer } from '../project-reports-slice';
|
||||
@@ -8,6 +8,8 @@ import ProjectReportsDrawerTabs from './ProjectReportsDrawerTabs';
|
||||
import { colors } from '../../../../styles/colors';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IRPTProject } from '@/types/reporting/reporting.types';
|
||||
import { useAuthService } from '../../../../hooks/useAuth';
|
||||
import { reportingExportApiService } from '@/api/reporting/reporting-export.api.service';
|
||||
|
||||
type ProjectReportsDrawerProps = {
|
||||
selectedProject: IRPTProject | null;
|
||||
@@ -17,6 +19,8 @@ const ProjectReportsDrawer = ({ selectedProject }: ProjectReportsDrawerProps) =>
|
||||
const { t } = useTranslation('reporting-projects-drawer');
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
const [exporting, setExporting] = useState<boolean>(false);
|
||||
|
||||
// get drawer open state and project list from the reducer
|
||||
const isDrawerOpen = useAppSelector(
|
||||
@@ -35,6 +39,54 @@ const ProjectReportsDrawer = ({ selectedProject }: ProjectReportsDrawerProps) =>
|
||||
}
|
||||
};
|
||||
|
||||
// Export handlers
|
||||
const handleExportMembers = useCallback(() => {
|
||||
if (!selectedProject?.id) return;
|
||||
try {
|
||||
setExporting(true);
|
||||
const teamName = currentSession?.team_name || 'Team';
|
||||
reportingExportApiService.exportProjectMembers(
|
||||
selectedProject.id,
|
||||
selectedProject.name,
|
||||
teamName
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error exporting project members:', error);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, [selectedProject, currentSession?.team_name]);
|
||||
|
||||
const handleExportTasks = useCallback(() => {
|
||||
if (!selectedProject?.id) return;
|
||||
try {
|
||||
setExporting(true);
|
||||
const teamName = currentSession?.team_name || 'Team';
|
||||
reportingExportApiService.exportProjectTasks(
|
||||
selectedProject.id,
|
||||
selectedProject.name,
|
||||
teamName
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error exporting project tasks:', error);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, [selectedProject, currentSession?.team_name]);
|
||||
|
||||
const handleExportClick = useCallback((key: string) => {
|
||||
switch (key) {
|
||||
case '1':
|
||||
handleExportMembers();
|
||||
break;
|
||||
case '2':
|
||||
handleExportTasks();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, [handleExportMembers, handleExportTasks]);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={isDrawerOpen}
|
||||
@@ -56,9 +108,15 @@ const ProjectReportsDrawer = ({ selectedProject }: ProjectReportsDrawerProps) =>
|
||||
{ key: '1', label: t('membersButton') },
|
||||
{ key: '2', label: t('tasksButton') },
|
||||
],
|
||||
onClick: ({ key }) => handleExportClick(key),
|
||||
}}
|
||||
>
|
||||
<Button type="primary" icon={<DownOutlined />} iconPosition="end">
|
||||
<Button
|
||||
type="primary"
|
||||
loading={exporting}
|
||||
icon={<DownOutlined />}
|
||||
iconPosition="end"
|
||||
>
|
||||
{t('exportButton')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
|
||||
@@ -680,8 +680,23 @@ const taskManagementSlice = createSlice({
|
||||
const group = state.groups.find(g => g.id === sourceGroupId);
|
||||
if (group) {
|
||||
const newTasks = Array.from(group.taskIds);
|
||||
const [removed] = newTasks.splice(newTasks.indexOf(sourceTaskId), 1);
|
||||
newTasks.splice(newTasks.indexOf(destinationTaskId), 0, removed);
|
||||
const sourceIndex = newTasks.indexOf(sourceTaskId);
|
||||
const destinationIndex = newTasks.indexOf(destinationTaskId);
|
||||
|
||||
// Remove the task from its current position
|
||||
const [removed] = newTasks.splice(sourceIndex, 1);
|
||||
|
||||
// Calculate the insertion index
|
||||
let insertIndex = destinationIndex;
|
||||
if (sourceIndex < destinationIndex) {
|
||||
// When dragging down, we need to insert after the destination
|
||||
insertIndex = destinationIndex;
|
||||
} else {
|
||||
// When dragging up, we insert before the destination
|
||||
insertIndex = destinationIndex;
|
||||
}
|
||||
|
||||
newTasks.splice(insertIndex, 0, removed);
|
||||
group.taskIds = newTasks;
|
||||
|
||||
// Update order for affected tasks using the appropriate sort field
|
||||
|
||||
@@ -244,44 +244,18 @@ export const useTaskSocketHandlers = () => {
|
||||
// Find current group containing the task
|
||||
const currentGroup = groups.find(group => group.taskIds.includes(response.id));
|
||||
|
||||
// Find target group based on new status value with multiple matching strategies
|
||||
let targetGroup = groups.find(group => group.groupValue === newStatusValue);
|
||||
// Find target group based on the actual status ID from response
|
||||
let targetGroup = groups.find(group => group.id === response.status_id);
|
||||
|
||||
// If not found, try case-insensitive matching
|
||||
// If not found by status ID, try matching with group value
|
||||
if (!targetGroup) {
|
||||
targetGroup = groups.find(group =>
|
||||
group.groupValue?.toLowerCase() === newStatusValue.toLowerCase()
|
||||
);
|
||||
targetGroup = groups.find(group => group.groupValue === response.status_id);
|
||||
}
|
||||
|
||||
// If still not found, try matching with title
|
||||
if (!targetGroup) {
|
||||
// If still not found, try matching by status name (fallback)
|
||||
if (!targetGroup && response.status) {
|
||||
targetGroup = groups.find(group =>
|
||||
group.title?.toLowerCase() === newStatusValue.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
// If still not found, try matching common status patterns
|
||||
if (!targetGroup && newStatusValue === 'todo') {
|
||||
targetGroup = groups.find(group =>
|
||||
group.title?.toLowerCase().includes('todo') ||
|
||||
group.title?.toLowerCase().includes('to do') ||
|
||||
group.title?.toLowerCase().includes('pending') ||
|
||||
group.groupValue?.toLowerCase().includes('todo')
|
||||
);
|
||||
} else if (!targetGroup && newStatusValue === 'doing') {
|
||||
targetGroup = groups.find(group =>
|
||||
group.title?.toLowerCase().includes('doing') ||
|
||||
group.title?.toLowerCase().includes('progress') ||
|
||||
group.title?.toLowerCase().includes('active') ||
|
||||
group.groupValue?.toLowerCase().includes('doing')
|
||||
);
|
||||
} else if (!targetGroup && newStatusValue === 'done') {
|
||||
targetGroup = groups.find(group =>
|
||||
group.title?.toLowerCase().includes('done') ||
|
||||
group.title?.toLowerCase().includes('complete') ||
|
||||
group.title?.toLowerCase().includes('finish') ||
|
||||
group.groupValue?.toLowerCase().includes('done')
|
||||
group.title?.toLowerCase() === response.status.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -295,14 +269,11 @@ export const useTaskSocketHandlers = () => {
|
||||
})
|
||||
);
|
||||
} else if (!targetGroup) {
|
||||
console.log('❌ Target group not found for status:', newStatusValue);
|
||||
} else if (!currentGroup) {
|
||||
console.log('❌ Current group not found for task:', response.id);
|
||||
} else {
|
||||
console.log('🔧 No group movement needed - task already in correct group');
|
||||
// Fallback: refetch tasks to ensure consistency
|
||||
if (projectId) {
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('🔧 Not grouped by status, skipping group movement');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
94
worklenz-frontend/src/hooks/useTaskTimerWithConflictCheck.ts
Normal file
94
worklenz-frontend/src/hooks/useTaskTimerWithConflictCheck.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Modal } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { taskTimeLogsApiService } from '@/api/tasks/task-time-logs.api.service';
|
||||
import { useTaskTimer } from './useTaskTimer';
|
||||
|
||||
interface ConflictingTimer {
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
project_name: string;
|
||||
}
|
||||
|
||||
export const useTaskTimerWithConflictCheck = (taskId: string, timerStartTime: string | null) => {
|
||||
const { socket } = useSocket();
|
||||
const { t: tTable } = useTranslation('task-list-table');
|
||||
const { t: tCommon } = useTranslation('common');
|
||||
|
||||
// Ensure timerStartTime is a number or null, as required by useTaskTimer
|
||||
const parsedTimerStartTime = typeof timerStartTime === 'string' && timerStartTime !== null
|
||||
? Number(timerStartTime)
|
||||
: timerStartTime;
|
||||
|
||||
const originalHook = useTaskTimer(taskId, parsedTimerStartTime as number | null);
|
||||
const [isCheckingConflict, setIsCheckingConflict] = useState(false);
|
||||
|
||||
const checkForConflictingTimers = useCallback(async () => {
|
||||
try {
|
||||
const response = await taskTimeLogsApiService.getRunningTimers();
|
||||
const runningTimers = response.body || [];
|
||||
|
||||
// Find conflicting timer (running timer for a different task)
|
||||
const conflictingTimer = runningTimers.find((timer: ConflictingTimer) =>
|
||||
timer.task_id !== taskId
|
||||
);
|
||||
|
||||
return conflictingTimer;
|
||||
} catch (error) {
|
||||
console.error('Error checking for conflicting timers:', error);
|
||||
return null;
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
const handleStartTimerWithConflictCheck = useCallback(async () => {
|
||||
if (isCheckingConflict) return;
|
||||
|
||||
setIsCheckingConflict(true);
|
||||
|
||||
try {
|
||||
const conflictingTimer = await checkForConflictingTimers();
|
||||
|
||||
if (conflictingTimer) {
|
||||
Modal.confirm({
|
||||
title: tTable('timer.conflictTitle'),
|
||||
content: tTable('timer.conflictMessage', {
|
||||
taskName: conflictingTimer.task_name,
|
||||
projectName: conflictingTimer.project_name
|
||||
}),
|
||||
okText: tTable('timer.stopAndStart'),
|
||||
cancelText: tCommon('cancel'),
|
||||
onOk: () => {
|
||||
// Stop the conflicting timer
|
||||
if (socket) {
|
||||
socket.emit(SocketEvents.TASK_TIMER_STOP.toString(), JSON.stringify({
|
||||
task_id: conflictingTimer.task_id
|
||||
}));
|
||||
}
|
||||
|
||||
// Start the new timer after a short delay
|
||||
setTimeout(() => {
|
||||
originalHook.handleStartTimer();
|
||||
}, 100);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// No conflict, start timer directly
|
||||
originalHook.handleStartTimer();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling timer start with conflict check:', error);
|
||||
// Fallback to original behavior
|
||||
originalHook.handleStartTimer();
|
||||
} finally {
|
||||
setIsCheckingConflict(false);
|
||||
}
|
||||
}, [isCheckingConflict, checkForConflictingTimers, tTable, tCommon, socket, originalHook]);
|
||||
|
||||
return {
|
||||
...originalHook,
|
||||
handleStartTimer: handleStartTimerWithConflictCheck,
|
||||
isCheckingConflict,
|
||||
};
|
||||
};
|
||||
@@ -1,62 +1,18 @@
|
||||
import { Col, ConfigProvider, Layout } from '@/shared/antd-imports';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { memo, useMemo, useEffect, useRef } from 'react';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
import { ConfigProvider, Layout } from '@/shared/antd-imports';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
import Navbar from '../features/navbar/navbar';
|
||||
import { useAppSelector } from '../hooks/useAppSelector';
|
||||
import { colors } from '../styles/colors';
|
||||
|
||||
import { useRenderPerformance } from '@/utils/performance';
|
||||
import { DynamicCSSLoader, LayoutStabilizer } from '@/utils/css-optimizations';
|
||||
|
||||
const MainLayout = memo(() => {
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const isDesktop = useMediaQuery({ query: '(min-width: 1024px)' });
|
||||
const layoutRef = useRef<HTMLDivElement>(null);
|
||||
const location = useLocation();
|
||||
|
||||
// Performance monitoring in development
|
||||
useRenderPerformance('MainLayout');
|
||||
const isProjectView = location.pathname.includes('/projects/') &&
|
||||
!location.pathname.endsWith('/projects');
|
||||
|
||||
// Apply layout optimizations
|
||||
useEffect(() => {
|
||||
if (layoutRef.current) {
|
||||
// Prevent layout shifts in main content area
|
||||
LayoutStabilizer.applyContainment(layoutRef.current, 'layout');
|
||||
|
||||
// Load non-critical CSS dynamically
|
||||
DynamicCSSLoader.loadCSS('/styles/non-critical.css', {
|
||||
priority: 'low',
|
||||
media: 'all'
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
// Memoize styles to prevent object recreation on every render
|
||||
const headerStyles = useMemo(
|
||||
() => ({
|
||||
zIndex: 999,
|
||||
position: 'fixed' as const,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: 0,
|
||||
borderBottom: themeMode === 'dark' ? '1px solid #303030' : 'none',
|
||||
}),
|
||||
[themeMode]
|
||||
);
|
||||
|
||||
const contentStyles = useMemo(
|
||||
() => ({
|
||||
paddingInline: isDesktop ? 64 : 24,
|
||||
overflowX: 'hidden' as const,
|
||||
}),
|
||||
[isDesktop]
|
||||
);
|
||||
|
||||
// Memoize theme configuration
|
||||
const themeConfig = useMemo(
|
||||
() => ({
|
||||
components: {
|
||||
@@ -69,23 +25,19 @@ const MainLayout = memo(() => {
|
||||
[themeMode]
|
||||
);
|
||||
|
||||
// Memoize header className
|
||||
const headerClassName = useMemo(
|
||||
() => `shadow-md ${themeMode === 'dark' ? '' : 'shadow-[#18181811]'}`,
|
||||
[themeMode]
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={themeConfig}>
|
||||
<Layout ref={layoutRef} style={{ minHeight: '100vh' }} className="prevent-layout-shift">
|
||||
<Layout.Header className={`${headerClassName} gpu-accelerated`} style={headerStyles}>
|
||||
<Layout className="min-h-screen">
|
||||
<Layout.Header
|
||||
className={`sticky top-0 z-[999] flex items-center p-0 shadow-md ${
|
||||
themeMode === 'dark' ? 'border-b border-[#303030]' : 'shadow-[#18181811]'
|
||||
}`}
|
||||
>
|
||||
<Navbar />
|
||||
</Layout.Header>
|
||||
|
||||
<Layout.Content className="layout-contained">
|
||||
<Col xxl={{ span: 18, offset: 3, flex: '100%' }} style={contentStyles} className="task-content-container">
|
||||
<Outlet />
|
||||
</Col>
|
||||
<Layout.Content className={`px-4 sm:px-8 lg:px-12 xl:px-16 ${!isProjectView ? 'overflow-x-hidden max-w-[1400px]' : ''} mx-auto w-full`}>
|
||||
<Outlet />
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from 'i18next';
|
||||
|
||||
import AuthenticatingPage from '../AuthenticatingPage';
|
||||
import { verifyAuthentication } from '@/features/auth/authSlice';
|
||||
import { setUser } from '@/features/user/userSlice';
|
||||
import { setSession } from '@/utils/session-helper';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/features/auth/authSlice', () => ({
|
||||
verifyAuthentication: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/user/userSlice', () => ({
|
||||
setUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/session-helper', () => ({
|
||||
setSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/errorLogger', () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/shared/constants', () => ({
|
||||
WORKLENZ_REDIRECT_PROJ_KEY: 'worklenz_redirect_proj',
|
||||
}));
|
||||
|
||||
// Mock navigation
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock dispatch
|
||||
const mockDispatch = vi.fn();
|
||||
vi.mock('@/hooks/useAppDispatch', () => ({
|
||||
useAppDispatch: () => mockDispatch,
|
||||
}));
|
||||
|
||||
// Setup i18n for testing
|
||||
i18n.init({
|
||||
lng: 'en',
|
||||
resources: {
|
||||
en: {
|
||||
'auth/auth-common': {
|
||||
authenticating: 'Authenticating...',
|
||||
gettingThingsReady: 'Getting things ready for you...',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create test store
|
||||
const createTestStore = () => {
|
||||
return configureStore({
|
||||
reducer: {
|
||||
auth: (state = {}) => state,
|
||||
user: (state = {}) => state,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderWithProviders = (component: React.ReactElement) => {
|
||||
const store = createTestStore();
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<BrowserRouter>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
{component}
|
||||
</I18nextProvider>
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('AuthenticatingPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
localStorage.clear();
|
||||
// Mock window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { href: '' },
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders loading state correctly', () => {
|
||||
renderWithProviders(<AuthenticatingPage />);
|
||||
|
||||
expect(screen.getByText('Authenticating...')).toBeInTheDocument();
|
||||
expect(screen.getByText('Getting things ready for you...')).toBeInTheDocument();
|
||||
expect(screen.getByRole('generic', { busy: true })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects to login when authentication fails', async () => {
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockResolvedValue({ authenticated: false }),
|
||||
});
|
||||
|
||||
renderWithProviders(<AuthenticatingPage />);
|
||||
|
||||
// Run all pending timers
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/auth/login');
|
||||
});
|
||||
|
||||
it('redirects to setup when user setup is not completed', async () => {
|
||||
const mockUser = {
|
||||
id: '1',
|
||||
email: 'test@example.com',
|
||||
setup_completed: false,
|
||||
};
|
||||
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockResolvedValue({
|
||||
authenticated: true,
|
||||
user: mockUser,
|
||||
}),
|
||||
});
|
||||
|
||||
renderWithProviders(<AuthenticatingPage />);
|
||||
|
||||
// Run all pending timers
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(setSession).toHaveBeenCalledWith(mockUser);
|
||||
expect(setUser).toHaveBeenCalledWith(mockUser);
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/worklenz/setup');
|
||||
});
|
||||
|
||||
it('redirects to home after successful authentication', async () => {
|
||||
const mockUser = {
|
||||
id: '1',
|
||||
email: 'test@example.com',
|
||||
setup_completed: true,
|
||||
};
|
||||
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockResolvedValue({
|
||||
authenticated: true,
|
||||
user: mockUser,
|
||||
}),
|
||||
});
|
||||
|
||||
renderWithProviders(<AuthenticatingPage />);
|
||||
|
||||
// Run all pending timers
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(setSession).toHaveBeenCalledWith(mockUser);
|
||||
expect(setUser).toHaveBeenCalledWith(mockUser);
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/worklenz/home');
|
||||
});
|
||||
|
||||
it('redirects to project when redirect key is present in localStorage', async () => {
|
||||
const projectId = 'test-project-123';
|
||||
localStorage.setItem('worklenz_redirect_proj', projectId);
|
||||
|
||||
const mockUser = {
|
||||
id: '1',
|
||||
email: 'test@example.com',
|
||||
setup_completed: true,
|
||||
};
|
||||
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockResolvedValue({
|
||||
authenticated: true,
|
||||
user: mockUser,
|
||||
}),
|
||||
});
|
||||
|
||||
// Mock window.location with a proper setter
|
||||
let hrefValue = '';
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
get href() {
|
||||
return hrefValue;
|
||||
},
|
||||
set href(value) {
|
||||
hrefValue = value;
|
||||
},
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
renderWithProviders(<AuthenticatingPage />);
|
||||
|
||||
// Run all pending timers
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(setSession).toHaveBeenCalledWith(mockUser);
|
||||
expect(setUser).toHaveBeenCalledWith(mockUser);
|
||||
expect(hrefValue).toBe(`/worklenz/projects/${projectId}?tab=tasks-list`);
|
||||
expect(localStorage.getItem('worklenz_redirect_proj')).toBeNull();
|
||||
});
|
||||
|
||||
it('handles authentication errors and redirects to login', async () => {
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockRejectedValue(new Error('Authentication failed')),
|
||||
});
|
||||
|
||||
renderWithProviders(<AuthenticatingPage />);
|
||||
|
||||
// Run all pending timers
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/auth/login');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from 'i18next';
|
||||
|
||||
import ForgotPasswordPage from '../ForgotPasswordPage';
|
||||
import { resetPassword, verifyAuthentication } from '@/features/auth/authSlice';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/features/auth/authSlice', () => ({
|
||||
resetPassword: vi.fn(),
|
||||
verifyAuthentication: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/user/userSlice', () => ({
|
||||
setUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/session-helper', () => ({
|
||||
setSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/errorLogger', () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useMixpanelTracking', () => ({
|
||||
useMixpanelTracking: () => ({
|
||||
trackMixpanelEvent: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useDoumentTItle', () => ({
|
||||
useDocumentTitle: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('react-responsive', () => ({
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
|
||||
// Mock navigation
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock dispatch
|
||||
const mockDispatch = vi.fn();
|
||||
vi.mock('@/hooks/useAppDispatch', () => ({
|
||||
useAppDispatch: () => mockDispatch,
|
||||
}));
|
||||
|
||||
// Setup i18n for testing
|
||||
i18n.init({
|
||||
lng: 'en',
|
||||
resources: {
|
||||
en: {
|
||||
'auth/forgot-password': {
|
||||
headerDescription: 'Enter your email to reset your password',
|
||||
emailRequired: 'Please input your email!',
|
||||
emailPlaceholder: 'Enter your email',
|
||||
resetPasswordButton: 'Reset Password',
|
||||
returnToLoginButton: 'Return to Login',
|
||||
orText: 'or',
|
||||
successTitle: 'Password Reset Email Sent',
|
||||
successMessage: 'Please check your email for instructions to reset your password.',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create test store
|
||||
const createTestStore = () => {
|
||||
return configureStore({
|
||||
reducer: {
|
||||
auth: (state = {}) => state,
|
||||
user: (state = {}) => state,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderWithProviders = (component: React.ReactElement) => {
|
||||
const store = createTestStore();
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<BrowserRouter>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
{component}
|
||||
</I18nextProvider>
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('ForgotPasswordPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Mock URL search params
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { search: '' },
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders forgot password form correctly', () => {
|
||||
renderWithProviders(<ForgotPasswordPage />);
|
||||
|
||||
expect(screen.getByText('Enter your email to reset your password')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Enter your email')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Reset Password' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Return to Login' })).toBeInTheDocument();
|
||||
expect(screen.getByText('or')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('validates required email field', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ForgotPasswordPage />);
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Please input your email!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('validates email format', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ForgotPasswordPage />);
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('Enter your email');
|
||||
await user.type(emailInput, 'invalid-email');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Please input your email!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('submits form with valid email', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockResolvedValue({ done: true }),
|
||||
});
|
||||
|
||||
renderWithProviders(<ForgotPasswordPage />);
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('Enter your email');
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(resetPassword).toHaveBeenCalledWith('test@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows success message after successful submission', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockResolvedValue({ done: true }),
|
||||
});
|
||||
|
||||
renderWithProviders(<ForgotPasswordPage />);
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('Enter your email');
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Password Reset Email Sent')).toBeInTheDocument();
|
||||
expect(screen.getByText('Please check your email for instructions to reset your password.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading state during submission', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockImplementation(() => new Promise(() => {})), // Never resolves
|
||||
});
|
||||
|
||||
renderWithProviders(<ForgotPasswordPage />);
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('Enter your email');
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles submission errors gracefully', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockRejectedValue(new Error('Reset failed')),
|
||||
});
|
||||
|
||||
renderWithProviders(<ForgotPasswordPage />);
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('Enter your email');
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should not show success message
|
||||
expect(screen.queryByText('Password Reset Email Sent')).not.toBeInTheDocument();
|
||||
// Should still show the form
|
||||
expect(screen.getByPlaceholderText('Enter your email')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates to login page when return button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ForgotPasswordPage />);
|
||||
|
||||
const returnButton = screen.getByRole('button', { name: 'Return to Login' });
|
||||
await user.click(returnButton);
|
||||
|
||||
expect(returnButton.closest('a')).toHaveAttribute('href', '/auth/login');
|
||||
});
|
||||
|
||||
it('handles team parameter from URL', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { search: '?team=test-team-id' },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
renderWithProviders(<ForgotPasswordPage />);
|
||||
|
||||
// Component should render normally even with team parameter
|
||||
expect(screen.getByText('Enter your email to reset your password')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects authenticated users to home', async () => {
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockResolvedValue({
|
||||
authenticated: true,
|
||||
user: { id: '1', email: 'test@example.com' },
|
||||
}),
|
||||
});
|
||||
|
||||
renderWithProviders(<ForgotPasswordPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/worklenz/home');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not submit with empty email after trimming', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockResolvedValue({ done: true }),
|
||||
});
|
||||
|
||||
renderWithProviders(<ForgotPasswordPage />);
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('Enter your email');
|
||||
await user.type(emailInput, ' '); // Only whitespace
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||
await user.click(submitButton);
|
||||
|
||||
// Should not call resetPassword with empty string
|
||||
expect(resetPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from 'i18next';
|
||||
|
||||
import LoggingOutPage from '../LoggingOutPage';
|
||||
import { authApiService } from '@/api/auth/auth.api.service';
|
||||
import CacheCleanup from '@/utils/cache-cleanup';
|
||||
|
||||
// Mock dependencies
|
||||
const mockAuthService = {
|
||||
signOut: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@/hooks/useAuth', () => ({
|
||||
useAuthService: () => mockAuthService,
|
||||
}));
|
||||
|
||||
vi.mock('@/api/auth/auth.api.service', () => ({
|
||||
authApiService: {
|
||||
logout: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/cache-cleanup', () => ({
|
||||
default: {
|
||||
clearAllCaches: vi.fn(),
|
||||
forceReload: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('react-responsive', () => ({
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
|
||||
// Mock navigation
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
// Setup i18n for testing
|
||||
i18n.init({
|
||||
lng: 'en',
|
||||
resources: {
|
||||
en: {
|
||||
'auth/auth-common': {
|
||||
loggingOut: 'Logging Out...',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const renderWithProviders = (component: React.ReactElement) => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
{component}
|
||||
</I18nextProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('LoggingOutPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Mock console.error to avoid noise in tests
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
vi.mock('@/hooks/useAuth', () => ({
|
||||
useAuthService: () => mockAuthService,
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders loading state correctly', () => {
|
||||
renderWithProviders(<LoggingOutPage />);
|
||||
|
||||
expect(screen.getByText('Logging Out...')).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('performs complete logout sequence successfully', async () => {
|
||||
mockAuthService.signOut.mockResolvedValue(undefined);
|
||||
(authApiService.logout as any).mockResolvedValue(undefined);
|
||||
(CacheCleanup.clearAllCaches as any).mockResolvedValue(undefined);
|
||||
|
||||
renderWithProviders(<LoggingOutPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAuthService.signOut).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authApiService.logout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(CacheCleanup.clearAllCaches).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Fast-forward time to trigger the setTimeout
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles auth service signOut failure', async () => {
|
||||
mockAuthService.signOut.mockRejectedValue(new Error('SignOut failed'));
|
||||
(authApiService.logout as any).mockResolvedValue(undefined);
|
||||
(CacheCleanup.clearAllCaches as any).mockResolvedValue(undefined);
|
||||
|
||||
renderWithProviders(<LoggingOutPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAuthService.signOut).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(console.error).toHaveBeenCalledWith('Logout error:', expect.any(Error));
|
||||
expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles backend logout failure', async () => {
|
||||
mockAuthService.signOut.mockResolvedValue(undefined);
|
||||
(authApiService.logout as any).mockRejectedValue(new Error('Backend logout failed'));
|
||||
(CacheCleanup.clearAllCaches as any).mockResolvedValue(undefined);
|
||||
|
||||
renderWithProviders(<LoggingOutPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAuthService.signOut).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authApiService.logout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(console.error).toHaveBeenCalledWith('Logout error:', expect.any(Error));
|
||||
expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles cache cleanup failure', async () => {
|
||||
mockAuthService.signOut.mockResolvedValue(undefined);
|
||||
(authApiService.logout as any).mockResolvedValue(undefined);
|
||||
(CacheCleanup.clearAllCaches as any).mockRejectedValue(new Error('Cache cleanup failed'));
|
||||
|
||||
renderWithProviders(<LoggingOutPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAuthService.signOut).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authApiService.logout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(CacheCleanup.clearAllCaches).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(console.error).toHaveBeenCalledWith('Logout error:', expect.any(Error));
|
||||
expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
|
||||
});
|
||||
});
|
||||
|
||||
it('triggers logout sequence immediately on mount', () => {
|
||||
renderWithProviders(<LoggingOutPage />);
|
||||
|
||||
expect(mockAuthService.signOut).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows consistent loading UI throughout logout process', async () => {
|
||||
mockAuthService.signOut.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
|
||||
(authApiService.logout as any).mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
|
||||
(CacheCleanup.clearAllCaches as any).mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
|
||||
|
||||
renderWithProviders(<LoggingOutPage />);
|
||||
|
||||
// Should show loading state immediately
|
||||
expect(screen.getByText('Logging Out...')).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
|
||||
|
||||
// Should continue showing loading state during the process
|
||||
vi.advanceTimersByTime(50);
|
||||
|
||||
expect(screen.getByText('Logging Out...')).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls forceReload with correct path after timeout', async () => {
|
||||
mockAuthService.signOut.mockResolvedValue(undefined);
|
||||
(authApiService.logout as any).mockResolvedValue(undefined);
|
||||
(CacheCleanup.clearAllCaches as any).mockResolvedValue(undefined);
|
||||
|
||||
renderWithProviders(<LoggingOutPage />);
|
||||
|
||||
// Wait for all async operations to complete
|
||||
await waitFor(() => {
|
||||
expect(CacheCleanup.clearAllCaches).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Fast-forward exactly 1000ms
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
|
||||
expect(CacheCleanup.forceReload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles complete failure of all logout steps', async () => {
|
||||
mockAuthService.signOut.mockRejectedValue(new Error('SignOut failed'));
|
||||
(authApiService.logout as any).mockRejectedValue(new Error('Backend logout failed'));
|
||||
(CacheCleanup.clearAllCaches as any).mockRejectedValue(new Error('Cache cleanup failed'));
|
||||
|
||||
renderWithProviders(<LoggingOutPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(console.error).toHaveBeenCalledWith('Logout error:', expect.any(Error));
|
||||
expect(CacheCleanup.forceReload).toHaveBeenCalledWith('/auth/login');
|
||||
});
|
||||
});
|
||||
});
|
||||
317
worklenz-frontend/src/pages/auth/__tests__/LoginPage.test.tsx
Normal file
317
worklenz-frontend/src/pages/auth/__tests__/LoginPage.test.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from 'i18next';
|
||||
|
||||
import LoginPage from '../LoginPage';
|
||||
import { login, verifyAuthentication } from '@/features/auth/authSlice';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/features/auth/authSlice', () => ({
|
||||
login: vi.fn(),
|
||||
verifyAuthentication: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/user/userSlice', () => ({
|
||||
setUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/session-helper', () => ({
|
||||
setSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/errorLogger', () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useMixpanelTracking', () => ({
|
||||
useMixpanelTracking: () => ({
|
||||
trackMixpanelEvent: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useDoumentTItle', () => ({
|
||||
useDocumentTitle: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useAuth', () => ({
|
||||
useAuthService: () => ({
|
||||
getCurrentSession: () => null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/alerts/alertService', () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('react-responsive', () => ({
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
|
||||
// Mock navigation
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock dispatch
|
||||
const mockDispatch = vi.fn();
|
||||
vi.mock('@/hooks/useAppDispatch', () => ({
|
||||
useAppDispatch: () => mockDispatch,
|
||||
}));
|
||||
|
||||
// Setup i18n for testing
|
||||
i18n.init({
|
||||
lng: 'en',
|
||||
resources: {
|
||||
en: {
|
||||
'auth/login': {
|
||||
headerDescription: 'Sign in to your account',
|
||||
emailRequired: 'Please input your email!',
|
||||
passwordRequired: 'Please input your password!',
|
||||
emailPlaceholder: 'Email',
|
||||
passwordPlaceholder: 'Password',
|
||||
loginButton: 'Sign In',
|
||||
rememberMe: 'Remember me',
|
||||
forgotPasswordButton: 'Forgot password?',
|
||||
signInWithGoogleButton: 'Sign in with Google',
|
||||
orText: 'or',
|
||||
dontHaveAccountText: "Don't have an account?",
|
||||
signupButton: 'Sign up',
|
||||
successMessage: 'Login successful!',
|
||||
'validationMessages.email': 'Please enter a valid email!',
|
||||
'validationMessages.password': 'Password must be at least 8 characters!',
|
||||
'errorMessages.loginErrorTitle': 'Login Failed',
|
||||
'errorMessages.loginErrorMessage': 'Invalid email or password',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create test store
|
||||
const createTestStore = (initialState: any = {}) => {
|
||||
return configureStore({
|
||||
reducer: {
|
||||
auth: (state = { isLoading: false, ...initialState.auth }) => state,
|
||||
user: (state = {}) => state,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderWithProviders = (component: React.ReactElement, initialState: any = {}) => {
|
||||
const store = createTestStore(initialState);
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<BrowserRouter>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
{component}
|
||||
</I18nextProvider>
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('LoginPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Mock environment variables
|
||||
vi.stubEnv('VITE_ENABLE_GOOGLE_LOGIN', 'true');
|
||||
vi.stubEnv('VITE_API_URL', 'http://localhost:3000');
|
||||
});
|
||||
|
||||
it('renders login form correctly', () => {
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
expect(screen.getByText('Sign in to your account')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Email')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Sign In' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Remember me')).toBeInTheDocument();
|
||||
expect(screen.getByText('Forgot password?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Google login button when enabled', () => {
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
expect(screen.getByText('Sign in with Google')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('validates required fields', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Sign In' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Please input your email!')).toBeInTheDocument();
|
||||
expect(screen.getByText('Please input your password!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('validates email format', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('Email');
|
||||
await user.type(emailInput, 'invalid-email');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Sign In' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Please enter a valid email!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('validates password minimum length', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('Email');
|
||||
const passwordInput = screen.getByPlaceholderText('Password');
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, '123');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Sign In' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Password must be at least 8 characters!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('submits form with valid credentials', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockResolvedValue({
|
||||
authenticated: true,
|
||||
user: { id: '1', email: 'test@example.com' },
|
||||
}),
|
||||
});
|
||||
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('Email');
|
||||
const passwordInput = screen.getByPlaceholderText('Password');
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'password123');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Sign In' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(login).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
remember: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading state during login', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LoginPage />, { auth: { isLoading: true } });
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Sign In' });
|
||||
expect(submitButton).toBeDisabled();
|
||||
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles Google login click', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
// Mock window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { href: '' },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const googleButton = screen.getByText('Sign in with Google');
|
||||
await user.click(googleButton);
|
||||
|
||||
expect(window.location.href).toBe('http://localhost:3000/secure/google');
|
||||
});
|
||||
|
||||
it('navigates to signup page', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
const signupLink = screen.getByText('Sign up');
|
||||
await user.click(signupLink);
|
||||
|
||||
// Link navigation is handled by React Router, so we just check the element exists
|
||||
expect(signupLink.closest('a')).toHaveAttribute('href', '/auth/signup');
|
||||
});
|
||||
|
||||
it('navigates to forgot password page', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
const forgotPasswordLink = screen.getByText('Forgot password?');
|
||||
await user.click(forgotPasswordLink);
|
||||
|
||||
expect(forgotPasswordLink.closest('a')).toHaveAttribute('href', '/auth/forgot-password');
|
||||
});
|
||||
|
||||
it('toggles remember me checkbox', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
const rememberMeCheckbox = screen.getByRole('checkbox', { name: 'Remember me' });
|
||||
expect(rememberMeCheckbox).toBeChecked(); // Default is true
|
||||
|
||||
await user.click(rememberMeCheckbox);
|
||||
expect(rememberMeCheckbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('redirects already authenticated users', async () => {
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockResolvedValue({
|
||||
authenticated: true,
|
||||
user: { id: '1', email: 'test@example.com', setup_completed: true },
|
||||
}),
|
||||
});
|
||||
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/worklenz/home');
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects to setup for users with incomplete setup', async () => {
|
||||
const mockCurrentSession = {
|
||||
id: '1',
|
||||
email: 'test@example.com',
|
||||
setup_completed: false,
|
||||
};
|
||||
|
||||
vi.mock('@/hooks/useAuth', () => ({
|
||||
useAuthService: () => ({
|
||||
getCurrentSession: () => mockCurrentSession,
|
||||
}),
|
||||
}));
|
||||
|
||||
renderWithProviders(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/worklenz/setup');
|
||||
});
|
||||
});
|
||||
});
|
||||
359
worklenz-frontend/src/pages/auth/__tests__/SignupPage.test.tsx
Normal file
359
worklenz-frontend/src/pages/auth/__tests__/SignupPage.test.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from 'i18next';
|
||||
|
||||
import SignupPage from '../SignupPage';
|
||||
import { signUp } from '@/features/auth/authSlice';
|
||||
import { authApiService } from '@/api/auth/auth.api.service';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/features/auth/authSlice', () => ({
|
||||
signUp: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/auth/auth.api.service', () => ({
|
||||
authApiService: {
|
||||
signUpCheck: vi.fn(),
|
||||
verifyRecaptchaToken: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useMixpanelTracking', () => ({
|
||||
useMixpanelTracking: () => ({
|
||||
trackMixpanelEvent: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useDoumentTItle', () => ({
|
||||
useDocumentTitle: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/errorLogger', () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/services/alerts/alertService', () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('react-responsive', () => ({
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
|
||||
// Mock navigation
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock dispatch
|
||||
const mockDispatch = vi.fn();
|
||||
vi.mock('@/hooks/useAppDispatch', () => ({
|
||||
useAppDispatch: () => mockDispatch,
|
||||
}));
|
||||
|
||||
// Setup i18n for testing
|
||||
i18n.init({
|
||||
lng: 'en',
|
||||
resources: {
|
||||
en: {
|
||||
'auth/signup': {
|
||||
headerDescription: 'Sign up to get started',
|
||||
nameLabel: 'Full Name',
|
||||
emailLabel: 'Email',
|
||||
passwordLabel: 'Password',
|
||||
nameRequired: 'Please input your name!',
|
||||
emailRequired: 'Please input your email!',
|
||||
passwordRequired: 'Please input your password!',
|
||||
nameMinCharacterRequired: 'Name must be at least 4 characters!',
|
||||
passwordMinCharacterRequired: 'Password must be at least 8 characters!',
|
||||
passwordMaxCharacterRequired: 'Password must be no more than 32 characters!',
|
||||
passwordPatternRequired: 'Password must contain uppercase, lowercase, number and special character!',
|
||||
namePlaceholder: 'Enter your full name',
|
||||
emailPlaceholder: 'Enter your email',
|
||||
strongPasswordPlaceholder: 'Enter a strong password',
|
||||
passwordGuideline: 'Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.',
|
||||
signupButton: 'Sign Up',
|
||||
signInWithGoogleButton: 'Sign up with Google',
|
||||
orText: 'or',
|
||||
alreadyHaveAccountText: 'Already have an account?',
|
||||
loginButton: 'Log in',
|
||||
bySigningUpText: 'By signing up, you agree to our',
|
||||
privacyPolicyLink: 'Privacy Policy',
|
||||
andText: 'and',
|
||||
termsOfUseLink: 'Terms of Use',
|
||||
reCAPTCHAVerificationError: 'reCAPTCHA Verification Failed',
|
||||
reCAPTCHAVerificationErrorMessage: 'Please try again',
|
||||
'passwordChecklist.minLength': 'At least 8 characters',
|
||||
'passwordChecklist.uppercase': 'One uppercase letter',
|
||||
'passwordChecklist.lowercase': 'One lowercase letter',
|
||||
'passwordChecklist.number': 'One number',
|
||||
'passwordChecklist.special': 'One special character',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create test store
|
||||
const createTestStore = () => {
|
||||
return configureStore({
|
||||
reducer: {
|
||||
themeReducer: (state = { mode: 'light' }) => state,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderWithProviders = (component: React.ReactElement) => {
|
||||
const store = createTestStore();
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<BrowserRouter>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
{component}
|
||||
</I18nextProvider>
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('SignupPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
// Mock environment variables
|
||||
vi.stubEnv('VITE_ENABLE_GOOGLE_LOGIN', 'true');
|
||||
vi.stubEnv('VITE_ENABLE_RECAPTCHA', 'false');
|
||||
vi.stubEnv('VITE_API_URL', 'http://localhost:3000');
|
||||
|
||||
// Mock URL search params
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { search: '' },
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders signup form correctly', () => {
|
||||
renderWithProviders(<SignupPage />);
|
||||
|
||||
expect(screen.getByText('Sign up to get started')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Enter your full name')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Enter your email')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Enter a strong password')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Sign Up' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Google signup button when enabled', () => {
|
||||
renderWithProviders(<SignupPage />);
|
||||
|
||||
expect(screen.getByText('Sign up with Google')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('validates required fields', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SignupPage />);
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Sign Up' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Please input your name!')).toBeInTheDocument();
|
||||
expect(screen.getByText('Please input your email!')).toBeInTheDocument();
|
||||
expect(screen.getByText('Please input your password!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('validates name minimum length', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SignupPage />);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('Enter your full name');
|
||||
await user.type(nameInput, 'Jo');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Sign Up' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Name must be at least 4 characters!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('validates email format', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SignupPage />);
|
||||
|
||||
const emailInput = screen.getByPlaceholderText('Enter your email');
|
||||
await user.type(emailInput, 'invalid-email');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Sign Up' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Please input your email!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('validates password requirements', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SignupPage />);
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText('Enter a strong password');
|
||||
await user.type(passwordInput, 'weak');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Sign Up' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Password must be at least 8 characters!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows password checklist when password field is focused', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SignupPage />);
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText('Enter a strong password');
|
||||
await user.click(passwordInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('At least 8 characters')).toBeInTheDocument();
|
||||
expect(screen.getByText('One uppercase letter')).toBeInTheDocument();
|
||||
expect(screen.getByText('One lowercase letter')).toBeInTheDocument();
|
||||
expect(screen.getByText('One number')).toBeInTheDocument();
|
||||
expect(screen.getByText('One special character')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('updates password checklist based on input', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SignupPage />);
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText('Enter a strong password');
|
||||
await user.click(passwordInput);
|
||||
await user.type(passwordInput, 'Password123!');
|
||||
|
||||
await waitFor(() => {
|
||||
// All checklist items should be visible and some should be checked
|
||||
expect(screen.getByText('At least 8 characters')).toBeInTheDocument();
|
||||
expect(screen.getByText('One uppercase letter')).toBeInTheDocument();
|
||||
expect(screen.getByText('One lowercase letter')).toBeInTheDocument();
|
||||
expect(screen.getByText('One number')).toBeInTheDocument();
|
||||
expect(screen.getByText('One special character')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('submits form with valid data', async () => {
|
||||
const user = userEvent.setup();
|
||||
(authApiService.signUpCheck as any).mockResolvedValue({ done: true });
|
||||
|
||||
renderWithProviders(<SignupPage />);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('Enter your full name');
|
||||
const emailInput = screen.getByPlaceholderText('Enter your email');
|
||||
const passwordInput = screen.getByPlaceholderText('Enter a strong password');
|
||||
|
||||
await user.type(nameInput, 'John Doe');
|
||||
await user.type(emailInput, 'john@example.com');
|
||||
await user.type(passwordInput, 'Password123!');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Sign Up' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authApiService.signUpCheck).toHaveBeenCalledWith({
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
password: 'Password123!',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('handles Google signup click', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SignupPage />);
|
||||
|
||||
// Mock window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { href: '' },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const googleButton = screen.getByText('Sign up with Google');
|
||||
await user.click(googleButton);
|
||||
|
||||
expect(window.location.href).toBe('http://localhost:3000/secure/google?');
|
||||
});
|
||||
|
||||
it('pre-fills form from URL parameters', () => {
|
||||
// Mock URLSearchParams
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { search: '?email=test@example.com&name=Test User' },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
renderWithProviders(<SignupPage />);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('Enter your full name') as HTMLInputElement;
|
||||
const emailInput = screen.getByPlaceholderText('Enter your email') as HTMLInputElement;
|
||||
|
||||
expect(nameInput.value).toBe('Test User');
|
||||
expect(emailInput.value).toBe('test@example.com');
|
||||
});
|
||||
|
||||
it('shows terms of use and privacy policy links', () => {
|
||||
renderWithProviders(<SignupPage />);
|
||||
|
||||
expect(screen.getByText('Privacy Policy')).toBeInTheDocument();
|
||||
expect(screen.getByText('Terms of Use')).toBeInTheDocument();
|
||||
|
||||
const privacyLink = screen.getByText('Privacy Policy').closest('a');
|
||||
const termsLink = screen.getByText('Terms of Use').closest('a');
|
||||
|
||||
expect(privacyLink).toHaveAttribute('href', 'https://worklenz.com/privacy/');
|
||||
expect(termsLink).toHaveAttribute('href', 'https://worklenz.com/terms/');
|
||||
});
|
||||
|
||||
it('navigates to login page', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SignupPage />);
|
||||
|
||||
const loginLink = screen.getByText('Log in');
|
||||
await user.click(loginLink);
|
||||
|
||||
expect(loginLink.closest('a')).toHaveAttribute('href', '/auth/login');
|
||||
});
|
||||
|
||||
it('shows loading state during signup', async () => {
|
||||
const user = userEvent.setup();
|
||||
(authApiService.signUpCheck as any).mockResolvedValue({ done: true });
|
||||
|
||||
renderWithProviders(<SignupPage />);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('Enter your full name');
|
||||
const emailInput = screen.getByPlaceholderText('Enter your email');
|
||||
const passwordInput = screen.getByPlaceholderText('Enter a strong password');
|
||||
|
||||
await user.type(nameInput, 'John Doe');
|
||||
await user.type(emailInput, 'john@example.com');
|
||||
await user.type(passwordInput, 'Password123!');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Sign Up' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,337 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { BrowserRouter, MemoryRouter } from 'react-router-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from 'i18next';
|
||||
|
||||
import VerifyResetEmailPage from '../VerifyResetEmailPage';
|
||||
import { updatePassword } from '@/features/auth/authSlice';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/features/auth/authSlice', () => ({
|
||||
updatePassword: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/errorLogger', () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useMixpanelTracking', () => ({
|
||||
useMixpanelTracking: () => ({
|
||||
trackMixpanelEvent: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useDoumentTItle', () => ({
|
||||
useDocumentTitle: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('react-responsive', () => ({
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
|
||||
// Mock navigation
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock dispatch
|
||||
const mockDispatch = vi.fn();
|
||||
vi.mock('@/hooks/useAppDispatch', () => ({
|
||||
useAppDispatch: () => mockDispatch,
|
||||
}));
|
||||
|
||||
// Setup i18n for testing
|
||||
i18n.init({
|
||||
lng: 'en',
|
||||
resources: {
|
||||
en: {
|
||||
'auth/verify-reset-email': {
|
||||
description: 'Enter your new password',
|
||||
passwordRequired: 'Please input your password!',
|
||||
confirmPasswordRequired: 'Please confirm your password!',
|
||||
placeholder: 'Enter new password',
|
||||
confirmPasswordPlaceholder: 'Confirm new password',
|
||||
resetPasswordButton: 'Reset Password',
|
||||
resendResetEmail: 'Resend Reset Email',
|
||||
orText: 'or',
|
||||
passwordMismatch: 'The two passwords do not match!',
|
||||
successTitle: 'Password Reset Successful',
|
||||
successMessage: 'Your password has been reset successfully.',
|
||||
'passwordChecklist.minLength': 'At least 8 characters',
|
||||
'passwordChecklist.uppercase': 'One uppercase letter',
|
||||
'passwordChecklist.lowercase': 'One lowercase letter',
|
||||
'passwordChecklist.number': 'One number',
|
||||
'passwordChecklist.special': 'One special character',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create test store
|
||||
const createTestStore = () => {
|
||||
return configureStore({
|
||||
reducer: {
|
||||
themeReducer: (state = { mode: 'light' }) => state,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderWithProviders = (component: React.ReactElement, route = '/verify-reset/test-hash/test-user') => {
|
||||
const store = createTestStore();
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter initialEntries={[route]}>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
{component}
|
||||
</I18nextProvider>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('VerifyResetEmailPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders password reset form correctly', () => {
|
||||
renderWithProviders(<VerifyResetEmailPage />);
|
||||
|
||||
expect(screen.getByText('Enter your new password')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Enter new password')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Confirm new password')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Reset Password' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Resend Reset Email' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows password checklist immediately', () => {
|
||||
renderWithProviders(<VerifyResetEmailPage />);
|
||||
|
||||
expect(screen.getByText('At least 8 characters')).toBeInTheDocument();
|
||||
expect(screen.getByText('One uppercase letter')).toBeInTheDocument();
|
||||
expect(screen.getByText('One lowercase letter')).toBeInTheDocument();
|
||||
expect(screen.getByText('One number')).toBeInTheDocument();
|
||||
expect(screen.getByText('One special character')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('validates required password fields', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<VerifyResetEmailPage />);
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Please input your password!')).toBeInTheDocument();
|
||||
expect(screen.getByText('Please confirm your password!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('validates password confirmation match', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<VerifyResetEmailPage />);
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText('Enter new password');
|
||||
const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
|
||||
|
||||
await user.type(passwordInput, 'Password123!');
|
||||
await user.type(confirmPasswordInput, 'DifferentPassword123!');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('The two passwords do not match!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('updates password checklist based on input', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<VerifyResetEmailPage />);
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText('Enter new password');
|
||||
await user.type(passwordInput, 'Password123!');
|
||||
|
||||
// All checklist items should be visible (this component shows them by default)
|
||||
expect(screen.getByText('At least 8 characters')).toBeInTheDocument();
|
||||
expect(screen.getByText('One uppercase letter')).toBeInTheDocument();
|
||||
expect(screen.getByText('One lowercase letter')).toBeInTheDocument();
|
||||
expect(screen.getByText('One number')).toBeInTheDocument();
|
||||
expect(screen.getByText('One special character')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits form with valid matching passwords', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockResolvedValue({ done: true }),
|
||||
});
|
||||
|
||||
renderWithProviders(<VerifyResetEmailPage />);
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText('Enter new password');
|
||||
const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
|
||||
|
||||
await user.type(passwordInput, 'Password123!');
|
||||
await user.type(confirmPasswordInput, 'Password123!');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updatePassword).toHaveBeenCalledWith({
|
||||
hash: 'test-hash',
|
||||
user: 'test-user',
|
||||
password: 'Password123!',
|
||||
confirmPassword: 'Password123!',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('shows success message after successful password reset', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockResolvedValue({ done: true }),
|
||||
});
|
||||
|
||||
renderWithProviders(<VerifyResetEmailPage />);
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText('Enter new password');
|
||||
const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
|
||||
|
||||
await user.type(passwordInput, 'Password123!');
|
||||
await user.type(confirmPasswordInput, 'Password123!');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Password Reset Successful')).toBeInTheDocument();
|
||||
expect(screen.getByText('Your password has been reset successfully.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/auth/login');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading state during submission', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockImplementation(() => new Promise(() => {})), // Never resolves
|
||||
});
|
||||
|
||||
renderWithProviders(<VerifyResetEmailPage />);
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText('Enter new password');
|
||||
const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
|
||||
|
||||
await user.type(passwordInput, 'Password123!');
|
||||
await user.type(confirmPasswordInput, 'Password123!');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: /loading/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles submission errors gracefully', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockRejectedValue(new Error('Reset failed')),
|
||||
});
|
||||
|
||||
renderWithProviders(<VerifyResetEmailPage />);
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText('Enter new password');
|
||||
const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
|
||||
|
||||
await user.type(passwordInput, 'Password123!');
|
||||
await user.type(confirmPasswordInput, 'Password123!');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should not show success message
|
||||
expect(screen.queryByText('Password Reset Successful')).not.toBeInTheDocument();
|
||||
// Should still show the form
|
||||
expect(screen.getByPlaceholderText('Enter new password')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates to forgot password page when resend button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<VerifyResetEmailPage />);
|
||||
|
||||
const resendButton = screen.getByRole('button', { name: 'Resend Reset Email' });
|
||||
await user.click(resendButton);
|
||||
|
||||
expect(resendButton.closest('a')).toHaveAttribute('href', '/auth/forgot-password');
|
||||
});
|
||||
|
||||
it('prevents pasting in confirm password field', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<VerifyResetEmailPage />);
|
||||
|
||||
const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
|
||||
|
||||
await user.click(confirmPasswordInput);
|
||||
|
||||
// Try to paste - should be prevented
|
||||
const pasteEvent = new ClipboardEvent('paste', {
|
||||
clipboardData: new DataTransfer(),
|
||||
});
|
||||
|
||||
fireEvent(confirmPasswordInput, pasteEvent);
|
||||
|
||||
// The preventDefault should be called (we can't easily test this directly,
|
||||
// but we can ensure the input behavior remains consistent)
|
||||
expect(confirmPasswordInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not submit with empty passwords after trimming', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockDispatch.mockReturnValue({
|
||||
unwrap: vi.fn().mockResolvedValue({ done: true }),
|
||||
});
|
||||
|
||||
renderWithProviders(<VerifyResetEmailPage />);
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText('Enter new password');
|
||||
const confirmPasswordInput = screen.getByPlaceholderText('Confirm new password');
|
||||
|
||||
await user.type(passwordInput, ' '); // Only whitespace
|
||||
await user.type(confirmPasswordInput, ' '); // Only whitespace
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Reset Password' });
|
||||
await user.click(submitButton);
|
||||
|
||||
// Should not call updatePassword with empty strings
|
||||
expect(updatePassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('extracts hash and user from URL params', () => {
|
||||
renderWithProviders(<VerifyResetEmailPage />, '/verify-reset/my-hash/my-user');
|
||||
|
||||
// Component should render normally, indicating it received the params
|
||||
expect(screen.getByText('Enter your new password')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useEffect, memo, useMemo, useCallback } from 'react';
|
||||
import React, { useEffect, memo, useMemo, useCallback } from 'react';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
import Col from 'antd/es/col';
|
||||
import Flex from 'antd/es/flex';
|
||||
import Row from 'antd/es/row';
|
||||
import Card from 'antd/es/card';
|
||||
|
||||
import GreetingWithTime from './greeting-with-time';
|
||||
import TasksList from '@/pages/home/task-list/tasks-list';
|
||||
import TodoList from '@/pages/home/todo-list/todo-list';
|
||||
import ProjectDrawer from '@/components/projects/project-drawer/project-drawer';
|
||||
import CreateProjectButton from '@/components/projects/project-create-button/project-create-button';
|
||||
import RecentAndFavouriteProjectList from '@/pages/home/recent-and-favourite-project-list/recent-and-favourite-project-list';
|
||||
import TodoList from './todo-list/todo-list';
|
||||
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
@@ -19,7 +21,7 @@ import { fetchProjectCategories } from '@/features/projects/lookups/projectCateg
|
||||
import { fetchProjectHealth } from '@/features/projects/lookups/projectHealth/projectHealthSlice';
|
||||
import { fetchProjects } from '@/features/home-page/home-page.slice';
|
||||
import { createPortal } from 'react-dom';
|
||||
import React, { Suspense } from 'react';
|
||||
import UserActivityFeed from './user-activity-feed/user-activity-feed';
|
||||
|
||||
const DESKTOP_MIN_WIDTH = 1024;
|
||||
const TASK_LIST_MIN_WIDTH = 500;
|
||||
@@ -27,7 +29,9 @@ const SIDEBAR_MAX_WIDTH = 400;
|
||||
|
||||
// Lazy load heavy components
|
||||
const TaskDrawer = React.lazy(() => import('@/components/task-drawer/task-drawer'));
|
||||
const SurveyPromptModal = React.lazy(() => import('@/components/survey/SurveyPromptModal').then(m => ({ default: m.SurveyPromptModal })));
|
||||
const SurveyPromptModal = React.lazy(() =>
|
||||
import('@/components/survey/SurveyPromptModal').then(m => ({ default: m.SurveyPromptModal }))
|
||||
);
|
||||
|
||||
const HomePage = memo(() => {
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -98,56 +102,33 @@ const HomePage = memo(() => {
|
||||
);
|
||||
}, [isDesktop, isOwnerOrAdmin]);
|
||||
|
||||
const MainContent = useMemo(() => {
|
||||
return isDesktop ? (
|
||||
<Flex gap={24} align="flex-start" className="w-full mt-12">
|
||||
<Flex style={desktopFlexStyle}>
|
||||
<TasksList />
|
||||
</Flex>
|
||||
<Flex vertical gap={24} style={sidebarFlexStyle}>
|
||||
<TodoList />
|
||||
<RecentAndFavouriteProjectList />
|
||||
</Flex>
|
||||
</Flex>
|
||||
) : (
|
||||
<Flex vertical gap={24} className="mt-6">
|
||||
<TasksList />
|
||||
<TodoList />
|
||||
<RecentAndFavouriteProjectList />
|
||||
</Flex>
|
||||
);
|
||||
}, [isDesktop, desktopFlexStyle, sidebarFlexStyle]);
|
||||
|
||||
return (
|
||||
<div className="my-24 min-h-[90vh]">
|
||||
<div className="my-8 min-h-[90vh]">
|
||||
<Col className="flex flex-col gap-6">
|
||||
<GreetingWithTime />
|
||||
{CreateProjectButtonComponent}
|
||||
</Col>
|
||||
|
||||
{MainContent}
|
||||
<Row gutter={[24, 24]} className="mt-12">
|
||||
<Col xs={24} lg={16}>
|
||||
<Flex vertical gap={24}>
|
||||
<TasksList />
|
||||
</Flex>
|
||||
</Col>
|
||||
|
||||
{/* Use Suspense for lazy-loaded components with error boundary */}
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
{createPortal(
|
||||
<React.Suspense fallback={null}>
|
||||
<TaskDrawer />
|
||||
</React.Suspense>,
|
||||
document.body,
|
||||
'home-task-drawer'
|
||||
)}
|
||||
</Suspense>
|
||||
<Col xs={24} lg={8}>
|
||||
<Flex vertical gap={24}>
|
||||
<TodoList />
|
||||
|
||||
{createPortal(
|
||||
<ProjectDrawer onClose={handleProjectDrawerClose} />,
|
||||
document.body,
|
||||
'project-drawer'
|
||||
)}
|
||||
<UserActivityFeed />
|
||||
|
||||
{/* Survey Modal - only shown to users who haven't completed it */}
|
||||
<Suspense fallback={null}>
|
||||
<SurveyPromptModal />
|
||||
</Suspense>
|
||||
<RecentAndFavouriteProjectList />
|
||||
</Flex>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{createPortal(<TaskDrawer />, document.body, 'home-task-drawer')}
|
||||
{createPortal(<ProjectDrawer onClose={() => {}} />, document.body, 'project-drawer')}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { CheckCircleOutlined, SyncOutlined } from '@/shared/antd-imports';
|
||||
import { CheckCircleOutlined, SyncOutlined, DownOutlined, RightOutlined } from '@/shared/antd-imports';
|
||||
import { useRef, useState } from 'react';
|
||||
import Form from 'antd/es/form';
|
||||
import Input, { InputRef } from 'antd/es/input';
|
||||
import Flex from 'antd/es/flex';
|
||||
import Card from 'antd/es/card';
|
||||
import Collapse from 'antd/es/collapse';
|
||||
import ConfigProvider from 'antd/es/config-provider';
|
||||
import Table, { TableProps } from 'antd/es/table';
|
||||
import Tooltip from 'antd/es/tooltip';
|
||||
@@ -23,6 +24,7 @@ import { useCreatePersonalTaskMutation } from '@/api/home-page/home-page.api.ser
|
||||
|
||||
const TodoList = () => {
|
||||
const [isAlertShowing, setIsAlertShowing] = useState(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
const [form] = Form.useForm();
|
||||
const { t } = useTranslation('home');
|
||||
|
||||
@@ -97,73 +99,109 @@ const TodoList = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<Typography.Title level={5} style={{ marginBlockEnd: 0 }}>
|
||||
{t('home:todoList.title')} ({data?.body.length})
|
||||
</Typography.Title>
|
||||
}
|
||||
extra={
|
||||
<Tooltip title={t('home:todoList.refreshTasks')}>
|
||||
<Button shape="circle" icon={<SyncOutlined spin={isFetching} />} onClick={refetch} />
|
||||
</Tooltip>
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<div>
|
||||
<Form form={form} onFinish={handleTodoSubmit}>
|
||||
<Form.Item name="name">
|
||||
<Flex vertical>
|
||||
<Input
|
||||
ref={todoInputRef}
|
||||
placeholder={t('home:todoList.addTask')}
|
||||
onChange={e => {
|
||||
const inputValue = e.currentTarget.value;
|
||||
<Card style={{ width: '100%' }} bodyStyle={{ padding: 0 }}>
|
||||
<style>{`
|
||||
.todo-collapse .ant-collapse-header {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
padding: 12px 16px !important;
|
||||
}
|
||||
.todo-collapse .ant-collapse-expand-icon {
|
||||
margin-right: 8px !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
`}</style>
|
||||
<Collapse
|
||||
defaultActiveKey={[]}
|
||||
ghost
|
||||
size="small"
|
||||
className="todo-collapse"
|
||||
expandIcon={({ isActive }) =>
|
||||
isActive ? <DownOutlined /> : <RightOutlined />
|
||||
}
|
||||
onChange={(keys) => {
|
||||
setIsCollapsed(keys.length === 0);
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: (
|
||||
<Flex style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||
{t('home:todoList.title')} ({data?.body.length})
|
||||
</Typography.Title>
|
||||
<Tooltip title={t('home:todoList.refreshTasks')}>
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<SyncOutlined spin={isFetching} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
),
|
||||
children: (
|
||||
<div style={{ padding: '0 16px 16px 16px' }}>
|
||||
<Form form={form} onFinish={handleTodoSubmit}>
|
||||
<Form.Item name="name">
|
||||
<Flex vertical>
|
||||
<Input
|
||||
ref={todoInputRef}
|
||||
placeholder={t('home:todoList.addTask')}
|
||||
onChange={e => {
|
||||
const inputValue = e.currentTarget.value;
|
||||
|
||||
if (inputValue.length >= 1) setIsAlertShowing(true);
|
||||
else if (inputValue === '') setIsAlertShowing(false);
|
||||
}}
|
||||
/>
|
||||
{isAlertShowing && (
|
||||
<Alert
|
||||
message={
|
||||
<Typography.Text style={{ fontSize: 11 }}>
|
||||
{t('home:todoList.pressEnter')} <strong>Enter</strong>{' '}
|
||||
{t('home:todoList.toCreate')}
|
||||
</Typography.Text>
|
||||
}
|
||||
type="info"
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 2,
|
||||
padding: '0 6px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
if (inputValue.length >= 1) setIsAlertShowing(true);
|
||||
else if (inputValue === '') setIsAlertShowing(false);
|
||||
}}
|
||||
/>
|
||||
{isAlertShowing && (
|
||||
<Alert
|
||||
message={
|
||||
<Typography.Text style={{ fontSize: 11 }}>
|
||||
{t('home:todoList.pressEnter')} <strong>Enter</strong>{' '}
|
||||
{t('home:todoList.toCreate')}
|
||||
</Typography.Text>
|
||||
}
|
||||
type="info"
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 2,
|
||||
padding: '0 6px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div style={{ maxHeight: 420, overflow: 'auto' }}>
|
||||
{data?.body.length === 0 ? (
|
||||
<EmptyListPlaceholder
|
||||
imageSrc="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
|
||||
text={t('home:todoList.noTasks')}
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
className="custom-two-colors-row-table"
|
||||
rowKey={record => record.id || ''}
|
||||
dataSource={data?.body}
|
||||
columns={columns}
|
||||
showHeader={false}
|
||||
pagination={false}
|
||||
size="small"
|
||||
loading={isFetching}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ maxHeight: 300, overflow: 'auto' }}>
|
||||
{data?.body.length === 0 ? (
|
||||
<EmptyListPlaceholder
|
||||
imageSrc="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
|
||||
text={t('home:todoList.noTasks')}
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
className="custom-two-colors-row-table"
|
||||
rowKey={record => record.id || ''}
|
||||
dataSource={data?.body}
|
||||
columns={columns}
|
||||
showHeader={false}
|
||||
pagination={false}
|
||||
size="small"
|
||||
loading={isFetching}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { Table, Typography, Tooltip, theme } from 'antd';
|
||||
import { FileTextOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { fromNow, formatDate } from '@/utils/dateUtils';
|
||||
import {
|
||||
setSelectedTaskId,
|
||||
setShowTaskDrawer,
|
||||
fetchTask,
|
||||
} from '@/features/task-drawer/task-drawer.slice';
|
||||
import { IUserRecentTask } from '@/types/home/user-activity.types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface TaskActivityListProps {
|
||||
tasks: IUserRecentTask[];
|
||||
}
|
||||
|
||||
const TaskActivityList: React.FC<TaskActivityListProps> = React.memo(({ tasks }) => {
|
||||
const { t } = useTranslation('home');
|
||||
const dispatch = useAppDispatch();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const handleTaskClick = useCallback(
|
||||
(taskId: string, projectId: string) => {
|
||||
dispatch(setSelectedTaskId(taskId));
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
dispatch(fetchTask({ taskId, projectId }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'task',
|
||||
render: (record: IUserRecentTask) => (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12,
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
padding: '8px 0'
|
||||
}}
|
||||
onClick={() => handleTaskClick(record.task_id, record.project_id)}
|
||||
aria-label={`${t('tasks.recentTaskAriaLabel')} ${record.task_name}`}
|
||||
>
|
||||
<div style={{
|
||||
marginTop: 2,
|
||||
color: token.colorPrimary,
|
||||
fontSize: 16
|
||||
}}>
|
||||
<FileTextOutlined />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<Text strong style={{ fontSize: 14 }}>
|
||||
{record.task_name}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{record.project_name}
|
||||
</Text>
|
||||
<Tooltip
|
||||
title={formatDate(record.last_activity_at, 'MMMM Do YYYY, h:mm:ss a')}
|
||||
placement="topRight"
|
||||
>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{fromNow(record.last_activity_at)}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
className="custom-two-colors-row-table"
|
||||
dataSource={tasks}
|
||||
columns={columns}
|
||||
rowKey="task_id"
|
||||
showHeader={false}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default TaskActivityList;
|
||||
@@ -0,0 +1,166 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { Table, Typography, Tag, Tooltip, Space, theme } from '@/shared/antd-imports';
|
||||
import { ClockCircleOutlined } from '@/shared/antd-imports';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { fromNow, formatDate } from '@/utils/dateUtils';
|
||||
import {
|
||||
setSelectedTaskId,
|
||||
setShowTaskDrawer,
|
||||
fetchTask,
|
||||
} from '@/features/task-drawer/task-drawer.slice';
|
||||
import { IUserTimeLoggedTask } from '@/types/home/user-activity.types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface TimeLoggedTaskListProps {
|
||||
tasks: IUserTimeLoggedTask[];
|
||||
}
|
||||
|
||||
const TimeLoggedTaskList: React.FC<TimeLoggedTaskListProps> = React.memo(({ tasks }) => {
|
||||
const { t } = useTranslation('home');
|
||||
const dispatch = useAppDispatch();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const handleTaskClick = useCallback(
|
||||
(taskId: string, projectId: string) => {
|
||||
dispatch(setSelectedTaskId(taskId));
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
dispatch(fetchTask({ taskId, projectId }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'task',
|
||||
render: (record: IUserTimeLoggedTask) => (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
padding: '8px 0'
|
||||
}}
|
||||
onClick={() => handleTaskClick(record.task_id, record.project_id)}
|
||||
aria-label={`${t('tasks.timeLoggedTaskAriaLabel')} ${record.task_name}`}
|
||||
>
|
||||
{/* Clock Icon */}
|
||||
<div style={{
|
||||
color: token.colorSuccess,
|
||||
fontSize: 14,
|
||||
flexShrink: 0
|
||||
}}>
|
||||
<ClockCircleOutlined />
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{/* Task Name */}
|
||||
<div style={{ marginBottom: 2 }}>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
fontSize: 13,
|
||||
lineHeight: 1.4,
|
||||
color: token.colorText
|
||||
}}
|
||||
ellipsis={{ tooltip: record.task_name }}
|
||||
>
|
||||
{record.task_name}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Project Name */}
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
lineHeight: 1.2,
|
||||
display: 'block',
|
||||
marginBottom: 4
|
||||
}}
|
||||
ellipsis={{ tooltip: record.project_name }}
|
||||
>
|
||||
{record.project_name}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Time and Status */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-end',
|
||||
gap: 3,
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{/* Time Logged */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Tag
|
||||
color="success"
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 11,
|
||||
padding: '0 6px',
|
||||
height: 18,
|
||||
lineHeight: '16px',
|
||||
borderRadius: 3
|
||||
}}
|
||||
>
|
||||
{record.total_time_logged_string}
|
||||
</Tag>
|
||||
{record.logged_by_timer && (
|
||||
<Tag
|
||||
color="processing"
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 10,
|
||||
padding: '0 4px',
|
||||
height: 16,
|
||||
lineHeight: '14px',
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
{t('tasks.timerTag')}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Time Ago */}
|
||||
<Tooltip
|
||||
title={formatDate(record.last_logged_at, 'MMMM Do YYYY, h:mm:ss a')}
|
||||
placement="topRight"
|
||||
>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
lineHeight: 1,
|
||||
color: token.colorTextTertiary
|
||||
}}
|
||||
>
|
||||
{fromNow(record.last_logged_at)}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
className="custom-two-colors-row-table"
|
||||
dataSource={tasks}
|
||||
columns={columns}
|
||||
rowKey="task_id"
|
||||
showHeader={false}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default TimeLoggedTaskList;
|
||||
@@ -0,0 +1,19 @@
|
||||
.activity-feed-item:hover {
|
||||
background-color: var(--activity-hover, #fafafa);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.activity-feed-item:active {
|
||||
transform: translateY(0);
|
||||
background-color: var(--activity-active, #f0f0f0);
|
||||
}
|
||||
|
||||
/* Dark theme support */
|
||||
[data-theme="dark"] .activity-feed-item:hover {
|
||||
background-color: var(--activity-hover, #262626);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .activity-feed-item:active {
|
||||
background-color: var(--activity-active, #1f1f1f);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import React, { useMemo, useCallback, useEffect } from 'react';
|
||||
import { Card, Segmented, Skeleton, Empty, Typography, Alert, Button, Tooltip } from '@/shared/antd-imports';
|
||||
import { ClockCircleOutlined, UnorderedListOutlined, SyncOutlined } from '@/shared/antd-imports';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { ActivityFeedType } from '@/types/home/user-activity.types';
|
||||
import { setActiveTab } from '@/features/home-page/user-activity.slice';
|
||||
import {
|
||||
useGetUserRecentTasksQuery,
|
||||
useGetUserTimeLoggedTasksQuery,
|
||||
} from '@/api/home-page/user-activity.api.service';
|
||||
import TaskActivityList from './task-activity-list';
|
||||
import TimeLoggedTaskList from './time-logged-task-list';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const UserActivityFeed: React.FC = () => {
|
||||
const { t } = useTranslation('home');
|
||||
const dispatch = useAppDispatch();
|
||||
const { activeTab } = useAppSelector(state => state.userActivityReducer);
|
||||
|
||||
const {
|
||||
data: recentTasksData,
|
||||
isLoading: loadingRecentTasks,
|
||||
error: recentTasksError,
|
||||
refetch: refetchRecentTasks,
|
||||
} = useGetUserRecentTasksQuery(
|
||||
{ limit: 10 },
|
||||
{
|
||||
skip: false,
|
||||
refetchOnMountOrArgChange: true
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
data: timeLoggedTasksData,
|
||||
isLoading: loadingTimeLoggedTasks,
|
||||
error: timeLoggedTasksError,
|
||||
refetch: refetchTimeLoggedTasks,
|
||||
} = useGetUserTimeLoggedTasksQuery(
|
||||
{ limit: 10 },
|
||||
{
|
||||
skip: false,
|
||||
refetchOnMountOrArgChange: true
|
||||
}
|
||||
);
|
||||
|
||||
const recentTasks = useMemo(() => {
|
||||
if (!recentTasksData) return [];
|
||||
// Handle both array and object responses from the API
|
||||
if (Array.isArray(recentTasksData)) {
|
||||
return recentTasksData;
|
||||
}
|
||||
// If it's an object with a data property (common API pattern)
|
||||
if (recentTasksData && typeof recentTasksData === 'object' && 'data' in recentTasksData) {
|
||||
const data = (recentTasksData as any).data;
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
// If it's a different object structure, try to extract tasks
|
||||
if (recentTasksData && typeof recentTasksData === 'object') {
|
||||
const possibleArrays = Object.values(recentTasksData as any).filter(Array.isArray);
|
||||
return possibleArrays.length > 0 ? possibleArrays[0] : [];
|
||||
}
|
||||
return [];
|
||||
}, [recentTasksData]);
|
||||
|
||||
const timeLoggedTasks = useMemo(() => {
|
||||
if (!timeLoggedTasksData) return [];
|
||||
// Handle both array and object responses from the API
|
||||
if (Array.isArray(timeLoggedTasksData)) {
|
||||
return timeLoggedTasksData;
|
||||
}
|
||||
// If it's an object with a data property (common API pattern)
|
||||
if (timeLoggedTasksData && typeof timeLoggedTasksData === 'object' && 'data' in timeLoggedTasksData) {
|
||||
const data = (timeLoggedTasksData as any).data;
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
// If it's a different object structure, try to extract tasks
|
||||
if (timeLoggedTasksData && typeof timeLoggedTasksData === 'object') {
|
||||
const possibleArrays = Object.values(timeLoggedTasksData as any).filter(Array.isArray);
|
||||
return possibleArrays.length > 0 ? possibleArrays[0] : [];
|
||||
}
|
||||
return [];
|
||||
}, [timeLoggedTasksData]);
|
||||
|
||||
const segmentOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: ActivityFeedType.TIME_LOGGED_TASKS,
|
||||
label: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<ClockCircleOutlined style={{ fontSize: 14 }} />
|
||||
{t('tasks.timeLoggedSegment')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: ActivityFeedType.RECENT_TASKS,
|
||||
label: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<UnorderedListOutlined style={{ fontSize: 14 }} />
|
||||
{t('tasks.recentTasksSegment')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(value: ActivityFeedType) => {
|
||||
dispatch(setActiveTab(value));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// Refetch data when the active tab changes
|
||||
useEffect(() => {
|
||||
if (activeTab === ActivityFeedType.RECENT_TASKS) {
|
||||
refetchRecentTasks();
|
||||
} else if (activeTab === ActivityFeedType.TIME_LOGGED_TASKS) {
|
||||
refetchTimeLoggedTasks();
|
||||
}
|
||||
}, [activeTab, refetchRecentTasks, refetchTimeLoggedTasks]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
if (activeTab === ActivityFeedType.TIME_LOGGED_TASKS) {
|
||||
refetchTimeLoggedTasks();
|
||||
} else {
|
||||
refetchRecentTasks();
|
||||
}
|
||||
}, [activeTab, refetchRecentTasks, refetchTimeLoggedTasks]);
|
||||
|
||||
const isLoading = activeTab === ActivityFeedType.TIME_LOGGED_TASKS ? loadingTimeLoggedTasks : loadingRecentTasks;
|
||||
const currentCount = activeTab === ActivityFeedType.TIME_LOGGED_TASKS ? timeLoggedTasks.length : recentTasks.length;
|
||||
|
||||
const renderContent = () => {
|
||||
if (activeTab === ActivityFeedType.TIME_LOGGED_TASKS) {
|
||||
if (loadingTimeLoggedTasks) {
|
||||
return <Skeleton active />;
|
||||
}
|
||||
if (timeLoggedTasksError) {
|
||||
return <Alert message={t('tasks.errorLoadingTimeLoggedTasks')} type="error" showIcon />;
|
||||
}
|
||||
if (timeLoggedTasks.length === 0) {
|
||||
return <Empty description={t('tasks.noTimeLoggedTasks')} />;
|
||||
}
|
||||
return (
|
||||
<div style={{ maxHeight: 450, overflow: 'auto' }}>
|
||||
<TimeLoggedTaskList tasks={timeLoggedTasks} />
|
||||
</div>
|
||||
);
|
||||
} else if (activeTab === ActivityFeedType.RECENT_TASKS) {
|
||||
if (loadingRecentTasks) {
|
||||
return <Skeleton active />;
|
||||
}
|
||||
if (recentTasksError) {
|
||||
return <Alert message={t('tasks.errorLoadingRecentTasks')} type="error" showIcon />;
|
||||
}
|
||||
if (recentTasks.length === 0) {
|
||||
return <Empty description={t('tasks.noRecentTasks')} />;
|
||||
}
|
||||
return (
|
||||
<div style={{ maxHeight: 450, overflow: 'auto' }}>
|
||||
<TaskActivityList tasks={recentTasks} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<Typography.Title level={5} style={{ marginBlockEnd: 0 }}>
|
||||
{t('tasks.recentActivity')} ({currentCount})
|
||||
</Typography.Title>
|
||||
}
|
||||
extra={
|
||||
<Tooltip title={t('tasks.refresh')}>
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<SyncOutlined spin={isLoading} />}
|
||||
onClick={handleRefresh}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Segmented
|
||||
options={segmentOptions}
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
style={{ marginBottom: 16, width: '100%' }}
|
||||
block
|
||||
/>
|
||||
{renderContent()}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(UserActivityFeed);
|
||||
@@ -802,7 +802,7 @@ const ProjectList: React.FC = () => {
|
||||
}, [loadingProjects, isFetchingProjects, viewMode, groupedProjects.loading, isLoading]);
|
||||
|
||||
return (
|
||||
<div style={{ marginBlock: 65, minHeight: '90vh' }}>
|
||||
<div style={{ minHeight: '90vh' }}>
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title={`${projectCount} ${t('projects')}`}
|
||||
|
||||
@@ -360,14 +360,14 @@ const ProjectView = React.memo(() => {
|
||||
// Show loading state while project is being fetched or translations are loading
|
||||
if (projectLoading || !isInitialized || !translationsReady) {
|
||||
return (
|
||||
<div style={{ marginBlockStart: 70, marginBlockEnd: 12, minHeight: '80vh' }}>
|
||||
<div style={{ marginBlockEnd: 12, minHeight: '80vh' }}>
|
||||
<SuspenseFallback />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginBlockStart: 70, marginBlockEnd: 12, minHeight: '80vh' }}>
|
||||
<div style={{ marginBlockEnd: 12, minHeight: '80vh' }}>
|
||||
<ProjectViewHeader />
|
||||
|
||||
<Tabs
|
||||
|
||||
@@ -28,9 +28,8 @@ const ProjectsReports = () => {
|
||||
|
||||
// Memoize the Excel export handler to prevent recreation on every render
|
||||
const handleExcelExport = useCallback(() => {
|
||||
if (currentSession?.team_name) {
|
||||
reportingExportApiService.exportProjects(currentSession.team_name);
|
||||
}
|
||||
const teamName = currentSession?.team_name || 'Team';
|
||||
reportingExportApiService.exportProjects(teamName);
|
||||
}, [currentSession?.team_name]);
|
||||
|
||||
// Memoize the archived checkbox handler to prevent recreation on every render
|
||||
|
||||
@@ -54,7 +54,7 @@ const Schedule: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginBlockStart: 65, minHeight: '90vh' }}>
|
||||
<div style={{ minHeight: '90vh' }}>
|
||||
<Flex align="center" justify="space-between">
|
||||
<Flex
|
||||
gap={16}
|
||||
|
||||
@@ -182,7 +182,8 @@ export {
|
||||
InfoCircleOutlined,
|
||||
WarningTwoTone,
|
||||
ShareAltOutlined,
|
||||
CloudDownloadOutlined
|
||||
CloudDownloadOutlined,
|
||||
CopyOutlined
|
||||
} from '@ant-design/icons';
|
||||
|
||||
// Re-export all components with React
|
||||
|
||||
83
worklenz-frontend/src/test/setup.ts
Normal file
83
worklenz-frontend/src/test/setup.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Mock environment variables
|
||||
Object.defineProperty(process, 'env', {
|
||||
value: {
|
||||
NODE_ENV: 'test',
|
||||
VITE_API_URL: 'http://localhost:3000',
|
||||
VITE_ENABLE_GOOGLE_LOGIN: 'true',
|
||||
VITE_ENABLE_RECAPTCHA: 'false',
|
||||
VITE_RECAPTCHA_SITE_KEY: 'test-site-key',
|
||||
},
|
||||
});
|
||||
|
||||
// Mock window.matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(), // deprecated
|
||||
removeListener: vi.fn(), // deprecated
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Mock IntersectionObserver
|
||||
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock ResizeObserver
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock window.getSelection
|
||||
Object.defineProperty(window, 'getSelection', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(() => ({
|
||||
rangeCount: 0,
|
||||
getRangeAt: vi.fn(),
|
||||
removeAllRanges: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
vi.stubGlobal('localStorage', localStorageMock);
|
||||
|
||||
// Mock sessionStorage
|
||||
const sessionStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
vi.stubGlobal('sessionStorage', sessionStorageMock);
|
||||
|
||||
// Suppress console warnings during tests
|
||||
const originalConsoleWarn = console.warn;
|
||||
console.warn = (...args) => {
|
||||
// Suppress specific warnings that are not relevant for tests
|
||||
if (
|
||||
args[0]?.includes?.('React Router Future Flag Warning') ||
|
||||
args[0]?.includes?.('validateDOMNesting')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
originalConsoleWarn(...args);
|
||||
};
|
||||
32
worklenz-frontend/src/types/home/user-activity.types.ts
Normal file
32
worklenz-frontend/src/types/home/user-activity.types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export interface IUserRecentTask {
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
project_id: string;
|
||||
project_name: string;
|
||||
last_activity_at: string;
|
||||
activity_count: number;
|
||||
project_color?: string;
|
||||
task_status?: string;
|
||||
status_color?: string;
|
||||
}
|
||||
|
||||
export interface IUserTimeLoggedTask {
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
project_id: string;
|
||||
project_name: string;
|
||||
total_time_logged: number;
|
||||
total_time_logged_string: string;
|
||||
last_logged_at: string;
|
||||
logged_by_timer: boolean;
|
||||
project_color?: string;
|
||||
task_status?: string;
|
||||
status_color?: string;
|
||||
log_entries_count?: number;
|
||||
estimated_time?: number;
|
||||
}
|
||||
|
||||
export enum ActivityFeedType {
|
||||
RECENT_TASKS = 'recent-tasks',
|
||||
TIME_LOGGED_TASKS = 'time-logged-tasks'
|
||||
}
|
||||
@@ -1,32 +1,59 @@
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||
import 'dayjs/locale/de';
|
||||
import 'dayjs/locale/es';
|
||||
import 'dayjs/locale/pt';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { getLanguageFromLocalStorage } from './language-utils';
|
||||
|
||||
// Initialize the relativeTime plugin
|
||||
// Initialize plugins
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(localizedFormat);
|
||||
|
||||
/**
|
||||
* Formats a date to a relative time string (e.g., "2 hours ago", "a day ago")
|
||||
* This mimics the Angular fromNow pipe functionality
|
||||
*
|
||||
* @param date - The date to format (string, Date, or dayjs object)
|
||||
* @returns A string representing the relative time
|
||||
*/
|
||||
export const fromNow = (date: string | Date | dayjs.Dayjs): string => {
|
||||
if (!date) return '';
|
||||
return dayjs(date).fromNow();
|
||||
// Map application languages to dayjs locales
|
||||
const getLocaleFromLanguage = (language: string): string => {
|
||||
const localeMap: Record<string, string> = {
|
||||
'en': 'en',
|
||||
'de': 'de',
|
||||
'es': 'es',
|
||||
'pt': 'pt',
|
||||
'alb': 'en', // Albanian not supported by dayjs, fallback to English
|
||||
'zh': 'zh-cn'
|
||||
};
|
||||
return localeMap[language] || 'en';
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a date to a specific format
|
||||
* Formats a date to a relative time string (e.g., "2 hours ago", "a day ago")
|
||||
* This mimics the Angular fromNow pipe functionality with locale support
|
||||
*
|
||||
* @param date - The date to format (string, Date, or dayjs object)
|
||||
* @param language - Optional language override (defaults to stored language)
|
||||
* @returns A string representing the relative time
|
||||
*/
|
||||
export const fromNow = (date: string | Date | dayjs.Dayjs, language?: string): string => {
|
||||
if (!date) return '';
|
||||
const currentLanguage = language || getLanguageFromLocalStorage();
|
||||
const locale = getLocaleFromLanguage(currentLanguage);
|
||||
return dayjs(date).locale(locale).fromNow();
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a date to a specific format with locale support
|
||||
*
|
||||
* @param date - The date to format (string, Date, or dayjs object)
|
||||
* @param format - The format string (default: 'YYYY-MM-DD')
|
||||
* @param language - Optional language override (defaults to stored language)
|
||||
* @returns A formatted date string
|
||||
*/
|
||||
export const formatDate = (
|
||||
date: string | Date | dayjs.Dayjs,
|
||||
format: string = 'YYYY-MM-DD'
|
||||
format: string = 'YYYY-MM-DD',
|
||||
language?: string
|
||||
): string => {
|
||||
if (!date) return '';
|
||||
return dayjs(date).format(format);
|
||||
const currentLanguage = language || getLanguageFromLocalStorage();
|
||||
const locale = getLocaleFromLanguage(currentLanguage);
|
||||
return dayjs(date).locale(locale).format(format);
|
||||
};
|
||||
|
||||
141
worklenz-frontend/src/utils/spamDetector.ts
Normal file
141
worklenz-frontend/src/utils/spamDetector.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
export interface SpamDetectionResult {
|
||||
isSpam: boolean;
|
||||
score: number;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export class SpamDetector {
|
||||
private static readonly SPAM_PATTERNS = [
|
||||
// URLs and links
|
||||
/https?:\/\//i,
|
||||
/www\./i,
|
||||
/\b\w+\.(com|net|org|io|co|me|ly|tk|ml|ga|cf)\b/i,
|
||||
|
||||
// Common spam phrases
|
||||
/click\s*(here|link|now)/i,
|
||||
/urgent|emergency|immediate/i,
|
||||
/win|won|winner|prize|reward/i,
|
||||
/free|bonus|gift|offer/i,
|
||||
/check\s*(out|this|pay)/i,
|
||||
/blockchain|crypto|bitcoin|compensation/i,
|
||||
/cash|money|dollars?|\$\d+/i,
|
||||
|
||||
// Excessive special characters
|
||||
/[!]{3,}/,
|
||||
/[🔔⬅👆💰$]{2,}/,
|
||||
/\b[A-Z]{5,}\b/,
|
||||
|
||||
// Suspicious formatting
|
||||
/\s{3,}/,
|
||||
/[.]{3,}/
|
||||
];
|
||||
|
||||
private static readonly SUSPICIOUS_WORDS = [
|
||||
'urgent', 'emergency', 'click', 'link', 'win', 'winner', 'prize',
|
||||
'free', 'bonus', 'cash', 'money', 'blockchain', 'crypto', 'compensation',
|
||||
'check', 'pay', 'reward', 'offer', 'gift'
|
||||
];
|
||||
|
||||
public static detectSpam(text: string): SpamDetectionResult {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return { isSpam: false, score: 0, reasons: [] };
|
||||
}
|
||||
|
||||
const normalizedText = text.toLowerCase().trim();
|
||||
const reasons: string[] = [];
|
||||
let score = 0;
|
||||
|
||||
// Check for URL patterns
|
||||
for (const pattern of this.SPAM_PATTERNS) {
|
||||
if (pattern.test(text)) {
|
||||
score += 30;
|
||||
if (pattern.toString().includes('https?') || pattern.toString().includes('www')) {
|
||||
reasons.push('Contains suspicious URLs or links');
|
||||
} else if (pattern.toString().includes('urgent|emergency')) {
|
||||
reasons.push('Contains urgent/emergency language');
|
||||
} else if (pattern.toString().includes('win|won|winner')) {
|
||||
reasons.push('Contains prize/winning language');
|
||||
} else if (pattern.toString().includes('cash|money')) {
|
||||
reasons.push('Contains monetary references');
|
||||
} else if (pattern.toString().includes('blockchain|crypto')) {
|
||||
reasons.push('Contains cryptocurrency references');
|
||||
} else if (pattern.toString().includes('[!]{3,}')) {
|
||||
reasons.push('Excessive use of exclamation marks');
|
||||
} else if (pattern.toString().includes('[🔔⬅👆💰$]')) {
|
||||
reasons.push('Contains suspicious emojis or symbols');
|
||||
} else if (pattern.toString().includes('[A-Z]{5,}')) {
|
||||
reasons.push('Contains excessive capital letters');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for excessive suspicious words
|
||||
const suspiciousWordCount = this.SUSPICIOUS_WORDS.filter(word =>
|
||||
normalizedText.includes(word)
|
||||
).length;
|
||||
|
||||
if (suspiciousWordCount >= 2) {
|
||||
score += suspiciousWordCount * 15;
|
||||
reasons.push(`Contains ${suspiciousWordCount} suspicious words`);
|
||||
}
|
||||
|
||||
// Check text length - very short or very long names are suspicious
|
||||
if (text.length < 2) {
|
||||
score += 20;
|
||||
reasons.push('Text too short');
|
||||
} else if (text.length > 100) {
|
||||
score += 25;
|
||||
reasons.push('Text unusually long');
|
||||
}
|
||||
|
||||
// Check for repeated characters
|
||||
if (/(.)\1{4,}/.test(text)) {
|
||||
score += 20;
|
||||
reasons.push('Contains repeated characters');
|
||||
}
|
||||
|
||||
// Check for mixed scripts (potential homograph attack)
|
||||
const hasLatin = /[a-zA-Z]/.test(text);
|
||||
const hasCyrillic = /[\u0400-\u04FF]/.test(text);
|
||||
const hasGreek = /[\u0370-\u03FF]/.test(text);
|
||||
|
||||
if ((hasLatin && hasCyrillic) || (hasLatin && hasGreek)) {
|
||||
score += 40;
|
||||
reasons.push('Contains mixed character scripts');
|
||||
}
|
||||
|
||||
const isSpam = score >= 50;
|
||||
|
||||
return {
|
||||
isSpam,
|
||||
score,
|
||||
reasons: [...new Set(reasons)] // Remove duplicates
|
||||
};
|
||||
}
|
||||
|
||||
public static isHighRiskContent(text: string): boolean {
|
||||
const patterns = [
|
||||
/gclnk\.com/i,
|
||||
/bit\.ly/i,
|
||||
/tinyurl/i,
|
||||
/\$\d{3,}/,
|
||||
/blockchain.*compensation/i,
|
||||
/urgent.*check/i
|
||||
];
|
||||
|
||||
return patterns.some(pattern => pattern.test(text));
|
||||
}
|
||||
|
||||
public static sanitizeText(text: string): string {
|
||||
if (!text || typeof text !== 'string') return '';
|
||||
|
||||
return text
|
||||
.trim()
|
||||
.replace(/https?:\/\/[^\s]+/gi, '[URL_REMOVED]')
|
||||
.replace(/www\.[^\s]+/gi, '[URL_REMOVED]')
|
||||
.replace(/[🔔⬅👆💰$]{2,}/g, '')
|
||||
.replace(/[!]{3,}/g, '!')
|
||||
.replace(/\s{3,}/g, ' ')
|
||||
.substring(0, 100);
|
||||
}
|
||||
}
|
||||
38
worklenz-frontend/vitest.config.ts
Normal file
38
worklenz-frontend/vitest.config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
css: true,
|
||||
reporters: ['verbose'],
|
||||
coverage: {
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'src/test/',
|
||||
],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: [
|
||||
{ find: '@', replacement: path.resolve(__dirname, './src') },
|
||||
{ find: '@components', replacement: path.resolve(__dirname, './src/components') },
|
||||
{ find: '@features', replacement: path.resolve(__dirname, './src/features') },
|
||||
{ find: '@assets', replacement: path.resolve(__dirname, './src/assets') },
|
||||
{ find: '@utils', replacement: path.resolve(__dirname, './src/utils') },
|
||||
{ find: '@hooks', replacement: path.resolve(__dirname, './src/hooks') },
|
||||
{ find: '@pages', replacement: path.resolve(__dirname, './src/pages') },
|
||||
{ find: '@api', replacement: path.resolve(__dirname, './src/api') },
|
||||
{ find: '@types', replacement: path.resolve(__dirname, './src/types') },
|
||||
{ find: '@shared', replacement: path.resolve(__dirname, './src/shared') },
|
||||
{ find: '@layouts', replacement: path.resolve(__dirname, './src/layouts') },
|
||||
{ find: '@services', replacement: path.resolve(__dirname, './src/services') },
|
||||
],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user