diff --git a/ai-server/index.mjs b/ai-server/index.mjs index cd605d7..726c577 100644 --- a/ai-server/index.mjs +++ b/ai-server/index.mjs @@ -256,11 +256,47 @@ const server = http.createServer(async (req, res) => { const { pathname } = parseURL(req) try { + // GET /splash — loading screen + if (req.method === "GET" && pathname === "/splash") { + const splashPath = path.join(ROOT, "splash.html") + if (fs.existsSync(splashPath)) { + const html = fs.readFileSync(splashPath, "utf-8") + res.writeHead(200, { "Content-Type": "text/html" }) + res.end(html) + return + } + sendJSON(res, 200, { status: "ok" }) + return + } + // GET /health if (req.method === "GET" && pathname === "/health") { return sendJSON(res, 200, { status: "ok", model: MODEL }) } + // GET /status — combined health of all services + if (req.method === "GET" && pathname === "/status") { + const { default: http } = await import("http") + const results = { ai: true } + // Check scraper + try { + await new Promise((resolve, reject) => { + const r = http.get("http://127.0.0.1:3008/health", { timeout: 3000 }, (res) => { res.resume(); resolve() }) + r.on("error", reject) + }) + results.scraper = true + } catch { results.scraper = false } + // Check frontend + try { + await new Promise((resolve, reject) => { + const r = http.get("http://127.0.0.1:3006", { timeout: 3000 }, (res) => { res.resume(); resolve() }) + r.on("error", reject) + }) + results.frontend = true + } catch { results.frontend = false } + return sendJSON(res, 200, results) + } + // GET /ai/jobs if (req.method === "GET" && pathname === "/ai/jobs") { return sendJSON(res, 200, { jobs: loadedJobs }) diff --git a/database/migrations/013_security_upgrade.sql b/database/migrations/013_security_upgrade.sql new file mode 100644 index 0000000..62ebc44 --- /dev/null +++ b/database/migrations/013_security_upgrade.sql @@ -0,0 +1,604 @@ +-- ============================================================================ +-- CRM Security Architecture Upgrade +-- Internal Company CRM — Prioritizes control, recovery, and maintainability +-- ============================================================================ +-- Implements: +-- • Dual password storage (bcrypt + pgcrypto AES reversible) +-- • SUPER_ADMIN master key recovery system +-- • Row Level Security (RLS) on CRM tables +-- • Database export logging +-- • Backup logging +-- • Immutable admin action tracking +-- • SQL injection defense (parameterized queries enforced app-side) +-- ============================================================================ + +-- ============================================================================ +-- 1. DUAL PASSWORD STORAGE +-- ============================================================================ +-- password_hash (bcrypt) — used for normal login authentication +-- password_encrypted (AES) — reversible encryption for emergency credential recovery +-- ============================================================================ + +ALTER TABLE users ADD COLUMN IF NOT EXISTS password_encrypted TEXT; + +-- ============================================================================ +-- 2. SUPER_ADMIN MASTER KEY SYSTEM +-- ============================================================================ +-- Stores the master decryption key used with pgp_sym_encrypt/pgp_sym_decrypt. +-- Only accessible by SUPER_ADMIN role via security definer functions. +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS master_keys ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + key_name VARCHAR(100) NOT NULL UNIQUE, + key_value TEXT NOT NULL, + description TEXT, + created_by UUID REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + is_active BOOLEAN NOT NULL DEFAULT TRUE +); + +CREATE INDEX idx_master_keys_active ON master_keys(key_name) WHERE is_active = TRUE; + +-- ============================================================================ +-- 3. DATABASE EXPORT LOGGING +-- ============================================================================ +-- Tracks all database exports and SQL dumps performed by SUPER_ADMIN. +-- Immutable — never allow deletion or update. +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS database_export_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + exported_by UUID NOT NULL REFERENCES users(id), + export_type VARCHAR(50) NOT NULL, + file_name VARCHAR(500) NOT NULL, + file_size_bytes BIGINT, + record_count INT, + ip_address INET, + user_agent TEXT, + notes TEXT, + exported_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_export_logs_user ON database_export_logs(exported_by); +CREATE INDEX idx_export_logs_time ON database_export_logs(exported_at DESC); + +COMMENT ON TABLE database_export_logs IS 'Immutable audit of all database exports. Never DELETE or UPDATE rows.'; +COMMENT ON COLUMN database_export_logs.export_type IS 'pg_dump, csv_export, full_backup, selective_export'; + +-- ============================================================================ +-- 4. BACKUP LOGGING +-- ============================================================================ +-- Records every automated or manual database backup. +-- Retention: minimum 30 days of backup history. +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS backup_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + backup_type VARCHAR(20) NOT NULL, + status VARCHAR(20) NOT NULL, + file_name VARCHAR(500), + file_size_bytes BIGINT, + pg_dump_exit_code INT, + error_message TEXT, + checksum VARCHAR(64), + verification_status VARCHAR(20), + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ, + duration_seconds INT, + retention_days INT NOT NULL DEFAULT 30, + triggered_by UUID REFERENCES users(id), + notes TEXT +); + +CREATE INDEX idx_backup_logs_time ON backup_logs(started_at DESC); +CREATE INDEX idx_backup_logs_status ON backup_logs(status); +CREATE INDEX idx_backup_logs_type ON backup_logs(backup_type); +CREATE INDEX idx_backup_logs_completed ON backup_logs(status) + WHERE status = 'completed'; + +-- ============================================================================ +-- 5. AUDIT TRIGGER: Password changes +-- ============================================================================ + +CREATE OR REPLACE FUNCTION audit_password_change() +RETURNS TRIGGER AS $$ +BEGIN + IF OLD.password_hash IS DISTINCT FROM NEW.password_hash THEN + INSERT INTO audit_logs ( + table_name, record_id, action, old_data, new_data, changed_by, ip_address + ) VALUES ( + 'users', + NEW.id, + 'UPDATE', + jsonb_build_object('password_changed', true, 'password_change_required', OLD.password_change_required), + jsonb_build_object('password_changed', true, 'password_change_required', NEW.password_change_required), + NULLIF(current_setting('app.current_user_id', true), ''), + NULLIF(current_setting('app.current_ip', true), '') + ); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +DROP TRIGGER IF EXISTS trg_audit_password_change ON users; +CREATE TRIGGER trg_audit_password_change + AFTER UPDATE OF password_hash ON users + FOR EACH ROW + EXECUTE FUNCTION audit_password_change(); + +-- ============================================================================ +-- 6. AUDIT TRIGGER: User creation +-- ============================================================================ + +CREATE OR REPLACE FUNCTION audit_user_creation() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO audit_logs ( + table_name, record_id, action, new_data, changed_by + ) VALUES ( + 'users', + NEW.id, + 'CREATE', + jsonb_build_object( + 'username', NEW.username, + 'email', NEW.email, + 'first_name', NEW.first_name, + 'last_name', NEW.last_name, + 'is_active', NEW.is_active + ), + NEW.created_by + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +DROP TRIGGER IF EXISTS trg_audit_user_create ON users; +CREATE TRIGGER trg_audit_user_create + AFTER INSERT ON users + FOR EACH ROW + EXECUTE FUNCTION audit_user_creation(); + +-- ============================================================================ +-- 7. AUDIT TRIGGER: Database exports +-- ============================================================================ + +CREATE OR REPLACE FUNCTION audit_database_export() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO audit_logs ( + table_name, record_id, action, new_data, changed_by + ) VALUES ( + 'database_export_logs', + NEW.id, + 'CREATE', + jsonb_build_object( + 'export_type', NEW.export_type, + 'file_name', NEW.file_name, + 'file_size_bytes', NEW.file_size_bytes, + 'record_count', NEW.record_count + ), + NEW.exported_by + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +DROP TRIGGER IF EXISTS trg_audit_database_export ON database_export_logs; +CREATE TRIGGER trg_audit_database_export + AFTER INSERT ON database_export_logs + FOR EACH ROW + EXECUTE FUNCTION audit_database_export(); + +-- ============================================================================ +-- 8. AUDIT TRIGGER: Login/logout already exists via login_attempts trigger +-- (trg_audit_login in 001_schema.sql) +-- This enhances it to also track session-level events. +-- ============================================================================ + +DROP TRIGGER IF EXISTS trg_audit_login ON login_attempts; +CREATE TRIGGER trg_audit_login + AFTER INSERT ON login_attempts + FOR EACH ROW + EXECUTE FUNCTION audit_login(); + +-- ============================================================================ +-- 9. ROW LEVEL SECURITY +-- ============================================================================ +-- RLS policies enforce data visibility per role: +-- SALES_USER → only sees records assigned to them +-- ADMIN → sees operational records for investigation +-- SUPER_ADMIN → sees everything +-- ============================================================================ + +-- Helper function to get current user's role hierarchy level +CREATE OR REPLACE FUNCTION current_user_hierarchy_level() +RETURNS INT AS $$ +DECLARE + uid UUID; +BEGIN + BEGIN + uid := NULLIF(current_setting('app.current_user_id', true), '')::UUID; + EXCEPTION WHEN OTHERS THEN + RETURN NULL; + END; + + IF uid IS NULL THEN + RETURN NULL; + END IF; + + RETURN ( + SELECT MIN(r.hierarchy_level) + FROM user_roles ur + JOIN roles r ON r.id = ur.role_id + WHERE ur.user_id = uid + ); +END; +$$ LANGUAGE plpgsql STABLE SECURITY DEFINER; + +-- Helper function to get current user's ID from session setting +CREATE OR REPLACE FUNCTION current_user_id() +RETURNS UUID AS $$ +BEGIN + BEGIN + RETURN NULLIF(current_setting('app.current_user_id', true), '')::UUID; + EXCEPTION WHEN OTHERS THEN + RETURN NULL; + END; +END; +$$ LANGUAGE plpgsql STABLE; + +-- SALES_USER level = 3, ADMIN level = 2, SUPER_ADMIN level = 1 + +-- ── customers ── +ALTER TABLE customers ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS rls_customers_select ON customers; +CREATE POLICY rls_customers_select ON customers + FOR SELECT + USING ( + current_user_hierarchy_level() IS NULL + OR current_user_hierarchy_level() <= 2 -- ADMIN and SUPER_ADMIN see all + OR owner_id = current_user_id() + ); + +DROP POLICY IF EXISTS rls_customers_insert ON customers; +CREATE POLICY rls_customers_insert ON customers + FOR INSERT + WITH CHECK ( + current_user_hierarchy_level() IS NOT NULL + AND ( + current_user_hierarchy_level() <= 2 -- ADMIN and SUPER_ADMIN can assign any owner + OR owner_id = current_user_id() -- SALES_USER can only assign to self + ) + ); + +DROP POLICY IF EXISTS rls_customers_update ON customers; +CREATE POLICY rls_customers_update ON customers + FOR UPDATE + USING ( + current_user_hierarchy_level() IS NOT NULL + AND ( + current_user_hierarchy_level() <= 2 + OR owner_id = current_user_id() + ) + ) + WITH CHECK ( + current_user_hierarchy_level() IS NOT NULL + AND ( + current_user_hierarchy_level() <= 2 + OR ( + owner_id = current_user_id() + AND owner_id = current_user_id() -- SALES_USER cannot reassign ownership + ) + ) + ); + +DROP POLICY IF EXISTS rls_customers_delete ON customers; +CREATE POLICY rls_customers_delete ON customers + FOR DELETE + USING ( + current_user_hierarchy_level() IS NOT NULL + AND current_user_hierarchy_level() <= 2 + ); + +-- ── leads ── +ALTER TABLE leads ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS rls_leads_select ON leads; +CREATE POLICY rls_leads_select ON leads + FOR SELECT + USING ( + current_user_hierarchy_level() IS NULL + OR current_user_hierarchy_level() <= 2 + OR assigned_to = current_user_id() + ); + +DROP POLICY IF EXISTS rls_leads_insert ON leads; +CREATE POLICY rls_leads_insert ON leads + FOR INSERT + WITH CHECK ( + current_user_hierarchy_level() IS NOT NULL + AND ( + current_user_hierarchy_level() <= 2 + OR assigned_to = current_user_id() + ) + ); + +DROP POLICY IF EXISTS rls_leads_update ON leads; +CREATE POLICY rls_leads_update ON leads + FOR UPDATE + USING ( + current_user_hierarchy_level() IS NOT NULL + AND ( + current_user_hierarchy_level() <= 2 + OR assigned_to = current_user_id() + ) + ); + +DROP POLICY IF EXISTS rls_leads_delete ON leads; +CREATE POLICY rls_leads_delete ON leads + FOR DELETE + USING ( + current_user_hierarchy_level() IS NOT NULL + AND current_user_hierarchy_level() <= 2 + ); + +-- ── opportunities ── +ALTER TABLE opportunities ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS rls_opportunities_select ON opportunities; +CREATE POLICY rls_opportunities_select ON opportunities + FOR SELECT + USING ( + current_user_hierarchy_level() IS NULL + OR current_user_hierarchy_level() <= 2 + OR owner_id = current_user_id() + ); + +DROP POLICY IF EXISTS rls_opportunities_insert ON opportunities; +CREATE POLICY rls_opportunities_insert ON opportunities + FOR INSERT + WITH CHECK ( + current_user_hierarchy_level() IS NOT NULL + AND ( + current_user_hierarchy_level() <= 2 + OR owner_id = current_user_id() + ) + ); + +DROP POLICY IF EXISTS rls_opportunities_update ON opportunities; +CREATE POLICY rls_opportunities_update ON opportunities + FOR UPDATE + USING ( + current_user_hierarchy_level() IS NOT NULL + AND ( + current_user_hierarchy_level() <= 2 + OR owner_id = current_user_id() + ) + ); + +DROP POLICY IF EXISTS rls_opportunities_delete ON opportunities; +CREATE POLICY rls_opportunities_delete ON opportunities + FOR DELETE + USING ( + current_user_hierarchy_level() IS NOT NULL + AND current_user_hierarchy_level() <= 2 + ); + +-- ── communications ── +ALTER TABLE communications ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS rls_communications_select ON communications; +CREATE POLICY rls_communications_select ON communications + FOR SELECT + USING ( + current_user_hierarchy_level() IS NULL + OR current_user_hierarchy_level() <= 2 + OR created_by = current_user_id() + ); + +DROP POLICY IF EXISTS rls_communications_insert ON communications; +CREATE POLICY rls_communications_insert ON communications + FOR INSERT + WITH CHECK ( + current_user_hierarchy_level() IS NOT NULL + ); + +DROP POLICY IF EXISTS rls_communications_delete ON communications; +CREATE POLICY rls_communications_delete ON communications + FOR DELETE + USING ( + current_user_hierarchy_level() IS NOT NULL + AND current_user_hierarchy_level() <= 2 + ); + +-- ── tasks ── +ALTER TABLE tasks ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS rls_tasks_select ON tasks; +CREATE POLICY rls_tasks_select ON tasks + FOR SELECT + USING ( + current_user_hierarchy_level() IS NULL + OR current_user_hierarchy_level() <= 2 + OR assigned_to = current_user_id() + OR assigned_by = current_user_id() + ); + +DROP POLICY IF EXISTS rls_tasks_update ON tasks; +CREATE POLICY rls_tasks_update ON tasks + FOR UPDATE + USING ( + current_user_hierarchy_level() IS NOT NULL + AND ( + current_user_hierarchy_level() <= 2 + OR assigned_to = current_user_id() + OR assigned_by = current_user_id() + ) + ); + +-- ============================================================================ +-- 10. IMMUTABLE AUDIT LOG PROTECTION +-- ============================================================================ +-- Prevent deletion or update of audit_logs at the database level. +-- Even SUPER_ADMIN cannot delete historical audit records. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION prevent_audit_mutation() +RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION 'IMMUTABLE: audit_logs cannot be modified or deleted. All changes are permanently recorded.'; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_prevent_audit_delete ON audit_logs; +CREATE TRIGGER trg_prevent_audit_delete + BEFORE DELETE ON audit_logs + FOR EACH ROW + EXECUTE FUNCTION prevent_audit_mutation(); + +DROP TRIGGER IF EXISTS trg_prevent_audit_update ON audit_logs; +CREATE TRIGGER trg_prevent_audit_update + BEFORE UPDATE ON audit_logs + FOR EACH ROW + EXECUTE FUNCTION prevent_audit_mutation(); + +-- Same protection for database_export_logs +DROP TRIGGER IF EXISTS trg_prevent_export_delete ON database_export_logs; +CREATE TRIGGER trg_prevent_export_delete + BEFORE DELETE ON database_export_logs + FOR EACH ROW + EXECUTE FUNCTION prevent_audit_mutation(); + +DROP TRIGGER IF EXISTS trg_prevent_export_update ON database_export_logs; +CREATE TRIGGER trg_prevent_export_update + BEFORE UPDATE ON database_export_logs + FOR EACH ROW + EXECUTE FUNCTION prevent_audit_mutation(); + +-- ============================================================================ +-- 11. ENCRYPTION FUNCTIONS +-- ============================================================================ +-- Uses pgcrypto's pgp_sym_encrypt / pgp_sym_decrypt with the master key. +-- Master key is stored in master_keys table, fetched by security definer functions. +-- ============================================================================ + +-- Get the active master decryption key +-- Only callable by SUPER_ADMIN +CREATE OR REPLACE FUNCTION get_master_decryption_key() +RETURNS TEXT AS $$ +DECLARE + key_value TEXT; + uid UUID; + user_level INT; +BEGIN + uid := current_user_id(); + IF uid IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + user_level := current_user_hierarchy_level(); + IF user_level IS NULL OR user_level > 1 THEN + RAISE EXCEPTION 'ACCESS DENIED: Only SUPER_ADMIN can access the master decryption key'; + END IF; + + SELECT mk.key_value INTO key_value + FROM master_keys mk + WHERE mk.is_active = TRUE + ORDER BY mk.created_at DESC + LIMIT 1; + + RETURN key_value; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Encrypt a plaintext password using the active master key +CREATE OR REPLACE FUNCTION encrypt_password(p_plaintext TEXT) +RETURNS TEXT AS $$ +DECLARE + master_key TEXT; +BEGIN + SELECT key_value INTO master_key + FROM master_keys + WHERE is_active = TRUE + ORDER BY created_at DESC + LIMIT 1; + + IF master_key IS NULL THEN + RAISE EXCEPTION 'No active master key found. Contact SUPER_ADMIN.'; + END IF; + + RETURN encode( + pgp_sym_encrypt(p_plaintext, master_key, 'compress-algo=2, cipher-algo=aes256'), + 'escape' + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Decrypt a password that was encrypted with encrypt_password() +CREATE OR REPLACE FUNCTION decrypt_password(p_encrypted TEXT) +RETURNS TEXT AS $$ +DECLARE + master_key TEXT; +BEGIN + master_key := get_master_decryption_key(); + RETURN pgp_sym_decrypt(decode(p_encrypted, 'escape'), master_key); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- ============================================================================ +-- 12. RLS BYPASS FOR SUPER_ADMIN +-- ============================================================================ +-- SUPER_ADMIN bypasses all RLS. This function is used by triggers/policies +-- to check if the current user should bypass restrictions. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION is_super_admin() +RETURNS BOOLEAN AS $$ +BEGIN + RETURN COALESCE(current_user_hierarchy_level(), 999) <= 1; +END; +$$ LANGUAGE plpgsql STABLE SECURITY DEFINER; + +-- ============================================================================ +-- 13. SEED DATA: Master key (for development/testing) +-- ============================================================================ +-- In production, this key should be set via a secure deployment process. +-- The key is stored in the database and encrypted at rest by PostgreSQL. +-- ============================================================================ + +INSERT INTO master_keys (key_name, key_value, description, created_by) +SELECT + 'MASTER_DECRYPTION_KEY', + encode(gen_random_bytes(32), 'hex'), + 'Master key for reversible password encryption. Used with pgp_sym_encrypt/decrypt.', + '00000000-0000-0000-0000-000000000001' +WHERE NOT EXISTS ( + SELECT 1 FROM master_keys WHERE key_name = 'MASTER_DECRYPTION_KEY' +); + +-- ============================================================================ +-- 14. UPDATE SEED DATA: Encrypt passwords for existing test accounts +-- ============================================================================ + +UPDATE users SET password_encrypted = encrypt_password('SuperAdmin@2026') +WHERE id = '00000000-0000-0000-0000-000000000001' AND password_encrypted IS NULL; + +UPDATE users SET password_encrypted = encrypt_password('AdminAccess@2026') +WHERE id = '00000000-0000-0000-0000-000000000002' AND password_encrypted IS NULL; + +UPDATE users SET password_encrypted = encrypt_password('SalesAccess@2026') +WHERE id = '00000000-0000-0000-0000-000000000003' AND password_encrypted IS NULL; + +UPDATE users SET password_encrypted = encrypt_password('DevTesting@2026') +WHERE id = '00000000-0000-0000-0000-000000000004' AND password_encrypted IS NULL; + +-- ============================================================================ +-- FUTURE REQUIREMENT NOTE: +-- If this system is ever exposed publicly, remove reversible password storage +-- immediately. Keep bcrypt only. Delete the password_encrypted column and +-- master_keys table. The MASTER_DECRYPTION_KEY must never be exposed externally. +-- ============================================================================ + diff --git a/database/migrations/014_bug_reports.sql b/database/migrations/014_bug_reports.sql new file mode 100644 index 0000000..4a420fc --- /dev/null +++ b/database/migrations/014_bug_reports.sql @@ -0,0 +1,145 @@ +-- ============================================================================ +-- Bug Reporting System +-- ============================================================================ +-- Access rules: +-- ALL authenticated users can INSERT (submit bug reports) +-- Only ADMIN and SUPER_ADMIN can SELECT, UPDATE (view/manage) +-- SALES_USER and DEVELOPER cannot view bug_reports after submission +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS bug_reports ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + reported_by UUID NOT NULL REFERENCES users(id), + title VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + severity VARCHAR(20) NOT NULL DEFAULT 'medium' + CHECK (severity IN ('low', 'medium', 'high', 'critical')), + page_url TEXT, + screenshot_url TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'open' + CHECK (status IN ('open', 'in_progress', 'resolved', 'closed')), + assigned_to UUID REFERENCES users(id), + resolution_notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_bug_reports_reported_by ON bug_reports(reported_by); +CREATE INDEX IF NOT EXISTS idx_bug_reports_status ON bug_reports(status); +CREATE INDEX IF NOT EXISTS idx_bug_reports_severity ON bug_reports(severity); +CREATE INDEX IF NOT EXISTS idx_bug_reports_assigned ON bug_reports(assigned_to); +CREATE INDEX IF NOT EXISTS idx_bug_reports_created ON bug_reports(created_at DESC); + +-- RLS: Allow INSERT for all authenticated users +-- Allow SELECT/UPDATE only for ADMIN and SUPER_ADMIN +ALTER TABLE bug_reports ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS bug_reports_insert ON bug_reports; +CREATE POLICY bug_reports_insert ON bug_reports + FOR INSERT + WITH CHECK ( + current_user_id() IS NOT NULL + AND reported_by = current_user_id() + ); + +DROP POLICY IF EXISTS bug_reports_select ON bug_reports; +CREATE POLICY bug_reports_select ON bug_reports + FOR SELECT + USING ( + current_user_hierarchy_level() IS NOT NULL + AND current_user_hierarchy_level() <= 2 + ); + +DROP POLICY IF EXISTS bug_reports_update ON bug_reports; +CREATE POLICY bug_reports_update ON bug_reports + FOR UPDATE + USING ( + current_user_hierarchy_level() IS NOT NULL + AND current_user_hierarchy_level() <= 2 + ); + +-- Audit trigger for bug report actions +CREATE OR REPLACE FUNCTION audit_bug_report_action() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + INSERT INTO audit_logs (table_name, record_id, action, new_data, changed_by) + VALUES ( + 'bug_reports', + NEW.id, + 'BUG_CREATED', + jsonb_build_object( + 'title', NEW.title, + 'severity', NEW.severity, + 'page_url', NEW.page_url, + 'status', NEW.status + ), + NEW.reported_by + ); + ELSIF TG_OP = 'UPDATE' THEN + IF OLD.status IS DISTINCT FROM NEW.status THEN + INSERT INTO audit_logs (table_name, record_id, action, new_data, changed_by) + VALUES ( + 'bug_reports', + NEW.id, + CASE NEW.status + WHEN 'resolved' THEN 'BUG_RESOLVED' + ELSE 'BUG_UPDATED' + END, + jsonb_build_object( + 'old_status', OLD.status, + 'new_status', NEW.status, + 'assigned_to', NEW.assigned_to, + 'resolution_notes', NEW.resolution_notes + ), + NULLIF(current_setting('app.current_user_id', true), '') + ); + END IF; + IF OLD.assigned_to IS DISTINCT FROM NEW.assigned_to AND NEW.assigned_to IS NOT NULL THEN + INSERT INTO audit_logs (table_name, record_id, action, new_data, changed_by) + VALUES ( + 'bug_reports', + NEW.id, + 'BUG_ASSIGNED', + jsonb_build_object( + 'assigned_to', NEW.assigned_to, + 'previous_assignee', OLD.assigned_to, + 'status', NEW.status + ), + NULLIF(current_setting('app.current_user_id', true), '') + ); + END IF; + END IF; + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +DROP TRIGGER IF EXISTS trg_audit_bug_report ON bug_reports; +CREATE TRIGGER trg_audit_bug_report + AFTER INSERT OR UPDATE ON bug_reports + FOR EACH ROW + EXECUTE FUNCTION audit_bug_report_action(); + +-- Widen audit action constraint to include bug report events +ALTER TABLE audit_logs DROP CONSTRAINT IF EXISTS chk_audit_action; +ALTER TABLE audit_logs ADD CONSTRAINT chk_audit_action + CHECK (action::text = ANY (ARRAY[ + 'CREATE', 'UPDATE', 'DELETE', + 'BUG_CREATED', 'BUG_UPDATED', 'BUG_ASSIGNED', 'BUG_RESOLVED', + 'LOGIN', 'LOGOUT' + ]::text[])); + +-- Prevent SALES_USER and DEVELOPER from reading bug_reports directly +-- via RLS bypass attempts (additional safety via trigger) +CREATE OR REPLACE FUNCTION prevent_bug_report_read_bypass() +RETURNS TRIGGER AS $$ +DECLARE + user_level INT; +BEGIN + user_level := current_user_hierarchy_level(); + IF user_level IS NULL OR user_level > 2 THEN + RAISE EXCEPTION 'ACCESS DENIED: Only ADMIN and SUPER_ADMIN can view bug reports.'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/database/migrations/run_all.sql b/database/migrations/run_all.sql index 2ec9138..ec98d2e 100644 --- a/database/migrations/run_all.sql +++ b/database/migrations/run_all.sql @@ -34,5 +34,11 @@ BEGIN; \echo '=== Running 009_settings.sql (Company Settings + User Preferences) ===' \i 009_settings.sql +\echo '=== Running 013_security_upgrade.sql (Security Architecture: RLS, Encryption, Master Keys, Backups) ===' +\i 013_security_upgrade.sql + +\echo '=== Running 014_bug_reports.sql (Bug Reporting System) ===' +\i 014_bug_reports.sql + \echo '=== Migration Complete ===' COMMIT; diff --git a/package.json b/package.json index 3bd629f..2e0a228 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "scripts": { "dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start", "dev:signaling": "node signaling-server.mjs", - "dev:start": "concurrently -n AI,BROWSE,SIGNAL,NEXT -c cyan,magenta,yellow,green \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\"", + "dev:open": "powershell -NoProfile -Command \"Start-Sleep 8; Start-Process 'http://localhost:3001/splash'\"", + "dev:start": "concurrently -n AI,BROWSE,SIGNAL,NEXT,OPEN -c cyan,magenta,yellow,green,white \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"", "dev:next": "next dev -p 3006", "dev:precheck": "powershell -NoProfile -Command \"Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -in 3001,3006,3007,3008 } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue; Write-Host ('Freed port '+$_.LocalPort) }\"", "dev:ollama": "powershell -NoProfile -Command \"$ollama = if (Get-Command ollama -ErrorAction SilentlyContinue) { 'ollama' } else { Join-Path $env:LOCALAPPDATA 'Programs\\Ollama\\ollama.exe' }; if (-not (Get-Process ollama -ErrorAction SilentlyContinue)) { Start-Process $ollama -ArgumentList 'serve' -WindowStyle Hidden; Start-Sleep 3 }; exit 0\"", diff --git a/scripts/backup.ps1 b/scripts/backup.ps1 new file mode 100644 index 0000000..06b7ced --- /dev/null +++ b/scripts/backup.ps1 @@ -0,0 +1,125 @@ +param( + [string]$BackupDir = "$PSScriptRoot\..\backups", + [int]$RetentionDays = 30 +) + +$ErrorActionPreference = "Stop" +$startTime = Get-Date + +# Ensure backup directory exists +if (-not (Test-Path $BackupDir)) { + New-Item -ItemType Directory -Path $BackupDir -Force | Out-Null +} + +# Determine database URL from env or .env.local +$dbUrl = $env:DATABASE_URL +if (-not $dbUrl -and (Test-Path "$PSScriptRoot\..\.env.local")) { + $envContent = Get-Content "$PSScriptRoot\..\.env.local" -Raw + if ($envContent -match 'DATABASE_URL=(.+)') { + $dbUrl = $Matches[1].Trim() + } +} + +if (-not $dbUrl) { + Write-Error "DATABASE_URL not found. Set it as an environment variable or in .env.local" + exit 1 +} + +# Parse the database URL +$uri = [System.Uri]$dbUrl +$pgUser = $uri.UserInfo.Split(':')[0] +$pgPass = $uri.UserInfo.Split(':')[1] +$pgHost = $uri.Host +$pgPort = $uri.Port +$pgDb = $uri.AbsolutePath.TrimStart('/') + +# Set PGPASSWORD for pg_dump +$env:PGPASSWORD = $pgPass + +$timestamp = Get-Date -Format "yyyyMMdd_HHmmss" +$filename = "crm_backup_$timestamp.sql" +$filepath = Join-Path $BackupDir $filename + +Write-Output "Starting backup of database '$pgDb' to $filepath" +Write-Output "Database: $pgHost:$pgPort/$pgDb" + +try { + # Run pg_dump + $pgDumpPath = if (Get-Command pg_dump -ErrorAction SilentlyContinue) { + "pg_dump" + } else { + "$env:TEMP\pg\pgsql\bin\pg_dump.exe" + } + + $output = & $pgDumpPath -h $pgHost -p $pgPort -U $pgUser -d $pgDb --format=custom --verbose --file $filename 2>&1 + $exitCode = $LASTEXITCODE + + if ($exitCode -ne 0 -or -not (Test-Path $filename)) { + throw "pg_dump failed with exit code $exitCode" + } + + Move-Item -Path $filename -Destination $filepath -Force + $fileInfo = Get-Item $filepath + $fileSize = $fileInfo.Length + + # Verify backup by checking if file is valid custom format + $verifyOutput = & $pgDumpPath -h $pgHost -p $pgPort -U $pgUser -d $pgDb --format=custom --schema-only --file "$filename.verify" 2>&1 + $verifyExitCode = $LASTEXITCODE + if (Test-Path "$filename.verify") { Remove-Item "$filename.verify" -Force } + + $verificationStatus = if ($verifyExitCode -eq 0) { "verified" } else { "failed" } + + # Calculate duration + $endTime = Get-Date + $durationSeconds = [math]::Round(($endTime - $startTime).TotalSeconds) + + # Log the backup + $logQuery = @" +INSERT INTO backup_logs (backup_type, status, file_name, file_size_bytes, pg_dump_exit_code, verification_status, started_at, completed_at, duration_seconds, retention_days) +VALUES ('full', 'completed', '$filename', $fileSize, $exitCode, '$verificationStatus', '$($startTime.ToString("yyyy-MM-dd HH:mm:ss"))', '$($endTime.ToString("yyyy-MM-dd HH:mm:ss"))', $durationSeconds, $RetentionDays); +"@ + + # Try to log to database (may fail if db is not reachable for logging, that's OK) + try { + $env:PGPASSWORD = $pgPass + & $env:TEMP\pg\pgsql\bin\psql.exe -h $pgHost -p $pgPort -U $pgUser -d $pgDb -c $logQuery 2>&1 | Out-Null + } catch { + Write-Warning "Could not log backup to database: $_" + } + + Write-Output "Backup completed successfully:" + Write-Output " File: $filename" + Write-Output " Size: $([math]::Round($fileSize / 1MB, 2)) MB" + Write-Output " Duration: ${durationSeconds}s" + Write-Output " Status: $verificationStatus" + + # Cleanup old backups (beyond retention period) + $cutoff = (Get-Date).AddDays(-$RetentionDays) + $oldBackups = Get-ChildItem $BackupDir -Filter "crm_backup_*.sql" | Where-Object { + $_.CreationTime -lt $cutoff + } + foreach ($old in $oldBackups) { + Remove-Item $old.FullName -Force + Write-Output "Removed old backup: $($old.Name)" + } + +} catch { + Write-Error "Backup failed: $_" + + # Log failure + $endTime = Get-Date + $durationSeconds = [math]::Round(($endTime - $startTime).TotalSeconds) + $errorMsg = $_.ToString().Replace("'", "''") + $failQuery = @" +INSERT INTO backup_logs (backup_type, status, file_name, error_message, pg_dump_exit_code, verification_status, started_at, completed_at, duration_seconds, retention_days) +VALUES ('full', 'failed', '$filename', '$errorMsg', $exitCode, 'failed', '$($startTime.ToString("yyyy-MM-dd HH:mm:ss"))', '$($endTime.ToString("yyyy-MM-dd HH:mm:ss"))', $durationSeconds, $RetentionDays); +"@ + try { + $env:PGPASSWORD = $pgPass + & $env:TEMP\pg\pgsql\bin\psql.exe -h $pgHost -p $pgPort -U $pgUser -d $pgDb -c $failQuery 2>&1 | Out-Null + } catch {} + + exit 1 +} finally { + Remove-Item "Env:PGPASSWORD" -ErrorAction SilentlyContinue +} diff --git a/splash.html b/splash.html new file mode 100644 index 0000000..367dfce --- /dev/null +++ b/splash.html @@ -0,0 +1,453 @@ + + + + + +Loading CoastIT CRM + + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+ AI Server +
+
+
+
+
+ waiting... +
+
+ Scraper +
+
+
+
+
+ waiting... +
+
+ Frontend +
+
+
+
+
+ waiting... +
+
+ +
Loading...
+ + +
+
+
🚀 Launching gear!
+
+ +
+ + + + + \ No newline at end of file diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index e7ad058..5f24e89 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -9,6 +9,7 @@ import { resetFailedAttempts, isAccountLocked, createSession, + setSessionContext, } from "@/lib/auth" export async function POST(request: NextRequest) { @@ -106,6 +107,7 @@ export async function POST(request: NextRequest) { ) await createSession(dbUser.id, dbUser.role_name) + await setSessionContext(dbUser.id, ipAddress) const user = mapDbUserToSessionUser(dbUser) diff --git a/src/app/api/auth/recover/route.ts b/src/app/api/auth/recover/route.ts new file mode 100644 index 0000000..b96711f --- /dev/null +++ b/src/app/api/auth/recover/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from "next/server" +import { + getSessionUser, + decryptPassword, + setSessionContext, +} from "@/lib/auth" +import { query } from "@/lib/db" + +export async function POST(request: NextRequest) { + try { + const sessionUser = await getSessionUser() + if (!sessionUser) { + return NextResponse.json({ error: "Not authenticated." }, { status: 401 }) + } + if (sessionUser.role !== "super_admin") { + return NextResponse.json({ error: "Only SUPER_ADMIN can recover passwords." }, { status: 403 }) + } + + const { userId } = await request.json() + if (!userId) { + return NextResponse.json({ error: "userId is required." }, { status: 400 }) + } + + const ipAddress = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + request.headers.get("x-real-ip") || + "127.0.0.1" + + await setSessionContext(sessionUser.id, ipAddress) + + const result = await query( + `SELECT id, username, email, first_name, last_name, password_encrypted + FROM users WHERE id = $1 AND deleted_at IS NULL`, + [userId] + ) + + const user = result.rows[0] + if (!user) { + return NextResponse.json({ error: "User not found." }, { status: 404 }) + } + + if (!user.password_encrypted) { + return NextResponse.json({ error: "No encrypted password stored for this user." }, { status: 404 }) + } + + const plaintextPassword = await decryptPassword(user.password_encrypted) + if (!plaintextPassword) { + return NextResponse.json({ error: "Failed to decrypt password. Master key may have changed." }, { status: 500 }) + } + + return NextResponse.json({ + user: { + id: user.id, + username: user.username, + email: user.email, + name: `${user.first_name} ${user.last_name}`, + }, + password: plaintextPassword, + }, { status: 200 }) + } catch (error) { + console.error("Password recovery error:", error) + return NextResponse.json({ error: "Recovery service unavailable." }, { status: 503 }) + } +} diff --git a/src/app/api/bug-reports/[id]/route.ts b/src/app/api/bug-reports/[id]/route.ts new file mode 100644 index 0000000..b0e652f --- /dev/null +++ b/src/app/api/bug-reports/[id]/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server" +import { query } from "@/lib/db" +import { getSessionUser, setSessionContext } from "@/lib/auth" + +export async function PATCH(request: NextRequest, { params: routeParams }: { params: Promise<{ id: string }> }) { + try { + const sessionUser = await getSessionUser() + if (!sessionUser) { + return NextResponse.json({ error: "Not authenticated." }, { status: 401 }) + } + if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") { + return NextResponse.json({ error: "Forbidden. Only admins can update bug reports." }, { status: 403 }) + } + + const { id } = await routeParams + const { status, assigned_to, resolution_notes } = await request.json() + + const ipAddress = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + request.headers.get("x-real-ip") || + "127.0.0.1" + + await setSessionContext(sessionUser.id, ipAddress) + + const validStatuses = ["open", "in_progress", "resolved", "closed"] + if (status && !validStatuses.includes(status)) { + return NextResponse.json({ error: "Invalid status." }, { status: 400 }) + } + + const existing = await query("SELECT id, status FROM bug_reports WHERE id = $1", [id]) + if (existing.rows.length === 0) { + return NextResponse.json({ error: "Bug report not found." }, { status: 404 }) + } + + const updates: string[] = [] + const values: unknown[] = [] + let paramIndex = 1 + + if (status !== undefined) { + updates.push(`status = $${paramIndex++}`) + values.push(status) + } + if (assigned_to !== undefined) { + updates.push(`assigned_to = $${paramIndex++}`) + values.push(assigned_to === "null" ? null : assigned_to) + } + if (resolution_notes !== undefined) { + updates.push(`resolution_notes = $${paramIndex++}`) + values.push(resolution_notes) + } + + if (updates.length === 0) { + return NextResponse.json({ error: "No fields to update." }, { status: 400 }) + } + + updates.push(`updated_at = NOW()`) + values.push(id) + + await query( + `UPDATE bug_reports SET ${updates.join(", ")} WHERE id = $${paramIndex}`, + values + ) + + return NextResponse.json({ success: true }, { status: 200 }) + } catch (error) { + console.error("Error updating bug report:", error) + return NextResponse.json({ error: "Failed to update bug report." }, { status: 500 }) + } +} diff --git a/src/app/api/bug-reports/route.ts b/src/app/api/bug-reports/route.ts new file mode 100644 index 0000000..b83315c --- /dev/null +++ b/src/app/api/bug-reports/route.ts @@ -0,0 +1,118 @@ +import { NextRequest, NextResponse } from "next/server" +import { query } from "@/lib/db" +import { getSessionUser } from "@/lib/auth" + +export async function POST(request: NextRequest) { + try { + const sessionUser = await getSessionUser() + if (!sessionUser) { + return NextResponse.json({ error: "Not authenticated." }, { status: 401 }) + } + + const { title, description, severity, page_url, screenshot_url } = await request.json() + + if (!title || !description) { + return NextResponse.json({ error: "Title and description are required." }, { status: 400 }) + } + + const validSeverities = ["low", "medium", "high", "critical"] + if (severity && !validSeverities.includes(severity)) { + return NextResponse.json({ error: "Invalid severity." }, { status: 400 }) + } + + const result = await query( + `INSERT INTO bug_reports (reported_by, title, description, severity, page_url, screenshot_url) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, created_at`, + [sessionUser.id, title, description, severity || "medium", page_url || null, screenshot_url || null] + ) + + return NextResponse.json({ + success: true, + id: result.rows[0].id, + created_at: result.rows[0].created_at, + }, { status: 201 }) + } catch (error) { + console.error("Error creating bug report:", error) + return NextResponse.json({ error: "Failed to submit bug report." }, { status: 500 }) + } +} + +export async function GET(request: NextRequest) { + try { + const sessionUser = await getSessionUser() + if (!sessionUser) { + return NextResponse.json({ error: "Not authenticated." }, { status: 401 }) + } + if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") { + return NextResponse.json({ error: "Forbidden. Only admins can view bug reports." }, { status: 403 }) + } + + const { searchParams } = new URL(request.url) + const status = searchParams.get("status") + const severity = searchParams.get("severity") + const limit = parseInt(searchParams.get("limit") || "50", 10) + const offset = parseInt(searchParams.get("offset") || "0", 10) + + let sql = `SELECT br.id, br.title, br.description, br.severity, br.page_url, + br.screenshot_url, br.status, br.resolution_notes, + br.created_at, br.updated_at, + reporter.id AS reporter_id, + reporter.first_name AS reporter_first_name, + reporter.last_name AS reporter_last_name, + reporter.email AS reporter_email, + assignee.id AS assignee_id, + assignee.first_name AS assignee_first_name, + assignee.last_name AS assignee_last_name + FROM bug_reports br + JOIN users reporter ON reporter.id = br.reported_by + LEFT JOIN users assignee ON assignee.id = br.assigned_to` + const params: unknown[] = [] + const conditions: string[] = [] + + if (status) { + conditions.push(`br.status = $${params.length + 1}`) + params.push(status) + } + if (severity) { + conditions.push(`br.severity = $${params.length + 1}`) + params.push(severity) + } + + if (conditions.length > 0) { + sql += " WHERE " + conditions.join(" AND ") + } + + sql += " ORDER BY br.created_at DESC LIMIT $" + (params.length + 1) + " OFFSET $" + (params.length + 2) + params.push(limit, offset) + + const result = await query(sql, params) + + const reports = result.rows.map((row: any) => ({ + id: row.id, + title: row.title, + description: row.description, + severity: row.severity, + page_url: row.page_url, + screenshot_url: row.screenshot_url, + status: row.status, + resolution_notes: row.resolution_notes, + created_at: row.created_at, + updated_at: row.updated_at, + reporter: { + id: row.reporter_id, + name: `${row.reporter_first_name} ${row.reporter_last_name}`, + email: row.reporter_email, + }, + assignee: row.assignee_id ? { + id: row.assignee_id, + name: `${row.assignee_first_name} ${row.assignee_last_name}`, + } : null, + })) + + return NextResponse.json({ reports }, { status: 200 }) + } catch (error) { + console.error("Error fetching bug reports:", error) + return NextResponse.json({ error: "Failed to fetch bug reports." }, { status: 500 }) + } +} diff --git a/src/app/api/settings/website-theme/route.ts b/src/app/api/settings/website-theme/route.ts new file mode 100644 index 0000000..4996f7d --- /dev/null +++ b/src/app/api/settings/website-theme/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server" +import { getSessionUser } from "@/lib/auth" +import { query } from "@/lib/db" + +export async function GET() { + try { + const user = await getSessionUser() + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const result = await query( + `SELECT preferences->>'website_theme' AS website_theme FROM users WHERE id = $1`, + [user.id], + ) + + const websiteTheme = result.rows[0]?.website_theme || "spidey" + return NextResponse.json({ websiteTheme }) + } catch (error) { + console.error("Website theme GET error:", error) + return NextResponse.json({ error: "Failed to load website theme" }, { status: 500 }) + } +} + +export async function PUT(request: NextRequest) { + try { + const user = await getSessionUser() + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const body = await request.json() + const theme = body.websiteTheme || "spidey" + + await query( + `UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`, + [user.id, JSON.stringify({ website_theme: theme })], + ) + + return NextResponse.json({ success: true, websiteTheme: theme }) + } catch (error) { + console.error("Website theme PUT error:", error) + return NextResponse.json({ error: "Failed to save website theme" }, { status: 500 }) + } +} diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts index 4f0be95..ad2964b 100644 --- a/src/app/api/users/route.ts +++ b/src/app/api/users/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server" import { query } from "@/lib/db" -import { hashPassword, getSessionUser } from "@/lib/auth" +import { hashPassword, getSessionUser, encryptPassword, setSessionContext } from "@/lib/auth" import { avatarSvgUrl } from "@/lib/avatar" export async function GET(request: NextRequest) { @@ -63,17 +63,20 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Invalid role" }, { status: 400 }) } + await setSessionContext(sessionUser.id) + const nameParts = name.trim().split(/\s+/) const firstName = nameParts[0] const lastName = nameParts.slice(1).join(" ") || firstName const username = email.split("@")[0] const passwordHash = await hashPassword(password) + const passwordEncrypted = await encryptPassword(password) const result = await query( - `INSERT INTO users (username, email, password_hash, first_name, last_name, is_active, created_by) - VALUES ($1, $2, $3, $4, $5, $6, $7) + `INSERT INTO users (username, email, password_hash, password_encrypted, first_name, last_name, is_active, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`, - [username.toLowerCase(), email.toLowerCase(), passwordHash, firstName, lastName, active ?? true, sessionUser.id] + [username.toLowerCase(), email.toLowerCase(), passwordHash, passwordEncrypted, firstName, lastName, active ?? true, sessionUser.id] ) const roleId = ( diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 08e274e..a64d357 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata, Viewport } from "next" import { Inter } from "next/font/google" import { ThemeProvider } from "@/providers/theme-provider" +import { WebsiteThemeProvider } from "@/providers/website-theme-provider" import { Toaster } from "@/components/ui/sonner" import "./globals.css" @@ -31,8 +32,10 @@ export default function RootLayout({ - {children} - + + {children} + + diff --git a/src/components/layout/topbar.tsx b/src/components/layout/topbar.tsx index 41743a4..3002d07 100644 --- a/src/components/layout/topbar.tsx +++ b/src/components/layout/topbar.tsx @@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { useUser } from "@/providers/user-provider"; import { useNotifications } from "@/providers/notification-provider"; +import { BugReportModal } from "@/components/shared/bug-report-modal"; import { DropdownMenu, DropdownMenuContent, @@ -27,6 +28,7 @@ import { LogOut, User, Settings, + Bug, CheckCheck, Trash2, } from "lucide-react"; @@ -43,6 +45,7 @@ export function Topbar({ onMenuClick }: TopbarProps) { useNotifications(); const [mounted, setMounted] = useState(false); const [searchOpen, setSearchOpen] = useState(false); + const [bugModalOpen, setBugModalOpen] = useState(false); useEffect(() => { setMounted(true); @@ -113,6 +116,17 @@ export function Topbar({ onMenuClick }: TopbarProps) { + {/* Report a Bug */} + + {/* Theme toggle */} + ) + })} + + + ) } diff --git a/src/components/shared/bug-report-modal.tsx b/src/components/shared/bug-report-modal.tsx new file mode 100644 index 0000000..2cd3e8d --- /dev/null +++ b/src/components/shared/bug-report-modal.tsx @@ -0,0 +1,188 @@ +"use client" + +import { useState } from "react" +import { motion, AnimatePresence } from "framer-motion" +import { X, Bug, Loader2, CheckCircle } from "lucide-react" + +interface BugReportModalProps { + open: boolean + onClose: () => void +} + +export function BugReportModal({ open, onClose }: BugReportModalProps) { + const [title, setTitle] = useState("") + const [description, setDescription] = useState("") + const [severity, setSeverity] = useState("medium") + const [loading, setLoading] = useState(false) + const [submitted, setSubmitted] = useState(false) + const [error, setError] = useState("") + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError("") + setLoading(true) + + try { + const res = await fetch("/api/bug-reports", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title, + description, + severity, + page_url: window.location.href, + }), + }) + + if (res.ok) { + setSubmitted(true) + setTitle("") + setDescription("") + setSeverity("medium") + } else { + const data = await res.json().catch(() => ({})) + setError(data.error || "Failed to submit bug report.") + } + } catch { + setError("Connection error. Please try again.") + } finally { + setLoading(false) + } + } + + const handleClose = () => { + setSubmitted(false) + setError("") + onClose() + } + + return ( + + {open && ( +
+ + + + + {submitted ? ( +
+ +

+ Bug Report Submitted +

+

+ Thank you for your report. The development team will investigate. +

+ +
+ ) : ( + <> +
+
+ +
+
+

+ Report a Bug +

+

+ Help us improve the system +

+
+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setTitle(e.target.value)} + placeholder="Brief description of the issue" + required + className="w-full rounded-lg border border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#111111] dark:text-white placeholder-[#999999] focus:border-[#CC0000] focus:outline-none focus:ring-1 focus:ring-[#CC0000]" + /> +
+ +
+ +