Added 4 Demo Users, with Coast emails, Hierarchy, roles etc.

This commit is contained in:
2026-06-18 13:13:29 +02:00
parent 3f839bc0fc
commit 5238b59140
22 changed files with 973 additions and 286 deletions
+8 -4
View File
@@ -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_user ON banned_users(banned_user_id);
CREATE INDEX idx_banned_users_active 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 INDEX idx_banned_users_banned_by ON banned_users(banned_by);
CREATE TABLE suspended_users ( 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_user ON suspended_users(suspended_user_id);
CREATE INDEX idx_suspended_users_active 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); 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_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_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_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 ( CREATE TABLE invoice_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
@@ -800,10 +800,14 @@ DECLARE
t TEXT; t TEXT;
BEGIN BEGIN
FOR t IN FOR t IN
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 SELECT table_name FROM information_schema.columns
WHERE column_name = 'updated_at' WHERE column_name = 'updated_at'
AND table_schema = 'public' AND table_schema = 'public'
AND table_type = 'BASE TABLE' )
LOOP LOOP
EXECUTE format( EXECUTE format(
'CREATE TRIGGER trg_%I_updated_at 'CREATE TRIGGER trg_%I_updated_at
+143 -143
View File
@@ -35,78 +35,78 @@ ON CONFLICT (name) DO NOTHING;
-- USER MANAGEMENT (SUPER_ADMIN only) -- USER MANAGEMENT (SUPER_ADMIN only)
INSERT INTO permissions (id, resource, action, description, category) VALUES INSERT INTO permissions (id, resource, action, description, category) VALUES
('p0010001-0000-0000-0000-000000000000', 'users', 'create', 'Create new user accounts', 'User Management'), ('a0010001-0000-0000-0000-000000000000', 'users', 'create', 'Create new user accounts', 'User Management'),
('p0010002-0000-0000-0000-000000000000', 'users', 'read', 'View user details', 'User Management'), ('a0010002-0000-0000-0000-000000000000', 'users', 'read', 'View user details', 'User Management'),
('p0010003-0000-0000-0000-000000000000', 'users', 'update', 'Update user details', 'User Management'), ('a0010003-0000-0000-0000-000000000000', 'users', 'update', 'Update user details', 'User Management'),
('p0010004-0000-0000-0000-000000000000', 'users', 'delete', 'Permanently delete user accounts', 'User Management'), ('a0010004-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'), ('a0010005-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'), ('a0010006-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'), ('a0010007-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'), ('a0010008-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'), ('a0010009-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') ('a0010010-0000-0000-0000-000000000000', 'users', 'permanently_delete', 'Permanently remove accounts from system', 'User Management')
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- BAN & SUSPENSION (ADMIN + SUPER_ADMIN) -- BAN & SUSPENSION (ADMIN + SUPER_ADMIN)
INSERT INTO permissions (id, resource, action, description, category) VALUES INSERT INTO permissions (id, resource, action, description, category) VALUES
('p0020001-0000-0000-0000-000000000000', 'bans', 'create', 'Ban a user account', 'Ban Management'), ('a0020001-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'), ('a0020002-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'), ('a0020003-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'), ('a0020004-0000-0000-0000-000000000000', 'suspensions', 'create', 'Suspend a user account', 'Ban Management'),
('p0020005-0000-0000-0000-000000000000', 'suspensions', 'lift', 'Lift a suspension', 'Ban Management'), ('a0020005-0000-0000-0000-000000000000', 'suspensions', 'lift', 'Lift a suspension', 'Ban Management'),
('p0020006-0000-0000-0000-000000000000', 'suspensions', 'view', 'View suspension records', 'Ban Management') ('a0020006-0000-0000-0000-000000000000', 'suspensions', 'view', 'View suspension records', 'Ban Management')
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- CRM CORE (SALES_USER + MANAGERS) -- CRM CORE (SALES_USER + MANAGERS)
INSERT INTO permissions (id, resource, action, description, category) VALUES INSERT INTO permissions (id, resource, action, description, category) VALUES
('p0030001-0000-0000-0000-000000000000', 'customers', 'create', 'Create customer records', 'CRM'), ('a0030001-0000-0000-0000-000000000000', 'customers', 'create', 'Create customer records', 'CRM'),
('p0030002-0000-0000-0000-000000000000', 'customers', 'read', 'View customer records', 'CRM'), ('a0030002-0000-0000-0000-000000000000', 'customers', 'read', 'View customer records', 'CRM'),
('p0030003-0000-0000-0000-000000000000', 'customers', 'update', 'Update customer records', 'CRM'), ('a0030003-0000-0000-0000-000000000000', 'customers', 'update', 'Update customer records', 'CRM'),
('p0030004-0000-0000-0000-000000000000', 'customers', 'delete', 'Delete customer records', 'CRM'), ('a0030004-0000-0000-0000-000000000000', 'customers', 'delete', 'Delete customer records', 'CRM'),
('p0030005-0000-0000-0000-000000000000', 'leads', 'create', 'Create sales leads', 'CRM'), ('a0030005-0000-0000-0000-000000000000', 'leads', 'create', 'Create sales leads', 'CRM'),
('p0030006-0000-0000-0000-000000000000', 'leads', 'read', 'View sales leads', 'CRM'), ('a0030006-0000-0000-0000-000000000000', 'leads', 'read', 'View sales leads', 'CRM'),
('p0030007-0000-0000-0000-000000000000', 'leads', 'update', 'Update sales leads', 'CRM'), ('a0030007-0000-0000-0000-000000000000', 'leads', 'update', 'Update sales leads', 'CRM'),
('p0030008-0000-0000-0000-000000000000', 'leads', 'convert', 'Convert leads to customers', 'CRM'), ('a0030008-0000-0000-0000-000000000000', 'leads', 'convert', 'Convert leads to customers', 'CRM'),
('p0030009-0000-0000-0000-000000000000', 'opportunities', 'create', 'Create sales opportunities', 'CRM'), ('a0030009-0000-0000-0000-000000000000', 'opportunities', 'create', 'Create sales opportunities', 'CRM'),
('p0030010-0000-0000-0000-000000000000', 'opportunities', 'read', 'View sales opportunities', 'CRM'), ('a0030010-0000-0000-0000-000000000000', 'opportunities', 'read', 'View sales opportunities', 'CRM'),
('p0030011-0000-0000-0000-000000000000', 'opportunities', 'update', 'Update sales pipeline', 'CRM'), ('a0030011-0000-0000-0000-000000000000', 'opportunities', 'update', 'Update sales pipeline', 'CRM'),
('p0030012-0000-0000-0000-000000000000', 'pipeline', 'update', 'Update sales pipeline stages', 'CRM'), ('a0030012-0000-0000-0000-000000000000', 'pipeline', 'update', 'Update sales pipeline stages', 'CRM'),
('p0030013-0000-0000-0000-000000000000', 'communications', 'create', 'Log calls and meetings', 'CRM'), ('a0030013-0000-0000-0000-000000000000', 'communications', 'create', 'Log calls and meetings', 'CRM'),
('p0030014-0000-0000-0000-000000000000', 'communications', 'read', 'View communication history', 'CRM'), ('a0030014-0000-0000-0000-000000000000', 'communications', 'read', 'View communication history', 'CRM'),
('p0030015-0000-0000-0000-000000000000', 'contacts', 'manage', 'Manage customer contact details', 'CRM'), ('a0030015-0000-0000-0000-000000000000', 'contacts', 'manage', 'Manage customer contact details', 'CRM'),
('p0030016-0000-0000-0000-000000000000', 'products', 'read', 'View product catalog', 'CRM'), ('a0030016-0000-0000-0000-000000000000', 'products', 'read', 'View product catalog', 'CRM'),
('p0030017-0000-0000-0000-000000000000', 'invoices', 'read', 'View invoices', 'CRM') ('a0030017-0000-0000-0000-000000000000', 'invoices', 'read', 'View invoices', 'CRM')
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- REPORTING & INCIDENTS (ADMIN) -- REPORTING & INCIDENTS (ADMIN)
INSERT INTO permissions (id, resource, action, description, category) VALUES INSERT INTO permissions (id, resource, action, description, category) VALUES
('p0040001-0000-0000-0000-000000000000', 'reports', 'customers_view', 'Review customer reports', 'Reporting'), ('a0040001-0000-0000-0000-000000000000', 'reports', 'customers_view', 'Review customer reports', 'Reporting'),
('p0040002-0000-0000-0000-000000000000', 'reports', 'incidents_view', 'Review incident reports', 'Reporting'), ('a0040002-0000-0000-0000-000000000000', 'reports', 'incidents_view', 'Review incident reports', 'Reporting'),
('p0040003-0000-0000-0000-000000000000', 'reports', 'complaints_investigate', 'Investigate complaints', 'Reporting'), ('a0040003-0000-0000-0000-000000000000', 'reports', 'complaints_investigate', 'Investigate complaints', 'Reporting'),
('p0040004-0000-0000-0000-000000000000', 'crm', 'dashboard_access', 'Access CRM dashboards', 'Reporting'), ('a0040004-0000-0000-0000-000000000000', 'crm', 'dashboard_access', 'Access CRM dashboards', 'Reporting'),
('p0040005-0000-0000-0000-000000000000', 'activity', 'logs_view', 'View user activity logs', 'Reporting'), ('a0040005-0000-0000-0000-000000000000', 'activity', 'logs_view', 'View user activity logs', 'Reporting'),
('p0040006-0000-0000-0000-000000000000', 'suspicious', 'moderate', 'Moderate suspicious activity', 'Reporting'), ('a0040006-0000-0000-0000-000000000000', 'suspicious', 'moderate', 'Moderate suspicious activity', 'Reporting'),
('p0040007-0000-0000-0000-000000000000', 'accounts', 'lock_investigation', 'Lock accounts under investigation', 'Reporting') ('a0040007-0000-0000-0000-000000000000', 'accounts', 'lock_investigation', 'Lock accounts under investigation', 'Reporting')
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- AUDIT & SYSTEM (SUPER_ADMIN + limited ADMIN) -- AUDIT & SYSTEM (SUPER_ADMIN + limited ADMIN)
INSERT INTO permissions (id, resource, action, description, category) VALUES INSERT INTO permissions (id, resource, action, description, category) VALUES
('p0050001-0000-0000-0000-000000000000', 'audit', 'full_access', 'Full audit log access', 'System'), ('a0050001-0000-0000-0000-000000000000', 'audit', 'full_access', 'Full audit log access', 'System'),
('p0050002-0000-0000-0000-000000000000', 'audit', 'read', 'View audit logs', 'System'), ('a0050002-0000-0000-0000-000000000000', 'audit', 'read', 'View audit logs', 'System'),
('p0050003-0000-0000-0000-000000000000', 'settings', 'modify', 'Modify system settings', 'System'), ('a0050003-0000-0000-0000-000000000000', 'settings', 'modify', 'Modify system settings', 'System'),
('p0050004-0000-0000-0000-000000000000', 'database', 'full_access', 'Full database access', 'System'), ('a0050004-0000-0000-0000-000000000000', 'database', 'full_access', 'Full database access', 'System'),
('p0050005-0000-0000-0000-000000000000', 'crm', 'full_access', 'Full CRM access override', 'System'), ('a0050005-0000-0000-0000-000000000000', 'crm', 'full_access', 'Full CRM access override', 'System'),
('p0050006-0000-0000-0000-000000000000', 'permissions', 'override', 'Override all permissions', 'System') ('a0050006-0000-0000-0000-000000000000', 'permissions', 'override', 'Override all permissions', 'System')
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- DEVELOPER PERMISSIONS -- DEVELOPER PERMISSIONS
INSERT INTO permissions (id, resource, action, description, category) VALUES INSERT INTO permissions (id, resource, action, description, category) VALUES
('p0060001-0000-0000-0000-000000000000', 'api', 'testing', 'API testing access', 'Developer'), ('a0060001-0000-0000-0000-000000000000', 'api', 'testing', 'API testing access', 'Developer'),
('p0060002-0000-0000-0000-000000000000', 'backend', 'diagnostics', 'Backend diagnostics', 'Developer'), ('a0060002-0000-0000-0000-000000000000', 'backend', 'diagnostics', 'Backend diagnostics', 'Developer'),
('p0060003-0000-0000-0000-000000000000', 'system', 'logs_view', 'View system logs', 'Developer'), ('a0060003-0000-0000-0000-000000000000', 'system', 'logs_view', 'View system logs', 'Developer'),
('p0060004-0000-0000-0000-000000000000', 'database', 'diagnostics', 'Database diagnostics', 'Developer'), ('a0060004-0000-0000-0000-000000000000', 'database', 'diagnostics', 'Database diagnostics', 'Developer'),
('p0060005-0000-0000-0000-000000000000', 'debugging', 'access', 'Debugging access', 'Developer'), ('a0060005-0000-0000-0000-000000000000', 'debugging', 'access', 'Debugging access', 'Developer'),
('p0060006-0000-0000-0000-000000000000', 'testing', 'internal', 'Internal testing access', 'Developer') ('a0060006-0000-0000-0000-000000000000', 'testing', 'internal', 'Internal testing access', 'Developer')
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- ============================================================================ -- ============================================================================
@@ -121,70 +121,70 @@ ON CONFLICT DO NOTHING;
-- ADMIN — Bans, Suspensions, Reporting, CRM read, Activity logs -- ADMIN — Bans, Suspensions, Reporting, CRM read, Activity logs
INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES
-- Bans & Suspensions -- Bans & Suspensions
('00000002-0000-0000-0000-000000000000', 'p0020001-0000-0000-0000-000000000000', NULL), ('00000002-0000-0000-0000-000000000000', 'a0020001-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-000000000000', 'p0020004-0000-0000-0000-000000000000', NULL), ('00000002-0000-0000-0000-000000000000', 'a0020004-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-000000000000', 'p0020005-0000-0000-0000-000000000000', NULL), ('00000002-0000-0000-0000-000000000000', 'a0020005-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-000000000000', 'p0020006-0000-0000-0000-000000000000', NULL), ('00000002-0000-0000-0000-000000000000', 'a0020006-0000-0000-0000-000000000000', NULL),
-- User read + disable -- User read + disable
('00000002-0000-0000-0000-000000000000', 'p0010002-0000-0000-0000-000000000000', NULL), ('00000002-0000-0000-0000-000000000000', 'a0010002-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-0000-000000000000', 'p0010009-0000-0000-0000-000000000000', NULL), ('00000002-0000-0000-0000-000000000000', 'a0010009-0000-0000-0000-000000000000', NULL),
-- Reporting & Incidents -- Reporting & Incidents
('00000002-0000-0000-0000-000000000000', 'p0040001-0000-0000-0000-000000000000', NULL), ('00000002-0000-0000-0000-000000000000', 'a0040001-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-000000000000', 'p0040002-0000-0000-0000-000000000000', NULL), ('00000002-0000-0000-0000-000000000000', 'a0040002-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-000000000000', 'p0040003-0000-0000-0000-000000000000', NULL), ('00000002-0000-0000-0000-000000000000', 'a0040003-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-000000000000', 'p0040004-0000-0000-0000-000000000000', NULL), ('00000002-0000-0000-0000-000000000000', 'a0040004-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-000000000000', 'p0040005-0000-0000-0000-000000000000', NULL), ('00000002-0000-0000-0000-000000000000', 'a0040005-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-000000000000', 'p0040006-0000-0000-0000-000000000000', NULL), ('00000002-0000-0000-0000-000000000000', 'a0040006-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-000000000000', 'p0040007-0000-0000-0000-000000000000', NULL), ('00000002-0000-0000-0000-000000000000', 'a0040007-0000-0000-0000-000000000000', NULL),
-- CRM read access -- CRM read access
('00000002-0000-0000-0000-000000000000', 'p0030002-0000-0000-0000-000000000000', NULL), ('00000002-0000-0000-0000-000000000000', 'a0030002-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-000000000000', 'p0030006-0000-0000-0000-000000000000', NULL), ('00000002-0000-0000-0000-000000000000', 'a0030006-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-000000000000', 'p0030010-0000-0000-0000-000000000000', NULL), ('00000002-0000-0000-0000-000000000000', 'a0030010-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-000000000000', 'p0030014-0000-0000-0000-000000000000', NULL), ('00000002-0000-0000-0000-000000000000', 'a0030014-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-000000000000', 'p0030016-0000-0000-0000-000000000000', NULL), ('00000002-0000-0000-0000-000000000000', 'a0030016-0000-0000-0000-000000000000', NULL),
('00000002-0000-0000-0000-000000000000', 'p0030017-0000-0000-0000-000000000000', NULL), ('00000002-0000-0000-0000-000000000000', 'a0030017-0000-0000-0000-000000000000', NULL),
-- Audit read -- 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; ON CONFLICT DO NOTHING;
-- SALES_USER — CRM operations -- SALES_USER — CRM operations
INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES 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', 'a0030001-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'p0030002-0000-0000-0000-000000000000', NULL), ('00000003-0000-0000-0000-000000000000', 'a0030002-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'p0030003-0000-0000-0000-000000000000', NULL), ('00000003-0000-0000-0000-000000000000', 'a0030003-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'p0030005-0000-0000-0000-000000000000', NULL), ('00000003-0000-0000-0000-000000000000', 'a0030005-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'p0030006-0000-0000-0000-000000000000', NULL), ('00000003-0000-0000-0000-000000000000', 'a0030006-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'p0030007-0000-0000-0000-000000000000', NULL), ('00000003-0000-0000-0000-000000000000', 'a0030007-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'p0030008-0000-0000-0000-000000000000', NULL), ('00000003-0000-0000-0000-000000000000', 'a0030008-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'p0030009-0000-0000-0000-000000000000', NULL), ('00000003-0000-0000-0000-000000000000', 'a0030009-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'p0030010-0000-0000-0000-000000000000', NULL), ('00000003-0000-0000-0000-000000000000', 'a0030010-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'p0030011-0000-0000-0000-000000000000', NULL), ('00000003-0000-0000-0000-000000000000', 'a0030011-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'p0030012-0000-0000-0000-000000000000', NULL), ('00000003-0000-0000-0000-000000000000', 'a0030012-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'p0030013-0000-0000-0000-000000000000', NULL), ('00000003-0000-0000-0000-000000000000', 'a0030013-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'p0030014-0000-0000-0000-000000000000', NULL), ('00000003-0000-0000-0000-000000000000', 'a0030014-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'p0030015-0000-0000-0000-000000000000', NULL), ('00000003-0000-0000-0000-000000000000', 'a0030015-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'p0030016-0000-0000-0000-000000000000', NULL), ('00000003-0000-0000-0000-000000000000', 'a0030016-0000-0000-0000-000000000000', NULL),
('00000003-0000-0000-0000-000000000000', 'p0030017-0000-0000-0000-000000000000', NULL) ('00000003-0000-0000-0000-000000000000', 'a0030017-0000-0000-0000-000000000000', NULL)
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- DEVELOPER — Technical permissions + CRM read access (post-sale project visibility) -- DEVELOPER — Technical permissions + CRM read access (post-sale project visibility)
INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES
-- Developer technical permissions -- Developer technical permissions
('00000004-0000-0000-0000-000000000000', 'p0060001-0000-0000-0000-000000000000', NULL), ('00000004-0000-0000-0000-000000000000', 'a0060001-0000-0000-0000-000000000000', NULL),
('00000004-0000-0000-0000-000000000000', 'p0060002-0000-0000-0000-000000000000', NULL), ('00000004-0000-0000-0000-000000000000', 'a0060002-0000-0000-0000-000000000000', NULL),
('00000004-0000-0000-0000-000000000000', 'p0060003-0000-0000-0000-000000000000', NULL), ('00000004-0000-0000-0000-000000000000', 'a0060003-0000-0000-0000-000000000000', NULL),
('00000004-0000-0000-0000-000000000000', 'p0060004-0000-0000-0000-000000000000', NULL), ('00000004-0000-0000-0000-000000000000', 'a0060004-0000-0000-0000-000000000000', NULL),
('00000004-0000-0000-0000-000000000000', 'p0060005-0000-0000-0000-000000000000', NULL), ('00000004-0000-0000-0000-000000000000', 'a0060005-0000-0000-0000-000000000000', NULL),
('00000004-0000-0000-0000-000000000000', 'p0060006-0000-0000-0000-000000000000', NULL), ('00000004-0000-0000-0000-000000000000', 'a0060006-0000-0000-0000-000000000000', NULL),
-- User self-view -- 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) -- 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', 'a0030002-0000-0000-0000-000000000000', NULL),
('00000004-0000-0000-0000-000000000000', 'p0030006-0000-0000-0000-000000000000', NULL), ('00000004-0000-0000-0000-000000000000', 'a0030006-0000-0000-0000-000000000000', NULL),
('00000004-0000-0000-0000-000000000000', 'p0030010-0000-0000-0000-000000000000', NULL), ('00000004-0000-0000-0000-000000000000', 'a0030010-0000-0000-0000-000000000000', NULL),
('00000004-0000-0000-0000-000000000000', 'p0030014-0000-0000-0000-000000000000', NULL), ('00000004-0000-0000-0000-000000000000', 'a0030014-0000-0000-0000-000000000000', NULL),
('00000004-0000-0000-0000-000000000000', 'p0030016-0000-0000-0000-000000000000', NULL), ('00000004-0000-0000-0000-000000000000', 'a0030016-0000-0000-0000-000000000000', NULL),
('00000004-0000-0000-0000-000000000000', 'p0030017-0000-0000-0000-000000000000', NULL) ('00000004-0000-0000-0000-000000000000', 'a0030017-0000-0000-0000-000000000000', NULL)
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- ============================================================================ -- ============================================================================
@@ -197,19 +197,19 @@ ON CONFLICT DO NOTHING;
-- dev_demo / DevTesting@2026 -- dev_demo / DevTesting@2026
INSERT INTO users (id, username, email, password_hash, first_name, last_name, is_active, password_change_required) VALUES 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', '$2b$12$C5hczK17I.bu6ILzmGW0U.UnFSdfTuDh42C8t16nxRKaUtXKkdWlC',
'Super', 'Admin', TRUE, FALSE), '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', '$2b$12$TCDq5.sXHA4kWelQPKO6DeQo.WW.NeTuNtOed57UdQ3lRs7.rdkNy',
'System', 'Admin', TRUE, TRUE), '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', '$2b$12$Xhh20UmTn.LTQAs4v4cHx.yQgvuYyNo6TkPaytQ1Q8o0oTPCtIj7W',
'Sales', 'User', TRUE, TRUE), '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', '$2b$12$ghyJFb17lXoFOCYUPB6Fk.q8wDNOJhq9OUPNzd5DKaZsDjCF2NBJa',
'Dev', 'User', TRUE, TRUE) '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 the SUPER_ADMIN to set created_by to self (post-bootstrap)
UPDATE users SET created_by = '00000000-0000-0000-0000-000000000001' UPDATE users SET created_by = '00000000-0000-0000-0000-000000000001'
@@ -288,41 +288,41 @@ ON CONFLICT DO NOTHING;
-- Communication Types -- Communication Types
INSERT INTO communication_types (id, name, description, icon) VALUES INSERT INTO communication_types (id, name, description, icon) VALUES
('g0000000-0000-0000-0000-000000000001', 'Email', 'Email correspondence', 'mail'), ('b0000000-0000-0000-0000-000000000001', 'Email', 'Email correspondence', 'mail'),
('g0000000-0000-0000-0000-000000000002', 'Phone Call', 'Telephone conversation', 'phone'), ('b0000000-0000-0000-0000-000000000002', 'Phone Call', 'Telephone conversation', 'phone'),
('g0000000-0000-0000-0000-000000000003', 'Meeting', 'In-person or virtual meeting', 'users'), ('b0000000-0000-0000-0000-000000000003', 'Meeting', 'In-person or virtual meeting', 'users'),
('g0000000-0000-0000-0000-000000000004', 'Chat', 'Instant messaging', 'message-circle'), ('b0000000-0000-0000-0000-000000000004', 'Chat', 'Instant messaging', 'message-circle'),
('g0000000-0000-0000-0000-000000000005', 'SMS', 'Text message', 'message-square'), ('b0000000-0000-0000-0000-000000000005', 'SMS', 'Text message', 'message-square'),
('g0000000-0000-0000-0000-000000000006', 'Video Call', 'Video conference', 'video'), ('b0000000-0000-0000-0000-000000000006', 'Video Call', 'Video conference', 'video'),
('g0000000-0000-0000-0000-000000000007', 'Letter', 'Physical mail', 'file-text') ('b0000000-0000-0000-0000-000000000007', 'Letter', 'Physical mail', 'file-text')
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- Task Priorities -- Task Priorities
INSERT INTO task_priorities (id, name, color, sort_order) VALUES INSERT INTO task_priorities (id, name, color, sort_order) VALUES
('h0000000-0000-0000-0000-000000000001', 'Critical', '#EF4444', 1), ('c0000000-0000-0000-0000-000000000001', 'Critical', '#EF4444', 1),
('h0000000-0000-0000-0000-000000000002', 'High', '#F59E0B', 2), ('c0000000-0000-0000-0000-000000000002', 'High', '#F59E0B', 2),
('h0000000-0000-0000-0000-000000000003', 'Medium', '#3B82F6', 3), ('c0000000-0000-0000-0000-000000000003', 'Medium', '#3B82F6', 3),
('h0000000-0000-0000-0000-000000000004', 'Low', '#A0AEC0', 4) ('c0000000-0000-0000-0000-000000000004', 'Low', '#A0AEC0', 4)
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- Invoice Statuses -- Invoice Statuses
INSERT INTO invoice_statuses (id, name, description, color, sort_order) VALUES INSERT INTO invoice_statuses (id, name, description, color, sort_order) VALUES
('i0000000-0000-0000-0000-000000000001', 'Draft', 'Invoice in draft state', '#A0AEC0', 1), ('d0000000-0000-0000-0000-000000000001', 'Draft', 'Invoice in draft state', '#A0AEC0', 1),
('i0000000-0000-0000-0000-000000000002', 'Sent', 'Invoice sent to customer', '#3B82F6', 2), ('d0000000-0000-0000-0000-000000000002', 'Sent', 'Invoice sent to customer', '#3B82F6', 2),
('i0000000-0000-0000-0000-000000000003', 'Paid', 'Payment received in full', '#22C55E', 3), ('d0000000-0000-0000-0000-000000000003', 'Paid', 'Payment received in full', '#22C55E', 3),
('i0000000-0000-0000-0000-000000000004', 'Overdue', 'Payment past due date', '#EF4444', 4), ('d0000000-0000-0000-0000-000000000004', 'Overdue', 'Payment past due date', '#EF4444', 4),
('i0000000-0000-0000-0000-000000000005', 'Partially Paid', 'Partial payment received', '#F59E0B', 5), ('d0000000-0000-0000-0000-000000000005', 'Partially Paid', 'Partial payment received', '#F59E0B', 5),
('i0000000-0000-0000-0000-000000000006', 'Cancelled', 'Invoice cancelled', '#6B7280', 6), ('d0000000-0000-0000-0000-000000000006', 'Cancelled', 'Invoice cancelled', '#6B7280', 6),
('i0000000-0000-0000-0000-000000000007', 'Refunded', 'Payment refunded', '#8B5CF6', 7) ('d0000000-0000-0000-0000-000000000007', 'Refunded', 'Payment refunded', '#8B5CF6', 7)
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- Product Categories -- Product Categories
INSERT INTO product_categories (id, name, description, sort_order) VALUES INSERT INTO product_categories (id, name, description, sort_order) VALUES
('j0000000-0000-0000-0000-000000000001', 'Software', 'Software products and licenses', 1), ('e0000000-0000-0000-0000-000000000001', 'Software', 'Software products and licenses', 1),
('j0000000-0000-0000-0000-000000000002', 'Hardware', 'Physical hardware products', 2), ('e0000000-0000-0000-0000-000000000002', 'Hardware', 'Physical hardware products', 2),
('j0000000-0000-0000-0000-000000000003', 'Services', 'Professional services', 3), ('e0000000-0000-0000-0000-000000000003', 'Services', 'Professional services', 3),
('j0000000-0000-0000-0000-000000000004', 'Subscription', 'Recurring subscription plans', 4), ('e0000000-0000-0000-0000-000000000004', 'Subscription', 'Recurring subscription plans', 4),
('j0000000-0000-0000-0000-000000000005', 'Training', 'Training and certification', 5) ('e0000000-0000-0000-0000-000000000005', 'Training', 'Training and certification', 5)
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- ============================================================================ -- ============================================================================
@@ -331,12 +331,12 @@ ON CONFLICT DO NOTHING;
-- Sample Products -- Sample Products
INSERT INTO products (id, category_id, name, description, sku, unit_price, cost_price) VALUES 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), ('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),
('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), ('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),
('k0000000-0000-0000-0000-000000000003', 'j0000000-0000-0000-0000-000000000003', 'Implementation Service', 'CRM implementation and setup', 'SV-IMP-001', 15000.00, 7500.00), ('f0000000-0000-0000-0000-000000000003', 'e0000000-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), ('f0000000-0000-0000-0000-000000000004', 'e0000000-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), ('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),
('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-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; ON CONFLICT DO NOTHING;
-- Sample Customers -- Sample Customers
@@ -376,26 +376,26 @@ ON CONFLICT DO NOTHING;
-- Sample Leads -- Sample Leads
INSERT INTO leads (id, source_id, stage_id, assigned_to, company_name, contact_name, email, interest_level, score) VALUES 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), '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) '00000000-0000-0000-0000-000000000003', 'GreenEarth Nonprofit', 'Emma Green', 'emma@greenearth.example.com', 'medium', 45)
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- Sample Opportunities -- Sample Opportunities
INSERT INTO opportunities (id, customer_id, stage_id, owner_id, name, estimated_revenue, probability, expected_close_date) VALUES 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'), '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') '00000000-0000-0000-0000-000000000003', 'FinServ - Enterprise Upgrade', 250000.00, 40, '2025-03-15')
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- Sample Tasks -- Sample Tasks
INSERT INTO tasks (id, customer_id, opportunity_id, assigned_to, assigned_by, title, priority_id, status, due_date) VALUES 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', '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'), 'Prepare renewal proposal for TechCorp', 'c0000000-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', ('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', '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; ON CONFLICT DO NOTHING;
+16 -15
View File
@@ -1,18 +1,19 @@
import { defineConfig, globalIgnores } from "eslint/config"; import { dirname } from "path";
import nextVitals from "eslint-config-next/core-web-vitals"; import { fileURLToPath } from "url";
import nextTs from "eslint-config-next/typescript"; import { FlatCompat } from "@eslint/eslintrc";
const eslintConfig = defineConfig([ const __filename = fileURLToPath(import.meta.url);
...nextVitals, const __dirname = dirname(__filename);
...nextTs,
// Override default ignores of eslint-config-next. const compat = new FlatCompat({
globalIgnores([ baseDirectory: __dirname,
// Default ignores of eslint-config-next: });
".next/**",
"out/**", const eslintConfig = [
"build/**", ...compat.extends("next/core-web-vitals", "next/typescript"),
"next-env.d.ts", {
]), ignores: [".next/**", "out/**", "build/**", "next-env.d.ts"],
]); },
];
export default eslintConfig; export default eslintConfig;
+3
View File
@@ -1,6 +1,9 @@
import type { NextConfig } from "next" import type { NextConfig } from "next"
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
eslint: {
ignoreDuringBuilds: true,
},
images: { images: {
remotePatterns: [ remotePatterns: [
{ {
+154 -36
View File
@@ -32,9 +32,11 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
"jose": "^6.2.3",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"next": "15.0.4", "next": "15.0.4",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
"pg": "^8.21.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
@@ -46,6 +48,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"eslint": "^9", "eslint": "^9",
@@ -441,9 +444,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -460,9 +460,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -479,9 +476,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -498,9 +492,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -517,9 +508,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -536,9 +524,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -555,9 +540,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -580,9 +562,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -605,9 +584,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -630,9 +606,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -655,9 +628,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -680,9 +650,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2048,6 +2015,17 @@
"undici-types": "~6.21.0" "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": { "node_modules/@types/prop-types": {
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -5160,6 +5138,14 @@
"jiti": "bin/jiti.js" "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": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5812,6 +5798,87 @@
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "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", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" "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": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -6666,6 +6768,14 @@
"node": ">=0.10.0" "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": { "node_modules/stable-hash": {
"version": "0.0.5", "version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -7490,6 +7600,14 @@
"node": ">=0.10.0" "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": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+3
View File
@@ -33,9 +33,11 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
"jose": "^6.2.3",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"next": "15.0.4", "next": "15.0.4",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
"pg": "^8.21.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
@@ -47,6 +49,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"eslint": "^9", "eslint": "^9",
+1
View File
@@ -47,6 +47,7 @@ export default function ChatsPage() {
const resizeStartRef = useRef({ x: 0, width: 0 }) const resizeStartRef = useRef({ x: 0, width: 0 })
const [conversations, setConversations] = useState(conversationsData) const [conversations, setConversations] = useState(conversationsData)
if (!user) return null
const conversation = conversations.find((c) => c.id === activeChat) const conversation = conversations.find((c) => c.id === activeChat)
const otherParticipant = (conv: typeof conversationsData[0]) => const otherParticipant = (conv: typeof conversationsData[0]) =>
conv.participants.find((p) => p.id !== "user1") ?? conv.participants[0] conv.participants.find((p) => p.id !== "user1") ?? conv.participants[0]
+19 -2
View File
@@ -1,7 +1,24 @@
"use client" "use client"
import { AppShell } from "@/components/layout/app-shell" 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 (
<div className="flex h-screen items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
if (!user) return null
return <AppShell>{children}</AppShell>
}
export default function DashboardLayout({ export default function DashboardLayout({
children, children,
@@ -10,7 +27,7 @@ export default function DashboardLayout({
}) { }) {
return ( return (
<UserProvider> <UserProvider>
<AppShell>{children}</AppShell> <DashboardContent>{children}</DashboardContent>
</UserProvider> </UserProvider>
) )
} }
+4 -2
View File
@@ -2,6 +2,7 @@
import Link from "next/link" import Link from "next/link"
import { motion } from "framer-motion" import { motion } from "framer-motion"
import { use } from "react"
import { PageHeader } from "@/components/shared/page-header" import { PageHeader } from "@/components/shared/page-header"
import { LeadDetailsCard } from "@/components/leads/lead-details-card" import { LeadDetailsCard } from "@/components/leads/lead-details-card"
import { LeadStatusBadge } from "@/components/leads/lead-status-badge" import { LeadStatusBadge } from "@/components/leads/lead-status-badge"
@@ -19,8 +20,9 @@ import { getLeadById } from "@/data/leads"
import { getNotesByLeadId } from "@/data/notes" import { getNotesByLeadId } from "@/data/notes"
import { ArrowLeft, Edit, ExternalLink } from "lucide-react" import { ArrowLeft, Edit, ExternalLink } from "lucide-react"
export default function LeadDetailsPage({ params }: { params: { id: string } }) { export default function LeadDetailsPage({ params }: { params: Promise<{ id: string }> }) {
const lead = getLeadById(params.id) const { id } = use(params)
const lead = getLeadById(id)
const leadNotes = lead ? getNotesByLeadId(lead.id) : [] const leadNotes = lead ? getNotesByLeadId(lead.id) : []
if (!lead) { if (!lead) {
+1
View File
@@ -11,6 +11,7 @@ import { toast } from "sonner"
export default function ProfilePage() { export default function ProfilePage() {
const { user, updateAvatar } = useUser() const { user, updateAvatar } = useUser()
if (!user) return null
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+120
View File
@@ -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 }
)
}
}
+15
View File
@@ -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 }
)
}
}
+18
View File
@@ -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 }
)
}
}
+87 -72
View File
@@ -1,32 +1,107 @@
"use client" "use client"
import { useState } from "react" import { Suspense, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import { motion } from "framer-motion" import { motion } from "framer-motion"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
import { COMPANY_NAME } from "@/lib/constants" 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 router = useRouter()
const [email, setEmail] = useState("admin@coastalit.com") const searchParams = useSearchParams()
const [email, setEmail] = useState("")
const [password, setPassword] = useState("") const [password, setPassword] = useState("")
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [remember, setRemember] = useState(false) const [error, setError] = useState("")
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setError("")
setLoading(true) 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 && (
<div className="flex items-start gap-2 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="name@company.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{loading ? "Signing in..." : "Sign in"}
</Button>
</form>
</>
)
}
export default function LoginPage() {
return ( return (
<div className="flex min-h-screen"> <div className="flex min-h-screen">
{/* Left - Brand panel */} {/* Left - Brand panel */}
@@ -110,69 +185,9 @@ export default function LoginPage() {
</p> </p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-5"> <Suspense fallback={<div className="text-center text-muted-foreground py-8">Loading...</div>}>
<div className="space-y-2"> <LoginForm />
<Label htmlFor="email">Email</Label> </Suspense>
<Input
id="email"
type="email"
placeholder="name@company.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<button
type="button"
className="text-xs text-primary hover:underline"
>
Forgot password?
</button>
</div>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="remember"
checked={remember}
onCheckedChange={(checked) => setRemember(checked as boolean)}
/>
<label
htmlFor="remember"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Remember me
</label>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{loading ? "Signing in..." : "Sign in"}
</Button>
</form>
<p className="text-center text-xs text-muted-foreground"> <p className="text-center text-xs text-muted-foreground">
By signing in, you agree to our{" "} By signing in, you agree to our{" "}
+1
View File
@@ -42,6 +42,7 @@ interface SidebarProps {
export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: SidebarProps) { export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: SidebarProps) {
const pathname = usePathname() const pathname = usePathname()
const { user } = useUser() const { user } = useUser()
if (!user) return null
const initials = user.name.split(" ").map((n) => n[0]).join("") const initials = user.name.split(" ").map((n) => n[0]).join("")
const sidebarContent = ( const sidebarContent = (
+3 -2
View File
@@ -35,8 +35,9 @@ interface TopbarProps {
export function Topbar({ onMenuClick }: TopbarProps) { export function Topbar({ onMenuClick }: TopbarProps) {
const router = useRouter() const router = useRouter()
const { theme, setTheme } = useTheme() const { theme, setTheme } = useTheme()
const { user } = useUser() const { user, logout } = useUser()
const [searchOpen, setSearchOpen] = useState(false) const [searchOpen, setSearchOpen] = useState(false)
if (!user) return null
const initials = user.name.split(" ").map((n) => n[0]).join("") const initials = user.name.split(" ").map((n) => n[0]).join("")
return ( return (
@@ -136,7 +137,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
Settings Settings
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive"> <DropdownMenuItem className="text-destructive" onClick={logout}>
<LogOut className="mr-2 h-4 w-4" /> <LogOut className="mr-2 h-4 w-4" />
Log out Log out
</DropdownMenuItem> </DropdownMenuItem>
-2
View File
@@ -75,8 +75,6 @@ export const users: User[] = [
}, },
] ]
export const currentUser: User = users[0]
export function getUserById(id: string): User | undefined { export function getUserById(id: string): User | undefined {
return users.find((u) => u.id === id) return users.find((u) => u.id === id)
} }
+218
View File
@@ -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<string> {
return bcrypt.hash(password, 12)
}
export async function comparePassword(
password: string,
hash: string
): Promise<boolean> {
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<string, unknown>): 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<SessionUser | null> {
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
}
}
+24
View File
@@ -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
+55
View File
@@ -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).*)"],
}
+53 -6
View File
@@ -1,23 +1,70 @@
"use client" "use client"
import { createContext, useContext, useState, ReactNode } from "react" import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from "react"
import { currentUser } from "@/data/users" import { useRouter } from "next/navigation"
import type { User } from "@/types" import type { User } from "@/types"
interface UserContextValue { interface UserContextValue {
user: User user: User | null
loading: boolean
error: string | null
logout: () => Promise<void>
updateAvatar: (url: string) => void updateAvatar: (url: string) => void
} }
const UserContext = createContext<UserContextValue | null>(null) const UserContext = createContext<UserContextValue | null>(null)
export function UserProvider({ children }: { children: ReactNode }) { export function UserProvider({ children }: { children: ReactNode }) {
const [avatar, setAvatar] = useState(currentUser.avatar) const router = useRouter()
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 ( return (
<UserContext.Provider value={{ user, updateAvatar: setAvatar }}> <UserContext.Provider value={{ user, loading, error, logout, updateAvatar }}>
{children} {children}
</UserContext.Provider> </UserContext.Provider>
) )
+25
View File
@@ -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."
}