Compare commits
1 Commits
chore/adde
...
chore/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5e56e48f8 |
220
docs/SPAM_PROTECTION_GUIDE.md
Normal file
220
docs/SPAM_PROTECTION_GUIDE.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# Worklenz Spam Protection System Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This guide documents the spam protection system implemented in Worklenz to prevent abuse of user invitations and registrations.
|
||||
|
||||
## System Components
|
||||
|
||||
### 1. Spam Detection (`/worklenz-backend/src/utils/spam-detector.ts`)
|
||||
|
||||
The core spam detection engine that analyzes text for suspicious patterns:
|
||||
|
||||
- **Flag-First Policy**: Suspicious content is flagged for review, not blocked
|
||||
- **Selective Blocking**: Only extremely obvious spam (score > 80) gets blocked
|
||||
- **URL Detection**: Identifies links, shortened URLs, and suspicious domains
|
||||
- **Spam Phrases**: Detects common spam tactics (urgent, click here, win prizes)
|
||||
- **Cryptocurrency Spam**: Identifies blockchain/crypto compensation scams
|
||||
- **Formatting Issues**: Excessive capitals, special characters, emojis
|
||||
- **Fake Name Detection**: Generic names (test, demo, fake, spam)
|
||||
- **Whitelist Support**: Legitimate business names bypass all checks
|
||||
- **Context-Aware**: Smart detection reduces false positives
|
||||
|
||||
### 2. Rate Limiting (`/worklenz-backend/src/middleware/rate-limiter.ts`)
|
||||
|
||||
Prevents volume-based attacks:
|
||||
|
||||
- **Invite Limits**: 5 invitations per 15 minutes per user
|
||||
- **Organization Creation**: 3 attempts per hour
|
||||
- **In-Memory Store**: Fast rate limit checking without database queries
|
||||
|
||||
### 3. Frontend Validation
|
||||
|
||||
Real-time feedback as users type:
|
||||
|
||||
- `/worklenz-frontend/src/components/account-setup/organization-step.tsx`
|
||||
- `/worklenz-frontend/src/components/admin-center/overview/organization-name/organization-name.tsx`
|
||||
- `/worklenz-frontend/src/components/settings/edit-team-name-modal.tsx`
|
||||
|
||||
### 4. Backend Enforcement
|
||||
|
||||
Blocks spam at API level:
|
||||
|
||||
- **Team Members Controller**: Validates organization/owner names before invites
|
||||
- **Signup Process**: Blocks spam during registration
|
||||
- **Logging**: All blocked attempts sent to Slack via winston logger
|
||||
|
||||
### 5. Database Schema
|
||||
|
||||
```sql
|
||||
-- Teams table: Simple status field
|
||||
ALTER TABLE teams ADD COLUMN status VARCHAR(20) DEFAULT 'active';
|
||||
|
||||
-- Moderation history tracking
|
||||
CREATE TABLE team_moderation (
|
||||
id UUID PRIMARY KEY,
|
||||
team_id UUID REFERENCES teams(id),
|
||||
status VARCHAR(20), -- 'flagged', 'suspended', 'restored'
|
||||
reason TEXT,
|
||||
moderator_id UUID,
|
||||
created_at TIMESTAMP,
|
||||
expires_at TIMESTAMP -- For temporary suspensions
|
||||
);
|
||||
|
||||
-- Spam detection logs
|
||||
CREATE TABLE spam_logs (
|
||||
id UUID PRIMARY KEY,
|
||||
team_id UUID,
|
||||
content_type VARCHAR(50),
|
||||
original_content TEXT,
|
||||
spam_score INTEGER,
|
||||
spam_reasons JSONB,
|
||||
action_taken VARCHAR(50)
|
||||
);
|
||||
```
|
||||
|
||||
## Admin Tools
|
||||
|
||||
### API Endpoints
|
||||
|
||||
```
|
||||
GET /api/moderation/flagged-organizations - View flagged teams
|
||||
POST /api/moderation/flag-organization - Manually flag a team
|
||||
POST /api/moderation/suspend-organization - Suspend a team
|
||||
POST /api/moderation/unsuspend-organization - Restore a team
|
||||
GET /api/moderation/scan-spam - Scan for spam in existing data
|
||||
GET /api/moderation/stats - View moderation statistics
|
||||
POST /api/moderation/bulk-scan - Bulk scan and auto-flag
|
||||
```
|
||||
|
||||
## Slack Notifications
|
||||
|
||||
The system sends structured alerts to Slack for:
|
||||
|
||||
- 🚨 **Spam Detected** (score > 30)
|
||||
- 🔥 **High Risk Content** (known spam domains)
|
||||
- 🛑 **Blocked Attempts** (invitations/signups)
|
||||
- ⚠️ **Rate Limit Exceeded**
|
||||
|
||||
Example Slack notification:
|
||||
```json
|
||||
{
|
||||
"alert_type": "high_risk_content",
|
||||
"team_name": "CLICK LINK: gclnk.com/spam",
|
||||
"user_email": "spammer@example.com",
|
||||
"spam_score": 95,
|
||||
"reasons": ["Contains suspicious URLs", "Contains monetary references"],
|
||||
"timestamp": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Testing the System
|
||||
|
||||
### Test Spam Patterns
|
||||
|
||||
These will be **FLAGGED** for review (flag-first approach):
|
||||
|
||||
1. **Suspicious Words**: "Free Software Solutions" (flagged but allowed)
|
||||
2. **URLs**: "Visit our site: bit.ly/win-prize" (flagged but allowed)
|
||||
3. **Cryptocurrency**: "🔔 $50,000 BLOCKCHAIN COMPENSATION" (flagged but allowed)
|
||||
4. **Urgency**: "URGENT! Click here NOW!!!" (flagged but allowed)
|
||||
5. **Generic Names**: "Test Company", "Demo Organization" (flagged but allowed)
|
||||
6. **Excessive Numbers**: "Company12345" (flagged but allowed)
|
||||
7. **Single Emoji**: "Great Company 💰" (flagged but allowed)
|
||||
|
||||
### BLOCKED Patterns (zero-tolerance - score > 80):
|
||||
|
||||
1. **Known Spam Domains**: "CLICK LINK: gclnk.com/spam"
|
||||
2. **Extreme Scam Patterns**: "🔔CHECK $213,953 BLOCKCHAIN COMPENSATION URGENT🔔"
|
||||
3. **Obvious Spam URLs**: Content with bit.ly/scam patterns
|
||||
|
||||
### Whitelisted (Will NOT be flagged):
|
||||
|
||||
1. **Legitimate Business**: "Microsoft Corporation", "Free Software Company"
|
||||
2. **Standard Suffixes**: "ABC Solutions Inc", "XYZ Consulting LLC"
|
||||
3. **Tech Companies**: "DataTech Services", "The Design Studio"
|
||||
4. **Context-Aware**: "Free Range Marketing", "Check Point Systems"
|
||||
5. **Legitimate "Test"**: "TestDrive Automotive" (not generic)
|
||||
|
||||
### Expected Behavior
|
||||
|
||||
1. **Suspicious Signup**: Flagged in logs, user allowed to proceed
|
||||
2. **Obvious Spam Signup**: Blocked with user-friendly message
|
||||
3. **Suspicious Invitations**: Flagged in logs, invitation sent
|
||||
4. **Obvious Spam Invitations**: Blocked with support contact suggestion
|
||||
5. **Frontend**: Shows warning message for suspicious content
|
||||
6. **Logger**: Sends Slack notification for all suspicious activity
|
||||
7. **Database**: Records all activity in spam_logs table
|
||||
|
||||
## Database Migration
|
||||
|
||||
Run these SQL scripts in order:
|
||||
|
||||
1. `spam_protection_tables.sql` - Creates new schema
|
||||
2. `fix_spam_protection_constraints.sql` - Fixes notification_settings constraints
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
No additional environment variables required. The system uses existing:
|
||||
- `COOKIE_SECRET` - For session management
|
||||
- Database connection settings
|
||||
|
||||
### Adjusting Thresholds
|
||||
|
||||
In `spam-detector.ts`:
|
||||
```typescript
|
||||
const isSpam = score >= 50; // Adjust threshold here
|
||||
```
|
||||
|
||||
In `rate-limiter.ts`:
|
||||
```typescript
|
||||
inviteRateLimit(5, 15 * 60 * 1000) // 5 requests per 15 minutes
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Check Spam Statistics
|
||||
```sql
|
||||
SELECT * FROM moderation_dashboard;
|
||||
SELECT COUNT(*) FROM spam_logs WHERE created_at > NOW() - INTERVAL '24 hours';
|
||||
```
|
||||
|
||||
### View Rate Limit Events
|
||||
```sql
|
||||
SELECT * FROM rate_limit_log WHERE blocked = true ORDER BY created_at DESC;
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: Legitimate users blocked
|
||||
|
||||
1. Check spam_logs for their content
|
||||
2. Adjust spam patterns or scoring threshold
|
||||
3. Whitelist specific domains if needed
|
||||
|
||||
### Issue: Notification settings error during signup
|
||||
|
||||
Run the fix script: `fix_spam_protection_constraints.sql`
|
||||
|
||||
### Issue: Slack notifications not received
|
||||
|
||||
1. Check winston logger configuration
|
||||
2. Verify log levels in `logger.ts`
|
||||
3. Ensure Slack webhook is configured
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Machine Learning**: Train on spam_logs data
|
||||
2. **IP Blocking**: Geographic or reputation-based blocking
|
||||
3. **CAPTCHA Integration**: For suspicious signups
|
||||
4. **Email Verification**: Stronger email validation
|
||||
5. **Allowlist Management**: Pre-approved domains
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Logs contain sensitive data - ensure proper access controls
|
||||
- Rate limit data stored in memory - consider Redis for scaling
|
||||
- Spam patterns should be regularly updated
|
||||
- Monitor for false positives and adjust accordingly
|
||||
41
test_sort_fix.sql
Normal file
41
test_sort_fix.sql
Normal file
@@ -0,0 +1,41 @@
|
||||
-- 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';
|
||||
30
test_sort_orders.sql
Normal file
30
test_sort_orders.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- 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;
|
||||
*/
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Fix for notification_settings constraint issue during signup
|
||||
-- This makes the team_id nullable temporarily during user creation
|
||||
|
||||
-- First, drop the existing NOT NULL constraint
|
||||
ALTER TABLE notification_settings
|
||||
ALTER COLUMN team_id DROP NOT NULL;
|
||||
|
||||
-- Add a constraint that ensures team_id is not null when there's no ongoing signup
|
||||
ALTER TABLE notification_settings
|
||||
ADD CONSTRAINT notification_settings_team_id_check
|
||||
CHECK (team_id IS NOT NULL OR user_id IS NOT NULL);
|
||||
|
||||
-- Update the notification_settings trigger to handle null team_id gracefully
|
||||
CREATE OR REPLACE FUNCTION notification_settings_insert_trigger_fn() RETURNS TRIGGER AS
|
||||
$$
|
||||
BEGIN
|
||||
-- Only insert if team_id is not null
|
||||
IF NEW.team_id IS NOT NULL AND
|
||||
(NOT EXISTS(SELECT 1 FROM notification_settings WHERE team_id = NEW.team_id AND user_id = NEW.user_id)) AND
|
||||
(NEW.active = TRUE)
|
||||
THEN
|
||||
INSERT INTO notification_settings (popup_notifications_enabled, show_unread_items_count, user_id,
|
||||
email_notifications_enabled, team_id, daily_digest_enabled)
|
||||
VALUES (TRUE, TRUE, NEW.user_id, TRUE, NEW.team_id, FALSE);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Also update the teams table to ensure the status column doesn't interfere with signup
|
||||
ALTER TABLE teams
|
||||
DROP CONSTRAINT IF EXISTS teams_status_check;
|
||||
|
||||
ALTER TABLE teams
|
||||
ADD CONSTRAINT teams_status_check
|
||||
CHECK (status IS NULL OR status IN ('active', 'flagged', 'suspended'));
|
||||
|
||||
-- Set default value for status
|
||||
ALTER TABLE teams
|
||||
ALTER COLUMN status SET DEFAULT 'active';
|
||||
|
||||
-- Update existing null values
|
||||
UPDATE teams SET status = 'active' WHERE status IS NULL;
|
||||
220
worklenz-backend/database/sql/spam_protection_tables.sql
Normal file
220
worklenz-backend/database/sql/spam_protection_tables.sql
Normal file
@@ -0,0 +1,220 @@
|
||||
-- Add minimal status column to teams table for performance
|
||||
ALTER TABLE teams
|
||||
ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'flagged', 'suspended'));
|
||||
|
||||
-- Create separate moderation table for detailed tracking
|
||||
CREATE TABLE IF NOT EXISTS team_moderation (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
status VARCHAR(20) NOT NULL CHECK (status IN ('flagged', 'suspended', 'restored')),
|
||||
reason TEXT,
|
||||
moderator_id UUID REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
expires_at TIMESTAMP, -- For temporary suspensions
|
||||
metadata JSONB -- For additional context
|
||||
);
|
||||
|
||||
-- Create indexes for efficient querying
|
||||
CREATE INDEX IF NOT EXISTS idx_teams_status ON teams(status, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_moderation_team_id ON team_moderation(team_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_moderation_status ON team_moderation(status, created_at);
|
||||
|
||||
-- Create spam_logs table to track spam detection events
|
||||
CREATE TABLE IF NOT EXISTS spam_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
content_type VARCHAR(50) NOT NULL, -- 'organization_name', 'owner_name', 'invitation'
|
||||
original_content TEXT NOT NULL,
|
||||
sanitized_content TEXT,
|
||||
spam_score INTEGER NOT NULL DEFAULT 0,
|
||||
spam_reasons JSONB,
|
||||
is_high_risk BOOLEAN DEFAULT FALSE,
|
||||
action_taken VARCHAR(50), -- 'blocked', 'flagged', 'allowed'
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
ip_address INET
|
||||
);
|
||||
|
||||
-- Create index for spam logs
|
||||
CREATE INDEX IF NOT EXISTS idx_spam_logs_team_id ON spam_logs(team_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_spam_logs_created_at ON spam_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_spam_logs_content_type ON spam_logs(content_type);
|
||||
|
||||
-- Create rate_limit_log table to track rate limiting events
|
||||
CREATE TABLE IF NOT EXISTS rate_limit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
ip_address INET NOT NULL,
|
||||
action_type VARCHAR(50) NOT NULL, -- 'invite_attempt', 'org_creation'
|
||||
blocked BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create index for rate limit logs
|
||||
CREATE INDEX IF NOT EXISTS idx_rate_limit_log_user_id ON rate_limit_log(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_rate_limit_log_created_at ON rate_limit_log(created_at);
|
||||
|
||||
-- Add admin flag to users table if it doesn't exist
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS is_admin BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- Function to log spam detection
|
||||
CREATE OR REPLACE FUNCTION log_spam_detection(
|
||||
p_team_id UUID,
|
||||
p_user_id UUID,
|
||||
p_content_type VARCHAR(50),
|
||||
p_original_content TEXT,
|
||||
p_sanitized_content TEXT,
|
||||
p_spam_score INTEGER,
|
||||
p_spam_reasons JSONB,
|
||||
p_is_high_risk BOOLEAN,
|
||||
p_action_taken VARCHAR(50),
|
||||
p_ip_address INET
|
||||
) RETURNS VOID AS $$
|
||||
BEGIN
|
||||
INSERT INTO spam_logs (
|
||||
team_id, user_id, content_type, original_content, sanitized_content,
|
||||
spam_score, spam_reasons, is_high_risk, action_taken, ip_address
|
||||
) VALUES (
|
||||
p_team_id, p_user_id, p_content_type, p_original_content, p_sanitized_content,
|
||||
p_spam_score, p_spam_reasons, p_is_high_risk, p_action_taken, p_ip_address
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Function to log rate limiting events
|
||||
CREATE OR REPLACE FUNCTION log_rate_limit_event(
|
||||
p_user_id UUID,
|
||||
p_ip_address INET,
|
||||
p_action_type VARCHAR(50),
|
||||
p_blocked BOOLEAN
|
||||
) RETURNS VOID AS $$
|
||||
BEGIN
|
||||
INSERT INTO rate_limit_log (user_id, ip_address, action_type, blocked)
|
||||
VALUES (p_user_id, p_ip_address, p_action_type, p_blocked);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Function to get spam statistics for a team
|
||||
CREATE OR REPLACE FUNCTION get_team_spam_stats(p_team_id UUID)
|
||||
RETURNS TABLE (
|
||||
total_detections BIGINT,
|
||||
high_risk_detections BIGINT,
|
||||
blocked_actions BIGINT,
|
||||
latest_detection TIMESTAMP
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
COUNT(*) as total_detections,
|
||||
COUNT(*) FILTER (WHERE is_high_risk = TRUE) as high_risk_detections,
|
||||
COUNT(*) FILTER (WHERE action_taken = 'blocked') as blocked_actions,
|
||||
MAX(created_at) as latest_detection
|
||||
FROM spam_logs
|
||||
WHERE team_id = p_team_id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- View for easy moderation dashboard
|
||||
CREATE OR REPLACE VIEW moderation_dashboard AS
|
||||
SELECT
|
||||
t.id as team_id,
|
||||
t.name as organization_name,
|
||||
u.name as owner_name,
|
||||
u.email as owner_email,
|
||||
t.created_at as team_created_at,
|
||||
t.status as current_status,
|
||||
tm.status as last_moderation_action,
|
||||
tm.reason as last_moderation_reason,
|
||||
tm.created_at as last_moderation_date,
|
||||
tm.expires_at as suspension_expires_at,
|
||||
moderator.name as moderator_name,
|
||||
(SELECT COUNT(*) FROM team_members WHERE team_id = t.id) as member_count,
|
||||
(SELECT COUNT(*) FROM spam_logs WHERE team_id = t.id) as spam_detection_count,
|
||||
(SELECT COUNT(*) FROM spam_logs WHERE team_id = t.id AND is_high_risk = TRUE) as high_risk_count
|
||||
FROM teams t
|
||||
INNER JOIN users u ON t.user_id = u.id
|
||||
LEFT JOIN team_moderation tm ON t.id = tm.team_id
|
||||
AND tm.created_at = (SELECT MAX(created_at) FROM team_moderation WHERE team_id = t.id)
|
||||
LEFT JOIN users moderator ON tm.moderator_id = moderator.id
|
||||
WHERE t.status != 'active' OR EXISTS(
|
||||
SELECT 1 FROM spam_logs WHERE team_id = t.id AND created_at > NOW() - INTERVAL '7 days'
|
||||
);
|
||||
|
||||
-- Function to update team status and create moderation records
|
||||
CREATE OR REPLACE FUNCTION update_team_status(
|
||||
p_team_id UUID,
|
||||
p_new_status VARCHAR(20),
|
||||
p_reason TEXT,
|
||||
p_moderator_id UUID DEFAULT NULL,
|
||||
p_expires_at TIMESTAMP DEFAULT NULL
|
||||
) RETURNS VOID AS $$
|
||||
BEGIN
|
||||
-- Update team status
|
||||
UPDATE teams SET status = p_new_status WHERE id = p_team_id;
|
||||
|
||||
-- Insert moderation record
|
||||
INSERT INTO team_moderation (team_id, status, reason, moderator_id, expires_at)
|
||||
VALUES (p_team_id, p_new_status, p_reason, p_moderator_id, p_expires_at);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Trigger to automatically flag teams with high spam scores
|
||||
CREATE OR REPLACE FUNCTION auto_flag_spam_teams()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Auto-flag teams if they have high spam scores or multiple violations
|
||||
IF NEW.spam_score > 80 OR NEW.is_high_risk = TRUE THEN
|
||||
PERFORM update_team_status(
|
||||
NEW.team_id,
|
||||
'flagged',
|
||||
'Auto-flagged: High spam score or high-risk content detected',
|
||||
NULL
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Function to check and restore expired suspensions
|
||||
CREATE OR REPLACE FUNCTION restore_expired_suspensions() RETURNS VOID AS $$
|
||||
BEGIN
|
||||
-- Find teams with expired suspensions
|
||||
UPDATE teams
|
||||
SET status = 'active'
|
||||
WHERE id IN (
|
||||
SELECT DISTINCT tm.team_id
|
||||
FROM team_moderation tm
|
||||
WHERE tm.status = 'suspended'
|
||||
AND tm.expires_at IS NOT NULL
|
||||
AND tm.expires_at < NOW()
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM team_moderation tm2
|
||||
WHERE tm2.team_id = tm.team_id
|
||||
AND tm2.created_at > tm.created_at
|
||||
)
|
||||
);
|
||||
|
||||
-- Log restoration records
|
||||
INSERT INTO team_moderation (team_id, status, reason, moderator_id)
|
||||
SELECT DISTINCT tm.team_id, 'restored', 'Auto-restored: suspension expired', NULL
|
||||
FROM team_moderation tm
|
||||
WHERE tm.status = 'suspended'
|
||||
AND tm.expires_at IS NOT NULL
|
||||
AND tm.expires_at < NOW()
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM team_moderation tm2
|
||||
WHERE tm2.team_id = tm.team_id
|
||||
AND tm2.created_at > tm.created_at
|
||||
AND tm2.status = 'restored'
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create trigger for auto-flagging
|
||||
DROP TRIGGER IF EXISTS trigger_auto_flag_spam ON spam_logs;
|
||||
CREATE TRIGGER trigger_auto_flag_spam
|
||||
AFTER INSERT ON spam_logs
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION auto_flag_spam_teams();
|
||||
473
worklenz-backend/package-lock.json
generated
473
worklenz-backend/package-lock.json
generated
@@ -33,6 +33,7 @@
|
||||
"express-rate-limit": "^6.8.0",
|
||||
"express-session": "^1.17.3",
|
||||
"express-validator": "^6.15.0",
|
||||
"grunt-cli": "^1.5.0",
|
||||
"helmet": "^6.2.0",
|
||||
"hpp": "^0.2.3",
|
||||
"http-errors": "^2.0.0",
|
||||
@@ -45,7 +46,6 @@
|
||||
"morgan": "^1.10.0",
|
||||
"nanoid": "^3.3.6",
|
||||
"passport": "^0.7.0",
|
||||
"passport-custom": "^1.1.1",
|
||||
"passport-google-oauth2": "^0.2.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-local": "^1.0.0",
|
||||
@@ -73,7 +73,6 @@
|
||||
"@types/compression": "^1.7.2",
|
||||
"@types/connect-flash": "^0.0.37",
|
||||
"@types/cookie-parser": "^1.4.3",
|
||||
"@types/cookie-signature": "^1.1.2",
|
||||
"@types/cron": "^2.0.1",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/csurf": "^1.11.2",
|
||||
@@ -127,7 +126,7 @@
|
||||
"typescript": "^4.9.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
"node": ">=16.13.0",
|
||||
"npm": ">=8.11.0",
|
||||
"yarn": "WARNING: Please use npm package manager instead of yarn"
|
||||
}
|
||||
@@ -5446,16 +5445,6 @@
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie-signature": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie-signature/-/cookie-signature-1.1.2.tgz",
|
||||
"integrity": "sha512-2OhrZV2LVnUAXklUFwuYUTokalh/dUb8rqt70OW6ByMSxYpauPZ+kfNLknX3aJyjY5iu8i3cUyoLZP9Fn37tTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cors": {
|
||||
"version": "2.8.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||
@@ -6466,12 +6455,30 @@
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/array-each": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/array-each/-/array-each-1.0.1.tgz",
|
||||
"integrity": "sha512-zHjL5SZa68hkKHBFBK6DJCTtr9sfTCPCaph/L7tMSLcTFgy+zX7E+6q5UArbtOtMBCtxdICpfTCspRse+ywyXA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/array-slice": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/array-slice/-/array-slice-1.1.0.tgz",
|
||||
"integrity": "sha512-B1qMD3RBP7O8o0H2KbrXDyB0IccejMF15+87Lvlor12ONPRHP6gTjXMNkt/d3ZuOGbAe66hFmaCfECI24Ufp6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/array-union": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
|
||||
@@ -6944,7 +6951,6 @@
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
@@ -8050,6 +8056,15 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-file": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz",
|
||||
"integrity": "sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
|
||||
@@ -8909,6 +8924,18 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/expand-tilde": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz",
|
||||
"integrity": "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"homedir-polyfill": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect": {
|
||||
"version": "28.1.3",
|
||||
"resolved": "https://registry.npmjs.org/expect/-/expect-28.1.3.tgz",
|
||||
@@ -9061,6 +9088,12 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-csv": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz",
|
||||
@@ -9189,7 +9222,6 @@
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
@@ -9255,6 +9287,46 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/findup-sync": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-4.0.0.tgz",
|
||||
"integrity": "sha512-6jvvn/12IC4quLBL1KNokxC7wWTvYncaVUYSoxWw7YykPLuRrnv4qdHcSOywOI5RpkOVGeQRtWM8/q+G6W6qfQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-file": "^1.0.0",
|
||||
"is-glob": "^4.0.0",
|
||||
"micromatch": "^4.0.2",
|
||||
"resolve-dir": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/fined": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fined/-/fined-1.2.0.tgz",
|
||||
"integrity": "sha512-ZYDqPLGxDkDhDZBjZBb+oD1+j0rA4E0pXY50eplAAOPg2N/gUBSSk5IM1/QhPfyVo19lJ+CvXpqfvk+b2p/8Ng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"expand-tilde": "^2.0.2",
|
||||
"is-plain-object": "^2.0.3",
|
||||
"object.defaults": "^1.1.0",
|
||||
"object.pick": "^1.2.0",
|
||||
"parse-filepath": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/flagged-respawn": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-1.0.1.tgz",
|
||||
"integrity": "sha512-lNaHNVymajmk0OJMBn8fVUAU1BtDeKIqKoVhk4xAALB57aALg6b4W0MfJ/cUE0g9YBXy5XhSlPIpYIJ7HaY/3Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/flat-cache": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
|
||||
@@ -9355,6 +9427,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/for-in": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
|
||||
"integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/for-own": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/for-own/-/for-own-1.0.0.tgz",
|
||||
"integrity": "sha512-0OABksIGrxKK8K4kynWkQ7y1zounQxP+CWnyclVwj81KW3vlLlGUx57DKGcP/LH216GzqnstnPocF16Nxs0Ycg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"for-in": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
@@ -9752,6 +9845,48 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/global-modules": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz",
|
||||
"integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"global-prefix": "^1.0.1",
|
||||
"is-windows": "^1.0.1",
|
||||
"resolve-dir": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/global-prefix": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz",
|
||||
"integrity": "sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"expand-tilde": "^2.0.2",
|
||||
"homedir-polyfill": "^1.0.1",
|
||||
"ini": "^1.3.4",
|
||||
"is-windows": "^1.0.1",
|
||||
"which": "^1.2.14"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/global-prefix/node_modules/which": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
|
||||
"integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"which": "bin/which"
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "11.12.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
|
||||
@@ -9808,6 +9943,34 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/grunt-cli": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/grunt-cli/-/grunt-cli-1.5.0.tgz",
|
||||
"integrity": "sha512-rILKAFoU0dzlf22SUfDtq2R1fosChXXlJM5j7wI6uoW8gwmXDXzbUvirlKZSYCdXl3LXFbR+8xyS+WFo+b6vlA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"grunt-known-options": "~2.0.0",
|
||||
"interpret": "~1.1.0",
|
||||
"liftup": "~3.0.1",
|
||||
"nopt": "~5.0.0",
|
||||
"v8flags": "^4.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"grunt": "bin/grunt"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/grunt-known-options": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/grunt-known-options/-/grunt-known-options-2.0.0.tgz",
|
||||
"integrity": "sha512-GD7cTz0I4SAede1/+pAbmJRG44zFLPipVtdL9o3vqx9IEyb7b4/Y3s7r6ofI3CchR5GvYJ+8buCSioDv5dQLiA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
@@ -9879,6 +10042,18 @@
|
||||
"dev": true,
|
||||
"license": "https://www.highcharts.com/license"
|
||||
},
|
||||
"node_modules/homedir-polyfill": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz",
|
||||
"integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parse-passwd": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hpp": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/hpp/-/hpp-0.2.3.tgz",
|
||||
@@ -10088,6 +10263,12 @@
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/interpret": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/interpret/-/interpret-1.1.0.tgz",
|
||||
"integrity": "sha512-CLM8SNMDu7C5psFCn6Wg/tgpj/bKAg7hc2gWqcuR9OD5Ft9PhBpIu8PLicPeis+xDd6YX2ncI8MCA64I9tftIA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -10097,6 +10278,19 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/is-absolute": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz",
|
||||
"integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-relative": "^1.0.0",
|
||||
"is-windows": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arrayish": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||
@@ -10158,7 +10352,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -10187,7 +10380,6 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
@@ -10200,7 +10392,6 @@
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
@@ -10216,6 +10407,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-object": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
|
||||
"integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"isobject": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-promise": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
|
||||
@@ -10240,6 +10443,18 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-relative": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz",
|
||||
"integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-unc-path": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-stream": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||
@@ -10252,6 +10467,27 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-unc-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz",
|
||||
"integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"unc-path-regex": "^0.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-windows": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz",
|
||||
"integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
@@ -10262,9 +10498,17 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/isobject": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
|
||||
"integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||
@@ -11282,6 +11526,15 @@
|
||||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/kind-of": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
||||
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
||||
@@ -11373,6 +11626,25 @@
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/liftup": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/liftup/-/liftup-3.0.1.tgz",
|
||||
"integrity": "sha512-yRHaiQDizWSzoXk3APcA71eOI/UuhEkNN9DiW2Tt44mhYzX4joFoCZlxsSOF7RyeLlfqzFLQI1ngFq3ggMPhOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"extend": "^3.0.2",
|
||||
"findup-sync": "^4.0.0",
|
||||
"fined": "^1.2.0",
|
||||
"flagged-respawn": "^1.0.1",
|
||||
"is-plain-object": "^2.0.4",
|
||||
"object.map": "^1.0.1",
|
||||
"rechoir": "^0.7.0",
|
||||
"resolve": "^1.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
@@ -11611,6 +11883,18 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/make-iterator": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/make-iterator/-/make-iterator-1.0.1.tgz",
|
||||
"integrity": "sha512-pxiuXh0iVEq7VM7KMIhs5gxsfxCux2URptUQaXo4iZZJxBAzTPOLE2BumO5dbfVYq/hBJFBR/a1mFDmOx5AGmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"kind-of": "^6.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/makeerror": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
|
||||
@@ -11621,6 +11905,15 @@
|
||||
"tmpl": "1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/map-cache": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz",
|
||||
"integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -11678,7 +11971,6 @@
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
@@ -12126,6 +12418,46 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/object.defaults": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/object.defaults/-/object.defaults-1.1.0.tgz",
|
||||
"integrity": "sha512-c/K0mw/F11k4dEUBMW8naXUuBuhxRCfG7W+yFy8EcijU/rSmazOUd1XAEEe6bC0OuXY4HUKjTJv7xbxIMqdxrA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"array-each": "^1.0.1",
|
||||
"array-slice": "^1.0.0",
|
||||
"for-own": "^1.0.0",
|
||||
"isobject": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object.map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/object.map/-/object.map-1.0.1.tgz",
|
||||
"integrity": "sha512-3+mAJu2PLfnSVGHwIWubpOFLscJANBKuB/6A4CxBstc4aqwQY0FWcsppuy4jU5GSB95yES5JHSI+33AWuS4k6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"for-own": "^1.0.0",
|
||||
"make-iterator": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object.pick": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz",
|
||||
"integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"isobject": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
@@ -12288,6 +12620,20 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-filepath": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz",
|
||||
"integrity": "sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-absolute": "^1.0.0",
|
||||
"map-cache": "^0.2.0",
|
||||
"path-root": "^0.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-json": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
|
||||
@@ -12307,6 +12653,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-passwd": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz",
|
||||
"integrity": "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-srcset": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
|
||||
@@ -12340,18 +12695,6 @@
|
||||
"url": "https://github.com/sponsors/jaredhanson"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-custom": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/passport-custom/-/passport-custom-1.1.1.tgz",
|
||||
"integrity": "sha512-/2m7jUGxmCYvoqenLB9UrmkCgPt64h8ZtV+UtuQklZ/Tn1NpKBeOorCYkB/8lMRoiZ5hUrCoMmDtxCS/d38mlg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"passport-strategy": "1.x.x"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-google-oauth2": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/passport-google-oauth2/-/passport-google-oauth2-0.2.0.tgz",
|
||||
@@ -12457,6 +12800,27 @@
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-root": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz",
|
||||
"integrity": "sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-root-regex": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-root-regex": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/path-root-regex/-/path-root-regex-0.1.2.tgz",
|
||||
"integrity": "sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz",
|
||||
@@ -12604,7 +12968,6 @@
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
@@ -13200,6 +13563,18 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rechoir": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz",
|
||||
"integrity": "sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve": "^1.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/redis": {
|
||||
"version": "4.7.1",
|
||||
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
|
||||
@@ -13351,6 +13726,19 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-dir": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz",
|
||||
"integrity": "sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"expand-tilde": "^2.0.0",
|
||||
"global-modules": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
@@ -14586,7 +14974,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
@@ -15107,6 +15494,15 @@
|
||||
"integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unc-path-regex": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz",
|
||||
"integrity": "sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
@@ -15336,6 +15732,15 @@
|
||||
"node": ">=10.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/v8flags": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/v8flags/-/v8flags-4.0.1.tgz",
|
||||
"integrity": "sha512-fcRLaS4H/hrZk9hYwbdRM35D0U8IYMfEClhXxCivOojl+yTRAZH3Zy2sSy6qVCiGbV9YAtPssP6jaChqC9vPCg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/validator": {
|
||||
"version": "13.15.15",
|
||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz",
|
||||
|
||||
@@ -80,7 +80,6 @@
|
||||
"morgan": "^1.10.0",
|
||||
"nanoid": "^3.3.6",
|
||||
"passport": "^0.7.0",
|
||||
"passport-custom": "^1.1.1",
|
||||
"passport-google-oauth2": "^0.2.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-local": "^1.0.0",
|
||||
@@ -108,7 +107,6 @@
|
||||
"@types/compression": "^1.7.2",
|
||||
"@types/connect-flash": "^0.0.37",
|
||||
"@types/cookie-parser": "^1.4.3",
|
||||
"@types/cookie-signature": "^1.1.2",
|
||||
"@types/cron": "^2.0.1",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/csurf": "^1.11.2",
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import bcrypt from "bcrypt";
|
||||
import passport from "passport";
|
||||
import {NextFunction} from "express";
|
||||
|
||||
import {sendResetEmail, sendResetSuccessEmail} from "../shared/email-templates";
|
||||
|
||||
@@ -183,162 +181,4 @@ export default class AuthController extends WorklenzControllerBase {
|
||||
res.status(500).send(new ServerResponse(false, null, DEFAULT_ERROR_MESSAGE));
|
||||
}
|
||||
}
|
||||
|
||||
public static googleMobileAuthPassport(req: IWorkLenzRequest, res: IWorkLenzResponse, next: NextFunction) {
|
||||
|
||||
const mobileOptions = {
|
||||
session: true,
|
||||
failureFlash: true,
|
||||
failWithError: false
|
||||
};
|
||||
|
||||
passport.authenticate("google-mobile", mobileOptions, (err: any, user: any, info: any) => {
|
||||
if (err) {
|
||||
return res.status(500).send({
|
||||
done: false,
|
||||
message: "Authentication failed",
|
||||
body: null
|
||||
});
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return res.status(400).send({
|
||||
done: false,
|
||||
message: info?.message || "Authentication failed",
|
||||
body: null
|
||||
});
|
||||
}
|
||||
// Log the user in (create session)
|
||||
req.login(user, (loginErr) => {
|
||||
if (loginErr) {
|
||||
return res.status(500).send({
|
||||
done: false,
|
||||
message: "Session creation failed",
|
||||
body: null
|
||||
});
|
||||
}
|
||||
|
||||
// Add build version
|
||||
user.build_v = FileConstants.getRelease();
|
||||
|
||||
// Ensure session is saved and cookie is set
|
||||
req.session.save((saveErr) => {
|
||||
if (saveErr) {
|
||||
return res.status(500).send({
|
||||
done: false,
|
||||
message: "Session save failed",
|
||||
body: null
|
||||
});
|
||||
}
|
||||
|
||||
// Get session cookie details
|
||||
const sessionName = process.env.SESSION_NAME || 'connect.sid';
|
||||
|
||||
// Return response with session info for mobile app to handle
|
||||
res.setHeader('X-Session-ID', req.sessionID);
|
||||
res.setHeader('X-Session-Name', sessionName);
|
||||
|
||||
return res.status(200).send({
|
||||
done: true,
|
||||
message: "Login successful",
|
||||
user,
|
||||
authenticated: true,
|
||||
sessionId: req.sessionID,
|
||||
sessionName: sessionName,
|
||||
newSessionId: req.sessionID
|
||||
});
|
||||
});
|
||||
}); // Close login callback
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
@HandleExceptions({logWithError: "body"})
|
||||
public static async googleMobileAuth(req: IWorkLenzRequest, res: IWorkLenzResponse) {
|
||||
const {idToken} = req.body;
|
||||
|
||||
if (!idToken) {
|
||||
return res.status(400).send(new ServerResponse(false, null, "ID token is required"));
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(`https://oauth2.googleapis.com/tokeninfo?id_token=${idToken}`);
|
||||
const profile = response.data;
|
||||
|
||||
// Validate token audience (client ID) - accept web, Android, and iOS client IDs
|
||||
const allowedClientIds = [
|
||||
process.env.GOOGLE_CLIENT_ID, // Web client ID
|
||||
process.env.GOOGLE_ANDROID_CLIENT_ID, // Android client ID
|
||||
process.env.GOOGLE_IOS_CLIENT_ID, // iOS client ID
|
||||
].filter(Boolean); // Remove undefined values
|
||||
|
||||
console.log("Token audience (aud):", profile.aud);
|
||||
console.log("Allowed client IDs:", allowedClientIds);
|
||||
console.log("Environment variables check:");
|
||||
console.log("- GOOGLE_CLIENT_ID:", process.env.GOOGLE_CLIENT_ID ? "Set" : "Not set");
|
||||
console.log("- GOOGLE_ANDROID_CLIENT_ID:", process.env.GOOGLE_ANDROID_CLIENT_ID ? "Set" : "Not set");
|
||||
console.log("- GOOGLE_IOS_CLIENT_ID:", process.env.GOOGLE_IOS_CLIENT_ID ? "Set" : "Not set");
|
||||
|
||||
if (!allowedClientIds.includes(profile.aud)) {
|
||||
return res.status(400).send(new ServerResponse(false, null, "Invalid token audience"));
|
||||
}
|
||||
|
||||
// Validate token issuer
|
||||
if (!["https://accounts.google.com", "accounts.google.com"].includes(profile.iss)) {
|
||||
return res.status(400).send(new ServerResponse(false, null, "Invalid token issuer"));
|
||||
}
|
||||
|
||||
// Check token expiry
|
||||
if (Date.now() >= profile.exp * 1000) {
|
||||
return res.status(400).send(new ServerResponse(false, null, "Token expired"));
|
||||
}
|
||||
|
||||
if (!profile.email_verified) {
|
||||
return res.status(400).send(new ServerResponse(false, null, "Email not verified"));
|
||||
}
|
||||
|
||||
// Check for existing local account
|
||||
const localAccountResult = await db.query("SELECT 1 FROM users WHERE email = $1 AND password IS NOT NULL AND is_deleted IS FALSE;", [profile.email]);
|
||||
if (localAccountResult.rowCount) {
|
||||
return res.status(400).send(new ServerResponse(false, null, `No Google account exists for email ${profile.email}.`));
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const userResult = await db.query(
|
||||
"SELECT id, google_id, name, email, active_team FROM users WHERE google_id = $1 OR email = $2;",
|
||||
[profile.sub, profile.email]
|
||||
);
|
||||
|
||||
let user: any;
|
||||
if (userResult.rowCount) {
|
||||
// Existing user - login
|
||||
user = userResult.rows[0];
|
||||
} else {
|
||||
// New user - register
|
||||
const googleUserData = {
|
||||
id: profile.sub,
|
||||
displayName: profile.name,
|
||||
email: profile.email,
|
||||
picture: profile.picture
|
||||
};
|
||||
|
||||
const registerResult = await db.query("SELECT register_google_user($1) AS user;", [JSON.stringify(googleUserData)]);
|
||||
user = registerResult.rows[0].user;
|
||||
}
|
||||
|
||||
// Create session
|
||||
req.login(user, (err) => {
|
||||
if (err) {
|
||||
log_error(err);
|
||||
return res.status(500).send(new ServerResponse(false, null, "Authentication failed"));
|
||||
}
|
||||
|
||||
user.build_v = FileConstants.getRelease();
|
||||
return res.status(200).send(new AuthResponse("Login Successful!", true, user, null, "User successfully logged in"));
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
log_error(error);
|
||||
return res.status(400).send(new ServerResponse(false, null, "Invalid ID token"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import db from "../config/db";
|
||||
import {ServerResponse} from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import {TASK_PRIORITY_COLOR_ALPHA, WorklenzColorCodes, WorklenzColorShades} from "../shared/constants";
|
||||
import {TASK_PRIORITY_COLOR_ALPHA, WorklenzColorCodes} from "../shared/constants";
|
||||
|
||||
export default class LabelsController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
@@ -73,44 +73,13 @@ export default class LabelsController extends WorklenzControllerBase {
|
||||
WHERE id = $1
|
||||
AND team_id = $2;`;
|
||||
|
||||
if (!Object.values(WorklenzColorShades).flat().includes(req.body.color))
|
||||
if (!WorklenzColorCodes.includes(req.body.color))
|
||||
return res.status(400).send(new ServerResponse(false, null));
|
||||
|
||||
const result = await db.query(q, [req.params.id, req.user?.team_id, req.body.color]);
|
||||
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 (!Object.values(WorklenzColorShades).flat().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
|
||||
|
||||
253
worklenz-backend/src/controllers/moderation-controller.ts
Normal file
253
worklenz-backend/src/controllers/moderation-controller.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||
import { ServerResponse } from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import db from "../config/db";
|
||||
import { SpamDetector } from "../utils/spam-detector";
|
||||
import { RateLimiter } from "../middleware/rate-limiter";
|
||||
|
||||
export default class ModerationController extends WorklenzControllerBase {
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getFlaggedOrganizations(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
if (!req.user?.is_admin) {
|
||||
return res.status(403).send(new ServerResponse(false, null, "Admin access required"));
|
||||
}
|
||||
|
||||
const q = `
|
||||
SELECT * FROM moderation_dashboard
|
||||
ORDER BY last_moderation_date DESC
|
||||
LIMIT 100;
|
||||
`;
|
||||
|
||||
const result = await db.query(q);
|
||||
|
||||
// Add spam analysis to each result
|
||||
const flaggedTeams = result.rows.map(team => {
|
||||
const orgSpamCheck = SpamDetector.detectSpam(team.organization_name);
|
||||
const ownerSpamCheck = SpamDetector.detectSpam(team.owner_name);
|
||||
|
||||
return {
|
||||
...team,
|
||||
org_spam_score: orgSpamCheck.score,
|
||||
org_spam_reasons: orgSpamCheck.reasons,
|
||||
owner_spam_score: ownerSpamCheck.score,
|
||||
owner_spam_reasons: ownerSpamCheck.reasons,
|
||||
is_high_risk: SpamDetector.isHighRiskContent(team.organization_name) ||
|
||||
SpamDetector.isHighRiskContent(team.owner_name)
|
||||
};
|
||||
});
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, flaggedTeams));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async flagOrganization(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
if (!req.user?.is_admin) {
|
||||
return res.status(403).send(new ServerResponse(false, null, "Admin access required"));
|
||||
}
|
||||
|
||||
const { teamId, reason } = req.body;
|
||||
if (!teamId) {
|
||||
return res.status(400).send(new ServerResponse(false, null, "Team ID is required"));
|
||||
}
|
||||
|
||||
const q = `SELECT update_team_status($1, 'flagged', $2, $3) as result`;
|
||||
const result = await db.query(q, [teamId, reason || 'Spam/Abuse', req.user.id]);
|
||||
|
||||
const teamQuery = `SELECT id, name FROM teams WHERE id = $1`;
|
||||
const teamResult = await db.query(teamQuery, [teamId]);
|
||||
|
||||
if (teamResult.rows.length === 0) {
|
||||
return res.status(404).send(new ServerResponse(false, null, "Organization not found"));
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, teamResult.rows[0], "Organization flagged successfully"));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async suspendOrganization(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
if (!req.user?.is_admin) {
|
||||
return res.status(403).send(new ServerResponse(false, null, "Admin access required"));
|
||||
}
|
||||
|
||||
const { teamId, reason, expiresAt } = req.body;
|
||||
if (!teamId) {
|
||||
return res.status(400).send(new ServerResponse(false, null, "Team ID is required"));
|
||||
}
|
||||
|
||||
const q = `SELECT update_team_status($1, 'suspended', $2, $3, $4) as result`;
|
||||
const result = await db.query(q, [teamId, reason || 'Terms of Service Violation', req.user.id, expiresAt || null]);
|
||||
|
||||
const teamQuery = `SELECT id, name FROM teams WHERE id = $1`;
|
||||
const teamResult = await db.query(teamQuery, [teamId]);
|
||||
|
||||
if (teamResult.rows.length === 0) {
|
||||
return res.status(404).send(new ServerResponse(false, null, "Organization not found"));
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, teamResult.rows[0], "Organization suspended successfully"));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async unsuspendOrganization(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
if (!req.user?.is_admin) {
|
||||
return res.status(403).send(new ServerResponse(false, null, "Admin access required"));
|
||||
}
|
||||
|
||||
const { teamId } = req.body;
|
||||
if (!teamId) {
|
||||
return res.status(400).send(new ServerResponse(false, null, "Team ID is required"));
|
||||
}
|
||||
|
||||
const q = `SELECT update_team_status($1, 'active', 'Manually restored by admin', $2) as result`;
|
||||
const result = await db.query(q, [teamId, req.user.id]);
|
||||
|
||||
const teamQuery = `SELECT id, name FROM teams WHERE id = $1`;
|
||||
const teamResult = await db.query(teamQuery, [teamId]);
|
||||
|
||||
if (teamResult.rows.length === 0) {
|
||||
return res.status(404).send(new ServerResponse(false, null, "Organization not found"));
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, teamResult.rows[0], "Organization restored successfully"));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async scanForSpam(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
if (!req.user?.is_admin) {
|
||||
return res.status(403).send(new ServerResponse(false, null, "Admin access required"));
|
||||
}
|
||||
|
||||
const q = `
|
||||
SELECT t.id, t.name as organization_name, u.name as owner_name, u.email as owner_email,
|
||||
t.created_at
|
||||
FROM teams t
|
||||
INNER JOIN users u ON t.user_id = u.id
|
||||
WHERE t.status = 'active'
|
||||
AND t.created_at > NOW() - INTERVAL '7 days'
|
||||
ORDER BY t.created_at DESC;
|
||||
`;
|
||||
|
||||
const result = await db.query(q);
|
||||
const suspiciousTeams = [];
|
||||
|
||||
for (const team of result.rows) {
|
||||
const orgSpamCheck = SpamDetector.detectSpam(team.organization_name);
|
||||
const ownerSpamCheck = SpamDetector.detectSpam(team.owner_name);
|
||||
|
||||
if (orgSpamCheck.isSpam || ownerSpamCheck.isSpam ||
|
||||
SpamDetector.isHighRiskContent(team.organization_name) ||
|
||||
SpamDetector.isHighRiskContent(team.owner_name)) {
|
||||
|
||||
suspiciousTeams.push({
|
||||
...team,
|
||||
org_spam_score: orgSpamCheck.score,
|
||||
org_spam_reasons: orgSpamCheck.reasons,
|
||||
owner_spam_score: ownerSpamCheck.score,
|
||||
owner_spam_reasons: ownerSpamCheck.reasons,
|
||||
is_high_risk: SpamDetector.isHighRiskContent(team.organization_name) ||
|
||||
SpamDetector.isHighRiskContent(team.owner_name)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, {
|
||||
total_scanned: result.rows.length,
|
||||
suspicious_count: suspiciousTeams.length,
|
||||
suspicious_teams: suspiciousTeams
|
||||
}));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getModerationStats(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
if (!req.user?.is_admin) {
|
||||
return res.status(403).send(new ServerResponse(false, null, "Admin access required"));
|
||||
}
|
||||
|
||||
const statsQuery = `
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM teams WHERE status = 'flagged') as flagged_count,
|
||||
(SELECT COUNT(*) FROM teams WHERE status = 'suspended') as suspended_count,
|
||||
(SELECT COUNT(*) FROM teams WHERE created_at > NOW() - INTERVAL '24 hours') as new_teams_24h,
|
||||
(SELECT COUNT(*) FROM teams WHERE created_at > NOW() - INTERVAL '7 days') as new_teams_7d
|
||||
`;
|
||||
|
||||
const result = await db.query(statsQuery);
|
||||
const stats = result.rows[0];
|
||||
|
||||
// Get rate limiting stats for recent activity
|
||||
const recentInviteActivity = RateLimiter.getStats(req.user?.id || '');
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, {
|
||||
...stats,
|
||||
rate_limit_stats: recentInviteActivity
|
||||
}));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async bulkScanAndFlag(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
if (!req.user?.is_admin) {
|
||||
return res.status(403).send(new ServerResponse(false, null, "Admin access required"));
|
||||
}
|
||||
|
||||
const { autoFlag = false } = req.body;
|
||||
|
||||
const q = `
|
||||
SELECT t.id, t.name as organization_name, u.name as owner_name
|
||||
FROM teams t
|
||||
INNER JOIN users u ON t.user_id = u.id
|
||||
WHERE t.status = 'active'
|
||||
AND t.created_at > NOW() - INTERVAL '30 days'
|
||||
LIMIT 1000;
|
||||
`;
|
||||
|
||||
const result = await db.query(q);
|
||||
const flaggedTeams = [];
|
||||
|
||||
for (const team of result.rows) {
|
||||
const orgSpamCheck = SpamDetector.detectSpam(team.organization_name);
|
||||
const ownerSpamCheck = SpamDetector.detectSpam(team.owner_name);
|
||||
const isHighRisk = SpamDetector.isHighRiskContent(team.organization_name) ||
|
||||
SpamDetector.isHighRiskContent(team.owner_name);
|
||||
|
||||
if ((orgSpamCheck.score > 70 || ownerSpamCheck.score > 70 || isHighRisk) && autoFlag) {
|
||||
// Auto-flag high-confidence spam
|
||||
const reasons = [
|
||||
...orgSpamCheck.reasons,
|
||||
...ownerSpamCheck.reasons,
|
||||
...(isHighRisk ? ['High-risk content detected'] : [])
|
||||
];
|
||||
|
||||
const flagQuery = `SELECT update_team_status($1, 'flagged', $2, $3) as result`;
|
||||
await db.query(flagQuery, [
|
||||
team.id,
|
||||
`Auto-flagged: ${reasons.join(', ')}`,
|
||||
req.user.id
|
||||
]);
|
||||
|
||||
flaggedTeams.push({
|
||||
...team,
|
||||
action: 'flagged',
|
||||
reasons: reasons
|
||||
});
|
||||
} else if (orgSpamCheck.isSpam || ownerSpamCheck.isSpam || isHighRisk) {
|
||||
flaggedTeams.push({
|
||||
...team,
|
||||
action: 'review_needed',
|
||||
org_spam_score: orgSpamCheck.score,
|
||||
owner_spam_score: ownerSpamCheck.score,
|
||||
reasons: [...orgSpamCheck.reasons, ...ownerSpamCheck.reasons, ...(isHighRisk ? ['High-risk content'] : [])]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, {
|
||||
total_scanned: result.rows.length,
|
||||
auto_flagged: flaggedTeams.filter(t => t.action === 'flagged').length,
|
||||
needs_review: flaggedTeams.filter(t => t.action === 'review_needed').length,
|
||||
teams: flaggedTeams
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -124,7 +124,7 @@ export default class TaskCommentsController extends WorklenzControllerBase {
|
||||
const q = `
|
||||
INSERT INTO task_comment_attachments (name, type, size, task_id, comment_id, team_id, project_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, name, type, task_id, comment_id, created_at,
|
||||
RETURNING id, name, type, task_id, comment_id, created_at,
|
||||
CONCAT($8::TEXT, '/', team_id, '/', project_id, '/', task_id, '/', comment_id, '/', id, '.', type) AS url;
|
||||
`;
|
||||
|
||||
@@ -217,43 +217,7 @@ export default class TaskCommentsController extends WorklenzControllerBase {
|
||||
}
|
||||
}
|
||||
|
||||
// Get user avatar URL from database
|
||||
const avatarQuery = `SELECT avatar_url FROM users WHERE id = $1`;
|
||||
const avatarResult = await db.query(avatarQuery, [req.user?.id]);
|
||||
const avatarUrl = avatarResult.rows[0]?.avatar_url || "";
|
||||
|
||||
// Get comment details including created_at
|
||||
const commentQuery = `SELECT created_at FROM task_comments WHERE id = $1`;
|
||||
const commentResult = await db.query(commentQuery, [response.id]);
|
||||
const commentData = commentResult.rows[0];
|
||||
|
||||
// Get attachments if any
|
||||
const attachmentsQuery = `SELECT id, name, type, size FROM task_comment_attachments WHERE comment_id = $1`;
|
||||
const attachmentsResult = await db.query(attachmentsQuery, [response.id]);
|
||||
const commentAttachments = attachmentsResult.rows.map(att => ({
|
||||
id: att.id,
|
||||
name: att.name,
|
||||
type: att.type,
|
||||
size: att.size
|
||||
}));
|
||||
|
||||
|
||||
const commentdata = {
|
||||
attachments: commentAttachments,
|
||||
avatar_url: avatarUrl,
|
||||
content: req.body.content,
|
||||
created_at: commentData?.created_at || new Date().toISOString(),
|
||||
edit: false,
|
||||
id: response.id,
|
||||
member_name: req.user?.name || "",
|
||||
mentions: mentions || [],
|
||||
rawContent: req.body.content,
|
||||
reactions: { likes: {} },
|
||||
team_member_id: req.user?.team_member_id || "",
|
||||
user_id: req.user?.id || ""
|
||||
};
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, commentdata));
|
||||
return res.status(200).send(new ServerResponse(true, data.comment));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
@@ -566,17 +530,17 @@ export default class TaskCommentsController extends WorklenzControllerBase {
|
||||
for (const attachment of attachments) {
|
||||
if (req.user?.subscription_status === "free" && req.user?.owner_id) {
|
||||
const limits = await getFreePlanSettings();
|
||||
|
||||
|
||||
const usedStorage = await getUsedStorage(req.user?.owner_id);
|
||||
if ((parseInt(usedStorage) + attachment.size) > megabytesToBytes(parseInt(limits.free_tier_storage))) {
|
||||
return res.status(200).send(new ServerResponse(false, [], `Sorry, the free plan cannot exceed ${limits.free_tier_storage}MB of storage.`));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const q = `
|
||||
INSERT INTO task_comment_attachments (name, type, size, task_id, comment_id, team_id, project_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, name, type, task_id, comment_id, created_at,
|
||||
RETURNING id, name, type, task_id, comment_id, created_at,
|
||||
CONCAT($8::TEXT, '/', team_id, '/', project_id, '/', task_id, '/', comment_id, '/', id, '.', type) AS url;
|
||||
`;
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@ 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 {
|
||||
|
||||
@@ -72,7 +76,8 @@ export default class TeamMembersController extends WorklenzControllerBase {
|
||||
|
||||
@HandleExceptions({
|
||||
raisedExceptions: {
|
||||
"ERROR_EMAIL_INVITATION_EXISTS": `Team member with email "{0}" already exists.`
|
||||
"ERROR_EMAIL_INVITATION_EXISTS": `Team member with email "{0}" already exists.`,
|
||||
"ERROR_SPAM_DETECTED": `Invitation blocked: {0}`
|
||||
}
|
||||
})
|
||||
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
@@ -82,6 +87,54 @@ export default class TeamMembersController extends WorklenzControllerBase {
|
||||
return res.status(200).send(new ServerResponse(false, "Required fields are missing."));
|
||||
}
|
||||
|
||||
// Validate organization name for spam - Flag suspicious, block only obvious spam
|
||||
const orgSpamCheck = SpamDetector.detectSpam(req.user?.team_name || '');
|
||||
const ownerSpamCheck = SpamDetector.detectSpam(req.user?.name || '');
|
||||
|
||||
// Only block extremely suspicious content for invitations (higher threshold)
|
||||
const isObviousSpam = orgSpamCheck.score > 70 || ownerSpamCheck.score > 70 ||
|
||||
SpamDetector.isHighRiskContent(req.user?.team_name || '') ||
|
||||
SpamDetector.isHighRiskContent(req.user?.name || '');
|
||||
|
||||
if (isObviousSpam) {
|
||||
logger.error('🛑 INVITATION BLOCKED - OBVIOUS SPAM', {
|
||||
user_id: req.user?.id,
|
||||
user_email: req.user?.email,
|
||||
team_id: req.user?.team_id,
|
||||
team_name: req.user?.team_name,
|
||||
owner_name: req.user?.name,
|
||||
org_spam_score: orgSpamCheck.score,
|
||||
owner_spam_score: ownerSpamCheck.score,
|
||||
org_reasons: orgSpamCheck.reasons,
|
||||
owner_reasons: ownerSpamCheck.reasons,
|
||||
ip_address: req.ip,
|
||||
timestamp: new Date().toISOString(),
|
||||
alert_type: 'obvious_spam_invitation_blocked'
|
||||
});
|
||||
return res.status(200).send(new ServerResponse(false, null, `Invitations temporarily disabled. Please contact support for assistance.`));
|
||||
}
|
||||
|
||||
// Log suspicious but allow invitations
|
||||
if (orgSpamCheck.score > 0 || ownerSpamCheck.score > 0) {
|
||||
logger.warn('⚠️ SUSPICIOUS INVITATION ATTEMPT', {
|
||||
user_id: req.user?.id,
|
||||
user_email: req.user?.email,
|
||||
team_id: req.user?.team_id,
|
||||
team_name: req.user?.team_name,
|
||||
owner_name: req.user?.name,
|
||||
org_spam_score: orgSpamCheck.score,
|
||||
owner_spam_score: ownerSpamCheck.score,
|
||||
org_reasons: orgSpamCheck.reasons,
|
||||
owner_reasons: ownerSpamCheck.reasons,
|
||||
ip_address: req.ip,
|
||||
timestamp: new Date().toISOString(),
|
||||
alert_type: 'suspicious_invitation_flagged'
|
||||
});
|
||||
// Continue with invitation but flag for review
|
||||
}
|
||||
|
||||
// High-risk content already checked above in isObviousSpam condition
|
||||
|
||||
/**
|
||||
* Checks the subscription status of the team.
|
||||
* @type {Object} subscriptionData - Object containing subscription information
|
||||
|
||||
141
worklenz-backend/src/middleware/rate-limiter.ts
Normal file
141
worklenz-backend/src/middleware/rate-limiter.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { NextFunction } from "express";
|
||||
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||
import { ServerResponse } from "../models/server-response";
|
||||
import loggerModule from "../utils/logger";
|
||||
|
||||
const { logger } = loggerModule;
|
||||
|
||||
interface RateLimitStore {
|
||||
[key: string]: {
|
||||
count: number;
|
||||
resetTime: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class RateLimiter {
|
||||
private static store: RateLimitStore = {};
|
||||
private static cleanupInterval: NodeJS.Timeout;
|
||||
|
||||
static {
|
||||
// Clean up expired entries every 5 minutes
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
Object.keys(this.store).forEach(key => {
|
||||
if (this.store[key].resetTime < now) {
|
||||
delete this.store[key];
|
||||
}
|
||||
});
|
||||
}, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
public static inviteRateLimit(
|
||||
maxRequests = 5,
|
||||
windowMs: number = 15 * 60 * 1000 // 15 minutes
|
||||
) {
|
||||
return (req: IWorkLenzRequest, res: IWorkLenzResponse, next: NextFunction) => {
|
||||
const identifier = req.user?.id || req.ip;
|
||||
const key = `invite_${identifier}`;
|
||||
const now = Date.now();
|
||||
|
||||
if (!this.store[key] || this.store[key].resetTime < now) {
|
||||
this.store[key] = {
|
||||
count: 1,
|
||||
resetTime: now + windowMs
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
if (this.store[key].count >= maxRequests) {
|
||||
const remainingTime = Math.ceil((this.store[key].resetTime - now) / 1000);
|
||||
|
||||
// Log rate limit exceeded for Slack notifications
|
||||
logger.warn("⚠️ RATE LIMIT EXCEEDED - INVITE ATTEMPTS", {
|
||||
user_id: req.user?.id,
|
||||
user_email: req.user?.email,
|
||||
ip_address: req.ip,
|
||||
attempts: this.store[key].count,
|
||||
max_attempts: maxRequests,
|
||||
remaining_time: remainingTime,
|
||||
timestamp: new Date().toISOString(),
|
||||
alert_type: "rate_limit_exceeded"
|
||||
});
|
||||
|
||||
return res.status(429).send(
|
||||
new ServerResponse(
|
||||
false,
|
||||
null,
|
||||
`Too many invitation attempts. Please try again in ${remainingTime} seconds.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
this.store[key].count++;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
public static organizationCreationRateLimit(
|
||||
maxRequests = 3,
|
||||
windowMs: number = 60 * 60 * 1000 // 1 hour
|
||||
) {
|
||||
return (req: IWorkLenzRequest, res: IWorkLenzResponse, next: NextFunction) => {
|
||||
const identifier = req.user?.id || req.ip;
|
||||
const key = `org_creation_${identifier}`;
|
||||
const now = Date.now();
|
||||
|
||||
if (!this.store[key] || this.store[key].resetTime < now) {
|
||||
this.store[key] = {
|
||||
count: 1,
|
||||
resetTime: now + windowMs
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
if (this.store[key].count >= maxRequests) {
|
||||
const remainingTime = Math.ceil((this.store[key].resetTime - now) / (1000 * 60));
|
||||
|
||||
// Log organization creation rate limit exceeded
|
||||
logger.warn("⚠️ RATE LIMIT EXCEEDED - ORG CREATION", {
|
||||
user_id: req.user?.id,
|
||||
user_email: req.user?.email,
|
||||
ip_address: req.ip,
|
||||
attempts: this.store[key].count,
|
||||
max_attempts: maxRequests,
|
||||
remaining_time_minutes: remainingTime,
|
||||
timestamp: new Date().toISOString(),
|
||||
alert_type: "org_creation_rate_limit"
|
||||
});
|
||||
|
||||
return res.status(429).send(
|
||||
new ServerResponse(
|
||||
false,
|
||||
null,
|
||||
`Too many organization creation attempts. Please try again in ${remainingTime} minutes.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
this.store[key].count++;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
public static getStats(identifier: string): { invites: number; orgCreations: number } {
|
||||
const inviteKey = `invite_${identifier}`;
|
||||
const orgKey = `org_creation_${identifier}`;
|
||||
|
||||
return {
|
||||
invites: this.store[inviteKey]?.count || 0,
|
||||
orgCreations: this.store[orgKey]?.count || 0
|
||||
};
|
||||
}
|
||||
|
||||
public static clearStats(identifier: string): void {
|
||||
const inviteKey = `invite_${identifier}`;
|
||||
const orgKey = `org_creation_${identifier}`;
|
||||
|
||||
delete this.store[inviteKey];
|
||||
delete this.store[orgKey];
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import session from "express-session";
|
||||
import db from "../config/db";
|
||||
import { isProduction } from "../shared/utils";
|
||||
import * as cookieSignature from "cookie-signature";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const pgSession = require("connect-pg-simple")(session);
|
||||
|
||||
const sessionConfig = {
|
||||
export default session({
|
||||
name: process.env.SESSION_NAME,
|
||||
secret: process.env.SESSION_SECRET || "development-secret-key",
|
||||
proxy: false,
|
||||
@@ -20,56 +18,10 @@ const sessionConfig = {
|
||||
}),
|
||||
cookie: {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
// For mobile app support in production, use "none", for local development use "lax"
|
||||
sameSite: "lax" as const,
|
||||
// Secure only in production (HTTPS required for sameSite: "none")
|
||||
secure: false,
|
||||
domain: undefined,
|
||||
// secure: isProduction(),
|
||||
// httpOnly: isProduction(),
|
||||
// sameSite: "none",
|
||||
// domain: isProduction() ? ".worklenz.com" : undefined,
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||
},
|
||||
// Custom session ID handling for mobile apps
|
||||
genid: () => {
|
||||
return randomBytes(24).toString("base64url");
|
||||
}
|
||||
};
|
||||
|
||||
const sessionMiddleware = session(sessionConfig);
|
||||
|
||||
// Enhanced session middleware that supports both cookies and headers for mobile apps
|
||||
export default (req: any, res: any, next: any) => {
|
||||
// Check if mobile app is sending session ID via header (fallback for cookie issues)
|
||||
const headerSessionId = req.headers["x-session-id"];
|
||||
const headerSessionName = req.headers["x-session-name"];
|
||||
|
||||
// Only process headers if they exist AND there's no existing valid session cookie
|
||||
if (headerSessionId && headerSessionName) {
|
||||
const secret = process.env.SESSION_SECRET || "development-secret-key";
|
||||
|
||||
try {
|
||||
// Create a signed cookie using the session secret
|
||||
const signedSessionId = `s:${cookieSignature.sign(headerSessionId, secret)}`;
|
||||
const encodedSignedId = encodeURIComponent(signedSessionId);
|
||||
const sessionCookie = `${headerSessionName}=${encodedSignedId}`;
|
||||
|
||||
if (req.headers.cookie) {
|
||||
// Replace existing session cookie while keeping other cookies
|
||||
req.headers.cookie = req.headers.cookie
|
||||
.split(";")
|
||||
.filter((cookie: string) => !cookie.trim().startsWith(headerSessionName))
|
||||
.concat(sessionCookie)
|
||||
.join(";");
|
||||
} else {
|
||||
// Set the session cookie from header
|
||||
req.headers.cookie = sessionCookie;
|
||||
}
|
||||
} catch (error) {
|
||||
// Fallback to the old method
|
||||
const sessionCookie = `${headerSessionName}=s%3A${headerSessionId}`;
|
||||
req.headers.cookie = sessionCookie;
|
||||
}
|
||||
}
|
||||
|
||||
// Always call the original session middleware (handles both cookie and header-converted cases)
|
||||
sessionMiddleware(req, res, next);
|
||||
};
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import {PASSWORD_POLICY} from "../../shared/constants";
|
||||
function isStrongPassword(password: string) {
|
||||
if (!isProduction()) return true;
|
||||
const strength = PasswordStrengthChecker.validate(password);
|
||||
return strength.value >= 2 && strength.length <= 32;
|
||||
return strength.value >= 2 && strength.length < 32;
|
||||
}
|
||||
|
||||
export default function (req: Request, res: Response, next: NextFunction) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {deserialize} from "./deserialize";
|
||||
import {serialize} from "./serialize";
|
||||
|
||||
import GoogleLogin from "./passport-strategies/passport-google";
|
||||
import GoogleMobileLogin from "./passport-strategies/passport-google-mobile";
|
||||
import LocalLogin from "./passport-strategies/passport-local-login";
|
||||
import LocalSignup from "./passport-strategies/passport-local-signup";
|
||||
|
||||
@@ -16,7 +15,6 @@ export default (passport: PassportStatic) => {
|
||||
passport.use("local-login", LocalLogin);
|
||||
passport.use("local-signup", LocalSignup);
|
||||
passport.use(GoogleLogin);
|
||||
passport.use("google-mobile", GoogleMobileLogin);
|
||||
passport.serializeUser(serialize);
|
||||
passport.deserializeUser(deserialize);
|
||||
};
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { Strategy as CustomStrategy } from "passport-custom";
|
||||
import axios from "axios";
|
||||
import { Request } from "express";
|
||||
import db from "../../config/db";
|
||||
import { log_error } from "../../shared/utils";
|
||||
import { ERROR_KEY } from "./passport-constants";
|
||||
|
||||
interface GoogleTokenProfile {
|
||||
sub: string;
|
||||
email: string;
|
||||
name: string;
|
||||
picture: string;
|
||||
email_verified: boolean;
|
||||
aud: string;
|
||||
iss: string;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
async function handleMobileGoogleAuth(req: Request, done: any) {
|
||||
try {
|
||||
const { idToken } = req.body;
|
||||
|
||||
if (!idToken) {
|
||||
return done(null, false, { message: "ID token is required" });
|
||||
}
|
||||
|
||||
// Verify Google ID token
|
||||
const response = await axios.get(
|
||||
`https://oauth2.googleapis.com/tokeninfo?id_token=${idToken}`
|
||||
);
|
||||
const profile: GoogleTokenProfile = response.data;
|
||||
|
||||
// Validate token audience (client ID)
|
||||
const allowedClientIds = [
|
||||
process.env.GOOGLE_CLIENT_ID,
|
||||
process.env.GOOGLE_ANDROID_CLIENT_ID,
|
||||
process.env.GOOGLE_IOS_CLIENT_ID,
|
||||
].filter(Boolean);
|
||||
|
||||
if (!allowedClientIds.includes(profile.aud)) {
|
||||
return done(null, false, { message: "Invalid token audience" });
|
||||
}
|
||||
|
||||
// Validate token issuer
|
||||
if (
|
||||
!["https://accounts.google.com", "accounts.google.com"].includes(
|
||||
profile.iss
|
||||
)
|
||||
) {
|
||||
return done(null, false, { message: "Invalid token issuer" });
|
||||
}
|
||||
|
||||
// Check token expiry
|
||||
if (Date.now() >= profile.exp * 1000) {
|
||||
return done(null, false, { message: "Token expired" });
|
||||
}
|
||||
|
||||
if (!profile.email_verified) {
|
||||
return done(null, false, { message: "Email not verified" });
|
||||
}
|
||||
|
||||
// Check for existing local account
|
||||
const localAccountResult = await db.query(
|
||||
"SELECT 1 FROM users WHERE email = $1 AND password IS NOT NULL AND is_deleted IS FALSE;",
|
||||
[profile.email]
|
||||
);
|
||||
|
||||
if (localAccountResult.rowCount) {
|
||||
const message = `No Google account exists for email ${profile.email}.`;
|
||||
return done(null, false, { message });
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const userResult = await db.query(
|
||||
"SELECT id, google_id, name, email, active_team FROM users WHERE google_id = $1 OR email = $2;",
|
||||
[profile.sub, profile.email]
|
||||
);
|
||||
|
||||
if (userResult.rowCount) {
|
||||
// Existing user - login
|
||||
const user = userResult.rows[0];
|
||||
return done(null, user, { message: "User successfully logged in" });
|
||||
}
|
||||
// New user - register
|
||||
const googleUserData = {
|
||||
id: profile.sub,
|
||||
displayName: profile.name,
|
||||
email: profile.email,
|
||||
picture: profile.picture,
|
||||
};
|
||||
|
||||
const registerResult = await db.query(
|
||||
"SELECT register_google_user($1) AS user;",
|
||||
[JSON.stringify(googleUserData)]
|
||||
);
|
||||
const { user } = registerResult.rows[0];
|
||||
|
||||
return done(null, user, {
|
||||
message: "User successfully registered and logged in",
|
||||
});
|
||||
} catch (error: any) {
|
||||
log_error(error);
|
||||
if (error.response?.status === 400) {
|
||||
return done(null, false, { message: "Invalid ID token" });
|
||||
}
|
||||
return done(error);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CustomStrategy(handleMobileGoogleAuth);
|
||||
@@ -8,6 +8,10 @@ import {log_error} from "../../shared/utils";
|
||||
import db from "../../config/db";
|
||||
import {Request} from "express";
|
||||
import {ERROR_KEY, SUCCESS_KEY} from "./passport-constants";
|
||||
import { SpamDetector } from "../../utils/spam-detector";
|
||||
import loggerModule from "../../utils/logger";
|
||||
|
||||
const { logger } = loggerModule;
|
||||
|
||||
async function isGoogleAccountFound(email: string) {
|
||||
const q = `
|
||||
@@ -49,12 +53,111 @@ async function handleSignUp(req: Request, email: string, password: string, done:
|
||||
|
||||
if (!team_name) return done(null, null, req.flash(ERROR_KEY, "Team name is required"));
|
||||
|
||||
// Check for spam in team name - Flag suspicious but allow signup
|
||||
const teamNameSpamCheck = SpamDetector.detectSpam(team_name);
|
||||
if (teamNameSpamCheck.score > 0 || teamNameSpamCheck.reasons.length > 0) {
|
||||
logger.warn('⚠️ SUSPICIOUS SIGNUP - TEAM NAME', {
|
||||
email,
|
||||
team_name,
|
||||
user_name: name,
|
||||
spam_score: teamNameSpamCheck.score,
|
||||
reasons: teamNameSpamCheck.reasons,
|
||||
ip_address: req.ip,
|
||||
timestamp: new Date().toISOString(),
|
||||
alert_type: 'suspicious_signup_flagged'
|
||||
});
|
||||
// Continue with signup but flag for review
|
||||
}
|
||||
|
||||
// Check for spam in user name - Flag suspicious but allow signup
|
||||
const userNameSpamCheck = SpamDetector.detectSpam(name);
|
||||
if (userNameSpamCheck.score > 0 || userNameSpamCheck.reasons.length > 0) {
|
||||
logger.warn('⚠️ SUSPICIOUS SIGNUP - USER NAME', {
|
||||
email,
|
||||
team_name,
|
||||
user_name: name,
|
||||
spam_score: userNameSpamCheck.score,
|
||||
reasons: userNameSpamCheck.reasons,
|
||||
ip_address: req.ip,
|
||||
timestamp: new Date().toISOString(),
|
||||
alert_type: 'suspicious_signup_flagged'
|
||||
});
|
||||
// Continue with signup but flag for review
|
||||
}
|
||||
|
||||
// Only block EXTREMELY high-risk content (known spam domains, obvious scams)
|
||||
if (SpamDetector.isHighRiskContent(team_name) || SpamDetector.isHighRiskContent(name)) {
|
||||
// Check if it's REALLY obvious spam (very high scores)
|
||||
const isObviousSpam = teamNameSpamCheck.score > 80 || userNameSpamCheck.score > 80 ||
|
||||
/gclnk\.com|bit\.ly\/scam|win.*\$\d+.*crypto/i.test(team_name + ' ' + name);
|
||||
|
||||
if (isObviousSpam) {
|
||||
logger.error('🛑 SIGNUP BLOCKED - OBVIOUS SPAM', {
|
||||
email,
|
||||
team_name,
|
||||
user_name: name,
|
||||
team_spam_score: teamNameSpamCheck.score,
|
||||
user_spam_score: userNameSpamCheck.score,
|
||||
ip_address: req.ip,
|
||||
timestamp: new Date().toISOString(),
|
||||
alert_type: 'obvious_spam_blocked'
|
||||
});
|
||||
return done(null, null, req.flash(ERROR_KEY, "Registration temporarily unavailable. Please contact support if you need immediate access."));
|
||||
} else {
|
||||
// High-risk but not obviously spam - flag and allow
|
||||
logger.error('🔥 HIGH RISK SIGNUP - FLAGGED', {
|
||||
email,
|
||||
team_name,
|
||||
user_name: name,
|
||||
team_spam_score: teamNameSpamCheck.score,
|
||||
user_spam_score: userNameSpamCheck.score,
|
||||
ip_address: req.ip,
|
||||
timestamp: new Date().toISOString(),
|
||||
alert_type: 'high_risk_signup_flagged'
|
||||
});
|
||||
// Continue with signup but flag for immediate review
|
||||
}
|
||||
}
|
||||
|
||||
const googleAccountFound = await isGoogleAccountFound(email);
|
||||
if (googleAccountFound)
|
||||
return done(null, null, req.flash(ERROR_KEY, `${req.body.email} is already linked with a Google account.`));
|
||||
|
||||
try {
|
||||
const user = await registerUser(password, team_id, name, team_name, email, timezone, team_member_id);
|
||||
|
||||
// If signup was suspicious, flag the team for review after creation
|
||||
const totalSuspicionScore = (teamNameSpamCheck.score || 0) + (userNameSpamCheck.score || 0);
|
||||
if (totalSuspicionScore > 0) {
|
||||
// Flag team for admin review (but don't block user)
|
||||
const flagQuery = `
|
||||
INSERT INTO spam_logs (team_id, user_id, content_type, original_content, spam_score, spam_reasons, action_taken, ip_address)
|
||||
VALUES (
|
||||
(SELECT team_id FROM users WHERE id = $1),
|
||||
$1,
|
||||
'signup_review',
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
'flagged_for_review',
|
||||
$5
|
||||
)
|
||||
`;
|
||||
|
||||
try {
|
||||
await db.query(flagQuery, [
|
||||
user.id,
|
||||
`Team: ${team_name} | User: ${name}`,
|
||||
totalSuspicionScore,
|
||||
JSON.stringify([...teamNameSpamCheck.reasons, ...userNameSpamCheck.reasons]),
|
||||
req.ip
|
||||
]);
|
||||
} catch (flagError) {
|
||||
// Don't fail signup if flagging fails
|
||||
logger.warn('Failed to flag suspicious signup for review', { error: flagError, user_id: user.id });
|
||||
}
|
||||
}
|
||||
|
||||
sendWelcomeEmail(email, name);
|
||||
return done(null, user, req.flash(SUCCESS_KEY, "Registration successful. Please check your email for verification."));
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -60,6 +60,7 @@ 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();
|
||||
|
||||
@@ -122,4 +123,5 @@ api.use("/task-recurring", taskRecurringApiRouter);
|
||||
api.use("/custom-columns", customColumnsApiRouter);
|
||||
|
||||
api.use("/logs", userActivityLogsApiRouter);
|
||||
api.use("/moderation", moderationApiRouter);
|
||||
export default api;
|
||||
|
||||
@@ -11,7 +11,6 @@ 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;
|
||||
|
||||
16
worklenz-backend/src/routes/apis/moderation-api-router.ts
Normal file
16
worklenz-backend/src/routes/apis/moderation-api-router.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import express from "express";
|
||||
import ModerationController from "../../controllers/moderation-controller";
|
||||
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||
|
||||
const moderationApiRouter = express.Router();
|
||||
|
||||
// Admin-only routes for spam/abuse moderation
|
||||
moderationApiRouter.get("/flagged-organizations", safeControllerFunction(ModerationController.getFlaggedOrganizations));
|
||||
moderationApiRouter.post("/flag-organization", safeControllerFunction(ModerationController.flagOrganization));
|
||||
moderationApiRouter.post("/suspend-organization", safeControllerFunction(ModerationController.suspendOrganization));
|
||||
moderationApiRouter.post("/unsuspend-organization", safeControllerFunction(ModerationController.unsuspendOrganization));
|
||||
moderationApiRouter.get("/scan-spam", safeControllerFunction(ModerationController.scanForSpam));
|
||||
moderationApiRouter.get("/stats", safeControllerFunction(ModerationController.getModerationStats));
|
||||
moderationApiRouter.post("/bulk-scan", safeControllerFunction(ModerationController.bulkScanAndFlag));
|
||||
|
||||
export default moderationApiRouter;
|
||||
@@ -6,6 +6,7 @@ import idParamValidator from "../../middlewares/validators/id-param-validator";
|
||||
import teamMembersBodyValidator from "../../middlewares/validators/team-members-body-validator";
|
||||
import teamOwnerOrAdminValidator from "../../middlewares/validators/team-owner-or-admin-validator";
|
||||
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||
import { RateLimiter } from "../../middleware/rate-limiter";
|
||||
|
||||
const teamMembersApiRouter = express.Router();
|
||||
|
||||
@@ -13,7 +14,7 @@ const teamMembersApiRouter = express.Router();
|
||||
teamMembersApiRouter.get("/export-all", safeControllerFunction(TeamMembersController.exportAllMembers));
|
||||
teamMembersApiRouter.get("/export/:id", idParamValidator, safeControllerFunction(TeamMembersController.exportByMember));
|
||||
|
||||
teamMembersApiRouter.post("/", teamOwnerOrAdminValidator, teamMembersBodyValidator, safeControllerFunction(TeamMembersController.create));
|
||||
teamMembersApiRouter.post("/", teamOwnerOrAdminValidator, RateLimiter.inviteRateLimit(5, 15 * 60 * 1000), teamMembersBodyValidator, safeControllerFunction(TeamMembersController.create));
|
||||
teamMembersApiRouter.get("/", safeControllerFunction(TeamMembersController.get));
|
||||
teamMembersApiRouter.get("/list", safeControllerFunction(TeamMembersController.getTeamMemberList));
|
||||
teamMembersApiRouter.get("/tree-map", safeControllerFunction(TeamMembersController.getTeamMembersTreeMap));
|
||||
@@ -30,6 +31,6 @@ teamMembersApiRouter.put("/:id", teamOwnerOrAdminValidator, idParamValidator, sa
|
||||
teamMembersApiRouter.delete("/:id", teamOwnerOrAdminValidator, idParamValidator, safeControllerFunction(TeamMembersController.deleteById));
|
||||
teamMembersApiRouter.get("/deactivate/:id", teamOwnerOrAdminValidator, idParamValidator, safeControllerFunction(TeamMembersController.toggleMemberActiveStatus));
|
||||
|
||||
teamMembersApiRouter.put("/add-member/:id", teamOwnerOrAdminValidator, teamMembersBodyValidator, safeControllerFunction(TeamMembersController.addTeamMember));
|
||||
teamMembersApiRouter.put("/add-member/:id", teamOwnerOrAdminValidator, RateLimiter.inviteRateLimit(3, 10 * 60 * 1000), teamMembersBodyValidator, safeControllerFunction(TeamMembersController.addTeamMember));
|
||||
|
||||
export default teamMembersApiRouter;
|
||||
|
||||
@@ -8,7 +8,6 @@ import resetEmailValidator from "../../middlewares/validators/reset-email-valida
|
||||
import updatePasswordValidator from "../../middlewares/validators/update-password-validator";
|
||||
import passwordValidator from "../../middlewares/validators/password-validator";
|
||||
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||
import FileConstants from "../../shared/file-constants";
|
||||
|
||||
const authRouter = express.Router();
|
||||
|
||||
@@ -56,9 +55,6 @@ authRouter.get("/google/verify", (req, res) => {
|
||||
})(req, res);
|
||||
});
|
||||
|
||||
// Mobile Google Sign-In using Passport strategy
|
||||
authRouter.post("/google/mobile", AuthController.googleMobileAuthPassport);
|
||||
|
||||
// Passport logout
|
||||
authRouter.get("/logout", AuthController.logout);
|
||||
|
||||
|
||||
@@ -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,33 +18,6 @@ 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",
|
||||
@@ -73,33 +46,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 } = {
|
||||
@@ -112,19 +85,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";
|
||||
@@ -140,6 +113,7 @@ 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
|
||||
@@ -151,8 +125,7 @@ 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;
|
||||
@@ -163,7 +136,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}`;
|
||||
@@ -177,16 +150,12 @@ 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"];
|
||||
@@ -203,5 +172,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"
|
||||
};
|
||||
|
||||
244
worklenz-backend/src/utils/spam-detector.ts
Normal file
244
worklenz-backend/src/utils/spam-detector.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import loggerModule from "./logger";
|
||||
|
||||
const { logger } = loggerModule;
|
||||
|
||||
export interface SpamDetectionResult {
|
||||
isSpam: boolean;
|
||||
score: number;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export class SpamDetector {
|
||||
// Whitelist for legitimate organizations that might trigger false positives
|
||||
private static readonly WHITELIST_PATTERNS = [
|
||||
/^(microsoft|google|apple|amazon|facebook|meta|twitter|linkedin|github|stackoverflow)$/i,
|
||||
/^.*(inc|llc|ltd|corp|corporation|company|co|group|enterprises|solutions|services|consulting|tech|technologies|agency|studio|lab|labs|systems|software|development|designs?)$/i,
|
||||
// Allow "free" when it's clearly about software/business
|
||||
/free.*(software|source|lance|consulting|solutions|services|tech|development|range|market|trade)/i,
|
||||
/(open|free).*(software|source)/i,
|
||||
// Common legitimate business patterns
|
||||
/^[a-z]+\s+(software|solutions|services|consulting|tech|technologies|systems|development|designs?|agency|studio|labs?|group|company)$/i,
|
||||
/^(the\s+)?[a-z]+\s+(company|group|studio|agency|lab|labs)$/i
|
||||
];
|
||||
|
||||
private static readonly SPAM_PATTERNS = [
|
||||
// URLs and links
|
||||
/https?:\/\//i,
|
||||
/www\./i,
|
||||
/\b\w+\.(com|net|org|io|co|me|ly|tk|ml|ga|cf|cc|to|us|biz|info|xyz)\b/i,
|
||||
|
||||
// Common spam phrases
|
||||
/click\s*(here|link|now)/i,
|
||||
/urgent|emergency|immediate|limited.time/i,
|
||||
/win|won|winner|prize|reward|congratulations/i,
|
||||
/free|bonus|gift|offer|special.offer/i,
|
||||
/check\s*(out|this|pay)|verify|claim/i,
|
||||
/blockchain|crypto|bitcoin|compensation|investment/i,
|
||||
/cash|money|dollars?|\$\d+|earn.*money/i,
|
||||
|
||||
// Excessive special characters
|
||||
/[!]{2,}/,
|
||||
/[🔔⬅👆💰$💎🎁🎉⚡]{1,}/,
|
||||
/\b[A-Z]{4,}\b/,
|
||||
|
||||
// Suspicious formatting
|
||||
/\s{3,}/,
|
||||
/[.]{3,}/,
|
||||
|
||||
// Additional suspicious patterns
|
||||
/act.now|don.t.miss|guaranteed|limited.spots/i,
|
||||
/download|install|app|software/i,
|
||||
/survey|questionnaire|feedback/i,
|
||||
/\d+%.*off|save.*\$|discount/i
|
||||
];
|
||||
|
||||
private static readonly SUSPICIOUS_WORDS = [
|
||||
"urgent", "emergency", "click", "link", "win", "winner", "prize",
|
||||
"free", "bonus", "cash", "money", "blockchain", "crypto", "compensation",
|
||||
"check", "pay", "reward", "offer", "gift", "congratulations", "claim",
|
||||
"verify", "earn", "investment", "guaranteed", "limited", "exclusive",
|
||||
"download", "install", "survey", "feedback", "discount", "save"
|
||||
];
|
||||
|
||||
public static detectSpam(text: string): SpamDetectionResult {
|
||||
if (!text || typeof text !== "string") {
|
||||
return { isSpam: false, score: 0, reasons: [] };
|
||||
}
|
||||
|
||||
const normalizedText = text.toLowerCase().trim();
|
||||
const reasons: string[] = [];
|
||||
let score = 0;
|
||||
|
||||
// Check for obviously fake organization names FIRST (before whitelist)
|
||||
if (/^(test|example|demo|fake|spam|abuse|temp)\s*(company|org|corp|inc|llc)?$/i.test(text.trim()) ||
|
||||
/(test|demo|fake|spam|abuse|temp)\s*(123|abc|xyz|\d+)/i.test(text)) {
|
||||
score += 30;
|
||||
reasons.push("Contains generic/test name patterns");
|
||||
}
|
||||
|
||||
// Check whitelist - bypass remaining checks for whitelisted organizations
|
||||
if (score === 0) { // Only check whitelist if no generic patterns found
|
||||
for (const pattern of this.WHITELIST_PATTERNS) {
|
||||
if (pattern.test(normalizedText)) {
|
||||
return { isSpam: false, score: 0, reasons: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for URL patterns
|
||||
for (const pattern of this.SPAM_PATTERNS) {
|
||||
if (pattern.test(text)) {
|
||||
score += 25; // Lowered from 30 to catch more suspicious content
|
||||
if (pattern.toString().includes("https?") || pattern.toString().includes("www")) {
|
||||
reasons.push("Contains suspicious URLs or links");
|
||||
} else if (pattern.toString().includes("urgent|emergency")) {
|
||||
reasons.push("Contains urgent/emergency language");
|
||||
} else if (pattern.toString().includes("win|won|winner")) {
|
||||
reasons.push("Contains prize/winning language");
|
||||
} else if (pattern.toString().includes("cash|money")) {
|
||||
reasons.push("Contains monetary references");
|
||||
} else if (pattern.toString().includes("blockchain|crypto")) {
|
||||
reasons.push("Contains cryptocurrency references");
|
||||
} else if (pattern.toString().includes("[!]{3,}")) {
|
||||
reasons.push("Excessive use of exclamation marks");
|
||||
} else if (pattern.toString().includes("[🔔⬅👆💰$]")) {
|
||||
reasons.push("Contains suspicious emojis or symbols");
|
||||
} else if (pattern.toString().includes("[A-Z]{5,}")) {
|
||||
reasons.push("Contains excessive capital letters");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for excessive suspicious words - Now with context awareness
|
||||
const suspiciousWords = this.SUSPICIOUS_WORDS.filter(word => {
|
||||
if (!normalizedText.includes(word)) return false;
|
||||
|
||||
// Context-aware filtering for common false positives
|
||||
if (word === 'free') {
|
||||
// Allow "free" in legitimate software/business contexts
|
||||
return !/free.*(software|source|lance|consulting|solutions|services|tech|development|range|market|trade)/i.test(text);
|
||||
}
|
||||
|
||||
if (word === 'check') {
|
||||
// Allow "check" in legitimate business contexts
|
||||
return !/check.*(list|mark|point|out|up|in|book|ing|ed)/i.test(text);
|
||||
}
|
||||
|
||||
if (word === 'save') {
|
||||
// Allow "save" in legitimate business contexts
|
||||
return !/save.*(data|file|document|time|energy|environment|earth)/i.test(text);
|
||||
}
|
||||
|
||||
return true; // Other words are still suspicious
|
||||
});
|
||||
|
||||
if (suspiciousWords.length >= 1) {
|
||||
score += suspiciousWords.length * 20;
|
||||
reasons.push(`Contains ${suspiciousWords.length} suspicious word${suspiciousWords.length > 1 ? 's' : ''}: ${suspiciousWords.join(', ')}`);
|
||||
}
|
||||
|
||||
// Check text length - very short or very long names are suspicious
|
||||
if (text.length < 2) {
|
||||
score += 20;
|
||||
reasons.push("Text too short");
|
||||
} else if (text.length > 100) {
|
||||
score += 25;
|
||||
reasons.push("Text unusually long");
|
||||
}
|
||||
|
||||
// Check for repeated characters
|
||||
if (/(.)\1{4,}/.test(text)) {
|
||||
score += 20;
|
||||
reasons.push("Contains repeated characters");
|
||||
}
|
||||
|
||||
// Check for mixed scripts (potential homograph attack)
|
||||
const hasLatin = /[a-zA-Z]/.test(text);
|
||||
const hasCyrillic = /[\u0400-\u04FF]/.test(text);
|
||||
const hasGreek = /[\u0370-\u03FF]/.test(text);
|
||||
|
||||
if ((hasLatin && hasCyrillic) || (hasLatin && hasGreek)) {
|
||||
score += 40;
|
||||
reasons.push("Contains mixed character scripts");
|
||||
}
|
||||
|
||||
// Generic name check already done above - skip duplicate check
|
||||
|
||||
// Check for excessive numbers in organization names (often spam)
|
||||
if (/\d{4,}/.test(text)) {
|
||||
score += 25;
|
||||
reasons.push("Contains excessive numbers");
|
||||
}
|
||||
|
||||
const isSpam = score >= 50;
|
||||
|
||||
// Log suspicious activity for Slack notifications
|
||||
if (isSpam || score > 30) {
|
||||
logger.warn("🚨 SPAM DETECTED", {
|
||||
text: text.substring(0, 100),
|
||||
score,
|
||||
reasons: [...new Set(reasons)],
|
||||
isSpam,
|
||||
timestamp: new Date().toISOString(),
|
||||
alert_type: "spam_detection"
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isSpam,
|
||||
score,
|
||||
reasons: [...new Set(reasons)] // Remove duplicates
|
||||
};
|
||||
}
|
||||
|
||||
public static isHighRiskContent(text: string): boolean {
|
||||
const patterns = [
|
||||
/gclnk\.com/i,
|
||||
/bit\.ly\/scam/i, // More specific bit.ly patterns
|
||||
/tinyurl\.com\/scam/i,
|
||||
/\$\d{3,}.*crypto/i, // Money + crypto combination
|
||||
/blockchain.*compensation.*urgent/i,
|
||||
/win.*\$\d+.*urgent/i, // Win money urgent pattern
|
||||
/click.*here.*\$\d+/i // Click here money pattern
|
||||
];
|
||||
|
||||
const isHighRisk = patterns.some(pattern => pattern.test(text));
|
||||
|
||||
// Log high-risk content immediately
|
||||
if (isHighRisk) {
|
||||
logger.error("🔥 HIGH RISK CONTENT DETECTED", {
|
||||
text: text.substring(0, 100),
|
||||
matched_patterns: patterns.filter(pattern => pattern.test(text)).map(p => p.toString()),
|
||||
timestamp: new Date().toISOString(),
|
||||
alert_type: "high_risk_content"
|
||||
});
|
||||
}
|
||||
|
||||
return isHighRisk;
|
||||
}
|
||||
|
||||
public static shouldBlockContent(text: string): boolean {
|
||||
const result = this.detectSpam(text);
|
||||
// Only block if extremely high score or high-risk patterns
|
||||
return result.score > 80 || this.isHighRiskContent(text);
|
||||
}
|
||||
|
||||
public static shouldFlagContent(text: string): boolean {
|
||||
const result = this.detectSpam(text);
|
||||
// Flag anything suspicious (score > 0) but not necessarily blocked
|
||||
return result.score > 0 || result.reasons.length > 0;
|
||||
}
|
||||
|
||||
public static sanitizeText(text: string): string {
|
||||
if (!text || typeof text !== "string") return "";
|
||||
|
||||
return text
|
||||
.trim()
|
||||
.replace(/https?:\/\/[^\s]+/gi, "[URL_REMOVED]")
|
||||
.replace(/www\.[^\s]+/gi, "[URL_REMOVED]")
|
||||
.replace(/[🔔⬅👆💰$]{2,}/g, "")
|
||||
.replace(/[!]{3,}/g, "!")
|
||||
.replace(/\s{3,}/g, " ")
|
||||
.substring(0, 100);
|
||||
}
|
||||
}
|
||||
1
worklenz-frontend/.gitignore
vendored
1
worklenz-frontend/.gitignore
vendored
@@ -11,6 +11,7 @@
|
||||
# production
|
||||
/build
|
||||
/public/tinymce
|
||||
/docs
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
@@ -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,7 +52,6 @@ class HubSpotManager {
|
||||
existingStyle.remove();
|
||||
}
|
||||
|
||||
// Apply dark mode CSS if dark theme is active
|
||||
if (isDark) {
|
||||
this.injectDarkModeCSS();
|
||||
}
|
||||
@@ -122,11 +121,4 @@ 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);
|
||||
})();
|
||||
});
|
||||
@@ -6,12 +6,5 @@
|
||||
"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",
|
||||
"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..."
|
||||
"cancel": "Anulo"
|
||||
}
|
||||
|
||||
@@ -7,9 +7,5 @@
|
||||
"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",
|
||||
"pageTitle": "Menaxho Etiketat",
|
||||
"deleteConfirmTitle": "Jeni i sigurt që dëshironi ta fshini këtë?",
|
||||
"deleteButton": "Fshi",
|
||||
"cancelButton": "Anulo"
|
||||
"colorChangeTooltip": "Klikoni për të ndryshuar ngjyrën"
|
||||
}
|
||||
|
||||
@@ -6,12 +6,5 @@
|
||||
"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",
|
||||
"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..."
|
||||
"cancel": "Abbrechen"
|
||||
}
|
||||
|
||||
@@ -7,9 +7,5 @@
|
||||
"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",
|
||||
"pageTitle": "Labels verwalten",
|
||||
"deleteConfirmTitle": "Sind Sie sicher, dass Sie dies löschen möchten?",
|
||||
"deleteButton": "Löschen",
|
||||
"cancelButton": "Abbrechen"
|
||||
"colorChangeTooltip": "Zum Ändern der Farbe klicken"
|
||||
}
|
||||
|
||||
@@ -6,12 +6,5 @@
|
||||
"reconnecting": "Disconnected from server.",
|
||||
"connection-lost": "Failed to connect to server. Please check your internet connection.",
|
||||
"connection-restored": "Connected to server successfully",
|
||||
"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..."
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
|
||||
@@ -7,9 +7,5 @@
|
||||
"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",
|
||||
"pageTitle": "Manage Labels",
|
||||
"deleteConfirmTitle": "Are you sure you want to delete this?",
|
||||
"deleteButton": "Delete",
|
||||
"cancelButton": "Cancel"
|
||||
"colorChangeTooltip": "Click to change color"
|
||||
}
|
||||
|
||||
@@ -6,12 +6,5 @@
|
||||
"reconnecting": "Reconectando al servidor...",
|
||||
"connection-lost": "Conexión perdida. Intentando reconectarse...",
|
||||
"connection-restored": "Conexión restaurada. Reconectando al servidor...",
|
||||
"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..."
|
||||
"cancel": "Cancelar"
|
||||
}
|
||||
|
||||
@@ -7,9 +7,5 @@
|
||||
"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",
|
||||
"pageTitle": "Administrar Etiquetas",
|
||||
"deleteConfirmTitle": "¿Estás seguro de que quieres eliminar esto?",
|
||||
"deleteButton": "Eliminar",
|
||||
"cancelButton": "Cancelar"
|
||||
"colorChangeTooltip": "Haz clic para cambiar el color"
|
||||
}
|
||||
|
||||
@@ -6,12 +6,5 @@
|
||||
"reconnecting": "Reconectando ao servidor...",
|
||||
"connection-lost": "Conexão perdida. Tentando reconectar...",
|
||||
"connection-restored": "Conexão restaurada. Reconectando ao servidor...",
|
||||
"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..."
|
||||
"cancel": "Cancelar"
|
||||
}
|
||||
|
||||
@@ -7,9 +7,5 @@
|
||||
"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",
|
||||
"pageTitle": "Gerenciar Rótulos",
|
||||
"deleteConfirmTitle": "Tem certeza de que deseja excluir isto?",
|
||||
"deleteButton": "Excluir",
|
||||
"cancelButton": "Cancelar"
|
||||
"colorChangeTooltip": "Clique para mudar a cor"
|
||||
}
|
||||
|
||||
@@ -6,12 +6,5 @@
|
||||
"reconnecting": "与服务器断开连接。",
|
||||
"connection-lost": "无法连接到服务器。请检查您的互联网连接。",
|
||||
"connection-restored": "成功连接到服务器",
|
||||
"cancel": "取消",
|
||||
"update-available": "Worklenz 已更新!",
|
||||
"update-description": "Worklenz 的新版本已可用,具有最新的功能和改进。",
|
||||
"update-instruction": "为了获得最佳体验,请刷新页面以应用新更改。",
|
||||
"update-whats-new": "💡 <1>新增内容:</1>性能增强、错误修复和用户体验改善",
|
||||
"update-now": "立即更新",
|
||||
"update-later": "稍后",
|
||||
"updating": "正在更新..."
|
||||
"cancel": "取消"
|
||||
}
|
||||
@@ -7,9 +7,5 @@
|
||||
"searchPlaceholder": "按名称搜索",
|
||||
"emptyText": "标签可以在更新或创建任务时创建。",
|
||||
"pinTooltip": "点击将其固定到主菜单",
|
||||
"colorChangeTooltip": "点击更改颜色",
|
||||
"pageTitle": "管理标签",
|
||||
"deleteConfirmTitle": "您确定要删除这个吗?",
|
||||
"deleteButton": "删除",
|
||||
"cancelButton": "取消"
|
||||
"colorChangeTooltip": "点击更改颜色"
|
||||
}
|
||||
@@ -325,12 +325,6 @@ 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 });
|
||||
@@ -355,44 +349,6 @@ 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
|
||||
|
||||
@@ -6,7 +6,6 @@ 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';
|
||||
@@ -203,16 +202,14 @@ const App: React.FC = memo(() => {
|
||||
return (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<ThemeWrapper>
|
||||
<UpdateNotificationProvider>
|
||||
<ModuleErrorBoundary>
|
||||
<RouterProvider
|
||||
router={router}
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
}}
|
||||
/>
|
||||
</ModuleErrorBoundary>
|
||||
</UpdateNotificationProvider>
|
||||
<ModuleErrorBoundary>
|
||||
<RouterProvider
|
||||
router={router}
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
}}
|
||||
/>
|
||||
</ModuleErrorBoundary>
|
||||
</ThemeWrapper>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -27,17 +27,12 @@ export const labelsApiService = {
|
||||
|
||||
updateColor: async (labelId: string, color: string): Promise<IServerResponse<ITaskLabel>> => {
|
||||
const response = await apiClient.put<IServerResponse<ITaskLabel>>(
|
||||
`${rootUrl}/tasks/${labelId}`,
|
||||
`${rootUrl}/tasks/${labelId}/color`,
|
||||
{ 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;
|
||||
|
||||
@@ -11,7 +11,9 @@ 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'));
|
||||
@@ -89,6 +91,14 @@ const mainRoutes: RouteObject[] = [
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: `settings/project-templates/edit/:templateId/:templateName`,
|
||||
element: (
|
||||
<Suspense fallback={<SuspenseFallback />}>
|
||||
<ProjectTemplateEditView />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'unauthorized',
|
||||
element: (
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Form, Input, InputRef, Typography, Card, Tooltip } from '@/shared/antd-imports';
|
||||
import { Form, Input, InputRef, Typography, Card, Tooltip, Alert } from '@/shared/antd-imports';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { setOrganizationName } from '@/features/account-setup/account-setup.slice';
|
||||
import { RootState } from '@/app/store';
|
||||
import { sanitizeInput } from '@/utils/sanitizeInput';
|
||||
import { SpamDetector } from '@/utils/spamDetector';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
@@ -29,6 +30,7 @@ export const OrganizationStep: React.FC<Props> = ({
|
||||
const dispatch = useDispatch();
|
||||
const { organizationName } = useSelector((state: RootState) => state.accountSetupReducer);
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const [spamWarning, setSpamWarning] = useState<string>('');
|
||||
|
||||
// Autofill organization name if not already set
|
||||
useEffect(() => {
|
||||
@@ -44,7 +46,19 @@ export const OrganizationStep: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
const handleOrgNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const sanitizedValue = sanitizeInput(e.target.value);
|
||||
const rawValue = e.target.value;
|
||||
const sanitizedValue = sanitizeInput(rawValue);
|
||||
|
||||
// Check for spam patterns
|
||||
const spamCheck = SpamDetector.detectSpam(rawValue);
|
||||
if (spamCheck.isSpam) {
|
||||
setSpamWarning(`Warning: ${spamCheck.reasons.join(', ')}`);
|
||||
} else if (SpamDetector.isHighRiskContent(rawValue)) {
|
||||
setSpamWarning('Warning: Content appears to contain suspicious links or patterns');
|
||||
} else {
|
||||
setSpamWarning('');
|
||||
}
|
||||
|
||||
dispatch(setOrganizationName(sanitizedValue));
|
||||
};
|
||||
|
||||
@@ -60,12 +74,25 @@ export const OrganizationStep: React.FC<Props> = ({
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* Spam Warning */}
|
||||
{spamWarning && (
|
||||
<div className="mb-4">
|
||||
<Alert
|
||||
message={spamWarning}
|
||||
type="warning"
|
||||
showIcon
|
||||
closable
|
||||
onClose={() => setSpamWarning('')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Form Card */}
|
||||
<div className="mb-6">
|
||||
<Card
|
||||
className="border-2 hover:shadow-md transition-all duration-200"
|
||||
style={{
|
||||
borderColor: token?.colorPrimary,
|
||||
borderColor: spamWarning ? token?.colorWarning : token?.colorPrimary,
|
||||
backgroundColor: token?.colorBgContainer
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { EnterOutlined, EditOutlined } from '@/shared/antd-imports';
|
||||
import { Card, Button, Tooltip, Typography } from '@/shared/antd-imports';
|
||||
import { Card, Button, Tooltip, Typography, Alert } from '@/shared/antd-imports';
|
||||
import TextArea from 'antd/es/input/TextArea';
|
||||
import { TFunction } from 'i18next';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { SpamDetector } from '@/utils/spamDetector';
|
||||
|
||||
interface OrganizationNameProps {
|
||||
themeMode: string;
|
||||
@@ -16,6 +17,7 @@ interface OrganizationNameProps {
|
||||
const OrganizationName = ({ themeMode, name, t, refetch }: OrganizationNameProps) => {
|
||||
const [isEditable, setIsEditable] = useState(false);
|
||||
const [newName, setNewName] = useState(name);
|
||||
const [spamWarning, setSpamWarning] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
setNewName(name);
|
||||
@@ -34,7 +36,18 @@ const OrganizationName = ({ themeMode, name, t, refetch }: OrganizationNameProps
|
||||
};
|
||||
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setNewName(e.target.value);
|
||||
const value = e.target.value;
|
||||
setNewName(value);
|
||||
|
||||
// Check for spam patterns
|
||||
const spamCheck = SpamDetector.detectSpam(value);
|
||||
if (spamCheck.isSpam) {
|
||||
setSpamWarning(`Warning: ${spamCheck.reasons.join(', ')}`);
|
||||
} else if (SpamDetector.isHighRiskContent(value)) {
|
||||
setSpamWarning('Warning: Content appears to contain suspicious links or patterns');
|
||||
} else {
|
||||
setSpamWarning('');
|
||||
}
|
||||
};
|
||||
|
||||
const updateOrganizationName = async () => {
|
||||
@@ -62,6 +75,16 @@ const OrganizationName = ({ themeMode, name, t, refetch }: OrganizationNameProps
|
||||
<Typography.Title level={5} style={{ margin: 0, marginBottom: '0.5rem' }}>
|
||||
{t('name')}
|
||||
</Typography.Title>
|
||||
{spamWarning && (
|
||||
<Alert
|
||||
message={spamWarning}
|
||||
type="warning"
|
||||
showIcon
|
||||
closable
|
||||
onClose={() => setSpamWarning('')}
|
||||
style={{ marginBottom: '8px' }}
|
||||
/>
|
||||
)}
|
||||
<div style={{ paddingTop: '8px' }}>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
{isEditable ? (
|
||||
|
||||
@@ -10,6 +10,3 @@ 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';
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Divider, Form, Input, message, Modal, Typography } from '@/shared/antd-imports';
|
||||
import { Divider, Form, Input, message, Modal, Typography, Alert } from '@/shared/antd-imports';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { editTeamName, fetchTeams } from '@/features/teams/teamSlice';
|
||||
import { ITeamGetResponse } from '@/types/teams/team.type';
|
||||
import { SpamDetector } from '@/utils/spamDetector';
|
||||
|
||||
interface EditTeamNameModalProps {
|
||||
team: ITeamGetResponse | null;
|
||||
@@ -16,6 +17,7 @@ const EditTeamNameModal = ({ team, isModalOpen, onCancel }: EditTeamNameModalPro
|
||||
const dispatch = useAppDispatch();
|
||||
const [form] = Form.useForm();
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [spamWarning, setSpamWarning] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (team) {
|
||||
@@ -67,6 +69,16 @@ const EditTeamNameModal = ({ team, isModalOpen, onCancel }: EditTeamNameModalPro
|
||||
destroyOnClose={true}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleFormSubmit}>
|
||||
{spamWarning && (
|
||||
<Alert
|
||||
message={spamWarning}
|
||||
type="warning"
|
||||
showIcon
|
||||
closable
|
||||
onClose={() => setSpamWarning('')}
|
||||
style={{ marginBottom: '16px' }}
|
||||
/>
|
||||
)}
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('name')}
|
||||
@@ -77,7 +89,20 @@ const EditTeamNameModal = ({ team, isModalOpen, onCancel }: EditTeamNameModalPro
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder={t('namePlaceholder')} />
|
||||
<Input
|
||||
placeholder={t('namePlaceholder')}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const spamCheck = SpamDetector.detectSpam(value);
|
||||
if (spamCheck.isSpam) {
|
||||
setSpamWarning(`Warning: ${spamCheck.reasons.join(', ')}`);
|
||||
} else if (SpamDetector.isHighRiskContent(value)) {
|
||||
setSpamWarning('Warning: Content appears to contain suspicious links or patterns');
|
||||
} else {
|
||||
setSpamWarning('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -2,10 +2,8 @@ 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}
|
||||
@@ -19,7 +17,7 @@ const CustomColorLabel = ({ label }: { label: ITaskLabel | null }) => {
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
<Typography.Text style={{ fontSize: 11, color: themeMode === 'dark' ? 'rgba(255, 255, 255, 0.85)' : colors.darkGray }}>
|
||||
<Typography.Text style={{ fontSize: 11, color: colors.darkGray }}>
|
||||
{label?.name}
|
||||
</Typography.Text>
|
||||
</Tag>
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,50 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as UpdateNotification } from './UpdateNotification';
|
||||
export { default as UpdateNotificationProvider } from './UpdateNotificationProvider';
|
||||
@@ -1,141 +0,0 @@
|
||||
// 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
|
||||
};
|
||||
}
|
||||
@@ -4,25 +4,40 @@ 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 className="my-6">
|
||||
<div
|
||||
style={{
|
||||
marginBlock: 96,
|
||||
minHeight: '90vh',
|
||||
marginLeft: `${isMarginAvailable ? '5%' : ''}`,
|
||||
marginRight: `${isMarginAvailable ? '5%' : ''}`,
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={4}>{t('adminCenter')}</Typography.Title>
|
||||
|
||||
{isTablet ? (
|
||||
<Flex
|
||||
gap={24}
|
||||
align="flex-start"
|
||||
className="w-full mt-6"
|
||||
style={{
|
||||
width: '100%',
|
||||
marginBlockStart: 24,
|
||||
}}
|
||||
>
|
||||
<Flex className="w-full max-w-60">
|
||||
<Flex style={{ width: '100%', maxWidth: 240 }}>
|
||||
<AdminCenterSidebar />
|
||||
</Flex>
|
||||
<Flex className="w-full">
|
||||
<Flex style={{ width: '100%' }}>
|
||||
<Outlet />
|
||||
</Flex>
|
||||
</Flex>
|
||||
@@ -30,7 +45,9 @@ const AdminCenterLayout: React.FC = () => {
|
||||
<Flex
|
||||
vertical
|
||||
gap={24}
|
||||
className="mt-6"
|
||||
style={{
|
||||
marginBlockStart: 24,
|
||||
}}
|
||||
>
|
||||
<AdminCenterSidebar />
|
||||
<Outlet />
|
||||
|
||||
@@ -1,25 +1,35 @@
|
||||
import { Flex, Typography } from '@/shared/antd-imports';
|
||||
import SettingsSidebar from '../pages/settings/sidebar/settings-sidebar';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Outlet, useNavigate } 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 className="my-6 min-h-[90vh]">
|
||||
<div style={{ marginBlock: 96, minHeight: '90vh' }}>
|
||||
<Typography.Title level={4}>Settings</Typography.Title>
|
||||
|
||||
{isTablet ? (
|
||||
<Flex
|
||||
gap={24}
|
||||
align="flex-start"
|
||||
className="w-full mt-6"
|
||||
style={{
|
||||
width: '100%',
|
||||
marginBlockStart: 24,
|
||||
}}
|
||||
>
|
||||
<Flex className="w-full max-w-60">
|
||||
<Flex style={{ width: '100%', maxWidth: 240 }}>
|
||||
<SettingsSidebar />
|
||||
</Flex>
|
||||
<Flex className="w-full">
|
||||
<Flex style={{ width: '100%' }}>
|
||||
<Outlet />
|
||||
</Flex>
|
||||
</Flex>
|
||||
@@ -27,7 +37,9 @@ const SettingsLayout = () => {
|
||||
<Flex
|
||||
vertical
|
||||
gap={24}
|
||||
className="mt-6"
|
||||
style={{
|
||||
marginBlockStart: 24,
|
||||
}}
|
||||
>
|
||||
<SettingsSidebar />
|
||||
<Outlet />
|
||||
|
||||
@@ -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/LabelsSettings'));
|
||||
const LabelsSettings = lazy(() => import('../../pages/settings/labels/labels-settings'));
|
||||
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'));
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
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;
|
||||
@@ -8,28 +8,22 @@ 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(t('pageTitle', 'Manage Labels'));
|
||||
useDocumentTitle('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);
|
||||
@@ -70,62 +64,32 @@ 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', 'Label'),
|
||||
onCell: record => ({
|
||||
onClick: () => handleEditClick(record.id!),
|
||||
}),
|
||||
title: t('labelColumn'),
|
||||
render: (record: ITaskLabel) => <CustomColorLabel label={record} />,
|
||||
},
|
||||
{
|
||||
key: 'associatedTask',
|
||||
title: t('associatedTaskColumn', 'Associated Task Count'),
|
||||
title: t('associatedTaskColumn'),
|
||||
render: (record: ITaskLabel) => <Typography.Text>{record.usage}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
key: 'actionBtns',
|
||||
width: 100,
|
||||
width: 60,
|
||||
render: (record: ITaskLabel) => (
|
||||
<div className="action-button opacity-0 transition-opacity duration-200">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -140,12 +104,12 @@ const LabelsSettings = () => {
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder={t('searchPlaceholder', 'Search by name')}
|
||||
placeholder={t('searchPlaceholder')}
|
||||
style={{ maxWidth: 232 }}
|
||||
suffix={<SearchOutlined />}
|
||||
/>
|
||||
|
||||
<Tooltip title={t('pinTooltip', 'Click to pin this into the main menu')} trigger={'hover'}>
|
||||
<Tooltip title={t('pinTooltip')} trigger={'hover'}>
|
||||
{/* this button pin this route to navbar */}
|
||||
<PinRouteToNavbarButton name="labels" path="/worklenz/settings/labels" />
|
||||
</Tooltip>
|
||||
@@ -155,17 +119,13 @@ const LabelsSettings = () => {
|
||||
>
|
||||
<Table
|
||||
locale={{
|
||||
emptyText: <Typography.Text>{t('emptyText', 'Labels can be created while updating or creating tasks.')}</Typography.Text>,
|
||||
emptyText: <Typography.Text>{t('emptyText')}</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,
|
||||
@@ -173,12 +133,6 @@ const LabelsSettings = () => {
|
||||
size: 'small',
|
||||
}}
|
||||
/>
|
||||
|
||||
<LabelsDrawer
|
||||
drawerOpen={showDrawer}
|
||||
labelId={selectedLabelId}
|
||||
drawerClosed={handleDrawerClose}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -313,293 +313,3 @@ 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',
|
||||
];
|
||||
|
||||
@@ -198,17 +198,6 @@ 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;
|
||||
@@ -223,27 +212,6 @@ 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;
|
||||
@@ -300,8 +268,6 @@ export function useServiceWorker() {
|
||||
swManager,
|
||||
clearCache: () => swManager?.clearCache(),
|
||||
forceUpdate: () => swManager?.forceUpdate(),
|
||||
hardReload: () => swManager?.hardReload(),
|
||||
checkForUpdates: () => swManager?.checkForUpdates(),
|
||||
getVersion: () => swManager?.getVersion(),
|
||||
};
|
||||
}
|
||||
141
worklenz-frontend/src/utils/spamDetector.ts
Normal file
141
worklenz-frontend/src/utils/spamDetector.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
export interface SpamDetectionResult {
|
||||
isSpam: boolean;
|
||||
score: number;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export class SpamDetector {
|
||||
private static readonly SPAM_PATTERNS = [
|
||||
// URLs and links
|
||||
/https?:\/\//i,
|
||||
/www\./i,
|
||||
/\b\w+\.(com|net|org|io|co|me|ly|tk|ml|ga|cf)\b/i,
|
||||
|
||||
// Common spam phrases
|
||||
/click\s*(here|link|now)/i,
|
||||
/urgent|emergency|immediate/i,
|
||||
/win|won|winner|prize|reward/i,
|
||||
/free|bonus|gift|offer/i,
|
||||
/check\s*(out|this|pay)/i,
|
||||
/blockchain|crypto|bitcoin|compensation/i,
|
||||
/cash|money|dollars?|\$\d+/i,
|
||||
|
||||
// Excessive special characters
|
||||
/[!]{3,}/,
|
||||
/[🔔⬅👆💰$]{2,}/,
|
||||
/\b[A-Z]{5,}\b/,
|
||||
|
||||
// Suspicious formatting
|
||||
/\s{3,}/,
|
||||
/[.]{3,}/
|
||||
];
|
||||
|
||||
private static readonly SUSPICIOUS_WORDS = [
|
||||
'urgent', 'emergency', 'click', 'link', 'win', 'winner', 'prize',
|
||||
'free', 'bonus', 'cash', 'money', 'blockchain', 'crypto', 'compensation',
|
||||
'check', 'pay', 'reward', 'offer', 'gift'
|
||||
];
|
||||
|
||||
public static detectSpam(text: string): SpamDetectionResult {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return { isSpam: false, score: 0, reasons: [] };
|
||||
}
|
||||
|
||||
const normalizedText = text.toLowerCase().trim();
|
||||
const reasons: string[] = [];
|
||||
let score = 0;
|
||||
|
||||
// Check for URL patterns
|
||||
for (const pattern of this.SPAM_PATTERNS) {
|
||||
if (pattern.test(text)) {
|
||||
score += 30;
|
||||
if (pattern.toString().includes('https?') || pattern.toString().includes('www')) {
|
||||
reasons.push('Contains suspicious URLs or links');
|
||||
} else if (pattern.toString().includes('urgent|emergency')) {
|
||||
reasons.push('Contains urgent/emergency language');
|
||||
} else if (pattern.toString().includes('win|won|winner')) {
|
||||
reasons.push('Contains prize/winning language');
|
||||
} else if (pattern.toString().includes('cash|money')) {
|
||||
reasons.push('Contains monetary references');
|
||||
} else if (pattern.toString().includes('blockchain|crypto')) {
|
||||
reasons.push('Contains cryptocurrency references');
|
||||
} else if (pattern.toString().includes('[!]{3,}')) {
|
||||
reasons.push('Excessive use of exclamation marks');
|
||||
} else if (pattern.toString().includes('[🔔⬅👆💰$]')) {
|
||||
reasons.push('Contains suspicious emojis or symbols');
|
||||
} else if (pattern.toString().includes('[A-Z]{5,}')) {
|
||||
reasons.push('Contains excessive capital letters');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for excessive suspicious words
|
||||
const suspiciousWordCount = this.SUSPICIOUS_WORDS.filter(word =>
|
||||
normalizedText.includes(word)
|
||||
).length;
|
||||
|
||||
if (suspiciousWordCount >= 2) {
|
||||
score += suspiciousWordCount * 15;
|
||||
reasons.push(`Contains ${suspiciousWordCount} suspicious words`);
|
||||
}
|
||||
|
||||
// Check text length - very short or very long names are suspicious
|
||||
if (text.length < 2) {
|
||||
score += 20;
|
||||
reasons.push('Text too short');
|
||||
} else if (text.length > 100) {
|
||||
score += 25;
|
||||
reasons.push('Text unusually long');
|
||||
}
|
||||
|
||||
// Check for repeated characters
|
||||
if (/(.)\1{4,}/.test(text)) {
|
||||
score += 20;
|
||||
reasons.push('Contains repeated characters');
|
||||
}
|
||||
|
||||
// Check for mixed scripts (potential homograph attack)
|
||||
const hasLatin = /[a-zA-Z]/.test(text);
|
||||
const hasCyrillic = /[\u0400-\u04FF]/.test(text);
|
||||
const hasGreek = /[\u0370-\u03FF]/.test(text);
|
||||
|
||||
if ((hasLatin && hasCyrillic) || (hasLatin && hasGreek)) {
|
||||
score += 40;
|
||||
reasons.push('Contains mixed character scripts');
|
||||
}
|
||||
|
||||
const isSpam = score >= 50;
|
||||
|
||||
return {
|
||||
isSpam,
|
||||
score,
|
||||
reasons: [...new Set(reasons)] // Remove duplicates
|
||||
};
|
||||
}
|
||||
|
||||
public static isHighRiskContent(text: string): boolean {
|
||||
const patterns = [
|
||||
/gclnk\.com/i,
|
||||
/bit\.ly/i,
|
||||
/tinyurl/i,
|
||||
/\$\d{3,}/,
|
||||
/blockchain.*compensation/i,
|
||||
/urgent.*check/i
|
||||
];
|
||||
|
||||
return patterns.some(pattern => pattern.test(text));
|
||||
}
|
||||
|
||||
public static sanitizeText(text: string): string {
|
||||
if (!text || typeof text !== 'string') return '';
|
||||
|
||||
return text
|
||||
.trim()
|
||||
.replace(/https?:\/\/[^\s]+/gi, '[URL_REMOVED]')
|
||||
.replace(/www\.[^\s]+/gi, '[URL_REMOVED]')
|
||||
.replace(/[🔔⬅👆💰$]{2,}/g, '')
|
||||
.replace(/[!]{3,}/g, '!')
|
||||
.replace(/\s{3,}/g, ' ')
|
||||
.substring(0, 100);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user