From 8ca246e42044490aa642e32be16adf3041eeafa2 Mon Sep 17 00:00:00 2001 From: timknowlden Date: Fri, 9 Jan 2026 17:23:37 +0000 Subject: [PATCH 01/26] no message --- ADMIN_AUTO_SETUP.md | 62 +++ MANUAL_SUPER_ADMIN_SETUP.md | 144 +++++++ database/ensureAdmin.js | 145 +++++++ database/init.js | 1 + database/migrate.js | 14 +- scripts/check-super-admin.js | 82 ++++ scripts/create-admin-remote.js | 203 ++++++++++ server.js | 4 +- src/App.css | 167 ++++----- src/components/AdminManager.css | 26 +- src/components/AppointmentsList.css | 8 +- src/components/EmailLogs.css | 8 +- src/components/EntryForm.css | 126 +++---- src/components/Financial.css | 8 +- src/components/LocationsManager.css | 35 +- src/components/Login.css | 148 ++++---- src/components/MyPlan.css | 3 +- src/components/ServicesManager.css | 35 +- src/components/SubscriptionManager.css | 1 + src/components/SuperAdminManager.css | 3 +- src/index.css | 498 ++++++++++++++++++++++++- 21 files changed, 1437 insertions(+), 284 deletions(-) create mode 100644 ADMIN_AUTO_SETUP.md create mode 100644 MANUAL_SUPER_ADMIN_SETUP.md create mode 100644 database/ensureAdmin.js create mode 100644 scripts/check-super-admin.js create mode 100644 scripts/create-admin-remote.js diff --git a/ADMIN_AUTO_SETUP.md b/ADMIN_AUTO_SETUP.md new file mode 100644 index 0000000..b5c08fa --- /dev/null +++ b/ADMIN_AUTO_SETUP.md @@ -0,0 +1,62 @@ +# Automatic Admin User Setup + +The application now **automatically creates a super admin user** when the server starts, if one doesn't already exist. + +## How It Works + +1. **On Server Startup:** + - Database is initialized + - Migrations run (ensures all columns exist) + - Admin user is automatically created if missing + +2. **Default Credentials:** + - **Username:** `admin` + - **Password:** `admin123!` + - **Email:** (none) + +3. **Customization via Environment Variables:** + You can customize the admin credentials by setting environment variables: + ```bash + ADMIN_USERNAME=myadmin + ADMIN_PASSWORD=MySecurePassword123! + ADMIN_EMAIL=admin@example.com + ``` + +## For Docker/Production + +Add to your `docker-compose.yml` or environment: +```yaml +environment: + - ADMIN_USERNAME=admin + - ADMIN_PASSWORD=YourSecurePasswordHere + - ADMIN_EMAIL=admin@yourdomain.com +``` + +## What Gets Created + +- Super admin user with `is_super_admin = 1` +- Default services (24 services) for the admin user +- All necessary database columns are automatically added + +## Database Schema + +The `users` table automatically includes: +- `id` (PRIMARY KEY) +- `username` (UNIQUE) +- `password_hash` +- `email` +- `is_super_admin` (INTEGER, DEFAULT 0) +- `created_at` (TIMESTAMP) + +## Manual Override + +If you need to manually create/update an admin user, you can still use: +```bash +node scripts/create-admin-remote.js [email] +``` + +## Security Note + +⚠️ **Important:** The default password is `admin123!` - **change it immediately after first login!** + +For production, always set `ADMIN_PASSWORD` environment variable to a strong password. diff --git a/MANUAL_SUPER_ADMIN_SETUP.md b/MANUAL_SUPER_ADMIN_SETUP.md new file mode 100644 index 0000000..7752ac6 --- /dev/null +++ b/MANUAL_SUPER_ADMIN_SETUP.md @@ -0,0 +1,144 @@ +# Manual Super Admin Setup Guide + +This guide shows you how to create a super admin user on a remote server using either SQL commands or the provided script. + +## Option 1: Using the Script (Recommended) + +The easiest way is to use the existing script: + +```bash +# On your remote server +cd /path/to/HairManager +node scripts/create-super-admin.js +``` + +**Note:** You'll need to edit `scripts/create-super-admin.js` first to set your desired username and password (lines 16-17). + +## Option 2: Manual SQL Execution + +If you need to create a super admin manually via SQL, follow these steps: + +### Step 1: Generate Password Hash + +You need to hash your password using bcrypt. You can do this in several ways: + +#### Method A: Using Node.js (on the server) +```bash +node -e "const bcrypt = require('bcrypt'); bcrypt.hash('YourPassword123!', 10).then(hash => console.log(hash));" +``` + +#### Method B: Using Python (if bcrypt is installed) +```python +import bcrypt +password = b'YourPassword123!' +hashed = bcrypt.hashpw(password, bcrypt.gensalt()) +print(hashed.decode()) +``` + +#### Method C: Create a temporary script +```javascript +// hash-password.js +import bcrypt from 'bcrypt'; +const password = 'YourPassword123!'; +const hash = await bcrypt.hash(password, 10); +console.log(hash); +``` + +Run: `node hash-password.js` + +### Step 2: Execute SQL + +Once you have the password hash, connect to your SQLite database and run: + +```sql +-- Check if user already exists +SELECT id, username, is_super_admin FROM users WHERE username = 'admin'; + +-- Option A: Create a NEW super admin user +INSERT INTO users (username, password_hash, email, is_super_admin) +VALUES ('admin', '$2b$10$YOUR_HASHED_PASSWORD_HERE', 'admin@example.com', 1); + +-- Option B: Update an EXISTING user to be super admin +UPDATE users +SET is_super_admin = 1, password_hash = '$2b$10$YOUR_HASHED_PASSWORD_HERE' +WHERE username = 'admin'; + +-- Verify the user was created/updated +SELECT id, username, email, is_super_admin FROM users WHERE username = 'admin'; +``` + +### Step 3: Verify + +After creating/updating, verify with: +```sql +SELECT id, username, email, is_super_admin, created_at +FROM users +WHERE is_super_admin = 1; +``` + +## Option 3: Quick SQLite Command Line + +If you have direct access to the database file on the server: + +```bash +# Connect to the database +sqlite3 /path/to/data/hairmanager.db + +# Then run the SQL commands from Option 2 +``` + +## Database Location + +The database location depends on your environment: + +- **Development**: `./hairmanager.db` (in project root) +- **Production/Docker**: `./data/hairmanager.db` (in data directory) +- **Custom**: Check your `NODE_ENV` and deployment configuration + +## Example: Complete Manual Setup + +```bash +# 1. SSH into your server +ssh user@your-server.com + +# 2. Navigate to your app directory +cd /path/to/HairManager + +# 3. Generate password hash +node -e "const bcrypt = require('bcrypt'); bcrypt.hash('MySecurePassword123!', 10).then(hash => console.log(hash));" + +# Copy the hash output (starts with $2b$10$...) + +# 4. Connect to database +sqlite3 data/hairmanager.db + +# 5. Insert super admin (replace HASH with your hash from step 3) +INSERT INTO users (username, password_hash, email, is_super_admin) +VALUES ('admin', 'HASH_FROM_STEP_3', 'admin@example.com', 1); + +# 6. Verify +SELECT id, username, is_super_admin FROM users WHERE username = 'admin'; + +# 7. Exit +.quit +``` + +## Troubleshooting + +### "no such table: users" +- Run the migration first: `node database/migrate.js` or ensure migrations have run + +### "column is_super_admin does not exist" +- The migration should add this column automatically +- Manually add it: `ALTER TABLE users ADD COLUMN is_super_admin INTEGER DEFAULT 0;` + +### "UNIQUE constraint failed" +- User already exists, use UPDATE instead of INSERT + +## Security Notes + +⚠️ **Important:** +- Never commit password hashes to version control +- Use strong passwords for super admin accounts +- Consider using environment variables for sensitive data +- After creating the admin, verify you can log in and then remove any temporary scripts diff --git a/database/ensureAdmin.js b/database/ensureAdmin.js new file mode 100644 index 0000000..900550b --- /dev/null +++ b/database/ensureAdmin.js @@ -0,0 +1,145 @@ +import bcrypt from 'bcrypt'; +import sqlite3 from 'sqlite3'; + +const runAsync = (db, sql, params = []) => { + return new Promise((resolve, reject) => { + db.run(sql, params, function(err) { + if (err) reject(err); + else resolve(this); + }); + }); +}; + +const getAsync = (db, sql, params = []) => { + return new Promise((resolve, reject) => { + db.get(sql, params, (err, row) => { + if (err) reject(err); + else resolve(row); + }); + }); +}; + +/** + * Ensures a super admin user exists in the database + * Uses environment variables for credentials, with sensible defaults + */ +export function ensureAdminUser(dbPath) { + return new Promise((resolve, reject) => { + const db = new sqlite3.Database(dbPath, (err) => { + if (err) { + console.error('Error opening database for admin user creation:', err); + reject(err); + return; + } + }); + + // Get admin credentials from environment variables or use defaults + const adminUsername = process.env.ADMIN_USERNAME || 'admin'; + const adminPassword = process.env.ADMIN_PASSWORD || 'admin123!'; + const adminEmail = process.env.ADMIN_EMAIL || null; + + // Check if admin user already exists + getAsync(db, 'SELECT id, username, is_super_admin FROM users WHERE username = ?', [adminUsername]) + .then(async (existingUser) => { + if (existingUser) { + // User exists - check if they're a super admin + if (existingUser.is_super_admin === 1) { + console.log(`✓ Super admin user "${adminUsername}" already exists (ID: ${existingUser.id})`); + db.close(); + resolve(); + return; + } else { + // User exists but isn't super admin - upgrade them + console.log(`⚠️ User "${adminUsername}" exists but is not a super admin. Upgrading...`); + try { + const passwordHash = await bcrypt.hash(adminPassword, 10); + await runAsync( + db, + 'UPDATE users SET is_super_admin = 1, password_hash = ?, email = COALESCE(?, email) WHERE username = ?', + [passwordHash, adminEmail, adminUsername] + ); + console.log(`✓ User "${adminUsername}" upgraded to super admin`); + db.close(); + resolve(); + } catch (hashErr) { + console.error('Error hashing password:', hashErr); + db.close(); + reject(hashErr); + } + return; + } + } + + // User doesn't exist - create new super admin + console.log(`📝 Creating super admin user "${adminUsername}"...`); + try { + const passwordHash = await bcrypt.hash(adminPassword, 10); + + const result = await runAsync( + db, + 'INSERT INTO users (username, password_hash, email, is_super_admin) VALUES (?, ?, ?, ?)', + [adminUsername, passwordHash, adminEmail, 1] + ); + + const userId = result.lastID; + console.log(`✓ Super admin user "${adminUsername}" created (ID: ${userId})`); + + // Create default services for the new admin user + const defaultServices = [ + { service_name: 'Blow Dry', type: 'Hair', price: 15.00 }, + { service_name: 'Shampoo & Set', type: 'Hair', price: 14.00 }, + { service_name: 'Dry Cut', type: 'Hair', price: 14.00 }, + { service_name: 'Cut & Blow Dry', type: 'Hair', price: 25.00 }, + { service_name: 'Cut & Set', type: 'Hair', price: 24.00 }, + { service_name: 'Restyling', type: 'Hair', price: 30.00 }, + { service_name: 'Gents Dry Cut', type: 'Hair', price: 14.50 }, + { service_name: 'Clipper Cuts', type: 'Hair', price: 6.00 }, + { service_name: 'Beard Trim', type: 'Hair', price: 5.00 }, + { service_name: 'Child Cut', type: 'Hair', price: 10.00 }, + { service_name: 'Child Cut & Blow Dry', type: 'Hair', price: 18.00 }, + { service_name: 'Other', type: 'Hair', price: 0.00 }, + { service_name: 'File & Polish', type: 'Nails', price: 10.00 }, + { service_name: 'Manicure', type: 'Nails', price: 18.00 }, + { service_name: 'Gel Polish', type: 'Nails', price: 20.00 }, + { service_name: 'Removal', type: 'Nails', price: 6.00 }, + { service_name: 'Gel Removal & Re-Apply', type: 'Nails', price: 25.00 }, + { service_name: 'Pedicure', type: 'Nails', price: 20.00 }, + { service_name: 'Blow Dry & Fringe Trim', type: 'Hair', price: 17.00 }, + { service_name: 'Nails Cut & Filed', type: 'Nails', price: 6.00 }, + { service_name: 'Wash & Cut', type: 'Hair', price: 20.00 }, + { service_name: 'Colour', type: 'Hair', price: 60.00 }, + { service_name: 'Colour, cut & blow dry', type: 'Hair', price: 45.00 }, + { service_name: 'Hair wash', type: 'Hair', price: 5.00 } + ]; + + // Insert default services for the new admin user + for (const service of defaultServices) { + await runAsync(db, 'INSERT OR IGNORE INTO services (user_id, service_name, type, price) VALUES (?, ?, ?, ?)', + [userId, service.service_name, service.type, service.price]); + } + + console.log(`✓ Default services created for super admin user`); + console.log(`\n🔑 Admin Credentials:`); + console.log(` Username: ${adminUsername}`); + console.log(` Password: ${adminPassword}`); + if (adminEmail) { + console.log(` Email: ${adminEmail}`); + } + console.log(`\n⚠️ IMPORTANT: Change the default password after first login!`); + console.log(` You can set ADMIN_USERNAME, ADMIN_PASSWORD, and ADMIN_EMAIL environment variables to customize.\n`); + + db.close(); + resolve(); + } catch (error) { + console.error('Error creating super admin:', error); + db.close(); + reject(error); + } + }) + .catch((err) => { + console.error('Error checking for admin user:', err); + db.close(); + reject(err); + }); + }); +} diff --git a/database/init.js b/database/init.js index 9743c08..9eeaa3f 100644 --- a/database/init.js +++ b/database/init.js @@ -30,6 +30,7 @@ export function initDatabase(dbPath) { username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, email TEXT, + is_super_admin INTEGER DEFAULT 0, created_at TEXT DEFAULT CURRENT_TIMESTAMP ) `) diff --git a/database/migrate.js b/database/migrate.js index afb3386..315248b 100644 --- a/database/migrate.js +++ b/database/migrate.js @@ -61,16 +61,28 @@ function migrateDatabase(customDbPath = null) { `)); } - // Check if is_super_admin column exists in users table (for existing tables) + // Check if all required columns exist in users table (for existing tables) db.all("PRAGMA table_info(users)", [], (err, userColumns) => { if (err) { console.error('Error checking users table info:', err); // Continue anyway } else if (userColumns) { const userColumnNames = userColumns.map(col => col.name); + + // Ensure is_super_admin column exists if (!userColumnNames.includes('is_super_admin')) { migrations.push(runAsync(db, 'ALTER TABLE users ADD COLUMN is_super_admin INTEGER DEFAULT 0')); } + + // Ensure email column exists (for older databases) + if (!userColumnNames.includes('email')) { + migrations.push(runAsync(db, 'ALTER TABLE users ADD COLUMN email TEXT')); + } + + // Ensure created_at column exists + if (!userColumnNames.includes('created_at')) { + migrations.push(runAsync(db, 'ALTER TABLE users ADD COLUMN created_at TEXT DEFAULT CURRENT_TIMESTAMP')); + } } }); diff --git a/scripts/check-super-admin.js b/scripts/check-super-admin.js new file mode 100644 index 0000000..d04f6e9 --- /dev/null +++ b/scripts/check-super-admin.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * Check if Super Admin Users Exist + * + * Usage: + * node scripts/check-super-admin.js + */ + +import sqlite3 from 'sqlite3'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Use data directory in production (Docker), or current directory in development +const dataDir = process.env.NODE_ENV === 'production' + ? join(__dirname, '..', 'data') + : join(__dirname, '..'); +const dbPath = join(dataDir, 'hairmanager.db'); + +console.log('\n🔍 Checking for Super Admin Users...\n'); +console.log(` Database: ${dbPath}\n`); + +return new Promise((resolve, reject) => { + const db = new sqlite3.Database(dbPath, (err) => { + if (err) { + console.error('❌ Error opening database:', err.message); + console.error(` Make sure the database exists at: ${dbPath}\n`); + reject(err); + return; + } + }); + + // Check for super admin users + db.all( + 'SELECT id, username, email, is_super_admin, created_at FROM users WHERE is_super_admin = 1', + [], + (err, superAdmins) => { + if (err) { + console.error('❌ Error querying database:', err.message); + db.close(); + reject(err); + return; + } + + if (superAdmins.length === 0) { + console.log('⚠️ No super admin users found!\n'); + console.log('💡 To create a super admin, run:'); + console.log(' node scripts/create-admin-remote.js [email]\n'); + } else { + console.log(`✅ Found ${superAdmins.length} super admin user(s):\n`); + superAdmins.forEach((admin, index) => { + console.log(` ${index + 1}. ${admin.username}`); + console.log(` ID: ${admin.id}`); + if (admin.email) { + console.log(` Email: ${admin.email}`); + } + console.log(` Created: ${admin.created_at || 'Unknown'}`); + console.log(''); + }); + } + + // Also show all users for reference + db.all('SELECT id, username, email, is_super_admin FROM users ORDER BY id', [], (err, allUsers) => { + if (!err && allUsers.length > 0) { + console.log('📋 All users in database:\n'); + allUsers.forEach(user => { + const role = user.is_super_admin ? '🔑 Super Admin' : '👤 User'; + console.log(` ${role} - ${user.username} (ID: ${user.id})`); + if (user.email) { + console.log(` Email: ${user.email}`); + } + }); + console.log(''); + } + db.close(); + resolve(); + }); + } + ); +}); diff --git a/scripts/create-admin-remote.js b/scripts/create-admin-remote.js new file mode 100644 index 0000000..f01d82f --- /dev/null +++ b/scripts/create-admin-remote.js @@ -0,0 +1,203 @@ +#!/usr/bin/env node +/** + * Create Super Admin User - Remote Server Helper + * + * Usage: + * node scripts/create-admin-remote.js [email] + * + * Example: + * node scripts/create-admin-remote.js admin MySecurePass123! admin@example.com + */ + +import bcrypt from 'bcrypt'; +import sqlite3 from 'sqlite3'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { migrateDatabase } from '../database/migrate.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Get arguments from command line +const args = process.argv.slice(2); + +if (args.length < 2) { + console.error('\n❌ Error: Missing required arguments\n'); + console.log('Usage: node scripts/create-admin-remote.js [email]\n'); + console.log('Example:'); + console.log(' node scripts/create-admin-remote.js admin MySecurePass123! admin@example.com\n'); + process.exit(1); +} + +const username = args[0]; +const password = args[1]; +const email = args[2] || null; + +// Use data directory in production (Docker), or current directory in development +const dataDir = process.env.NODE_ENV === 'production' + ? join(__dirname, '..', 'data') + : join(__dirname, '..'); +const dbPath = join(dataDir, 'hairmanager.db'); + +console.log('\n🔧 Creating Super Admin User...\n'); +console.log(` Username: ${username}`); +console.log(` Email: ${email || '(none)'}`); +console.log(` Database: ${dbPath}\n`); + +async function createSuperAdmin() { + // First, ensure migration has run + console.log('📦 Running database migration...'); + try { + await migrateDatabase(dbPath); + console.log('✓ Migration completed\n'); + } catch (err) { + console.error('⚠️ Migration warning (continuing anyway):', err.message); + console.log(''); + } + + return new Promise((resolve, reject) => { + const db = new sqlite3.Database(dbPath, (err) => { + if (err) { + console.error('❌ Error opening database:', err.message); + console.error(` Make sure the database exists at: ${dbPath}\n`); + reject(err); + return; + } + }); + + // Check if user already exists + db.get('SELECT id, username, is_super_admin FROM users WHERE username = ?', [username], async (err, existingUser) => { + if (err) { + console.error('❌ Error checking for existing user:', err.message); + db.close(); + reject(err); + return; + } + + if (existingUser) { + // Update existing user to be super admin + console.log(`⚠️ User "${username}" already exists (ID: ${existingUser.id})`); + console.log(` Current super admin status: ${existingUser.is_super_admin ? 'Yes' : 'No'}`); + console.log(' Updating to super admin...\n'); + + try { + const passwordHash = await bcrypt.hash(password, 10); + db.run( + 'UPDATE users SET password_hash = ?, is_super_admin = 1, email = COALESCE(?, email) WHERE username = ?', + [passwordHash, email, username], + function(updateErr) { + if (updateErr) { + console.error('❌ Error updating user:', updateErr.message); + db.close(); + reject(updateErr); + return; + } + console.log(`✅ User "${username}" updated to super admin`); + console.log(` Password has been updated`); + if (email) { + console.log(` Email set to: ${email}`); + } + console.log(''); + db.close(); + resolve(); + } + ); + } catch (hashErr) { + console.error('❌ Error hashing password:', hashErr.message); + db.close(); + reject(hashErr); + } + return; + } + + // Create new user + console.log(`📝 Creating new super admin user...\n`); + try { + const passwordHash = await bcrypt.hash(password, 10); + + db.run( + 'INSERT INTO users (username, password_hash, email, is_super_admin) VALUES (?, ?, ?, ?)', + [username, passwordHash, email, 1], + function(insertErr) { + if (insertErr) { + console.error('❌ Error creating user:', insertErr.message); + db.close(); + reject(insertErr); + return; + } + + const userId = this.lastID; + console.log(`✅ Super admin user "${username}" created successfully!`); + console.log(` User ID: ${userId}`); + if (email) { + console.log(` Email: ${email}`); + } + console.log(''); + + // Create default services for the new user + const defaultServices = [ + { service_name: 'Blow Dry', type: 'Hair', price: 15.00 }, + { service_name: 'Shampoo & Set', type: 'Hair', price: 14.00 }, + { service_name: 'Dry Cut', type: 'Hair', price: 14.00 }, + { service_name: 'Cut & Blow Dry', type: 'Hair', price: 25.00 }, + { service_name: 'Cut & Set', type: 'Hair', price: 24.00 }, + { service_name: 'Restyling', type: 'Hair', price: 30.00 }, + { service_name: 'Gents Dry Cut', type: 'Hair', price: 14.50 }, + { service_name: 'Clipper Cuts', type: 'Hair', price: 6.00 }, + { service_name: 'Beard Trim', type: 'Hair', price: 5.00 }, + { service_name: 'Child Cut', type: 'Hair', price: 10.00 }, + { service_name: 'Child Cut & Blow Dry', type: 'Hair', price: 18.00 }, + { service_name: 'Other', type: 'Hair', price: 0.00 }, + { service_name: 'File & Polish', type: 'Nails', price: 10.00 }, + { service_name: 'Manicure', type: 'Nails', price: 18.00 }, + { service_name: 'Gel Polish', type: 'Nails', price: 20.00 }, + { service_name: 'Removal', type: 'Nails', price: 6.00 }, + { service_name: 'Gel Removal & Re-Apply', type: 'Nails', price: 25.00 }, + { service_name: 'Pedicure', type: 'Nails', price: 20.00 }, + { service_name: 'Blow Dry & Fringe Trim', type: 'Hair', price: 17.00 }, + { service_name: 'Nails Cut & Filed', type: 'Nails', price: 6.00 }, + { service_name: 'Wash & Cut', type: 'Hair', price: 20.00 }, + { service_name: 'Colour', type: 'Hair', price: 60.00 }, + { service_name: 'Colour, cut & blow dry', type: 'Hair', price: 45.00 }, + { service_name: 'Hair wash', type: 'Hair', price: 5.00 } + ]; + + // Insert default services for the new user + const stmt = db.prepare('INSERT OR IGNORE INTO services (user_id, service_name, type, price) VALUES (?, ?, ?, ?)'); + defaultServices.forEach(service => { + stmt.run([userId, service.service_name, service.type, service.price]); + }); + stmt.finalize(); + + console.log(`✅ Default services created for user "${username}"`); + console.log(''); + db.close(); + resolve(); + } + ); + } catch (error) { + console.error('❌ Error hashing password:', error.message); + db.close(); + reject(error); + } + }); + }); +} + +// Run the script +createSuperAdmin() + .then(() => { + console.log('🎉 Setup complete! You can now log in with:'); + console.log(` Username: ${username}`); + console.log(` Password: ${password}\n`); + process.exit(0); + }) + .catch((err) => { + console.error('\n❌ Error creating super admin:', err.message); + console.error('\n💡 Troubleshooting:'); + console.error(' 1. Make sure the database file exists'); + console.error(' 2. Check file permissions on the database'); + console.error(' 3. Ensure migrations have run'); + console.error(' 4. Check NODE_ENV if using Docker/production\n'); + process.exit(1); + }); diff --git a/server.js b/server.js index d04ca23..42464ea 100644 --- a/server.js +++ b/server.js @@ -6,6 +6,7 @@ import { dirname, join } from 'path'; import { readFileSync } from 'fs'; import { initDatabase } from './database/init.js'; import { migrateDatabase } from './database/migrate.js'; +import { ensureAdminUser } from './database/ensureAdmin.js'; import appointmentsRoutes from './routes/appointments.js'; import servicesRoutes from './routes/services.js'; import locationsRoutes from './routes/locations.js'; @@ -551,9 +552,10 @@ const dataDir = process.env.NODE_ENV === 'production' : __dirname; const dbPath = join(dataDir, 'hairmanager.db'); -// Initialize database first, then migrate, then start server +// Initialize database first, then migrate, then ensure admin user exists, then start server initDatabase(dbPath) .then(() => migrateDatabase(dbPath)) + .then(() => ensureAdminUser(dbPath)) .then(() => { // Create database connection after initialization const db = new sqlite3.Database(dbPath, (err) => { diff --git a/src/App.css b/src/App.css index 83abd6c..28b73e6 100644 --- a/src/App.css +++ b/src/App.css @@ -1,17 +1,17 @@ .app { min-height: 100vh; - background: #e5e5e5; + background: var(--color-bg-page); } -/* Impersonation Banner */ +/* ===== IMPERSONATION BANNER ===== */ .impersonation-banner { background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%); - color: white; + color: var(--color-text-inverse); padding: 10px 20px; - font-size: 13px; + font-size: var(--text-sm); position: sticky; top: 0; - z-index: 1000; + z-index: var(--z-banner); box-shadow: 0 2px 8px rgba(124, 58, 237, 0.3); } @@ -21,11 +21,11 @@ display: flex; align-items: center; justify-content: center; - gap: 12px; + gap: var(--space-md); } .impersonation-icon { - font-size: 14px; + font-size: var(--text-base); opacity: 0.9; } @@ -34,7 +34,7 @@ } .impersonation-content strong { - font-weight: 600; + font-weight: var(--font-semibold); } .return-btn { @@ -43,14 +43,14 @@ gap: 6px; padding: 6px 14px; background: rgba(255, 255, 255, 0.2); - color: white; + color: var(--color-text-inverse); border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 6px; + border-radius: var(--radius-md); cursor: pointer; - font-size: 12px; - font-weight: 600; - transition: all 0.15s ease; - margin-left: 8px; + font-size: var(--text-sm); + font-weight: var(--font-semibold); + transition: all var(--transition-fast); + margin-left: var(--space-sm); } .return-btn:hover { @@ -58,18 +58,20 @@ border-color: rgba(255, 255, 255, 0.5); } +/* ===== APP HEADER ===== */ .app-header { - background: white; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + background: var(--color-bg-primary); + box-shadow: var(--shadow-sm); margin-bottom: 0; width: 100%; + border-bottom: 1px solid var(--color-border); } .app-header-content { max-width: 1400px; margin-left: auto; margin-right: auto; - padding: 20px; + padding: var(--space-md) var(--space-lg); position: relative; } @@ -77,86 +79,81 @@ display: flex; align-items: center; justify-content: space-between; - gap: 20px; + gap: var(--space-lg); } .business-name { margin: 0; - color: #333; - font-size: 1.8em; - font-weight: 700; + color: var(--color-text-primary); + font-size: 1.6em; + font-weight: var(--font-bold); white-space: nowrap; flex-shrink: 0; letter-spacing: -0.5px; } +/* ===== NAVIGATION TABS ===== */ .tabs { display: flex; justify-content: center; align-items: center; - gap: 10px; + gap: var(--space-sm); flex: 1; } .nav-divider { width: 1px; - height: 30px; - background-color: #d0d0d0; - margin: 0 5px; + height: 28px; + background-color: var(--color-border); + margin: 0 var(--space-sm); flex-shrink: 0; } .tabs button { - padding: 10px 20px; + padding: 10px 18px; border: none; background: transparent; - color: #666; - border-radius: 8px; + color: var(--color-text-secondary); + border-radius: var(--radius-md); cursor: pointer; - font-size: 15px; - font-weight: 500; - transition: all 0.2s; + font-size: var(--text-md); + font-weight: var(--font-medium); + transition: all var(--transition-fast); display: flex; align-items: center; - gap: 8px; + gap: var(--space-sm); } .tabs button:hover { - background: #f5f5f5; - color: #333; + background: var(--color-bg-secondary); + color: var(--color-text-primary); } .tabs button.active { - background: #00BCD4; - color: white; - border: none; + background: var(--color-primary); + color: var(--color-text-inverse); } .tabs .entry-btn { - border: 2px solid #ddd; - background: white; + border: 2px solid var(--color-border); + background: var(--color-bg-primary); } .tabs .entry-btn:hover { - border-color: #00BCD4; - color: #00BCD4; - background: white; + border-color: var(--color-primary); + color: var(--color-primary); + background: var(--color-bg-primary); } .tabs .entry-btn.active { - border-color: #00BCD4; - background: white; - color: #00BCD4; -} - -.tabs .entry-btn.active:hover { - border-color: #00BCD4; - background: white; - color: #00BCD4; + border-color: var(--color-primary); + background: var(--color-bg-primary); + color: var(--color-primary); } +/* ===== MAIN CONTENT ===== */ .app-main { - padding: 30px 20px; + padding: var(--space-lg) var(--space-lg); width: 100%; max-width: 1400px; margin: 0 auto; @@ -166,42 +163,46 @@ min-height: calc(100vh - 200px); } -/* Profile Menu Styles */ +/* ===== PROFILE MENU ===== */ .profile-menu-container { position: relative; - z-index: 1000; - padding-bottom: 8px; + z-index: var(--z-dropdown); + padding-bottom: var(--space-sm); flex-shrink: 0; - /* Padding creates hover bridge to dropdown */ } .profile-trigger { display: flex; align-items: center; - gap: 10px; + gap: var(--space-sm); cursor: pointer; - padding: 4px 8px; - border-radius: 4px; + padding: var(--space-xs) var(--space-sm); + border-radius: var(--radius-sm); + transition: background var(--transition-fast); +} + +.profile-trigger:hover { + background: var(--color-bg-secondary); } .profile-avatar { width: 36px; height: 36px; border-radius: 50%; - background-color: #333; - color: white; + background-color: var(--color-text-primary); + color: var(--color-text-inverse); display: flex; align-items: center; justify-content: center; - font-weight: 600; - font-size: 14px; + font-weight: var(--font-semibold); + font-size: var(--text-base); flex-shrink: 0; } .profile-name { - color: #333; - font-size: 14px; - font-weight: 500; + color: var(--color-text-primary); + font-size: var(--text-base); + font-weight: var(--font-medium); white-space: nowrap; } @@ -210,45 +211,45 @@ top: 100%; right: 0; left: 0; - height: 8px; - /* Invisible bridge to prevent mouse leave when moving to dropdown */ - z-index: 1000; + height: var(--space-sm); + z-index: var(--z-dropdown); } .profile-dropdown { position: absolute; - top: calc(100% + 8px); + top: calc(100% + var(--space-sm)); right: 0; - background: white; - border-radius: 8px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - min-width: 160px; - padding: 4px 0; - z-index: 1001; + background: var(--color-bg-primary); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + min-width: 180px; + padding: var(--space-xs) 0; + z-index: calc(var(--z-dropdown) + 1); overflow: hidden; + border: 1px solid var(--color-border); } .profile-dropdown-item { width: 100%; - padding: 10px 16px; + padding: 12px var(--space-md); border: none; background: none; text-align: left; cursor: pointer; display: flex; align-items: center; - gap: 10px; - color: #333; - font-size: 14px; - transition: background-color 0.2s; + gap: var(--space-sm); + color: var(--color-text-primary); + font-size: var(--text-base); + transition: background var(--transition-fast); } .profile-dropdown-item:hover { - background-color: #f5f5f5; + background: var(--color-bg-secondary); } .profile-dropdown-item svg { width: 16px; height: 16px; - color: #666; + color: var(--color-text-secondary); } diff --git a/src/components/AdminManager.css b/src/components/AdminManager.css index 477852f..f044dfd 100644 --- a/src/components/AdminManager.css +++ b/src/components/AdminManager.css @@ -1,10 +1,10 @@ .admin-manager { max-width: 900px; margin: 0 auto; - padding: 30px; - background: white; - border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + padding: var(--space-lg); + background: var(--color-bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); } .admin-header { @@ -12,15 +12,15 @@ } .admin-header h2 { - margin: 0 0 8px 0; - color: #333; - font-size: 28px; + margin: 0 0 var(--space-sm) 0; + color: var(--color-text-primary); + font-size: var(--text-3xl); } .admin-subtitle { margin: 0; - color: #666; - font-size: 14px; + color: var(--color-text-secondary); + font-size: var(--text-base); } .form-section { @@ -76,8 +76,8 @@ .form-group textarea:focus, .form-group select:focus { outline: none; - border-color: #2196F3; - box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.1); + border-color: var(--color-primary); + box-shadow: 0 0 0 2px var(--color-primary-light); } .field-help { @@ -94,8 +94,8 @@ } .submit-btn { - background-color: #2196F3; - color: white; + background-color: var(--color-primary); + color: var(--color-text-inverse); border: none; padding: 12px 24px; border-radius: 8px; diff --git a/src/components/AppointmentsList.css b/src/components/AppointmentsList.css index ec67383..fad3ad9 100644 --- a/src/components/AppointmentsList.css +++ b/src/components/AppointmentsList.css @@ -1,15 +1,15 @@ .appointments-list { max-width: 1400px; margin: 0 auto; - padding: 30px; + padding: var(--space-lg); width: 100%; box-sizing: border-box; overflow-x: hidden; position: relative; min-height: 100%; - background: white; - border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + background: var(--color-bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); } .appointments-header { diff --git a/src/components/EmailLogs.css b/src/components/EmailLogs.css index 58f3146..30a2003 100644 --- a/src/components/EmailLogs.css +++ b/src/components/EmailLogs.css @@ -1,10 +1,10 @@ .email-logs { - padding: 30px; + padding: var(--space-lg); max-width: 1400px; margin: 0 auto; - background: white; - border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + background: var(--color-bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); } .email-logs-header { diff --git a/src/components/EntryForm.css b/src/components/EntryForm.css index 0bc636e..e0b7b6e 100644 --- a/src/components/EntryForm.css +++ b/src/components/EntryForm.css @@ -1,19 +1,19 @@ .entry-form { max-width: 900px; margin: 0 auto; - padding: 30px; - background: white; - border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + padding: var(--space-lg); + background: var(--color-bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); overflow: visible; } .entry-form h2 { margin-top: 0; - margin-bottom: 18px; - color: #333; - font-size: 24px; - font-weight: 600; + margin-bottom: var(--space-lg); + color: var(--color-text-primary); + font-size: var(--text-2xl); + font-weight: var(--font-semibold); } .form-header { @@ -70,9 +70,9 @@ .form-group input:focus, .form-group select:focus { outline: none; - border-color: #4CAF50; - box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1); - background: #fafafa; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-light); + background: var(--color-bg-secondary); } .location-select, @@ -219,17 +219,17 @@ } .appointment-row:hover { - border-color: #4CAF50; - box-shadow: 0 2px 8px rgba(76, 175, 80, 0.1); + border-color: var(--color-primary); + box-shadow: 0 2px 8px var(--color-primary-light); } .appointment-row:focus-within { - border-left-color: #4CAF50 !important; - border-right-color: #4CAF50 !important; - border-bottom-color: #4CAF50 !important; - border-top-color: #4CAF50 !important; - border-top: 2px solid #4CAF50 !important; - box-shadow: 0 2px 8px rgba(76, 175, 80, 0.1); + border-left-color: var(--color-primary) !important; + border-right-color: var(--color-primary) !important; + border-bottom-color: var(--color-primary) !important; + border-top-color: var(--color-primary) !important; + border-top: 2px solid var(--color-primary) !important; + box-shadow: 0 2px 8px var(--color-primary-light); } .appointment-number { @@ -239,11 +239,11 @@ display: flex; align-items: center; justify-content: center; - background: #4CAF50; - color: white; + background: var(--color-primary); + color: var(--color-text-inverse); border-radius: 50%; - font-weight: 600; - font-size: 13px; + font-weight: var(--font-semibold); + font-size: var(--text-sm); } .appointment-client-name { @@ -282,9 +282,9 @@ .price-input:focus { outline: none; - border-color: #4CAF50; - box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1); - background: #fafafa; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-light); + background: var(--color-bg-secondary); } .appointment-service select { @@ -319,9 +319,9 @@ .appointment-service select:focus { outline: none; - border-color: #4CAF50; - box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1); - background: #fafafa; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-light); + background: var(--color-bg-secondary); } .remove-btn { @@ -329,20 +329,20 @@ width: 36px; height: 36px; padding: 0; - background: #f44336; - color: white; + background: var(--color-danger); + color: var(--color-text-inverse); border: none; - border-radius: 6px; + border-radius: var(--radius-md); cursor: pointer; - font-size: 14px; + font-size: var(--text-base); display: flex; align-items: center; justify-content: center; - transition: all 0.2s; + transition: all var(--transition-fast); } .remove-btn:hover:not(:disabled) { - background: #d32f2f; + background: var(--color-danger-hover); transform: scale(1.05); } @@ -356,26 +356,26 @@ .add-appointment-btn { width: 100%; padding: 14px; - background: #2196F3; - color: white; + background: var(--color-info); + color: var(--color-text-inverse); border: none; - border-radius: 8px; + border-radius: var(--radius-md); cursor: pointer; - font-size: 16px; - font-weight: 600; + font-size: var(--text-lg); + font-weight: var(--font-semibold); display: flex; align-items: center; justify-content: center; - gap: 8px; - transition: all 0.2s; - box-shadow: 0 2px 4px rgba(33, 150, 243, 0.2); - margin-bottom: 20px; + gap: var(--space-sm); + transition: all var(--transition-normal); + box-shadow: 0 2px 4px var(--color-info-light); + margin-bottom: var(--space-lg); } .add-appointment-btn:hover { - background: #1976D2; + background: var(--color-info-hover); transform: translateY(-2px); - box-shadow: 0 4px 8px rgba(33, 150, 243, 0.3); + box-shadow: 0 4px 8px var(--color-info-light); } .entry-form .submit-btn, @@ -384,25 +384,25 @@ form .submit-btn, form button.submit-btn { width: 100%; padding: 14px; - background: #4CAF50 !important; - color: white !important; + background: var(--color-primary) !important; + color: var(--color-text-inverse) !important; border: none !important; - border-radius: 8px; + border-radius: var(--radius-md); cursor: pointer; - font-size: 16px; - font-weight: 600; - margin-top: 20px; - transition: all 0.2s; - box-shadow: 0 2px 4px rgba(76, 175, 80, 0.2); + font-size: var(--text-lg); + font-weight: var(--font-semibold); + margin-top: var(--space-lg); + transition: all var(--transition-normal); + box-shadow: 0 2px 4px var(--color-primary-light); } .entry-form .submit-btn:hover:not(:disabled), .entry-form button.submit-btn:hover:not(:disabled), form .submit-btn:hover:not(:disabled), form button.submit-btn:hover:not(:disabled) { - background: #45a049 !important; + background: var(--color-primary-hover) !important; transform: translateY(-2px); - box-shadow: 0 4px 8px rgba(76, 175, 80, 0.4); + box-shadow: 0 4px 8px var(--color-primary-light); } .submit-btn:disabled { @@ -464,23 +464,23 @@ form button.submit-btn:hover:not(:disabled) { .autocomplete-input:focus { outline: none; - border-color: #4CAF50; - box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1); - background: #fafafa; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-light); + background: var(--color-bg-secondary); } .autocomplete-suggestions { position: fixed !important; z-index: 99999 !important; - background: white; - border: 2px solid #4CAF50; - border-radius: 6px; + background: var(--color-bg-primary); + border: 2px solid var(--color-primary); + border-radius: var(--radius-md); max-height: 200px; overflow-y: auto; margin: 0; padding: 0; list-style: none; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + box-shadow: var(--shadow-lg); margin-top: 2px; } diff --git a/src/components/Financial.css b/src/components/Financial.css index 60c0ecc..ad67e45 100644 --- a/src/components/Financial.css +++ b/src/components/Financial.css @@ -1,10 +1,10 @@ .financial-container { max-width: 1200px; margin: 0 auto; - padding: 30px; - background: white; - border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + padding: var(--space-lg); + background: var(--color-bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); } .financial-header { diff --git a/src/components/LocationsManager.css b/src/components/LocationsManager.css index 42dc420..cac95fd 100644 --- a/src/components/LocationsManager.css +++ b/src/components/LocationsManager.css @@ -1,10 +1,10 @@ .locations-manager { max-width: 1400px; margin: 0 auto; - padding: 30px; - background: white; - border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + padding: var(--space-lg); + background: var(--color-bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); } .locations-header { @@ -20,18 +20,23 @@ align-items: center; } -.clear-filters-btn { - padding: 8px 16px; - background: #ff9800; - color: white; - border: none; - border-radius: 4px; +.locations-manager .clear-filters-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + background: #fef2f2; + color: var(--color-danger); + border: 1px solid #fecaca; + border-radius: var(--radius-md); + font-size: var(--text-sm); + font-weight: var(--font-medium); cursor: pointer; - font-size: 14px; + transition: all var(--transition-fast); } -.clear-filters-btn:hover { - background: #f57c00; +.locations-manager .clear-filters-btn:hover { + background: #fee2e2; } .filter-info { @@ -63,8 +68,8 @@ .filter-input:focus { outline: none; - border-color: #4CAF50; - box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.1); + border-color: var(--color-primary); + box-shadow: 0 0 0 2px var(--color-primary-light); } .filter-input::placeholder { diff --git a/src/components/Login.css b/src/components/Login.css index 4c8d2e5..1495d79 100644 --- a/src/components/Login.css +++ b/src/components/Login.css @@ -3,161 +3,163 @@ justify-content: center; align-items: center; min-height: 100vh; - background: #e5e5e5; - padding: 20px; + background: var(--color-bg-page); + padding: var(--space-lg); } .login-box { - background: white; - border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - padding: 40px; + background: var(--color-bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + padding: var(--space-2xl); width: 100%; - max-width: 450px; + max-width: 420px; } .login-box h1 { text-align: center; - color: #333; - margin: 0 0 10px 0; + color: var(--color-text-primary); + margin: 0 0 var(--space-sm) 0; font-size: 1.8em; - font-weight: 700; + font-weight: var(--font-bold); letter-spacing: -0.5px; } .login-box h2 { text-align: center; - color: #666; - margin: 0 0 30px 0; - font-size: 24px; - font-weight: 600; + color: var(--color-text-secondary); + margin: 0 0 var(--space-xl) 0; + font-size: var(--text-2xl); + font-weight: var(--font-semibold); } -.form-group { - margin-bottom: 20px; +.login-box .form-group { + margin-bottom: var(--space-lg); } -.form-group label { +.login-box .form-group label { display: block; - margin-bottom: 8px; - color: #333; - font-weight: 500; + margin-bottom: var(--space-sm); + color: var(--color-text-primary); + font-weight: var(--font-medium); + font-size: var(--text-base); } -.form-group input { +.login-box .form-group input { width: 100%; - padding: 10px 12px; - border: 2px solid #e0e0e0; - border-radius: 6px; - font-size: 14px; + padding: 12px 14px; + border: 2px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--text-base); box-sizing: border-box; - transition: all 0.2s; - background: white; - height: 43px; + transition: all var(--transition-fast); + background: var(--color-bg-primary); + height: 46px; } -.form-group input:hover { - border-color: #bdbdbd; +.login-box .form-group input:hover { + border-color: var(--color-border-dark); } -.form-group input:focus { +.login-box .form-group input:focus { outline: none; - border-color: #00BCD4; - box-shadow: 0 0 0 3px rgba(0, 188, 212, 0.1); - background: #fafafa; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-light); + background: var(--color-bg-secondary); } -.form-group.remember-me-group { - margin-bottom: 20px; +.login-box .form-group.remember-me-group { + margin-bottom: var(--space-lg); } .remember-me-label { display: flex; align-items: center; - gap: 12px; + gap: var(--space-sm); cursor: pointer; - font-weight: 400; + font-weight: var(--font-normal); margin-bottom: 0; - color: #333; - font-size: 14px; + color: var(--color-text-primary); + font-size: var(--text-base); } .remember-me-label input[type="checkbox"] { width: 18px; height: 18px; - margin-right: 6px; cursor: pointer; - accent-color: #00BCD4; + accent-color: var(--color-primary); flex-shrink: 0; } -.error-message { - background: #fee; - color: #c33; - padding: 12px; - border-radius: 4px; - margin-bottom: 20px; - border: 1px solid #fcc; +.login-box .error-message { + background: #fef2f2; + color: var(--color-danger); + padding: var(--space-md); + border-radius: var(--radius-md); + margin-bottom: var(--space-lg); + border-left: 4px solid var(--color-danger); + font-size: var(--text-sm); } -.success-message { - background: #efe; - color: #3c3; - padding: 12px; - border-radius: 4px; - margin-bottom: 20px; - border: 1px solid #cfc; +.login-box .success-message { + background: #f0fdf4; + color: var(--color-success); + padding: var(--space-md); + border-radius: var(--radius-md); + margin-bottom: var(--space-lg); + border-left: 4px solid var(--color-success); + font-size: var(--text-sm); } .submit-button { width: 100%; - padding: 12px 24px; - background: #00BCD4; - color: white; + padding: 14px var(--space-lg); + background: var(--color-primary); + color: var(--color-text-inverse); border: none; - border-radius: 20px; - font-size: 15px; - font-weight: 500; + border-radius: var(--radius-full); + font-size: var(--text-md); + font-weight: var(--font-medium); cursor: pointer; - transition: all 0.2s; - height: 44px; + transition: all var(--transition-fast); + height: 48px; display: flex; align-items: center; justify-content: center; } .submit-button:hover:not(:disabled) { - background: #00ACC1; + background: var(--color-primary-hover); } .submit-button:disabled { - background: #ccc; + background: var(--color-border-dark); cursor: not-allowed; } .switch-mode { text-align: center; - margin-top: 20px; - color: #666; + margin-top: var(--space-lg); + color: var(--color-text-secondary); + font-size: var(--text-base); } .switch-mode > div { - margin-bottom: 8px; + margin-bottom: var(--space-sm); } .link-button { background: none; border: none; - color: #00BCD4; + color: var(--color-primary); cursor: pointer; text-decoration: underline; font-size: inherit; padding: 0; margin-left: 5px; - transition: color 0.2s; + transition: color var(--transition-fast); } .link-button:hover { - color: #00ACC1; + color: var(--color-primary-hover); } - diff --git a/src/components/MyPlan.css b/src/components/MyPlan.css index b7f9ccb..ff8fe2c 100644 --- a/src/components/MyPlan.css +++ b/src/components/MyPlan.css @@ -1,7 +1,8 @@ .my-plan-page { max-width: 1200px; margin: 0 auto; - padding: 0 20px; + padding: 0 var(--space-lg); + color: var(--color-text-primary); } .my-plan-page .loading, diff --git a/src/components/ServicesManager.css b/src/components/ServicesManager.css index 47f7f17..ca0881a 100644 --- a/src/components/ServicesManager.css +++ b/src/components/ServicesManager.css @@ -1,10 +1,10 @@ .services-manager { max-width: 1400px; margin: 0 auto; - padding: 30px; - background: white; - border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + padding: var(--space-lg); + background: var(--color-bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); } .services-header { @@ -20,18 +20,23 @@ align-items: center; } -.clear-filters-btn { - padding: 8px 16px; - background: #ff9800; - color: white; - border: none; - border-radius: 4px; +.services-manager .clear-filters-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + background: #fef2f2; + color: var(--color-danger); + border: 1px solid #fecaca; + border-radius: var(--radius-md); + font-size: var(--text-sm); + font-weight: var(--font-medium); cursor: pointer; - font-size: 14px; + transition: all var(--transition-fast); } -.clear-filters-btn:hover { - background: #f57c00; +.services-manager .clear-filters-btn:hover { + background: #fee2e2; } .filter-info { @@ -65,8 +70,8 @@ .filter-input:focus, .filter-select:focus { outline: none; - border-color: #4CAF50; - box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.1); + border-color: var(--color-primary); + box-shadow: 0 0 0 2px var(--color-primary-light); } .filter-input::placeholder { diff --git a/src/components/SubscriptionManager.css b/src/components/SubscriptionManager.css index 6a6cbff..0e79133 100644 --- a/src/components/SubscriptionManager.css +++ b/src/components/SubscriptionManager.css @@ -1,6 +1,7 @@ .subscription-manager { max-width: 1400px; margin: 0 auto; + color: var(--color-text-primary); } /* Messages */ diff --git a/src/components/SuperAdminManager.css b/src/components/SuperAdminManager.css index d2a8c34..d2ae5c7 100644 --- a/src/components/SuperAdminManager.css +++ b/src/components/SuperAdminManager.css @@ -1,7 +1,8 @@ .super-admin-manager { max-width: 1400px; margin: 0 auto; - padding: 32px 24px; + padding: var(--space-xl) var(--space-lg); + color: var(--color-text-primary); } /* Header */ diff --git a/src/index.css b/src/index.css index d196124..5383b6f 100644 --- a/src/index.css +++ b/src/index.css @@ -3,31 +3,517 @@ } :root { - font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + /* Typography */ + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.5; font-weight: 400; - color-scheme: light; - color: #213547; - background-color: #ffffff; - font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + + /* ===== DESIGN TOKENS ===== */ + + /* Primary Colors */ + --color-primary: #00BCD4; + --color-primary-hover: #00ACC1; + --color-primary-light: rgba(0, 188, 212, 0.1); + --color-primary-dark: #0097A7; + + /* Secondary/Accent Colors */ + --color-accent: #ff9800; + --color-accent-hover: #f57c00; + + /* Semantic Colors */ + --color-success: #10b981; + --color-success-hover: #059669; + --color-success-light: rgba(16, 185, 129, 0.1); + + --color-warning: #f59e0b; + --color-warning-hover: #d97706; + --color-warning-light: rgba(245, 158, 11, 0.1); + + --color-danger: #ef4444; + --color-danger-hover: #dc2626; + --color-danger-light: rgba(239, 68, 68, 0.1); + + --color-info: #3b82f6; + --color-info-hover: #2563eb; + --color-info-light: rgba(59, 130, 246, 0.1); + + /* Neutral Colors */ + --color-text-primary: #1a1a2e; + --color-text-secondary: #6b7280; + --color-text-muted: #9ca3af; + --color-text-inverse: #ffffff; + + --color-bg-primary: #ffffff; + --color-bg-secondary: #f9fafb; + --color-bg-tertiary: #f3f4f6; + --color-bg-page: #e5e5e5; + + --color-border: #e5e7eb; + --color-border-light: #f3f4f6; + --color-border-dark: #d1d5db; + + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.15); + --shadow-xl: 0 8px 24px rgba(0, 0, 0, 0.15); + + /* Border Radius */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --radius-full: 9999px; + + /* Spacing */ + --space-xs: 4px; + --space-sm: 8px; + --space-md: 16px; + --space-lg: 24px; + --space-xl: 32px; + --space-2xl: 48px; + + /* Font Sizes */ + --text-xs: 11px; + --text-sm: 13px; + --text-base: 14px; + --text-md: 15px; + --text-lg: 16px; + --text-xl: 18px; + --text-2xl: 24px; + --text-3xl: 28px; + + /* Font Weights */ + --font-normal: 400; + --font-medium: 500; + --font-semibold: 600; + --font-bold: 700; + + /* Transitions */ + --transition-fast: 0.15s ease; + --transition-normal: 0.2s ease; + --transition-slow: 0.3s ease; + + /* Z-Index Scale */ + --z-dropdown: 100; + --z-sticky: 200; + --z-modal: 500; + --z-popover: 600; + --z-tooltip: 700; + --z-banner: 1000; } body { margin: 0; min-width: 320px; min-height: 100vh; + color: var(--color-text-primary); + background-color: var(--color-bg-page); } h1, h2, h3, h4, h5, h6 { margin: 0; - font-weight: 600; + font-weight: var(--font-semibold); + color: var(--color-text-primary); } button { font-family: inherit; } + +/* ===== GLOBAL COMPONENT STYLES ===== */ + +/* Standard Card Container */ +.card { + background: var(--color-bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + padding: var(--space-lg); +} + +/* Standard Section Header */ +.section-title { + font-size: var(--text-3xl); + font-weight: var(--font-bold); + color: var(--color-text-primary); + margin: 0 0 var(--space-sm) 0; +} + +.section-subtitle { + font-size: var(--text-base); + color: var(--color-text-secondary); + margin: 0; +} + +/* ===== BUTTON SYSTEM ===== */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-sm); + padding: 10px 20px; + font-size: var(--text-base); + font-weight: var(--font-medium); + border-radius: var(--radius-md); + border: none; + cursor: pointer; + transition: all var(--transition-fast); + white-space: nowrap; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Primary Button */ +.btn-primary { + background: var(--color-primary); + color: var(--color-text-inverse); +} + +.btn-primary:hover:not(:disabled) { + background: var(--color-primary-hover); +} + +/* Success Button */ +.btn-success { + background: var(--color-success); + color: var(--color-text-inverse); +} + +.btn-success:hover:not(:disabled) { + background: var(--color-success-hover); +} + +/* Danger Button */ +.btn-danger { + background: var(--color-danger); + color: var(--color-text-inverse); +} + +.btn-danger:hover:not(:disabled) { + background: var(--color-danger-hover); +} + +/* Warning Button */ +.btn-warning { + background: var(--color-warning); + color: var(--color-text-inverse); +} + +.btn-warning:hover:not(:disabled) { + background: var(--color-warning-hover); +} + +/* Info Button */ +.btn-info { + background: var(--color-info); + color: var(--color-text-inverse); +} + +.btn-info:hover:not(:disabled) { + background: var(--color-info-hover); +} + +/* Secondary/Outline Button */ +.btn-secondary { + background: var(--color-bg-primary); + color: var(--color-text-primary); + border: 1px solid var(--color-border); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--color-bg-secondary); + border-color: var(--color-border-dark); +} + +/* Ghost Button */ +.btn-ghost { + background: transparent; + color: var(--color-text-secondary); +} + +.btn-ghost:hover:not(:disabled) { + background: var(--color-bg-tertiary); + color: var(--color-text-primary); +} + +/* Button Sizes */ +.btn-sm { + padding: 6px 12px; + font-size: var(--text-sm); +} + +.btn-lg { + padding: 12px 24px; + font-size: var(--text-lg); +} + +/* Icon-only Button */ +.btn-icon { + width: 36px; + height: 36px; + padding: 0; + border-radius: var(--radius-md); +} + +.btn-icon.btn-sm { + width: 28px; + height: 28px; +} + +/* Pill Button */ +.btn-pill { + border-radius: var(--radius-full); +} + +/* ===== FORM INPUTS ===== */ + +.form-input, +.form-select, +.form-textarea { + width: 100%; + padding: 10px 12px; + font-size: var(--text-base); + font-family: inherit; + color: var(--color-text-primary); + background: var(--color-bg-primary); + border: 2px solid var(--color-border); + border-radius: var(--radius-md); + transition: all var(--transition-fast); +} + +.form-input:hover, +.form-select:hover, +.form-textarea:hover { + border-color: var(--color-border-dark); +} + +.form-input:focus, +.form-select:focus, +.form-textarea:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-light); + background: var(--color-bg-secondary); +} + +.form-input::placeholder { + color: var(--color-text-muted); +} + +.form-label { + display: block; + margin-bottom: var(--space-sm); + font-size: var(--text-sm); + font-weight: var(--font-medium); + color: var(--color-text-primary); +} + +/* Filter Inputs (smaller) */ +.filter-input, +.filter-select { + width: 100%; + padding: 6px 10px; + font-size: var(--text-sm); + color: var(--color-text-primary); + background: var(--color-bg-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + transition: border-color var(--transition-fast); +} + +.filter-input:focus, +.filter-select:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 2px var(--color-primary-light); +} + +.filter-input::placeholder { + color: var(--color-text-muted); +} + +/* ===== TABLE STYLES ===== */ + +.data-table { + width: 100%; + border-collapse: collapse; + background: var(--color-bg-primary); +} + +.data-table th { + padding: var(--space-md); + text-align: left; + font-size: var(--text-xs); + font-weight: var(--font-semibold); + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-text-secondary); + background: var(--color-bg-secondary); + border-bottom: 1px solid var(--color-border); +} + +.data-table td { + padding: var(--space-md); + font-size: var(--text-base); + color: var(--color-text-primary); + border-bottom: 1px solid var(--color-border-light); + vertical-align: middle; +} + +.data-table tbody tr:hover { + background: var(--color-bg-secondary); +} + +.data-table th.sortable { + cursor: pointer; + user-select: none; + transition: background var(--transition-fast); +} + +.data-table th.sortable:hover { + background: var(--color-bg-tertiary); +} + +.data-table .filter-row th { + padding: var(--space-sm) var(--space-md); + background: var(--color-bg-tertiary); +} + +/* Sort Icons */ +.sort-icon { + margin-left: var(--space-xs); + font-size: 10px; + vertical-align: middle; +} + +.sort-icon.inactive { + opacity: 0.3; +} + +.sort-icon.active { + color: var(--color-primary); + opacity: 1; +} + +/* ===== BADGES ===== */ + +.badge { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + padding: 4px 10px; + font-size: var(--text-xs); + font-weight: var(--font-medium); + border-radius: var(--radius-full); +} + +.badge-primary { + background: var(--color-primary-light); + color: var(--color-primary-dark); +} + +.badge-success { + background: var(--color-success-light); + color: var(--color-success); +} + +.badge-warning { + background: var(--color-warning-light); + color: var(--color-warning-hover); +} + +.badge-danger { + background: var(--color-danger-light); + color: var(--color-danger); +} + +.badge-info { + background: var(--color-info-light); + color: var(--color-info); +} + +.badge-neutral { + background: var(--color-bg-tertiary); + color: var(--color-text-secondary); +} + +/* ===== MESSAGES ===== */ + +.message { + padding: var(--space-md) var(--space-lg); + border-radius: var(--radius-md); + font-size: var(--text-sm); + font-weight: var(--font-medium); + margin-bottom: var(--space-md); +} + +.message-success { + background: #f0fdf4; + color: #166534; + border-left: 4px solid var(--color-success); +} + +.message-error { + background: #fef2f2; + color: #991b1b; + border-left: 4px solid var(--color-danger); +} + +.message-warning { + background: #fffbeb; + color: #92400e; + border-left: 4px solid var(--color-warning); +} + +.message-info { + background: #eff6ff; + color: #1e40af; + border-left: 4px solid var(--color-info); +} + +/* ===== CLEAR FILTERS BUTTON ===== */ + +.clear-filters-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + background: #fef2f2; + color: var(--color-danger); + border: 1px solid #fecaca; + border-radius: var(--radius-md); + font-size: var(--text-sm); + font-weight: var(--font-medium); + cursor: pointer; + transition: all var(--transition-fast); +} + +.clear-filters-btn:hover { + background: #fee2e2; +} + +/* ===== LOADING STATE ===== */ + +.loading { + text-align: center; + padding: var(--space-2xl); + color: var(--color-text-secondary); + font-size: var(--text-md); +} + +/* ===== EMPTY STATE ===== */ + +.empty-state { + text-align: center; + padding: var(--space-2xl); + color: var(--color-text-muted); +} From bd8d8dabe99434ae640d0ba1fe90498a8e81c1a0 Mon Sep 17 00:00:00 2001 From: timknowlden Date: Mon, 12 Jan 2026 10:50:29 +0000 Subject: [PATCH 02/26] Update GitHub workflow to use custom GH_PAT token --- .github/workflows/docker-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 81d68eb..887a1cd 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -32,7 +32,7 @@ jobs: with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + password: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }} - name: Get version from package.json id: package-version From 308acff3828f2346ad8a6e9fe4018c17e416d838 Mon Sep 17 00:00:00 2001 From: timknowlden Date: Mon, 12 Jan 2026 10:59:42 +0000 Subject: [PATCH 03/26] Update GHCR authentication to use PAT as username when available --- .github/workflows/docker-build.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 887a1cd..d387d5b 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -27,12 +27,21 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Log in to GitHub Container Registry + - name: Log in to GitHub Container Registry (with PAT) + if: secrets.GH_PAT != '' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.GH_PAT }} + password: ${{ secrets.GH_PAT }} + + - name: Log in to GitHub Container Registry (with GITHUB_TOKEN) + if: secrets.GH_PAT == '' uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} - password: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Get version from package.json id: package-version From 40b83af9f8f2e4aaed208cdde08b838b4e6d56e3 Mon Sep 17 00:00:00 2001 From: timknowlden Date: Mon, 12 Jan 2026 11:09:54 +0000 Subject: [PATCH 04/26] Fix workflow syntax error - remove secret existence check --- .github/workflows/docker-build.yml | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index d387d5b..96e0fe4 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -27,21 +27,12 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Log in to GitHub Container Registry (with PAT) - if: secrets.GH_PAT != '' + - name: Log in to GitHub Container Registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} - username: ${{ secrets.GH_PAT }} - password: ${{ secrets.GH_PAT }} - - - name: Log in to GitHub Container Registry (with GITHUB_TOKEN) - if: secrets.GH_PAT == '' - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + username: ${{ secrets.GH_PAT || github.actor }} + password: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }} - name: Get version from package.json id: package-version From 43eb8bb921dff1e20c7467dcaa7832039ab42e98 Mon Sep 17 00:00:00 2001 From: timknowlden Date: Mon, 12 Jan 2026 11:17:27 +0000 Subject: [PATCH 05/26] Bump version to 1.0.5 and commit all pending changes --- database/ensureAdmin.js | 4 +- package-lock.json | 58 +++++++++++ package.json | 3 +- routes/admin-users.js | 4 +- routes/auth.js | 104 +++++++++++++------- routes/subscriptions.js | 106 ++++++++++++++++++++ src/App.css | 16 +++ src/App.jsx | 146 ++++++++++++++++++++-------- src/components/AppointmentsList.css | 4 +- src/components/EmailLogs.css | 4 +- src/components/EntryForm.css | 6 +- src/components/Financial.css | 19 ++-- src/components/LocationsManager.css | 4 +- src/components/MyPlan.css | 16 ++- src/components/ServicesManager.css | 4 +- src/main.jsx | 9 +- 16 files changed, 401 insertions(+), 106 deletions(-) diff --git a/database/ensureAdmin.js b/database/ensureAdmin.js index 900550b..8805dc0 100644 --- a/database/ensureAdmin.js +++ b/database/ensureAdmin.js @@ -38,8 +38,8 @@ export function ensureAdminUser(dbPath) { const adminPassword = process.env.ADMIN_PASSWORD || 'admin123!'; const adminEmail = process.env.ADMIN_EMAIL || null; - // Check if admin user already exists - getAsync(db, 'SELECT id, username, is_super_admin FROM users WHERE username = ?', [adminUsername]) + // Check if admin user already exists (case-insensitive) + getAsync(db, 'SELECT id, username, is_super_admin FROM users WHERE LOWER(username) = LOWER(?)', [adminUsername]) .then(async (existingUser) => { if (existingUser) { // User exists - check if they're a super admin diff --git a/package-lock.json b/package-lock.json index 50797b0..08ef1f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "react-icons": "^5.5.0", + "react-router-dom": "^7.12.0", "react-window": "^2.2.3", "sqlite3": "^5.1.7" }, @@ -5921,6 +5922,57 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz", + "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz", + "integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==", + "license": "MIT", + "dependencies": { + "react-router": "7.12.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-router/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/react-window": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-window/-/react-window-2.2.3.tgz", @@ -6169,6 +6221,12 @@ "license": "ISC", "optional": true }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", diff --git a/package.json b/package.json index e93a736..8e7fbc1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "hairmanager", "private": true, - "version": "1.0.4", + "version": "1.0.5", "type": "module", "scripts": { "dev": "vite", @@ -32,6 +32,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "react-icons": "^5.5.0", + "react-router-dom": "^7.12.0", "react-window": "^2.2.3", "sqlite3": "^5.1.7" }, diff --git a/routes/admin-users.js b/routes/admin-users.js index 76f146e..09e67bc 100644 --- a/routes/admin-users.js +++ b/routes/admin-users.js @@ -130,7 +130,7 @@ router.post('/', async (req, res) => { } // Check if user already exists - db.get('SELECT id FROM users WHERE username = ?', [username], async (err, existingUser) => { + db.get('SELECT id FROM users WHERE LOWER(username) = LOWER(?)', [username], async (err, existingUser) => { if (err) { console.error('Error checking for existing user:', err); return res.status(500).json({ error: 'Database error' }); @@ -237,7 +237,7 @@ router.put('/:id', async (req, res) => { // Check if username is being changed and if it already exists if (username) { - db.get('SELECT id FROM users WHERE username = ? AND id != ?', [username, userId], async (err, existingUser) => { + db.get('SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND id != ?', [username, userId], async (err, existingUser) => { if (err) { console.error('Error checking for existing username:', err); return res.status(500).json({ error: 'Database error' }); diff --git a/routes/auth.js b/routes/auth.js index 842e2b0..a171ffc 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -15,8 +15,8 @@ router.post('/register', async (req, res) => { return res.status(400).json({ error: 'Username and password are required' }); } - // Check if user already exists - db.get('SELECT id FROM users WHERE username = ?', [username], async (err, existingUser) => { + // Check if user already exists (case-insensitive) + db.get('SELECT id FROM users WHERE LOWER(username) = LOWER(?)', [username], async (err, existingUser) => { if (err) { console.error('Error checking for existing user:', err); return res.status(500).json({ error: 'Database error' }); @@ -111,8 +111,8 @@ router.post('/login', async (req, res) => { return res.status(400).json({ error: 'Username and password are required' }); } - // Find user - explicitly select is_super_admin to ensure it's included - db.get('SELECT id, username, password_hash, email, is_super_admin, created_at FROM users WHERE username = ?', [username], async (err, user) => { + // Find user - case-insensitive username lookup + db.get('SELECT id, username, password_hash, email, is_super_admin, created_at FROM users WHERE LOWER(username) = LOWER(?)', [username], async (err, user) => { if (err) { console.error('Error finding user:', err); return res.status(500).json({ error: 'Database error' }); @@ -144,26 +144,43 @@ router.post('/login', async (req, res) => { converted: isSuperAdmin }); - // Generate JWT token (include is_super_admin for convenience, but we'll verify from DB) - const token = jwt.sign({ - userId: user.id, - username: user.username, - is_super_admin: isSuperAdmin - }, JWT_SECRET, { expiresIn: '7d' }); + // Get user's subscription plan name for immediate access + db.get( + `SELECT sp.name as plan_name + FROM user_subscriptions us + JOIN subscription_plans sp ON us.plan_id = sp.id + WHERE us.user_id = ? AND (us.status = 'active' OR us.status IS NULL) + LIMIT 1`, + [user.id], + (planErr, subscription) => { + let planName = 'free'; + if (!planErr && subscription && subscription.plan_name) { + planName = subscription.plan_name; + } - const responseUser = { - id: user.id, - username: user.username, - email: user.email, - is_super_admin: isSuperAdmin - }; - - console.log('[AUTH /login] Response user object being sent:', JSON.stringify(responseUser, null, 2)); - - res.json({ - token, - user: responseUser - }); + // Generate JWT token (include is_super_admin for convenience, but we'll verify from DB) + const token = jwt.sign({ + userId: user.id, + username: user.username, + is_super_admin: isSuperAdmin + }, JWT_SECRET, { expiresIn: '7d' }); + + const responseUser = { + id: user.id, + username: user.username, + email: user.email, + is_super_admin: isSuperAdmin, + plan_name: planName + }; + + console.log('[AUTH /login] Response user object being sent:', JSON.stringify(responseUser, null, 2)); + + res.json({ + token, + user: responseUser + }); + } + ); } catch (error) { console.error('Error verifying password:', error); res.status(500).json({ error: 'Error verifying password' }); @@ -205,18 +222,35 @@ router.get('/me', (req, res) => { console.log('[AUTH /me] Returning is_super_admin:', isSuperAdmin); - const responseUser = { - id: user.id, - username: user.username, - email: user.email, - is_super_admin: isSuperAdmin - }; - - console.log('[AUTH /me] Response user object:', JSON.stringify(responseUser, null, 2)); - - res.json({ - user: responseUser - }); + // Get user's subscription plan name for immediate access + db.get( + `SELECT sp.name as plan_name + FROM user_subscriptions us + JOIN subscription_plans sp ON us.plan_id = sp.id + WHERE us.user_id = ? AND (us.status = 'active' OR us.status IS NULL) + LIMIT 1`, + [user.id], + (planErr, subscription) => { + let planName = 'free'; + if (!planErr && subscription && subscription.plan_name) { + planName = subscription.plan_name; + } + + const responseUser = { + id: user.id, + username: user.username, + email: user.email, + is_super_admin: isSuperAdmin, + plan_name: planName + }; + + console.log('[AUTH /me] Response user object:', JSON.stringify(responseUser, null, 2)); + + res.json({ + user: responseUser + }); + } + ); }); } catch (error) { return res.status(401).json({ error: 'Invalid token' }); diff --git a/routes/subscriptions.js b/routes/subscriptions.js index dbdcbc1..b3348bd 100644 --- a/routes/subscriptions.js +++ b/routes/subscriptions.js @@ -90,6 +90,112 @@ router.get('/my-subscription', authenticateToken, (req, res) => { ); }); +// Get current user's subscription usage (plan + usage stats) +router.get('/usage', authenticateToken, (req, res) => { + const db = req.app.locals.db; + const userId = req.userId; + + // Get user's current plan and limits + db.get( + `SELECT + sp.name as plan_name, + sp.display_name as plan_display_name, + sp.max_appointments, + sp.max_locations, + sp.max_services, + sp.features, + sp.price_monthly, + sp.currency, + us.status as subscription_status + FROM user_subscriptions us + JOIN subscription_plans sp ON us.plan_id = sp.id + WHERE us.user_id = ? AND (us.status = 'active' OR us.status IS NULL)`, + [userId], + (err, userSubscription) => { + if (err) { + console.error('Error fetching user subscription:', err); + return res.status(500).json({ error: 'Database error' }); + } + + console.log('[subscriptions/usage] User subscription query result:', userSubscription); + + let currentPlan = userSubscription; + if (!currentPlan) { + // If no active subscription, get the 'Free' plan + db.get( + `SELECT name, display_name, max_appointments, max_locations, max_services, features, price_monthly, currency + FROM subscription_plans WHERE name = 'free'`, + [], + (planErr, freePlan) => { + if (planErr || !freePlan) { + return res.status(500).json({ error: 'No default "Free" plan found.' }); + } + currentPlan = { + name: freePlan.name, + display_name: freePlan.display_name, + max_appointments: freePlan.max_appointments, + max_locations: freePlan.max_locations, + max_services: freePlan.max_services, + features: freePlan.features ? JSON.parse(freePlan.features) : [], + price_monthly: freePlan.price_monthly, + currency: freePlan.currency + }; + fetchUsageAndRespond(); + } + ); + return; + } + + // Parse features if needed + if (currentPlan.features && typeof currentPlan.features === 'string') { + currentPlan.features = JSON.parse(currentPlan.features); + } + + fetchUsageAndRespond(); + + function fetchUsageAndRespond() { + // Get all usage counts in a single optimized query + db.get( + `SELECT + (SELECT COUNT(*) FROM appointments WHERE user_id = ?) as appointment_count, + (SELECT COUNT(*) FROM address_data WHERE user_id = ?) as location_count, + (SELECT COUNT(*) FROM services WHERE user_id = ?) as service_count`, + [userId, userId, userId], + (err, row) => { + if (err) { + console.error('Error fetching usage counts:', err); + return res.status(500).json({ error: 'Database error' }); + } + + const planData = { + name: currentPlan.plan_name || currentPlan.name, + display_name: currentPlan.plan_display_name || currentPlan.display_name, + max_appointments: currentPlan.max_appointments, + max_locations: currentPlan.max_locations, + max_services: currentPlan.max_services, + features: currentPlan.features || [], + price: currentPlan.price_monthly, + currency: currentPlan.currency || 'GBP' + }; + + console.log('[subscriptions/usage] Returning plan data:', planData); + console.log('[subscriptions/usage] Plan name:', planData.name); + + res.json({ + plan: planData, + usage: { + appointments: row.appointment_count || 0, + locations: row.location_count || 0, + services: row.service_count || 0 + } + }); + } + ); + } + } + ); +}); + // Get current user's usage statistics router.get('/my-usage', authenticateToken, (req, res) => { const db = req.app.locals.db; diff --git a/src/App.css b/src/App.css index 28b73e6..d73d168 100644 --- a/src/App.css +++ b/src/App.css @@ -253,3 +253,19 @@ height: 16px; color: var(--color-text-secondary); } + +/* ===== SMOOTH TAB TRANSITIONS ===== */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.nav-tab { + transition: opacity 0.2s ease-in, transform 0.2s ease-in; +} diff --git a/src/App.jsx b/src/App.jsx index 1abfcf9..5768138 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; import { FaPlus, FaList, FaMapMarkerAlt, FaCut, FaUser, FaSignOutAlt, FaChartLine, FaEnvelope, FaUserShield, FaArrowLeft, FaEye, FaCrown } from 'react-icons/fa'; import { useAuth } from './contexts/AuthContext'; import EntryForm from './components/EntryForm'; @@ -17,9 +18,28 @@ import './App.css'; import { API_BASE } from './config.js'; function App() { + const navigate = useNavigate(); + const location = useLocation(); const { isAuthenticated, loading, user, logout, getAuthHeaders, isSuperAdmin } = useAuth(); const [impersonation, setImpersonation] = useState(null); const [userPlan, setUserPlan] = useState(null); + const [isLoadingPlan, setIsLoadingPlan] = useState(true); + + // Map URL paths to tab names + const pathToTab = { + '/entry': 'entry', + '/appointments': 'list', + '/locations': 'locations', + '/services': 'services', + '/financial': 'financial', + '/super-admin': 'super-admin', + '/invoice': 'invoice', + '/email-logs': 'email-logs', + '/my-plan': 'my-plan' + }; + + // Get active tab from URL + const activeTab = pathToTab[location.pathname] || 'list'; // Check for impersonation data on mount useEffect(() => { @@ -32,24 +52,56 @@ function App() { // Fetch user's subscription plan useEffect(() => { const fetchUserPlan = async () => { - if (!isAuthenticated) return; + if (!isAuthenticated) { + setUserPlan(null); + setIsLoadingPlan(false); + return; + } + setIsLoadingPlan(true); try { const response = await fetch(`${API_BASE}/subscriptions/usage`, { headers: getAuthHeaders() }); if (response.ok) { const data = await response.json(); - setUserPlan(data.plan); + console.log('[App.jsx] Subscription usage data:', data); + console.log('[App.jsx] Plan data:', data.plan); + if (data.plan) { + setUserPlan(data.plan); + } else { + console.warn('[App.jsx] No plan data in response'); + setUserPlan(null); + } + } else { + const errorText = await response.text(); + console.error('[App.jsx] Failed to fetch subscription usage:', response.status, response.statusText, errorText); + setUserPlan(null); } } catch (err) { - console.error('Error fetching user plan:', err); + console.error('[App.jsx] Error fetching user plan:', err); + setUserPlan(null); + } finally { + setIsLoadingPlan(false); } }; fetchUserPlan(); }, [isAuthenticated]); - // Check if user has access to paid features - const hasPaidPlan = userPlan && userPlan.name !== 'Free'; + // Check if user has access to paid features (case-insensitive check) + // First check user object from auth (available immediately), then fall back to userPlan + const planName = user?.plan_name || (userPlan && userPlan.name) || 'free'; + const hasPaidPlan = planName && planName.toLowerCase() !== 'free'; + + // Debug logging + useEffect(() => { + console.log('[App.jsx] User object:', user); + console.log('[App.jsx] Plan name from user:', user?.plan_name); + console.log('[App.jsx] User plan state:', userPlan); + console.log('[App.jsx] Final plan name:', planName); + console.log('[App.jsx] Has paid plan?', hasPaidPlan); + console.log('[App.jsx] Is super admin?', isSuperAdmin); + console.log('[App.jsx] Will show Financial tab?', hasPaidPlan || isSuperAdmin); + }, [user, userPlan, planName, hasPaidPlan, isSuperAdmin]); // Return to original admin account const returnToAdmin = () => { @@ -75,7 +127,14 @@ function App() { console.log('isSuperAdmin:', isSuperAdmin); } }, [user, isSuperAdmin]); - const [activeTab, setActiveTab] = useState('list'); + + // Redirect to /appointments if on root path + useEffect(() => { + if (isAuthenticated && location.pathname === '/') { + navigate('/appointments', { replace: true }); + } + }, [isAuthenticated, location.pathname, navigate]); + const [refreshTrigger, setRefreshTrigger] = useState(0); const [newAppointmentIds, setNewAppointmentIds] = useState(null); const [pageTitle, setPageTitle] = useState("HairManager"); @@ -92,10 +151,10 @@ function App() { // Refetch profile settings when Profile tab becomes active useEffect(() => { - if (activeTab === 'admin' && isAuthenticated) { + if (location.pathname === '/admin' && isAuthenticated) { fetchProfileSettings(); } - }, [activeTab, isAuthenticated]); + }, [location.pathname, isAuthenticated]); // Close profile menu when clicking outside useEffect(() => { @@ -172,14 +231,14 @@ function App() { const handleAppointmentsAdded = (newIds) => { setNewAppointmentIds(newIds); setRefreshTrigger(prev => prev + 1); - setActiveTab('list'); + navigate('/appointments'); }; const handleCreateInvoice = (appointments) => { console.log('handleCreateInvoice called with appointments:', appointments); setInvoiceAppointments(appointments); - setActiveTab('invoice'); - console.log('Active tab set to invoice'); + navigate('/invoice'); + console.log('Navigated to invoice'); }; return ( @@ -204,33 +263,36 @@ function App() {