From 5238b5914008c3ec1a4f2d49e3b91ae9b307b055 Mon Sep 17 00:00:00 2001 From: TroodonEnjoyer Date: Thu, 18 Jun 2026 13:13:29 +0200 Subject: [PATCH] Added 4 Demo Users, with Coast emails, Hierarchy, roles etc. --- database/migrations/001_schema.sql | 16 +- database/migrations/002_seed.sql | 286 ++++++++++++------------ eslint.config.mjs | 31 +-- next.config.ts | 3 + package-lock.json | 190 +++++++++++++--- package.json | 3 + src/app/(dashboard)/chats/page.tsx | 1 + src/app/(dashboard)/layout.tsx | 21 +- src/app/(dashboard)/leads/[id]/page.tsx | 6 +- src/app/(dashboard)/profile/page.tsx | 1 + src/app/api/auth/login/route.ts | 120 ++++++++++ src/app/api/auth/logout/route.ts | 15 ++ src/app/api/auth/me/route.ts | 18 ++ src/app/login/page.tsx | 159 +++++++------ src/components/layout/sidebar.tsx | 1 + src/components/layout/topbar.tsx | 5 +- src/data/users.ts | 2 - src/lib/auth.ts | 218 ++++++++++++++++++ src/lib/db.ts | 24 ++ src/middleware.ts | 55 +++++ src/providers/user-provider.tsx | 59 ++++- start-pg.ps1 | 25 +++ 22 files changed, 973 insertions(+), 286 deletions(-) create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/logout/route.ts create mode 100644 src/app/api/auth/me/route.ts create mode 100644 src/lib/auth.ts create mode 100644 src/lib/db.ts create mode 100644 src/middleware.ts create mode 100644 start-pg.ps1 diff --git a/database/migrations/001_schema.sql b/database/migrations/001_schema.sql index a70728d..231cd74 100644 --- a/database/migrations/001_schema.sql +++ b/database/migrations/001_schema.sql @@ -119,7 +119,7 @@ CREATE TABLE banned_users ( CREATE INDEX idx_banned_users_user ON banned_users(banned_user_id); CREATE INDEX idx_banned_users_active ON banned_users(banned_user_id) - WHERE is_reversed = FALSE AND (permanent_ban = TRUE OR expires_at > NOW()); + WHERE is_reversed = FALSE; CREATE INDEX idx_banned_users_banned_by ON banned_users(banned_by); CREATE TABLE suspended_users ( @@ -138,7 +138,7 @@ CREATE TABLE suspended_users ( CREATE INDEX idx_suspended_users_user ON suspended_users(suspended_user_id); CREATE INDEX idx_suspended_users_active ON suspended_users(suspended_user_id) - WHERE is_active = TRUE AND expires_at > NOW(); + WHERE is_active = TRUE; CREATE INDEX idx_suspended_users_suspended_by ON suspended_users(suspended_by); -- ============================================================================ @@ -687,7 +687,7 @@ CREATE INDEX idx_invoices_customer ON invoices(customer_id) WHERE deleted_at IS CREATE INDEX idx_invoices_status ON invoices(status_id) WHERE deleted_at IS NULL; CREATE INDEX idx_invoices_issued ON invoices(issued_date DESC) WHERE deleted_at IS NULL; CREATE INDEX idx_invoices_due ON invoices(due_date) WHERE deleted_at IS NULL; -CREATE INDEX idx_invoices_overdue ON invoices(due_date, status_id) WHERE deleted_at IS NULL AND due_date < NOW(); +-- Skipped: cannot use NOW() in partial index predicate (not IMMUTABLE) CREATE TABLE invoice_items ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), @@ -800,10 +800,14 @@ DECLARE t TEXT; BEGIN FOR t IN - SELECT table_name FROM information_schema.columns - WHERE column_name = 'updated_at' - AND table_schema = 'public' + SELECT table_name FROM information_schema.tables + WHERE table_schema = 'public' AND table_type = 'BASE TABLE' + AND table_name IN ( + SELECT table_name FROM information_schema.columns + WHERE column_name = 'updated_at' + AND table_schema = 'public' + ) LOOP EXECUTE format( 'CREATE TRIGGER trg_%I_updated_at diff --git a/database/migrations/002_seed.sql b/database/migrations/002_seed.sql index 92b32a3..6120547 100644 --- a/database/migrations/002_seed.sql +++ b/database/migrations/002_seed.sql @@ -35,78 +35,78 @@ ON CONFLICT (name) DO NOTHING; -- USER MANAGEMENT (SUPER_ADMIN only) INSERT INTO permissions (id, resource, action, description, category) VALUES - ('p0010001-0000-0000-0000-000000000000', 'users', 'create', 'Create new user accounts', 'User Management'), - ('p0010002-0000-0000-0000-000000000000', 'users', 'read', 'View user details', 'User Management'), - ('p0010003-0000-0000-0000-000000000000', 'users', 'update', 'Update user details', 'User Management'), - ('p0010004-0000-0000-0000-000000000000', 'users', 'delete', 'Permanently delete user accounts', 'User Management'), - ('p0010005-0000-0000-0000-000000000000', 'users', 'assign_roles', 'Assign roles to users', 'User Management'), - ('p0010006-0000-0000-0000-000000000000', 'users', 'promote', 'Promote users to higher roles', 'User Management'), - ('p0010007-0000-0000-0000-000000000000', 'users', 'demote', 'Demote users to lower roles', 'User Management'), - ('p0010008-0000-0000-0000-000000000000', 'users', 'reset_password', 'Reset user passwords', 'User Management'), - ('p0010009-0000-0000-0000-000000000000', 'users', 'disable', 'Disable/disable user accounts', 'User Management'), - ('p0010010-0000-0000-0000-000000000000', 'users', 'permanently_delete', 'Permanently remove accounts from system', 'User Management') + ('a0010001-0000-0000-0000-000000000000', 'users', 'create', 'Create new user accounts', 'User Management'), + ('a0010002-0000-0000-0000-000000000000', 'users', 'read', 'View user details', 'User Management'), + ('a0010003-0000-0000-0000-000000000000', 'users', 'update', 'Update user details', 'User Management'), + ('a0010004-0000-0000-0000-000000000000', 'users', 'delete', 'Permanently delete user accounts', 'User Management'), + ('a0010005-0000-0000-0000-000000000000', 'users', 'assign_roles', 'Assign roles to users', 'User Management'), + ('a0010006-0000-0000-0000-000000000000', 'users', 'promote', 'Promote users to higher roles', 'User Management'), + ('a0010007-0000-0000-0000-000000000000', 'users', 'demote', 'Demote users to lower roles', 'User Management'), + ('a0010008-0000-0000-0000-000000000000', 'users', 'reset_password', 'Reset user passwords', 'User Management'), + ('a0010009-0000-0000-0000-000000000000', 'users', 'disable', 'Disable/disable user accounts', 'User Management'), + ('a0010010-0000-0000-0000-000000000000', 'users', 'permanently_delete', 'Permanently remove accounts from system', 'User Management') ON CONFLICT DO NOTHING; -- BAN & SUSPENSION (ADMIN + SUPER_ADMIN) INSERT INTO permissions (id, resource, action, description, category) VALUES - ('p0020001-0000-0000-0000-000000000000', 'bans', 'create', 'Ban a user account', 'Ban Management'), - ('p0020002-0000-0000-0000-000000000000', 'bans', 'reverse', 'Reverse a ban on a user', 'Ban Management'), - ('p0020003-0000-0000-0000-000000000000', 'bans', 'permanently_delete', 'Permanently delete banned users', 'Ban Management'), - ('p0020004-0000-0000-0000-000000000000', 'suspensions', 'create', 'Suspend a user account', 'Ban Management'), - ('p0020005-0000-0000-0000-000000000000', 'suspensions', 'lift', 'Lift a suspension', 'Ban Management'), - ('p0020006-0000-0000-0000-000000000000', 'suspensions', 'view', 'View suspension records', 'Ban Management') + ('a0020001-0000-0000-0000-000000000000', 'bans', 'create', 'Ban a user account', 'Ban Management'), + ('a0020002-0000-0000-0000-000000000000', 'bans', 'reverse', 'Reverse a ban on a user', 'Ban Management'), + ('a0020003-0000-0000-0000-000000000000', 'bans', 'permanently_delete', 'Permanently delete banned users', 'Ban Management'), + ('a0020004-0000-0000-0000-000000000000', 'suspensions', 'create', 'Suspend a user account', 'Ban Management'), + ('a0020005-0000-0000-0000-000000000000', 'suspensions', 'lift', 'Lift a suspension', 'Ban Management'), + ('a0020006-0000-0000-0000-000000000000', 'suspensions', 'view', 'View suspension records', 'Ban Management') ON CONFLICT DO NOTHING; -- CRM CORE (SALES_USER + MANAGERS) INSERT INTO permissions (id, resource, action, description, category) VALUES - ('p0030001-0000-0000-0000-000000000000', 'customers', 'create', 'Create customer records', 'CRM'), - ('p0030002-0000-0000-0000-000000000000', 'customers', 'read', 'View customer records', 'CRM'), - ('p0030003-0000-0000-0000-000000000000', 'customers', 'update', 'Update customer records', 'CRM'), - ('p0030004-0000-0000-0000-000000000000', 'customers', 'delete', 'Delete customer records', 'CRM'), - ('p0030005-0000-0000-0000-000000000000', 'leads', 'create', 'Create sales leads', 'CRM'), - ('p0030006-0000-0000-0000-000000000000', 'leads', 'read', 'View sales leads', 'CRM'), - ('p0030007-0000-0000-0000-000000000000', 'leads', 'update', 'Update sales leads', 'CRM'), - ('p0030008-0000-0000-0000-000000000000', 'leads', 'convert', 'Convert leads to customers', 'CRM'), - ('p0030009-0000-0000-0000-000000000000', 'opportunities', 'create', 'Create sales opportunities', 'CRM'), - ('p0030010-0000-0000-0000-000000000000', 'opportunities', 'read', 'View sales opportunities', 'CRM'), - ('p0030011-0000-0000-0000-000000000000', 'opportunities', 'update', 'Update sales pipeline', 'CRM'), - ('p0030012-0000-0000-0000-000000000000', 'pipeline', 'update', 'Update sales pipeline stages', 'CRM'), - ('p0030013-0000-0000-0000-000000000000', 'communications', 'create', 'Log calls and meetings', 'CRM'), - ('p0030014-0000-0000-0000-000000000000', 'communications', 'read', 'View communication history', 'CRM'), - ('p0030015-0000-0000-0000-000000000000', 'contacts', 'manage', 'Manage customer contact details', 'CRM'), - ('p0030016-0000-0000-0000-000000000000', 'products', 'read', 'View product catalog', 'CRM'), - ('p0030017-0000-0000-0000-000000000000', 'invoices', 'read', 'View invoices', 'CRM') + ('a0030001-0000-0000-0000-000000000000', 'customers', 'create', 'Create customer records', 'CRM'), + ('a0030002-0000-0000-0000-000000000000', 'customers', 'read', 'View customer records', 'CRM'), + ('a0030003-0000-0000-0000-000000000000', 'customers', 'update', 'Update customer records', 'CRM'), + ('a0030004-0000-0000-0000-000000000000', 'customers', 'delete', 'Delete customer records', 'CRM'), + ('a0030005-0000-0000-0000-000000000000', 'leads', 'create', 'Create sales leads', 'CRM'), + ('a0030006-0000-0000-0000-000000000000', 'leads', 'read', 'View sales leads', 'CRM'), + ('a0030007-0000-0000-0000-000000000000', 'leads', 'update', 'Update sales leads', 'CRM'), + ('a0030008-0000-0000-0000-000000000000', 'leads', 'convert', 'Convert leads to customers', 'CRM'), + ('a0030009-0000-0000-0000-000000000000', 'opportunities', 'create', 'Create sales opportunities', 'CRM'), + ('a0030010-0000-0000-0000-000000000000', 'opportunities', 'read', 'View sales opportunities', 'CRM'), + ('a0030011-0000-0000-0000-000000000000', 'opportunities', 'update', 'Update sales pipeline', 'CRM'), + ('a0030012-0000-0000-0000-000000000000', 'pipeline', 'update', 'Update sales pipeline stages', 'CRM'), + ('a0030013-0000-0000-0000-000000000000', 'communications', 'create', 'Log calls and meetings', 'CRM'), + ('a0030014-0000-0000-0000-000000000000', 'communications', 'read', 'View communication history', 'CRM'), + ('a0030015-0000-0000-0000-000000000000', 'contacts', 'manage', 'Manage customer contact details', 'CRM'), + ('a0030016-0000-0000-0000-000000000000', 'products', 'read', 'View product catalog', 'CRM'), + ('a0030017-0000-0000-0000-000000000000', 'invoices', 'read', 'View invoices', 'CRM') ON CONFLICT DO NOTHING; -- REPORTING & INCIDENTS (ADMIN) INSERT INTO permissions (id, resource, action, description, category) VALUES - ('p0040001-0000-0000-0000-000000000000', 'reports', 'customers_view', 'Review customer reports', 'Reporting'), - ('p0040002-0000-0000-0000-000000000000', 'reports', 'incidents_view', 'Review incident reports', 'Reporting'), - ('p0040003-0000-0000-0000-000000000000', 'reports', 'complaints_investigate', 'Investigate complaints', 'Reporting'), - ('p0040004-0000-0000-0000-000000000000', 'crm', 'dashboard_access', 'Access CRM dashboards', 'Reporting'), - ('p0040005-0000-0000-0000-000000000000', 'activity', 'logs_view', 'View user activity logs', 'Reporting'), - ('p0040006-0000-0000-0000-000000000000', 'suspicious', 'moderate', 'Moderate suspicious activity', 'Reporting'), - ('p0040007-0000-0000-0000-000000000000', 'accounts', 'lock_investigation', 'Lock accounts under investigation', 'Reporting') + ('a0040001-0000-0000-0000-000000000000', 'reports', 'customers_view', 'Review customer reports', 'Reporting'), + ('a0040002-0000-0000-0000-000000000000', 'reports', 'incidents_view', 'Review incident reports', 'Reporting'), + ('a0040003-0000-0000-0000-000000000000', 'reports', 'complaints_investigate', 'Investigate complaints', 'Reporting'), + ('a0040004-0000-0000-0000-000000000000', 'crm', 'dashboard_access', 'Access CRM dashboards', 'Reporting'), + ('a0040005-0000-0000-0000-000000000000', 'activity', 'logs_view', 'View user activity logs', 'Reporting'), + ('a0040006-0000-0000-0000-000000000000', 'suspicious', 'moderate', 'Moderate suspicious activity', 'Reporting'), + ('a0040007-0000-0000-0000-000000000000', 'accounts', 'lock_investigation', 'Lock accounts under investigation', 'Reporting') ON CONFLICT DO NOTHING; -- AUDIT & SYSTEM (SUPER_ADMIN + limited ADMIN) INSERT INTO permissions (id, resource, action, description, category) VALUES - ('p0050001-0000-0000-0000-000000000000', 'audit', 'full_access', 'Full audit log access', 'System'), - ('p0050002-0000-0000-0000-000000000000', 'audit', 'read', 'View audit logs', 'System'), - ('p0050003-0000-0000-0000-000000000000', 'settings', 'modify', 'Modify system settings', 'System'), - ('p0050004-0000-0000-0000-000000000000', 'database', 'full_access', 'Full database access', 'System'), - ('p0050005-0000-0000-0000-000000000000', 'crm', 'full_access', 'Full CRM access override', 'System'), - ('p0050006-0000-0000-0000-000000000000', 'permissions', 'override', 'Override all permissions', 'System') + ('a0050001-0000-0000-0000-000000000000', 'audit', 'full_access', 'Full audit log access', 'System'), + ('a0050002-0000-0000-0000-000000000000', 'audit', 'read', 'View audit logs', 'System'), + ('a0050003-0000-0000-0000-000000000000', 'settings', 'modify', 'Modify system settings', 'System'), + ('a0050004-0000-0000-0000-000000000000', 'database', 'full_access', 'Full database access', 'System'), + ('a0050005-0000-0000-0000-000000000000', 'crm', 'full_access', 'Full CRM access override', 'System'), + ('a0050006-0000-0000-0000-000000000000', 'permissions', 'override', 'Override all permissions', 'System') ON CONFLICT DO NOTHING; -- DEVELOPER PERMISSIONS INSERT INTO permissions (id, resource, action, description, category) VALUES - ('p0060001-0000-0000-0000-000000000000', 'api', 'testing', 'API testing access', 'Developer'), - ('p0060002-0000-0000-0000-000000000000', 'backend', 'diagnostics', 'Backend diagnostics', 'Developer'), - ('p0060003-0000-0000-0000-000000000000', 'system', 'logs_view', 'View system logs', 'Developer'), - ('p0060004-0000-0000-0000-000000000000', 'database', 'diagnostics', 'Database diagnostics', 'Developer'), - ('p0060005-0000-0000-0000-000000000000', 'debugging', 'access', 'Debugging access', 'Developer'), - ('p0060006-0000-0000-0000-000000000000', 'testing', 'internal', 'Internal testing access', 'Developer') + ('a0060001-0000-0000-0000-000000000000', 'api', 'testing', 'API testing access', 'Developer'), + ('a0060002-0000-0000-0000-000000000000', 'backend', 'diagnostics', 'Backend diagnostics', 'Developer'), + ('a0060003-0000-0000-0000-000000000000', 'system', 'logs_view', 'View system logs', 'Developer'), + ('a0060004-0000-0000-0000-000000000000', 'database', 'diagnostics', 'Database diagnostics', 'Developer'), + ('a0060005-0000-0000-0000-000000000000', 'debugging', 'access', 'Debugging access', 'Developer'), + ('a0060006-0000-0000-0000-000000000000', 'testing', 'internal', 'Internal testing access', 'Developer') ON CONFLICT DO NOTHING; -- ============================================================================ @@ -121,70 +121,70 @@ ON CONFLICT DO NOTHING; -- ADMIN — Bans, Suspensions, Reporting, CRM read, Activity logs INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES -- Bans & Suspensions - ('00000002-0000-0000-0000-000000000000', 'p0020001-0000-0000-0000-000000000000', NULL), - ('00000002-0000-0000-0000-000000000000', 'p0020004-0000-0000-0000-000000000000', NULL), - ('00000002-0000-0000-0000-000000000000', 'p0020005-0000-0000-0000-000000000000', NULL), - ('00000002-0000-0000-0000-000000000000', 'p0020006-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'a0020001-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'a0020004-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'a0020005-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'a0020006-0000-0000-0000-000000000000', NULL), -- User read + disable - ('00000002-0000-0000-0000-000000000000', 'p0010002-0000-0000-0000-000000000000', NULL), - ('00000002-0000-0000-0000-0000-000000000000', 'p0010009-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'a0010002-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'a0010009-0000-0000-0000-000000000000', NULL), -- Reporting & Incidents - ('00000002-0000-0000-0000-000000000000', 'p0040001-0000-0000-0000-000000000000', NULL), - ('00000002-0000-0000-0000-000000000000', 'p0040002-0000-0000-0000-000000000000', NULL), - ('00000002-0000-0000-0000-000000000000', 'p0040003-0000-0000-0000-000000000000', NULL), - ('00000002-0000-0000-0000-000000000000', 'p0040004-0000-0000-0000-000000000000', NULL), - ('00000002-0000-0000-0000-000000000000', 'p0040005-0000-0000-0000-000000000000', NULL), - ('00000002-0000-0000-0000-000000000000', 'p0040006-0000-0000-0000-000000000000', NULL), - ('00000002-0000-0000-0000-000000000000', 'p0040007-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'a0040001-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'a0040002-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'a0040003-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'a0040004-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'a0040005-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'a0040006-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'a0040007-0000-0000-0000-000000000000', NULL), -- CRM read access - ('00000002-0000-0000-0000-000000000000', 'p0030002-0000-0000-0000-000000000000', NULL), - ('00000002-0000-0000-0000-000000000000', 'p0030006-0000-0000-0000-000000000000', NULL), - ('00000002-0000-0000-0000-000000000000', 'p0030010-0000-0000-0000-000000000000', NULL), - ('00000002-0000-0000-0000-000000000000', 'p0030014-0000-0000-0000-000000000000', NULL), - ('00000002-0000-0000-0000-000000000000', 'p0030016-0000-0000-0000-000000000000', NULL), - ('00000002-0000-0000-0000-000000000000', 'p0030017-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'a0030002-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'a0030006-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'a0030010-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'a0030014-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'a0030016-0000-0000-0000-000000000000', NULL), + ('00000002-0000-0000-0000-000000000000', 'a0030017-0000-0000-0000-000000000000', NULL), -- Audit read - ('00000002-0000-0000-0000-000000000000', 'p0050002-0000-0000-0000-000000000000', NULL) + ('00000002-0000-0000-0000-000000000000', 'a0050002-0000-0000-0000-000000000000', NULL) ON CONFLICT DO NOTHING; -- SALES_USER — CRM operations INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES - ('00000003-0000-0000-0000-000000000000', 'p0030001-0000-0000-0000-000000000000', NULL), - ('00000003-0000-0000-0000-000000000000', 'p0030002-0000-0000-0000-000000000000', NULL), - ('00000003-0000-0000-0000-000000000000', 'p0030003-0000-0000-0000-000000000000', NULL), - ('00000003-0000-0000-0000-000000000000', 'p0030005-0000-0000-0000-000000000000', NULL), - ('00000003-0000-0000-0000-000000000000', 'p0030006-0000-0000-0000-000000000000', NULL), - ('00000003-0000-0000-0000-000000000000', 'p0030007-0000-0000-0000-000000000000', NULL), - ('00000003-0000-0000-0000-000000000000', 'p0030008-0000-0000-0000-000000000000', NULL), - ('00000003-0000-0000-0000-000000000000', 'p0030009-0000-0000-0000-000000000000', NULL), - ('00000003-0000-0000-0000-000000000000', 'p0030010-0000-0000-0000-000000000000', NULL), - ('00000003-0000-0000-0000-000000000000', 'p0030011-0000-0000-0000-000000000000', NULL), - ('00000003-0000-0000-0000-000000000000', 'p0030012-0000-0000-0000-000000000000', NULL), - ('00000003-0000-0000-0000-000000000000', 'p0030013-0000-0000-0000-000000000000', NULL), - ('00000003-0000-0000-0000-000000000000', 'p0030014-0000-0000-0000-000000000000', NULL), - ('00000003-0000-0000-0000-000000000000', 'p0030015-0000-0000-0000-000000000000', NULL), - ('00000003-0000-0000-0000-000000000000', 'p0030016-0000-0000-0000-000000000000', NULL), - ('00000003-0000-0000-0000-000000000000', 'p0030017-0000-0000-0000-000000000000', NULL) + ('00000003-0000-0000-0000-000000000000', 'a0030001-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'a0030002-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'a0030003-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'a0030005-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'a0030006-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'a0030007-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'a0030008-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'a0030009-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'a0030010-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'a0030011-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'a0030012-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'a0030013-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'a0030014-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'a0030015-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'a0030016-0000-0000-0000-000000000000', NULL), + ('00000003-0000-0000-0000-000000000000', 'a0030017-0000-0000-0000-000000000000', NULL) ON CONFLICT DO NOTHING; -- DEVELOPER — Technical permissions + CRM read access (post-sale project visibility) INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES -- Developer technical permissions - ('00000004-0000-0000-0000-000000000000', 'p0060001-0000-0000-0000-000000000000', NULL), - ('00000004-0000-0000-0000-000000000000', 'p0060002-0000-0000-0000-000000000000', NULL), - ('00000004-0000-0000-0000-000000000000', 'p0060003-0000-0000-0000-000000000000', NULL), - ('00000004-0000-0000-0000-000000000000', 'p0060004-0000-0000-0000-000000000000', NULL), - ('00000004-0000-0000-0000-000000000000', 'p0060005-0000-0000-0000-000000000000', NULL), - ('00000004-0000-0000-0000-000000000000', 'p0060006-0000-0000-0000-000000000000', NULL), + ('00000004-0000-0000-0000-000000000000', 'a0060001-0000-0000-0000-000000000000', NULL), + ('00000004-0000-0000-0000-000000000000', 'a0060002-0000-0000-0000-000000000000', NULL), + ('00000004-0000-0000-0000-000000000000', 'a0060003-0000-0000-0000-000000000000', NULL), + ('00000004-0000-0000-0000-000000000000', 'a0060004-0000-0000-0000-000000000000', NULL), + ('00000004-0000-0000-0000-000000000000', 'a0060005-0000-0000-0000-000000000000', NULL), + ('00000004-0000-0000-0000-000000000000', 'a0060006-0000-0000-0000-000000000000', NULL), -- User self-view - ('00000004-0000-0000-0000-000000000000', 'p0010002-0000-0000-0000-000000000000', NULL), + ('00000004-0000-0000-0000-000000000000', 'a0010002-0000-0000-0000-000000000000', NULL), -- CRM access (matching SALES_USER level for post-sale project visibility) - ('00000004-0000-0000-0000-000000000000', 'p0030002-0000-0000-0000-000000000000', NULL), - ('00000004-0000-0000-0000-000000000000', 'p0030006-0000-0000-0000-000000000000', NULL), - ('00000004-0000-0000-0000-000000000000', 'p0030010-0000-0000-0000-000000000000', NULL), - ('00000004-0000-0000-0000-000000000000', 'p0030014-0000-0000-0000-000000000000', NULL), - ('00000004-0000-0000-0000-000000000000', 'p0030016-0000-0000-0000-000000000000', NULL), - ('00000004-0000-0000-0000-000000000000', 'p0030017-0000-0000-0000-000000000000', NULL) + ('00000004-0000-0000-0000-000000000000', 'a0030002-0000-0000-0000-000000000000', NULL), + ('00000004-0000-0000-0000-000000000000', 'a0030006-0000-0000-0000-000000000000', NULL), + ('00000004-0000-0000-0000-000000000000', 'a0030010-0000-0000-0000-000000000000', NULL), + ('00000004-0000-0000-0000-000000000000', 'a0030014-0000-0000-0000-000000000000', NULL), + ('00000004-0000-0000-0000-000000000000', 'a0030016-0000-0000-0000-000000000000', NULL), + ('00000004-0000-0000-0000-000000000000', 'a0030017-0000-0000-0000-000000000000', NULL) ON CONFLICT DO NOTHING; -- ============================================================================ @@ -197,19 +197,19 @@ ON CONFLICT DO NOTHING; -- dev_demo / DevTesting@2026 INSERT INTO users (id, username, email, password_hash, first_name, last_name, is_active, password_change_required) VALUES - ('00000000-0000-0000-0000-000000000001', 'superadmin_demo', 'superadmin@crm.demo', + ('00000000-0000-0000-0000-000000000001', 'superadmin_demo', 'superadmin@coastit.co.za', '$2b$12$C5hczK17I.bu6ILzmGW0U.UnFSdfTuDh42C8t16nxRKaUtXKkdWlC', 'Super', 'Admin', TRUE, FALSE), - ('00000000-0000-0000-0000-000000000002', 'admin_demo', 'admin@crm.demo', + ('00000000-0000-0000-0000-000000000002', 'admin_demo', 'admin@coastit.co.za', '$2b$12$TCDq5.sXHA4kWelQPKO6DeQo.WW.NeTuNtOed57UdQ3lRs7.rdkNy', 'System', 'Admin', TRUE, TRUE), - ('00000000-0000-0000-0000-000000000003', 'sales_demo', 'sales@crm.demo', + ('00000000-0000-0000-0000-000000000003', 'sales_demo', 'sales@coastit.co.za', '$2b$12$Xhh20UmTn.LTQAs4v4cHx.yQgvuYyNo6TkPaytQ1Q8o0oTPCtIj7W', 'Sales', 'User', TRUE, TRUE), - ('00000000-0000-0000-0000-000000000004', 'dev_demo', 'dev@crm.demo', + ('00000000-0000-0000-0000-000000000004', 'dev_demo', 'dev@coastit.co.za', '$2b$12$ghyJFb17lXoFOCYUPB6Fk.q8wDNOJhq9OUPNzd5DKaZsDjCF2NBJa', 'Dev', 'User', TRUE, TRUE) -ON CONFLICT (username) DO NOTHING; +ON CONFLICT (username) WHERE (deleted_at IS NULL) DO NOTHING; -- Update the SUPER_ADMIN to set created_by to self (post-bootstrap) UPDATE users SET created_by = '00000000-0000-0000-0000-000000000001' @@ -288,41 +288,41 @@ ON CONFLICT DO NOTHING; -- Communication Types INSERT INTO communication_types (id, name, description, icon) VALUES - ('g0000000-0000-0000-0000-000000000001', 'Email', 'Email correspondence', 'mail'), - ('g0000000-0000-0000-0000-000000000002', 'Phone Call', 'Telephone conversation', 'phone'), - ('g0000000-0000-0000-0000-000000000003', 'Meeting', 'In-person or virtual meeting', 'users'), - ('g0000000-0000-0000-0000-000000000004', 'Chat', 'Instant messaging', 'message-circle'), - ('g0000000-0000-0000-0000-000000000005', 'SMS', 'Text message', 'message-square'), - ('g0000000-0000-0000-0000-000000000006', 'Video Call', 'Video conference', 'video'), - ('g0000000-0000-0000-0000-000000000007', 'Letter', 'Physical mail', 'file-text') + ('b0000000-0000-0000-0000-000000000001', 'Email', 'Email correspondence', 'mail'), + ('b0000000-0000-0000-0000-000000000002', 'Phone Call', 'Telephone conversation', 'phone'), + ('b0000000-0000-0000-0000-000000000003', 'Meeting', 'In-person or virtual meeting', 'users'), + ('b0000000-0000-0000-0000-000000000004', 'Chat', 'Instant messaging', 'message-circle'), + ('b0000000-0000-0000-0000-000000000005', 'SMS', 'Text message', 'message-square'), + ('b0000000-0000-0000-0000-000000000006', 'Video Call', 'Video conference', 'video'), + ('b0000000-0000-0000-0000-000000000007', 'Letter', 'Physical mail', 'file-text') ON CONFLICT DO NOTHING; -- Task Priorities INSERT INTO task_priorities (id, name, color, sort_order) VALUES - ('h0000000-0000-0000-0000-000000000001', 'Critical', '#EF4444', 1), - ('h0000000-0000-0000-0000-000000000002', 'High', '#F59E0B', 2), - ('h0000000-0000-0000-0000-000000000003', 'Medium', '#3B82F6', 3), - ('h0000000-0000-0000-0000-000000000004', 'Low', '#A0AEC0', 4) + ('c0000000-0000-0000-0000-000000000001', 'Critical', '#EF4444', 1), + ('c0000000-0000-0000-0000-000000000002', 'High', '#F59E0B', 2), + ('c0000000-0000-0000-0000-000000000003', 'Medium', '#3B82F6', 3), + ('c0000000-0000-0000-0000-000000000004', 'Low', '#A0AEC0', 4) ON CONFLICT DO NOTHING; -- Invoice Statuses INSERT INTO invoice_statuses (id, name, description, color, sort_order) VALUES - ('i0000000-0000-0000-0000-000000000001', 'Draft', 'Invoice in draft state', '#A0AEC0', 1), - ('i0000000-0000-0000-0000-000000000002', 'Sent', 'Invoice sent to customer', '#3B82F6', 2), - ('i0000000-0000-0000-0000-000000000003', 'Paid', 'Payment received in full', '#22C55E', 3), - ('i0000000-0000-0000-0000-000000000004', 'Overdue', 'Payment past due date', '#EF4444', 4), - ('i0000000-0000-0000-0000-000000000005', 'Partially Paid', 'Partial payment received', '#F59E0B', 5), - ('i0000000-0000-0000-0000-000000000006', 'Cancelled', 'Invoice cancelled', '#6B7280', 6), - ('i0000000-0000-0000-0000-000000000007', 'Refunded', 'Payment refunded', '#8B5CF6', 7) + ('d0000000-0000-0000-0000-000000000001', 'Draft', 'Invoice in draft state', '#A0AEC0', 1), + ('d0000000-0000-0000-0000-000000000002', 'Sent', 'Invoice sent to customer', '#3B82F6', 2), + ('d0000000-0000-0000-0000-000000000003', 'Paid', 'Payment received in full', '#22C55E', 3), + ('d0000000-0000-0000-0000-000000000004', 'Overdue', 'Payment past due date', '#EF4444', 4), + ('d0000000-0000-0000-0000-000000000005', 'Partially Paid', 'Partial payment received', '#F59E0B', 5), + ('d0000000-0000-0000-0000-000000000006', 'Cancelled', 'Invoice cancelled', '#6B7280', 6), + ('d0000000-0000-0000-0000-000000000007', 'Refunded', 'Payment refunded', '#8B5CF6', 7) ON CONFLICT DO NOTHING; -- Product Categories INSERT INTO product_categories (id, name, description, sort_order) VALUES - ('j0000000-0000-0000-0000-000000000001', 'Software', 'Software products and licenses', 1), - ('j0000000-0000-0000-0000-000000000002', 'Hardware', 'Physical hardware products', 2), - ('j0000000-0000-0000-0000-000000000003', 'Services', 'Professional services', 3), - ('j0000000-0000-0000-0000-000000000004', 'Subscription', 'Recurring subscription plans', 4), - ('j0000000-0000-0000-0000-000000000005', 'Training', 'Training and certification', 5) + ('e0000000-0000-0000-0000-000000000001', 'Software', 'Software products and licenses', 1), + ('e0000000-0000-0000-0000-000000000002', 'Hardware', 'Physical hardware products', 2), + ('e0000000-0000-0000-0000-000000000003', 'Services', 'Professional services', 3), + ('e0000000-0000-0000-0000-000000000004', 'Subscription', 'Recurring subscription plans', 4), + ('e0000000-0000-0000-0000-000000000005', 'Training', 'Training and certification', 5) ON CONFLICT DO NOTHING; -- ============================================================================ @@ -331,12 +331,12 @@ ON CONFLICT DO NOTHING; -- Sample Products INSERT INTO products (id, category_id, name, description, sku, unit_price, cost_price) VALUES - ('k0000000-0000-0000-0000-000000000001', 'j0000000-0000-0000-0000-000000000001', 'CRM Pro License', 'Enterprise CRM license per user/year', 'SW-CRM-001', 1200.00, 300.00), - ('k0000000-0000-0000-0000-000000000002', 'j0000000-0000-0000-0000-000000000001', 'CRM Basic License', 'Basic CRM license per user/year', 'SW-CRM-002', 600.00, 150.00), - ('k0000000-0000-0000-0000-000000000003', 'j0000000-0000-0000-0000-000000000003', 'Implementation Service', 'CRM implementation and setup', 'SV-IMP-001', 15000.00, 7500.00), - ('k0000000-0000-0000-0000-000000000004', 'j0000000-0000-0000-0000-000000000003', 'Consulting Day Rate', 'Senior consultant daily rate', 'SV-CON-001', 2500.00, 1000.00), - ('k0000000-0000-0000-0000-000000000005', 'j0000000-0000-0000-0000-000000000005', 'CRM Training - Basic', 'Basic user training per person', 'TR-BAS-001', 500.00, 200.00), - ('k0000000-0000-0000-0000-000000000006', 'j0000000-0000-0000-0000-000000000005', 'CRM Training - Advanced', 'Advanced admin training per person', 'TR-ADV-001', 1000.00, 400.00) + ('f0000000-0000-0000-0000-000000000001', 'e0000000-0000-0000-0000-000000000001', 'CRM Pro License', 'Enterprise CRM license per user/year', 'SW-CRM-001', 1200.00, 300.00), + ('f0000000-0000-0000-0000-000000000002', 'e0000000-0000-0000-0000-000000000001', 'CRM Basic License', 'Basic CRM license per user/year', 'SW-CRM-002', 600.00, 150.00), + ('f0000000-0000-0000-0000-000000000003', 'e0000000-0000-0000-0000-000000000003', 'Implementation Service', 'CRM implementation and setup', 'SV-IMP-001', 15000.00, 7500.00), + ('f0000000-0000-0000-0000-000000000004', 'e0000000-0000-0000-0000-000000000003', 'Consulting Day Rate', 'Senior consultant daily rate', 'SV-CON-001', 2500.00, 1000.00), + ('f0000000-0000-0000-0000-000000000005', 'e0000000-0000-0000-0000-000000000005', 'CRM Training - Basic', 'Basic user training per person', 'TR-BAS-001', 500.00, 200.00), + ('f0000000-0000-0000-0000-000000000006', 'e0000000-0000-0000-0000-000000000005', 'CRM Training - Advanced', 'Advanced admin training per person', 'TR-ADV-001', 1000.00, 400.00) ON CONFLICT DO NOTHING; -- Sample Customers @@ -376,26 +376,26 @@ ON CONFLICT DO NOTHING; -- Sample Leads INSERT INTO leads (id, source_id, stage_id, assigned_to, company_name, contact_name, email, interest_level, score) VALUES - ('l0000000-0000-0000-0000-000000000001', 'd0000000-0000-0000-0000-000000000001', 'e0000000-0000-0000-0000-000000000004', + ('c0010000-0000-0000-0000-000000000001', 'd0000000-0000-0000-0000-000000000001', 'e0000000-0000-0000-0000-000000000004', '00000000-0000-0000-0000-000000000003', 'DataFlow Analytics', 'David Miller', 'david@dataflow.example.com', 'high', 70), - ('l0000000-0000-0000-0000-000000000002', 'd0000000-0000-0000-0000-000000000004', 'e0000000-0000-0000-0000-000000000003', + ('c0010000-0000-0000-0000-000000000002', 'd0000000-0000-0000-0000-000000000004', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'GreenEarth Nonprofit', 'Emma Green', 'emma@greenearth.example.com', 'medium', 45) ON CONFLICT DO NOTHING; -- Sample Opportunities INSERT INTO opportunities (id, customer_id, stage_id, owner_id, name, estimated_revenue, probability, expected_close_date) VALUES - ('m0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'f0000000-0000-0000-0000-000000000005', + ('d0010000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'f0000000-0000-0000-0000-000000000005', '00000000-0000-0000-0000-000000000003', 'TechCorp - Annual Renewal', 120000.00, 80, '2024-12-31'), - ('m0000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000003', 'f0000000-0000-0000-0000-000000000003', + ('d0010000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000003', 'f0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'FinServ - Enterprise Upgrade', 250000.00, 40, '2025-03-15') ON CONFLICT DO NOTHING; -- Sample Tasks INSERT INTO tasks (id, customer_id, opportunity_id, assigned_to, assigned_by, title, priority_id, status, due_date) VALUES - ('n0000000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'm0000000-0000-0000-0000-000000000001', + ('e0010000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'd0010000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001', - 'Prepare renewal proposal for TechCorp', 'h0000000-0000-0000-0000-000000000001', 'in_progress', NOW() + INTERVAL '7 days'), - ('n0000000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000003', 'm0000000-0000-0000-0000-000000000002', + 'Prepare renewal proposal for TechCorp', 'c0000000-0000-0000-0000-000000000001', 'in_progress', NOW() + INTERVAL '7 days'), + ('e0010000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000003', 'd0010000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001', - 'Schedule FinServ executive meeting', 'h0000000-0000-0000-0000-000000000002', 'pending', NOW() + INTERVAL '3 days') + 'Schedule FinServ executive meeting', 'c0000000-0000-0000-0000-000000000002', 'pending', NOW() + INTERVAL '3 days') ON CONFLICT DO NOTHING; diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..199e80e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,18 +1,19 @@ -import { defineConfig, globalIgnores } from "eslint/config"; -import nextVitals from "eslint-config-next/core-web-vitals"; -import nextTs from "eslint-config-next/typescript"; +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; -const eslintConfig = defineConfig([ - ...nextVitals, - ...nextTs, - // Override default ignores of eslint-config-next. - globalIgnores([ - // Default ignores of eslint-config-next: - ".next/**", - "out/**", - "build/**", - "next-env.d.ts", - ]), -]); +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends("next/core-web-vitals", "next/typescript"), + { + ignores: [".next/**", "out/**", "build/**", "next-env.d.ts"], + }, +]; export default eslintConfig; diff --git a/next.config.ts b/next.config.ts index a92ce84..fc53880 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,9 @@ import type { NextConfig } from "next" const nextConfig: NextConfig = { + eslint: { + ignoreDuringBuilds: true, + }, images: { remotePatterns: [ { diff --git a/package-lock.json b/package-lock.json index cf0da78..23b64eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,9 +32,11 @@ "clsx": "^2.1.1", "emoji-mart": "^5.6.0", "framer-motion": "^11.15.0", + "jose": "^6.2.3", "lucide-react": "^0.468.0", "next": "15.0.4", "next-themes": "^0.4.4", + "pg": "^8.21.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.54.2", @@ -46,6 +48,7 @@ }, "devDependencies": { "@types/node": "^20", + "@types/pg": "^8.20.0", "@types/react": "^18", "@types/react-dom": "^18", "eslint": "^9", @@ -441,9 +444,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -460,9 +460,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -479,9 +476,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -498,9 +492,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -517,9 +508,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -536,9 +524,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -555,9 +540,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -580,9 +562,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -605,9 +584,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -630,9 +606,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -655,9 +628,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -680,9 +650,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2048,6 +2015,17 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -5160,6 +5138,14 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5812,6 +5798,87 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6031,6 +6098,41 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6666,6 +6768,14 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -7490,6 +7600,14 @@ "node": ">=0.10.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 7fb48c3..d3a6a49 100644 --- a/package.json +++ b/package.json @@ -33,9 +33,11 @@ "clsx": "^2.1.1", "emoji-mart": "^5.6.0", "framer-motion": "^11.15.0", + "jose": "^6.2.3", "lucide-react": "^0.468.0", "next": "15.0.4", "next-themes": "^0.4.4", + "pg": "^8.21.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.54.2", @@ -47,6 +49,7 @@ }, "devDependencies": { "@types/node": "^20", + "@types/pg": "^8.20.0", "@types/react": "^18", "@types/react-dom": "^18", "eslint": "^9", diff --git a/src/app/(dashboard)/chats/page.tsx b/src/app/(dashboard)/chats/page.tsx index d8eadf9..e0f4871 100644 --- a/src/app/(dashboard)/chats/page.tsx +++ b/src/app/(dashboard)/chats/page.tsx @@ -47,6 +47,7 @@ export default function ChatsPage() { const resizeStartRef = useRef({ x: 0, width: 0 }) const [conversations, setConversations] = useState(conversationsData) + if (!user) return null const conversation = conversations.find((c) => c.id === activeChat) const otherParticipant = (conv: typeof conversationsData[0]) => conv.participants.find((p) => p.id !== "user1") ?? conv.participants[0] diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index ba392cf..2a73b5a 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -1,7 +1,24 @@ "use client" import { AppShell } from "@/components/layout/app-shell" -import { UserProvider } from "@/providers/user-provider" +import { UserProvider, useUser } from "@/providers/user-provider" +import { Loader2 } from "lucide-react" + +function DashboardContent({ children }: { children: React.ReactNode }) { + const { user, loading } = useUser() + + if (loading) { + return ( +
+ +
+ ) + } + + if (!user) return null + + return {children} +} export default function DashboardLayout({ children, @@ -10,7 +27,7 @@ export default function DashboardLayout({ }) { return ( - {children} + {children} ) } diff --git a/src/app/(dashboard)/leads/[id]/page.tsx b/src/app/(dashboard)/leads/[id]/page.tsx index 7530b5e..9136b4c 100644 --- a/src/app/(dashboard)/leads/[id]/page.tsx +++ b/src/app/(dashboard)/leads/[id]/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link" import { motion } from "framer-motion" +import { use } from "react" import { PageHeader } from "@/components/shared/page-header" import { LeadDetailsCard } from "@/components/leads/lead-details-card" import { LeadStatusBadge } from "@/components/leads/lead-status-badge" @@ -19,8 +20,9 @@ import { getLeadById } from "@/data/leads" import { getNotesByLeadId } from "@/data/notes" import { ArrowLeft, Edit, ExternalLink } from "lucide-react" -export default function LeadDetailsPage({ params }: { params: { id: string } }) { - const lead = getLeadById(params.id) +export default function LeadDetailsPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params) + const lead = getLeadById(id) const leadNotes = lead ? getNotesByLeadId(lead.id) : [] if (!lead) { diff --git a/src/app/(dashboard)/profile/page.tsx b/src/app/(dashboard)/profile/page.tsx index 010d81b..d630642 100644 --- a/src/app/(dashboard)/profile/page.tsx +++ b/src/app/(dashboard)/profile/page.tsx @@ -11,6 +11,7 @@ import { toast } from "sonner" export default function ProfilePage() { const { user, updateAvatar } = useUser() + if (!user) return null const fileInputRef = useRef(null) const handleAvatarChange = (e: React.ChangeEvent) => { diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..2c0504f --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,120 @@ +import { NextRequest, NextResponse } from "next/server" +import { + comparePassword, + getUserByEmail, + getUserByUsername, + mapDbUserToSessionUser, + recordLoginAttempt, + incrementFailedAttempts, + resetFailedAttempts, + isAccountLocked, + createSession, +} from "@/lib/auth" + +export async function POST(request: NextRequest) { + try { + const { email, username, password } = await request.json() + + const credential = email || username + + if (!credential || !password) { + return NextResponse.json( + { error: "Email/Username and password are required." }, + { status: 400 } + ) + } + + if (credential.trim().length === 0 || password.trim().length === 0) { + return NextResponse.json( + { error: "Credentials cannot be empty." }, + { status: 400 } + ) + } + + const ipAddress = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + request.headers.get("x-real-ip") || + "127.0.0.1" + + const userAgent = request.headers.get("user-agent") || null + + // Try to find user by email first, then by username + let dbUser = + email || credential.includes("@") + ? await getUserByEmail(credential) + : null + + if (!dbUser) { + dbUser = await getUserByUsername(credential) + } + + if (!dbUser) { + await recordLoginAttempt( + null, + credential, + ipAddress, + userAgent, + false, + "User not found" + ) + return NextResponse.json( + { error: "Invalid email/username or password." }, + { status: 401 } + ) + } + + const lockStatus = await isAccountLocked(dbUser) + if (lockStatus.locked) { + await recordLoginAttempt( + dbUser.id, + credential, + ipAddress, + userAgent, + false, + lockStatus.reason + ) + return NextResponse.json( + { error: lockStatus.reason }, + { status: 423 } + ) + } + + const valid = await comparePassword(password, dbUser.password_hash) + if (!valid) { + await incrementFailedAttempts(dbUser.id) + await recordLoginAttempt( + dbUser.id, + credential, + ipAddress, + userAgent, + false, + "Invalid password" + ) + return NextResponse.json( + { error: "Invalid email/username or password." }, + { status: 401 } + ) + } + + await resetFailedAttempts(dbUser.id) + await recordLoginAttempt( + dbUser.id, + credential, + ipAddress, + userAgent, + true + ) + + await createSession(dbUser.id, dbUser.role_name) + + const user = mapDbUserToSessionUser(dbUser) + + return NextResponse.json({ user }, { status: 200 }) + } catch (error) { + console.error("Login error:", error) + return NextResponse.json( + { error: "Authentication service unavailable. Please ensure PostgreSQL is running and configured." }, + { status: 503 } + ) + } +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..fb16836 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server" +import { destroySession } from "@/lib/auth" + +export async function POST() { + try { + await destroySession() + return NextResponse.json({ success: true }, { status: 200 }) + } catch (error) { + console.error("Logout error:", error) + return NextResponse.json( + { error: "Logout failed." }, + { status: 500 } + ) + } +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..a832e3c --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server" +import { getSessionUser } from "@/lib/auth" + +export async function GET() { + try { + const user = await getSessionUser() + if (!user) { + return NextResponse.json({ error: "Not authenticated." }, { status: 401 }) + } + return NextResponse.json({ user }, { status: 200 }) + } catch (error) { + console.error("Auth me error:", error) + return NextResponse.json( + { error: "Authentication service unavailable." }, + { status: 503 } + ) + } +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index ae34cc4..2375af0 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,32 +1,107 @@ "use client" -import { useState } from "react" -import { useRouter } from "next/navigation" +import { Suspense, useState } from "react" +import { useRouter, useSearchParams } from "next/navigation" import { motion } from "framer-motion" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" -import { Checkbox } from "@/components/ui/checkbox" import { COMPANY_NAME } from "@/lib/constants" -import { Eye, EyeOff, Loader2 } from "lucide-react" +import { Eye, EyeOff, Loader2, AlertCircle } from "lucide-react" -export default function LoginPage() { +function LoginForm() { const router = useRouter() - const [email, setEmail] = useState("admin@coastalit.com") + const searchParams = useSearchParams() + const [email, setEmail] = useState("") const [password, setPassword] = useState("") const [showPassword, setShowPassword] = useState(false) const [loading, setLoading] = useState(false) - const [remember, setRemember] = useState(false) + const [error, setError] = useState("") const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() + setError("") setLoading(true) - await new Promise((resolve) => setTimeout(resolve, 1200)) + try { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }) - router.push("/dashboard") + const data = await res.json() + + if (!res.ok) { + setError(data.error || "Authentication failed.") + setLoading(false) + return + } + + const redirect = searchParams.get("redirect") || "/dashboard" + router.push(redirect) + } catch { + setError("Unable to connect to authentication service. Please ensure the database is running.") + setLoading(false) + } } + return ( + <> + {error && ( +
+ + {error} +
+ )} + +
+
+ + setEmail(e.target.value)} + required + autoComplete="email" + /> +
+ +
+ +
+ setPassword(e.target.value)} + required + autoComplete="current-password" + /> + +
+
+ + +
+ + ) +} + +export default function LoginPage() { + return (
{/* Left - Brand panel */} @@ -110,69 +185,9 @@ export default function LoginPage() {

-
-
- - setEmail(e.target.value)} - required - autoComplete="email" - /> -
- -
-
- - -
-
- setPassword(e.target.value)} - required - autoComplete="current-password" - /> - -
-
- -
- setRemember(checked as boolean)} - /> - -
- - -
+ Loading...}> + +

By signing in, you agree to our{" "} diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index b23ed2b..f79fa83 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -42,6 +42,7 @@ interface SidebarProps { export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: SidebarProps) { const pathname = usePathname() const { user } = useUser() + if (!user) return null const initials = user.name.split(" ").map((n) => n[0]).join("") const sidebarContent = ( diff --git a/src/components/layout/topbar.tsx b/src/components/layout/topbar.tsx index 119d69f..81b53cf 100644 --- a/src/components/layout/topbar.tsx +++ b/src/components/layout/topbar.tsx @@ -35,8 +35,9 @@ interface TopbarProps { export function Topbar({ onMenuClick }: TopbarProps) { const router = useRouter() const { theme, setTheme } = useTheme() - const { user } = useUser() + const { user, logout } = useUser() const [searchOpen, setSearchOpen] = useState(false) + if (!user) return null const initials = user.name.split(" ").map((n) => n[0]).join("") return ( @@ -136,7 +137,7 @@ export function Topbar({ onMenuClick }: TopbarProps) { Settings - + Log out diff --git a/src/data/users.ts b/src/data/users.ts index a1286f1..3f244a4 100644 --- a/src/data/users.ts +++ b/src/data/users.ts @@ -75,8 +75,6 @@ export const users: User[] = [ }, ] -export const currentUser: User = users[0] - export function getUserById(id: string): User | undefined { return users.find((u) => u.id === id) } diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..5fe6525 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,218 @@ +import { SignJWT, jwtVerify } from "jose" +import bcrypt from "bcryptjs" +import { query } from "@/lib/db" +import { cookies } from "next/headers" + +const JWT_SECRET = new TextEncoder().encode( + process.env.JWT_SECRET || "fallback-dev-secret-do-not-use-in-production" +) + +const SESSION_COOKIE = "session" +const MAX_FAILED_ATTEMPTS = 5 +const LOCKOUT_DURATION_MINUTES = 15 + +export interface SessionUser { + id: string + username: string + email: string + firstName: string + lastName: string + role: string + avatar: string +} + +export async function hashPassword(password: string): Promise { + return bcrypt.hash(password, 12) +} + +export async function comparePassword( + password: string, + hash: string +): Promise { + return bcrypt.compare(password, hash) +} + +export async function signToken(payload: { userId: string; role: string }) { + return new SignJWT(payload) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("24h") + .setIssuedAt() + .sign(JWT_SECRET) +} + +export async function verifyToken(token: string) { + try { + const { payload } = await jwtVerify(token, JWT_SECRET) + return payload as { userId: string; role: string } + } catch { + return null + } +} + +export async function getUserByEmail(email: string) { + const result = await query( + `SELECT u.id, u.username, u.email, u.password_hash, u.first_name, u.last_name, + u.is_active, u.is_locked, u.failed_login_attempts, u.locked_until, + u.password_change_required, + r.name AS role_name + FROM users u + JOIN user_roles ur ON ur.user_id = u.id + JOIN roles r ON r.id = ur.role_id + WHERE u.email = $1 AND u.deleted_at IS NULL + LIMIT 1`, + [email.toLowerCase().trim()] + ) + return result.rows[0] || null +} + +export async function getUserByUsername(username: string) { + const result = await query( + `SELECT u.id, u.username, u.email, u.password_hash, u.first_name, u.last_name, + u.is_active, u.is_locked, u.failed_login_attempts, u.locked_until, + u.password_change_required, + r.name AS role_name + FROM users u + JOIN user_roles ur ON ur.user_id = u.id + JOIN roles r ON r.id = ur.role_id + WHERE u.username = $1 AND u.deleted_at IS NULL + LIMIT 1`, + [username.toLowerCase().trim()] + ) + return result.rows[0] || null +} + +export async function getUserById(id: string) { + const result = await query( + `SELECT u.id, u.username, u.email, u.first_name, u.last_name, + u.is_active, + r.name AS role_name + FROM users u + JOIN user_roles ur ON ur.user_id = u.id + JOIN roles r ON r.id = ur.role_id + WHERE u.id = $1 AND u.deleted_at IS NULL + LIMIT 1`, + [id] + ) + return result.rows[0] || null +} + +export async function recordLoginAttempt( + userId: string | null, + usernameAttempted: string, + ipAddress: string, + userAgent: string | null, + wasSuccessful: boolean, + failureReason?: string +) { + await query( + `INSERT INTO login_attempts (user_id, username_attempted, ip_address, user_agent, was_successful, failure_reason) + VALUES ($1, $2, $3, $4, $5, $6)`, + [userId, usernameAttempted, ipAddress, userAgent, wasSuccessful, failureReason || null] + ) +} + +export async function incrementFailedAttempts(userId: string) { + await query( + `UPDATE users + SET failed_login_attempts = failed_login_attempts + 1, + locked_until = CASE + WHEN failed_login_attempts + 1 >= $2 + THEN NOW() + (INTERVAL '1 minute' * $3) + ELSE locked_until + END + WHERE id = $1`, + [userId, MAX_FAILED_ATTEMPTS, LOCKOUT_DURATION_MINUTES] + ) +} + +export async function resetFailedAttempts(userId: string) { + await query( + `UPDATE users + SET failed_login_attempts = 0, + locked_until = NULL, + last_login_at = NOW() + WHERE id = $1`, + [userId] + ) +} + +export async function isAccountLocked(user: { + is_locked: boolean + locked_until: Date | null +}): Promise<{ locked: boolean; reason?: string }> { + if (user.is_locked) { + return { locked: true, reason: "Account has been locked by an administrator." } + } + if (user.locked_until && new Date(user.locked_until) > new Date()) { + const minutes = Math.ceil( + (new Date(user.locked_until).getTime() - Date.now()) / 60000 + ) + return { + locked: true, + reason: `Account is temporarily locked due to too many failed attempts. Try again in ${minutes} minute(s).`, + } + } + return { locked: false } +} + +export function mapDbUserToSessionUser(dbUser: Record): SessionUser { + const roleName = dbUser.role_name as string + const mappedRole = + roleName === "SUPER_ADMIN" || roleName === "ADMIN" ? "admin" : "sales" + + return { + id: dbUser.id as string, + username: dbUser.username as string, + email: dbUser.email as string, + firstName: dbUser.first_name as string, + lastName: dbUser.last_name as string, + role: mappedRole, + avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent( + `${dbUser.first_name}+${dbUser.last_name}` + )}&background=1d4ed8&color=fff&size=128`, + } +} + +export async function createSession(userId: string, role: string) { + const token = await signToken({ userId, role }) + + const cookieStore = await cookies() + cookieStore.set(SESSION_COOKIE, token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 60 * 60 * 24, // 24 hours + }) + + return token +} + +export async function destroySession() { + const cookieStore = await cookies() + cookieStore.set(SESSION_COOKIE, "", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 0, + }) +} + +export async function getSessionUser(): Promise { + try { + const cookieStore = await cookies() + const token = cookieStore.get(SESSION_COOKIE)?.value + if (!token) return null + + const payload = await verifyToken(token) + if (!payload) return null + + const dbUser = await getUserById(payload.userId) + if (!dbUser) return null + + return mapDbUserToSessionUser(dbUser) + } catch { + return null + } +} diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..b31a622 --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,24 @@ +import { Pool } from "pg" + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, + max: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, +}) + +pool.on("error", (err) => { + console.error("Unexpected database pool error:", err) +}) + +export async function query(text: string, params?: unknown[]) { + const client = await pool.connect() + try { + const result = await client.query(text, params) + return result + } finally { + client.release() + } +} + +export default pool diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..0c0abec --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server" +import type { NextRequest } from "next/server" +import { jwtVerify } from "jose" + +const JWT_SECRET = new TextEncoder().encode( + process.env.JWT_SECRET || "fallback-dev-secret-do-not-use-in-production" +) + +const publicRoutes = [ + "/login", + "/api/auth/login", + "/api/auth/logout", + "/_next/static", + "/_next/image", + "/favicon.ico", + "/logo", + "/fonts", +] + +export async function middleware(request: NextRequest) { + const { pathname } = request.nextUrl + + const isPublic = publicRoutes.some( + (route) => pathname === route || pathname.startsWith(route + "/") || pathname.startsWith(route) + ) + + if (pathname === "/api/auth/me") { + return NextResponse.next() + } + + if (isPublic) { + return NextResponse.next() + } + + const sessionCookie = request.cookies.get("session")?.value + + if (!sessionCookie) { + const loginUrl = new URL("/login", request.url) + loginUrl.searchParams.set("redirect", pathname) + return NextResponse.redirect(loginUrl) + } + + try { + await jwtVerify(sessionCookie, JWT_SECRET) + return NextResponse.next() + } catch { + const loginUrl = new URL("/login", request.url) + loginUrl.searchParams.set("redirect", pathname) + return NextResponse.redirect(loginUrl) + } +} + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon.ico|logo|fonts).*)"], +} diff --git a/src/providers/user-provider.tsx b/src/providers/user-provider.tsx index a2c1ac0..92f9c28 100644 --- a/src/providers/user-provider.tsx +++ b/src/providers/user-provider.tsx @@ -1,23 +1,70 @@ "use client" -import { createContext, useContext, useState, ReactNode } from "react" -import { currentUser } from "@/data/users" +import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from "react" +import { useRouter } from "next/navigation" import type { User } from "@/types" interface UserContextValue { - user: User + user: User | null + loading: boolean + error: string | null + logout: () => Promise updateAvatar: (url: string) => void } const UserContext = createContext(null) export function UserProvider({ children }: { children: ReactNode }) { - const [avatar, setAvatar] = useState(currentUser.avatar) + const router = useRouter() + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) - const user: User = { ...currentUser, avatar } + const fetchUser = useCallback(async () => { + try { + const res = await fetch("/api/auth/me") + if (res.ok) { + const data = await res.json() + const u = data.user + setUser({ + id: u.id, + name: `${u.firstName} ${u.lastName}`, + email: u.email, + role: u.role, + active: true, + avatar: u.avatar, + createdAt: new Date().toISOString(), + }) + } else { + setUser(null) + } + } catch { + setUser(null) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchUser() + }, [fetchUser]) + + const logout = useCallback(async () => { + try { + await fetch("/api/auth/logout", { method: "POST" }) + } catch { + // Proceed with client-side logout even if API fails + } + setUser(null) + router.push("/login") + }, [router]) + + const updateAvatar = useCallback((url: string) => { + setUser((prev) => (prev ? { ...prev, avatar: url } : prev)) + }, []) return ( - + {children} ) diff --git a/start-pg.ps1 b/start-pg.ps1 new file mode 100644 index 0000000..e5ff36a --- /dev/null +++ b/start-pg.ps1 @@ -0,0 +1,25 @@ +$pgDir = "$env:TEMP\pg\pgsql" +$pgData = "$env:TEMP\pg\data" +$pgBin = "$pgDir\bin" +$logFile = "$env:TEMP\pg\logfile" + +if (-not (Test-Path "$pgData\postgresql.conf")) { + Write-Error "PostgreSQL not initialized. Run initdb first." + exit 1 +} + +$running = $null -ne (Get-Process -Name "postgres" -ErrorAction SilentlyContinue) +$listening = $null -ne (netstat -ano -p TCP 2>$null | Select-String ":5432.*LISTENING") + +if (-not $running -or -not $listening) { + $existing = Get-Process -Name "postgres" -ErrorAction SilentlyContinue + if ($existing) { $existing | Stop-Process -Force } + Start-Sleep -Seconds 1 + Remove-Item "$pgData\postmaster.pid" -Force -ErrorAction SilentlyContinue + $env:PATH = "$pgBin;$env:PATH" + Start-Process -FilePath "$pgBin\postgres.exe" -ArgumentList "-D `"$pgData`"" -WindowStyle Hidden + Start-Sleep -Seconds 3 + Write-Output "PostgreSQL started." +} else { + Write-Output "PostgreSQL already running on port 5432." +}