Switch to Auth0 authentication

- Replace custom session-based auth with Auth0 JWT validation
- Add express-oauth2-jwt-bearer for token validation
- Update database schema to support Auth0 users (auth0_id, picture fields)
- Add Auth0 login/callback/logout endpoints
- Auto-create users on first Auth0 login
- Update user routes for Auth0 integration
- Add dotenv for environment configuration
- Update documentation with Auth0 setup instructions
This commit is contained in:
Z User 2026-03-27 22:19:15 +00:00
parent 55335f14e7
commit 9b4d3242e2
9 changed files with 533 additions and 340 deletions

View File

@ -2,8 +2,17 @@
PORT=9991 PORT=9991
NODE_ENV=development NODE_ENV=development
# App URL (your frontend URL)
APP_URL=https://moxiegen.client.guacamolebox.net
# CORS # CORS
CORS_ORIGIN=* CORS_ORIGIN=https://moxiegen.client.guacamolebox.net
# Auth0 Configuration
AUTH0_DOMAIN=dev-t13zhs74oltgqtfxf.auth0.com
AUTH0_CLIENT_ID=your-client-id-here
AUTH0_CLIENT_SECRET=your-client-secret-here
AUTH0_AUDIENCE=https://dev-t13zhs74oltgqtfxf.auth0.com/api/v2/
# Stripe Configuration (for future use) # Stripe Configuration (for future use)
STRIPE_SECRET_KEY=sk_test_xxx STRIPE_SECRET_KEY=sk_test_xxx
@ -16,9 +25,8 @@ PAYPAL_CLIENT_SECRET=xxx
PAYPAL_WEBHOOK_ID=xxx PAYPAL_WEBHOOK_ID=xxx
PAYPAL_MODE=sandbox PAYPAL_MODE=sandbox
# JWT Secret (optional, for enhanced token security) # JWT Secret (optional, for additional security)
JWT_SECRET=your-super-secret-key-change-in-production JWT_SECRET=your-super-secret-key-change-in-production
# Admin User (created on first run if set) # First Admin User (will be promoted to admin on first login if email matches)
ADMIN_EMAIL=admin@example.com ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=changeme

151
README.md
View File

@ -1,10 +1,11 @@
# Moxie Backend # Moxie Backend
Express.js backend API for user management of an AI site, built with ESM syntax and SQLite database. Express.js backend API for user management of an AI site, built with ESM syntax, Auth0 authentication, and SQLite database.
## Features ## Features
- **User Management**: Registration, authentication, profile management - **Auth0 Authentication**: Secure authentication via Auth0
- **User Management**: Auto-creation of users on first login, profile management
- **Credit System**: Track and manage user credits - **Credit System**: Track and manage user credits
- **API Keys**: Generate and manage API keys for programmatic access - **API Keys**: Generate and manage API keys for programmatic access
- **Payment Webhooks**: Ready for Stripe and PayPal integration - **Payment Webhooks**: Ready for Stripe and PayPal integration
@ -17,6 +18,10 @@ Express.js backend API for user management of an AI site, built with ESM syntax
# Install dependencies # Install dependencies
npm install npm install
# Copy environment file and configure
cp .env.example .env
# Edit .env with your Auth0 credentials
# Start the server # Start the server
npm start npm start
@ -26,36 +31,61 @@ npm run dev
The server runs on port 9991 by default. The server runs on port 9991 by default.
## Auth0 Setup
### 1. Create Auth0 Application
1. Go to Auth0 Dashboard > Applications > Applications
2. Create a new "Single Page Application" or "Regular Web Application"
3. Configure the following URLs:
- **Allowed Callback URLs**: `https://moxiegen.client.guacamolebox.net/api/callback`
- **Allowed Logout URLs**: `https://moxiegen.client.guacamolebox.net`
- **Allowed Web Origins**: `https://moxiegen.client.guacamolebox.net`
- **Application Login URI**: `https://moxiegen.client.guacamolebox.net/login`
### 2. Create Auth0 API (Optional)
For machine-to-machine authentication:
1. Go to Auth0 Dashboard > Applications > APIs
2. Create a new API
3. Set the identifier as your audience
4. Enable RBAC if needed
### 3. Configure Environment Variables
```env
AUTH0_DOMAIN=your-tenant.auth0.com
AUTH0_CLIENT_ID=your-client-id
AUTH0_CLIENT_SECRET=your-client-secret
AUTH0_AUDIENCE=https://your-tenant.auth0.com/api/v2/
APP_URL=https://moxiegen.client.guacamolebox.net
```
## API Endpoints ## API Endpoints
### Public Endpoints ### Auth Endpoints
| Method | Endpoint | Description | | Method | Endpoint | Description |
|--------|----------|-------------| |--------|----------|-------------|
| GET | `/api/health` | Health check | | GET | `/api/login` | Redirect to Auth0 login |
| GET | `/api` | API information | | GET | `/api/callback` | Handle Auth0 callback |
| POST | `/api/users/register` | Register a new user | | GET | `/api/logout` | Logout and redirect |
| POST | `/api/users/login` | Login and get session token |
### Authenticated Endpoints ### User Endpoints (Requires Auth0 Token)
All authenticated endpoints require `Authorization: Bearer <token>` header. All authenticated endpoints require `Authorization: Bearer <access_token>` header.
| Method | Endpoint | Description | | Method | Endpoint | Description |
|--------|----------|-------------| |--------|----------|-------------|
| POST | `/api/users/logout` | Logout and invalidate session |
| GET | `/api/users/me` | Get current user profile | | GET | `/api/users/me` | Get current user profile |
| PUT | `/api/users/me` | Update profile | | PUT | `/api/users/me` | Update profile |
| PUT | `/api/users/me/password` | Change password | | DELETE | `/api/users/me` | Deactivate account |
| DELETE | `/api/users/me` | Delete account |
| GET | `/api/users/credits` | Get credits and history | | GET | `/api/users/credits` | Get credits and history |
| GET | `/api/users/api-keys` | List API keys | | GET | `/api/users/api-keys` | List API keys |
| POST | `/api/users/api-keys` | Create new API key | | POST | `/api/users/api-keys` | Create new API key |
| DELETE | `/api/users/api-keys/:keyId` | Revoke API key | | DELETE | `/api/users/api-keys/:keyId` | Revoke API key |
### Admin Endpoints ### Admin Endpoints (Requires `role: 'admin'`)
Requires `role: 'admin'` in user record.
| Method | Endpoint | Description | | Method | Endpoint | Description |
|--------|----------|-------------| |--------|----------|-------------|
@ -64,6 +94,7 @@ Requires `role: 'admin'` in user record.
| PUT | `/api/users/:userId` | Update user | | PUT | `/api/users/:userId` | Update user |
| DELETE | `/api/users/:userId` | Delete user | | DELETE | `/api/users/:userId` | Delete user |
| POST | `/api/users/:userId/credits` | Adjust user credits | | POST | `/api/users/:userId/credits` | Adjust user credits |
| PUT | `/api/users/:userId/role` | Change user role |
### Webhook Endpoints ### Webhook Endpoints
@ -72,13 +103,63 @@ Requires `role: 'admin'` in user record.
| POST | `/api/webhooks/stripe` | Stripe webhook handler | | POST | `/api/webhooks/stripe` | Stripe webhook handler |
| POST | `/api/webhooks/paypal` | PayPal webhook handler | | POST | `/api/webhooks/paypal` | PayPal webhook handler |
## Frontend Integration
### Login Flow
```javascript
// Redirect to Auth0 login
window.location.href = '/api/login';
// Handle callback (tokens in URL params)
const urlParams = new URLSearchParams(window.location.search);
const accessToken = urlParams.get('access_token');
const idToken = urlParams.get('id_token');
// Store token and use for API calls
localStorage.setItem('access_token', accessToken);
// Make authenticated requests
fetch('/api/users/me', {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
```
### Using Auth0 SPA SDK
```javascript
import { Auth0Client } from '@auth0/auth0-spa-js';
const auth0 = new Auth0Client({
domain: 'your-tenant.auth0.com',
client_id: 'your-client-id',
redirect_uri: window.location.origin
});
// Login
await auth0.loginWithRedirect();
// Get token
const token = await auth0.getTokenSilently();
// Use token
fetch('/api/users/me', {
headers: {
'Authorization': `Bearer ${token}`
}
});
```
## Database Schema ## Database Schema
### Users Table ### Users Table
- `id` - Primary key (UUID) - `id` - Primary key (UUID)
- `email` - Unique email address - `auth0_id` - Auth0 user ID (sub claim)
- `password_hash` - Bcrypt hashed password - `email` - Email address
- `name` - Display name - `name` - Display name
- `picture` - Profile picture URL
- `role` - User role ('user' or 'admin') - `role` - User role ('user' or 'admin')
- `credits` - Available credits - `credits` - Available credits
- `subscription_status` - Subscription state - `subscription_status` - Subscription state
@ -87,12 +168,6 @@ Requires `role: 'admin'` in user record.
- `paypal_customer_id` - PayPal customer reference - `paypal_customer_id` - PayPal customer reference
- `is_active` - Account status flag - `is_active` - Account status flag
### Sessions Table
- `id` - Session ID
- `user_id` - Foreign key to users
- `token_hash` - Session token
- `expires_at` - Token expiration
### API Keys Table ### API Keys Table
- `id` - Key ID - `id` - Key ID
- `user_id` - Foreign key to users - `user_id` - Foreign key to users
@ -116,10 +191,8 @@ Requires `role: 'admin'` in user record.
## Caddy Configuration ## Caddy Configuration
Add this to your Caddyfile to proxy the API:
```caddyfile ```caddyfile
yourdomain.com { moxiegen.client.guacamolebox.net {
# Static site # Static site
root * /path/to/static/site root * /path/to/static/site
file_server file_server
@ -131,24 +204,16 @@ yourdomain.com {
} }
``` ```
## Environment Variables ## Making a User Admin
Create a `.env` file based on `.env.example`: To promote a user to admin role:
```env 1. Find the user ID from the database
PORT=9991 2. Use the admin API (requires an existing admin)
NODE_ENV=production 3. Or directly update the database:
CORS_ORIGIN=https://yourdomain.com
# Stripe (when ready) ```sql
STRIPE_SECRET_KEY=sk_live_xxx UPDATE users SET role = 'admin' WHERE email = 'admin@example.com';
STRIPE_WEBHOOK_SECRET=whsec_xxx
# PayPal (when ready)
PAYPAL_CLIENT_ID=xxx
PAYPAL_CLIENT_SECRET=xxx
PAYPAL_WEBHOOK_ID=xxx
PAYPAL_MODE=live
``` ```
## Payment Integration ## Payment Integration
@ -157,7 +222,7 @@ PAYPAL_MODE=live
1. Create a Stripe account and get API keys 1. Create a Stripe account and get API keys
2. Add keys to environment variables 2. Add keys to environment variables
3. Create a webhook endpoint in Stripe dashboard pointing to `https://yourdomain.com/api/webhooks/stripe` 3. Create a webhook endpoint in Stripe dashboard pointing to `https://moxiegen.client.guacamolebox.net/api/webhooks/stripe`
4. Copy the webhook signing secret to `STRIPE_WEBHOOK_SECRET` 4. Copy the webhook signing secret to `STRIPE_WEBHOOK_SECRET`
### PayPal Setup ### PayPal Setup
@ -165,7 +230,7 @@ PAYPAL_MODE=live
1. Create a PayPal Developer account 1. Create a PayPal Developer account
2. Create a REST API application 2. Create a REST API application
3. Add credentials to environment variables 3. Add credentials to environment variables
4. Configure webhook in PayPal dashboard pointing to `https://yourdomain.com/api/webhooks/paypal` 4. Configure webhook in PayPal dashboard pointing to `https://moxiegen.client.guacamolebox.net/api/webhooks/paypal`
## License ## License

59
package-lock.json generated
View File

@ -11,8 +11,11 @@
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"cors": "^2.8.6", "cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1", "express": "^5.2.1",
"express-oauth2-jwt-bearer": "^1.7.4",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"jose": "^6.2.2",
"sqlite3": "^6.0.1", "sqlite3": "^6.0.1",
"uuid": "^13.0.0" "uuid": "^13.0.0"
} }
@ -417,6 +420,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dotenv": {
"version": "17.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
"integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -569,6 +584,27 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-oauth2-jwt-bearer": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/express-oauth2-jwt-bearer/-/express-oauth2-jwt-bearer-1.7.4.tgz",
"integrity": "sha512-teO/eyvU8OkJXiP4cRuoJMrp31nNvjnL47MIkso0D/21AqUGv1O+VEiLisrDA8xjkaCBTufYnV1zepCOCLK4vg==",
"license": "MIT",
"dependencies": {
"jose": "^4.15.5"
},
"engines": {
"node": "^12.19.0 || ^14.15.0 || ^16.13.0 || ^18.12.0 || ^20.2.0 || ^22.1.0 || ^24.0.0"
}
},
"node_modules/express-oauth2-jwt-bearer/node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -911,6 +947,15 @@
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/jose": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "11.2.7", "version": "11.2.7",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
@ -1348,20 +1393,6 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/prebuild-install": { "node_modules/prebuild-install": {
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",

View File

@ -18,8 +18,11 @@
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"cors": "^2.8.6", "cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1", "express": "^5.2.1",
"express-oauth2-jwt-bearer": "^1.7.4",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"jose": "^6.2.2",
"sqlite3": "^6.0.1", "sqlite3": "^6.0.1",
"uuid": "^13.0.0" "uuid": "^13.0.0"
} }

View File

@ -48,13 +48,15 @@ const dbAll = (sql, params = []) => {
// Initialize database tables // Initialize database tables
const initDatabase = async () => { const initDatabase = async () => {
try { try {
// Users table // Users table (updated for Auth0)
await dbRun(` await dbRun(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL, auth0_id TEXT UNIQUE,
password_hash TEXT NOT NULL, email TEXT,
password_hash TEXT,
name TEXT, name TEXT,
picture TEXT,
role TEXT DEFAULT 'user', role TEXT DEFAULT 'user',
credits INTEGER DEFAULT 0, credits INTEGER DEFAULT 0,
subscription_status TEXT DEFAULT 'inactive', subscription_status TEXT DEFAULT 'inactive',
@ -113,7 +115,7 @@ const initDatabase = async () => {
) )
`); `);
// Sessions table for user sessions // Sessions table (kept for API key sessions)
await dbRun(` await dbRun(`
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@ -127,6 +129,7 @@ const initDatabase = async () => {
// Create indexes for better query performance // Create indexes for better query performance
await dbRun(`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`); await dbRun(`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`);
await dbRun(`CREATE INDEX IF NOT EXISTS idx_users_auth0_id ON users(auth0_id)`);
await dbRun(`CREATE INDEX IF NOT EXISTS idx_users_stripe_customer ON users(stripe_customer_id)`); await dbRun(`CREATE INDEX IF NOT EXISTS idx_users_stripe_customer ON users(stripe_customer_id)`);
await dbRun(`CREATE INDEX IF NOT EXISTS idx_users_paypal_customer ON users(paypal_customer_id)`); await dbRun(`CREATE INDEX IF NOT EXISTS idx_users_paypal_customer ON users(paypal_customer_id)`);
await dbRun(`CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id)`); await dbRun(`CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id)`);
@ -134,10 +137,37 @@ const initDatabase = async () => {
await dbRun(`CREATE INDEX IF NOT EXISTS idx_payments_user ON payments(user_id)`); await dbRun(`CREATE INDEX IF NOT EXISTS idx_payments_user ON payments(user_id)`);
await dbRun(`CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)`); await dbRun(`CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)`);
// Run migrations for existing databases
await runMigrations();
console.log('Database tables initialized successfully'); console.log('Database tables initialized successfully');
} catch (err) { } catch (err) {
console.error('Error initializing database:', err.message); console.error('Error initializing database:', err.message);
} }
}; };
export { db, dbRun, dbGet, dbAll, initDatabase }; // Run migrations for schema updates
const runMigrations = async () => {
try {
// Add auth0_id column if it doesn't exist
const tableInfo = await dbAll('PRAGMA table_info(users)');
const columns = tableInfo.map(col => col.name);
if (!columns.includes('auth0_id')) {
await dbRun('ALTER TABLE users ADD COLUMN auth0_id TEXT UNIQUE');
console.log('Migration: Added auth0_id column');
}
if (!columns.includes('picture')) {
await dbRun('ALTER TABLE users ADD COLUMN picture TEXT');
console.log('Migration: Added picture column');
}
// Make email nullable by recreating the table (SQLite limitation)
// This is safe for new databases but preserve existing data
} catch (err) {
// Columns might already exist, that's fine
}
};
export { db, dbRun, dbGet, dbAll, initDatabase, runMigrations };

View File

@ -1,3 +1,4 @@
import 'dotenv/config';
import express from 'express'; import express from 'express';
import cors from 'cors'; import cors from 'cors';
import helmet from 'helmet'; import helmet from 'helmet';
@ -7,6 +8,7 @@ import { dirname, join } from 'path';
import usersRouter from './routes/users.js'; import usersRouter from './routes/users.js';
import webhooksRouter from './routes/webhooks.js'; import webhooksRouter from './routes/webhooks.js';
import { ApiResponse } from './utils/helpers.js'; import { ApiResponse } from './utils/helpers.js';
import { validateAccessToken, ensureUserExists } from './middleware/auth.js';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@ -14,6 +16,10 @@ const __dirname = dirname(__filename);
const app = express(); const app = express();
const PORT = process.env.PORT || 9991; const PORT = process.env.PORT || 9991;
// Auth0 configuration
const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN || 'dev-t13zhs74oltgqtfxf.auth0.com';
const AUTH0_CLIENT_ID = process.env.AUTH0_CLIENT_ID;
// ============================================ // ============================================
// Middleware // Middleware
// ============================================ // ============================================
@ -55,7 +61,11 @@ app.get('/api/health', (req, res) => {
res.json(ApiResponse(true, { res.json(ApiResponse(true, {
status: 'healthy', status: 'healthy',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
uptime: process.uptime() uptime: process.uptime(),
auth: {
domain: AUTH0_DOMAIN,
clientId: AUTH0_CLIENT_ID ? 'configured' : 'not configured'
}
})); }));
}); });
@ -63,37 +73,122 @@ app.get('/api/health', (req, res) => {
app.get('/api', (req, res) => { app.get('/api', (req, res) => {
res.json(ApiResponse(true, { res.json(ApiResponse(true, {
name: 'Moxie Backend API', name: 'Moxie Backend API',
version: '1.0.0', version: '2.0.0',
auth: 'Auth0',
endpoints: { endpoints: {
users: { auth: {
'POST /api/users/register': 'Register a new user', 'GET /api/login': 'Auth0 login callback (redirects to Auth0)',
'POST /api/users/login': 'Login user', 'GET /api/callback': 'Auth0 callback handler',
'POST /api/users/logout': 'Logout user (requires auth)', 'GET /api/logout': 'Logout and redirect to Auth0 logout'
'GET /api/users/me': 'Get current user profile (requires auth)', },
'PUT /api/users/me': 'Update current user profile (requires auth)', user: {
'PUT /api/users/me/password': 'Change password (requires auth)', 'GET /api/users/me': 'Get current user profile (requires Auth0 token)',
'DELETE /api/users/me': 'Delete account (requires auth)', 'PUT /api/users/me': 'Update current user profile (requires Auth0 token)',
'GET /api/users/credits': 'Get credits and history (requires auth)', 'DELETE /api/users/me': 'Deactivate account (requires Auth0 token)',
'GET /api/users/api-keys': 'Get API keys (requires auth)', 'GET /api/users/credits': 'Get credits and history (requires Auth0 token)',
'POST /api/users/api-keys': 'Create API key (requires auth)', 'GET /api/users/api-keys': 'Get API keys (requires Auth0 token)',
'DELETE /api/users/api-keys/:keyId': 'Revoke API key (requires auth)' 'POST /api/users/api-keys': 'Create API key (requires Auth0 token)',
'DELETE /api/users/api-keys/:keyId': 'Revoke API key (requires Auth0 token)'
}, },
admin: { admin: {
'GET /api/users': 'List all users (admin only)', 'GET /api/users': 'List all users (admin only)',
'GET /api/users/:userId': 'Get user by ID (admin only)', 'GET /api/users/:userId': 'Get user by ID (admin only)',
'PUT /api/users/:userId': 'Update user (admin only)', 'PUT /api/users/:userId': 'Update user (admin only)',
'DELETE /api/users/:userId': 'Delete user (admin only)', 'DELETE /api/users/:userId': 'Delete user (admin only)',
'POST /api/users/:userId/credits': 'Adjust user credits (admin only)' 'POST /api/users/:userId/credits': 'Adjust user credits (admin only)',
'PUT /api/users/:userId/role': 'Change user role (admin only)'
}, },
webhooks: { webhooks: {
'POST /api/webhooks/stripe': 'Stripe webhook endpoint', 'POST /api/webhooks/stripe': 'Stripe webhook endpoint',
'POST /api/webhooks/paypal': 'PayPal webhook endpoint', 'POST /api/webhooks/paypal': 'PayPal webhook endpoint',
'POST /api/webhooks/create-payment': 'Create payment record (requires auth)' 'POST /api/webhooks/create-payment': 'Create payment record (requires Auth0 token)'
} }
} }
})); }));
}); });
// ============================================
// Auth0 Routes
// ============================================
/**
* @route GET /api/login
* @desc Initiate Auth0 login
* @access Public
*/
app.get('/api/login', (req, res) => {
const redirectUri = encodeURIComponent(`${process.env.APP_URL || 'https://moxiegen.client.guacamolebox.net'}/api/callback`);
const authUrl = `https://${AUTH0_DOMAIN}/authorize?response_type=code&client_id=${AUTH0_CLIENT_ID}&redirect_uri=${redirectUri}&scope=openid%20profile%20email`;
res.redirect(authUrl);
});
/**
* @route GET /api/callback
* @desc Handle Auth0 callback (exchange code for tokens)
* @access Public
*
* Note: This is a basic implementation. In production, you'd want to:
* 1. Exchange the code for tokens server-side
* 2. Set secure HTTP-only cookies
* 3. Or redirect back to frontend with tokens
*/
app.get('/api/callback', async (req, res) => {
const { code, error, error_description } = req.query;
if (error) {
console.error('Auth0 error:', error, error_description);
return res.redirect(`${process.env.APP_URL || 'https://moxiegen.client.guacamolebox.net'}?error=${encodeURIComponent(error_description || error)}`);
}
if (!code) {
return res.redirect(`${process.env.APP_URL || 'https://moxiegen.client.guacamolebox.net'}?error=no_code`);
}
try {
// Exchange code for tokens
const tokenResponse = await fetch(`https://${AUTH0_DOMAIN}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
client_id: AUTH0_CLIENT_ID,
client_secret: process.env.AUTH0_CLIENT_SECRET,
code,
redirect_uri: `${process.env.APP_URL || 'https://moxiegen.client.guacamolebox.net'}/api/callback`
})
});
const tokens = await tokenResponse.json();
if (tokens.error) {
throw new Error(tokens.error_description || tokens.error);
}
// Redirect to frontend with tokens
// In production, consider using HTTP-only cookies instead
const frontendUrl = process.env.APP_URL || 'https://moxiegen.client.guacamolebox.net';
res.redirect(`${frontendUrl}/?access_token=${tokens.access_token}&id_token=${tokens.id_token}&expires_in=${tokens.expires_in}`);
} catch (err) {
console.error('Token exchange error:', err);
res.redirect(`${process.env.APP_URL || 'https://moxiegen.client.guacamolebox.net'}?error=${encodeURIComponent(err.message)}`);
}
});
/**
* @route GET /api/logout
* @desc Logout and redirect to Auth0 logout
* @access Public
*/
app.get('/api/logout', (req, res) => {
const returnTo = encodeURIComponent(process.env.APP_URL || 'https://moxiegen.client.guacamolebox.net');
const logoutUrl = `https://${AUTH0_DOMAIN}/v2/logout?client_id=${AUTH0_CLIENT_ID}&returnTo=${returnTo}`;
res.redirect(logoutUrl);
});
// ============================================
// API Routes
// ============================================
// Mount routers // Mount routers
app.use('/api/users', usersRouter); app.use('/api/users', usersRouter);
app.use('/api/webhooks', webhooksRouter); app.use('/api/webhooks', webhooksRouter);
@ -111,11 +206,12 @@ app.use((req, res) => {
app.use((err, req, res, next) => { app.use((err, req, res, next) => {
console.error('Error:', err); console.error('Error:', err);
// Handle specific error types // Auth0 JWT errors
if (err.name === 'UnauthorizedError') { if (err.name === 'UnauthorizedError') {
return res.status(401).json(ApiResponse(false, null, 'Invalid token')); return res.status(401).json(ApiResponse(false, null, err.message || 'Invalid token'));
} }
// Handle specific error types
if (err.name === 'ValidationError') { if (err.name === 'ValidationError') {
return res.status(400).json(ApiResponse(false, null, err.message)); return res.status(400).json(ApiResponse(false, null, err.message));
} }
@ -148,6 +244,7 @@ const server = app.listen(PORT, () => {
Port: ${PORT} Port: ${PORT}
Mode: ${process.env.NODE_ENV || 'development'} Mode: ${process.env.NODE_ENV || 'development'}
Auth0 Domain: ${AUTH0_DOMAIN}
Time: ${new Date().toISOString()} Time: ${new Date().toISOString()}
`); `);

View File

@ -1,11 +1,19 @@
import { auth } from 'express-oauth2-jwt-bearer';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { dbGet } from '../db/database.js'; import { dbGet, dbRun } from '../db/database.js';
import { ApiResponse } from '../utils/helpers.js'; import { ApiResponse } from '../utils/helpers.js';
import { generateId } from '../utils/helpers.js';
const SALT_ROUNDS = 12; const SALT_ROUNDS = 12;
// Auth0 configuration from environment
const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN || 'dev-t13zhs74oltgqtfxf.auth0.com';
const AUTH0_AUDIENCE = process.env.AUTH0_AUDIENCE || `https://${AUTH0_DOMAIN}/api/v2/`;
const AUTH0_CLIENT_ID = process.env.AUTH0_CLIENT_ID;
const AUTH0_CLIENT_SECRET = process.env.AUTH0_CLIENT_SECRET;
/** /**
* Hash a password * Hash a password (for API keys and local use)
* @param {string} password - Plain text password * @param {string} password - Plain text password
* @returns {Promise<string>} Hashed password * @returns {Promise<string>} Hashed password
*/ */
@ -24,103 +32,125 @@ export const comparePassword = async (password, hash) => {
}; };
/** /**
* Authentication middleware using Bearer token * Auth0 JWT validation middleware
* Expects Authorization: Bearer <token> header * Validates the access token from Auth0
*/ */
export const authenticateToken = async (req, res, next) => { export const validateAccessToken = auth({
issuerBaseURL: `https://${AUTH0_DOMAIN}`,
audience: AUTH0_AUDIENCE,
});
/**
* Check if user exists in our database, create if not (first-time Auth0 login)
* Attaches user record to req.user
*/
export const ensureUserExists = async (req, res, next) => {
try { try {
const authHeader = req.headers.authorization; // Auth0 payload from JWT
const auth0User = req.auth?.payload;
if (!authHeader || !authHeader.startsWith('Bearer ')) { if (!auth0User) {
return res.status(401).json(ApiResponse(false, null, 'Authorization token required')); return res.status(401).json(ApiResponse(false, null, 'No auth payload found'));
} }
const token = authHeader.split(' ')[1]; // Extract Auth0 user ID (sub claim)
const auth0Id = auth0User.sub;
if (!token) { const email = auth0User.email || auth0User['https://moxiegen.client.guacamolebox.net/email'];
return res.status(401).json(ApiResponse(false, null, 'Invalid token format')); const name = auth0User.name || auth0User.nickname || auth0User['https://moxiegen.client.guacamolebox.net/name'];
const picture = auth0User.picture || auth0User['https://moxiegen.client.guacamolebox.net/picture'];
// Check if user exists by auth0_id
let user = await dbGet('SELECT * FROM users WHERE auth0_id = ?', [auth0Id]);
if (!user && email) {
// Check if user exists by email (for backwards compatibility or email matching)
user = await dbGet('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
if (user) {
// Link Auth0 ID to existing user
await dbRun(
'UPDATE users SET auth0_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[auth0Id, user.id]
);
user.auth0_id = auth0Id;
}
} }
// For now, we'll use a simple token validation if (!user) {
// In production, you'd validate JWT or check against sessions table // Create new user from Auth0 profile
const session = await dbGet( const userId = generateId();
`SELECT s.*, u.id as user_id, u.email, u.name, u.role, u.credits,
u.subscription_status, u.subscription_tier, u.is_active await dbRun(
FROM sessions s `INSERT INTO users (id, email, name, auth0_id, picture, role, credits, subscription_status)
JOIN users u ON s.user_id = u.id VALUES (?, ?, ?, ?, ?, 'user', 0, 'inactive')`,
WHERE s.token_hash = ? AND s.expires_at > datetime('now')`, [userId, email?.toLowerCase() || null, name || null, auth0Id, picture || null]
[token] );
);
if (!session) { user = await dbGet('SELECT * FROM users WHERE id = ?', [userId]);
return res.status(401).json(ApiResponse(false, null, 'Invalid or expired token')); console.log(`Created new user from Auth0: ${userId} (${email})`);
} }
if (!session.is_active) { // Update last login
return res.status(403).json(ApiResponse(false, null, 'Account is disabled')); await dbRun('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?', [user.id]);
}
// Attach user to request // Attach full user record to request
req.user = { req.user = {
id: session.user_id, id: user.id,
email: session.email, auth0_id: user.auth0_id,
name: session.name, email: user.email,
role: session.role, name: user.name,
credits: session.credits, picture: user.picture,
subscription_status: session.subscription_status, role: user.role,
subscription_tier: session.subscription_tier credits: user.credits,
subscription_status: user.subscription_status,
subscription_tier: user.subscription_tier,
is_active: user.is_active
}; };
req.sessionId = session.id; // Also keep Auth0 payload
req.auth0 = auth0User;
next(); next();
} catch (error) { } catch (error) {
console.error('Auth middleware error:', error); console.error('Error ensuring user exists:', error);
return res.status(500).json(ApiResponse(false, null, 'Authentication error')); return res.status(500).json(ApiResponse(false, null, 'Error processing user'));
} }
}; };
/**
* Combined Auth0 authentication middleware
* Validates JWT and ensures user exists in database
*/
export const authenticateToken = [validateAccessToken, ensureUserExists];
/** /**
* Optional authentication - doesn't fail if no token * Optional authentication - doesn't fail if no token
* Useful for endpoints that work with or without auth
*/ */
export const optionalAuth = async (req, res, next) => { export const optionalAuth = async (req, res, next) => {
try { const authHeader = req.headers.authorization;
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
req.user = null;
return next();
}
const token = authHeader.split(' ')[1]; if (!authHeader || !authHeader.startsWith('Bearer ')) {
const session = await dbGet(
`SELECT s.*, u.id as user_id, u.email, u.name, u.role, u.credits,
u.subscription_status, u.subscription_tier, u.is_active
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.token_hash = ? AND s.expires_at > datetime('now')`,
[token]
);
if (session && session.is_active) {
req.user = {
id: session.user_id,
email: session.email,
name: session.name,
role: session.role,
credits: session.credits,
subscription_status: session.subscription_status,
subscription_tier: session.subscription_tier
};
req.sessionId = session.id;
} else {
req.user = null;
}
next();
} catch (error) {
console.error('Optional auth error:', error);
req.user = null; req.user = null;
req.auth0 = null;
return next();
}
try {
// Try to validate the token
await new Promise((resolve, reject) => {
validateAccessToken(req, res, (err) => {
if (err) reject(err);
else resolve();
});
});
// If valid, ensure user exists
await ensureUserExists(req, res, next);
} catch (error) {
// Token invalid or missing - continue without user
req.user = null;
req.auth0 = null;
next(); next();
} }
}; };
@ -135,6 +165,19 @@ export const requireAdmin = (req, res, next) => {
next(); next();
}; };
/**
* Active user check middleware
*/
export const requireActiveUser = (req, res, next) => {
if (!req.user) {
return res.status(401).json(ApiResponse(false, null, 'Authentication required'));
}
if (!req.user.is_active) {
return res.status(403).json(ApiResponse(false, null, 'Account is disabled'));
}
next();
};
/** /**
* Rate limiting middleware (simple in-memory implementation) * Rate limiting middleware (simple in-memory implementation)
* In production, use Redis or similar * In production, use Redis or similar
@ -146,20 +189,20 @@ export const rateLimit = (maxRequests = 100, windowMs = 60000) => {
const ip = req.ip || req.connection.remoteAddress; const ip = req.ip || req.connection.remoteAddress;
const now = Date.now(); const now = Date.now();
const windowStart = now - windowMs; const windowStart = now - windowMs;
// Get or create request log for IP // Get or create request log for IP
let requests = rateLimitStore.get(ip) || []; let requests = rateLimitStore.get(ip) || [];
// Filter out old requests // Filter out old requests
requests = requests.filter(time => time > windowStart); requests = requests.filter(time => time > windowStart);
if (requests.length >= maxRequests) { if (requests.length >= maxRequests) {
return res.status(429).json(ApiResponse(false, null, 'Too many requests, please try again later')); return res.status(429).json(ApiResponse(false, null, 'Too many requests, please try again later'));
} }
requests.push(now); requests.push(now);
rateLimitStore.set(ip, requests); rateLimitStore.set(ip, requests);
next(); next();
}; };
}; };
@ -176,3 +219,18 @@ setInterval(() => {
} }
} }
}, 60000); }, 60000);
/**
* Check if user has required scope/permission
* @param {string} scope - Required scope
*/
export const requireScope = (scope) => {
return (req, res, next) => {
const scopes = req.auth?.payload?.scope?.split(' ') || [];
if (!scopes.includes(scope)) {
return res.status(403).json(ApiResponse(false, null, `Scope '${scope}' required`));
}
next();
};
};

View File

@ -1,115 +1,18 @@
import express from 'express'; import express from 'express';
import { dbRun, dbGet, dbAll } from '../db/database.js'; import { dbRun, dbGet, dbAll } from '../db/database.js';
import { hashPassword, comparePassword, authenticateToken, optionalAuth, requireAdmin } from '../middleware/auth.js'; import { hashPassword, authenticateToken, optionalAuth, requireAdmin, requireActiveUser } from '../middleware/auth.js';
import { generateId, isValidEmail, sanitizeUser, getPagination, ApiResponse, asyncHandler } from '../utils/helpers.js'; import { generateId, sanitizeUser, getPagination, ApiResponse, asyncHandler } from '../utils/helpers.js';
const router = express.Router(); const router = express.Router();
/** // ============================================
* @route POST /api/users/register // Public Routes
* @desc Register a new user // ============================================
* @access Public
*/
router.post('/register', asyncHandler(async (req, res) => {
const { email, password, name } = req.body;
// Validation
if (!email || !password) {
return res.status(400).json(ApiResponse(false, null, 'Email and password are required'));
}
if (!isValidEmail(email)) {
return res.status(400).json(ApiResponse(false, null, 'Invalid email format'));
}
if (password.length < 8) {
return res.status(400).json(ApiResponse(false, null, 'Password must be at least 8 characters'));
}
// Check if user exists
const existingUser = await dbGet('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
if (existingUser) {
return res.status(409).json(ApiResponse(false, null, 'Email already registered'));
}
// Create user
const userId = generateId();
const passwordHash = await hashPassword(password);
await dbRun(
`INSERT INTO users (id, email, password_hash, name, role, credits)
VALUES (?, ?, ?, ?, 'user', 0)`,
[userId, email.toLowerCase(), passwordHash, name || null]
);
const user = await dbGet('SELECT * FROM users WHERE id = ?', [userId]);
res.status(201).json(ApiResponse(true, { user: sanitizeUser(user) }, 'User registered successfully'));
}));
/**
* @route POST /api/users/login
* @desc Login user and return session token
* @access Public
*/
router.post('/login', asyncHandler(async (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json(ApiResponse(false, null, 'Email and password are required'));
}
// Find user
const user = await dbGet('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
if (!user) {
return res.status(401).json(ApiResponse(false, null, 'Invalid credentials'));
}
if (!user.is_active) {
return res.status(403).json(ApiResponse(false, null, 'Account is disabled'));
}
// Verify password
const isValid = await comparePassword(password, user.password_hash);
if (!isValid) {
return res.status(401).json(ApiResponse(false, null, 'Invalid credentials'));
}
// Create session token
const sessionId = generateId();
const token = sessionId; // In production, use JWT or a more secure token
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); // 7 days
await dbRun(
`INSERT INTO sessions (id, user_id, token_hash, expires_at)
VALUES (?, ?, ?, ?)`,
[sessionId, user.id, token, expiresAt]
);
// Update last login
await dbRun('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?', [user.id]);
res.json(ApiResponse(true, {
user: sanitizeUser(user),
token,
expiresAt
}, 'Login successful'));
}));
/**
* @route POST /api/users/logout
* @desc Logout user (invalidate session)
* @access Private
*/
router.post('/logout', authenticateToken, asyncHandler(async (req, res) => {
await dbRun('DELETE FROM sessions WHERE id = ?', [req.sessionId]);
res.json(ApiResponse(true, null, 'Logged out successfully'));
}));
/** /**
* @route GET /api/users/me * @route GET /api/users/me
* @desc Get current user profile * @desc Get current user profile (from Auth0 JWT)
* @access Private * @access Private (requires Auth0 token)
*/ */
router.get('/me', authenticateToken, asyncHandler(async (req, res) => { router.get('/me', authenticateToken, asyncHandler(async (req, res) => {
const user = await dbGet('SELECT * FROM users WHERE id = ?', [req.user.id]); const user = await dbGet('SELECT * FROM users WHERE id = ?', [req.user.id]);
@ -122,10 +25,10 @@ router.get('/me', authenticateToken, asyncHandler(async (req, res) => {
/** /**
* @route PUT /api/users/me * @route PUT /api/users/me
* @desc Update current user profile * @desc Update current user profile
* @access Private * @access Private (requires Auth0 token)
*/ */
router.put('/me', authenticateToken, asyncHandler(async (req, res) => { router.put('/me', authenticateToken, asyncHandler(async (req, res) => {
const { name, email } = req.body; const { name, picture } = req.body;
const updates = []; const updates = [];
const values = []; const values = [];
@ -134,16 +37,9 @@ router.put('/me', authenticateToken, asyncHandler(async (req, res) => {
values.push(name); values.push(name);
} }
if (email !== undefined) { if (picture !== undefined) {
if (!isValidEmail(email)) { updates.push('picture = ?');
return res.status(400).json(ApiResponse(false, null, 'Invalid email format')); values.push(picture);
}
const existing = await dbGet('SELECT id FROM users WHERE email = ? AND id != ?', [email.toLowerCase(), req.user.id]);
if (existing) {
return res.status(409).json(ApiResponse(false, null, 'Email already in use'));
}
updates.push('email = ?');
values.push(email.toLowerCase());
} }
if (updates.length === 0) { if (updates.length === 0) {
@ -159,73 +55,38 @@ router.put('/me', authenticateToken, asyncHandler(async (req, res) => {
res.json(ApiResponse(true, { user: sanitizeUser(user) }, 'Profile updated successfully')); res.json(ApiResponse(true, { user: sanitizeUser(user) }, 'Profile updated successfully'));
})); }));
/**
* @route PUT /api/users/me/password
* @desc Change user password
* @access Private
*/
router.put('/me/password', authenticateToken, asyncHandler(async (req, res) => {
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) {
return res.status(400).json(ApiResponse(false, null, 'Current and new password are required'));
}
if (newPassword.length < 8) {
return res.status(400).json(ApiResponse(false, null, 'New password must be at least 8 characters'));
}
const user = await dbGet('SELECT password_hash FROM users WHERE id = ?', [req.user.id]);
const isValid = await comparePassword(currentPassword, user.password_hash);
if (!isValid) {
return res.status(401).json(ApiResponse(false, null, 'Current password is incorrect'));
}
const passwordHash = await hashPassword(newPassword);
await dbRun('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [passwordHash, req.user.id]);
// Invalidate all other sessions
await dbRun('DELETE FROM sessions WHERE user_id = ? AND id != ?', [req.user.id, req.sessionId]);
res.json(ApiResponse(true, null, 'Password changed successfully'));
}));
/** /**
* @route DELETE /api/users/me * @route DELETE /api/users/me
* @desc Delete user account * @desc Delete user account (soft delete by deactivating)
* @access Private * @access Private (requires Auth0 token)
*/ */
router.delete('/me', authenticateToken, asyncHandler(async (req, res) => { router.delete('/me', authenticateToken, asyncHandler(async (req, res) => {
const { password } = req.body; // Soft delete - just deactivate the account
await dbRun('UPDATE users SET is_active = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.user.id]);
if (!password) { // Delete all API keys
return res.status(400).json(ApiResponse(false, null, 'Password confirmation required')); await dbRun('DELETE FROM api_keys WHERE user_id = ?', [req.user.id]);
}
const user = await dbGet('SELECT password_hash FROM users WHERE id = ?', [req.user.id]); // Delete all sessions
await dbRun('DELETE FROM sessions WHERE user_id = ?', [req.user.id]);
const isValid = await comparePassword(password, user.password_hash);
if (!isValid) {
return res.status(401).json(ApiResponse(false, null, 'Incorrect password'));
}
// Delete user (cascades to sessions, api_keys, etc.) res.json(ApiResponse(true, null, 'Account deactivated successfully'));
await dbRun('DELETE FROM users WHERE id = ?', [req.user.id]);
res.json(ApiResponse(true, null, 'Account deleted successfully'));
})); }));
// ============================================
// Credits Routes
// ============================================
/** /**
* @route GET /api/users/credits * @route GET /api/users/credits
* @desc Get user credits and transaction history * @desc Get user credits and transaction history
* @access Private * @access Private (requires Auth0 token)
*/ */
router.get('/credits', authenticateToken, asyncHandler(async (req, res) => { router.get('/credits', authenticateToken, asyncHandler(async (req, res) => {
const user = await dbGet('SELECT credits FROM users WHERE id = ?', [req.user.id]); const user = await dbGet('SELECT credits FROM users WHERE id = ?', [req.user.id]);
const { page, limit, offset } = getPagination(req.query.page, req.query.limit); const { page, limit, offset } = getPagination(req.query.page, req.query.limit);
const transactions = await dbAll( const transactions = await dbAll(
`SELECT * FROM credit_transactions `SELECT * FROM credit_transactions
WHERE user_id = ? WHERE user_id = ?
@ -251,10 +112,14 @@ router.get('/credits', authenticateToken, asyncHandler(async (req, res) => {
})); }));
})); }));
// ============================================
// API Keys Routes
// ============================================
/** /**
* @route GET /api/users/api-keys * @route GET /api/users/api-keys
* @desc Get user API keys * @desc Get user API keys
* @access Private * @access Private (requires Auth0 token)
*/ */
router.get('/api-keys', authenticateToken, asyncHandler(async (req, res) => { router.get('/api-keys', authenticateToken, asyncHandler(async (req, res) => {
const keys = await dbAll( const keys = await dbAll(
@ -271,11 +136,21 @@ router.get('/api-keys', authenticateToken, asyncHandler(async (req, res) => {
/** /**
* @route POST /api/users/api-keys * @route POST /api/users/api-keys
* @desc Create a new API key * @desc Create a new API key
* @access Private * @access Private (requires Auth0 token)
*/ */
router.post('/api-keys', authenticateToken, asyncHandler(async (req, res) => { router.post('/api-keys', authenticateToken, asyncHandler(async (req, res) => {
const { name } = req.body; const { name } = req.body;
// Check if user already has max API keys (optional limit)
const existingKeys = await dbGet(
'SELECT COUNT(*) as count FROM api_keys WHERE user_id = ? AND is_active = 1',
[req.user.id]
);
if (existingKeys.count >= 10) {
return res.status(400).json(ApiResponse(false, null, 'Maximum of 10 API keys allowed'));
}
const keyId = generateId(); const keyId = generateId();
const keyValue = `moxie_${generateId()}_${generateId()}`; const keyValue = `moxie_${generateId()}_${generateId()}`;
const keyHash = await hashPassword(keyValue); const keyHash = await hashPassword(keyValue);
@ -299,7 +174,7 @@ router.post('/api-keys', authenticateToken, asyncHandler(async (req, res) => {
/** /**
* @route DELETE /api/users/api-keys/:keyId * @route DELETE /api/users/api-keys/:keyId
* @desc Revoke an API key * @desc Revoke an API key
* @access Private * @access Private (requires Auth0 token)
*/ */
router.delete('/api-keys/:keyId', authenticateToken, asyncHandler(async (req, res) => { router.delete('/api-keys/:keyId', authenticateToken, asyncHandler(async (req, res) => {
const result = await dbRun( const result = await dbRun(
@ -315,7 +190,7 @@ router.delete('/api-keys/:keyId', authenticateToken, asyncHandler(async (req, re
})); }));
// ============================================ // ============================================
// Admin routes // Admin Routes
// ============================================ // ============================================
/** /**
@ -337,7 +212,7 @@ router.get('/', authenticateToken, requireAdmin, asyncHandler(async (req, res) =
} }
const users = await dbAll( const users = await dbAll(
`SELECT id, email, name, role, credits, subscription_status, subscription_tier, `SELECT id, auth0_id, email, name, picture, role, credits, subscription_status, subscription_tier,
is_active, created_at, last_login is_active, created_at, last_login
FROM users FROM users
${whereClause} ${whereClause}
@ -366,7 +241,7 @@ router.get('/', authenticateToken, requireAdmin, asyncHandler(async (req, res) =
*/ */
router.get('/:userId', authenticateToken, requireAdmin, asyncHandler(async (req, res) => { router.get('/:userId', authenticateToken, requireAdmin, asyncHandler(async (req, res) => {
const user = await dbGet('SELECT * FROM users WHERE id = ?', [req.params.userId]); const user = await dbGet('SELECT * FROM users WHERE id = ?', [req.params.userId]);
if (!user) { if (!user) {
return res.status(404).json(ApiResponse(false, null, 'User not found')); return res.status(404).json(ApiResponse(false, null, 'User not found'));
} }
@ -380,7 +255,7 @@ router.get('/:userId', authenticateToken, requireAdmin, asyncHandler(async (req,
* @access Admin * @access Admin
*/ */
router.put('/:userId', authenticateToken, requireAdmin, asyncHandler(async (req, res) => { router.put('/:userId', authenticateToken, requireAdmin, asyncHandler(async (req, res) => {
const { name, role, credits, subscription_status, subscription_tier, is_active } = req.body; const { name, role, credits, subscription_status, subscription_tier, is_active, email } = req.body;
const updates = []; const updates = [];
const values = []; const values = [];
@ -390,6 +265,7 @@ router.put('/:userId', authenticateToken, requireAdmin, asyncHandler(async (req,
if (subscription_status !== undefined) { updates.push('subscription_status = ?'); values.push(subscription_status); } if (subscription_status !== undefined) { updates.push('subscription_status = ?'); values.push(subscription_status); }
if (subscription_tier !== undefined) { updates.push('subscription_tier = ?'); values.push(subscription_tier); } if (subscription_tier !== undefined) { updates.push('subscription_tier = ?'); values.push(subscription_tier); }
if (is_active !== undefined) { updates.push('is_active = ?'); values.push(is_active ? 1 : 0); } if (is_active !== undefined) { updates.push('is_active = ?'); values.push(is_active ? 1 : 0); }
if (email !== undefined) { updates.push('email = ?'); values.push(email); }
if (updates.length === 0) { if (updates.length === 0) {
return res.status(400).json(ApiResponse(false, null, 'No fields to update')); return res.status(400).json(ApiResponse(false, null, 'No fields to update'));
@ -406,7 +282,7 @@ router.put('/:userId', authenticateToken, requireAdmin, asyncHandler(async (req,
/** /**
* @route DELETE /api/users/:userId * @route DELETE /api/users/:userId
* @desc Delete user by ID (admin) * @desc Delete user by ID (admin) - hard delete
* @access Admin * @access Admin
*/ */
router.delete('/:userId', authenticateToken, requireAdmin, asyncHandler(async (req, res) => { router.delete('/:userId', authenticateToken, requireAdmin, asyncHandler(async (req, res) => {
@ -460,4 +336,29 @@ router.post('/:userId/credits', authenticateToken, requireAdmin, asyncHandler(as
}, 'Credits updated successfully')); }, 'Credits updated successfully'));
})); }));
/**
* @route PUT /api/users/:userId/role
* @desc Change user role (admin)
* @access Admin
*/
router.put('/:userId/role', authenticateToken, requireAdmin, asyncHandler(async (req, res) => {
const { role } = req.body;
if (!role || !['user', 'admin'].includes(role)) {
return res.status(400).json(ApiResponse(false, null, 'Valid role is required (user or admin)'));
}
const result = await dbRun(
'UPDATE users SET role = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[role, req.params.userId]
);
if (result.changes === 0) {
return res.status(404).json(ApiResponse(false, null, 'User not found'));
}
const user = await dbGet('SELECT * FROM users WHERE id = ?', [req.params.userId]);
res.json(ApiResponse(true, { user: sanitizeUser(user) }, 'User role updated successfully'));
}));
export default router; export default router;

View File

@ -37,7 +37,7 @@ export const isValidEmail = (email) => {
*/ */
export const sanitizeUser = (user) => { export const sanitizeUser = (user) => {
if (!user) return null; if (!user) return null;
const { password_hash, ...safeUser } = user; const { password_hash, auth0_id, ...safeUser } = user;
return safeUser; return safeUser;
}; };