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:
parent
55335f14e7
commit
9b4d3242e2
16
.env.example
16
.env.example
@ -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
151
README.md
@ -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
59
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
133
src/index.js
133
src/index.js
@ -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()} ║
|
||||||
╚════════════════════════════════════════════╝
|
╚════════════════════════════════════════════╝
|
||||||
`);
|
`);
|
||||||
|
|||||||
@ -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();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user