Compare commits

..

6 Commits

Author SHA1 Message Date
Chamika J
136dac17fb feat(labels): implement label update functionality and enhance UI
- Added `updateLabel` method in `LabelsController` to handle label updates with validation for name and color.
- Updated API routes to include the new label update endpoint.
- Introduced `LabelsDrawer` component for editing labels, including a color picker and form validation.
- Enhanced localization files to support new UI strings for label management.
- Implemented a new `LabelsSettings` page to manage labels with search and edit capabilities.
- Improved color handling with a comprehensive color palette for better user experience.
2025-08-04 12:28:52 +05:30
Chamika J
884cb9c462 refactor(SettingsLayout): streamline layout and improve styling
- Removed unused imports and simplified margin handling in SettingsLayout.
- Updated layout classes for better responsiveness and visual consistency.
- Enhanced sidebar and outlet rendering with improved Flex component usage.
- Streamlined overall layout for a cleaner and more modern appearance.
2025-08-04 09:58:49 +05:30
Chamika J
d1bd36e0a4 refactor(AdminCenterLayout): simplify layout structure and improve styling
- Removed unused imports and simplified margin handling in AdminCenterLayout.
- Updated layout classes for better responsiveness and visual consistency.
- Enhanced sidebar and outlet rendering with improved Flex component usage.
- Streamlined overall layout for a cleaner and more modern appearance.
2025-08-04 09:55:56 +05:30
Chamika J
7c42087854 feat(update-notification): implement update notification system for new versions
- Added a service worker message handler to check for updates and notify users.
- Created `UpdateNotification` component to display update prompts with options to reload or dismiss.
- Introduced `UpdateNotificationProvider` to manage update state and notifications globally.
- Implemented `useUpdateChecker` hook for periodic update checks and user notification management.
- Updated localization files to include new strings related to update notifications.
- Enhanced service worker functionality to support hard reloads and update checks.
2025-07-31 16:12:04 +05:30
Chamika J
14c89dec24 chore(tests): remove obsolete SQL test scripts for sort order validation
- Deleted `test_sort_fix.sql` and `test_sort_orders.sql` as they are no longer needed for the current implementation.
- These scripts were previously used to verify sort order constraints and helper functions, but have been deemed unnecessary.
2025-07-31 15:51:43 +05:30
Chamika J
b1bdf0ac11 feat(hubspot): add dark mode support and light color scheme for chat widget
- Removed production check to ensure HubSpot script loads in all environments.
- Implemented dark mode CSS injection when the dark theme is active.
- Added a style block to enforce a light color scheme for the HubSpot chat widget, improving visibility and user experience.
2025-07-31 15:44:04 +05:30
51 changed files with 1212 additions and 1729 deletions

View File

@@ -1,220 +0,0 @@
# 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

View File

@@ -1,41 +0,0 @@
-- Test script to verify the sort order constraint fix
-- Test the helper function
SELECT get_sort_column_name('status'); -- Should return 'status_sort_order'
SELECT get_sort_column_name('priority'); -- Should return 'priority_sort_order'
SELECT get_sort_column_name('phase'); -- Should return 'phase_sort_order'
SELECT get_sort_column_name('members'); -- Should return 'member_sort_order'
SELECT get_sort_column_name('unknown'); -- Should return 'status_sort_order' (default)
-- Test bulk update function (example - would need real project_id and task_ids)
/*
SELECT update_task_sort_orders_bulk(
'[
{"task_id": "example-uuid", "sort_order": 1, "status_id": "status-uuid"},
{"task_id": "example-uuid-2", "sort_order": 2, "status_id": "status-uuid"}
]'::json,
'status'
);
*/
-- Verify that sort_order constraint still exists and works
SELECT
tc.constraint_name,
tc.table_name,
kcu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
WHERE tc.constraint_name = 'tasks_sort_order_unique';
-- Check that new sort order columns don't have unique constraints (which is correct)
SELECT
tc.constraint_name,
tc.table_name,
kcu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
WHERE kcu.table_name = 'tasks'
AND kcu.column_name IN ('status_sort_order', 'priority_sort_order', 'phase_sort_order', 'member_sort_order')
AND tc.constraint_type = 'UNIQUE';

View File

@@ -1,30 +0,0 @@
-- Test script to validate the separate sort order implementation
-- Check if new columns exist
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = 'tasks'
AND column_name IN ('status_sort_order', 'priority_sort_order', 'phase_sort_order', 'member_sort_order')
ORDER BY column_name;
-- Check if helper function exists
SELECT routine_name, routine_type
FROM information_schema.routines
WHERE routine_name IN ('get_sort_column_name', 'update_task_sort_orders_bulk', 'handle_task_list_sort_order_change');
-- Sample test data to verify different sort orders work
-- (This would be run after the migrations)
/*
-- Test: Tasks should have different orders for different groupings
SELECT
id,
name,
sort_order,
status_sort_order,
priority_sort_order,
phase_sort_order,
member_sort_order
FROM tasks
WHERE project_id = '<test-project-id>'
ORDER BY status_sort_order;
*/

View File

@@ -1,43 +0,0 @@
-- 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;

View File

@@ -1,220 +0,0 @@
-- 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();

View File

@@ -80,6 +80,37 @@ export default class LabelsController extends WorklenzControllerBase {
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async updateLabel(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const updates = [];
const values = [req.params.id, req.user?.team_id];
let paramIndex = 3;
if (req.body.name) {
updates.push(`name = $${paramIndex++}`);
values.push(req.body.name);
}
if (req.body.color) {
if (!WorklenzColorCodes.includes(req.body.color))
return res.status(400).send(new ServerResponse(false, null));
updates.push(`color_code = $${paramIndex++}`);
values.push(req.body.color);
}
if (updates.length === 0) {
return res.status(400).send(new ServerResponse(false, "No valid fields to update"));
}
const q = `UPDATE team_labels
SET ${updates.join(', ')}
WHERE id = $1
AND team_id = $2;`;
const result = await db.query(q, values);
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const q = `DELETE

View File

@@ -1,253 +0,0 @@
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
}));
}
}

View File

@@ -17,10 +17,6 @@ import { statusExclude, TEAM_MEMBER_TREE_MAP_COLOR_ALPHA, TRIAL_MEMBER_LIMIT } f
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 {
@@ -76,8 +72,7 @@ export default class TeamMembersController extends WorklenzControllerBase {
@HandleExceptions({
raisedExceptions: {
"ERROR_EMAIL_INVITATION_EXISTS": `Team member with email "{0}" already exists.`,
"ERROR_SPAM_DETECTED": `Invitation blocked: {0}`
"ERROR_EMAIL_INVITATION_EXISTS": `Team member with email "{0}" already exists.`
}
})
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
@@ -87,54 +82,6 @@ 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

View File

@@ -1,141 +0,0 @@
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];
}
}

View File

@@ -8,10 +8,6 @@ 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 = `
@@ -53,111 +49,12 @@ 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) {

View File

@@ -60,7 +60,6 @@ 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();
@@ -123,5 +122,4 @@ api.use("/task-recurring", taskRecurringApiRouter);
api.use("/custom-columns", customColumnsApiRouter);
api.use("/logs", userActivityLogsApiRouter);
api.use("/moderation", moderationApiRouter);
export default api;

View File

@@ -11,6 +11,7 @@ labelsApiRouter.get("/", safeControllerFunction(LabelsController.get));
labelsApiRouter.get("/tasks/:id", idParamValidator, safeControllerFunction(LabelsController.getByTask));
labelsApiRouter.get("/project/:id", idParamValidator, safeControllerFunction(LabelsController.getByProject));
labelsApiRouter.put("/tasks/:id", idParamValidator, teamOwnerOrAdminValidator, safeControllerFunction(LabelsController.updateColor));
labelsApiRouter.put("/team/:id", idParamValidator, teamOwnerOrAdminValidator, safeControllerFunction(LabelsController.updateLabel));
labelsApiRouter.delete("/team/:id", idParamValidator, teamOwnerOrAdminValidator, safeControllerFunction(LabelsController.deleteById));
export default labelsApiRouter;

View File

@@ -1,16 +0,0 @@
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;

View File

@@ -6,7 +6,6 @@ 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();
@@ -14,7 +13,7 @@ const teamMembersApiRouter = express.Router();
teamMembersApiRouter.get("/export-all", safeControllerFunction(TeamMembersController.exportAllMembers));
teamMembersApiRouter.get("/export/:id", idParamValidator, safeControllerFunction(TeamMembersController.exportByMember));
teamMembersApiRouter.post("/", teamOwnerOrAdminValidator, RateLimiter.inviteRateLimit(5, 15 * 60 * 1000), teamMembersBodyValidator, safeControllerFunction(TeamMembersController.create));
teamMembersApiRouter.post("/", teamOwnerOrAdminValidator, teamMembersBodyValidator, safeControllerFunction(TeamMembersController.create));
teamMembersApiRouter.get("/", safeControllerFunction(TeamMembersController.get));
teamMembersApiRouter.get("/list", safeControllerFunction(TeamMembersController.getTeamMemberList));
teamMembersApiRouter.get("/tree-map", safeControllerFunction(TeamMembersController.getTeamMembersTreeMap));
@@ -31,6 +30,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, RateLimiter.inviteRateLimit(3, 10 * 60 * 1000), teamMembersBodyValidator, safeControllerFunction(TeamMembersController.addTeamMember));
teamMembersApiRouter.put("/add-member/:id", teamOwnerOrAdminValidator, teamMembersBodyValidator, safeControllerFunction(TeamMembersController.addTeamMember));
export default teamMembersApiRouter;

View File

@@ -6,7 +6,7 @@ export const DEFAULT_ERROR_MESSAGE = "Unknown error has occurred.";
export const SessionsStatus = {
IDLE: "IDLE",
STARTED: "STARTED",
ENDED: "ENDED"
ENDED: "ENDED",
};
export const LOG_DESCRIPTIONS = {
@@ -18,6 +18,33 @@ export const LOG_DESCRIPTIONS = {
PROJECT_MEMBER_REMOVED: "was removed from the project by",
};
export const WorklenzColorShades = {
"#154c9b": ["#0D2A50", "#112E54", "#153258", "#19365C", "#1D3A60", "#213E64", "#254268", "#29466C", "#2D4A70", "#314E74"],
"#3b7ad4": ["#224884", "#26528A", "#2A5C90", "#2E6696", "#32709C", "#367AA2", "#3A84A8", "#3E8EAE", "#4298B4", "#46A2BA"],
"#70a6f3": ["#3D5D8A", "#46679E", "#5071B2", "#597BC6", "#6385DA", "#6C8FEE", "#7699F2", "#7FA3F6", "#89ADFA", "#92B7FE"],
"#7781ca": ["#42486F", "#4C5283", "#565C97", "#6066AB", "#6A70BF", "#747AD3", "#7E84E7", "#888EFB", "#9298FF", "#9CA2FF"],
"#9877ca": ["#542D70", "#6E3A8A", "#8847A4", "#A254BE", "#BC61D8", "#D66EF2", "#E07BFC", "#EA88FF", "#F495FF", "#FEA2FF"],
"#c178c9": ["#6A2E6F", "#843B89", "#9E48A3", "#B855BD", "#D262D7", "#EC6FF1", "#F67CFB", "#FF89FF", "#FF96FF", "#FFA3FF"],
"#ee87c5": ["#832C6A", "#9D3984", "#B7469E", "#D153B8", "#EB60D2", "#FF6DEC", "#FF7AF6", "#FF87FF", "#FF94FF", "#FFA1FF"],
"#ca7881": ["#6F2C3E", "#893958", "#A34672", "#BD538C", "#D760A6", "#F16DC0", "#FB7ADA", "#FF87F4", "#FF94FF", "#FFA1FF"],
"#75c9c0": ["#3F6B66", "#497E7A", "#53918E", "#5DA4A2", "#67B7B6", "#71CBCA", "#7BDEDE", "#85F2F2", "#8FFFFF", "#99FFFF"],
"#75c997": ["#3F6B54", "#497E6A", "#53917F", "#5DA495", "#67B7AA", "#71CBBF", "#7BDED4", "#85F2E9", "#8FFFFF", "#99FFFF"],
"#80ca79": ["#456F3E", "#5A804D", "#6F935C", "#84A66B", "#99B97A", "#AECC89", "#C3DF98", "#D8F2A7", "#EDFFB6", "#FFFFC5"],
"#aacb78": ["#5F6F3E", "#7A804D", "#94935C", "#AFA66B", "#CAB97A", "#E5CC89", "#FFDF98", "#FFF2A7", "#FFFFB6", "#FFFFC5"],
"#cbbc78": ["#6F5D3E", "#8A704D", "#A4835C", "#BF966B", "#DAA97A", "#F5BC89", "#FFCF98", "#FFE2A7", "#FFF5B6", "#FFFFC5"],
"#cb9878": ["#704D3E", "#8B604D", "#A6735C", "#C1866B", "#DC997A", "#F7AC89", "#FFBF98", "#FFD2A7", "#FFE5B6", "#FFF8C5"],
"#bb774c": ["#653D27", "#80502C", "#9B6331", "#B67636", "#D1893B", "#EC9C40", "#FFAF45", "#FFC24A", "#FFD54F", "#FFE854"],
"#905b39": ["#4D2F1A", "#623C23", "#774A2C", "#8C5735", "#A1643E", "#B67147", "#CB7E50", "#E08B59", "#F59862", "#FFA56B"],
"#903737": ["#4D1A1A", "#622323", "#772C2C", "#8C3535", "#A13E3E", "#B64747", "#CB5050", "#E05959", "#F56262", "#FF6B6B"],
"#bf4949": ["#661212", "#801B1B", "#992424", "#B32D2D", "#CC3636", "#E63F3F", "#FF4848", "#FF5151", "#FF5A5A", "#FF6363"],
"#f37070": ["#853A3A", "#A04D4D", "#BA6060", "#D47373", "#EF8686", "#FF9999", "#FFA3A3", "#FFACAC", "#FFB6B6", "#FFBFBF"],
"#ff9c3c": ["#8F5614", "#AA6F1F", "#C48829", "#DFA233", "#F9BB3D", "#FFC04E", "#FFC75F", "#FFCE70", "#FFD581", "#FFDB92"],
"#fbc84c": ["#8F6D14", "#AA862F", "#C4A029", "#DFB933", "#F9D23D", "#FFD74E", "#FFDC5F", "#FFE170", "#FFE681", "#FFEB92"],
"#cbc8a1": ["#6F6D58", "#8A886F", "#A4A286", "#BFBC9D", "#DAD6B4", "#F5F0CB", "#FFFEDE", "#FFFFF2", "#FFFFCD", "#FFFFCD"],
"#a9a9a9": ["#5D5D5D", "#757575", "#8D8D8D", "#A5A5A5", "#BDBDBD", "#D5D5D5", "#EDEDED", "#F5F5F5", "#FFFFFF", "#FFFFFF"],
"#767676": ["#404040", "#4D4D4D", "#5A5A5A", "#676767", "#747474", "#818181", "#8E8E8E", "#9B9B9B", "#A8A8A8", "#B5B5B5"]
} as const;
export const WorklenzColorCodes = [
"#154c9b",
"#3b7ad4",
@@ -46,33 +73,33 @@ export const WorklenzColorCodes = [
];
export const AvatarNamesMap: { [x: string]: string } = {
"A": "#154c9b",
"B": "#3b7ad4",
"C": "#70a6f3",
"D": "#7781ca",
"E": "#9877ca",
"F": "#c178c9",
"G": "#ee87c5",
"H": "#ca7881",
"I": "#75c9c0",
"J": "#75c997",
"K": "#80ca79",
"L": "#aacb78",
"M": "#cbbc78",
"N": "#cb9878",
"O": "#bb774c",
"P": "#905b39",
"Q": "#903737",
"R": "#bf4949",
"S": "#f37070",
"T": "#ff9c3c",
"U": "#fbc84c",
"V": "#cbc8a1",
"W": "#a9a9a9",
"X": "#767676",
"Y": "#cb9878",
"Z": "#903737",
"+": "#9e9e9e"
A: "#154c9b",
B: "#3b7ad4",
C: "#70a6f3",
D: "#7781ca",
E: "#9877ca",
F: "#c178c9",
G: "#ee87c5",
H: "#ca7881",
I: "#75c9c0",
J: "#75c997",
K: "#80ca79",
L: "#aacb78",
M: "#cbbc78",
N: "#cb9878",
O: "#bb774c",
P: "#905b39",
Q: "#903737",
R: "#bf4949",
S: "#f37070",
T: "#ff9c3c",
U: "#fbc84c",
V: "#cbc8a1",
W: "#a9a9a9",
X: "#767676",
Y: "#cb9878",
Z: "#903737",
"+": "#9e9e9e",
};
export const NumbersColorMap: { [x: string]: string } = {
@@ -85,19 +112,19 @@ export const NumbersColorMap: { [x: string]: string } = {
"6": "#ee87c5",
"7": "#ca7881",
"8": "#75c9c0",
"9": "#75c997"
"9": "#75c997",
};
export const PriorityColorCodes: { [x: number]: string; } = {
export const PriorityColorCodes: { [x: number]: string } = {
0: "#2E8B57",
1: "#DAA520",
2: "#CD5C5C"
2: "#CD5C5C",
};
export const PriorityColorCodesDark: { [x: number]: string; } = {
export const PriorityColorCodesDark: { [x: number]: string } = {
0: "#3CB371",
1: "#B8860B",
2: "#F08080"
2: "#F08080",
};
export const TASK_STATUS_TODO_COLOR = "#a9a9a9";
@@ -113,7 +140,6 @@ export const TASK_DUE_UPCOMING_COLOR = "#70a6f3";
export const TASK_DUE_OVERDUE_COLOR = "#f37070";
export const TASK_DUE_NO_DUE_COLOR = "#a9a9a9";
export const DEFAULT_PAGE_SIZE = 20;
// S3 Credentials
@@ -125,7 +151,8 @@ export const S3_SECRET_ACCESS_KEY = process.env.S3_SECRET_ACCESS_KEY || "";
// Azure Blob Storage Credentials
export const STORAGE_PROVIDER = process.env.STORAGE_PROVIDER || "s3";
export const AZURE_STORAGE_ACCOUNT_NAME = process.env.AZURE_STORAGE_ACCOUNT_NAME;
export const AZURE_STORAGE_ACCOUNT_NAME =
process.env.AZURE_STORAGE_ACCOUNT_NAME;
export const AZURE_STORAGE_CONTAINER = process.env.AZURE_STORAGE_CONTAINER;
export const AZURE_STORAGE_ACCOUNT_KEY = process.env.AZURE_STORAGE_ACCOUNT_KEY;
export const AZURE_STORAGE_URL = process.env.AZURE_STORAGE_URL;
@@ -136,7 +163,7 @@ export function getStorageUrl() {
console.warn("AZURE_STORAGE_URL is not defined, falling back to S3_URL");
return S3_URL;
}
// Return just the base Azure Blob Storage URL
// AZURE_STORAGE_URL should be in the format: https://storageaccountname.blob.core.windows.net
return `${AZURE_STORAGE_URL}/${AZURE_STORAGE_CONTAINER}`;
@@ -150,12 +177,16 @@ export const TEAM_MEMBER_TREE_MAP_COLOR_ALPHA = "40";
// LICENSING SERVER URLS
export const LOCAL_URL = "http://localhost:3001";
export const UAT_SERVER_URL = process.env.UAT_SERVER_URL || "https://your-uat-server-url";
export const DEV_SERVER_URL = process.env.DEV_SERVER_URL || "https://your-dev-server-url";
export const PRODUCTION_SERVER_URL = process.env.PRODUCTION_SERVER_URL || "https://your-production-server-url";
export const UAT_SERVER_URL =
process.env.UAT_SERVER_URL || "https://your-uat-server-url";
export const DEV_SERVER_URL =
process.env.DEV_SERVER_URL || "https://your-dev-server-url";
export const PRODUCTION_SERVER_URL =
process.env.PRODUCTION_SERVER_URL || "https://your-production-server-url";
// *Sync with the client
export const PASSWORD_POLICY = "Minimum of 8 characters, with upper and lowercase and a number and a symbol.";
export const PASSWORD_POLICY =
"Minimum of 8 characters, with upper and lowercase and a number and a symbol.";
// paddle status to exclude
export const statusExclude = ["past_due", "paused", "deleted"];
@@ -172,5 +203,5 @@ export const DATE_RANGES = {
LAST_WEEK: "LAST_WEEK",
LAST_MONTH: "LAST_MONTH",
LAST_QUARTER: "LAST_QUARTER",
ALL_TIME: "ALL_TIME"
ALL_TIME: "ALL_TIME",
};

View File

@@ -1,244 +0,0 @@
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);
}
}

View File

@@ -11,7 +11,6 @@
# production
/build
/public/tinymce
/docs
# misc
.DS_Store

View File

@@ -15,7 +15,7 @@ class HubSpotManager {
* Load HubSpot script with dark mode support
*/
init() {
if (!this.isProduction) return;
// if (!this.isProduction) return;
const loadHubSpot = () => {
const script = document.createElement('script');
@@ -52,6 +52,7 @@ class HubSpotManager {
existingStyle.remove();
}
// Apply dark mode CSS if dark theme is active
if (isDark) {
this.injectDarkModeCSS();
}
@@ -121,4 +122,11 @@ document.addEventListener('DOMContentLoaded', () => {
// Make available globally for potential cleanup
window.HubSpotManager = hubspot;
});
});
// Add this style to ensure the chat widget uses the light color scheme
(function() {
var style = document.createElement('style');
style.innerHTML = '#hubspot-messages-iframe-container { color-scheme: light !important; }';
document.head.appendChild(style);
})();

View File

@@ -6,5 +6,12 @@
"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",
"cancel": "Anulo"
"cancel": "Anulo",
"update-available": "Worklenz u përditesua!",
"update-description": "Një version i ri i Worklenz është i disponueshëm me karakteristikat dhe përmirësimet më të fundit.",
"update-instruction": "Për eksperiencën më të mirë, ju lutemi rifreskoni faqen për të aplikuar ndryshimet e reja.",
"update-whats-new": "💡 <1>Çfarë ka të re:</1> Përmirësim i performancës, rregullime të gabimeve dhe eksperiencön e përmirësuar e përdoruesit",
"update-now": "Përditeso tani",
"update-later": "Më vonë",
"updating": "Duke u përditesuar..."
}

View File

@@ -7,5 +7,9 @@
"searchPlaceholder": "Kërko sipas emrit",
"emptyText": "Etiketat mund të krijohen gjatë përditësimit ose krijimit të detyrave.",
"pinTooltip": "Klikoni për ta fiksuar në menynë kryesore",
"colorChangeTooltip": "Klikoni për të ndryshuar ngjyrën"
"colorChangeTooltip": "Klikoni për të ndryshuar ngjyrën",
"pageTitle": "Menaxho Etiketat",
"deleteConfirmTitle": "Jeni i sigurt që dëshironi ta fshini këtë?",
"deleteButton": "Fshi",
"cancelButton": "Anulo"
}

View File

@@ -6,5 +6,12 @@
"reconnecting": "Vom Server getrennt.",
"connection-lost": "Verbindung zum Server fehlgeschlagen. Bitte überprüfen Sie Ihre Internetverbindung.",
"connection-restored": "Erfolgreich mit dem Server verbunden",
"cancel": "Abbrechen"
"cancel": "Abbrechen",
"update-available": "Worklenz aktualisiert!",
"update-description": "Eine neue Version von Worklenz ist verfügbar mit den neuesten Funktionen und Verbesserungen.",
"update-instruction": "Für die beste Erfahrung laden Sie bitte die Seite neu, um die neuen Änderungen zu übernehmen.",
"update-whats-new": "💡 <1>Was ist neu:</1> Verbesserte Leistung, Fehlerbehebungen und verbesserte Benutzererfahrung",
"update-now": "Jetzt aktualisieren",
"update-later": "Später",
"updating": "Wird aktualisiert..."
}

View File

@@ -7,5 +7,9 @@
"searchPlaceholder": "Nach Name suchen",
"emptyText": "Labels können beim Aktualisieren oder Erstellen von Aufgaben erstellt werden.",
"pinTooltip": "Zum Anheften an das Hauptmenü klicken",
"colorChangeTooltip": "Zum Ändern der Farbe klicken"
"colorChangeTooltip": "Zum Ändern der Farbe klicken",
"pageTitle": "Labels verwalten",
"deleteConfirmTitle": "Sind Sie sicher, dass Sie dies löschen möchten?",
"deleteButton": "Löschen",
"cancelButton": "Abbrechen"
}

View File

@@ -6,5 +6,12 @@
"reconnecting": "Disconnected from server.",
"connection-lost": "Failed to connect to server. Please check your internet connection.",
"connection-restored": "Connected to server successfully",
"cancel": "Cancel"
"cancel": "Cancel",
"update-available": "Worklenz Updated!",
"update-description": "A new version of Worklenz is available with the latest features and improvements.",
"update-instruction": "To get the best experience, please reload the page to apply the new changes.",
"update-whats-new": "💡 <1>What's new:</1> Enhanced performance, bug fixes, and improved user experience",
"update-now": "Update Now",
"update-later": "Later",
"updating": "Updating..."
}

View File

@@ -7,5 +7,9 @@
"searchPlaceholder": "Search by name",
"emptyText": "Labels can be created while updating or creating tasks.",
"pinTooltip": "Click to pin this into the main menu",
"colorChangeTooltip": "Click to change color"
"colorChangeTooltip": "Click to change color",
"pageTitle": "Manage Labels",
"deleteConfirmTitle": "Are you sure you want to delete this?",
"deleteButton": "Delete",
"cancelButton": "Cancel"
}

View File

@@ -6,5 +6,12 @@
"reconnecting": "Reconectando al servidor...",
"connection-lost": "Conexión perdida. Intentando reconectarse...",
"connection-restored": "Conexión restaurada. Reconectando al servidor...",
"cancel": "Cancelar"
"cancel": "Cancelar",
"update-available": "¡Worklenz actualizado!",
"update-description": "Una nueva versión de Worklenz está disponible con las últimas funciones y mejoras.",
"update-instruction": "Para obtener la mejor experiencia, por favor recarga la página para aplicar los nuevos cambios.",
"update-whats-new": "💡 <1>Qué hay de nuevo:</1> Rendimiento mejorado, correcciones de errores y experiencia de usuario mejorada",
"update-now": "Actualizar ahora",
"update-later": "Más tarde",
"updating": "Actualizando..."
}

View File

@@ -7,5 +7,9 @@
"searchPlaceholder": "Buscar por nombre",
"emptyText": "Las etiquetas se pueden crear al actualizar o crear tareas.",
"pinTooltip": "Haz clic para fijar esto en el menú principal",
"colorChangeTooltip": "Haz clic para cambiar el color"
"colorChangeTooltip": "Haz clic para cambiar el color",
"pageTitle": "Administrar Etiquetas",
"deleteConfirmTitle": "¿Estás seguro de que quieres eliminar esto?",
"deleteButton": "Eliminar",
"cancelButton": "Cancelar"
}

View File

@@ -6,5 +6,12 @@
"reconnecting": "Reconectando ao servidor...",
"connection-lost": "Conexão perdida. Tentando reconectar...",
"connection-restored": "Conexão restaurada. Reconectando ao servidor...",
"cancel": "Cancelar"
"cancel": "Cancelar",
"update-available": "Worklenz atualizado!",
"update-description": "Uma nova versão do Worklenz está disponível com os recursos e melhorias mais recentes.",
"update-instruction": "Para obter a melhor experiência, por favor recarregue a página para aplicar as novas mudanças.",
"update-whats-new": "💡 <1>O que há de novo:</1> Performance aprimorada, correções de bugs e experiência do usuário melhorada",
"update-now": "Atualizar agora",
"update-later": "Mais tarde",
"updating": "Atualizando..."
}

View File

@@ -7,5 +7,9 @@
"searchPlaceholder": "Pesquisar por nome",
"emptyText": "Os rótulos podem ser criados ao atualizar ou criar tarefas.",
"pinTooltip": "Clique para fixar isso no menu principal",
"colorChangeTooltip": "Clique para mudar a cor"
"colorChangeTooltip": "Clique para mudar a cor",
"pageTitle": "Gerenciar Rótulos",
"deleteConfirmTitle": "Tem certeza de que deseja excluir isto?",
"deleteButton": "Excluir",
"cancelButton": "Cancelar"
}

View File

@@ -6,5 +6,12 @@
"reconnecting": "与服务器断开连接。",
"connection-lost": "无法连接到服务器。请检查您的互联网连接。",
"connection-restored": "成功连接到服务器",
"cancel": "取消"
"cancel": "取消",
"update-available": "Worklenz 已更新!",
"update-description": "Worklenz 的新版本已可用,具有最新的功能和改进。",
"update-instruction": "为了获得最佳体验,请刷新页面以应用新更改。",
"update-whats-new": "💡 <1>新增内容:</1>性能增强、错误修复和用户体验改善",
"update-now": "立即更新",
"update-later": "稍后",
"updating": "正在更新..."
}

View File

@@ -7,5 +7,9 @@
"searchPlaceholder": "按名称搜索",
"emptyText": "标签可以在更新或创建任务时创建。",
"pinTooltip": "点击将其固定到主菜单",
"colorChangeTooltip": "点击更改颜色"
"colorChangeTooltip": "点击更改颜色",
"pageTitle": "管理标签",
"deleteConfirmTitle": "您确定要删除这个吗?",
"deleteButton": "删除",
"cancelButton": "取消"
}

View File

@@ -325,6 +325,12 @@ self.addEventListener('message', event => {
event.ports[0].postMessage({ version: CACHE_VERSION });
break;
case 'CHECK_FOR_UPDATES':
checkForUpdates().then((hasUpdates) => {
event.ports[0].postMessage({ hasUpdates });
});
break;
case 'CLEAR_CACHE':
clearAllCaches().then(() => {
event.ports[0].postMessage({ success: true });
@@ -349,6 +355,44 @@ async function clearAllCaches() {
console.log('Service Worker: All caches cleared');
}
async function checkForUpdates() {
try {
// Check if there's a new service worker available
const registration = await self.registration.update();
const hasNewWorker = registration.installing || registration.waiting;
if (hasNewWorker) {
console.log('Service Worker: New version detected');
return true;
}
// Also check if the main app files have been updated by trying to fetch index.html
// and comparing it with the cached version
try {
const cache = await caches.open(CACHE_NAMES.STATIC);
const cachedResponse = await cache.match('/');
const networkResponse = await fetch('/', { cache: 'no-cache' });
if (cachedResponse && networkResponse.ok) {
const cachedContent = await cachedResponse.text();
const networkContent = await networkResponse.text();
if (cachedContent !== networkContent) {
console.log('Service Worker: App content has changed');
return true;
}
}
} catch (error) {
console.log('Service Worker: Could not check for content updates', error);
}
return false;
} catch (error) {
console.error('Service Worker: Error checking for updates', error);
return false;
}
}
async function handleLogout() {
try {
// Clear all caches

View File

@@ -6,6 +6,7 @@ import i18next from 'i18next';
// Components
import ThemeWrapper from './features/theme/ThemeWrapper';
import ModuleErrorBoundary from './components/ModuleErrorBoundary';
import { UpdateNotificationProvider } from './components/update-notification';
// Routes
import router from './app/routes';
@@ -202,14 +203,16 @@ const App: React.FC = memo(() => {
return (
<Suspense fallback={<SuspenseFallback />}>
<ThemeWrapper>
<ModuleErrorBoundary>
<RouterProvider
router={router}
future={{
v7_startTransition: true,
}}
/>
</ModuleErrorBoundary>
<UpdateNotificationProvider>
<ModuleErrorBoundary>
<RouterProvider
router={router}
future={{
v7_startTransition: true,
}}
/>
</ModuleErrorBoundary>
</UpdateNotificationProvider>
</ThemeWrapper>
</Suspense>
);

View File

@@ -27,12 +27,17 @@ export const labelsApiService = {
updateColor: async (labelId: string, color: string): Promise<IServerResponse<ITaskLabel>> => {
const response = await apiClient.put<IServerResponse<ITaskLabel>>(
`${rootUrl}/tasks/${labelId}/color`,
`${rootUrl}/tasks/${labelId}`,
{ color }
);
return response.data;
},
updateLabel: async (labelId: string, data: { name?: string; color?: string }): Promise<IServerResponse<ITaskLabel>> => {
const response = await apiClient.put<IServerResponse<ITaskLabel>>(`${rootUrl}/team/${labelId}`, data);
return response.data;
},
deleteById: async (labelId: string): Promise<IServerResponse<void>> => {
const response = await apiClient.delete<IServerResponse<void>>(`${rootUrl}/team/${labelId}`);
return response.data;

View File

@@ -11,9 +11,7 @@ import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallba
const HomePage = lazy(() => import('@/pages/home/home-page'));
const ProjectList = lazy(() => import('@/pages/projects/project-list'));
const Schedule = lazy(() => import('@/pages/schedule/schedule'));
const ProjectTemplateEditView = lazy(
() => import('@/pages/settings/project-templates/projectTemplateEditView/ProjectTemplateEditView')
);
const LicenseExpired = lazy(() => import('@/pages/license-expired/license-expired'));
const ProjectView = lazy(() => import('@/pages/projects/projectView/project-view'));
const Unauthorized = lazy(() => import('@/pages/unauthorized/unauthorized'));
@@ -91,14 +89,6 @@ const mainRoutes: RouteObject[] = [
</Suspense>
),
},
{
path: `settings/project-templates/edit/:templateId/:templateName`,
element: (
<Suspense fallback={<SuspenseFallback />}>
<ProjectTemplateEditView />
</Suspense>
),
},
{
path: 'unauthorized',
element: (

View File

@@ -1,11 +1,10 @@
import React, { useEffect, useRef, useState } from 'react';
import { Form, Input, InputRef, Typography, Card, Tooltip, Alert } from '@/shared/antd-imports';
import { Form, Input, InputRef, Typography, Card, Tooltip } 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;
@@ -30,7 +29,6 @@ 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(() => {
@@ -46,19 +44,7 @@ export const OrganizationStep: React.FC<Props> = ({
};
const handleOrgNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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('');
}
const sanitizedValue = sanitizeInput(e.target.value);
dispatch(setOrganizationName(sanitizedValue));
};
@@ -74,25 +60,12 @@ 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: spamWarning ? token?.colorWarning : token?.colorPrimary,
borderColor: token?.colorPrimary,
backgroundColor: token?.colorBgContainer
}}
>

View File

@@ -1,11 +1,10 @@
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, Alert } from '@/shared/antd-imports';
import { Card, Button, Tooltip, Typography } 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;
@@ -17,7 +16,6 @@ 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);
@@ -36,18 +34,7 @@ const OrganizationName = ({ themeMode, name, t, refetch }: OrganizationNameProps
};
const handleNameChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
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('');
}
setNewName(e.target.value);
};
const updateOrganizationName = async () => {
@@ -75,16 +62,6 @@ 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 ? (

View File

@@ -10,3 +10,6 @@ export { default as LabelsSelector } from './LabelsSelector';
export { default as Progress } from './Progress';
export { default as Tag } from './Tag';
export { default as Tooltip } from './Tooltip';
// Update Notification Components
export * from './update-notification';

View File

@@ -1,10 +1,9 @@
import { Divider, Form, Input, message, Modal, Typography, Alert } from '@/shared/antd-imports';
import { Divider, Form, Input, message, Modal, Typography } 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;
@@ -17,7 +16,6 @@ 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) {
@@ -69,16 +67,6 @@ 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')}
@@ -89,20 +77,7 @@ const EditTeamNameModal = ({ team, isModalOpen, onCancel }: EditTeamNameModalPro
},
]}
>
<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('');
}
}}
/>
<Input placeholder={t('namePlaceholder')} />
</Form.Item>
</Form>
</Modal>

View File

@@ -2,8 +2,10 @@ import { Tag, Typography } from '@/shared/antd-imports';
import { colors } from '@/styles/colors';
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
import { ALPHA_CHANNEL } from '@/shared/constants';
import { useAppSelector } from '@/hooks/useAppSelector';
const CustomColorLabel = ({ label }: { label: ITaskLabel | null }) => {
const themeMode = useAppSelector(state => state.themeReducer.mode);
return (
<Tag
key={label?.id}
@@ -17,7 +19,7 @@ const CustomColorLabel = ({ label }: { label: ITaskLabel | null }) => {
fontSize: 11,
}}
>
<Typography.Text style={{ fontSize: 11, color: colors.darkGray }}>
<Typography.Text style={{ fontSize: 11, color: themeMode === 'dark' ? 'rgba(255, 255, 255, 0.85)' : colors.darkGray }}>
{label?.name}
</Typography.Text>
</Tag>

View File

@@ -0,0 +1,121 @@
// Update Notification Component
// Shows a notification when new build is available and provides update options
import React from 'react';
import { Modal, Button, Space, Typography } from '@/shared/antd-imports';
import { ReloadOutlined, CloseOutlined, DownloadOutlined } from '@/shared/antd-imports';
import { useTranslation } from 'react-i18next';
import { useServiceWorker } from '../../utils/serviceWorkerRegistration';
const { Text, Title } = Typography;
interface UpdateNotificationProps {
visible: boolean;
onClose: () => void;
onUpdate: () => void;
}
const UpdateNotification: React.FC<UpdateNotificationProps> = ({
visible,
onClose,
onUpdate
}) => {
const { t } = useTranslation('common');
const [isUpdating, setIsUpdating] = React.useState(false);
const { hardReload } = useServiceWorker();
const handleUpdate = async () => {
setIsUpdating(true);
try {
if (hardReload) {
await hardReload();
} else {
// Fallback to regular reload
window.location.reload();
}
onUpdate();
} catch (error) {
console.error('Error during update:', error);
// Fallback to regular reload
window.location.reload();
}
};
const handleLater = () => {
onClose();
};
return (
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<DownloadOutlined style={{ color: '#1890ff' }} />
<Title level={4} style={{ margin: 0, color: '#1890ff' }}>
{t('update-available')}
</Title>
</div>
}
open={visible}
onCancel={handleLater}
footer={null}
centered
closable={false}
maskClosable={false}
width={460}
styles={{
body: { padding: '20px 24px' }
}}
>
<div style={{ marginBottom: '20px' }}>
<Text style={{ fontSize: '16px', lineHeight: '1.6' }}>
{t('update-description')}
</Text>
<br />
<br />
<Text style={{ fontSize: '14px', color: '#8c8c8c' }}>
{t('update-instruction')}
</Text>
</div>
<div style={{
background: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: '6px',
padding: '12px',
marginBottom: '20px'
}}>
<Text style={{ fontSize: '13px', color: '#389e0d' }}>
{t('update-whats-new', {
interpolation: { escapeValue: false }
})}
</Text>
</div>
<Space
style={{
width: '100%',
justifyContent: 'flex-end'
}}
size="middle"
>
<Button
icon={<CloseOutlined />}
onClick={handleLater}
disabled={isUpdating}
>
{t('update-later')}
</Button>
<Button
type="primary"
icon={<ReloadOutlined />}
loading={isUpdating}
onClick={handleUpdate}
>
{isUpdating ? t('updating') : t('update-now')}
</Button>
</Space>
</Modal>
);
};
export default UpdateNotification;

View File

@@ -0,0 +1,50 @@
// Update Notification Provider
// Provides global update notification management
import React from 'react';
import { useUpdateChecker } from '../../hooks/useUpdateChecker';
import UpdateNotification from './UpdateNotification';
interface UpdateNotificationProviderProps {
children: React.ReactNode;
checkInterval?: number;
enableAutoCheck?: boolean;
}
const UpdateNotificationProvider: React.FC<UpdateNotificationProviderProps> = ({
children,
checkInterval = 5 * 60 * 1000, // 5 minutes
enableAutoCheck = true
}) => {
const {
showUpdateNotification,
setShowUpdateNotification,
dismissUpdate
} = useUpdateChecker({
checkInterval,
enableAutoCheck,
showNotificationOnUpdate: true
});
const handleClose = () => {
dismissUpdate();
};
const handleUpdate = () => {
// The hardReload function in UpdateNotification will handle the actual update
setShowUpdateNotification(false);
};
return (
<>
{children}
<UpdateNotification
visible={showUpdateNotification}
onClose={handleClose}
onUpdate={handleUpdate}
/>
</>
);
};
export default UpdateNotificationProvider;

View File

@@ -0,0 +1,2 @@
export { default as UpdateNotification } from './UpdateNotification';
export { default as UpdateNotificationProvider } from './UpdateNotificationProvider';

View File

@@ -0,0 +1,141 @@
// Update Checker Hook
// Periodically checks for app updates and manages update notifications
import React from 'react';
import { useServiceWorker } from '../utils/serviceWorkerRegistration';
interface UseUpdateCheckerOptions {
checkInterval?: number; // Check interval in milliseconds (default: 5 minutes)
enableAutoCheck?: boolean; // Enable automatic checking (default: true)
showNotificationOnUpdate?: boolean; // Show notification when update is found (default: true)
}
interface UseUpdateCheckerReturn {
hasUpdate: boolean;
isChecking: boolean;
lastChecked: Date | null;
checkForUpdates: () => Promise<void>;
dismissUpdate: () => void;
showUpdateNotification: boolean;
setShowUpdateNotification: (show: boolean) => void;
}
export function useUpdateChecker(options: UseUpdateCheckerOptions = {}): UseUpdateCheckerReturn {
const {
checkInterval = 5 * 60 * 1000, // 5 minutes
enableAutoCheck = true,
showNotificationOnUpdate = true
} = options;
const { checkForUpdates: serviceWorkerCheckUpdates, swManager } = useServiceWorker();
const [hasUpdate, setHasUpdate] = React.useState(false);
const [isChecking, setIsChecking] = React.useState(false);
const [lastChecked, setLastChecked] = React.useState<Date | null>(null);
const [showUpdateNotification, setShowUpdateNotification] = React.useState(false);
const [updateDismissed, setUpdateDismissed] = React.useState(false);
// Check for updates function
const checkForUpdates = React.useCallback(async () => {
if (!serviceWorkerCheckUpdates || isChecking) return;
setIsChecking(true);
try {
const hasUpdates = await serviceWorkerCheckUpdates();
setHasUpdate(hasUpdates);
setLastChecked(new Date());
// Show notification if update found and user hasn't dismissed it
if (hasUpdates && showNotificationOnUpdate && !updateDismissed) {
setShowUpdateNotification(true);
}
console.log('Update check completed:', { hasUpdates });
} catch (error) {
console.error('Error checking for updates:', error);
} finally {
setIsChecking(false);
}
}, [serviceWorkerCheckUpdates, isChecking, showNotificationOnUpdate, updateDismissed]);
// Dismiss update notification
const dismissUpdate = React.useCallback(() => {
setUpdateDismissed(true);
setShowUpdateNotification(false);
}, []);
// Set up automatic checking interval
React.useEffect(() => {
if (!enableAutoCheck || !swManager) return;
// Initial check after a short delay
const initialTimeout = setTimeout(() => {
checkForUpdates();
}, 10000); // 10 seconds after component mount
// Set up interval for periodic checks
const intervalId = setInterval(() => {
checkForUpdates();
}, checkInterval);
return () => {
clearTimeout(initialTimeout);
clearInterval(intervalId);
};
}, [enableAutoCheck, swManager, checkInterval, checkForUpdates]);
// Listen for visibility change to check for updates when user returns to tab
React.useEffect(() => {
if (!enableAutoCheck) return;
const handleVisibilityChange = () => {
if (!document.hidden && swManager) {
// Check for updates when user returns to the tab
setTimeout(() => {
checkForUpdates();
}, 2000); // 2 second delay
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [enableAutoCheck, swManager, checkForUpdates]);
// Listen for focus events to check for updates
React.useEffect(() => {
if (!enableAutoCheck) return;
const handleFocus = () => {
if (swManager && !isChecking) {
// Check for updates when window regains focus
setTimeout(() => {
checkForUpdates();
}, 1000); // 1 second delay
}
};
window.addEventListener('focus', handleFocus);
return () => {
window.removeEventListener('focus', handleFocus);
};
}, [enableAutoCheck, swManager, isChecking, checkForUpdates]);
// Reset dismissed state when new update is found
React.useEffect(() => {
if (hasUpdate && updateDismissed) {
setUpdateDismissed(false);
}
}, [hasUpdate, updateDismissed]);
return {
hasUpdate,
isChecking,
lastChecked,
checkForUpdates,
dismissUpdate,
showUpdateNotification,
setShowUpdateNotification
};
}

View File

@@ -4,40 +4,25 @@ import { Outlet } from 'react-router-dom';
import { useMediaQuery } from 'react-responsive';
import AdminCenterSidebar from '@/pages/admin-center/sidebar/sidebar';
import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '@/hooks/useAppDispatch';
const AdminCenterLayout: React.FC = () => {
const dispatch = useAppDispatch();
const isTablet = useMediaQuery({ query: '(min-width:768px)' });
const isMarginAvailable = useMediaQuery({ query: '(min-width: 1000px)' });
const { t } = useTranslation('admin-center/sidebar');
return (
<div
style={{
marginBlock: 96,
minHeight: '90vh',
marginLeft: `${isMarginAvailable ? '5%' : ''}`,
marginRight: `${isMarginAvailable ? '5%' : ''}`,
}}
>
<div className="my-6">
<Typography.Title level={4}>{t('adminCenter')}</Typography.Title>
{isTablet ? (
<Flex
gap={24}
align="flex-start"
style={{
width: '100%',
marginBlockStart: 24,
}}
className="w-full mt-6"
>
<Flex style={{ width: '100%', maxWidth: 240 }}>
<Flex className="w-full max-w-60">
<AdminCenterSidebar />
</Flex>
<Flex style={{ width: '100%' }}>
<Flex className="w-full">
<Outlet />
</Flex>
</Flex>
@@ -45,9 +30,7 @@ const AdminCenterLayout: React.FC = () => {
<Flex
vertical
gap={24}
style={{
marginBlockStart: 24,
}}
className="mt-6"
>
<AdminCenterSidebar />
<Outlet />

View File

@@ -1,35 +1,25 @@
import { Flex, Typography } from '@/shared/antd-imports';
import SettingsSidebar from '../pages/settings/sidebar/settings-sidebar';
import { Outlet, useNavigate } from 'react-router-dom';
import { Outlet } from 'react-router-dom';
import { useMediaQuery } from 'react-responsive';
import { useEffect } from 'react';
import { useAuthService } from '@/hooks/useAuth';
const SettingsLayout = () => {
const isTablet = useMediaQuery({ query: '(min-width: 768px)' });
const { getCurrentSession } = useAuthService();
const currentSession = getCurrentSession();
const navigate = useNavigate();
return (
<div style={{ marginBlock: 96, minHeight: '90vh' }}>
<div className="my-6 min-h-[90vh]">
<Typography.Title level={4}>Settings</Typography.Title>
{isTablet ? (
<Flex
gap={24}
align="flex-start"
style={{
width: '100%',
marginBlockStart: 24,
}}
className="w-full mt-6"
>
<Flex style={{ width: '100%', maxWidth: 240 }}>
<Flex className="w-full max-w-60">
<SettingsSidebar />
</Flex>
<Flex style={{ width: '100%' }}>
<Flex className="w-full">
<Outlet />
</Flex>
</Flex>
@@ -37,9 +27,7 @@ const SettingsLayout = () => {
<Flex
vertical
gap={24}
style={{
marginBlockStart: 24,
}}
className="mt-6"
>
<SettingsSidebar />
<Outlet />

View File

@@ -18,7 +18,7 @@ const ProfileSettings = lazy(() => import('../../pages/settings/profile/profile-
const NotificationsSettings = lazy(() => import('../../pages/settings/notifications/notifications-settings'));
const ClientsSettings = lazy(() => import('../../pages/settings/clients/clients-settings'));
const JobTitlesSettings = lazy(() => import('@/pages/settings/job-titles/job-titles-settings'));
const LabelsSettings = lazy(() => import('../../pages/settings/labels/labels-settings'));
const LabelsSettings = lazy(() => import('../../pages/settings/labels/LabelsSettings'));
const CategoriesSettings = lazy(() => import('../../pages/settings/categories/categories-settings'));
const ProjectTemplatesSettings = lazy(() => import('@/pages/settings/project-templates/project-templates-settings'));
const TaskTemplatesSettings = lazy(() => import('@/pages/settings/task-templates/task-templates-settings'));

View File

@@ -8,22 +8,28 @@ import {
TableProps,
Tooltip,
Typography,
DeleteOutlined,
ExclamationCircleFilled,
SearchOutlined,
EditOutlined,
} from '@/shared/antd-imports';
import { useEffect, useMemo, useState } from 'react';
import PinRouteToNavbarButton from '../../../components/PinRouteToNavbarButton';
import PinRouteToNavbarButton from '@/components/PinRouteToNavbarButton';
import { useTranslation } from 'react-i18next';
import { DeleteOutlined, ExclamationCircleFilled, SearchOutlined } from '@/shared/antd-imports';
import { ITaskLabel } from '@/types/label.type';
import { labelsApiService } from '@/api/taskAttributes/labels/labels.api.service';
import CustomColorLabel from '@components/task-list-common/labelsSelector/custom-color-label';
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
import logger from '@/utils/errorLogger';
import LabelsDrawer from './labels-drawer';
const LabelsSettings = () => {
const { t } = useTranslation('settings/labels');
useDocumentTitle('Manage Labels');
useDocumentTitle(t('pageTitle', 'Manage Labels'));
const [selectedLabelId, setSelectedLabelId] = useState<string | null>(null);
const [showDrawer, setShowDrawer] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [labels, setLabels] = useState<ITaskLabel[]>([]);
const [loading, setLoading] = useState(false);
@@ -64,32 +70,62 @@ const LabelsSettings = () => {
}
};
const handleEditClick = (id: string) => {
setSelectedLabelId(id);
setShowDrawer(true);
};
const handleDrawerClose = () => {
setSelectedLabelId(null);
setShowDrawer(false);
getLabels();
};
// table columns
const columns: TableProps['columns'] = [
{
key: 'label',
title: t('labelColumn'),
title: t('labelColumn', 'Label'),
onCell: record => ({
onClick: () => handleEditClick(record.id!),
}),
render: (record: ITaskLabel) => <CustomColorLabel label={record} />,
},
{
key: 'associatedTask',
title: t('associatedTaskColumn'),
title: t('associatedTaskColumn', 'Associated Task Count'),
render: (record: ITaskLabel) => <Typography.Text>{record.usage}</Typography.Text>,
},
{
key: 'actionBtns',
width: 60,
width: 100,
render: (record: ITaskLabel) => (
<div className="action-button opacity-0 transition-opacity duration-200">
<Popconfirm
title="Are you sure you want to delete this?"
icon={<ExclamationCircleFilled style={{ color: '#ff9800' }} />}
okText="Delete"
cancelText="Cancel"
onConfirm={() => deleteLabel(record.id!)}
>
<Button shape="default" icon={<DeleteOutlined />} size="small" />
</Popconfirm>
<Flex gap={4}>
<Tooltip title={t('editTooltip', 'Edit')}>
<Button
shape="default"
icon={<EditOutlined />}
size="small"
onClick={(e) => {
e.stopPropagation();
handleEditClick(record.id!);
}}
/>
</Tooltip>
<Popconfirm
title={t('deleteConfirmTitle', 'Are you sure you want to delete this?')}
icon={<ExclamationCircleFilled style={{ color: '#ff9800' }} />}
okText={t('deleteButton', 'Delete')}
cancelText={t('cancelButton', 'Cancel')}
onConfirm={() => deleteLabel(record.id!)}
>
<Tooltip title={t('deleteTooltip', 'Delete')}>
<Button shape="default" icon={<DeleteOutlined />} size="small" />
</Tooltip>
</Popconfirm>
</Flex>
</div>
),
},
@@ -104,12 +140,12 @@ const LabelsSettings = () => {
<Input
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder={t('searchPlaceholder')}
placeholder={t('searchPlaceholder', 'Search by name')}
style={{ maxWidth: 232 }}
suffix={<SearchOutlined />}
/>
<Tooltip title={t('pinTooltip')} trigger={'hover'}>
<Tooltip title={t('pinTooltip', 'Click to pin this into the main menu')} trigger={'hover'}>
{/* this button pin this route to navbar */}
<PinRouteToNavbarButton name="labels" path="/worklenz/settings/labels" />
</Tooltip>
@@ -119,13 +155,17 @@ const LabelsSettings = () => {
>
<Table
locale={{
emptyText: <Typography.Text>{t('emptyText')}</Typography.Text>,
emptyText: <Typography.Text>{t('emptyText', 'Labels can be created while updating or creating tasks.')}</Typography.Text>,
}}
loading={loading}
className="custom-two-colors-row-table"
dataSource={filteredData}
columns={columns}
rowKey={record => record.id!}
onRow={(record) => ({
style: { cursor: 'pointer' },
onClick: () => handleEditClick(record.id!),
})}
pagination={{
showSizeChanger: true,
defaultPageSize: 20,
@@ -133,6 +173,12 @@ const LabelsSettings = () => {
size: 'small',
}}
/>
<LabelsDrawer
drawerOpen={showDrawer}
labelId={selectedLabelId}
drawerClosed={handleDrawerClose}
/>
</Card>
);
};

View File

@@ -0,0 +1,228 @@
import { Button, Drawer, Form, Input, message, Typography, Flex, Dropdown } from '@/shared/antd-imports';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { theme } from 'antd';
import { labelsApiService } from '@/api/taskAttributes/labels/labels.api.service';
const WorklenzColorShades = {
"#154c9b": ["#0D2A50", "#112E54", "#153258", "#19365C", "#1D3A60", "#213E64", "#254268", "#29466C", "#2D4A70", "#314E74"],
"#3b7ad4": ["#224884", "#26528A", "#2A5C90", "#2E6696", "#32709C", "#367AA2", "#3A84A8", "#3E8EAE", "#4298B4", "#46A2BA"],
"#70a6f3": ["#3D5D8A", "#46679E", "#5071B2", "#597BC6", "#6385DA", "#6C8FEE", "#7699F2", "#7FA3F6", "#89ADFA", "#92B7FE"],
"#7781ca": ["#42486F", "#4C5283", "#565C97", "#6066AB", "#6A70BF", "#747AD3", "#7E84E7", "#888EFB", "#9298FF", "#9CA2FF"],
"#9877ca": ["#542D70", "#6E3A8A", "#8847A4", "#A254BE", "#BC61D8", "#D66EF2", "#E07BFC", "#EA88FF", "#F495FF", "#FEA2FF"],
"#c178c9": ["#6A2E6F", "#843B89", "#9E48A3", "#B855BD", "#D262D7", "#EC6FF1", "#F67CFB", "#FF89FF", "#FF96FF", "#FFA3FF"],
"#ee87c5": ["#832C6A", "#9D3984", "#B7469E", "#D153B8", "#EB60D2", "#FF6DEC", "#FF7AF6", "#FF87FF", "#FF94FF", "#FFA1FF"],
"#ca7881": ["#6F2C3E", "#893958", "#A34672", "#BD538C", "#D760A6", "#F16DC0", "#FB7ADA", "#FF87F4", "#FF94FF", "#FFA1FF"],
"#75c9c0": ["#3F6B66", "#497E7A", "#53918E", "#5DA4A2", "#67B7B6", "#71CBCA", "#7BDEDE", "#85F2F2", "#8FFFFF", "#99FFFF"],
"#75c997": ["#3F6B54", "#497E6A", "#53917F", "#5DA495", "#67B7AA", "#71CBBF", "#7BDED4", "#85F2E9", "#8FFFFF", "#99FFFF"],
"#80ca79": ["#456F3E", "#5A804D", "#6F935C", "#84A66B", "#99B97A", "#AECC89", "#C3DF98", "#D8F2A7", "#EDFFB6", "#FFFFC5"],
"#aacb78": ["#5F6F3E", "#7A804D", "#94935C", "#AFA66B", "#CAB97A", "#E5CC89", "#FFDF98", "#FFF2A7", "#FFFFB6", "#FFFFC5"],
"#cbbc78": ["#6F5D3E", "#8A704D", "#A4835C", "#BF966B", "#DAA97A", "#F5BC89", "#FFCF98", "#FFE2A7", "#FFF5B6", "#FFFFC5"],
"#cb9878": ["#704D3E", "#8B604D", "#A6735C", "#C1866B", "#DC997A", "#F7AC89", "#FFBF98", "#FFD2A7", "#FFE5B6", "#FFF8C5"],
"#bb774c": ["#653D27", "#80502C", "#9B6331", "#B67636", "#D1893B", "#EC9C40", "#FFAF45", "#FFC24A", "#FFD54F", "#FFE854"],
"#905b39": ["#4D2F1A", "#623C23", "#774A2C", "#8C5735", "#A1643E", "#B67147", "#CB7E50", "#E08B59", "#F59862", "#FFA56B"],
"#903737": ["#4D1A1A", "#622323", "#772C2C", "#8C3535", "#A13E3E", "#B64747", "#CB5050", "#E05959", "#F56262", "#FF6B6B"],
"#bf4949": ["#661212", "#801B1B", "#992424", "#B32D2D", "#CC3636", "#E63F3F", "#FF4848", "#FF5151", "#FF5A5A", "#FF6363"],
"#f37070": ["#853A3A", "#A04D4D", "#BA6060", "#D47373", "#EF8686", "#FF9999", "#FFA3A3", "#FFACAC", "#FFB6B6", "#FFBFBF"],
"#ff9c3c": ["#8F5614", "#AA6F1F", "#C48829", "#DFA233", "#F9BB3D", "#FFC04E", "#FFC75F", "#FFCE70", "#FFD581", "#FFDB92"],
"#fbc84c": ["#8F6D14", "#AA862F", "#C4A029", "#DFB933", "#F9D23D", "#FFD74E", "#FFDC5F", "#FFE170", "#FFE681", "#FFEB92"],
"#cbc8a1": ["#6F6D58", "#8A886F", "#A4A286", "#BFBC9D", "#DAD6B4", "#F5F0CB", "#FFFEDE", "#FFFFF2", "#FFFFCD", "#FFFFCD"],
"#a9a9a9": ["#5D5D5D", "#757575", "#8D8D8D", "#A5A5A5", "#BDBDBD", "#D5D5D5", "#EDEDED", "#F5F5F5", "#FFFFFF", "#FFFFFF"],
"#767676": ["#404040", "#4D4D4D", "#5A5A5A", "#676767", "#747474", "#818181", "#8E8E8E", "#9B9B9B", "#A8A8A8", "#B5B5B5"]
} as const;
// Flatten the color shades into a single array for the color picker
const WorklenzColorCodes = Object.values(WorklenzColorShades).flat();
type LabelsDrawerProps = {
drawerOpen: boolean;
labelId: string | null;
drawerClosed: () => void;
};
const LabelsDrawer = ({
drawerOpen = false,
labelId = null,
drawerClosed,
}: LabelsDrawerProps) => {
const { t } = useTranslation('settings/labels');
const { token } = theme.useToken();
const [form] = Form.useForm();
useEffect(() => {
if (labelId) {
getLabelById(labelId);
} else {
form.resetFields();
form.setFieldsValue({ color_code: Object.keys(WorklenzColorShades)[0] }); // Set default color
}
}, [labelId, form]);
const getLabelById = async (id: string) => {
try {
const response = await labelsApiService.getLabels();
if (response.done) {
const label = response.body.find((l: any) => l.id === id);
if (label) {
form.setFieldsValue({
name: label.name,
color_code: label.color_code
});
}
}
} catch (error) {
message.error(t('fetchLabelErrorMessage', 'Failed to fetch label'));
}
};
const handleFormSubmit = async (values: { name: string; color_code: string }) => {
try {
if (labelId) {
const response = await labelsApiService.updateLabel(labelId, {
name: values.name,
color: values.color_code,
});
if (response.done) {
message.success(t('updateLabelSuccessMessage', 'Label updated successfully'));
drawerClosed();
}
} else {
// For creating new labels, we'd need a create API endpoint
message.info(t('createNotSupported', 'Creating new labels is done through tasks'));
drawerClosed();
}
} catch (error) {
message.error(labelId ? t('updateLabelErrorMessage', 'Failed to update label') : t('createLabelErrorMessage', 'Failed to create label'));
}
};
const handleClose = () => {
form.resetFields();
drawerClosed();
};
const ColorPicker = ({ value, onChange }: { value?: string; onChange?: (color: string) => void }) => (
<Dropdown
dropdownRender={() => (
<div style={{
padding: 16,
backgroundColor: token.colorBgElevated,
borderRadius: token.borderRadius,
boxShadow: token.boxShadowSecondary,
border: `1px solid ${token.colorBorder}`,
width: 400,
maxHeight: 500,
overflowY: 'auto'
}}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(10, 1fr)',
gap: 6,
justifyItems: 'center'
}}>
{WorklenzColorCodes.map((color) => (
<div
key={color}
style={{
width: 18,
height: 18,
backgroundColor: color,
borderRadius: 2,
border: value === color ? `2px solid ${token.colorPrimary}` : `1px solid ${token.colorBorder}`,
cursor: 'pointer',
transition: 'all 0.2s ease',
flexShrink: 0
}}
onClick={() => onChange?.(color)}
onMouseEnter={(e) => {
if (value !== color) {
e.currentTarget.style.transform = 'scale(1.2)';
e.currentTarget.style.boxShadow = token.boxShadow;
e.currentTarget.style.zIndex = '10';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.zIndex = '1';
}}
/>
))}
</div>
</div>
)}
trigger={['click']}
>
<div
style={{
width: 40,
height: 40,
backgroundColor: value || Object.keys(WorklenzColorShades)[0],
borderRadius: 4,
border: `1px solid ${token.colorBorder}`,
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = token.boxShadow;
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = 'none';
}}
/>
</Dropdown>
);
return (
<Drawer
title={
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
{labelId ? t('updateLabelDrawerTitle', 'Edit Label') : t('createLabelDrawerTitle', 'Create Label')}
</Typography.Text>
}
open={drawerOpen}
onClose={handleClose}
destroyOnClose
width={400}
>
<Form form={form} layout="vertical" onFinish={handleFormSubmit}>
<Form.Item
name="name"
label={t('nameLabel', 'Name')}
rules={[
{
required: true,
message: t('nameRequiredMessage', 'Please enter a label name'),
},
]}
>
<Input placeholder={t('namePlaceholder', 'Enter label name')} />
</Form.Item>
<Form.Item
name="color_code"
label={t('colorLabel', 'Color')}
rules={[
{
required: true,
message: t('colorRequiredMessage', 'Please select a color'),
},
]}
>
<ColorPicker />
</Form.Item>
<Flex justify="end" gap={8}>
<Button onClick={handleClose}>
{t('cancelButton', 'Cancel')}
</Button>
<Button type="primary" htmlType="submit">
{labelId ? t('updateButton', 'Update') : t('createButton', 'Create')}
</Button>
</Flex>
</Form>
</Drawer>
);
};
export default LabelsDrawer;

View File

@@ -313,3 +313,293 @@ export const durations: IRPTDuration[] = [
dates: '',
},
];
export const WorklenzColorCodes = [
// Row 1: Slate/Gray spectrum
'#0f172a',
'#1e293b',
'#334155',
'#475569',
'#64748b',
'#94a3b8',
'#cbd5e1',
'#e2e8f0',
'#f1f5f9',
'#f8fafc',
'#ffffff',
'#000000',
'#1a1a1a',
'#2d2d30',
'#3e3e42',
'#525252',
// Row 2: Blue spectrum - dark to light
'#0c4a6e',
'#075985',
'#0369a1',
'#0284c7',
'#0ea5e9',
'#38bdf8',
'#7dd3fc',
'#bae6fd',
'#e0f2fe',
'#f0f9ff',
'#1e3a8a',
'#1d4ed8',
'#2563eb',
'#3b82f6',
'#60a5fa',
'#93c5fd',
// Row 3: Indigo/Violet spectrum
'#312e81',
'#3730a3',
'#4338ca',
'#4f46e5',
'#6366f1',
'#818cf8',
'#a5b4fc',
'#c7d2fe',
'#e0e7ff',
'#eef2ff',
'#581c87',
'#6b21a8',
'#7c3aed',
'#8b5cf6',
'#a78bfa',
'#c4b5fd',
// Row 4: Purple/Fuchsia spectrum
'#701a75',
'#86198f',
'#a21caf',
'#c026d3',
'#d946ef',
'#e879f9',
'#f0abfc',
'#f3e8ff',
'#faf5ff',
'#fdf4ff',
'#831843',
'#be185d',
'#e11d48',
'#f43f5e',
'#fb7185',
'#fda4af',
// Row 5: Pink/Rose spectrum
'#9f1239',
'#be123c',
'#e11d48',
'#f43f5e',
'#fb7185',
'#fda4af',
'#fecdd3',
'#fed7d7',
'#fef2f2',
'#fff1f2',
'#450a0a',
'#7f1d1d',
'#991b1b',
'#dc2626',
'#ef4444',
'#f87171',
// Row 6: Red spectrum
'#7f1d1d',
'#991b1b',
'#dc2626',
'#ef4444',
'#f87171',
'#fca5a5',
'#fecaca',
'#fef2f2',
'#fffbeb',
'#fefce8',
'#92400e',
'#a16207',
'#ca8a04',
'#eab308',
'#facc15',
'#fef08a',
// Row 7: Orange spectrum
'#9a3412',
'#c2410c',
'#ea580c',
'#f97316',
'#fb923c',
'#fdba74',
'#fed7aa',
'#ffedd5',
'#fff7ed',
'#fffbeb',
'#78350f',
'#92400e',
'#c2410c',
'#ea580c',
'#f97316',
'#fb923c',
// Row 8: Amber/Yellow spectrum
'#451a03',
'#78350f',
'#92400e',
'#a16207',
'#ca8a04',
'#eab308',
'#facc15',
'#fef08a',
'#fefce8',
'#fffbeb',
'#365314',
'#4d7c0f',
'#65a30d',
'#84cc16',
'#a3e635',
'#bef264',
// Row 9: Lime/Green spectrum
'#1a2e05',
'#365314',
'#4d7c0f',
'#65a30d',
'#84cc16',
'#a3e635',
'#bef264',
'#d9f99d',
'#ecfccb',
'#f7fee7',
'#14532d',
'#166534',
'#15803d',
'#16a34a',
'#22c55e',
'#4ade80',
// Row 10: Emerald spectrum
'#064e3b',
'#065f46',
'#047857',
'#059669',
'#10b981',
'#34d399',
'#6ee7b7',
'#a7f3d0',
'#d1fae5',
'#ecfdf5',
'#0f766e',
'#0d9488',
'#14b8a6',
'#2dd4bf',
'#5eead4',
'#99f6e4',
// Row 11: Teal/Cyan spectrum
'#134e4a',
'#155e75',
'#0891b2',
'#0e7490',
'#0284c7',
'#0ea5e9',
'#22d3ee',
'#67e8f9',
'#a5f3fc',
'#cffafe',
'#164e63',
'#0c4a6e',
'#075985',
'#0369a1',
'#0284c7',
'#0ea5e9',
// Row 12: Sky spectrum
'#0c4a6e',
'#075985',
'#0369a1',
'#0284c7',
'#0ea5e9',
'#38bdf8',
'#7dd3fc',
'#bae6fd',
'#e0f2fe',
'#f0f9ff',
'#1e40af',
'#1d4ed8',
'#2563eb',
'#3b82f6',
'#60a5fa',
'#93c5fd',
// Row 13: Warm grays and browns
'#292524',
'#44403c',
'#57534e',
'#78716c',
'#a8a29e',
'#d6d3d1',
'#e7e5e4',
'#f5f5f4',
'#fafaf9',
'#ffffff',
'#7c2d12',
'#9a3412',
'#c2410c',
'#ea580c',
'#f97316',
'#fb923c',
// Row 14: Cool grays
'#111827',
'#1f2937',
'#374151',
'#4b5563',
'#6b7280',
'#9ca3af',
'#d1d5db',
'#e5e7eb',
'#f3f4f6',
'#f9fafb',
'#030712',
'#0c0a09',
'#1c1917',
'#292524',
'#44403c',
'#57534e',
// Row 15: Neutral spectrum
'#171717',
'#262626',
'#404040',
'#525252',
'#737373',
'#a3a3a3',
'#d4d4d4',
'#e5e5e5',
'#f5f5f5',
'#fafafa',
'#09090b',
'#18181b',
'#27272a',
'#3f3f46',
'#52525b',
'#71717a',
// Row 16: Extended colors
'#a1a1aa',
'#d4d4d8',
'#e4e4e7',
'#f4f4f5',
'#fafafa',
'#27272a',
'#3f3f46',
'#52525b',
'#71717a',
'#a1a1aa',
'#d4d4d8',
'#e4e4e7',
'#f4f4f5',
'#fafafa',
'#ffffff',
'#000000',
];

View File

@@ -198,6 +198,17 @@ export class ServiceWorkerManager {
}
}
// Check for updates
async checkForUpdates(): Promise<boolean> {
try {
const response = await this.sendMessage('CHECK_FOR_UPDATES');
return response.hasUpdates;
} catch (error) {
console.error('Failed to check for updates:', error);
return false;
}
}
// Force update service worker
async forceUpdate(): Promise<void> {
if (!this.registration) return;
@@ -212,6 +223,27 @@ export class ServiceWorkerManager {
}
}
// Perform hard reload (clear cache and reload)
async hardReload(): Promise<void> {
try {
// Clear all caches first
await this.clearCache();
// Force update the service worker
if (this.registration) {
await this.registration.update();
await this.sendMessage('SKIP_WAITING');
}
// Perform hard reload by clearing browser cache
window.location.reload();
} catch (error) {
console.error('Failed to perform hard reload:', error);
// Fallback to regular reload
window.location.reload();
}
}
// Check if app is running offline
isOffline(): boolean {
return !navigator.onLine;
@@ -268,6 +300,8 @@ export function useServiceWorker() {
swManager,
clearCache: () => swManager?.clearCache(),
forceUpdate: () => swManager?.forceUpdate(),
hardReload: () => swManager?.hardReload(),
checkForUpdates: () => swManager?.checkForUpdates(),
getVersion: () => swManager?.getVersion(),
};
}

View File

@@ -1,141 +0,0 @@
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);
}
}