initial design fix
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data');
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
const UPLOAD_DIR = path.join(DATA_DIR, 'uploads');
|
||||
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
|
||||
export const UPLOAD_PATH = UPLOAD_DIR;
|
||||
|
||||
const db = new Database(path.join(DATA_DIR, 'dashboard.db'));
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
pin_hash TEXT UNIQUE,
|
||||
password_hash TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_pin_hash ON users (pin_hash)
|
||||
WHERE pin_hash IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
category TEXT NOT NULL DEFAULT 'General',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
completion INTEGER NOT NULL DEFAULT 0,
|
||||
external_url TEXT,
|
||||
drive_url TEXT,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
accent_color TEXT NOT NULL DEFAULT '#6366f1',
|
||||
is_new INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
mimetype TEXT NOT NULL DEFAULT 'text/markdown',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tools (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
category TEXT NOT NULL DEFAULT 'General',
|
||||
external_url TEXT,
|
||||
is_new INTEGER NOT NULL DEFAULT 1,
|
||||
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
notes TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES
|
||||
('app_title', '"CODEDUMP"'),
|
||||
('logo_url', 'null'),
|
||||
('accent_color', '"#6366f1"'),
|
||||
('company_name', '"Your Company"');
|
||||
`);
|
||||
|
||||
function bootstrapAdmin() {
|
||||
const existing = db.prepare("SELECT id FROM users WHERE role = 'admin' LIMIT 1").get();
|
||||
if (existing) return;
|
||||
|
||||
const adminUsername = process.env.ADMIN_USERNAME || 'admin';
|
||||
const adminPassword = process.env.ADMIN_PASSWORD || 'codedump2024';
|
||||
const passwordHash = bcrypt.hashSync(adminPassword, 10);
|
||||
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO users (id, username, role, password_hash)
|
||||
VALUES (?, ?, 'admin', ?)
|
||||
`).run(uuidv4(), adminUsername, passwordHash);
|
||||
|
||||
console.log(`[CODEDUMP] Admin account created → username: "${adminUsername}"`);
|
||||
console.log(`[CODEDUMP] Change the default password via Admin → Users.`);
|
||||
}
|
||||
|
||||
bootstrapAdmin();
|
||||
|
||||
export default db;
|
||||
@@ -0,0 +1,39 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import './db/schema'; // initialize DB + bootstrap admin
|
||||
import { requireAuth } from './middleware/auth';
|
||||
import authRouter from './routes/auth';
|
||||
import projectsRouter from './routes/projects';
|
||||
import toolsRouter from './routes/tools';
|
||||
import uploadsRouter from './routes/uploads';
|
||||
import settingsRouter from './routes/settings';
|
||||
import usersRouter from './routes/users';
|
||||
|
||||
const app = express();
|
||||
const PORT = Number(process.env.PORT || 3000);
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
|
||||
// Public — auth endpoints (login doesn't require token)
|
||||
app.use('/api/auth', authRouter);
|
||||
|
||||
// Protected — all other API routes require a valid JWT
|
||||
app.use('/api/projects', requireAuth, projectsRouter);
|
||||
app.use('/api/tools', requireAuth, toolsRouter);
|
||||
app.use('/api/uploads', requireAuth, uploadsRouter);
|
||||
app.use('/api/settings', requireAuth, settingsRouter);
|
||||
app.use('/api/users', usersRouter); // requireAdmin applied inside router
|
||||
|
||||
// Serve built React client in production
|
||||
const clientDist = path.join(__dirname, '../../client/dist');
|
||||
app.use(express.static(clientDist));
|
||||
app.get(/^(?!\/api).*/, (_req, res) => {
|
||||
res.sendFile(path.join(clientDist, 'index.html'));
|
||||
});
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`[CODEDUMP] Running on port ${PORT}`);
|
||||
console.log(`[CODEDUMP] Data directory: ${process.env.DATA_DIR || 'data/'}`);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
// PINs use HMAC-SHA256 keyed on JWT_SECRET so the hash is deterministic
|
||||
// (needed for direct DB lookup). bcrypt is non-deterministic and would
|
||||
// require iterating all users, which is too slow at any reasonable work factor.
|
||||
export function hashPin(pin: string): string {
|
||||
const secret = process.env.JWT_SECRET || 'codedump-secret-change-in-production';
|
||||
return crypto.createHmac('sha256', secret).update(String(pin)).digest('hex');
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
export const JWT_SECRET = process.env.JWT_SECRET || 'codedump-secret-change-in-production';
|
||||
export const JWT_EXPIRY = '12h';
|
||||
|
||||
export interface JwtPayload {
|
||||
id: string;
|
||||
username: string;
|
||||
role: 'admin' | 'user';
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: JwtPayload;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const token = extractToken(req);
|
||||
if (!token) return res.status(401).json({ error: 'Authentication required' });
|
||||
|
||||
try {
|
||||
req.user = jwt.verify(token, JWT_SECRET) as JwtPayload;
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAdmin(req: Request, res: Response, next: NextFunction) {
|
||||
requireAuth(req, res, () => {
|
||||
if (req.user?.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
function extractToken(req: Request): string | null {
|
||||
const auth = req.headers.authorization;
|
||||
if (auth?.startsWith('Bearer ')) return auth.slice(7);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import db from '../db/schema';
|
||||
import { requireAuth, JWT_SECRET, JWT_EXPIRY, JwtPayload } from '../middleware/auth';
|
||||
import { hashPin } from '../lib/pinHash';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// POST /api/auth/pin — identify user by unique PIN, no username needed
|
||||
router.post('/pin', (req: Request, res: Response) => {
|
||||
const { pin } = req.body;
|
||||
if (!pin || !/^\d{4}$/.test(String(pin))) {
|
||||
return res.status(400).json({ error: 'A 4-digit PIN is required' });
|
||||
}
|
||||
|
||||
const pinHash = hashPin(String(pin));
|
||||
const user = db.prepare(
|
||||
"SELECT * FROM users WHERE pin_hash = ? AND role = 'user'"
|
||||
).get(pinHash) as any;
|
||||
|
||||
if (!user) return res.status(401).json({ error: 'Invalid PIN' });
|
||||
|
||||
const payload: JwtPayload = { id: user.id, username: user.username, role: 'user' };
|
||||
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRY });
|
||||
res.json({ token, user: payload });
|
||||
});
|
||||
|
||||
// POST /api/auth/login — admin username + password login
|
||||
router.post('/login', (req: Request, res: Response) => {
|
||||
const { username, password } = req.body;
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'username and password required' });
|
||||
}
|
||||
|
||||
const user = db.prepare(
|
||||
"SELECT * FROM users WHERE username = ? AND role = 'admin'"
|
||||
).get(username) as any;
|
||||
|
||||
if (!user || !bcrypt.compareSync(password, user.password_hash || '')) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const payload: JwtPayload = { id: user.id, username: user.username, role: 'admin' };
|
||||
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRY });
|
||||
res.json({ token, user: payload });
|
||||
});
|
||||
|
||||
// GET /api/auth/me — validate token
|
||||
router.get('/me', requireAuth, (req: Request, res: Response) => {
|
||||
res.json(req.user);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import db from '../db/schema';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', (_req: Request, res: Response) => {
|
||||
const projects = db.prepare(`
|
||||
SELECT p.*,
|
||||
(SELECT COUNT(*) FROM documents d WHERE d.project_id = p.id) as doc_count
|
||||
FROM projects p ORDER BY p.updated_at DESC
|
||||
`).all();
|
||||
res.json(projects.map(normalizeProject));
|
||||
});
|
||||
|
||||
router.get('/:id', (req: Request, res: Response) => {
|
||||
const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(req.params.id) as any;
|
||||
if (!project) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
const docs = db.prepare('SELECT * FROM documents WHERE project_id = ? ORDER BY created_at DESC').all(req.params.id);
|
||||
res.json({ ...normalizeProject(project), documents: docs });
|
||||
});
|
||||
|
||||
router.post('/', (req: Request, res: Response) => {
|
||||
const { name, description, category, status, completion, external_url, drive_url, tags, accent_color, is_new } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'name is required' });
|
||||
|
||||
const id = uuidv4();
|
||||
db.prepare(`
|
||||
INSERT INTO projects (id, name, description, category, status, completion, external_url, drive_url, tags, accent_color, is_new)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
name.trim(),
|
||||
description || '',
|
||||
category || 'General',
|
||||
status || 'active',
|
||||
completion ?? 0,
|
||||
external_url || null,
|
||||
drive_url || null,
|
||||
JSON.stringify(tags || []),
|
||||
accent_color || '#6366f1',
|
||||
is_new ? 1 : 1
|
||||
);
|
||||
|
||||
const created = db.prepare('SELECT * FROM projects WHERE id = ?').get(id) as any;
|
||||
res.status(201).json(normalizeProject(created));
|
||||
});
|
||||
|
||||
router.put('/:id', (req: Request, res: Response) => {
|
||||
const existing = db.prepare('SELECT id FROM projects WHERE id = ?').get(req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
const { name, description, category, status, completion, external_url, drive_url, tags, accent_color, is_new } = req.body;
|
||||
db.prepare(`
|
||||
UPDATE projects SET
|
||||
name = COALESCE(?, name),
|
||||
description = COALESCE(?, description),
|
||||
category = COALESCE(?, category),
|
||||
status = COALESCE(?, status),
|
||||
completion = COALESCE(?, completion),
|
||||
external_url = ?,
|
||||
drive_url = ?,
|
||||
tags = COALESCE(?, tags),
|
||||
accent_color = COALESCE(?, accent_color),
|
||||
is_new = COALESCE(?, is_new),
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
name ?? null,
|
||||
description ?? null,
|
||||
category ?? null,
|
||||
status ?? null,
|
||||
completion ?? null,
|
||||
external_url !== undefined ? (external_url || null) : undefined,
|
||||
drive_url !== undefined ? (drive_url || null) : undefined,
|
||||
tags !== undefined ? JSON.stringify(tags) : null,
|
||||
accent_color ?? null,
|
||||
is_new !== undefined ? (is_new ? 1 : 0) : null,
|
||||
req.params.id
|
||||
);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM projects WHERE id = ?').get(req.params.id) as any;
|
||||
res.json(normalizeProject(updated));
|
||||
});
|
||||
|
||||
router.delete('/:id', (req: Request, res: Response) => {
|
||||
const existing = db.prepare('SELECT id FROM projects WHERE id = ?').get(req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'Not found' });
|
||||
db.prepare('DELETE FROM projects WHERE id = ?').run(req.params.id);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
function normalizeProject(p: any) {
|
||||
return {
|
||||
...p,
|
||||
tags: typeof p.tags === 'string' ? JSON.parse(p.tags) : p.tags,
|
||||
is_new: Boolean(p.is_new),
|
||||
completion: Number(p.completion),
|
||||
};
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import db, { UPLOAD_PATH } from '../db/schema';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const logoUpload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, UPLOAD_PATH),
|
||||
filename: (_req, file, cb) => cb(null, `logo-${uuidv4()}${path.extname(file.originalname)}`),
|
||||
}),
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowed = ['.png', '.jpg', '.jpeg', '.svg', '.gif', '.webp'];
|
||||
cb(null, allowed.includes(path.extname(file.originalname).toLowerCase()));
|
||||
},
|
||||
});
|
||||
|
||||
router.get('/', (_req: Request, res: Response) => {
|
||||
const rows = db.prepare('SELECT key, value FROM settings').all() as { key: string; value: string }[];
|
||||
const settings: Record<string, any> = {};
|
||||
for (const row of rows) {
|
||||
try { settings[row.key] = JSON.parse(row.value); } catch { settings[row.key] = row.value; }
|
||||
}
|
||||
res.json(settings);
|
||||
});
|
||||
|
||||
router.put('/', (req: Request, res: Response) => {
|
||||
const allowed = ['app_title', 'logo_url', 'accent_color', 'company_name'];
|
||||
const upsert = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)');
|
||||
const update = db.transaction((body: Record<string, any>) => {
|
||||
for (const key of allowed) {
|
||||
if (key in body) upsert.run(key, JSON.stringify(body[key]));
|
||||
}
|
||||
});
|
||||
update(req.body);
|
||||
|
||||
const rows = db.prepare('SELECT key, value FROM settings').all() as { key: string; value: string }[];
|
||||
const settings: Record<string, any> = {};
|
||||
for (const row of rows) {
|
||||
try { settings[row.key] = JSON.parse(row.value); } catch { settings[row.key] = row.value; }
|
||||
}
|
||||
res.json(settings);
|
||||
});
|
||||
|
||||
router.post('/logo', logoUpload.single('logo'), (req: Request, res: Response) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
const url = `/api/uploads/${req.file.filename}`;
|
||||
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run('logo_url', JSON.stringify(url));
|
||||
res.json({ logo_url: url });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import db from '../db/schema';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', (_req: Request, res: Response) => {
|
||||
const tools = db.prepare('SELECT * FROM tools ORDER BY added_at DESC').all();
|
||||
res.json(tools.map(normalizeTool));
|
||||
});
|
||||
|
||||
router.get('/:id', (req: Request, res: Response) => {
|
||||
const tool = db.prepare('SELECT * FROM tools WHERE id = ?').get(req.params.id) as any;
|
||||
if (!tool) return res.status(404).json({ error: 'Not found' });
|
||||
res.json(normalizeTool(tool));
|
||||
});
|
||||
|
||||
router.post('/', (req: Request, res: Response) => {
|
||||
const { name, description, category, external_url, is_new, notes } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'name is required' });
|
||||
|
||||
const id = uuidv4();
|
||||
db.prepare(`
|
||||
INSERT INTO tools (id, name, description, category, external_url, is_new, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
name.trim(),
|
||||
description || '',
|
||||
category || 'General',
|
||||
external_url || null,
|
||||
is_new !== false ? 1 : 0,
|
||||
notes || ''
|
||||
);
|
||||
|
||||
const created = db.prepare('SELECT * FROM tools WHERE id = ?').get(id) as any;
|
||||
res.status(201).json(normalizeTool(created));
|
||||
});
|
||||
|
||||
router.put('/:id', (req: Request, res: Response) => {
|
||||
const existing = db.prepare('SELECT id FROM tools WHERE id = ?').get(req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
const { name, description, category, external_url, is_new, notes } = req.body;
|
||||
db.prepare(`
|
||||
UPDATE tools SET
|
||||
name = COALESCE(?, name),
|
||||
description = COALESCE(?, description),
|
||||
category = COALESCE(?, category),
|
||||
external_url = COALESCE(?, external_url),
|
||||
is_new = COALESCE(?, is_new),
|
||||
notes = COALESCE(?, notes)
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
name ?? null,
|
||||
description ?? null,
|
||||
category ?? null,
|
||||
external_url !== undefined ? (external_url || null) : null,
|
||||
is_new !== undefined ? (is_new ? 1 : 0) : null,
|
||||
notes ?? null,
|
||||
req.params.id
|
||||
);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM tools WHERE id = ?').get(req.params.id) as any;
|
||||
res.json(normalizeTool(updated));
|
||||
});
|
||||
|
||||
router.delete('/:id', (req: Request, res: Response) => {
|
||||
const existing = db.prepare('SELECT id FROM tools WHERE id = ?').get(req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'Not found' });
|
||||
db.prepare('DELETE FROM tools WHERE id = ?').run(req.params.id);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
function normalizeTool(t: any) {
|
||||
return { ...t, is_new: Boolean(t.is_new) };
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import db, { UPLOAD_PATH } from '../db/schema';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, UPLOAD_PATH),
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `${uuidv4()}${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: Number(process.env.MAX_UPLOAD_MB || 50) * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowed = ['.md', '.txt', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.svg'];
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
cb(null, allowed.includes(ext));
|
||||
},
|
||||
});
|
||||
|
||||
// Upload doc to a project
|
||||
router.post('/projects/:projectId', upload.single('file'), (req: Request, res: Response) => {
|
||||
const project = db.prepare('SELECT id FROM projects WHERE id = ?').get(req.params.projectId);
|
||||
if (!project) return res.status(404).json({ error: 'Project not found' });
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
|
||||
const id = uuidv4();
|
||||
db.prepare(`
|
||||
INSERT INTO documents (id, project_id, filename, original_name, mimetype)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(id, req.params.projectId, req.file.filename, req.file.originalname, req.file.mimetype);
|
||||
|
||||
res.status(201).json({
|
||||
id,
|
||||
project_id: req.params.projectId,
|
||||
filename: req.file.filename,
|
||||
original_name: req.file.originalname,
|
||||
mimetype: req.file.mimetype,
|
||||
});
|
||||
});
|
||||
|
||||
// Get raw file
|
||||
router.get('/:filename', (req: Request, res: Response) => {
|
||||
const filePath = path.join(UPLOAD_PATH, path.basename(req.params.filename));
|
||||
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File not found' });
|
||||
res.sendFile(filePath);
|
||||
});
|
||||
|
||||
// Delete a document
|
||||
router.delete('/documents/:id', (req: Request, res: Response) => {
|
||||
const doc = db.prepare('SELECT * FROM documents WHERE id = ?').get(req.params.id) as any;
|
||||
if (!doc) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
const filePath = path.join(UPLOAD_PATH, doc.filename);
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
|
||||
db.prepare('DELETE FROM documents WHERE id = ?').run(req.params.id);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import db from '../db/schema';
|
||||
import { requireAdmin } from '../middleware/auth';
|
||||
import { hashPin } from '../lib/pinHash';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAdmin);
|
||||
|
||||
router.get('/', (_req: Request, res: Response) => {
|
||||
const users = db.prepare(
|
||||
'SELECT id, username, role, created_at FROM users ORDER BY role DESC, username ASC'
|
||||
).all();
|
||||
res.json(users);
|
||||
});
|
||||
|
||||
router.post('/', (req: Request, res: Response) => {
|
||||
const { username, pin, password, role } = req.body;
|
||||
if (!username?.trim()) return res.status(400).json({ error: 'username is required' });
|
||||
|
||||
const userRole = role === 'admin' ? 'admin' : 'user';
|
||||
|
||||
if (userRole === 'user') {
|
||||
if (!pin || !/^\d{4}$/.test(String(pin))) {
|
||||
return res.status(400).json({ error: 'A 4-digit PIN is required for user accounts' });
|
||||
}
|
||||
} else {
|
||||
if (!password || password.length < 6) {
|
||||
return res.status(400).json({ error: 'Password must be at least 6 characters for admin accounts' });
|
||||
}
|
||||
}
|
||||
|
||||
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username.trim());
|
||||
if (existing) return res.status(409).json({ error: 'Username already exists' });
|
||||
|
||||
let pinHash: string | null = null;
|
||||
if (userRole === 'user') {
|
||||
pinHash = hashPin(String(pin));
|
||||
const pinTaken = db.prepare('SELECT id FROM users WHERE pin_hash = ?').get(pinHash);
|
||||
if (pinTaken) return res.status(409).json({ error: 'That PIN is already in use by another user. Choose a different PIN.' });
|
||||
}
|
||||
|
||||
const id = uuidv4();
|
||||
const passwordHash = userRole === 'admin' ? bcrypt.hashSync(password, 10) : null;
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO users (id, username, role, pin_hash, password_hash)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(id, username.trim(), userRole, pinHash, passwordHash);
|
||||
|
||||
res.status(201).json(
|
||||
db.prepare('SELECT id, username, role, created_at FROM users WHERE id = ?').get(id)
|
||||
);
|
||||
});
|
||||
|
||||
router.put('/:id', (req: Request, res: Response) => {
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id) as any;
|
||||
if (!user) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
const { pin, password, username } = req.body;
|
||||
|
||||
if (username?.trim() && username.trim() !== user.username) {
|
||||
const clash = db.prepare('SELECT id FROM users WHERE username = ? AND id != ?').get(username.trim(), req.params.id);
|
||||
if (clash) return res.status(409).json({ error: 'Username already taken' });
|
||||
db.prepare('UPDATE users SET username = ? WHERE id = ?').run(username.trim(), req.params.id);
|
||||
}
|
||||
|
||||
if (user.role === 'user' && pin !== undefined && pin !== '') {
|
||||
if (!/^\d{4}$/.test(String(pin))) {
|
||||
return res.status(400).json({ error: 'PIN must be exactly 4 digits' });
|
||||
}
|
||||
const pinHash = hashPin(String(pin));
|
||||
const pinTaken = db.prepare('SELECT id FROM users WHERE pin_hash = ? AND id != ?').get(pinHash, req.params.id);
|
||||
if (pinTaken) return res.status(409).json({ error: 'That PIN is already in use by another user.' });
|
||||
db.prepare('UPDATE users SET pin_hash = ? WHERE id = ?').run(pinHash, req.params.id);
|
||||
}
|
||||
|
||||
if (user.role === 'admin' && password) {
|
||||
if (password.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(bcrypt.hashSync(password, 10), req.params.id);
|
||||
}
|
||||
|
||||
res.json(
|
||||
db.prepare('SELECT id, username, role, created_at FROM users WHERE id = ?').get(req.params.id)
|
||||
);
|
||||
});
|
||||
|
||||
router.delete('/:id', (req: Request, res: Response) => {
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id) as any;
|
||||
if (!user) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
if (user.role === 'admin') {
|
||||
const adminCount = (db.prepare("SELECT COUNT(*) as c FROM users WHERE role = 'admin'").get() as any).c;
|
||||
if (adminCount <= 1) return res.status(400).json({ error: 'Cannot delete the last admin account' });
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user