Initial scaffold: offertesysteem (clients, projects, quotes, quote_items)

- Next.js 15 + TypeScript + Tailwind, standalone output
- SQLite schema: users, clients, projects, quotes, quote_items
- HMAC-signed sessie-cookie auth
- Dockerfile (Node 22 Alpine, multi-stage)
- create-admin script
- Doel: portal.rikxplatform.nl

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maarten Elsenaar
2026-09-14 12:24:27 +02:00
commit af1a8e4bed
26 changed files with 2852 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
import { cookies } from "next/headers";
import { createHmac, timingSafeEqual, randomBytes, scryptSync } from "crypto";
import { getDb } from "./db";
const SESSION_SECRET = process.env.SESSION_SECRET!;
const COOKIE_NAME = "session";
const MAX_AGE = 60 * 60 * 24 * 7; // 7 dagen
type SessionPayload = { userId: number; email: string; name: string };
function sign(payload: SessionPayload): string {
const data = JSON.stringify(payload);
const encoded = Buffer.from(data).toString("base64url");
const sig = createHmac("sha256", SESSION_SECRET).update(encoded).digest("base64url");
return `${encoded}.${sig}`;
}
function verify(token: string): SessionPayload | null {
const [encoded, sig] = token.split(".");
if (!encoded || !sig) return null;
const expected = createHmac("sha256", SESSION_SECRET).update(encoded).digest("base64url");
try {
if (!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
} catch {
return null;
}
return JSON.parse(Buffer.from(encoded, "base64url").toString());
}
export async function getSession(): Promise<SessionPayload | null> {
const jar = await cookies();
const token = jar.get(COOKIE_NAME)?.value;
if (!token) return null;
return verify(token);
}
export async function createSession(payload: SessionPayload) {
const jar = await cookies();
jar.set(COOKIE_NAME, sign(payload), {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: MAX_AGE,
path: "/",
});
}
export async function destroySession() {
const jar = await cookies();
jar.delete(COOKIE_NAME);
}
export function hashPassword(password: string): string {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(password, salt, 64).toString("hex");
return `${salt}:${hash}`;
}
export function verifyPassword(password: string, stored: string): boolean {
const [salt, hash] = stored.split(":");
const attempt = scryptSync(password, salt, 64).toString("hex");
return timingSafeEqual(Buffer.from(hash, "hex"), Buffer.from(attempt, "hex"));
}
export function getUserByEmail(email: string) {
const db = getDb();
return db.prepare("SELECT * FROM users WHERE email = ?").get(email) as
| { id: number; email: string; name: string; password_hash: string; role: string }
| undefined;
}
+74
View File
@@ -0,0 +1,74 @@
import { DatabaseSync } from "node:sqlite";
import path from "path";
const DB_PATH =
process.env.DB_PATH || path.join(process.cwd(), "data", "app.db");
let _db: DatabaseSync | null = null;
export function getDb(): DatabaseSync {
if (!_db) {
_db = new DatabaseSync(DB_PATH);
_db.exec("PRAGMA journal_mode = WAL");
_db.exec("PRAGMA foreign_keys = ON");
migrate(_db);
}
return _db;
}
function migrate(db: DatabaseSync) {
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS clients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT,
phone TEXT,
address TEXT,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER NOT NULL REFERENCES clients(id),
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS quotes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id),
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
notes TEXT,
sent_at TEXT,
accepted_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS quote_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
quote_id INTEGER NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
description TEXT NOT NULL,
quantity REAL NOT NULL DEFAULT 1,
unit TEXT,
unit_price REAL NOT NULL DEFAULT 0,
total REAL GENERATED ALWAYS AS (quantity * unit_price) VIRTUAL,
sort_order INTEGER NOT NULL DEFAULT 0
);
`);
}