v3.0.0: Auth, admin panel, security fixes, per-tab model selector
- Add authMiddleware to all AI/transcribe routes (were unauthenticated) - Add full admin panel: user management, registration toggle, stats - Fix XSS in email verification (escape user.name in HTML) - Fix missing APP_URL fallback in password reset email - Add per-tab model selector (respects OpenRouter/Bedrock/Azure lists) - Fix transcribeAudio to send Authorization header - Fix labs input: textarea instead of single-line input - Add structured logging: audit_log, api_log, access_log tables - Add admin CLI (admin-cli.js) for Docker exec management - Fix duplicate var duration declaration in ai.js catch block - Fix RETURNING check case-sensitivity in database.js
This commit is contained in:
parent
565bec9ca8
commit
ac8e7bb890
32 changed files with 2249 additions and 814 deletions
31
.env.example
31
.env.example
|
|
@ -1,5 +1,28 @@
|
|||
# AI APIs (required)
|
||||
# ============================================================
|
||||
# AI PROVIDER (choose one)
|
||||
# ============================================================
|
||||
|
||||
# Option 1: OpenRouter (default, cheapest, NOT HIPAA)
|
||||
AI_PROVIDER=openrouter
|
||||
OPENROUTER_API_KEY=sk-or-v1-your-key
|
||||
|
||||
# Option 2: AWS Bedrock (HIPAA compliant with BAA)
|
||||
# AI_PROVIDER=bedrock
|
||||
# AWS_BEDROCK_REGION=us-east-1
|
||||
# AWS_ACCESS_KEY_ID=AKIA...
|
||||
# AWS_SECRET_ACCESS_KEY=...
|
||||
# (Or use IAM role — no keys needed if running on EC2/ECS)
|
||||
|
||||
# Option 3: Azure OpenAI (HIPAA compliant with BAA)
|
||||
# AI_PROVIDER=azure
|
||||
# AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com
|
||||
# AZURE_OPENAI_API_KEY=your-key
|
||||
# AZURE_DEPLOYMENT_NAME=gpt-4o-mini
|
||||
# AZURE_OPENAI_API_VERSION=2024-02-01
|
||||
|
||||
# ============================================================
|
||||
# Whisper (always OpenAI for now)
|
||||
# ============================================================
|
||||
OPENAI_API_KEY=sk-your-openai-key
|
||||
|
||||
# Optional
|
||||
|
|
@ -20,3 +43,9 @@ SMTP_FROM=noreply@yourdomain.com
|
|||
|
||||
# Nextcloud (optional)
|
||||
NEXTCLOUD_URL=https://cloud.yourdomain.com
|
||||
|
||||
# ============================================================
|
||||
# DATABASE
|
||||
# ============================================================
|
||||
DATABASE_URL=postgresql://pedscribe:<password>@postgres:5432/pedscribe
|
||||
DB_PASSWORD=pedscribe_secret_change_me
|
||||
|
|
|
|||
|
|
@ -1,19 +1,18 @@
|
|||
FROM node:20-alpine
|
||||
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --production
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /app/data
|
||||
RUN mkdir -p /app/data/logs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s \
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
|
|
|
|||
187
admin-cli.js
Normal file
187
admin-cli.js
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
#!/usr/bin/env node
|
||||
// ============================================================
|
||||
// ADMIN CLI — Command line admin tool
|
||||
// Run inside Docker: docker exec pediatric-ai-scribe node admin-cli.js <command>
|
||||
// ============================================================
|
||||
|
||||
require('dotenv').config();
|
||||
var db = require('./src/db/database');
|
||||
var bcrypt = require('bcryptjs');
|
||||
|
||||
var args = process.argv.slice(2);
|
||||
var command = args[0];
|
||||
|
||||
async function main() {
|
||||
// Verify DB connection before proceeding
|
||||
await db.query('SELECT 1', []);
|
||||
|
||||
switch (command) {
|
||||
|
||||
case 'make-admin': {
|
||||
var email = args[1];
|
||||
if (!email) { console.log('Usage: node admin-cli.js make-admin <email>'); process.exit(1); }
|
||||
var user = await db.get('SELECT id, name, role FROM users WHERE email = ?', [email.toLowerCase()]);
|
||||
if (!user) { console.log('❌ User not found: ' + email); process.exit(1); }
|
||||
await db.run('UPDATE users SET role = ? WHERE id = ?', ['admin', user.id]);
|
||||
console.log('✅ ' + user.name + ' (' + email + ') is now ADMIN');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'remove-admin': {
|
||||
var email = args[1];
|
||||
if (!email) { console.log('Usage: node admin-cli.js remove-admin <email>'); process.exit(1); }
|
||||
var user = await db.get('SELECT id, name, role FROM users WHERE email = ?', [email.toLowerCase()]);
|
||||
if (!user) { console.log('❌ User not found: ' + email); process.exit(1); }
|
||||
await db.run('UPDATE users SET role = ? WHERE id = ?', ['user', user.id]);
|
||||
console.log('✅ ' + user.name + ' (' + email + ') is now regular user');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'verify-user': {
|
||||
var email = args[1];
|
||||
if (!email) { console.log('Usage: node admin-cli.js verify-user <email>'); process.exit(1); }
|
||||
var user = await db.get('SELECT id, name FROM users WHERE email = ?', [email.toLowerCase()]);
|
||||
if (!user) { console.log('❌ User not found: ' + email); process.exit(1); }
|
||||
await db.run('UPDATE users SET email_verified = true, verify_token = NULL WHERE id = ?', [user.id]);
|
||||
console.log('✅ ' + user.name + ' (' + email + ') email verified');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'disable-user': {
|
||||
var email = args[1];
|
||||
if (!email) { console.log('Usage: node admin-cli.js disable-user <email>'); process.exit(1); }
|
||||
var user = await db.get('SELECT id, name FROM users WHERE email = ?', [email.toLowerCase()]);
|
||||
if (!user) { console.log('❌ User not found: ' + email); process.exit(1); }
|
||||
await db.run('UPDATE users SET disabled = true WHERE id = ?', [user.id]);
|
||||
console.log('✅ ' + user.name + ' (' + email + ') DISABLED');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'enable-user': {
|
||||
var email = args[1];
|
||||
if (!email) { console.log('Usage: node admin-cli.js enable-user <email>'); process.exit(1); }
|
||||
var user = await db.get('SELECT id, name FROM users WHERE email = ?', [email.toLowerCase()]);
|
||||
if (!user) { console.log('❌ User not found: ' + email); process.exit(1); }
|
||||
await db.run('UPDATE users SET disabled = false WHERE id = ?', [user.id]);
|
||||
console.log('✅ ' + user.name + ' (' + email + ') ENABLED');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'delete-user': {
|
||||
var email = args[1];
|
||||
if (!email) { console.log('Usage: node admin-cli.js delete-user <email>'); process.exit(1); }
|
||||
var user = await db.get('SELECT id, name FROM users WHERE email = ?', [email.toLowerCase()]);
|
||||
if (!user) { console.log('❌ User not found: ' + email); process.exit(1); }
|
||||
await db.run('DELETE FROM users WHERE id = ?', [user.id]);
|
||||
console.log('✅ ' + user.name + ' (' + email + ') DELETED');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'reset-password': {
|
||||
var email = args[1];
|
||||
var newPass = args[2];
|
||||
if (!email || !newPass) { console.log('Usage: node admin-cli.js reset-password <email> <new-password>'); process.exit(1); }
|
||||
if (newPass.length < 8) { console.log('❌ Password must be 8+ characters'); process.exit(1); }
|
||||
var user = await db.get('SELECT id, name FROM users WHERE email = ?', [email.toLowerCase()]);
|
||||
if (!user) { console.log('❌ User not found: ' + email); process.exit(1); }
|
||||
var hash = await bcrypt.hash(newPass, 12);
|
||||
await db.run('UPDATE users SET password = ? WHERE id = ?', [hash, user.id]);
|
||||
console.log('✅ Password reset for ' + user.name + ' (' + email + ')');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'create-admin': {
|
||||
var email = args[1];
|
||||
var password = args[2];
|
||||
var name = args[3];
|
||||
if (!email || !password || !name) {
|
||||
console.log('Usage: node admin-cli.js create-admin <email> <password> <name>');
|
||||
process.exit(1);
|
||||
}
|
||||
if (password.length < 8) { console.log('❌ Password must be 8+ characters'); process.exit(1); }
|
||||
var existing = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
|
||||
if (existing) { console.log('❌ Email already exists: ' + email); process.exit(1); }
|
||||
var hash = await bcrypt.hash(password, 12);
|
||||
await db.run(
|
||||
'INSERT INTO users (email, password, name, role, email_verified) VALUES (?, ?, ?, ?, true)',
|
||||
[email.toLowerCase(), hash, name, 'admin']
|
||||
);
|
||||
console.log('✅ Admin user created: ' + name + ' (' + email + ')');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'list-users': {
|
||||
var users = await db.all('SELECT id, email, name, role, email_verified, disabled, created_at FROM users ORDER BY id', []);
|
||||
console.log('');
|
||||
console.log('ID | Email | Name | Role | Verified | Disabled');
|
||||
console.log('---+---------------------------+-------------------+-------+----------+---------');
|
||||
users.forEach(function(u) {
|
||||
console.log(
|
||||
String(u.id).padEnd(3) + '| ' +
|
||||
u.email.padEnd(26) + '| ' +
|
||||
(u.name || '').padEnd(18) + '| ' +
|
||||
(u.role || 'user').padEnd(6) + '| ' +
|
||||
(u.email_verified ? '✅' : '❌').padEnd(9) + '| ' +
|
||||
(u.disabled ? '🚫 Yes' : '✅ No')
|
||||
);
|
||||
});
|
||||
console.log('');
|
||||
console.log('Total: ' + users.length + ' users');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'toggle-registration': {
|
||||
var current = await db.getSetting('registration_enabled');
|
||||
var newValue = current === 'false' ? 'true' : 'false';
|
||||
await db.setSetting('registration_enabled', newValue);
|
||||
console.log('✅ Registration is now: ' + (newValue === 'true' ? 'ENABLED' : 'DISABLED'));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stats': {
|
||||
var userCount = await db.get('SELECT COUNT(*) as count FROM users', []);
|
||||
var apiCount = await db.get('SELECT COUNT(*) as count FROM api_log', []);
|
||||
var todayApi = await db.get("SELECT COUNT(*) as count FROM api_log WHERE timestamp > NOW() - INTERVAL '1 day'", []);
|
||||
var regEnabled = await db.getSetting('registration_enabled');
|
||||
|
||||
console.log('');
|
||||
console.log('📊 App Statistics');
|
||||
console.log('─────────────────');
|
||||
console.log('Users: ' + (userCount ? userCount.count : 0));
|
||||
console.log('Total API calls: ' + (apiCount ? apiCount.count : 0));
|
||||
console.log('Today API calls: ' + (todayApi ? todayApi.count : 0));
|
||||
console.log('Registration: ' + (regEnabled !== 'false' ? '✅ Enabled' : '❌ Disabled'));
|
||||
console.log('');
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.log('');
|
||||
console.log('🏥 Pediatric AI Scribe — Admin CLI');
|
||||
console.log('');
|
||||
console.log('Usage: docker exec pediatric-ai-scribe node admin-cli.js <command>');
|
||||
console.log('');
|
||||
console.log('User Management:');
|
||||
console.log(' list-users List all users');
|
||||
console.log(' create-admin <email> <password> <name> Create admin user');
|
||||
console.log(' make-admin <email> Promote user to admin');
|
||||
console.log(' remove-admin <email> Demote admin to user');
|
||||
console.log(' verify-user <email> Verify user email');
|
||||
console.log(' disable-user <email> Disable user account');
|
||||
console.log(' enable-user <email> Enable user account');
|
||||
console.log(' delete-user <email> Delete user permanently');
|
||||
console.log(' reset-password <email> <new-password> Reset user password');
|
||||
console.log('');
|
||||
console.log('App Settings:');
|
||||
console.log(' toggle-registration Toggle registration on/off');
|
||||
console.log(' stats Show app statistics');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch(function(err) {
|
||||
console.error('❌ Error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,13 +1,16 @@
|
|||
services:
|
||||
pediatric-scribe:
|
||||
image: danielonyejesi/pediatric-ai-scribe:latest
|
||||
build: .
|
||||
image: danielonyejesi/pediatric-ai-scribe-v3:latest
|
||||
ports:
|
||||
- "3552:3000"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- scribe-data:/app/data
|
||||
- scribe-logs:/app/data/logs
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
container_name: pediatric-ai-scribe
|
||||
healthcheck:
|
||||
|
|
@ -15,7 +18,25 @@ services:
|
|||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
start_period: 20s
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: pedscribe
|
||||
POSTGRES_USER: pedscribe
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
container_name: pedscribe-db
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U pedscribe"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
scribe-data:
|
||||
pgdata:
|
||||
scribe-logs:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "pediatric-ai-scribe",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"description": "AI-powered pediatric clinical documentation platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
@ -13,16 +13,17 @@
|
|||
"cors": "^2.8.5",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"axios": "^1.7.7",
|
||||
"better-sqlite3": "^11.6.0",
|
||||
"pg": "^8.13.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"speakeasy": "^2.0.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"nodemailer": "^6.9.16",
|
||||
"crypto": "^1.0.1",
|
||||
"@simplewebauthn/server": "^10.0.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"helmet": "^8.0.0",
|
||||
"express-rate-limit": "^7.4.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.700.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--
|
|||
.toast-success{background:var(--green);}.toast-error{background:var(--red);}.toast-info{background:var(--blue);}
|
||||
@keyframes slideIn{from{transform:translateX(100%);opacity:0;}to{transform:translateX(0);opacity:1;}}
|
||||
|
||||
.hidden{display:none !important;}
|
||||
.hidden{display:none;}
|
||||
|
||||
/* RESPONSIVE */
|
||||
@media(max-width:768px){
|
||||
|
|
@ -242,3 +242,28 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--
|
|||
|
||||
.editable-box::-webkit-scrollbar,.output-text::-webkit-scrollbar{width:5px;}
|
||||
.editable-box::-webkit-scrollbar-thumb,.output-text::-webkit-scrollbar-thumb{background:var(--g300);border-radius:3px;}
|
||||
|
||||
/* Per-tab model selector */
|
||||
.demo-field-model { flex: 1; min-width: 180px; }
|
||||
.demo-field-model select { min-width: 180px; max-width: 260px; }
|
||||
|
||||
/* Labs textarea */
|
||||
.visit-labs, .lab-values {
|
||||
width: 100%; padding: 8px 12px; border: 1.5px solid var(--g300);
|
||||
border-radius: 8px; font-size: 13px; font-family: inherit;
|
||||
resize: vertical; margin-top: 8px; line-height: 1.5;
|
||||
}
|
||||
.visit-labs:focus, .lab-values:focus {
|
||||
outline: none; border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-light);
|
||||
}
|
||||
|
||||
/* Admin Panel */
|
||||
.admin-stats{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:12px;}
|
||||
.stat-card{flex:1;min-width:120px;background:white;border-radius:var(--radius);box-shadow:var(--shadow);padding:16px;text-align:center;}
|
||||
.stat-value{font-size:28px;font-weight:700;color:var(--blue);}
|
||||
.stat-label{font-size:12px;color:var(--g500);margin-top:4px;}
|
||||
.admin-table{width:100%;border-collapse:collapse;font-size:13px;}
|
||||
.admin-table th{text-align:left;padding:10px 16px;background:var(--g50);border-bottom:2px solid var(--g200);font-weight:600;color:var(--g600);font-size:12px;text-transform:uppercase;letter-spacing:0.4px;}
|
||||
.admin-table td{padding:10px 16px;border-bottom:1px solid var(--g100);vertical-align:middle;}
|
||||
.admin-table tr:last-child td{border-bottom:none;}
|
||||
.admin-table tr:hover td{background:var(--g50);}
|
||||
|
|
|
|||
|
|
@ -147,6 +147,10 @@
|
|||
<i class="fas fa-baby"></i>
|
||||
<span>Milestones</span>
|
||||
</button>
|
||||
<button class="tab-btn hidden" data-tab="admin" id="admin-tab-btn">
|
||||
<i class="fas fa-user-shield"></i>
|
||||
<span>Admin</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
|
|
@ -175,6 +179,10 @@
|
|||
<option value="ed">Emergency Dept</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="demo-field demo-field-model">
|
||||
<label><i class="fas fa-robot"></i> Model</label>
|
||||
<select class="tab-model-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
|
|
@ -228,6 +236,10 @@
|
|||
<option value="ed">Emergency Dept</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="demo-field demo-field-model">
|
||||
<label><i class="fas fa-robot"></i> Model</label>
|
||||
<select class="tab-model-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
|
|
@ -302,6 +314,10 @@
|
|||
<option value="organSystem">Organ System (ICU)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="demo-field demo-field-model">
|
||||
<label><i class="fas fa-robot"></i> Model</label>
|
||||
<select class="tab-model-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ED Note -->
|
||||
|
|
@ -425,6 +441,10 @@
|
|||
<option value="ed">ED Visit</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="demo-field demo-field-model">
|
||||
<label><i class="fas fa-robot"></i> Model</label>
|
||||
<select class="tab-model-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visits -->
|
||||
|
|
@ -446,7 +466,7 @@
|
|||
<input type="text" class="visit-specialty" placeholder="Specialty (e.g., Endocrinology)">
|
||||
</div>
|
||||
<div class="editable-box visit-content" contenteditable="true" data-placeholder="Paste note here..."></div>
|
||||
<input type="text" class="visit-labs" placeholder="Labs from this visit (optional)">
|
||||
<textarea class="visit-labs" placeholder="Labs from this visit (optional)" rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -459,7 +479,7 @@
|
|||
<div id="cr-labs-container">
|
||||
<div class="lab-entry">
|
||||
<input type="date" class="lab-date">
|
||||
<input type="text" class="lab-values" placeholder="Lab results...">
|
||||
<textarea class="lab-values" placeholder="Lab results..." rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -507,6 +527,10 @@
|
|||
<option value="subjective">Subjective Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="demo-field demo-field-model">
|
||||
<label><i class="fas fa-robot"></i> Model</label>
|
||||
<select class="tab-model-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
|
|
@ -582,6 +606,10 @@
|
|||
<option value="list">Structured List</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="demo-field demo-field-model">
|
||||
<label><i class="fas fa-robot"></i> Model</label>
|
||||
<select class="tab-model-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
|
|
@ -627,6 +655,54 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== TAB: ADMIN ===== -->
|
||||
<section id="admin-tab" class="tab-content">
|
||||
<div class="module-header">
|
||||
<h2><i class="fas fa-user-shield"></i> Admin Panel</h2>
|
||||
<p>Manage users, app settings, and view usage statistics</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="admin-stats" id="admin-stats">
|
||||
<div class="stat-card"><div class="stat-value" id="stat-users">—</div><div class="stat-label">Total Users</div></div>
|
||||
<div class="stat-card"><div class="stat-value" id="stat-api-total">—</div><div class="stat-label">Total API Calls</div></div>
|
||||
<div class="stat-card"><div class="stat-value" id="stat-api-today">—</div><div class="stat-label">API Calls Today</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Registration -->
|
||||
<div class="card">
|
||||
<div class="card-header"><h3><i class="fas fa-door-open"></i> Registration</h3></div>
|
||||
<div style="padding:16px;display:flex;align-items:center;gap:16px;flex-wrap:wrap;">
|
||||
<span id="reg-status-text" style="font-size:13px;color:var(--g600);">Loading...</span>
|
||||
<button id="btn-toggle-reg" class="btn-sm btn-primary">Toggle</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-users"></i> Users</h3>
|
||||
<button id="btn-refresh-users" class="btn-sm btn-ghost"><i class="fas fa-sync-alt"></i> Refresh</button>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="admin-table" id="admin-users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name / Email</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Joined</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="admin-users-body">
|
||||
<tr><td colspan="5" style="text-align:center;color:var(--g400);padding:20px;">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- SETTINGS MODAL -->
|
||||
|
|
@ -718,5 +794,6 @@
|
|||
<script src="/js/soap.js"></script>
|
||||
<script src="/js/milestones.js"></script>
|
||||
<script src="/js/nextcloud.js"></script>
|
||||
<script src="/js/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
231
public/js/admin.js
Normal file
231
public/js/admin.js
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
// ============================================================
|
||||
// ADMIN.JS — Admin panel: users, settings, stats
|
||||
// ============================================================
|
||||
|
||||
(function() {
|
||||
|
||||
var loaded = false;
|
||||
|
||||
// Load admin panel when tab is clicked
|
||||
document.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('.tab-btn');
|
||||
if (btn && btn.getAttribute('data-tab') === 'admin') {
|
||||
if (!loaded) { loadAdmin(); loaded = true; }
|
||||
}
|
||||
// Refresh button
|
||||
if (e.target.closest('#btn-refresh-users')) {
|
||||
loadUsers();
|
||||
}
|
||||
// Toggle registration
|
||||
if (e.target.closest('#btn-toggle-reg')) {
|
||||
toggleRegistration();
|
||||
}
|
||||
});
|
||||
|
||||
function loadAdmin() {
|
||||
loadSettings();
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
// ---- SETTINGS + STATS ----
|
||||
|
||||
function loadSettings() {
|
||||
fetch('/api/admin/settings', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) return;
|
||||
var stats = data.stats || {};
|
||||
var settings = data.settings || {};
|
||||
|
||||
var el = document.getElementById('stat-users');
|
||||
if (el) el.textContent = stats.totalUsers !== undefined ? stats.totalUsers : '—';
|
||||
el = document.getElementById('stat-api-total');
|
||||
if (el) el.textContent = stats.totalApiCalls !== undefined ? stats.totalApiCalls : '—';
|
||||
el = document.getElementById('stat-api-today');
|
||||
if (el) el.textContent = stats.todayApiCalls !== undefined ? stats.todayApiCalls : '—';
|
||||
|
||||
updateRegStatus(settings.registrationEnabled !== false);
|
||||
})
|
||||
.catch(function(err) { console.error('[Admin] Settings load failed:', err); });
|
||||
}
|
||||
|
||||
function updateRegStatus(enabled) {
|
||||
var text = document.getElementById('reg-status-text');
|
||||
var btn = document.getElementById('btn-toggle-reg');
|
||||
if (text) {
|
||||
text.innerHTML = 'Registration is currently <strong style="color:' + (enabled ? 'var(--green)' : 'var(--red)') + '">' + (enabled ? '✅ Enabled' : '❌ Disabled') + '</strong>';
|
||||
}
|
||||
if (btn) btn.textContent = enabled ? 'Disable Registration' : 'Enable Registration';
|
||||
window._adminRegEnabled = enabled;
|
||||
}
|
||||
|
||||
function toggleRegistration() {
|
||||
var newVal = !window._adminRegEnabled;
|
||||
fetch('/api/admin/settings/registration', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ enabled: newVal })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
updateRegStatus(newVal);
|
||||
showToast('Registration ' + (newVal ? 'enabled' : 'disabled'), 'success');
|
||||
} else {
|
||||
showToast(data.error || 'Failed', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function() { showToast('Request failed', 'error'); });
|
||||
}
|
||||
|
||||
// ---- USERS ----
|
||||
|
||||
function loadUsers() {
|
||||
var tbody = document.getElementById('admin-users-body');
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--g400);padding:20px;">Loading...</td></tr>';
|
||||
|
||||
fetch('/api/admin/users', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) { tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--red);padding:20px;">Failed to load users</td></tr>'; return; }
|
||||
renderUsers(data.users || []);
|
||||
})
|
||||
.catch(function() { tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--red);padding:20px;">Request failed</td></tr>'; });
|
||||
}
|
||||
|
||||
function renderUsers(users) {
|
||||
var tbody = document.getElementById('admin-users-body');
|
||||
if (!tbody) return;
|
||||
|
||||
var currentUser = JSON.parse(localStorage.getItem('ped_scribe_user') || '{}');
|
||||
|
||||
if (users.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--g400);padding:20px;">No users found</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = users.map(function(u) {
|
||||
var isSelf = currentUser.id && u.id === currentUser.id;
|
||||
var roleColor = u.role === 'admin' ? 'var(--blue)' : 'var(--g500)';
|
||||
var statusColor = u.disabled ? 'var(--red)' : (u.email_verified ? 'var(--green)' : 'var(--amber)');
|
||||
var statusText = u.disabled ? 'Disabled' : (u.email_verified ? 'Active' : 'Unverified');
|
||||
var joined = u.created_at ? new Date(u.created_at).toLocaleDateString() : '—';
|
||||
|
||||
var actions = [];
|
||||
if (!isSelf) {
|
||||
if (!u.email_verified) {
|
||||
actions.push('<button class="btn-sm btn-primary admin-action" data-action="verify" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Verify</button>');
|
||||
}
|
||||
if (u.disabled) {
|
||||
actions.push('<button class="btn-sm btn-success admin-action" data-action="enable" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Enable</button>');
|
||||
} else {
|
||||
actions.push('<button class="btn-sm btn-ghost admin-action" data-action="disable" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Disable</button>');
|
||||
}
|
||||
if (u.role === 'admin') {
|
||||
actions.push('<button class="btn-sm btn-ghost admin-action" data-action="demote" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Demote</button>');
|
||||
} else {
|
||||
actions.push('<button class="btn-sm btn-primary admin-action" data-action="promote" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Make Admin</button>');
|
||||
}
|
||||
actions.push('<button class="btn-sm btn-ghost admin-action" data-action="reset-pw" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Reset PW</button>');
|
||||
actions.push('<button class="btn-sm admin-action" style="background:var(--red-light);color:var(--red);" data-action="delete" data-id="' + u.id + '" data-email="' + esc(u.email) + '" data-name="' + esc(u.name) + '">Delete</button>');
|
||||
} else {
|
||||
actions.push('<span style="font-size:12px;color:var(--g400);">(you)</span>');
|
||||
}
|
||||
|
||||
return '<tr>' +
|
||||
'<td><div style="font-weight:600;font-size:13px;">' + esc(u.name) + '</div><div style="font-size:11px;color:var(--g500);">' + esc(u.email) + '</div></td>' +
|
||||
'<td><span style="font-size:12px;font-weight:600;color:' + roleColor + ';">' + (u.role || 'user') + '</span></td>' +
|
||||
'<td><span style="font-size:12px;color:' + statusColor + ';">' + statusText + '</span></td>' +
|
||||
'<td style="font-size:12px;color:var(--g500);">' + joined + '</td>' +
|
||||
'<td style="white-space:nowrap;">' + actions.join(' ') + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
|
||||
// Bind action buttons
|
||||
tbody.querySelectorAll('.admin-action').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
handleUserAction(btn.dataset.action, btn.dataset.id, btn.dataset.email, btn.dataset.name);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleUserAction(action, userId, email, name) {
|
||||
switch (action) {
|
||||
|
||||
case 'verify':
|
||||
if (!confirm('Verify email for ' + email + '?')) return;
|
||||
adminPost('/api/admin/users/' + userId + '/verify', {}, function() {
|
||||
showToast(email + ' verified', 'success'); loadUsers();
|
||||
});
|
||||
break;
|
||||
|
||||
case 'disable':
|
||||
if (!confirm('Disable account for ' + email + '?')) return;
|
||||
adminPost('/api/admin/users/' + userId + '/disable', {}, function() {
|
||||
showToast(email + ' disabled', 'info'); loadUsers();
|
||||
});
|
||||
break;
|
||||
|
||||
case 'enable':
|
||||
adminPost('/api/admin/users/' + userId + '/enable', {}, function() {
|
||||
showToast(email + ' enabled', 'success'); loadUsers();
|
||||
});
|
||||
break;
|
||||
|
||||
case 'promote':
|
||||
if (!confirm('Grant admin role to ' + email + '?')) return;
|
||||
adminPost('/api/admin/users/' + userId + '/role', { role: 'admin' }, function() {
|
||||
showToast(email + ' is now admin', 'success'); loadUsers();
|
||||
});
|
||||
break;
|
||||
|
||||
case 'demote':
|
||||
if (!confirm('Remove admin role from ' + email + '?')) return;
|
||||
adminPost('/api/admin/users/' + userId + '/role', { role: 'user' }, function() {
|
||||
showToast(email + ' demoted to user', 'info'); loadUsers();
|
||||
});
|
||||
break;
|
||||
|
||||
case 'reset-pw':
|
||||
var newPw = prompt('New password for ' + email + ' (8+ characters):');
|
||||
if (!newPw) return;
|
||||
if (newPw.length < 8) { showToast('Password must be 8+ characters', 'error'); return; }
|
||||
adminPost('/api/admin/users/' + userId + '/reset-password', { newPassword: newPw }, function() {
|
||||
showToast('Password reset for ' + email, 'success');
|
||||
});
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
if (!confirm('Permanently delete user ' + (name || email) + '?\n\nThis cannot be undone.')) return;
|
||||
fetch('/api/admin/users/' + userId, { method: 'DELETE', headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) { showToast(email + ' deleted', 'info'); loadUsers(); }
|
||||
else showToast(data.error || 'Delete failed', 'error');
|
||||
})
|
||||
.catch(function() { showToast('Request failed', 'error'); });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function adminPost(url, body, onSuccess) {
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) { onSuccess(data); }
|
||||
else showToast(data.error || 'Request failed', 'error');
|
||||
})
|
||||
.catch(function() { showToast('Request failed', 'error'); });
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
if (!str) return '';
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
})();
|
||||
255
public/js/app.js
255
public/js/app.js
|
|
@ -1,61 +1,111 @@
|
|||
// ============================================================
|
||||
// APP.JS — Core utilities, tabs, model selector, refine, speak
|
||||
// APP.JS — Core utilities, tabs, model selector, helpers
|
||||
// ============================================================
|
||||
|
||||
// --- TAB NAVIGATION ---
|
||||
document.querySelectorAll('.tab-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
|
||||
document.querySelectorAll('.tab-content').forEach(function(c) { c.classList.remove('active'); });
|
||||
btn.classList.add('active');
|
||||
document.getElementById(btn.getAttribute('data-tab') + '-tab').classList.add('active');
|
||||
});
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// --- MODEL SELECTOR ---
|
||||
var modelSelect = document.getElementById('global-model-select');
|
||||
var costBadge = document.getElementById('model-cost-badge');
|
||||
|
||||
fetch('/api/models').then(function(r) { return r.json(); }).then(function(data) {
|
||||
if (data.models && modelSelect) {
|
||||
modelSelect.innerHTML = '';
|
||||
data.models.forEach(function(m) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = m.id;
|
||||
var prefix = m.tag === 'FREE' ? '🆓' : m.tag === 'PREMIUM' ? '👑' : m.tag === 'REASONING' ? '🧠' : '⚡';
|
||||
opt.textContent = prefix + ' ' + m.name + ' (' + m.cost + ')';
|
||||
modelSelect.appendChild(opt);
|
||||
// --- TAB NAVIGATION ---
|
||||
document.querySelectorAll('.tab-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
|
||||
document.querySelectorAll('.tab-content').forEach(function(c) { c.classList.remove('active'); });
|
||||
btn.classList.add('active');
|
||||
var tabId = btn.getAttribute('data-tab') + '-tab';
|
||||
var tabEl = document.getElementById(tabId);
|
||||
if (tabEl) tabEl.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// --- MODEL SELECTOR ---
|
||||
var modelSelect = document.getElementById('global-model-select');
|
||||
var costBadge = document.getElementById('model-cost-badge');
|
||||
window._currentModels = [];
|
||||
window._currentProvider = 'openrouter';
|
||||
|
||||
fetch('/api/models')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
window._currentModels = data.models || [];
|
||||
window._currentProvider = data.provider || 'openrouter';
|
||||
|
||||
var categories = { free: '🆓 Free', fast: '⚡ Fast & Cheap', smart: '🧠 Smart', premium: '👑 Premium' };
|
||||
|
||||
function buildModelOptions(selectEl) {
|
||||
selectEl.innerHTML = '';
|
||||
var grouped = {};
|
||||
window._currentModels.forEach(function(m) {
|
||||
var cat = m.category || 'smart';
|
||||
if (!grouped[cat]) grouped[cat] = [];
|
||||
grouped[cat].push(m);
|
||||
});
|
||||
Object.keys(categories).forEach(function(cat) {
|
||||
if (!grouped[cat] || grouped[cat].length === 0) return;
|
||||
var optgroup = document.createElement('optgroup');
|
||||
optgroup.label = categories[cat];
|
||||
grouped[cat].forEach(function(m) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = m.id;
|
||||
opt.textContent = m.name + ' (' + m.cost + ')';
|
||||
optgroup.appendChild(opt);
|
||||
});
|
||||
selectEl.appendChild(optgroup);
|
||||
});
|
||||
}
|
||||
|
||||
if (modelSelect && window._currentModels.length > 0) {
|
||||
buildModelOptions(modelSelect);
|
||||
if (costBadge) costBadge.textContent = window._currentProvider.toUpperCase() + ' | ' + window._currentModels[0].cost;
|
||||
}
|
||||
|
||||
// Populate all per-tab model selectors
|
||||
document.querySelectorAll('.tab-model-select').forEach(function(sel) {
|
||||
buildModelOptions(sel);
|
||||
});
|
||||
})
|
||||
.catch(function(err) { console.warn('Models load failed:', err); });
|
||||
|
||||
if (modelSelect) {
|
||||
modelSelect.addEventListener('change', function() {
|
||||
var m = window._currentModels.find(function(x) { return x.id === modelSelect.value; });
|
||||
if (m && costBadge) costBadge.textContent = window._currentProvider.toUpperCase() + ' | ' + m.cost;
|
||||
showToast('Model: ' + modelSelect.value.split('/').pop(), 'info');
|
||||
});
|
||||
if (costBadge) costBadge.textContent = data.models[0].cost;
|
||||
}
|
||||
}).catch(function() {});
|
||||
|
||||
console.log('✅ App.js DOM ready');
|
||||
|
||||
}); // end DOMContentLoaded
|
||||
|
||||
// ============================================================
|
||||
// GLOBAL FUNCTIONS (must be outside DOMContentLoaded)
|
||||
// ============================================================
|
||||
|
||||
function getSelectedModel() {
|
||||
return modelSelect ? modelSelect.value : 'google/gemini-2.5-flash';
|
||||
// Prefer the active tab's own model selector if present
|
||||
var activeTab = document.querySelector('.tab-content.active');
|
||||
if (activeTab) {
|
||||
var tabSel = activeTab.querySelector('.tab-model-select');
|
||||
if (tabSel && tabSel.value) return tabSel.value;
|
||||
}
|
||||
var sel = document.getElementById('global-model-select');
|
||||
return sel ? sel.value : 'google/gemini-2.5-flash';
|
||||
}
|
||||
|
||||
if (modelSelect) {
|
||||
modelSelect.addEventListener('change', function() {
|
||||
fetch('/api/models').then(function(r) { return r.json(); }).then(function(data) {
|
||||
var m = data.models.find(function(x) { return x.id === modelSelect.value; });
|
||||
if (m && costBadge) costBadge.textContent = m.cost;
|
||||
});
|
||||
showToast('Model: ' + modelSelect.value.split('/').pop(), 'info');
|
||||
});
|
||||
}
|
||||
|
||||
// --- LOADING ---
|
||||
function showLoading(text) {
|
||||
document.getElementById('loading-text').textContent = text || 'Processing...';
|
||||
document.getElementById('loading-overlay').classList.remove('hidden');
|
||||
}
|
||||
function hideLoading() {
|
||||
document.getElementById('loading-overlay').classList.add('hidden');
|
||||
var overlay = document.getElementById('loading-overlay');
|
||||
var textEl = document.getElementById('loading-text');
|
||||
if (textEl) textEl.textContent = text || 'Processing...';
|
||||
if (overlay) { overlay.style.display = 'flex'; overlay.className = overlay.className.replace('hidden', '').trim(); }
|
||||
}
|
||||
|
||||
function hideLoading() {
|
||||
var overlay = document.getElementById('loading-overlay');
|
||||
if (overlay) overlay.style.display = 'none';
|
||||
}
|
||||
|
||||
// --- TOAST ---
|
||||
function showToast(message, type) {
|
||||
var container = document.getElementById('toast-container');
|
||||
if (!container) { console.log('Toast:', type, message); return; }
|
||||
var toast = document.createElement('div');
|
||||
toast.className = 'toast toast-' + (type || 'success');
|
||||
var icon = type === 'error' ? 'exclamation-circle' : type === 'info' ? 'info-circle' : 'check-circle';
|
||||
|
|
@ -64,9 +114,9 @@ function showToast(message, type) {
|
|||
setTimeout(function() { toast.remove(); }, 3500);
|
||||
}
|
||||
|
||||
// --- COPY ---
|
||||
function copyText(elementId) {
|
||||
var el = document.getElementById(elementId);
|
||||
if (!el) return;
|
||||
var text = el.innerText || el.textContent;
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
showToast('Copied!', 'success');
|
||||
|
|
@ -81,7 +131,7 @@ function copyText(elementId) {
|
|||
});
|
||||
}
|
||||
|
||||
// --- SPEAK / STOP ---
|
||||
// Speak / Stop
|
||||
var currentlyReadingId = null;
|
||||
var currentAudio = null;
|
||||
|
||||
|
|
@ -89,14 +139,9 @@ function speakText(elementId) {
|
|||
if (currentlyReadingId === elementId) { stopReading(); return; }
|
||||
stopReading();
|
||||
var el = document.getElementById(elementId);
|
||||
if (!el) return;
|
||||
var text = (el.innerText || el.textContent).trim();
|
||||
if (!text) { showToast('Nothing to read', 'error'); return; }
|
||||
|
||||
var readBtn = findReadButton(elementId);
|
||||
useBrowserTTS(text, elementId, readBtn);
|
||||
}
|
||||
|
||||
function useBrowserTTS(text, elementId, readBtn) {
|
||||
if (!('speechSynthesis' in window)) { showToast('TTS not supported', 'error'); return; }
|
||||
window.speechSynthesis.cancel();
|
||||
var utter = new SpeechSynthesisUtterance(text);
|
||||
|
|
@ -104,7 +149,8 @@ function useBrowserTTS(text, elementId, readBtn) {
|
|||
utter.onend = function() { stopReading(); };
|
||||
utter.onerror = function() { stopReading(); };
|
||||
currentlyReadingId = elementId;
|
||||
setReadButtonActive(readBtn, true);
|
||||
var btn = findReadButton(elementId);
|
||||
if (btn) { btn.classList.add('btn-reading'); btn.innerHTML = '<i class="fas fa-stop"></i> Stop'; }
|
||||
window.speechSynthesis.speak(utter);
|
||||
showToast('Reading — click Stop to end', 'info');
|
||||
}
|
||||
|
|
@ -113,7 +159,8 @@ function stopReading() {
|
|||
if ('speechSynthesis' in window) window.speechSynthesis.cancel();
|
||||
if (currentAudio) { currentAudio.pause(); currentAudio = null; }
|
||||
if (currentlyReadingId) {
|
||||
setReadButtonActive(findReadButton(currentlyReadingId), false);
|
||||
var btn = findReadButton(currentlyReadingId);
|
||||
if (btn) { btn.classList.remove('btn-reading'); btn.innerHTML = '<i class="fas fa-volume-up"></i> Read'; }
|
||||
currentlyReadingId = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -131,23 +178,17 @@ function findReadButton(elementId) {
|
|||
return null;
|
||||
}
|
||||
|
||||
function setReadButtonActive(btn, active) {
|
||||
if (!btn) return;
|
||||
if (active) { btn.classList.add('btn-reading'); btn.innerHTML = '<i class="fas fa-stop"></i> Stop'; }
|
||||
else { btn.classList.remove('btn-reading'); btn.innerHTML = '<i class="fas fa-volume-up"></i> Read'; }
|
||||
}
|
||||
|
||||
// --- TIMER ---
|
||||
// Timer
|
||||
function createTimer(el) {
|
||||
var s = 0, iv = null;
|
||||
return {
|
||||
start: function() { s = 0; this.update(); var self = this; iv = setInterval(function() { s++; self.update(); }, 1000); },
|
||||
stop: function() { if (iv) { clearInterval(iv); iv = null; } return s; },
|
||||
update: function() { el.textContent = String(Math.floor(s/60)).padStart(2,'0') + ':' + String(s%60).padStart(2,'0'); }
|
||||
update: function() { el.textContent = String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0'); }
|
||||
};
|
||||
}
|
||||
|
||||
// --- AUDIO RECORDER ---
|
||||
// Audio Recorder
|
||||
function AudioRecorder() { this.mediaRecorder = null; this.chunks = []; this.stream = null; }
|
||||
AudioRecorder.prototype.start = function() {
|
||||
var self = this; self.chunks = [];
|
||||
|
|
@ -173,89 +214,74 @@ AudioRecorder.prototype.stop = function() {
|
|||
});
|
||||
};
|
||||
|
||||
// --- TRANSCRIBE HELPER ---
|
||||
function transcribeAudio(blob) {
|
||||
var formData = new FormData();
|
||||
formData.append('audio', blob, 'audio.webm');
|
||||
return fetch('/api/transcribe', { method: 'POST', body: formData })
|
||||
.then(function(r) { return r.json(); });
|
||||
return fetch('/api/transcribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + (window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '') },
|
||||
body: formData
|
||||
}).then(function(r) { return r.json(); });
|
||||
}
|
||||
|
||||
// --- REFINE HELPER ---
|
||||
function refineDocument(outputElementId, inputElementId) {
|
||||
var doc = document.getElementById(outputElementId).innerText.trim();
|
||||
var instructions = document.getElementById(inputElementId).value.trim();
|
||||
if (!doc) { showToast('No document to refine', 'error'); return; }
|
||||
if (!instructions) { showToast('Enter instructions for AI', 'error'); return; }
|
||||
|
||||
var doc = document.getElementById(outputElementId);
|
||||
var input = document.getElementById(inputElementId);
|
||||
if (!doc || !input) return;
|
||||
var docText = doc.innerText.trim();
|
||||
var instructions = input.value.trim();
|
||||
if (!docText) { showToast('No document', 'error'); return; }
|
||||
if (!instructions) { showToast('Enter instructions', 'error'); return; }
|
||||
showLoading('Refining...');
|
||||
|
||||
fetch('/api/refine', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ currentDocument: doc, instructions: instructions, model: getSelectedModel() })
|
||||
method: 'POST', headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ currentDocument: docText, instructions: instructions, model: getSelectedModel() })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideLoading();
|
||||
if (data.success) {
|
||||
document.getElementById(outputElementId).textContent = data.refined;
|
||||
document.getElementById(inputElementId).value = '';
|
||||
showToast('Refined!', 'success');
|
||||
} else {
|
||||
showToast(data.error || 'Refine failed', 'error');
|
||||
}
|
||||
if (data.success) { doc.textContent = data.refined; input.value = ''; showToast('Refined!', 'success'); }
|
||||
else showToast(data.error || 'Failed', 'error');
|
||||
})
|
||||
.catch(function(err) { hideLoading(); showToast('Error: ' + err.message, 'error'); });
|
||||
.catch(function(err) { hideLoading(); showToast(err.message, 'error'); });
|
||||
}
|
||||
|
||||
// --- SHORTEN HELPER ---
|
||||
function shortenDocument(outputElementId) {
|
||||
var doc = document.getElementById(outputElementId).innerText.trim();
|
||||
if (!doc) { showToast('No document to shorten', 'error'); return; }
|
||||
|
||||
var doc = document.getElementById(outputElementId);
|
||||
if (!doc) return;
|
||||
var text = doc.innerText.trim();
|
||||
if (!text) { showToast('No document', 'error'); return; }
|
||||
showLoading('Shortening...');
|
||||
|
||||
fetch('/api/shorten', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ document: doc, model: getSelectedModel() })
|
||||
method: 'POST', headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ document: text, model: getSelectedModel() })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideLoading();
|
||||
if (data.success) {
|
||||
document.getElementById(outputElementId).textContent = data.shortened;
|
||||
showToast('Shortened!', 'success');
|
||||
} else {
|
||||
showToast(data.error || 'Failed', 'error');
|
||||
}
|
||||
if (data.success) { doc.textContent = data.shortened; showToast('Shortened!', 'success'); }
|
||||
else showToast(data.error || 'Failed', 'error');
|
||||
})
|
||||
.catch(function(err) { hideLoading(); showToast('Error: ' + err.message, 'error'); });
|
||||
.catch(function(err) { hideLoading(); showToast(err.message, 'error'); });
|
||||
}
|
||||
|
||||
// --- EXPORT NEXTCLOUD HELPER ---
|
||||
function exportToNextcloud(elementId, docType) {
|
||||
var text = document.getElementById(elementId).innerText.trim();
|
||||
var el = document.getElementById(elementId);
|
||||
if (!el) return;
|
||||
var text = el.innerText.trim();
|
||||
if (!text) { showToast('Nothing to export', 'error'); return; }
|
||||
|
||||
var date = new Date().toISOString().split('T')[0];
|
||||
var filename = docType + '-' + date + '-' + Date.now();
|
||||
|
||||
fetch('/api/nextcloud/export', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ content: text, filename: filename, type: docType })
|
||||
method: 'POST', headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ content: text, filename: docType + '-' + Date.now(), type: docType })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) showToast(data.message, 'success');
|
||||
else showToast(data.error || 'Export failed', 'error');
|
||||
})
|
||||
.catch(function(err) { showToast('Nextcloud not connected. Go to Settings.', 'error'); });
|
||||
.catch(function() { showToast('Nextcloud not connected', 'error'); });
|
||||
}
|
||||
|
||||
// --- WEB SPEECH RECOGNITION HELPER ---
|
||||
function createSpeechRecognition() {
|
||||
if (!(window.SpeechRecognition || window.webkitSpeechRecognition)) return null;
|
||||
var SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
|
|
@ -266,12 +292,9 @@ function createSpeechRecognition() {
|
|||
return rec;
|
||||
}
|
||||
|
||||
console.log('✅ App.js loaded');
|
||||
// Register Service Worker for PWA
|
||||
// PWA Service Worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').then(function() {
|
||||
console.log('✅ PWA: Service Worker registered');
|
||||
}).catch(function(err) {
|
||||
console.log('PWA: SW registration failed', err);
|
||||
});
|
||||
navigator.serviceWorker.register('/sw.js').catch(function() {});
|
||||
}
|
||||
|
||||
console.log('✅ App.js loaded');
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
// ============================================================
|
||||
// AUTH.JS — Login, Register, 2FA, Session Management
|
||||
// ============================================================
|
||||
(function() {
|
||||
|
||||
// Wait for DOM to be fully ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
var authScreen = document.getElementById('auth-screen');
|
||||
var mainApp = document.getElementById('main-app');
|
||||
var loginForm = document.getElementById('login-form');
|
||||
|
|
@ -12,192 +15,102 @@
|
|||
var TOKEN_KEY = 'ped_scribe_token';
|
||||
var USER_KEY = 'ped_scribe_user';
|
||||
|
||||
// Check existing session
|
||||
var savedToken = localStorage.getItem(TOKEN_KEY);
|
||||
var savedUser = localStorage.getItem(USER_KEY);
|
||||
console.log('[Auth] Initializing...');
|
||||
console.log('[Auth] authScreen:', !!authScreen);
|
||||
console.log('[Auth] mainApp:', !!mainApp);
|
||||
console.log('[Auth] loginForm:', !!loginForm);
|
||||
console.log('[Auth] registerForm:', !!registerForm);
|
||||
|
||||
if (savedToken && savedUser) {
|
||||
try {
|
||||
var user = JSON.parse(savedUser);
|
||||
showApp(user, savedToken);
|
||||
} catch(e) {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
}
|
||||
// Make sure forms are visible/hidden correctly on load
|
||||
showLoginForm();
|
||||
|
||||
// Check for saved session
|
||||
var savedToken = localStorage.getItem(TOKEN_KEY);
|
||||
if (savedToken) {
|
||||
console.log('[Auth] Found saved token, verifying...');
|
||||
fetch('/api/auth/me', {
|
||||
headers: { 'Authorization': 'Bearer ' + savedToken }
|
||||
})
|
||||
.then(function(r) {
|
||||
if (r.ok) return r.json();
|
||||
throw new Error('expired');
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data && data.user) {
|
||||
console.log('[Auth] Token valid, logging in as:', data.user.email);
|
||||
enterApp(data.user, savedToken);
|
||||
} else {
|
||||
console.log('[Auth] Token invalid');
|
||||
clearSession();
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
console.log('[Auth] Token expired or invalid');
|
||||
clearSession();
|
||||
});
|
||||
}
|
||||
|
||||
// Form toggles
|
||||
document.getElementById('show-register').addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
loginForm.classList.add('hidden');
|
||||
registerForm.classList.remove('hidden');
|
||||
forgotForm.classList.add('hidden');
|
||||
});
|
||||
// ---- HELPER FUNCTIONS ----
|
||||
|
||||
document.getElementById('show-login').addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
loginForm.classList.remove('hidden');
|
||||
registerForm.classList.add('hidden');
|
||||
forgotForm.classList.add('hidden');
|
||||
});
|
||||
function showLoginForm() {
|
||||
if (loginForm) loginForm.style.display = 'block';
|
||||
if (registerForm) registerForm.style.display = 'none';
|
||||
if (forgotForm) forgotForm.style.display = 'none';
|
||||
}
|
||||
|
||||
document.getElementById('show-login-2').addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
loginForm.classList.remove('hidden');
|
||||
registerForm.classList.add('hidden');
|
||||
forgotForm.classList.add('hidden');
|
||||
});
|
||||
function showRegisterForm() {
|
||||
if (loginForm) loginForm.style.display = 'none';
|
||||
if (registerForm) registerForm.style.display = 'block';
|
||||
if (forgotForm) forgotForm.style.display = 'none';
|
||||
}
|
||||
|
||||
document.getElementById('show-forgot').addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
loginForm.classList.add('hidden');
|
||||
registerForm.classList.add('hidden');
|
||||
forgotForm.classList.remove('hidden');
|
||||
});
|
||||
function showForgotForm() {
|
||||
if (loginForm) loginForm.style.display = 'none';
|
||||
if (registerForm) registerForm.style.display = 'none';
|
||||
if (forgotForm) forgotForm.style.display = 'block';
|
||||
}
|
||||
|
||||
// Login
|
||||
loginForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
var email = document.getElementById('login-email').value;
|
||||
var password = document.getElementById('login-password').value;
|
||||
var totpCode = document.getElementById('login-totp').value;
|
||||
function enterApp(user, token) {
|
||||
window.AUTH_TOKEN = token;
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
|
||||
showLoading('Signing in...');
|
||||
if (authScreen) authScreen.style.display = 'none';
|
||||
if (mainApp) {
|
||||
mainApp.style.display = 'block';
|
||||
mainApp.className = mainApp.className.replace('hidden', '').trim();
|
||||
}
|
||||
if (userName) userName.textContent = user.name || user.email;
|
||||
|
||||
fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email, password: password, totpCode: totpCode || undefined })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideLoading();
|
||||
if (data.requires2FA) {
|
||||
document.getElementById('totp-group').classList.remove('hidden');
|
||||
showToast('Enter your 2FA code', 'info');
|
||||
return;
|
||||
}
|
||||
if (data.success) {
|
||||
localStorage.setItem(TOKEN_KEY, data.token);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
|
||||
showApp(data.user, data.token);
|
||||
showToast('Welcome back, ' + data.user.name + '!', 'success');
|
||||
// Show admin tab for admins
|
||||
var adminTabBtn = document.getElementById('admin-tab-btn');
|
||||
if (adminTabBtn) {
|
||||
if (user.role === 'admin') {
|
||||
adminTabBtn.classList.remove('hidden');
|
||||
} else {
|
||||
showToast(data.error || 'Login failed', 'error');
|
||||
adminTabBtn.classList.add('hidden');
|
||||
}
|
||||
})
|
||||
// Handle email verification needed
|
||||
if (data.needsVerification) {
|
||||
showToast(data.message || 'Please verify your email first', 'error');
|
||||
|
||||
// Show resend option
|
||||
var loginLinks = loginForm.querySelector('.auth-links');
|
||||
if (!document.getElementById('resend-verify-link')) {
|
||||
var resendLink = document.createElement('a');
|
||||
resendLink.href = '#';
|
||||
resendLink.id = 'resend-verify-link';
|
||||
resendLink.textContent = 'Resend verification email';
|
||||
resendLink.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
var email = document.getElementById('login-email').value;
|
||||
if (!email) { showToast('Enter your email first', 'error'); return; }
|
||||
fetch('/api/auth/resend-verification', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) { showToast(d.message || 'Verification email sent!', 'success'); });
|
||||
});
|
||||
loginLinks.appendChild(resendLink);
|
||||
}
|
||||
return;
|
||||
}
|
||||
.catch(function(err) {
|
||||
hideLoading();
|
||||
showToast('Error: ' + err.message, 'error');
|
||||
});
|
||||
});
|
||||
|
||||
// Register
|
||||
registerForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
var name = document.getElementById('reg-name').value;
|
||||
var email = document.getElementById('reg-email').value;
|
||||
var password = document.getElementById('reg-password').value;
|
||||
|
||||
if (password.length < 8) {
|
||||
showToast('Password must be at least 8 characters', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading('Creating account...');
|
||||
console.log('[Auth] ✅ Entered app as:', user.email, 'Role:', user.role);
|
||||
}
|
||||
|
||||
fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name, email: email, password: password })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideLoading();
|
||||
if (data.success) {
|
||||
localStorage.setItem(TOKEN_KEY, data.token);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
|
||||
showApp(data.user, data.token);
|
||||
showToast('Account created! Welcome, ' + data.user.name, 'success');
|
||||
} else {
|
||||
showToast(data.error || 'Registration failed', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
hideLoading();
|
||||
showToast('Error: ' + err.message, 'error');
|
||||
});
|
||||
});
|
||||
function exitApp() {
|
||||
clearSession();
|
||||
if (authScreen) authScreen.style.display = 'flex';
|
||||
if (mainApp) mainApp.style.display = 'none';
|
||||
var adminTabBtn = document.getElementById('admin-tab-btn');
|
||||
if (adminTabBtn) adminTabBtn.classList.add('hidden');
|
||||
showLoginForm();
|
||||
}
|
||||
|
||||
// Forgot password
|
||||
forgotForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
var email = document.getElementById('forgot-email').value;
|
||||
|
||||
showLoading('Sending reset link...');
|
||||
|
||||
fetch('/api/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideLoading();
|
||||
showToast(data.message || 'If account exists, reset email sent', 'success');
|
||||
loginForm.classList.remove('hidden');
|
||||
forgotForm.classList.add('hidden');
|
||||
})
|
||||
.catch(function(err) {
|
||||
hideLoading();
|
||||
showToast('Error: ' + err.message, 'error');
|
||||
});
|
||||
});
|
||||
|
||||
// Logout
|
||||
document.getElementById('btn-logout').addEventListener('click', function() {
|
||||
function clearSession() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
authScreen.classList.remove('hidden');
|
||||
mainApp.classList.add('hidden');
|
||||
showToast('Logged out', 'info');
|
||||
});
|
||||
|
||||
function showApp(user, token) {
|
||||
authScreen.classList.add('hidden');
|
||||
mainApp.classList.remove('hidden');
|
||||
if (userName) userName.textContent = user.name || user.email;
|
||||
window.AUTH_TOKEN = token;
|
||||
window.AUTH_TOKEN = null;
|
||||
}
|
||||
|
||||
// Helper: add auth header to all API calls
|
||||
// Global auth header helper
|
||||
window.getAuthHeaders = function() {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -205,90 +118,287 @@
|
|||
};
|
||||
};
|
||||
|
||||
// Settings modal
|
||||
document.getElementById('btn-settings').addEventListener('click', function() {
|
||||
document.getElementById('settings-modal').classList.remove('hidden');
|
||||
load2FAStatus();
|
||||
loadNextcloudStatus();
|
||||
// ---- FORM TOGGLE LINKS ----
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
var target = e.target;
|
||||
|
||||
// Handle link clicks by ID
|
||||
if (target.id === 'show-register' || target.closest('#show-register')) {
|
||||
e.preventDefault();
|
||||
showRegisterForm();
|
||||
}
|
||||
if (target.id === 'show-login' || target.closest('#show-login')) {
|
||||
e.preventDefault();
|
||||
showLoginForm();
|
||||
}
|
||||
if (target.id === 'show-login-2' || target.closest('#show-login-2')) {
|
||||
e.preventDefault();
|
||||
showLoginForm();
|
||||
}
|
||||
if (target.id === 'show-forgot' || target.closest('#show-forgot')) {
|
||||
e.preventDefault();
|
||||
showForgotForm();
|
||||
}
|
||||
|
||||
// Logout button
|
||||
if (target.id === 'btn-logout' || target.closest('#btn-logout')) {
|
||||
e.preventDefault();
|
||||
exitApp();
|
||||
showToast('Logged out', 'info');
|
||||
}
|
||||
|
||||
// Settings button
|
||||
if (target.id === 'btn-settings' || target.closest('#btn-settings')) {
|
||||
e.preventDefault();
|
||||
var modal = document.getElementById('settings-modal');
|
||||
if (modal) {
|
||||
modal.style.display = 'flex';
|
||||
modal.className = modal.className.replace('hidden', '').trim();
|
||||
load2FAStatus();
|
||||
if (typeof loadNextcloudStatus === 'function') loadNextcloudStatus();
|
||||
}
|
||||
}
|
||||
|
||||
// Close settings
|
||||
if (target.id === 'close-settings' || target.closest('#close-settings')) {
|
||||
e.preventDefault();
|
||||
var modal = document.getElementById('settings-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
}
|
||||
|
||||
// Setup 2FA
|
||||
if (target.id === 'btn-setup-2fa' || target.closest('#btn-setup-2fa')) {
|
||||
e.preventDefault();
|
||||
fetch('/api/auth/setup-2fa', { method: 'POST', headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
var qr = document.getElementById('2fa-qr');
|
||||
var secret = document.getElementById('2fa-secret');
|
||||
var setup = document.getElementById('2fa-setup');
|
||||
if (qr) qr.src = data.qrCode;
|
||||
if (secret) secret.textContent = data.secret;
|
||||
if (setup) { setup.style.display = 'block'; setup.className = setup.className.replace('hidden', '').trim(); }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Verify 2FA
|
||||
if (target.id === 'btn-verify-2fa' || target.closest('#btn-verify-2fa')) {
|
||||
e.preventDefault();
|
||||
var code = document.getElementById('2fa-verify-code').value;
|
||||
fetch('/api/auth/verify-2fa', {
|
||||
method: 'POST', headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ code: code })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
showToast('2FA enabled!', 'success');
|
||||
var setup = document.getElementById('2fa-setup');
|
||||
if (setup) setup.style.display = 'none';
|
||||
load2FAStatus();
|
||||
} else {
|
||||
showToast(data.error || 'Invalid code', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Disable 2FA
|
||||
if (target.id === 'btn-disable-2fa' || target.closest('#btn-disable-2fa')) {
|
||||
e.preventDefault();
|
||||
var pw = prompt('Enter password to disable 2FA:');
|
||||
if (!pw) return;
|
||||
fetch('/api/auth/disable-2fa', {
|
||||
method: 'POST', headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ password: pw })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) { showToast('2FA disabled', 'info'); load2FAStatus(); }
|
||||
else showToast(data.error || 'Failed', 'error');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('close-settings').addEventListener('click', function() {
|
||||
document.getElementById('settings-modal').classList.add('hidden');
|
||||
});
|
||||
// ---- LOGIN FORM SUBMIT ----
|
||||
if (loginForm) {
|
||||
loginForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 2FA Setup
|
||||
var email = document.getElementById('login-email').value.trim();
|
||||
var password = document.getElementById('login-password').value;
|
||||
var totpEl = document.getElementById('login-totp');
|
||||
var totpCode = totpEl ? totpEl.value.trim() : '';
|
||||
|
||||
if (!email || !password) {
|
||||
showToast('Enter email and password', 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('[Auth] Attempting login for:', email);
|
||||
showLoading('Signing in...');
|
||||
|
||||
var body = { email: email, password: password };
|
||||
if (totpCode) body.totpCode = totpCode;
|
||||
|
||||
fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideLoading();
|
||||
console.log('[Auth] Login response:', JSON.stringify(data).substring(0, 200));
|
||||
|
||||
if (data.requires2FA) {
|
||||
var grp = document.getElementById('totp-group');
|
||||
if (grp) { grp.style.display = 'block'; grp.className = grp.className.replace('hidden', '').trim(); }
|
||||
showToast('Enter your 2FA code', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.needsVerification) {
|
||||
showToast('Verify your email first. Check inbox.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.success && data.token && data.user) {
|
||||
enterApp(data.user, data.token);
|
||||
showToast('Welcome, ' + data.user.name + '!', 'success');
|
||||
} else {
|
||||
showToast(data.error || 'Login failed', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
hideLoading();
|
||||
console.error('[Auth] Login error:', err);
|
||||
showToast('Connection error', 'error');
|
||||
});
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// ---- REGISTER FORM SUBMIT ----
|
||||
if (registerForm) {
|
||||
registerForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
var name = document.getElementById('reg-name').value.trim();
|
||||
var email = document.getElementById('reg-email').value.trim();
|
||||
var password = document.getElementById('reg-password').value;
|
||||
|
||||
if (!name || !email || !password) {
|
||||
showToast('Fill all fields', 'error');
|
||||
return false;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
showToast('Password must be 8+ characters', 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('[Auth] Registering:', email);
|
||||
showLoading('Creating account...');
|
||||
|
||||
fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name, email: email, password: password })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideLoading();
|
||||
console.log('[Auth] Register response:', JSON.stringify(data).substring(0, 200));
|
||||
|
||||
if (data.success && data.token && data.user) {
|
||||
enterApp(data.user, data.token);
|
||||
showToast(data.message || 'Account created!', 'success');
|
||||
} else if (data.success && data.needsVerification) {
|
||||
showToast(data.message || 'Check email to verify', 'success');
|
||||
showLoginForm();
|
||||
} else {
|
||||
showToast(data.error || 'Registration failed', 'error');
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
hideLoading();
|
||||
console.error('[Auth] Register error:', err);
|
||||
showToast('Connection error', 'error');
|
||||
});
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// ---- FORGOT PASSWORD SUBMIT ----
|
||||
if (forgotForm) {
|
||||
forgotForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
var email = document.getElementById('forgot-email').value.trim();
|
||||
if (!email) { showToast('Enter email', 'error'); return false; }
|
||||
|
||||
showLoading('Sending...');
|
||||
|
||||
fetch('/api/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideLoading();
|
||||
showToast(data.message || 'Check your email', 'success');
|
||||
showLoginForm();
|
||||
})
|
||||
.catch(function(err) {
|
||||
hideLoading();
|
||||
showToast('Error', 'error');
|
||||
});
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 2FA STATUS ----
|
||||
function load2FAStatus() {
|
||||
fetch('/api/auth/me', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.user) {
|
||||
var status = document.getElementById('2fa-status');
|
||||
var setupBtn = document.getElementById('btn-setup-2fa');
|
||||
var disableBtn = document.getElementById('btn-disable-2fa');
|
||||
if (data.user.totp_enabled) {
|
||||
status.textContent = 'Status: ✅ Enabled';
|
||||
status.style.color = '#10b981';
|
||||
setupBtn.classList.add('hidden');
|
||||
disableBtn.classList.remove('hidden');
|
||||
} else {
|
||||
status.textContent = 'Status: ❌ Not enabled';
|
||||
status.style.color = '#ef4444';
|
||||
setupBtn.classList.remove('hidden');
|
||||
disableBtn.classList.add('hidden');
|
||||
}
|
||||
if (!data || !data.user) return;
|
||||
var status = document.getElementById('2fa-status');
|
||||
var setupBtn = document.getElementById('btn-setup-2fa');
|
||||
var disableBtn = document.getElementById('btn-disable-2fa');
|
||||
|
||||
if (data.user.totp_enabled) {
|
||||
if (status) { status.textContent = 'Status: ✅ Enabled'; status.style.color = '#10b981'; }
|
||||
if (setupBtn) setupBtn.style.display = 'none';
|
||||
if (disableBtn) disableBtn.style.display = 'inline-flex';
|
||||
} else {
|
||||
if (status) { status.textContent = 'Status: ❌ Not enabled'; status.style.color = '#ef4444'; }
|
||||
if (setupBtn) setupBtn.style.display = 'inline-flex';
|
||||
if (disableBtn) disableBtn.style.display = 'none';
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(function() {});
|
||||
}
|
||||
|
||||
document.getElementById('btn-setup-2fa').addEventListener('click', function() {
|
||||
fetch('/api/auth/setup-2fa', { method: 'POST', headers: getAuthHeaders() })
|
||||
// Check registration status
|
||||
fetch('/api/auth/registration-status')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
document.getElementById('2fa-qr').src = data.qrCode;
|
||||
document.getElementById('2fa-secret').textContent = data.secret;
|
||||
document.getElementById('2fa-setup').classList.remove('hidden');
|
||||
if (!data.registrationEnabled) {
|
||||
var link = document.getElementById('show-register');
|
||||
if (link) link.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('btn-verify-2fa').addEventListener('click', function() {
|
||||
var code = document.getElementById('2fa-verify-code').value;
|
||||
fetch('/api/auth/verify-2fa', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ code: code })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
showToast('2FA enabled!', 'success');
|
||||
document.getElementById('2fa-setup').classList.add('hidden');
|
||||
load2FAStatus();
|
||||
} else {
|
||||
showToast(data.error || 'Invalid code', 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
.catch(function() {});
|
||||
|
||||
document.getElementById('btn-disable-2fa').addEventListener('click', function() {
|
||||
var pw = prompt('Enter your password to disable 2FA:');
|
||||
if (!pw) return;
|
||||
fetch('/api/auth/disable-2fa', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ password: pw })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
showToast('2FA disabled', 'info');
|
||||
load2FAStatus();
|
||||
} else {
|
||||
showToast(data.error || 'Failed', 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log('✅ Auth module loaded');
|
||||
})();
|
||||
console.log('[Auth] ✅ Module ready');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
'<input type="text" class="visit-specialist" placeholder="Specialist name">' +
|
||||
'<input type="text" class="visit-specialty" placeholder="Specialty (e.g., Endocrinology)"></div>' +
|
||||
'<div class="editable-box visit-content" contenteditable="true" data-placeholder="Paste note here..."></div>' +
|
||||
'<input type="text" class="visit-labs" placeholder="Labs from this visit (optional)"></div>';
|
||||
'<textarea class="visit-labs" placeholder="Labs from this visit (optional)" rows="3"></textarea></div>';
|
||||
visitsContainer.appendChild(card);
|
||||
card.querySelector('.remove-visit-btn').addEventListener('click', function() { card.remove(); });
|
||||
});
|
||||
|
|
@ -34,7 +34,7 @@
|
|||
addLabBtn.addEventListener('click', function() {
|
||||
var entry = document.createElement('div');
|
||||
entry.className = 'lab-entry';
|
||||
entry.innerHTML = '<input type="date" class="lab-date"><input type="text" class="lab-values" placeholder="Lab results...">' +
|
||||
entry.innerHTML = '<input type="date" class="lab-date"><textarea class="lab-values" placeholder="Lab results..." rows="3"></textarea>' +
|
||||
'<button class="btn-sm btn-ghost" onclick="this.parentElement.remove()"><i class="fas fa-trash"></i></button>';
|
||||
labsContainer.appendChild(entry);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@
|
|||
addLabBtn.addEventListener('click', function() {
|
||||
var entry = document.createElement('div');
|
||||
entry.className = 'lab-entry';
|
||||
entry.innerHTML = '<input type="date" class="lab-date"><input type="text" class="lab-values" placeholder="Lab results...">' +
|
||||
entry.innerHTML = '<input type="date" class="lab-date"><textarea class="lab-values" placeholder="Lab results..." rows="3"></textarea>' +
|
||||
'<button class="btn-sm btn-ghost" onclick="this.parentElement.remove()"><i class="fas fa-trash"></i></button>';
|
||||
labsContainer.appendChild(entry);
|
||||
});
|
||||
|
|
|
|||
40
public/sw.js
40
public/sw.js
|
|
@ -1,37 +1,19 @@
|
|||
var CACHE_NAME = 'pedscribe-v2';
|
||||
var urlsToCache = [
|
||||
'/',
|
||||
'/css/styles.css',
|
||||
'/js/app.js',
|
||||
'/js/auth.js',
|
||||
'/js/milestonesData.js',
|
||||
'/js/milestones.js',
|
||||
'/js/liveEncounter.js',
|
||||
'/js/voiceDictation.js',
|
||||
'/js/hospitalCourse.js',
|
||||
'/js/chartReview.js',
|
||||
'/js/soap.js',
|
||||
'/js/nextcloud.js',
|
||||
'/manifest.json'
|
||||
];
|
||||
// Minimal service worker — pass everything through, cache nothing
|
||||
self.addEventListener('install', function() {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('install', function(event) {
|
||||
self.addEventListener('activate', function(event) {
|
||||
// Clear all old caches
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then(function(cache) {
|
||||
return cache.addAll(urlsToCache);
|
||||
caches.keys().then(function(names) {
|
||||
return Promise.all(
|
||||
names.map(function(name) { return caches.delete(name); })
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', function(event) {
|
||||
// Network first for API calls, cache first for static assets
|
||||
if (event.request.url.includes('/api/')) {
|
||||
event.respondWith(fetch(event.request));
|
||||
} else {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then(function(response) {
|
||||
return response || fetch(event.request);
|
||||
})
|
||||
);
|
||||
}
|
||||
event.respondWith(fetch(event.request));
|
||||
});
|
||||
|
|
|
|||
43
server.js
43
server.js
|
|
@ -6,32 +6,22 @@ const rateLimit = require('express-rate-limit');
|
|||
const cookieParser = require('cookie-parser');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const loggingMiddleware = require('./src/middleware/logging');
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
|
||||
// Security
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: false,
|
||||
crossOriginEmbedderPolicy: false
|
||||
}));
|
||||
app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false }));
|
||||
app.use(cors());
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
|
||||
// Rate limiting
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 1 * 60 * 1000,
|
||||
max: 60,
|
||||
message: { error: 'Too many requests, slow down' }
|
||||
});
|
||||
app.use('/api/', apiLimiter);
|
||||
|
||||
// Static files (login page accessible without auth)
|
||||
app.use('/api/', rateLimit({ windowMs: 60000, max: 60, message: { error: 'Too many requests' } }));
|
||||
app.use(loggingMiddleware);
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// Routes
|
||||
app.use('/api/auth', require('./src/routes/auth'));
|
||||
app.use('/api/admin', require('./src/routes/admin'));
|
||||
app.use('/api', require('./src/routes/transcribe'));
|
||||
app.use('/api', require('./src/routes/hpi'));
|
||||
app.use('/api', require('./src/routes/hospitalCourse'));
|
||||
|
|
@ -41,31 +31,32 @@ app.use('/api', require('./src/routes/soap'));
|
|||
app.use('/api', require('./src/routes/tts'));
|
||||
app.use('/api', require('./src/routes/nextcloud'));
|
||||
app.use('/api', require('./src/routes/refine'));
|
||||
// Models endpoint
|
||||
const { AVAILABLE_MODELS } = require('./src/utils/models');
|
||||
app.get('/api/models', (req, res) => res.json({ models: AVAILABLE_MODELS }));
|
||||
app.use('/api', require('./src/routes/logs'));
|
||||
|
||||
// Health
|
||||
const { getAvailableModels, activeProvider: modelsProvider } = require('./src/utils/models');
|
||||
app.get('/api/models', (req, res) => res.json({ models: getAvailableModels(), provider: modelsProvider }));
|
||||
|
||||
const { activeProvider } = require('./src/utils/ai');
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'running',
|
||||
status: 'running', version: '2.1.0', provider: activeProvider,
|
||||
timestamp: new Date().toISOString(),
|
||||
openrouter: process.env.OPENROUTER_API_KEY ? 'configured' : 'missing',
|
||||
bedrock: process.env.AWS_BEDROCK_REGION ? 'configured' : 'not configured',
|
||||
azure: process.env.AZURE_OPENAI_ENDPOINT ? 'configured' : 'not configured',
|
||||
whisper: process.env.OPENAI_API_KEY ? 'configured' : 'missing'
|
||||
});
|
||||
});
|
||||
|
||||
// SPA fallback
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||
});
|
||||
app.get('*', (req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
server.listen(PORT, () => {
|
||||
console.log('');
|
||||
console.log('==========================================');
|
||||
console.log('🏥 PEDIATRIC AI SCRIBE v2.0');
|
||||
console.log('🏥 PEDIATRIC AI SCRIBE v2.1');
|
||||
console.log('==========================================');
|
||||
console.log(`🌐 http://localhost:${PORT}`);
|
||||
console.log('🌐 http://localhost:' + PORT);
|
||||
console.log('🤖 Provider: ' + activeProvider);
|
||||
console.log('==========================================');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,47 +1,173 @@
|
|||
const Database = require('better-sqlite3');
|
||||
const { Pool } = require('pg');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DB_PATH = path.join(__dirname, '../../data/scribe.db');
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma('journal_mode = WAL');
|
||||
const DATABASE_URL = process.env.DATABASE_URL || 'postgresql://pedscribe:pedscribe_secret_change_me@localhost:5432/pedscribe';
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
email_verified INTEGER DEFAULT 0,
|
||||
verify_token TEXT,
|
||||
verify_expires INTEGER,
|
||||
totp_secret TEXT,
|
||||
totp_enabled INTEGER DEFAULT 0,
|
||||
passkey_credentials TEXT,
|
||||
nextcloud_url TEXT,
|
||||
nextcloud_user TEXT,
|
||||
nextcloud_token TEXT,
|
||||
nextcloud_folder TEXT DEFAULT '/PediatricScribe',
|
||||
reset_token TEXT,
|
||||
reset_expires INTEGER,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
const pool = new Pool({
|
||||
connectionString: DATABASE_URL,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000
|
||||
});
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
action TEXT NOT NULL,
|
||||
details TEXT,
|
||||
ip_address TEXT,
|
||||
timestamp TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
pool.query('SELECT NOW()')
|
||||
.then(function() { console.log('✅ PostgreSQL: connected'); })
|
||||
.catch(function(err) { console.error('❌ PostgreSQL: connection failed:', err.message); });
|
||||
|
||||
// Add columns if upgrading from v1
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0"); } catch(e) {}
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN verify_token TEXT"); } catch(e) {}
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN verify_expires INTEGER"); } catch(e) {}
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN nextcloud_folder TEXT DEFAULT '/PediatricScribe'"); } catch(e) {}
|
||||
async function initDatabase() {
|
||||
var client = await pool.connect();
|
||||
try {
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
role TEXT DEFAULT 'user',
|
||||
email_verified BOOLEAN DEFAULT false,
|
||||
verify_token TEXT,
|
||||
verify_expires BIGINT,
|
||||
totp_secret TEXT,
|
||||
totp_enabled BOOLEAN DEFAULT false,
|
||||
passkey_credentials TEXT,
|
||||
nextcloud_url TEXT,
|
||||
nextcloud_user TEXT,
|
||||
nextcloud_token TEXT,
|
||||
nextcloud_folder TEXT DEFAULT '/PediatricScribe',
|
||||
reset_token TEXT,
|
||||
reset_expires BIGINT,
|
||||
disabled BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
action TEXT NOT NULL,
|
||||
category TEXT DEFAULT 'general',
|
||||
details TEXT,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
model_used TEXT,
|
||||
tokens_used INTEGER,
|
||||
duration_ms INTEGER,
|
||||
status TEXT DEFAULT 'success',
|
||||
timestamp TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
method TEXT,
|
||||
status_code INTEGER,
|
||||
request_size INTEGER,
|
||||
response_size INTEGER,
|
||||
model_used TEXT,
|
||||
tokens_input INTEGER,
|
||||
tokens_output INTEGER,
|
||||
cost_estimate REAL,
|
||||
duration_ms INTEGER,
|
||||
ip_address TEXT,
|
||||
error TEXT,
|
||||
timestamp TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS access_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
action TEXT NOT NULL,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
success BOOLEAN DEFAULT true,
|
||||
timestamp TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_user ON api_log(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_ts ON api_log(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_access_ts ON access_log(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
`);
|
||||
|
||||
// Add columns if upgrading
|
||||
var migrations = [
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT DEFAULT 'user'",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS disabled BOOLEAN DEFAULT false"
|
||||
];
|
||||
for (var i = 0; i < migrations.length; i++) {
|
||||
try { await client.query(migrations[i]); } catch(e) {}
|
||||
}
|
||||
|
||||
// Initialize default settings
|
||||
await client.query(`
|
||||
INSERT INTO app_settings (key, value) VALUES ('registration_enabled', 'true')
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`);
|
||||
|
||||
console.log('✅ Database tables: ready');
|
||||
} catch (err) {
|
||||
console.error('❌ Database init error:', err.message);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
initDatabase();
|
||||
|
||||
// Query helpers
|
||||
var db = {
|
||||
get: async function(sql, params) {
|
||||
var result = await pool.query(convertPlaceholders(sql), params);
|
||||
return result.rows[0] || null;
|
||||
},
|
||||
all: async function(sql, params) {
|
||||
var result = await pool.query(convertPlaceholders(sql), params);
|
||||
return result.rows;
|
||||
},
|
||||
run: async function(sql, params) {
|
||||
var pgSql = convertPlaceholders(sql);
|
||||
if (sql.trim().toUpperCase().startsWith('INSERT') && pgSql.toUpperCase().indexOf('RETURNING') === -1) {
|
||||
pgSql += ' RETURNING id';
|
||||
}
|
||||
var result = await pool.query(pgSql, params);
|
||||
return {
|
||||
lastInsertRowid: result.rows[0] ? result.rows[0].id : null,
|
||||
changes: result.rowCount
|
||||
};
|
||||
},
|
||||
query: async function(sql, params) {
|
||||
return pool.query(convertPlaceholders(sql), params);
|
||||
},
|
||||
// Settings helpers
|
||||
getSetting: async function(key) {
|
||||
var row = await pool.query('SELECT value FROM app_settings WHERE key = $1', [key]);
|
||||
return row.rows[0] ? row.rows[0].value : null;
|
||||
},
|
||||
setSetting: async function(key, value) {
|
||||
await pool.query(
|
||||
'INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, NOW()) ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()',
|
||||
[key, value]
|
||||
);
|
||||
},
|
||||
pool: pool
|
||||
};
|
||||
|
||||
function convertPlaceholders(sql) {
|
||||
var index = 0;
|
||||
return sql.replace(/\?/g, function() {
|
||||
index++;
|
||||
return '$' + index;
|
||||
});
|
||||
}
|
||||
|
||||
console.log('✅ Database initialized');
|
||||
module.exports = db;
|
||||
|
|
|
|||
|
|
@ -3,11 +3,10 @@ const db = require('../db/database');
|
|||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
|
||||
|
||||
function authMiddleware(req, res, next) {
|
||||
// Get token from header or cookie
|
||||
let token = null;
|
||||
async function authMiddleware(req, res, next) {
|
||||
var token = null;
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
var authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
token = authHeader.substring(7);
|
||||
} else if (req.cookies && req.cookies.token) {
|
||||
|
|
@ -19,13 +18,17 @@ function authMiddleware(req, res, next) {
|
|||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
const user = db.prepare('SELECT id, email, name, totp_enabled FROM users WHERE id = ?').get(decoded.userId);
|
||||
var decoded = jwt.verify(token, JWT_SECRET);
|
||||
var user = await db.get('SELECT id, email, name, role, totp_enabled, disabled FROM users WHERE id = ?', [decoded.userId]);
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
if (user.disabled) {
|
||||
return res.status(403).json({ error: 'Account disabled. Contact administrator.' });
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (err) {
|
||||
|
|
@ -33,12 +36,12 @@ function authMiddleware(req, res, next) {
|
|||
}
|
||||
}
|
||||
|
||||
function optionalAuth(req, res, next) {
|
||||
try {
|
||||
authMiddleware(req, res, next);
|
||||
} catch (e) {
|
||||
next();
|
||||
// Admin-only middleware — use after authMiddleware
|
||||
async function adminMiddleware(req, res, next) {
|
||||
if (!req.user || req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { authMiddleware, optionalAuth, JWT_SECRET };
|
||||
module.exports = { authMiddleware, adminMiddleware, JWT_SECRET };
|
||||
|
|
|
|||
46
src/middleware/logging.js
Normal file
46
src/middleware/logging.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// ============================================================
|
||||
// LOGGING MIDDLEWARE — Logs all API calls
|
||||
// ============================================================
|
||||
|
||||
var logger = require('../utils/logger');
|
||||
|
||||
function loggingMiddleware(req, res, next) {
|
||||
var startTime = Date.now();
|
||||
|
||||
// Capture original json method
|
||||
var originalJson = res.json.bind(res);
|
||||
|
||||
res.json = function(data) {
|
||||
var duration = Date.now() - startTime;
|
||||
|
||||
// Log API calls (skip static, health, models)
|
||||
if (req.path.startsWith('/api/') &&
|
||||
req.path !== '/api/health' &&
|
||||
req.path !== '/api/models' &&
|
||||
req.method !== 'GET') {
|
||||
|
||||
var userId = req.user ? req.user.id : null;
|
||||
var responseStr = '';
|
||||
try { responseStr = JSON.stringify(data); } catch(e) { responseStr = ''; }
|
||||
|
||||
logger.apiCall(userId, req.path, {
|
||||
method: req.method,
|
||||
statusCode: res.statusCode,
|
||||
requestSize: parseInt(req.headers['content-length'] || 0),
|
||||
responseSize: responseStr.length,
|
||||
model: (data && data.model) || (req.body && req.body.model) || null,
|
||||
tokensInput: (data && data.usage && data.usage.prompt_tokens) ? data.usage.prompt_tokens : 0,
|
||||
tokensOutput: (data && data.usage && data.usage.completion_tokens) ? data.usage.completion_tokens : 0,
|
||||
duration: duration,
|
||||
ip: req.ip || (req.connection && req.connection.remoteAddress) || null,
|
||||
error: (data && data.error) || null
|
||||
});
|
||||
}
|
||||
|
||||
return originalJson(data);
|
||||
};
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = loggingMiddleware;
|
||||
241
src/routes/admin.js
Normal file
241
src/routes/admin.js
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
// ============================================================
|
||||
// ADMIN ROUTES — User management, settings
|
||||
// ============================================================
|
||||
|
||||
var express = require('express');
|
||||
var router = express.Router();
|
||||
var bcrypt = require('bcryptjs');
|
||||
var db = require('../db/database');
|
||||
var { authMiddleware, adminMiddleware } = require('../middleware/auth');
|
||||
var logger = require('../utils/logger');
|
||||
|
||||
// All admin routes require auth + admin role
|
||||
router.use(authMiddleware);
|
||||
router.use(adminMiddleware);
|
||||
|
||||
// ============================================================
|
||||
// GET ALL USERS
|
||||
// ============================================================
|
||||
router.get('/users', async function(req, res) {
|
||||
try {
|
||||
var users = await db.all(
|
||||
'SELECT id, email, name, role, email_verified, totp_enabled, disabled, created_at FROM users ORDER BY created_at DESC',
|
||||
[]
|
||||
);
|
||||
res.json({ success: true, users: users });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// GET SINGLE USER DETAILS
|
||||
// ============================================================
|
||||
router.get('/users/:id', async function(req, res) {
|
||||
try {
|
||||
var user = await db.get(
|
||||
'SELECT id, email, name, role, email_verified, totp_enabled, disabled, nextcloud_url, created_at, updated_at FROM users WHERE id = ?',
|
||||
[req.params.id]
|
||||
);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
// Get user stats
|
||||
var apiCount = await db.get('SELECT COUNT(*) as count FROM api_log WHERE user_id = ?', [req.params.id]);
|
||||
var lastLogin = await db.get("SELECT timestamp FROM audit_log WHERE user_id = ? AND action = 'login' ORDER BY timestamp DESC LIMIT 1", [req.params.id]);
|
||||
|
||||
user.api_calls = apiCount ? parseInt(apiCount.count) : 0;
|
||||
user.last_login = lastLogin ? lastLogin.timestamp : null;
|
||||
|
||||
res.json({ success: true, user: user });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// VERIFY USER EMAIL (admin override)
|
||||
// ============================================================
|
||||
router.post('/users/:id/verify', async function(req, res) {
|
||||
try {
|
||||
var user = await db.get('SELECT id, email, name FROM users WHERE id = ?', [req.params.id]);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
await db.run('UPDATE users SET email_verified = true, verify_token = NULL, verify_expires = NULL WHERE id = ?', [user.id]);
|
||||
|
||||
logger.audit(req.user.id, 'admin_verify_user', 'Verified user: ' + user.email, req, { category: 'admin' });
|
||||
|
||||
res.json({ success: true, message: user.email + ' verified' });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// DISABLE USER
|
||||
// ============================================================
|
||||
router.post('/users/:id/disable', async function(req, res) {
|
||||
try {
|
||||
var userId = parseInt(req.params.id);
|
||||
|
||||
// Prevent disabling yourself
|
||||
if (userId === req.user.id) {
|
||||
return res.status(400).json({ error: 'Cannot disable your own account' });
|
||||
}
|
||||
|
||||
var user = await db.get('SELECT id, email, name FROM users WHERE id = ?', [userId]);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
await db.run('UPDATE users SET disabled = true WHERE id = ?', [userId]);
|
||||
|
||||
logger.audit(req.user.id, 'admin_disable_user', 'Disabled user: ' + user.email, req, { category: 'admin' });
|
||||
|
||||
res.json({ success: true, message: user.email + ' disabled' });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// ENABLE USER
|
||||
// ============================================================
|
||||
router.post('/users/:id/enable', async function(req, res) {
|
||||
try {
|
||||
var user = await db.get('SELECT id, email, name FROM users WHERE id = ?', [req.params.id]);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
await db.run('UPDATE users SET disabled = false WHERE id = ?', [user.id]);
|
||||
|
||||
logger.audit(req.user.id, 'admin_enable_user', 'Enabled user: ' + user.email, req, { category: 'admin' });
|
||||
|
||||
res.json({ success: true, message: user.email + ' enabled' });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// SET USER ROLE (admin/user)
|
||||
// ============================================================
|
||||
router.post('/users/:id/role', async function(req, res) {
|
||||
try {
|
||||
var userId = parseInt(req.params.id);
|
||||
var { role } = req.body;
|
||||
|
||||
if (!role || (role !== 'admin' && role !== 'user')) {
|
||||
return res.status(400).json({ error: 'Role must be "admin" or "user"' });
|
||||
}
|
||||
|
||||
// Prevent removing your own admin
|
||||
if (userId === req.user.id && role !== 'admin') {
|
||||
return res.status(400).json({ error: 'Cannot remove your own admin role' });
|
||||
}
|
||||
|
||||
var user = await db.get('SELECT id, email FROM users WHERE id = ?', [userId]);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
await db.run('UPDATE users SET role = ? WHERE id = ?', [role, userId]);
|
||||
|
||||
logger.audit(req.user.id, 'admin_change_role', 'Set ' + user.email + ' role to ' + role, req, { category: 'admin' });
|
||||
|
||||
res.json({ success: true, message: user.email + ' is now ' + role });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// DELETE USER
|
||||
// ============================================================
|
||||
router.delete('/users/:id', async function(req, res) {
|
||||
try {
|
||||
var userId = parseInt(req.params.id);
|
||||
|
||||
if (userId === req.user.id) {
|
||||
return res.status(400).json({ error: 'Cannot delete your own account' });
|
||||
}
|
||||
|
||||
var user = await db.get('SELECT id, email, name FROM users WHERE id = ?', [userId]);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
// Delete user (audit/api logs keep user_id as NULL due to ON DELETE SET NULL)
|
||||
await db.run('DELETE FROM users WHERE id = ?', [userId]);
|
||||
|
||||
logger.audit(req.user.id, 'admin_delete_user', 'Deleted user: ' + user.email + ' (' + user.name + ')', req, { category: 'admin' });
|
||||
|
||||
res.json({ success: true, message: user.email + ' deleted' });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// RESET USER PASSWORD (admin override)
|
||||
// ============================================================
|
||||
router.post('/users/:id/reset-password', async function(req, res) {
|
||||
try {
|
||||
var { newPassword } = req.body;
|
||||
if (!newPassword || newPassword.length < 8) return res.status(400).json({ error: 'Password must be 8+ characters' });
|
||||
|
||||
var user = await db.get('SELECT id, email FROM users WHERE id = ?', [req.params.id]);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
var hash = await bcrypt.hash(newPassword, 12);
|
||||
await db.run('UPDATE users SET password = ?, reset_token = NULL, reset_expires = NULL WHERE id = ?', [hash, user.id]);
|
||||
|
||||
logger.audit(req.user.id, 'admin_reset_password', 'Reset password for: ' + user.email, req, { category: 'admin' });
|
||||
|
||||
res.json({ success: true, message: 'Password reset for ' + user.email });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// TOGGLE REGISTRATION
|
||||
// ============================================================
|
||||
router.post('/settings/registration', async function(req, res) {
|
||||
try {
|
||||
var { enabled } = req.body;
|
||||
var value = enabled ? 'true' : 'false';
|
||||
|
||||
await db.setSetting('registration_enabled', value);
|
||||
|
||||
logger.audit(req.user.id, 'admin_toggle_registration', 'Registration ' + (enabled ? 'enabled' : 'disabled'), req, { category: 'admin' });
|
||||
|
||||
res.json({ success: true, registrationEnabled: enabled, message: 'Registration ' + (enabled ? 'enabled' : 'disabled') });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// GET APP SETTINGS
|
||||
// ============================================================
|
||||
router.get('/settings', async function(req, res) {
|
||||
try {
|
||||
var regEnabled = await db.getSetting('registration_enabled');
|
||||
var userCount = await db.get('SELECT COUNT(*) as count FROM users', []);
|
||||
var apiCount = await db.get('SELECT COUNT(*) as count FROM api_log', []);
|
||||
var todayApiCount = await db.get("SELECT COUNT(*) as count FROM api_log WHERE timestamp > NOW() - INTERVAL '1 day'", []);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
settings: {
|
||||
registrationEnabled: regEnabled !== 'false'
|
||||
},
|
||||
stats: {
|
||||
totalUsers: userCount ? parseInt(userCount.count) : 0,
|
||||
totalApiCalls: apiCount ? parseInt(apiCount.count) : 0,
|
||||
todayApiCalls: todayApiCount ? parseInt(todayApiCount.count) : 0
|
||||
}
|
||||
});
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// GET ALL LOGS (admin view)
|
||||
// ============================================================
|
||||
router.get('/logs/all', async function(req, res) {
|
||||
try {
|
||||
var limit = parseInt(req.query.limit) || 100;
|
||||
var category = req.query.category;
|
||||
|
||||
var sql = 'SELECT a.*, u.email as user_email, u.name as user_name FROM audit_log a LEFT JOIN users u ON a.user_id = u.id';
|
||||
var params = [];
|
||||
|
||||
if (category) {
|
||||
sql += ' WHERE a.category = ?';
|
||||
params.push(category);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY a.timestamp DESC LIMIT ?';
|
||||
params.push(limit);
|
||||
|
||||
var logs = await db.all(sql, params);
|
||||
res.json({ success: true, logs: logs });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -8,33 +8,21 @@ const crypto = require('crypto');
|
|||
const db = require('../db/database');
|
||||
const { JWT_SECRET, authMiddleware } = require('../middleware/auth');
|
||||
|
||||
// ============================================================
|
||||
// EMAIL HELPER
|
||||
// ============================================================
|
||||
// Email helper
|
||||
async function sendEmail(to, subject, html) {
|
||||
if (!process.env.SMTP_HOST) {
|
||||
console.log('[Email] SMTP not configured. Would send to:', to);
|
||||
console.log('[Email] Subject:', subject);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const nodemailer = require('nodemailer');
|
||||
const transporter = nodemailer.createTransport({
|
||||
var nodemailer = require('nodemailer');
|
||||
var transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT) || 587,
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: process.env.SMTP_FROM || process.env.SMTP_USER,
|
||||
to: to,
|
||||
subject: subject,
|
||||
html: html
|
||||
});
|
||||
|
||||
console.log('[Email] Sent to:', to);
|
||||
await transporter.sendMail({ from: process.env.SMTP_FROM || process.env.SMTP_USER, to: to, subject: subject, html: html });
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[Email] Failed:', err.message);
|
||||
|
|
@ -43,60 +31,62 @@ async function sendEmail(to, subject, html) {
|
|||
}
|
||||
|
||||
// ============================================================
|
||||
// REGISTER (with email verification)
|
||||
// REGISTER (checks if registration is enabled)
|
||||
// ============================================================
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const { email, password, name } = req.body;
|
||||
// Check if registration is enabled
|
||||
var regEnabled = await db.getSetting('registration_enabled');
|
||||
if (regEnabled === 'false') {
|
||||
return res.status(403).json({ error: 'Registration is currently disabled. Contact an administrator.' });
|
||||
}
|
||||
|
||||
var { email, password, name } = req.body;
|
||||
if (!email || !password || !name) return res.status(400).json({ error: 'All fields required' });
|
||||
if (password.length < 8) return res.status(400).json({ error: 'Password must be 8+ characters' });
|
||||
|
||||
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email.toLowerCase());
|
||||
var existing = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
|
||||
if (existing) return res.status(400).json({ error: 'Email already registered' });
|
||||
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
const verifyToken = crypto.randomBytes(32).toString('hex');
|
||||
const verifyExpires = Date.now() + 24 * 60 * 60 * 1000; // 24 hours
|
||||
var hash = await bcrypt.hash(password, 12);
|
||||
var verifyToken = crypto.randomBytes(32).toString('hex');
|
||||
var verifyExpires = Date.now() + 24 * 60 * 60 * 1000;
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO users (email, password, name, verify_token, verify_expires, email_verified) VALUES (?, ?, ?, ?, ?, 0)'
|
||||
).run(email.toLowerCase(), hash, name, verifyToken, verifyExpires);
|
||||
// First user becomes admin automatically
|
||||
var userCount = await db.get('SELECT COUNT(*) as count FROM users', []);
|
||||
var role = (userCount && parseInt(userCount.count) === 0) ? 'admin' : 'user';
|
||||
|
||||
// Send verification email
|
||||
const verifyUrl = `${process.env.APP_URL || 'http://localhost:3000'}/api/auth/verify-email?token=${verifyToken}`;
|
||||
|
||||
await sendEmail(email, 'Verify your email - Pediatric AI Scribe',
|
||||
`<div style="font-family:sans-serif;max-width:500px;margin:0 auto;padding:20px;">
|
||||
<h2 style="color:#2563eb;">Welcome to Pediatric AI Scribe!</h2>
|
||||
<p>Hi ${name},</p>
|
||||
<p>Please verify your email address by clicking the button below:</p>
|
||||
<p style="text-align:center;margin:30px 0;">
|
||||
<a href="${verifyUrl}" style="background:#2563eb;color:white;padding:12px 30px;border-radius:8px;text-decoration:none;font-weight:600;">Verify Email</a>
|
||||
</p>
|
||||
<p style="color:#6b7280;font-size:13px;">Or copy this link: ${verifyUrl}</p>
|
||||
<p style="color:#6b7280;font-size:13px;">This link expires in 24 hours.</p>
|
||||
</div>`
|
||||
var result = await db.run(
|
||||
'INSERT INTO users (email, password, name, role, verify_token, verify_expires, email_verified) VALUES (?, ?, ?, ?, ?, ?, false)',
|
||||
[email.toLowerCase(), hash, name, role, verifyToken, verifyExpires]
|
||||
);
|
||||
|
||||
db.prepare('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)').run(result.lastInsertRowid, 'register', req.ip);
|
||||
var userId = result.lastInsertRowid;
|
||||
|
||||
// If SMTP not configured, auto-verify (for development)
|
||||
var verifyUrl = (process.env.APP_URL || 'http://localhost:3000') + '/api/auth/verify-email?token=' + verifyToken;
|
||||
await sendEmail(email, 'Verify your email - Pediatric AI Scribe',
|
||||
'<div style="font-family:sans-serif;max-width:500px;margin:0 auto;padding:20px;">' +
|
||||
'<h2 style="color:#2563eb;">Welcome to Pediatric AI Scribe!</h2>' +
|
||||
'<p>Hi ' + name + ',</p><p>Verify your email:</p>' +
|
||||
'<p style="text-align:center;margin:30px 0;"><a href="' + verifyUrl + '" style="background:#2563eb;color:white;padding:12px 30px;border-radius:8px;text-decoration:none;font-weight:600;">Verify Email</a></p>' +
|
||||
'<p style="color:#6b7280;font-size:13px;">Expires in 24 hours.</p></div>'
|
||||
);
|
||||
|
||||
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
|
||||
[userId, 'register', req.ip, role === 'admin' ? 'First user — auto admin' : 'standard user']);
|
||||
|
||||
// Auto-verify if no SMTP
|
||||
if (!process.env.SMTP_HOST) {
|
||||
db.prepare('UPDATE users SET email_verified = 1, verify_token = NULL WHERE id = ?').run(result.lastInsertRowid);
|
||||
const token = jwt.sign({ userId: result.lastInsertRowid }, JWT_SECRET, { expiresIn: '7d' });
|
||||
await db.run('UPDATE users SET email_verified = true, verify_token = NULL WHERE id = ?', [userId]);
|
||||
var token = jwt.sign({ userId: userId }, JWT_SECRET, { expiresIn: '7d' });
|
||||
return res.json({
|
||||
success: true,
|
||||
token,
|
||||
user: { id: result.lastInsertRowid, email: email.toLowerCase(), name, email_verified: 1 },
|
||||
message: 'Account created (auto-verified, SMTP not configured)'
|
||||
success: true, token: token,
|
||||
user: { id: userId, email: email.toLowerCase(), name: name, role: role, email_verified: true },
|
||||
message: role === 'admin' ? 'Account created as ADMIN (first user). Auto-verified.' : 'Account created (auto-verified).'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
needsVerification: true,
|
||||
message: 'Account created! Check your email for verification link.'
|
||||
});
|
||||
res.json({ success: true, needsVerification: true, message: 'Check your email for verification link.' + (role === 'admin' ? ' You are the first user and have admin privileges.' : '') });
|
||||
} catch (err) {
|
||||
console.error('Register error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
|
|
@ -106,177 +96,149 @@ router.post('/register', async (req, res) => {
|
|||
// ============================================================
|
||||
// VERIFY EMAIL
|
||||
// ============================================================
|
||||
router.get('/verify-email', (req, res) => {
|
||||
const { token } = req.query;
|
||||
if (!token) return res.status(400).send('Missing verification token');
|
||||
|
||||
const user = db.prepare('SELECT id, name FROM users WHERE verify_token = ? AND verify_expires > ?').get(token, Date.now());
|
||||
|
||||
if (!user) {
|
||||
return res.send(`
|
||||
<html><body style="font-family:sans-serif;text-align:center;padding:60px;">
|
||||
<h2 style="color:#ef4444;">❌ Invalid or Expired Link</h2>
|
||||
<p>This verification link is invalid or has expired.</p>
|
||||
<p><a href="${process.env.APP_URL || '/'}">Go to app</a></p>
|
||||
</body></html>
|
||||
`);
|
||||
}
|
||||
|
||||
db.prepare('UPDATE users SET email_verified = 1, verify_token = NULL, verify_expires = NULL WHERE id = ?').run(user.id);
|
||||
db.prepare('INSERT INTO audit_log (user_id, action) VALUES (?, ?)').run(user.id, 'email_verified');
|
||||
|
||||
res.send(`
|
||||
<html><body style="font-family:sans-serif;text-align:center;padding:60px;">
|
||||
<h2 style="color:#10b981;">✅ Email Verified!</h2>
|
||||
<p>Welcome, ${user.name}! Your account is now active.</p>
|
||||
<p><a href="${process.env.APP_URL || '/'}" style="background:#2563eb;color:white;padding:12px 30px;border-radius:8px;text-decoration:none;font-weight:600;">Open App</a></p>
|
||||
</body></html>
|
||||
`);
|
||||
router.get('/verify-email', async (req, res) => {
|
||||
try {
|
||||
var token = req.query.token;
|
||||
if (!token) return res.status(400).send('Missing token');
|
||||
var user = await db.get('SELECT id, name FROM users WHERE verify_token = ? AND verify_expires > ?', [token, Date.now()]);
|
||||
if (!user) {
|
||||
return res.send('<html><body style="font-family:sans-serif;text-align:center;padding:60px;"><h2 style="color:#ef4444;">Invalid or Expired Link</h2><p><a href="' + (process.env.APP_URL || '/') + '">Go to app</a></p></body></html>');
|
||||
}
|
||||
await db.run('UPDATE users SET email_verified = true, verify_token = NULL, verify_expires = NULL WHERE id = ?', [user.id]);
|
||||
await db.run('INSERT INTO audit_log (user_id, action) VALUES (?, ?)', [user.id, 'email_verified']);
|
||||
var safeName = user.name.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
res.send('<html><body style="font-family:sans-serif;text-align:center;padding:60px;"><h2 style="color:#10b981;">✅ Email Verified!</h2><p>Welcome, ' + safeName + '!</p><p><a href="' + (process.env.APP_URL || '/') + '" style="background:#2563eb;color:white;padding:12px 30px;border-radius:8px;text-decoration:none;">Open App</a></p></body></html>');
|
||||
} catch (err) { res.status(500).send('Error: ' + err.message); }
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// RESEND VERIFICATION
|
||||
// ============================================================
|
||||
router.post('/resend-verification', async (req, res) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
const user = db.prepare('SELECT id, name, email_verified FROM users WHERE email = ?').get(email.toLowerCase());
|
||||
|
||||
var user = await db.get('SELECT id, name, email_verified FROM users WHERE email = ?', [req.body.email.toLowerCase()]);
|
||||
if (!user) return res.json({ success: true, message: 'If account exists, verification email sent' });
|
||||
if (user.email_verified) return res.json({ success: true, message: 'Email already verified. You can log in.' });
|
||||
|
||||
const verifyToken = crypto.randomBytes(32).toString('hex');
|
||||
const verifyExpires = Date.now() + 24 * 60 * 60 * 1000;
|
||||
|
||||
db.prepare('UPDATE users SET verify_token = ?, verify_expires = ? WHERE id = ?').run(verifyToken, verifyExpires, user.id);
|
||||
|
||||
const verifyUrl = `${process.env.APP_URL || 'http://localhost:3000'}/api/auth/verify-email?token=${verifyToken}`;
|
||||
await sendEmail(email, 'Verify your email - Pediatric AI Scribe',
|
||||
`<p>Hi ${user.name},</p><p>Click to verify: <a href="${verifyUrl}">${verifyUrl}</a></p><p>Expires in 24 hours.</p>`
|
||||
);
|
||||
|
||||
if (user.email_verified) return res.json({ success: true, message: 'Already verified.' });
|
||||
var verifyToken = crypto.randomBytes(32).toString('hex');
|
||||
await db.run('UPDATE users SET verify_token = ?, verify_expires = ? WHERE id = ?', [verifyToken, Date.now() + 86400000, user.id]);
|
||||
var verifyUrl = (process.env.APP_URL || 'http://localhost:3000') + '/api/auth/verify-email?token=' + verifyToken;
|
||||
await sendEmail(req.body.email, 'Verify your email', '<p>Click to verify: <a href="' + verifyUrl + '">' + verifyUrl + '</a></p>');
|
||||
res.json({ success: true, message: 'Verification email sent' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// LOGIN (checks email verification)
|
||||
// LOGIN (checks disabled status)
|
||||
// ============================================================
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { email, password, totpCode } = req.body;
|
||||
var { email, password, totpCode } = req.body;
|
||||
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
|
||||
|
||||
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase());
|
||||
var user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
|
||||
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
|
||||
const valid = await bcrypt.compare(password, user.password);
|
||||
if (user.disabled) {
|
||||
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
|
||||
[user.id, 'login_blocked', req.ip, 'Account disabled']);
|
||||
return res.status(403).json({ error: 'Account disabled. Contact an administrator.' });
|
||||
}
|
||||
|
||||
var valid = await bcrypt.compare(password, user.password);
|
||||
if (!valid) {
|
||||
db.prepare('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)').run(user.id, 'login_failed', req.ip);
|
||||
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login_failed', req.ip]);
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
// Check email verification
|
||||
if (!user.email_verified) {
|
||||
return res.status(403).json({
|
||||
error: 'Email not verified',
|
||||
needsVerification: true,
|
||||
message: 'Please check your email for the verification link.'
|
||||
});
|
||||
return res.status(403).json({ error: 'Email not verified', needsVerification: true });
|
||||
}
|
||||
|
||||
// Check 2FA
|
||||
if (user.totp_enabled) {
|
||||
if (!totpCode) return res.json({ requires2FA: true });
|
||||
const verified = speakeasy.totp.verify({ secret: user.totp_secret, encoding: 'base32', token: totpCode, window: 1 });
|
||||
var verified = speakeasy.totp.verify({ secret: user.totp_secret, encoding: 'base32', token: totpCode, window: 1 });
|
||||
if (!verified) return res.status(401).json({ error: 'Invalid 2FA code' });
|
||||
}
|
||||
|
||||
const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
|
||||
db.prepare('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)').run(user.id, 'login', req.ip);
|
||||
var token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
|
||||
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login', req.ip]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
token,
|
||||
user: { id: user.id, email: user.email, name: user.name, totp_enabled: user.totp_enabled, email_verified: user.email_verified }
|
||||
success: true, token: token,
|
||||
user: { id: user.id, email: user.email, name: user.name, role: user.role, totp_enabled: user.totp_enabled, email_verified: user.email_verified }
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 2FA SETUP / VERIFY / DISABLE (same as before)
|
||||
// ============================================================
|
||||
// 2FA
|
||||
router.post('/setup-2fa', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const secret = speakeasy.generateSecret({ name: `PedScribe (${req.user.email})`, issuer: 'Pediatric AI Scribe' });
|
||||
db.prepare('UPDATE users SET totp_secret = ? WHERE id = ?').run(secret.base32, req.user.id);
|
||||
const qrUrl = await QRCode.toDataURL(secret.otpauth_url);
|
||||
var secret = speakeasy.generateSecret({ name: 'PedScribe (' + req.user.email + ')', issuer: 'Pediatric AI Scribe' });
|
||||
await db.run('UPDATE users SET totp_secret = ? WHERE id = ?', [secret.base32, req.user.id]);
|
||||
var qrUrl = await QRCode.toDataURL(secret.otpauth_url);
|
||||
res.json({ success: true, secret: secret.base32, qrCode: qrUrl });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
router.post('/verify-2fa', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const user = db.prepare('SELECT totp_secret FROM users WHERE id = ?').get(req.user.id);
|
||||
const verified = speakeasy.totp.verify({ secret: user.totp_secret, encoding: 'base32', token: req.body.code, window: 1 });
|
||||
var user = await db.get('SELECT totp_secret FROM users WHERE id = ?', [req.user.id]);
|
||||
var verified = speakeasy.totp.verify({ secret: user.totp_secret, encoding: 'base32', token: req.body.code, window: 1 });
|
||||
if (!verified) return res.status(400).json({ error: 'Invalid code' });
|
||||
db.prepare('UPDATE users SET totp_enabled = 1 WHERE id = ?').run(req.user.id);
|
||||
await db.run('UPDATE users SET totp_enabled = true WHERE id = ?', [req.user.id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
router.post('/disable-2fa', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const user = db.prepare('SELECT password FROM users WHERE id = ?').get(req.user.id);
|
||||
const valid = await bcrypt.compare(req.body.password, user.password);
|
||||
var user = await db.get('SELECT password FROM users WHERE id = ?', [req.user.id]);
|
||||
var valid = await bcrypt.compare(req.body.password, user.password);
|
||||
if (!valid) return res.status(401).json({ error: 'Wrong password' });
|
||||
db.prepare('UPDATE users SET totp_enabled = 0, totp_secret = NULL WHERE id = ?').run(req.user.id);
|
||||
await db.run('UPDATE users SET totp_enabled = false, totp_secret = NULL WHERE id = ?', [req.user.id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// FORGOT / RESET PASSWORD (same as before)
|
||||
// ============================================================
|
||||
// Password reset
|
||||
router.post('/forgot-password', async (req, res) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
const user = db.prepare('SELECT id, name FROM users WHERE email = ?').get(email.toLowerCase());
|
||||
var user = await db.get('SELECT id, name FROM users WHERE email = ?', [req.body.email.toLowerCase()]);
|
||||
if (!user) return res.json({ success: true, message: 'If account exists, reset email sent' });
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
db.prepare('UPDATE users SET reset_token = ?, reset_expires = ? WHERE id = ?').run(token, Date.now() + 3600000, user.id);
|
||||
|
||||
await sendEmail(email, 'Password Reset - Pediatric AI Scribe',
|
||||
`<p>Hi ${user.name},</p><p>Reset your password: <a href="${process.env.APP_URL}/reset-password?token=${token}">Click here</a></p><p>Expires in 1 hour.</p>`
|
||||
);
|
||||
|
||||
var token = crypto.randomBytes(32).toString('hex');
|
||||
await db.run('UPDATE users SET reset_token = ?, reset_expires = ? WHERE id = ?', [token, Date.now() + 3600000, user.id]);
|
||||
await sendEmail(req.body.email, 'Password Reset', '<p>Reset: <a href="' + (process.env.APP_URL || 'http://localhost:3000') + '/reset-password?token=' + token + '">Click here</a></p>');
|
||||
res.json({ success: true, message: 'If account exists, reset email sent' });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
router.post('/reset-password', async (req, res) => {
|
||||
try {
|
||||
const { token, newPassword } = req.body;
|
||||
var { token, newPassword } = req.body;
|
||||
if (!token || !newPassword || newPassword.length < 8) return res.status(400).json({ error: 'Valid token and 8+ char password required' });
|
||||
const user = db.prepare('SELECT id FROM users WHERE reset_token = ? AND reset_expires > ?').get(token, Date.now());
|
||||
var user = await db.get('SELECT id FROM users WHERE reset_token = ? AND reset_expires > ?', [token, Date.now()]);
|
||||
if (!user) return res.status(400).json({ error: 'Invalid or expired token' });
|
||||
const hash = await bcrypt.hash(newPassword, 12);
|
||||
db.prepare('UPDATE users SET password = ?, reset_token = NULL, reset_expires = NULL WHERE id = ?').run(hash, user.id);
|
||||
var hash = await bcrypt.hash(newPassword, 12);
|
||||
await db.run('UPDATE users SET password = ?, reset_token = NULL, reset_expires = NULL WHERE id = ?', [hash, user.id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// GET CURRENT USER
|
||||
// ============================================================
|
||||
router.get('/me', authMiddleware, (req, res) => {
|
||||
const user = db.prepare('SELECT id, email, name, totp_enabled, email_verified, nextcloud_url, nextcloud_user, nextcloud_folder, created_at FROM users WHERE id = ?').get(req.user.id);
|
||||
res.json({ user });
|
||||
// Get current user
|
||||
router.get('/me', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
var user = await db.get(
|
||||
'SELECT id, email, name, role, totp_enabled, email_verified, nextcloud_url, nextcloud_user, nextcloud_folder, created_at FROM users WHERE id = ?',
|
||||
[req.user.id]
|
||||
);
|
||||
res.json({ user: user });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Check if registration is enabled (public endpoint)
|
||||
router.get('/registration-status', async (req, res) => {
|
||||
try {
|
||||
var enabled = await db.getSetting('registration_enabled');
|
||||
res.json({ registrationEnabled: enabled !== 'false' });
|
||||
} catch (err) { res.json({ registrationEnabled: true }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ const express = require('express');
|
|||
const router = express.Router();
|
||||
const { callAI } = require('../utils/ai');
|
||||
const PROMPTS = require('../utils/prompts');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
|
||||
// Generate chart review
|
||||
router.post('/generate-chart-review', async (req, res) => {
|
||||
router.post('/generate-chart-review', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
type, // 'outpatient' | 'subspecialty' | 'ed'
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ const express = require('express');
|
|||
const router = express.Router();
|
||||
const { callAI } = require('../utils/ai');
|
||||
const PROMPTS = require('../utils/prompts');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
|
||||
// Generate hospital course
|
||||
router.post('/generate-hospital-course', async (req, res) => {
|
||||
router.post('/generate-hospital-course', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
notes, // Array of { date, type, content }
|
||||
|
|
@ -96,7 +97,7 @@ router.post('/generate-hospital-course', async (req, res) => {
|
|||
});
|
||||
|
||||
// Ask for clarification/missing info
|
||||
router.post('/hospital-course-clarify', async (req, res) => {
|
||||
router.post('/hospital-course-clarify', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { currentDraft, notes } = req.body;
|
||||
|
||||
|
|
@ -112,7 +113,7 @@ router.post('/hospital-course-clarify', async (req, res) => {
|
|||
});
|
||||
|
||||
// Add discharge day info
|
||||
router.post('/hospital-course-update', async (req, res) => {
|
||||
router.post('/hospital-course-update', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { currentCourse, updates, model, instructions } = req.body;
|
||||
if (!currentCourse) return res.status(400).json({ error: 'No current course' });
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ const express = require('express');
|
|||
const router = express.Router();
|
||||
const { callAI } = require('../utils/ai');
|
||||
const PROMPTS = require('../utils/prompts');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
|
||||
// HPI from encounter
|
||||
router.post('/generate-hpi-encounter', async (req, res) => {
|
||||
router.post('/generate-hpi-encounter', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { transcript, patientAge, patientGender, model, setting } = req.body;
|
||||
if (!transcript || !transcript.trim()) return res.status(400).json({ error: 'Transcript empty' });
|
||||
|
|
@ -23,7 +24,7 @@ router.post('/generate-hpi-encounter', async (req, res) => {
|
|||
});
|
||||
|
||||
// HPI from dictation
|
||||
router.post('/generate-hpi-dictation', async (req, res) => {
|
||||
router.post('/generate-hpi-dictation', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { transcript, patientAge, patientGender, model, setting } = req.body;
|
||||
if (!transcript || !transcript.trim()) return res.status(400).json({ error: 'Dictation empty' });
|
||||
|
|
|
|||
35
src/routes/logs.js
Normal file
35
src/routes/logs.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
var express = require('express');
|
||||
var router = express.Router();
|
||||
var { authMiddleware } = require('../middleware/auth');
|
||||
var logger = require('../utils/logger');
|
||||
|
||||
router.get('/logs/usage', authMiddleware, async function(req, res) {
|
||||
try {
|
||||
var days = parseInt(req.query.days) || 30;
|
||||
var summary = await logger.getUsageSummary(req.user.id, days);
|
||||
res.json({ success: true, summary: summary, period: days + ' days' });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
router.get('/logs/audit', authMiddleware, async function(req, res) {
|
||||
try {
|
||||
var logs = await logger.getAuditLogs(req.user.id, parseInt(req.query.limit) || 50);
|
||||
res.json({ success: true, logs: logs });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
router.get('/logs/api', authMiddleware, async function(req, res) {
|
||||
try {
|
||||
var logs = await logger.getApiLogs(req.user.id, parseInt(req.query.limit) || 50);
|
||||
res.json({ success: true, logs: logs });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
router.get('/logs/access', authMiddleware, async function(req, res) {
|
||||
try {
|
||||
var logs = await logger.getAccessLogs(req.user.id, parseInt(req.query.limit) || 50);
|
||||
res.json({ success: true, logs: logs });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -2,9 +2,10 @@ const express = require('express');
|
|||
const router = express.Router();
|
||||
const { callAI } = require('../utils/ai');
|
||||
const PROMPTS = require('../utils/prompts');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
|
||||
// Narrative format
|
||||
router.post('/generate-milestone-narrative', async (req, res) => {
|
||||
router.post('/generate-milestone-narrative', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { milestones, ageGroup, patientAge, patientGender, model, format } = req.body;
|
||||
if (!milestones) return res.status(400).json({ error: 'No milestones' });
|
||||
|
|
@ -47,7 +48,7 @@ router.post('/generate-milestone-narrative', async (req, res) => {
|
|||
});
|
||||
|
||||
// 3-sentence summary
|
||||
router.post('/generate-milestone-summary', async (req, res) => {
|
||||
router.post('/generate-milestone-summary', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { narrative, ageGroup, patientAge, patientGender, model } = req.body;
|
||||
if (!narrative) return res.status(400).json({ error: 'No narrative' });
|
||||
|
|
|
|||
|
|
@ -1,139 +1,66 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const axios = require('axios');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
const db = require('../db/database');
|
||||
var express = require('express');
|
||||
var router = express.Router();
|
||||
var axios = require('axios');
|
||||
var { authMiddleware } = require('../middleware/auth');
|
||||
var db = require('../db/database');
|
||||
|
||||
// Connect
|
||||
router.post('/nextcloud/connect', authMiddleware, async (req, res) => {
|
||||
router.post('/nextcloud/connect', authMiddleware, async function(req, res) {
|
||||
try {
|
||||
const { nextcloudUrl, username, appPassword, folder } = req.body;
|
||||
var { nextcloudUrl, username, appPassword, folder } = req.body;
|
||||
if (!nextcloudUrl || !username || !appPassword) return res.status(400).json({ error: 'All fields required' });
|
||||
|
||||
var cleanUrl = nextcloudUrl.replace(/\/+$/, '');
|
||||
var targetFolder = (folder || '/PediatricScribe').replace(/\/+$/, '');
|
||||
|
||||
// Test connection
|
||||
try {
|
||||
await axios({ method: 'PROPFIND', url: `${cleanUrl}/remote.php/dav/files/${username}/`, auth: { username, password: appPassword }, headers: { Depth: '0' } });
|
||||
await axios({ method: 'PROPFIND', url: cleanUrl + '/remote.php/dav/files/' + username + '/', auth: { username: username, password: appPassword }, headers: { Depth: '0' } });
|
||||
} catch (e) {
|
||||
return res.status(400).json({ error: 'Cannot connect. Check URL, username, and app password.' });
|
||||
return res.status(400).json({ error: 'Cannot connect. Check credentials.' });
|
||||
}
|
||||
|
||||
// Create folder
|
||||
try {
|
||||
var parts = targetFolder.split('/').filter(Boolean);
|
||||
var current = '';
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
current += '/' + parts[i];
|
||||
try {
|
||||
await axios({ method: 'MKCOL', url: `${cleanUrl}/remote.php/dav/files/${username}${current}/`, auth: { username, password: appPassword } });
|
||||
} catch (e) { /* exists */ }
|
||||
}
|
||||
} catch (e) { /* folder exists */ }
|
||||
var parts = targetFolder.split('/').filter(Boolean);
|
||||
var current = '';
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
current += '/' + parts[i];
|
||||
try { await axios({ method: 'MKCOL', url: cleanUrl + '/remote.php/dav/files/' + username + current + '/', auth: { username: username, password: appPassword } }); } catch (e) {}
|
||||
}
|
||||
|
||||
db.prepare('UPDATE users SET nextcloud_url = ?, nextcloud_user = ?, nextcloud_token = ?, nextcloud_folder = ? WHERE id = ?')
|
||||
.run(cleanUrl, username, appPassword, targetFolder, req.user.id);
|
||||
await db.run('UPDATE users SET nextcloud_url = ?, nextcloud_user = ?, nextcloud_token = ?, nextcloud_folder = ? WHERE id = ?',
|
||||
[cleanUrl, username, appPassword, targetFolder, req.user.id]);
|
||||
|
||||
res.json({ success: true, message: `Connected! Files saved to ${targetFolder}/` });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
res.json({ success: true, message: 'Connected! Files saved to ' + targetFolder + '/' });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// List folders (for picker)
|
||||
router.get('/nextcloud/folders', authMiddleware, async (req, res) => {
|
||||
router.post('/nextcloud/export', authMiddleware, async function(req, res) {
|
||||
try {
|
||||
var user = db.prepare('SELECT nextcloud_url, nextcloud_user, nextcloud_token FROM users WHERE id = ?').get(req.user.id);
|
||||
if (!user.nextcloud_url) return res.status(400).json({ error: 'Not connected' });
|
||||
|
||||
var path = req.query.path || '/';
|
||||
var url = `${user.nextcloud_url}/remote.php/dav/files/${user.nextcloud_user}${path}`;
|
||||
|
||||
var response = await axios({
|
||||
method: 'PROPFIND',
|
||||
url: url,
|
||||
auth: { username: user.nextcloud_user, password: user.nextcloud_token },
|
||||
headers: { Depth: '1' }
|
||||
});
|
||||
|
||||
// Parse WebDAV XML response for folders
|
||||
var folders = [];
|
||||
var matches = response.data.match(/<d:href>([^<]+)<\/d:href>/g) || [];
|
||||
matches.forEach(function(match) {
|
||||
var href = match.replace(/<\/?d:href>/g, '');
|
||||
if (href.endsWith('/')) {
|
||||
var decoded = decodeURIComponent(href);
|
||||
var parts = decoded.split('/').filter(Boolean);
|
||||
var name = parts[parts.length - 1];
|
||||
if (name && name !== user.nextcloud_user) {
|
||||
folders.push({ name: name, path: decoded.replace(/.*\/remote\.php\/dav\/files\/[^/]+/, '') });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ success: true, folders: folders, currentPath: path });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Export with subfolder support
|
||||
router.post('/nextcloud/export', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { content, filename, type, subfolder } = req.body;
|
||||
var user = db.prepare('SELECT nextcloud_url, nextcloud_user, nextcloud_token, nextcloud_folder FROM users WHERE id = ?').get(req.user.id);
|
||||
|
||||
if (!user.nextcloud_url || !user.nextcloud_token) return res.status(400).json({ error: 'Nextcloud not connected. Go to Settings.' });
|
||||
var { content, filename, type } = req.body;
|
||||
var user = await db.get('SELECT nextcloud_url, nextcloud_user, nextcloud_token, nextcloud_folder FROM users WHERE id = ?', [req.user.id]);
|
||||
if (!user || !user.nextcloud_url) return res.status(400).json({ error: 'Nextcloud not connected. Go to Settings.' });
|
||||
|
||||
var baseFolder = user.nextcloud_folder || '/PediatricScribe';
|
||||
var targetPath = baseFolder;
|
||||
|
||||
// Create date subfolder automatically
|
||||
var today = new Date().toISOString().split('T')[0];
|
||||
targetPath += '/' + today;
|
||||
var targetPath = baseFolder + '/' + today;
|
||||
|
||||
// Additional subfolder if specified
|
||||
if (subfolder) targetPath += '/' + subfolder.replace(/[^a-zA-Z0-9-_ ]/g, '');
|
||||
|
||||
// Create folder path
|
||||
var parts = targetPath.split('/').filter(Boolean);
|
||||
var current = '';
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
current += '/' + parts[i];
|
||||
try {
|
||||
await axios({ method: 'MKCOL', url: `${user.nextcloud_url}/remote.php/dav/files/${user.nextcloud_user}${current}/`, auth: { username: user.nextcloud_user, password: user.nextcloud_token } });
|
||||
} catch (e) { /* exists */ }
|
||||
try { await axios({ method: 'MKCOL', url: user.nextcloud_url + '/remote.php/dav/files/' + user.nextcloud_user + current + '/', auth: { username: user.nextcloud_user, password: user.nextcloud_token } }); } catch (e) {}
|
||||
}
|
||||
|
||||
var time = new Date().toTimeString().split(' ')[0].replace(/:/g, '-');
|
||||
var safeName = (filename || type + '-' + time).replace(/[^a-zA-Z0-9-_]/g, '_');
|
||||
var filePath = `${user.nextcloud_url}/remote.php/dav/files/${user.nextcloud_user}${targetPath}/${safeName}.txt`;
|
||||
var filePath = user.nextcloud_url + '/remote.php/dav/files/' + user.nextcloud_user + targetPath + '/' + safeName + '.txt';
|
||||
|
||||
await axios({
|
||||
method: 'PUT',
|
||||
url: filePath,
|
||||
data: content,
|
||||
auth: { username: user.nextcloud_user, password: user.nextcloud_token },
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
|
||||
});
|
||||
await axios({ method: 'PUT', url: filePath, data: content, auth: { username: user.nextcloud_user, password: user.nextcloud_token }, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
|
||||
|
||||
res.json({ success: true, message: `Saved to ${targetPath}/${safeName}.txt`, path: `${targetPath}/${safeName}.txt` });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
res.json({ success: true, message: 'Saved to ' + targetPath + '/' + safeName + '.txt' });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Update folder setting
|
||||
router.post('/nextcloud/set-folder', authMiddleware, (req, res) => {
|
||||
const { folder } = req.body;
|
||||
db.prepare('UPDATE users SET nextcloud_folder = ? WHERE id = ?').run(folder || '/PediatricScribe', req.user.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Disconnect
|
||||
router.post('/nextcloud/disconnect', authMiddleware, (req, res) => {
|
||||
db.prepare('UPDATE users SET nextcloud_url = NULL, nextcloud_user = NULL, nextcloud_token = NULL, nextcloud_folder = NULL WHERE id = ?').run(req.user.id);
|
||||
router.post('/nextcloud/disconnect', authMiddleware, async function(req, res) {
|
||||
await db.run('UPDATE users SET nextcloud_url = NULL, nextcloud_user = NULL, nextcloud_token = NULL, nextcloud_folder = NULL WHERE id = ?', [req.user.id]);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,27 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { callAI } = require('../utils/ai');
|
||||
const PROMPTS = require('../utils/prompts');
|
||||
// ============================================================
|
||||
// REFINE ROUTES — Modify, shorten, clarify any document
|
||||
// ============================================================
|
||||
|
||||
// Refine/modify any generated document
|
||||
router.post('/refine', async (req, res) => {
|
||||
var express = require('express');
|
||||
var router = express.Router();
|
||||
var { callAI } = require('../utils/ai');
|
||||
var PROMPTS = require('../utils/prompts');
|
||||
var { authMiddleware } = require('../middleware/auth');
|
||||
|
||||
// Refine/modify document with instructions
|
||||
router.post('/refine', authMiddleware, async function(req, res) {
|
||||
try {
|
||||
const { currentDocument, instructions, model } = req.body;
|
||||
var currentDocument = req.body.currentDocument;
|
||||
var instructions = req.body.instructions;
|
||||
var model = req.body.model;
|
||||
|
||||
if (!currentDocument) return res.status(400).json({ error: 'No document to refine' });
|
||||
if (!instructions) return res.status(400).json({ error: 'No instructions provided' });
|
||||
|
||||
const result = await callAI([
|
||||
var result = await callAI([
|
||||
{ role: 'system', content: PROMPTS.refine },
|
||||
{ role: 'user', content: `CURRENT DOCUMENT:\n${currentDocument}\n\nUSER INSTRUCTIONS:\n${instructions}` }
|
||||
], { model, maxTokens: 6000 });
|
||||
{ role: 'user', content: 'CURRENT DOCUMENT:\n' + currentDocument + '\n\nUSER INSTRUCTIONS:\n' + instructions }
|
||||
], { model: model, maxTokens: 6000 });
|
||||
|
||||
res.json({ success: true, refined: result.content, model: result.model });
|
||||
} catch (err) {
|
||||
|
|
@ -21,16 +29,18 @@ router.post('/refine', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// Shorten any document
|
||||
router.post('/shorten', async (req, res) => {
|
||||
// Shorten document
|
||||
router.post('/shorten', authMiddleware, async function(req, res) {
|
||||
try {
|
||||
const { document, model } = req.body;
|
||||
var document = req.body.document;
|
||||
var model = req.body.model;
|
||||
|
||||
if (!document) return res.status(400).json({ error: 'No document' });
|
||||
|
||||
const result = await callAI([
|
||||
var result = await callAI([
|
||||
{ role: 'system', content: PROMPTS.shortenDocument },
|
||||
{ role: 'user', content: document }
|
||||
], { model });
|
||||
], { model: model });
|
||||
|
||||
res.json({ success: true, shortened: result.content, model: result.model });
|
||||
} catch (err) {
|
||||
|
|
@ -39,14 +49,18 @@ router.post('/shorten', async (req, res) => {
|
|||
});
|
||||
|
||||
// Ask AI for clarification questions
|
||||
router.post('/clarify', async (req, res) => {
|
||||
router.post('/clarify', authMiddleware, async function(req, res) {
|
||||
try {
|
||||
const { document, context, model } = req.body;
|
||||
var document = req.body.document;
|
||||
var context = req.body.context;
|
||||
var model = req.body.model;
|
||||
|
||||
const result = await callAI([
|
||||
if (!document) return res.status(400).json({ error: 'No document' });
|
||||
|
||||
var result = await callAI([
|
||||
{ role: 'system', content: PROMPTS.askClarification },
|
||||
{ role: 'user', content: `Document type: ${context || 'medical document'}\n\nDOCUMENT:\n${document}` }
|
||||
], { model, maxTokens: 1000 });
|
||||
{ role: 'user', content: 'Document type: ' + (context || 'medical document') + '\n\nDOCUMENT:\n' + document }
|
||||
], { model: model, maxTokens: 1000 });
|
||||
|
||||
res.json({ success: true, questions: result.content, model: result.model });
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ const express = require('express');
|
|||
const router = express.Router();
|
||||
const { callAI } = require('../utils/ai');
|
||||
const PROMPTS = require('../utils/prompts');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
|
||||
router.post('/generate-soap', async (req, res) => {
|
||||
router.post('/generate-soap', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { transcript, patientAge, patientGender, model, type, additionalInstructions } = req.body;
|
||||
if (!transcript || !transcript.trim()) return res.status(400).json({ error: 'Empty input' });
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@ const express = require('express');
|
|||
const router = express.Router();
|
||||
const multer = require('multer');
|
||||
const { whisperClient } = require('../utils/ai');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });
|
||||
|
||||
router.post('/transcribe', upload.single('audio'), async (req, res) => {
|
||||
router.post('/transcribe', authMiddleware, upload.single('audio'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'No audio' });
|
||||
if (!whisperClient) return res.status(400).json({ error: 'Whisper not configured' });
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const axios = require('axios');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
|
||||
router.post('/text-to-speech', async (req, res) => {
|
||||
router.post('/text-to-speech', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
if (!process.env.ELEVENLABS_API_KEY) return res.status(400).json({ error: 'Not configured' });
|
||||
const response = await axios({
|
||||
|
|
|
|||
274
src/utils/ai.js
274
src/utils/ai.js
|
|
@ -1,48 +1,268 @@
|
|||
// ============================================================
|
||||
// AI.JS — Multi-provider AI client
|
||||
// Supports: OpenRouter, AWS Bedrock, Azure OpenAI
|
||||
// Default: OpenRouter (set AI_PROVIDER in .env to switch)
|
||||
// ============================================================
|
||||
|
||||
const { OpenAI } = require('openai');
|
||||
const { DEFAULT_MODEL, FALLBACK_MODEL } = require('./models');
|
||||
const { DEFAULT_MODEL, FALLBACK_MODEL, getBedrockModelId } = require('./models');
|
||||
const logger = require('./logger');
|
||||
|
||||
const openrouter = new OpenAI({
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
apiKey: process.env.OPENROUTER_API_KEY,
|
||||
defaultHeaders: {
|
||||
'HTTP-Referer': process.env.APP_URL || 'http://localhost:3000',
|
||||
'X-Title': 'Pediatric AI Scribe'
|
||||
var activeProvider = process.env.AI_PROVIDER || 'openrouter';
|
||||
|
||||
// ============================================================
|
||||
// OPENROUTER CLIENT (default)
|
||||
// ============================================================
|
||||
var openrouter = null;
|
||||
if (process.env.OPENROUTER_API_KEY) {
|
||||
openrouter = new OpenAI({
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
apiKey: process.env.OPENROUTER_API_KEY,
|
||||
defaultHeaders: {
|
||||
'HTTP-Referer': process.env.APP_URL || 'http://localhost:3000',
|
||||
'X-Title': 'Pediatric AI Scribe'
|
||||
}
|
||||
});
|
||||
console.log('✅ OpenRouter: configured');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// AWS BEDROCK CLIENT (optional, HIPAA compliant)
|
||||
// ============================================================
|
||||
var bedrockClient = null;
|
||||
if (process.env.AWS_BEDROCK_REGION) {
|
||||
try {
|
||||
var BedrockModule = require('@aws-sdk/client-bedrock-runtime');
|
||||
var BedrockRuntimeClient = BedrockModule.BedrockRuntimeClient;
|
||||
bedrockClient = new BedrockRuntimeClient({
|
||||
region: process.env.AWS_BEDROCK_REGION || 'us-east-1',
|
||||
credentials: process.env.AWS_ACCESS_KEY_ID ? {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
|
||||
} : undefined
|
||||
});
|
||||
activeProvider = 'bedrock';
|
||||
console.log('✅ AWS Bedrock: configured (region: ' + process.env.AWS_BEDROCK_REGION + ')');
|
||||
} catch (e) {
|
||||
console.log('⚠️ AWS Bedrock: SDK not installed. Install with: npm install @aws-sdk/client-bedrock-runtime');
|
||||
console.log('⚠️ Falling back to OpenRouter');
|
||||
activeProvider = 'openrouter';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let whisperClient = null;
|
||||
// ============================================================
|
||||
// AZURE OPENAI CLIENT (optional, HIPAA compliant)
|
||||
// ============================================================
|
||||
var azureClient = null;
|
||||
if (process.env.AZURE_OPENAI_ENDPOINT) {
|
||||
try {
|
||||
azureClient = new OpenAI({
|
||||
apiKey: process.env.AZURE_OPENAI_API_KEY,
|
||||
baseURL: process.env.AZURE_OPENAI_ENDPOINT.replace(/\/+$/, '') + '/openai/deployments/' + (process.env.AZURE_DEPLOYMENT_NAME || 'gpt-4o-mini'),
|
||||
defaultQuery: { 'api-version': process.env.AZURE_OPENAI_API_VERSION || '2024-08-01-preview' },
|
||||
defaultHeaders: { 'api-key': process.env.AZURE_OPENAI_API_KEY }
|
||||
});
|
||||
activeProvider = 'azure';
|
||||
console.log('✅ Azure OpenAI: configured (deployment: ' + (process.env.AZURE_DEPLOYMENT_NAME || 'gpt-4o-mini') + ')');
|
||||
} catch (e) {
|
||||
console.log('⚠️ Azure OpenAI: configuration failed:', e.message);
|
||||
console.log('⚠️ Falling back to OpenRouter');
|
||||
activeProvider = 'openrouter';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// WHISPER CLIENT (always OpenAI, separate from text AI)
|
||||
// ============================================================
|
||||
var whisperClient = null;
|
||||
if (process.env.OPENAI_API_KEY && process.env.OPENAI_API_KEY.startsWith('sk-')) {
|
||||
whisperClient = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||
console.log('✅ Whisper: configured');
|
||||
} else {
|
||||
console.log('⚠️ Whisper: not configured (set OPENAI_API_KEY)');
|
||||
}
|
||||
|
||||
async function callAI(messages, options = {}) {
|
||||
const model = options.model || DEFAULT_MODEL;
|
||||
const temperature = options.temperature || 0.3;
|
||||
const maxTokens = options.maxTokens || 4000;
|
||||
// Force provider from env if explicitly set
|
||||
if (process.env.AI_PROVIDER) {
|
||||
activeProvider = process.env.AI_PROVIDER;
|
||||
}
|
||||
|
||||
// Validate: if chosen provider has no client, fall back
|
||||
if (activeProvider === 'bedrock' && !bedrockClient) {
|
||||
console.log('⚠️ Bedrock selected but not available. Falling back to OpenRouter.');
|
||||
activeProvider = 'openrouter';
|
||||
}
|
||||
if (activeProvider === 'azure' && !azureClient) {
|
||||
console.log('⚠️ Azure selected but not available. Falling back to OpenRouter.');
|
||||
activeProvider = 'openrouter';
|
||||
}
|
||||
if (activeProvider === 'openrouter' && !openrouter) {
|
||||
console.error('❌ OpenRouter selected but OPENROUTER_API_KEY not set!');
|
||||
}
|
||||
|
||||
console.log('🤖 Active AI provider:', activeProvider);
|
||||
|
||||
// ============================================================
|
||||
// CALL OPENROUTER
|
||||
// ============================================================
|
||||
async function callOpenRouter(messages, model, temperature, maxTokens) {
|
||||
if (!openrouter) throw new Error('OpenRouter not configured. Set OPENROUTER_API_KEY in .env');
|
||||
|
||||
var completion = await openrouter.chat.completions.create({
|
||||
model: model,
|
||||
messages: messages,
|
||||
temperature: temperature,
|
||||
max_tokens: maxTokens
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
content: completion.choices[0].message.content,
|
||||
model: model,
|
||||
provider: 'openrouter',
|
||||
usage: completion.usage || null
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CALL AZURE OPENAI
|
||||
// ============================================================
|
||||
async function callAzure(messages, model, temperature, maxTokens) {
|
||||
if (!azureClient) throw new Error('Azure OpenAI not configured');
|
||||
|
||||
var completion = await azureClient.chat.completions.create({
|
||||
model: process.env.AZURE_DEPLOYMENT_NAME || 'gpt-4o-mini',
|
||||
messages: messages,
|
||||
temperature: temperature,
|
||||
max_tokens: maxTokens
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
content: completion.choices[0].message.content,
|
||||
model: process.env.AZURE_DEPLOYMENT_NAME || model,
|
||||
provider: 'azure',
|
||||
usage: completion.usage || null
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CALL AWS BEDROCK
|
||||
// ============================================================
|
||||
async function callBedrock(messages, model, temperature, maxTokens) {
|
||||
if (!bedrockClient) throw new Error('AWS Bedrock not configured');
|
||||
|
||||
var BedrockModule = require('@aws-sdk/client-bedrock-runtime');
|
||||
var InvokeModelCommand = BedrockModule.InvokeModelCommand;
|
||||
|
||||
// Map friendly model ID to Bedrock model ID
|
||||
var modelId = getBedrockModelId(model);
|
||||
|
||||
// Separate system message from chat messages
|
||||
var systemMsg = '';
|
||||
var chatMessages = [];
|
||||
messages.forEach(function(m) {
|
||||
if (m.role === 'system') {
|
||||
systemMsg += (systemMsg ? '\n' : '') + m.content;
|
||||
} else {
|
||||
chatMessages.push({ role: m.role, content: m.content });
|
||||
}
|
||||
});
|
||||
|
||||
// Build request body (Anthropic Messages API format for Bedrock)
|
||||
var body = {
|
||||
anthropic_version: 'bedrock-2023-05-31',
|
||||
max_tokens: maxTokens,
|
||||
temperature: temperature,
|
||||
messages: chatMessages
|
||||
};
|
||||
if (systemMsg) body.system = systemMsg;
|
||||
|
||||
var command = new InvokeModelCommand({
|
||||
modelId: modelId,
|
||||
contentType: 'application/json',
|
||||
accept: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
var response = await bedrockClient.send(command);
|
||||
var responseBody = JSON.parse(new TextDecoder().decode(response.body));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
content: responseBody.content[0].text,
|
||||
model: modelId,
|
||||
provider: 'bedrock',
|
||||
usage: {
|
||||
prompt_tokens: responseBody.usage ? responseBody.usage.input_tokens : 0,
|
||||
completion_tokens: responseBody.usage ? responseBody.usage.output_tokens : 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MAIN CALL AI FUNCTION — Routes to correct provider
|
||||
// ============================================================
|
||||
async function callAI(messages, options) {
|
||||
options = options || {};
|
||||
var model = options.model || DEFAULT_MODEL;
|
||||
var temperature = options.temperature || 0.3;
|
||||
var maxTokens = options.maxTokens || 4000;
|
||||
var startTime = Date.now();
|
||||
|
||||
try {
|
||||
console.log(`[AI] ${model} | ${messages[messages.length - 1].content.substring(0, 60)}...`);
|
||||
const completion = await openrouter.chat.completions.create({
|
||||
model, messages, temperature, max_tokens: maxTokens
|
||||
var result;
|
||||
|
||||
// Route to correct provider
|
||||
if (activeProvider === 'bedrock' && bedrockClient) {
|
||||
result = await callBedrock(messages, model, temperature, maxTokens);
|
||||
} else if (activeProvider === 'azure' && azureClient) {
|
||||
result = await callAzure(messages, model, temperature, maxTokens);
|
||||
} else if (openrouter) {
|
||||
result = await callOpenRouter(messages, model, temperature, maxTokens);
|
||||
} else {
|
||||
throw new Error('No AI provider configured. Set OPENROUTER_API_KEY, AWS_BEDROCK_REGION, or AZURE_OPENAI_ENDPOINT in .env');
|
||||
}
|
||||
|
||||
var duration = Date.now() - startTime;
|
||||
|
||||
logger.info('AI call success', {
|
||||
provider: activeProvider,
|
||||
model: result.model,
|
||||
duration: duration,
|
||||
outputLength: result.content.length,
|
||||
tokens: result.usage
|
||||
});
|
||||
const content = completion.choices[0].message.content;
|
||||
console.log(`[AI] Done: ${content.length} chars`);
|
||||
return { success: true, content, model, usage: completion.usage };
|
||||
|
||||
result.duration = duration;
|
||||
return result;
|
||||
|
||||
} catch (err) {
|
||||
console.error(`[AI] ${model} failed: ${err.message}`);
|
||||
if (model !== FALLBACK_MODEL) {
|
||||
duration = Date.now() - startTime;
|
||||
logger.error('AI call failed', {
|
||||
provider: activeProvider,
|
||||
model: model,
|
||||
error: err.message,
|
||||
duration: duration
|
||||
});
|
||||
|
||||
// Try fallback model (only on OpenRouter — Bedrock/Azure don't have multiple models easily)
|
||||
if (activeProvider === 'openrouter' && model !== FALLBACK_MODEL && openrouter) {
|
||||
logger.warn('Trying fallback model: ' + FALLBACK_MODEL);
|
||||
try {
|
||||
const completion = await openrouter.chat.completions.create({
|
||||
model: FALLBACK_MODEL, messages, temperature, max_tokens: maxTokens
|
||||
});
|
||||
return { success: true, content: completion.choices[0].message.content, model: FALLBACK_MODEL, fallback: true };
|
||||
var fallbackResult = await callOpenRouter(messages, FALLBACK_MODEL, temperature, maxTokens);
|
||||
fallbackResult.fallback = true;
|
||||
fallbackResult.duration = Date.now() - startTime;
|
||||
logger.info('Fallback success', { model: FALLBACK_MODEL });
|
||||
return fallbackResult;
|
||||
} catch (err2) {
|
||||
throw new Error(`All models failed: ${err2.message}`);
|
||||
logger.error('Fallback also failed', { error: err2.message });
|
||||
throw new Error('All models failed: ' + err2.message);
|
||||
}
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { callAI, whisperClient, openrouter };
|
||||
module.exports = { callAI, whisperClient, activeProvider };
|
||||
|
|
|
|||
106
src/utils/logger.js
Normal file
106
src/utils/logger.js
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const db = require('../db/database');
|
||||
|
||||
var LOG_DIR = path.join(__dirname, '../../data/logs');
|
||||
if (!fs.existsSync(LOG_DIR)) {
|
||||
fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function estimateCost(model, inputTokens, outputTokens) {
|
||||
if (!model || !inputTokens) return 0;
|
||||
var rates = {
|
||||
'google/gemini-2.5-flash': { input: 0.075, output: 0.30 },
|
||||
'google/gemini-2.5-pro': { input: 1.25, output: 5.00 },
|
||||
'deepseek/deepseek-chat-v3-0324': { input: 0.14, output: 0.28 },
|
||||
'deepseek/deepseek-r1': { input: 0.55, output: 2.19 },
|
||||
'deepseek/deepseek-r1:free': { input: 0, output: 0 },
|
||||
'qwen/qwen3-30b-a3b:free': { input: 0, output: 0 },
|
||||
'anthropic/vendor-model-sonnet-4': { input: 3.00, output: 15.00 },
|
||||
'anthropic/vendor-model-3-haiku': { input: 0.25, output: 1.25 },
|
||||
'openai/gpt-4.1-mini': { input: 0.40, output: 1.60 },
|
||||
'meta-llama/llama-3.3-70b-instruct': { input: 0.10, output: 0.16 }
|
||||
};
|
||||
var rate = rates[model] || { input: 0.50, output: 1.00 };
|
||||
return ((inputTokens / 1000000) * rate.input) + (((outputTokens || 0) / 1000000) * rate.output);
|
||||
}
|
||||
|
||||
var logger = {
|
||||
|
||||
audit: function(userId, action, details, req, extra) {
|
||||
var e = extra || {};
|
||||
db.run(
|
||||
'INSERT INTO audit_log (user_id, action, category, details, ip_address, user_agent, model_used, tokens_used, duration_ms, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
userId || null, action, e.category || 'general',
|
||||
typeof details === 'string' ? details : JSON.stringify(details),
|
||||
req ? (req.ip || null) : null,
|
||||
req ? (req.headers['user-agent'] || '').substring(0, 255) : null,
|
||||
e.model || null, e.tokens || null, e.duration || null, e.status || 'success'
|
||||
]
|
||||
).catch(function(err) { console.error('[Logger] Audit failed:', err.message); });
|
||||
},
|
||||
|
||||
apiCall: function(userId, endpoint, data) {
|
||||
var d = data || {};
|
||||
var cost = estimateCost(d.model, d.tokensInput, d.tokensOutput);
|
||||
db.run(
|
||||
'INSERT INTO api_log (user_id, endpoint, method, status_code, request_size, response_size, model_used, tokens_input, tokens_output, cost_estimate, duration_ms, ip_address, error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
userId || null, endpoint, d.method || 'POST', d.statusCode || 200,
|
||||
d.requestSize || 0, d.responseSize || 0, d.model || null,
|
||||
d.tokensInput || 0, d.tokensOutput || 0, cost, d.duration || 0,
|
||||
d.ip || null, d.error || null
|
||||
]
|
||||
).catch(function(err) { console.error('[Logger] API log failed:', err.message); });
|
||||
},
|
||||
|
||||
access: function(userId, action, req, success) {
|
||||
db.run(
|
||||
'INSERT INTO access_log (user_id, action, ip_address, user_agent, success) VALUES (?, ?, ?, ?, ?)',
|
||||
[
|
||||
userId || null, action,
|
||||
req ? (req.ip || null) : null,
|
||||
req ? (req.headers['user-agent'] || '').substring(0, 255) : null,
|
||||
success ? true : false
|
||||
]
|
||||
).catch(function(err) { console.error('[Logger] Access log failed:', err.message); });
|
||||
},
|
||||
|
||||
file: function(level, message, data) {
|
||||
try {
|
||||
var now = new Date();
|
||||
var date = now.toISOString().split('T')[0];
|
||||
var logFile = path.join(LOG_DIR, date + '.log');
|
||||
var line = '[' + now.toISOString() + '] [' + level.toUpperCase() + '] ' + message;
|
||||
if (data) line += ' | ' + (typeof data === 'string' ? data : JSON.stringify(data));
|
||||
line += '\n';
|
||||
fs.appendFileSync(logFile, line);
|
||||
if (level === 'error') console.error(line.trim());
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
info: function(msg, data) { this.file('info', msg, data); },
|
||||
warn: function(msg, data) { this.file('warn', msg, data); },
|
||||
error: function(msg, data) { this.file('error', msg, data); },
|
||||
|
||||
getAuditLogs: function(userId, limit) {
|
||||
return db.all('SELECT * FROM audit_log WHERE user_id = ? ORDER BY timestamp DESC LIMIT ?', [userId, limit || 100]);
|
||||
},
|
||||
getApiLogs: function(userId, limit) {
|
||||
return db.all('SELECT * FROM api_log WHERE user_id = ? ORDER BY timestamp DESC LIMIT ?', [userId, limit || 100]);
|
||||
},
|
||||
getAccessLogs: function(userId, limit) {
|
||||
return db.all('SELECT * FROM access_log WHERE user_id = ? ORDER BY timestamp DESC LIMIT ?', [userId, limit || 100]);
|
||||
},
|
||||
getUsageSummary: function(userId, days) {
|
||||
var since = new Date(Date.now() - (days || 30) * 86400000).toISOString();
|
||||
return db.all(
|
||||
'SELECT COUNT(*) as total_calls, SUM(tokens_input) as total_input_tokens, SUM(tokens_output) as total_output_tokens, SUM(cost_estimate) as total_estimated_cost, AVG(duration_ms) as avg_duration_ms, model_used, endpoint FROM api_log WHERE user_id = ? AND timestamp > ? GROUP BY model_used, endpoint ORDER BY total_calls DESC',
|
||||
[userId, since]
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
console.log('✅ Logger initialized');
|
||||
module.exports = logger;
|
||||
|
|
@ -1,35 +1,107 @@
|
|||
const AVAILABLE_MODELS = [
|
||||
// Best value
|
||||
// ============================================================
|
||||
// MODELS.JS — Provider-aware model list
|
||||
// ============================================================
|
||||
|
||||
var activeProvider = process.env.AI_PROVIDER || 'openrouter';
|
||||
|
||||
var OPENROUTER_MODELS = [
|
||||
{ id: 'google/gemini-2.5-flash', name: 'Gemini Flash 2.5', cost: '~$0.001', tag: 'BEST VALUE', category: 'fast' },
|
||||
{ id: 'google/gemini-2.5-pro', name: 'Gemini Pro 2.5', cost: '~$0.01', tag: 'SMART', category: 'premium' },
|
||||
{ id: 'google/gemini-2.5-flash:thinking', name: 'Gemini Flash Thinking', cost: '~$0.002', tag: 'REASONING', category: 'smart' },
|
||||
|
||||
// DeepSeek
|
||||
{ id: 'deepseek/deepseek-chat-v3-0324', name: 'DeepSeek V3', cost: '~$0.001', tag: 'CHEAP', category: 'fast' },
|
||||
{ id: 'deepseek/deepseek-r1', name: 'DeepSeek R1', cost: '~$0.005', tag: 'REASONING', category: 'smart' },
|
||||
{ id: 'deepseek/deepseek-r1:free', name: 'DeepSeek R1 Free', cost: 'FREE', tag: 'FREE', category: 'free' },
|
||||
|
||||
// Qwen
|
||||
{ id: 'qwen/qwen3-235b-a22b', name: 'Qwen3 235B', cost: '~$0.005', tag: 'SMART', category: 'smart' },
|
||||
{ id: 'qwen/qwen3-30b-a3b:free', name: 'Qwen3 30B Free', cost: 'FREE', tag: 'FREE', category: 'free' },
|
||||
|
||||
// Meta Llama
|
||||
{ id: 'meta-llama/llama-4-maverick', name: 'Llama 4 Maverick', cost: '~$0.002', tag: 'NEW', category: 'smart' },
|
||||
{ id: 'meta-llama/llama-3.3-70b-instruct', name: 'Llama 3.3 70B', cost: '~$0.001', tag: 'GOOD', category: 'fast' },
|
||||
|
||||
// Premium
|
||||
{ id: 'openai/gpt-4.1', name: 'GPT-4.1', cost: '~$0.01', tag: 'PREMIUM', category: 'premium' },
|
||||
{ id: 'openai/gpt-4.1-mini', name: 'GPT-4.1 Mini', cost: '~$0.003', tag: 'SMART', category: 'smart' },
|
||||
{ id: 'openai/o4-mini', name: 'o4-mini (Reasoning)', cost: '~$0.01', tag: 'REASONING', category: 'premium' },
|
||||
{ id: 'anthropic/vendor-model-sonnet-4', name: 'vendor model Sonnet 4', cost: '~$0.015', tag: 'PREMIUM', category: 'premium' },
|
||||
{ id: 'anthropic/vendor-model-3.5-sonnet', name: 'vendor model 3.5 Sonnet', cost: '~$0.015', tag: 'MEDICAL', category: 'premium' },
|
||||
|
||||
// Mistral
|
||||
{ id: 'mistralai/mistral-large-2411', name: 'Mistral Large', cost: '~$0.006', tag: 'GOOD', category: 'smart' },
|
||||
{ id: 'mistralai/mistral-small-3.2-24b-instruct:free', name: 'Mistral Small Free', cost: 'FREE', tag: 'FREE', category: 'free' }
|
||||
];
|
||||
|
||||
const DEFAULT_MODEL = 'google/gemini-2.5-flash';
|
||||
const FALLBACK_MODEL = 'deepseek/deepseek-chat-v3-0324';
|
||||
var BEDROCK_MODELS = [
|
||||
{ id: 'anthropic/vendor-model-sonnet-4', name: 'vendor model Sonnet 4', cost: '~$0.015', tag: 'BEST', category: 'premium',
|
||||
bedrockId: 'anthropic.agent-config-sonnet-4-20250514-v1:0' },
|
||||
{ id: 'anthropic/vendor-model-3.5-sonnet', name: 'vendor model 3.5 Sonnet', cost: '~$0.015', tag: 'MEDICAL', category: 'premium',
|
||||
bedrockId: 'anthropic.agent-config-3-5-sonnet-20241022-v2:0' },
|
||||
{ id: 'anthropic/vendor-model-3-haiku', name: 'vendor model 3 Haiku', cost: '~$0.001', tag: 'CHEAPEST', category: 'fast',
|
||||
bedrockId: 'anthropic.agent-config-3-haiku-20240307-v1:0' },
|
||||
{ id: 'meta-llama/llama-3.1-70b', name: 'Llama 3.1 70B', cost: '~$0.003', tag: 'GOOD', category: 'smart',
|
||||
bedrockId: 'meta.llama3-1-70b-instruct-v1:0' },
|
||||
{ id: 'meta-llama/llama-3.1-8b', name: 'Llama 3.1 8B', cost: '~$0.001', tag: 'FAST', category: 'fast',
|
||||
bedrockId: 'meta.llama3-1-8b-instruct-v1:0' },
|
||||
{ id: 'mistral/mistral-large', name: 'Mistral Large', cost: '~$0.008', tag: 'GOOD', category: 'smart',
|
||||
bedrockId: 'mistral.mistral-large-2407-v1:0' },
|
||||
{ id: 'amazon/titan-text-premier', name: 'Amazon Titan Premier', cost: '~$0.002', tag: 'AWS', category: 'fast',
|
||||
bedrockId: 'amazon.titan-text-premier-v1:0' }
|
||||
];
|
||||
|
||||
module.exports = { AVAILABLE_MODELS, DEFAULT_MODEL, FALLBACK_MODEL };
|
||||
var AZURE_MODELS = [
|
||||
{ id: 'gpt-4o', name: 'GPT-4o', cost: '~$0.01', tag: 'BEST', category: 'premium',
|
||||
deploymentNote: 'Set AZURE_DEPLOYMENT_NAME in .env' },
|
||||
{ id: 'gpt-4o-mini', name: 'GPT-4o Mini', cost: '~$0.003', tag: 'SMART', category: 'smart',
|
||||
deploymentNote: 'Set AZURE_DEPLOYMENT_NAME in .env' },
|
||||
{ id: 'gpt-4.1', name: 'GPT-4.1', cost: '~$0.01', tag: 'NEW', category: 'premium',
|
||||
deploymentNote: 'Set AZURE_DEPLOYMENT_NAME in .env' },
|
||||
{ id: 'gpt-4.1-mini', name: 'GPT-4.1 Mini', cost: '~$0.003', tag: 'VALUE', category: 'smart',
|
||||
deploymentNote: 'Set AZURE_DEPLOYMENT_NAME in .env' }
|
||||
];
|
||||
|
||||
function getAvailableModels() {
|
||||
switch (activeProvider) {
|
||||
case 'bedrock': return BEDROCK_MODELS;
|
||||
case 'azure': return AZURE_MODELS;
|
||||
case 'openrouter':
|
||||
default: return OPENROUTER_MODELS;
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultModel() {
|
||||
switch (activeProvider) {
|
||||
case 'bedrock': return 'anthropic/vendor-model-3-haiku';
|
||||
case 'azure': return process.env.AZURE_DEPLOYMENT_NAME || 'gpt-4o-mini';
|
||||
case 'openrouter':
|
||||
default: return 'google/gemini-2.5-flash';
|
||||
}
|
||||
}
|
||||
|
||||
function getFallbackModel() {
|
||||
switch (activeProvider) {
|
||||
case 'bedrock': return 'meta-llama/llama-3.1-8b';
|
||||
case 'azure': return process.env.AZURE_DEPLOYMENT_NAME || 'gpt-4o-mini';
|
||||
case 'openrouter':
|
||||
default: return 'deepseek/deepseek-chat-v3-0324';
|
||||
}
|
||||
}
|
||||
|
||||
function getBedrockModelId(modelId) {
|
||||
var found = BEDROCK_MODELS.find(function(m) { return m.id === modelId; });
|
||||
return found ? found.bedrockId : modelId;
|
||||
}
|
||||
|
||||
var AVAILABLE_MODELS = getAvailableModels();
|
||||
var DEFAULT_MODEL = getDefaultModel();
|
||||
var FALLBACK_MODEL = getFallbackModel();
|
||||
|
||||
console.log('🤖 Provider:', activeProvider);
|
||||
console.log('🤖 Default model:', DEFAULT_MODEL);
|
||||
console.log('🤖 Models available:', AVAILABLE_MODELS.length);
|
||||
|
||||
module.exports = {
|
||||
AVAILABLE_MODELS,
|
||||
DEFAULT_MODEL,
|
||||
FALLBACK_MODEL,
|
||||
OPENROUTER_MODELS,
|
||||
BEDROCK_MODELS,
|
||||
AZURE_MODELS,
|
||||
getAvailableModels,
|
||||
getDefaultModel,
|
||||
getFallbackModel,
|
||||
getBedrockModelId,
|
||||
activeProvider
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue